Skip to main content

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 (the changes job) 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:

  1. Skip CI when a change is docs-only. The naïve win.
  2. 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.
  3. Re-run when main moves under the PR. A green-once anchor proves the code was tested — but not against the current base. If main advances (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 declares needs: changes + if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true') }}. A job skipped via if: reports a success/neutral conclusion, so it still satisfies a (future) branch-protection required status check. A workflow-level paths-ignore would 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 a paths-ignore stanza.

  • 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 diffs anchor..head. The query is hardened against the attacker-controlled branch name (github.head_ref on 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 jq select on both head_repository.full_name and head_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 only anchor..head and 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):

    1. a green anchor is resolvable in the checkout;
    2. the diff vs that anchor is docs-only; and
    3. the live base-branch tip is still an ancestor of the anchor (the base-ancestry guard) — fetched authoritatively from origin (the event payload's base.sha can 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=true on 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, a git diff failure, 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 the workflows/ci.yml/runs query returns nothing), it falls back to the event's own base (base..head for PRs, event.before..head for 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 inside anchor..head and trips code=true.

  • The docs-only pattern list lives in the workflow bash (canonical). A changed file is docs-only if it matches wiki/**, docs/**, any *.md anywhere (module READMEs, CLAUDE.md, skill markdown, the .claude/.agents/.cursor mirrors), the .claude/.agents/.cursor trees (non-markdown too), .github/ISSUE_TEMPLATE/**, the PR template (.github/PULL_REQUEST_TEMPLATE.md or .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.php classifies as code. Everything else is code — including .github/workflows/**, the Makefile, docker files, lockfiles, and the non-markdown files under packages/stables/skills (its composer.json + SkillsServiceProvider.php; only *.md there is docs). The diff feeding the classifier uses git diff --no-renames, which is security-critical: without it, git mv code.php evil.md collapses to only the docs-looking evil.md path and would classify docs-only; --no-renames emits both the deleted code.php (code) and the added evil.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.)

ScenarioBuild jobsWhy
Any changed file is code — PHP/JS/config, .github/workflows/**, Makefile, composer.json/lock, the skills-package PHP/composer.jsonRUNAny 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 unmovedSKIPSkip 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 runRUNThe 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 runRUN (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 anchorRUNBase-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 PRRUNThe 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 / SKIPAnchor 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)RUNFail-open: no meaningful range to diff
No green run yet in the branch's historyRUN / SKIPNo 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 failureRUNFail-open at every ambiguous step
The gate job itself times out or errorsRUNThe build jobs' condition treats any non-success gate result as fail-open (needs.changes.result != 'success')
Empty diffRUNFail-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-hosted static job 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 main advancement 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: read on the changes job 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-wide contents: read default. It is granted on the classifier job alone (a job-level permissions: block), so the heavy build jobs run with contents: read and 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_only bash 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 to branch=main and the same classify/fallback logic applies, so a docs-only push straight to main can 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 single wiki/README.md touch): the gate ran, all three build jobs reported skipped, overall run conclusion success. Honest caveat: PR #74's branch forked at b9edf9a, before the anchor/allowlist/base-ancestry logic landed, so it exercised the pre-anchor version of the gate — it proves the if:-skip mechanism and the docs-only classifier, not the anchor path.
  • Anchor path — run 29183288062. PR #73's docs-only push 9b9a648, diffed against the green anchor ade7d64, 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. The if:-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-filter et 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 to scripts/ci-detect-changes.sh is 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.

← Decision log index