D101 — CI docs-only skip gate: last-green anchor + base-ancestry guard (RESOLVED)
Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Sits on top of the pipeline established by D99 and split across runners by D100; the operational view (the decision table developers consult) is on Testing & the gates. The workflow bash in
.github/workflows/ci.yml(thechangesjob) is the canonical source of truth for this logic; this ADR records the rationale and that page summarizes it.
Context
This repo is documentation-heavy by contract: every shipped change syncs three doc layers (module README + skill + wiki) in the same diff (see How a change ships), so a large fraction of pushes touch only docs. Under D100 the heavy gates run on paid, credit-metered Blacksmith ARM runners — so a docs-only push burning a full arm64 build + the parallel suite + the SDK-package suite was spending real credits (and wall time) to certify text that cannot break the code.
The requirement arrived in three sharpening steps, each of which the final gate must satisfy:
- Skip CI when a change is docs-only. The naïve win.
- Skip docs-only follow-up commits within a PR, but only after a green run. A PR that mixes code and docs must still run everything until the code is proven green; once it is, later docs-only pushes on that PR should stop re-running the whole pipeline.
- Re-run when
mainmoves under the PR. A green-once anchor proves the code was tested — but not against the current base. Ifmainadvances (a silent fast-forward, a merge landing, a rebase), a docs-only push must not skip on a stale green without ever testing the merge against the new base.
The overriding constraint throughout: CI must never wrongly skip. Every ambiguous state runs the full pipeline. Saving credits is worthless if one bad skip lets broken code merge.
Decision
Gate the three build jobs behind a fast changes classifier job — via needs: + if:, not a
workflow-level paths-ignore — that anchors its diff on the branch's last green run and re-runs
whenever the base has moved.
-
A gate job, not
paths-ignore— for required-checks compatibility. The classifier is its own always-running job (changes/ "Detect changed paths"); each build job declaresneeds: changes+if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true') }}. A job skipped viaif:reports a success/neutral conclusion, so it still satisfies a (future) branch-protection required status check. A workflow-levelpaths-ignorewould instead make the workflow not run at all, leaving any required check pending forever and permanently blocking the PR. That single property is why the gate is a fourth job rather than apaths-ignorestanza. -
Anchor the diff on the last green run. Instead of diffing the whole PR (
base..head) — which re-runs everything on every docs-only push while the PR contains any code — the classifier queries this workflow's most recent successful run on this branch and diffsanchor..head. The query is hardened against the attacker-controlled branch name (github.head_refon fork PRs): the name is first passed through a strict character allowlist (fail-open on anything exotic), then sent as URL-encoded query fields (gh api -X GET …/runs -f branch=… -f status=success -f per_page=1, never spliced raw into the URL), and the run is pinned to this repo and branch via a jqselecton bothhead_repository.full_nameandhead_branch(safe to interpolate only because the allowlist already excludes"/\). So once a code commit's CI is green, a follow-up docs-only push diffs onlyanchor..headand skips the build jobs, until the next code change moves the anchor forward. This is safe by induction: a skipped-but-green run is itself a valid anchor, because it could only have gone green-with-skips if its code content equalled the last executed green — so anchoring on it is equivalent to anchoring on that executed green. -
Push/PR-synchronize granularity. The classifier acts on the head state of a push or a PR-synchronize, not on individual commits: several commits landing in one push are diffed as a single
anchor..head(or fallback) range and get one classification and one run/skip decision — intermediate commits never receive their own run. This is also why a docs commit pushed together with a code commit runs everything: the combined head-state diff contains the code path. -
A skip requires three conditions together (pull_request events):
- a green anchor is resolvable in the checkout;
- the diff vs that anchor is docs-only; and
- the live base-branch tip is still an ancestor of the anchor (the base-ancestry guard) —
fetched authoritatively from
origin(the event payload'sbase.shacan be stale), because a green anchor proves the code was tested but not that it was tested against the current base. If the base tip is not contained in the anchor's history, the base moved and the full pipeline runs, so the merge is tested against the new base rather than skipping on a stale green. Push events carry no merge-ref semantics, so the guard is consulted only on pull_request events.
-
Fail-open doctrine: every ambiguous state runs CI. The classifier is hand-rolled bash (no third-party action) and emits
code=trueon any doubt — branch creation (all-zeros base), an unresolvable base/head sha, an exotic branch name (allowlist reject), an Actions-API failure, a base-tip fetch/rev-parse failure, agit difffailure, or an empty diff. When no green anchor exists (the branch has no green history; its sha was GC'd by a force-push; or the workflow file itself was renamed, so theworkflows/ci.yml/runsquery returns nothing), it falls back to the event's own base (base..headfor PRs,event.before..headfor pushes) — today's pre-gate behavior — and classifies that. That fallback is a safe degradation, not a fail-closed state: the whole-PR diff is still classified, so a genuinely docs-only fallback diff may still skip while anything code-bearing runs. A docs push after a red run still re-runs, because the anchor is the older green, so the still-broken code sits insideanchor..headand tripscode=true. -
The docs-only pattern list lives in the workflow bash (canonical). A changed file is docs-only if it matches
wiki/**,docs/**, any*.mdanywhere (module READMEs,CLAUDE.md, skill markdown, the.claude/.agents/.cursormirrors), the.claude/.agents/.cursortrees (non-markdown too),.github/ISSUE_TEMPLATE/**, the PR template (.github/PULL_REQUEST_TEMPLATE.mdor.github/PULL_REQUEST_TEMPLATE/**), or the licence (LICENSE/LICENSE.*). The template/licence patterns are anchored (not a*suffix) so a lookalike such as.github/PULL_REQUEST_TEMPLATEx.phpclassifies as code. Everything else is code — including.github/workflows/**, theMakefile, docker files, lockfiles, and the non-markdown files underpackages/stables/skills(itscomposer.json+SkillsServiceProvider.php; only*.mdthere is docs). The diff feeding the classifier usesgit diff --no-renames, which is security-critical: without it,git mv code.php evil.mdcollapses to only the docs-lookingevil.mdpath and would classify docs-only;--no-renamesemits both the deletedcode.php(code) and the addedevil.md, so a rename can never smuggle code past the gate. The list is maintained in the bash and summarized on Testing & the gates and here — change all three together.
The decision table
Two routes skip, everything else runs: the anchored route (a green anchor + a docs-only diff
vs that anchor + an unmoved base) and the anchor-less fallback route (no usable anchor, but
the whole-PR / event-base diff is itself genuinely docs-only — a fully docs-only PR has no code to
test, so skipping is safe). Every other state runs the full pipeline. (Verify against the changes
job bash in .github/workflows/ci.yml — it is canonical.)
| Scenario | Build jobs | Why |
|---|---|---|
Any changed file is code — PHP/JS/config, .github/workflows/**, Makefile, composer.json/lock, the skills-package PHP/composer.json | RUN | Any path off the docs-only list trips code=true; a mixed docs+code diff runs everything |
| All changed files docs-only + green anchor resolvable + base unmoved | SKIP | Skip route 1 (anchored): all three conditions hold. Jobs report skipped, which counts as success for required checks; no Blacksmith credits spent |
| Docs-only push after a red run | RUN | The anchor is the older green run, so the still-broken code sits inside anchor..head and trips code=true — a docs push can't turn a red PR green |
| Docs-only push after a cancelled run | RUN (usually) | A cancelled run is not a success, so it can't anchor; the diff vs the older green (or the fallback) still contains the unproven code |
| Docs-only, but the base branch advanced since the anchor | RUN | Base-ancestry guard (PR events): the live base tip is no longer an ancestor of the anchor, so the merge is re-tested against the new base |
| Merge-from-main pushed into a PR | RUN | The base moved, so the base-ancestry guard trips; and if main's delta carried code it is in the anchor..head diff too |
| Rebase / force-push (anchor sha unresolvable) | RUN / SKIP | Anchor GC'd/unreachable → fall back to the event base and classify the whole-PR diff: its code trips code=true, but skip route 2 (the anchor-less fallback) still SKIPS a genuinely docs-only whole-PR diff; an unresolvable before-sha fails open |
Branch creation (before sha all-zeros) | RUN | Fail-open: no meaningful range to diff |
| No green run yet in the branch's history | RUN / SKIP | No anchor → fall back to the whole-PR (base..head) / push (event.before..head) diff and classify that: a code-bearing PR runs, but skip route 2 (the anchor-less fallback) still SKIPS a genuinely docs-only whole-PR diff |
Actions API / base-tip fetch / git diff failure | RUN | Fail-open at every ambiguous step |
| The gate job itself times out or errors | RUN | The build jobs' condition treats any non-success gate result as fail-open (needs.changes.result != 'success') |
| Empty diff | RUN | Fail-open: nothing to classify |
Consequences
- Docs-only pushes and PRs spend no Blacksmith credits and no wall time — the two Blacksmith
jobs (
tests,test-packages) and the GitHub-hostedstaticjob all skip. Given the three-layer docs-sync contract, this is a frequent path. - Skipped-green runs are valid anchors by induction (Decision) — so a PR that goes green, then receives only docs pushes, keeps skipping without ever losing its green certification.
- Conservative re-runs when the base moves. The base-ancestry guard re-runs the full pipeline
whenever GitHub's stock behavior would have accepted a stale merge test — a merge-from-main, a
rebase, or a silent
mainadvancement followed by a docs-only push. It errs toward running: a docs-only push after the base moved re-runs rather than skipping. - One added token scope, job-scoped:
actions: readon thechangesjob only. The anchor query reads this workflow's own run history via the REST API — a read-only capability, and the only scope added beyond the workflow-widecontents: readdefault. It is granted on the classifier job alone (a job-levelpermissions:block), so the heavy build jobs run withcontents: readand nothing more. No write scope anywhere; the pipeline remains secret-free (see D100). - The pattern list is a maintained surface. When a new top-level docs directory or file type
appears, update the
is_docs_onlybash and the two doc summaries (this ADR + Testing & the gates) in the same change — the wiki-sync discipline applies to this gate like any other surface. - Fork PRs with docs-only diffs now show all-skipped green checks without any Blacksmith job
running. This cannot false-green a code-bearing fork PR: such a diff trips
code=true, and the Blacksmith build jobs (billed to the repo owner) never execute for forks — so their required checks stay pending rather than passing, and human review plus branch protection still gate the merge. - The gate also covers direct pushes to
main. On a push event the anchor is scoped tobranch=mainand the same classify/fallback logic applies, so a docs-only push straight tomaincan skip the build jobs too. Orthogonally, the Aikido security gate is a separate GitHub App check that reports independently of this skip decision — a docs-only skip does not skip security scanning. - Live-verified (2026-07-12). A docs-only PR (#74) skipped all three build jobs and the run reported success; code-bearing runs execute the full pipeline unchanged. See Live verification below for the exact runs and what remains code-review-verified.
Live verification
Fired live (2026-07-12), plus what is verified by code review and synthetic-repo smoke tests only:
- Docs-only skip — run
29182403543. Throwaway PR #74 (a singlewiki/README.mdtouch): the gate ran, all three build jobs reported skipped, overall run conclusion success. Honest caveat: PR #74's branch forked atb9edf9a, before the anchor/allowlist/base-ancestry logic landed, so it exercised the pre-anchor version of the gate — it proves theif:-skip mechanism and the docs-only classifier, not the anchor path. - Anchor path — run
29183288062. PR #73's docs-only push9b9a648, diffed against the green anchorade7d64, skipped the build jobs — the last-green anchor doing exactly its job on a code-bearing PR. - Docs-after-a-non-green run — PR #75. Proved that a docs-only push on top of non-green history
still runs CI, but via the anchor-less fallback: the throwaway branch had no green run to
anchor on, and its classify log showed
fallback = event base … (no green anchor …). This exercises the fallback branch, not the red-anchor row (which needs a prior green on the same branch). - Not yet fired live: the red-anchor row (a docs push after a red run on a branch that already has a green anchor) and the base-ancestry guard are verified by code review and synthetic-repo smoke tests only — no live run has driven them yet.
Alternatives rejected
- Workflow-level
paths-ignore. The obvious one-liner — but a workflow that never runs leaves a branch-protection required check pending forever, permanently blocking the PR. Theif:-skip gate reports success instead, so it stays compatible with required checks. Decisive. [skip ci]in commit messages. Human-dependent (someone must remember, on every commit) and content-blind — it cannot distinguish a docs-only change from a code change, so it is both forgettable and unsafe. The gate classifies the actual diff.- A third-party action (
dorny/paths-filteret al.). Adds a supply-chain surface (a pinned-but-external action executing in CI) for what ~100 lines of auditable in-repo bash (about 200 with comments) does. Keeping it hand-rolled keeps the trust boundary exactly where D99/D100 left it. - Extracting the classifier to
scripts/ci-detect-changes.sh(the repo's precedent for larger CI logic, and shellcheck-able as a standalone file). Kept inline deliberately: the comments here are load-bearing documentation that must sit next to the logic they justify, and the fail-open contract is now enforced structurally by the dependent jobs'if:expression (!cancelled() && (needs.changes.result != 'success' || …)), so a classifier crash runs the pipeline regardless of where the script lives — extraction buys shellcheck at the cost of splitting the rationale from the code. Extraction toscripts/ci-detect-changes.shis the plan if the classifier grows materially again. - Per-push diff with no anchor (classify only the pushed commit range). Cannot distinguish docs-after-green from docs-after-red: without anchoring on the last green run, a docs-only push onto still-broken code would skip and let a red PR appear to pass. The last-green anchor is what makes the skip safe.