Repository navigation
Echo Consent CI
Run the consent check on every pull request, deploy or night and fail the pipeline when a tracker starts firing before the choice or after Reject. This page covers the GitHub Action's mode: consent, the masked job summary, checking many sites from a sites file, baselines, expiring ignores, signing and other CI systems.
# .github/workflows/consent.yml
name: consent
on:
pull_request:
schedule:
- cron: "17 5 * * 1" # every Monday, for changes made outside the repository
jobs:
consent:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # for pr-comment
steps:
- uses: actions/checkout@v4
- uses: RhythmShahriar/echo@v1.2.0
with:
mode: consent
site: https://staging.shop.test/ https://staging.shop.test/shop/
fail-on: error
pr-comment: truemode: consent installs and verifies Echo like the other modes (the release's cosign signature, then the archive's checksum) but doesn't start the catcher. It runs rhythm-echo consent on the pages in site in the runner's Chrome (the GitHub ubuntu and macOS images ship one, so nothing is downloaded), then writes the job summary and fails the job when the check found issues at or above fail-on or couldn't check. It runs on Linux and macOS runners.
Check only sites you own or are authorised to test. The runner loads them like one visitor; tracker hits are held as on your machine.
| Input | Default | Description |
|---|---|---|
mode |
run |
consent. |
site |
"" |
The page or pages to check, separated by spaces or new lines. Empty uses consent: urls from the config, or --sites in args. |
fail-on |
error |
error, warning, notice or none; with --against in args, broke (the default), new, changed or none. |
args |
"" |
Extra flags, split like a shell would but never run through one, e.g. --runs +gpc --geo de --against baseline. --show-ids is refused. |
working-directory |
. |
The folder with .echo/ (config and results). |
config |
"" |
A config file relative to working-directory. |
upload |
"false" |
Upload the evidence folder (screenshots of the site, consent.json, the reports) as the echo-consent artifact. The Action warns when it's on in a public repository: anyone can download a public repository's artifacts. |
retention-days |
"14" |
How many days GitHub keeps echo-consent (1 to 90). |
sign |
"false" |
Sign consent.json with keyless cosign (consent.json.sigstore.json in the evidence folder). Needs permissions: id-token: write. The repository and workflow name go to the public Rekor log. |
summary-details |
"false" |
Put Echo's own Markdown report in the summary and the comment: query strings, hosts, ID tails and every event. Off by default. |
pr-comment |
"false" |
On a pull request, post one comment with the summary, updated on every later run. Needs pull-requests: write. |
log |
"true" |
Upload Echo's log as echo-log when the step fails or finds something, masked like the summary. |
version |
the Action's own release | Echo version to install. Consent checks need 1.2.0 or later. |
Other inputs (seal, listen, smtp, name) are for the other modes and are ignored in mode consent. url, token and the smtp output belong to mode up; action.yml has an input and an output both named smtp; neither is used here.
| Output | Description |
|---|---|
exit-code |
0 ok, 1 fail-on hit, 2 usage error, 3 couldn't check, 130 interrupted. |
json |
Path of consent.json in the evidence folder. |
before-choice |
How many different trackers fired before the choice (trackers, not hits). |
after-reject |
How many different trackers fired after Reject (trackers, not hits). |
inconclusive |
How many runs couldn't make or confirm their choice. |
report |
Path of report.html in the evidence folder. |
summary |
Path of a copy of the job summary (Markdown). |
comment-id |
ID of the pull request comment, with pr-comment. |
On exit 1 the step ends with Echo found consent issues at or above --fail-on error; on exit 3 with Echo couldn't check consent on N runs (see the summary).
Anyone who can read the repository can read a job summary, so it shows tracker names, counts, check titles and page paths, never query strings, cookie values, IDs or screenshots. The summary for the two-site check below, as the Action writes it:
## ♪ Echo consent
**Findings.** 3 pages, 9 runs, 3 trackers, policy advanced. Trackers before the choice: 1. Trackers after Reject: 1.
| Page | Banner | No choice | Accept | Reject |
|---|---|---|---|---|
| brew / | Complianz | nothing fired | Meta Pixel, Google Ads, Google Analytics 4 | nothing fired |
| shop / | Complianz | ✗ Meta Pixel | ✗ Meta Pixel, Google Ads, Google Analytics 4 | ✗ Meta Pixel |
| shop /page-2/ | Complianz | ✗ Meta Pixel | ✗ Meta Pixel, Google Ads, Google Analytics 4 | ✗ Meta Pixel |
### Errors (8)
| Check | Page | Run | What |
|---|---|---|---|
| consent/before-choice | shop / | No choice | Meta Pixel fired before the visitor chose |
| consent/before-choice | shop / | Accept | Meta Pixel fired before the visitor chose |
| consent/before-choice | shop / | Reject | Meta Pixel fired before the visitor chose |
| consent/after-reject | shop / | Reject | Meta Pixel fired after Reject |
| consent/before-choice | shop /page-2/ | No choice | Meta Pixel fired before the visitor chose |
| consent/before-choice | shop /page-2/ | Accept | Meta Pixel fired before the visitor chose |
| consent/before-choice | shop /page-2/ | Reject | Meta Pixel fired before the visitor chose |
| consent/after-reject | shop /page-2/ | Reject | Meta Pixel fired after Reject |
### Notices (2)
| Check | Page | Run | What |
|---|---|---|---|
| consent/meta-no-consent-api | shop / | No choice | Meta Pixel never received fbq('consent', ...) |
| consent/meta-no-consent-api | shop /page-2/ | No choice | Meta Pixel never received fbq('consent', ...) |
_Evidence, not legal advice. Hits held in the browser. Hosts mapped with --map-host; https pages were answered over plain http, so TLS wasn't checked._
<sub>Query strings, cookie values, IDs and screenshots are hidden here. The evidence folder (upload: true) has report.html and consent.json.</sub>
Preview the summary locally. The summary is written by integrations/action/summary.sh (needs bash and jq) from the check's envelope:
rhythm echo consent https://staging.shop.test/ -o consent.json
bash integrations/action/summary.sh consent consent.jsonWith pr-comment: true the comment is the same text. With upload: true, open report.html from the echo-consent artifact for the timelines and screenshots.
- uses: RhythmShahriar/echo@v1.2.0
id: consent
with:
mode: consent
site: https://staging.shop.test/
fail-on: none # decide below
- if: steps.consent.outputs.after-reject != '0'
run: echo "::warning::${{ steps.consent.outputs.after-reject }} trackers fire after Reject"--sites reads the same sites.yaml Soundcheck fleet reads. Echo uses each entry's name and url, plus an optional consent: map with the pages to check and extra flags; Soundcheck's own keys are ignored.
# sites.yaml
sites:
- name: brew
url: https://g01.shop.test/
- name: shop
url: https://c02.shop.test/
consent:
urls: [/, /page-2/]
args: ["--cmp", "complianz"]$ rhythm echo consent --sites sites.yaml $map
rhythm echo 1.2.0 - consent check of 2 sites (3 pages)
...
shop
https://c02.shop.test/
Banner: Complianz, found after 0.1s, Reject on the banner
Consent Mode: v2 default, all four signals denied at 0.02s
[1/3] No choice FAIL Meta Pixel fired before the choice
[2/3] Accept FAIL Meta Pixel fired before the choice; Google Ads, Google Analytics 4 fired; update granted at 3.2s; kept on 1 more page
[3/3] Reject FAIL Meta Pixel fired before the choice; Meta Pixel fired after Reject on 2 of 3 pages; update denied at 3.2s; kept on 1 more page
https://c02.shop.test/page-2/
...
brew
https://g01.shop.test/
Banner: Complianz, found after 0.1s, Reject on the banner
Consent Mode: v2 default, all four signals denied at 0.03s
[1/3] No choice ok nothing fired; Meta Pixel held back
[2/3] Accept ok Meta Pixel, Google Ads, Google Analytics 4 fired; update granted at 3.2s; kept on 1 more page
[3/3] Reject ok nothing fired; Meta Pixel held back; update denied at 3.2s; kept on 1 more page
Findings - 8 errors, 0 warnings, 2 notices - 9 runs, 3 trackers, policy advanced
error consent/before-choice Meta Pixel fired before the visitor chose (No choice, shop /)
...
Sites are listed worst first and each issue names its site and page. In the Action, pass the file in args and leave site empty:
with:
mode: consent
args: --sites sites.yaml --parallel 6On the benchmark, 50 sites with --parallel 6 took 5.6 minutes. A command checks at most 50 pages without --sites.
A baseline turns "is anything wrong" into "did anything get worse", so a known issue doesn't fail every build while a new one does.
rhythm echo consent https://staging.shop.test --save baseline # once, on a state you accept
rhythm echo consent https://staging.shop.test --against baseline # every build--save <name> keeps the result in .echo/consent/baselines/<name>/; saving again replaces it and says (replaced the one from <date> UTC). --against takes a baseline name, a consent.json, a result folder, or last (the newest result before this one). --fail-on then reads broke (the default), new, changed or none; the classes are in Consent checks.
$ rhythm echo consent https://c08.shop.test/ --against baseline $map
rhythm echo 1.2.0 - consent check of https://c08.shop.test/ against baseline from 2026-10-28 20:10 UTC
...
Broke - 4 broke, 0 new, 0 changed, 0 fixed - 3 runs, 4 trackers
BROKE TikTok Pixel now fires before the choice / (No choice, Accept, Reject)
BROKE TikTok Pixel now fires after Reject / (Reject)
The verdict counts each page, run and tracker (one per change key) while the lines group them, so 4 broke above is two lines; -v prints each line's keys, as below.
Use one baseline name per site (--save shop, --against shop). Echo compares by page path, run and tracker and doesn't refuse a baseline of another site; --against last likewise compares with whatever was checked last in that store.
-v prints each change's key; --accept <key> takes one as intended for this comparison, as compare --accept does:
$ rhythm echo consent report latest --against baseline -v
...
BROKE TikTok Pixel now fires before the choice / (No choice, Accept, Reject)
(key: consent/before-choice|/|none|tiktok)
(key: consent/before-choice|/|accept|tiktok)
(key: consent/before-choice|/|reject|tiktok)
BROKE TikTok Pixel now fires after Reject / (Reject)
(key: consent/after-reject|/|reject|tiktok)
$ rhythm echo consent report latest --against baseline --accept 'consent/after-reject|/|reject|tiktok' -q
Broke - 3 broke, 0 new, 0 changed, 0 fixed - 3 runs, 4 trackers
BROKE TikTok Pixel now fires before the choice / (No choice, Accept, Reject)
BROKE TikTok Pixel now fires after Reject (accepted) / (Reject)
consent report reruns nothing: it reads a saved result and exits as the check would, so one job can check and a later step can compare again with other flags.
The baseline is a consent.json. Keep it in the repository (.echo/consent/baselines/ is a plain folder) or pass it between jobs as an artifact, then point --against at the file:
with:
mode: consent
site: https://staging.shop.test/
args: --against baselines/consent.jsonA baseline from any 1.2.x Echo loads.
consent: ignore: in the config hides one issue until a date at most a year ahead, with a reason that shows in every report. When it expires, the issue counts again. See Ignoring a known issue.
Locally, with your own ed25519 key:
openssl genpkey -algorithm ed25519 -out team.key && chmod 600 team.key
openssl pkey -in team.key -pubout -out team.pub
rhythm echo consent https://staging.shop.test --sign-key team.key
rhythm echo consent verify .echo/consent/<id> --key team.pubIn the Action, sign: true signs with keyless cosign and the job's GitHub identity. Check it with cosign:
cosign verify-blob --bundle consent.json.sigstore.json \
--certificate-identity-regexp '^https://github.com/<owner>/<repo>/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com consent.jsonconsent verify checks the hashes and an ed25519 signature; it doesn't verify keyless bundles. See Evidence.
The command works anywhere Chrome runs. The exit code is the gate; JUnit feeds a test report:
# .gitlab-ci.yml
consent:
image: debian:bookworm
script:
- apt-get update && apt-get install -y chromium curl
- curl -fsSL https://rythm.sh/install | RHYTHM_TOOLS=echo sh # as root: into /usr/local/bin
- rhythm echo consent https://staging.shop.test/ --browser /usr/bin/chromium --no-sandbox -o consent.xml
artifacts:
when: always
reports:
junit: consent.xml--no-sandbox is needed when Chrome runs as root in a container (also RHYTHM_ECHO_NO_SANDBOX=1); Echo then warns that you should check only sites you trust. JUnit has one test suite per page and one test case per run (/ · Reject), with one <failure> per error. In CI (CI set), Echo shows no once-only notice, no tip and no color.
← Banners (CMPs) · Home · Evidence →
rhythm · Useful ideas. Working systems. · Check only sites you own or have permission to test. Keep staging sealed (responsible use) · This wiki is published from wiki/ in the rhythm repository; suggest changes there with a pull request.
Get started
rhythm in depth
Tuner
- Overview
- Your first scan
- Reading results
- Choosing checks
- Requests and politeness
- Output and reports
- Batch scans
- Local scans
- Integrity and malware
- Changes and notifications
Tuner in depth
- Known vulnerabilities
- PHP advisories
- CI and code scanning
- GitHub Action
- Config file
- Watch and serve
- Writing checks
- Check packs
- Finding reference
- CLI reference
- Coming from Wordfence CLI or WPScan
- FAQ and troubleshooting
Soundcheck
- Overview
- Your first check
- Reading results
- Review and accept
- Discovery
- Page health
- Visual changes
- Interactions
- Forms and flows
- Cache
- Speed
- Accessibility and SEO
- EAA mode
- Keyboard walkthrough
- Accessibility statement
Soundcheck in depth
- CI
- CI baselines and PR comments
- Configuration
- Error rules and exec
- WordPress updates
- Fleet and client reports
- History
- Import from BackstopJS and Lost Pixel
- Firefox and WebKit
- CLI reference
- FAQ and troubleshooting
- Benchmark
Echo
- Overview
- Your first run
- Connectors
- Sealing staging
- Echo Inbox
- Messages and checks
- Checks and rules
- Attachments
- Actions and compare
- With Soundcheck
- Replay
- Deliverability
Echo cookie consent
Echo in depth
- Docker and DDEV
- Agency hub and reports
- Schedule
- MCP server
- Conformance kit
- CI
- Configuration
- API
- CLI reference
- FAQ and troubleshooting
- Benchmark
Roadie
- Overview
- Getting started
- Copy and prepare
- Sources and targets
- Profiles
- Pseudonymisation
- Live keys
- Kill switch
- Verify and reports
Roadie in depth
- Laravel
- Generic SQL and Postgres
- Multisite
- CI and the GitHub Action
- Configuration
- Security and limits
- CLI reference
- Troubleshooting
- FAQ and honest limits
- Benchmark
Sightread
- Overview
- Your first review
- Signal reference
- Allow and baseline
- Sources and integrity
- With Soundcheck and Roadie
- Watch
Sightread in depth
- CI and the GitHub Action
- Configuration
- Output and reports
- Security model
- CLI reference
- FAQ and honest limits
- Benchmark
Project