Baseline gating
How findings are fingerprinted and matched across environments, what a baseline does and does not promise, and how to keep one from fossilising.
A run that fails on any finding is unusable on a site that is not clean yet. So it gets switched off, or quietly ignored, and the pipeline keeps a red badge nobody reads. A baseline replaces the question.
The four flags
| Flag | What it does |
|---|---|
--baseline <file> | The stored report to compare against. On its own it is an error: it weakens the gate, so it has to be asked for by name. |
--regressions-only | Fail only on findings that are new relative to the baseline. |
--update-baseline | Write this run to the baseline and exit 0 instead of judging against it. |
--max-debt <n> | Fail when the site carries more than n findings in total, new or known. |
Capturing one
mkdir -p .goflag
goflag https://example.com \
--baseline .goflag/baseline.json --update-baselinegoflag: baseline captured — 13 findings grandfathered in .goflag/baseline.json
goflag: set --max-debt 13 to stop that number growing, and lower it as you fix.Commit the file. It is a reviewable record of what the team decided to live with, and a diff on it in a merge request is a decision worth seeing.
Re-running --update-baseline later prints what changed rather than silently
replacing the file:
goflag: baseline updated — 2 newly accepted, 5 resolved, 10 findings now grandfatheredWhat a finding is matched by
Findings are matched by fingerprint, not by list position. A fingerprint is
built from the stable parts of a finding: the rule (or finding class) that
produced it, the route it was found on, and one discriminator — the absolute link
target for a broken link, the peer route and locale for an hreflang reciprocity
issue, an occurrence counter for a metadata rule that fires more than once on a
page. A translation hole is fingerprinted by its route alone, so a hole that
widens from one missing locale to three is the same finding:
--regressions-only gates on a hole existing, not on its size.
title.missing on /about
fingerprint(seo, title.missing, routeKey(page), occurrence)
- https://example.com
- seo-42d6ff5567
- http://localhost:3000
- seo-42d6ff5567
same id — routeKey drops the origin, so the same defect on the same route is one finding wherever it is audited.
a dead internal link on /about
fingerprint(link, routeKey(page), targetKey(target))
- https://example.com
- link-4bb9a3831f
- http://localhost:3000
- link-49fe870d09
two ids — targetKey keeps the origin, because two hosts are two different targets — so a baseline taken on production re-proposes every internal link when it is compared against a localhost run.
A fingerprint deliberately excludes anything that moves without the defect moving:
- The origin of the page. Page URLs are normalised to origin-independent
routes, so an SEO, site, translation or unreachable-page finding captured
against
https://example.commatches the same finding onhttp://localhost:3000. This is what makes the merge-request job possible at all. Broken links are the exception: a link finding is identified by (page route, absolute link target), and the target keeps its origin, because two hosts are two different targets. A baseline captured on production therefore reports every pre-existing broken internal link as new when it is compared against a--startrun on localhost. Capture the baseline in the environment the merge-request job audits — run--update-baselineonce behind--start— or accept that internal link debt is re-proposed the first time. - Counts and ordering. Adding a page does not renumber existing findings.
- Message text. Rewording a rule's message in a new version of goflag does not invalidate your baseline.
What it does include is the route. Move a page, and its findings are new findings, which is correct: nobody reviewed the metadata at the new URL.
Reading the gate
$ npx @goflag/cli https://example.com --baseline .goflag/baseline.json --regressions-only --max-debt 14
goflag --regressions-only
REGRESSION 1 new · 13 known findings NOT gating this build · 1 resolved
baseline https://example.com/ — taken 2026-07-21T09:14:02.881Z (14 days ago)
New findings
+ error seo canonical.absolute on https://example.com/pricing
Resolved
- warn seo og.image.missing on https://example.com/about
The diff lists rule ids and absolute page URLs, not messages: it is a list of
what moved, not a report. Run without --regressions-only, or read the
--report JSON, for the message and the fix.
The headline changes with the state of the run: REGRESSION when new findings
exist, REGRESSION GATE when only known debt is passing through, CLEAN when
nothing is outstanding. The two gate labels are not a typo for each other.
Three deliberate choices in that output:
- It is never green and never says "clean" while findings are outstanding. This mode passes builds on sites with known defects. A green flag would say the opposite of what happened.
- The debt is the headline, not a footnote.
13 known findings NOT gating this buildis printed in bold on every run, because a reader who stops seeing the number stops knowing it exists. - Resolved findings are listed. A gate that only ever shows problems gives no signal that the backlog is moving, which is how a gate stops being read.
The baseline's age is printed for the same reason. A baseline taken 400 days ago is not a decision any more; it is a fossil.
Accepting a finding on purpose
Sometimes the finding is real and the answer is still "not this quarter". Accept it explicitly:
goflag … --baseline .goflag/baseline.json --update-baselineThen review the diff on the baseline file. The line you added is the sentence "we know, and we chose this", written down, in the repository, next to the code it concerns.
What you should not do is raise --max-debt to make a red pipeline go away. That
is the same decision with the evidence deleted.
Lowering the ratchet
goflag … --baseline .goflag/baseline.json --regressions-only --max-debt 12Fix a finding, drop the number by one, commit both in the same change. Without this step a baseline is a permission slip; with it, it is a plan.
A staging host is not a debt
Disallow: / and noindex on every page are what a staging environment is for,
and goflag cannot know that from the outside, so it reports both. Written into a
baseline, that pair becomes a --max-debt 2 for as long as the job exists, and a
budget with room in it for two real regressions.
Tell it instead. The answer is an argument and never read from the target, because a production site shipped with its staging settings must not be able to excuse itself:
goflag https://staging.example.com --expect-indexing no # the deployed develop
goflag https://example.com --expect-indexing yes # production$ npx @goflag/cli https://staging.example.com --expect-indexing no
goflag https://staging.example.com/
YELLOW FLAG 2 pages crawled, 2 scanned, expecting indexing: no
0 broken links 0 missing translations 0 SEO issues 1 site issue 0 unreachable pages
Site-wide issues
locales: en, fr (via sitemap)
https://staging.example.com/robots.txt
warn indexing.blocked-url-indexable robots.txt keeps Googlebot out of 2 pages on a target declared out of the index: https://staging.example.com/en, https://staging.example.com/fr. All of them say noindex, which Googlebot never reads because it never fetches them. A blocked URL can still appear in Google's results, from links alone.
The declaration moves the question rather than dropping it.
| Declared | No longer reported | Now reported |
|---|---|---|
no | robots.blocks-site, sitemap.entry.noindex | a page Google may index, a blocked page |
yes | robots.blocks-site | a whole-site block, as an error, a noindex on the home page or on every page |
The finding left under no is real. Google documents that robots.txt does not
keep a URL out of its results, and that a blocked page never has its noindex
read. Exclusion takes a noindex the crawler can reach, so the fix is opening the
host, which also lets in every crawler that honours the file. Whether that beats a
URL surfacing with no description is your decision, and an accepted finding is what
a baseline is for: --max-debt 1, with this as the commit message.
A page declaring index on a blocked host still fails under either. No
declaration explains that one.
yes does not call every noindex an error. Production has search results and
thank-you pages. It names the home page, or all of them, which is what a staging
build reaching production looks like.
One baseline can serve two declarations. A merge-request job building
production-shaped pages takes yes, or nothing, and a deployed develop job takes
no, so the flag belongs on each job rather than in arguments they share. Capture
the baseline with the deployed job's. The merge-request job then prints a note on
every run saying the two differ, and that note is expected there. Finding identity
ignores the host, so a yes run still treats a new block as a new finding: it has
its own rule, and no baseline captured under no holds it.
What a baseline does not do
- It does not make the findings go away.
goflagwithout--regressions-onlystill reports all of them, and that is the run to read when you want the list. - It does not protect you from a rule getting stricter. A new version of goflag can produce findings your baseline has never seen. That is what pinning the version is for.
- It does not know why you accepted anything. The file records what; the commit message is where why lives.