Skip to content

ci: unblock docs-only PRs without weakening the gate - #1185

Merged
2600hz-oscillator merged 3 commits into
mainfrom
ci/docs-only-required-checks
Jul 27, 2026
Merged

ci: unblock docs-only PRs without weakening the gate#1185
2600hz-oscillator merged 3 commits into
mainfrom
ci/docs-only-required-checks

Conversation

@2600hz-oscillator

@2600hz-oscillator 2600hz-oscillator commented Jul 27, 2026

Copy link
Copy Markdown
Owner

The deadlock

.github/workflows/ci.yml carries, on both push and pull_request:

paths-ignore:
  - '**/*.md'
  - '.myrobots/**'
  - 'LICENSE'

The intent is right — a prose-only change should not burn a ~25-minute pipeline.

But ruleset 16042163 ("main: green PRs only") requires two status contexts:

required context produced by
typecheck + unit + ART + E2E ci.yml job ci (the umbrella)
vrt-strict (visual regression — strict subset) ci.yml job vrt-strict

When CI is path-skipped those contexts never report, and GitHub treats a
never-reported required check as pending forever. The PR lands at
mergeStateStatus: BLOCKED with mergeable: MERGEABLE, zero failures, zero
running jobs
, and can never auto-merge. There is nothing to re-run: the run
does not exist.

Verified on the live cases

#1184 — docs-only (.myrobots/26-07-22-roundup.md), auto-merge armed since
2026-07-27T06:06:49Z, BLOCKED. gh run list --branch docs/roundup-2026-07-27
returns exactly one run — Deploy — and no CI run at all. The only
contexts that ever reported are Deploy → PR-1184 preview and CodeRabbit.

#1175 — the .myrobots corpus PR — merged fine, and that confirms the
diagnosis rather than contradicting it: it touched 62 files, 61 under
.myrobots/** plus .gitignore, which is not in paths-ignore. One
non-ignored file ⇒ CI ran ⇒ contexts reported ⇒ mergeable.

Verify CI passed for this branch (reported skipped on #1184) is not
related: it is deploy.yml's verify-ci job, if: github.event_name == 'workflow_dispatch' — a guard for manual Actions-UI deploys, not a PR gate. It
was never meant to solve this and is left alone.

This is about to bite repeatedly: .myrobots/ is now a tracked planning
directory and a large DX7/UI program plan is being written into
.myrobots/plans/ right now.

The option chosen — and why

Option 1 (companion workflow on the inverse path filter), made sound.
Rejected alternatives:

  • Drop paths-ignore (option 2's premise). ~25 min of CI wall-time on every
    typo fix, on a repo where a cycle is ~25 min under load and .myrobots/ is now
    a tracked planning corpus. That deliberate optimisation is worth keeping.
  • Let jobs start and no-op internally via a change detector. Honest, but the
    detector has to be a dependency of every job, which serialises ~20 s in front
    of a ~4-5 min critical path on every code PR — paying a permanent tax on
    the common case to fix the rare one. It also rewrites the most load-bearing
    workflow in the repo (125 KB, 20 jobs, 3 attest gates) to fix a trigger bug.
  • Naming the companion's jobs after the required contexts (option 1's naïve
    form). Unsound — see below.

Why the naïve form is unsound, and what this does instead

GitHub path filters are per-file ANY-match. With the same list P:

ci.yml            paths-ignore: P   fires  ⟺  ∃ file ∉ P
docs-only-gate    paths:        P   fires  ⟺  ∃ file ∈ P

so for a non-empty changeset:

changeset ci.yml bypass outcome
docs only bypass posts the contexts
code only real suite gates
both both fire

Path filters cannot express "every changed file is a doc" — they are
ANY-match, and ! negation inside paths: only re-expresses paths-ignore. So
the companion does fire on a mixed PR, and the naïve form breaks there:

  • a job named after a required context creates a check run the instant it
    starts and cannot withdraw it; and
  • a job-level if: skip reports as SUCCESS to branch protection — so gating
    the job on "docs only" would satisfy the context on precisely the mixed PRs
    where the real CI is still running or red.

This PR therefore posts commit statuses from an explicitly guarded step, not
job names.
On a mixed PR the workflow emits nothing at all. The job is
named docs-only gate (bypass check, not a required context) and a unit test
asserts no job in the file is named after a required context.

Both-touched-paths handling — two independent guards

A status is posted only when both hold:

  • G1 (predicate) — every file in the full PR diff (gh api pulls/N/files --paginate) matches P, computed locally.
  • G2 (oracle)no run of .github/workflows/ci.yml exists for this head
    SHA, polled for 60 s. This asks GitHub what it did rather than re-deriving
    glob semantics, so it is exact by construction.

Either guard alone closes the mixed case: G2 because a mixed PR always produces a
CI run, G1 because it sees the source file. Requiring both means any disagreement
between this matcher and GitHub's — e.g. whether **/*.md matches a root
README.md, which GitHub's filter engine and minimatch read differently —
degrades to "no bypass, real CI gates", never to "bypass while code went
ungated"
. G2 also fails safe: an API error is treated as run present.

Two smaller cases: a fork PR is refused (its GITHUB_TOKEN cannot write
statuses); a PR with >300 files has its path filters skipped entirely by
GitHub, so both workflows run and G2 refuses.

Statuses are per-commit, so a docs-only SHA that later gains a code commit gets a
new SHA with no bypass status and a real CI run.

It also never creates a ci.yml run, so daily-prod-deploy.yml's find-green
scan (which queries actions/workflows/ci.yml/runs?head_sha= and asserts the
umbrella job is present) cannot be fooled into treating a docs-only commit as a
fully-green deploy candidate.

Negative-control evidence

This PR IS the both-touched case, live

It touches CLAUDE.md and three code files, so both workflows fired on
35344f34 — the exact scenario the design has to survive. From the bypass job's
log:

resolved 4 changed file(s)
attempt 1: ci.yml run(s) present for 35344f34… (total_count=1)
decision: post=false
reason:   G1 FAILED — 3 non-doc file(s) changed:
          .github/workflows/docs-only-gate.yml, scripts/docs-only-gate.mjs,
          scripts/docs-only-gate.test.ts. The real CI suite gates this PR.

Both guards fired independently — G2 found the real CI run on its first poll,
G1 named the three source files — and the commit-status list for that SHA is:

$ gh api repos/:owner/:repo/statuses/35344f34… --jq '.[] | {context, creator}'
{"context":"CodeRabbit","creator":"coderabbitai[bot]"}
{"context":"CodeRabbit","creator":"coderabbitai[bot]"}

Zero statuses from the bypass. The required contexts on this PR come only
from the real ci umbrella and vrt-strict jobs.

The unit gate

scripts/docs-only-gate.test.ts60 tests, pure-unit, unit lane, zero
added CI wall-time. The decision is a pure function (decideBypass) so the
negative control is a test, not a CI experiment.

  • Every one of these, alone, is refused (post: false, G1 FAILED):
    packages/web/src/lib/audio/modules/adsr.ts, packages/dsp/src/cube.ts,
    packages/server/src/relay.ts, e2e/tests/ai-smoke.spec.ts,
    e2e/vrt/vrt-exemptions.ts, .github/workflows/ci.yml,
    .github/workflows/docs-only-gate.yml, scripts/docs-only-gate.mjs,
    package.json, package-lock.json, Taskfile.yml, .gitignore,
    contract-lock.txt, db/schema/001_init.sql, a VRT baseline PNG, an ART
    .f32 baseline, plus the adversarial CLAUDE.md.ts and docs.md/thing.ts.
  • Both-touched — docs + one source file → refused by G1 before CI reports;
    docs-only + ciRunExists: true → refused by G2. Each asserted separately.
  • Exhaustivepost === (sameRepo && !ciRunExists && isDocsOnly(files))
    over the full cross-product.
  • The live casesdocs(myrobots): roundup — correct the depolarizer misdiagnosis, record FLICKER v1+v2 + the flake family #1184's file list → bypass; docs(myrobots): commit the planning corpus — triaged 117 → 40 files, un-ignore the dir #1175's file list (with
    .gitignore) → refused, reproducing why it merged normally; this PR's own
    file list
    (.github/**, scripts/**) → refused, full suite runs.
  • Drift guards — the paths list is byte-identical to both of ci.yml's
    paths-ignore lists; the posted contexts are byte-identical to ci.yml's ci
    and vrt-strict job names (U+2014 em-dash asserted explicitly); the bypass
    declares no job named after a required context and no name: CI; the
    status-posting step is guarded on steps.decide.outputs.post == 'true'; and
    ci.yml still declares paths-ignore (if that optimisation is ever removed,
    this workflow is dead weight and the test says so).
  • The CLI seam — three real node scripts/docs-only-gate.mjs decide
    invocations assert the exact $GITHUB_OUTPUT the workflow reads, including
    the JSON.parse(process.env.CONTEXTS) round-trip back to REQUIRED_CONTEXTS.
    (The second commit moved the contexts from a dynamic import() inside
    github-script to a step output: AsyncFunction bodies have no reliable
    module referrer, and that was the one path the live run could not exercise
    because the posting step is correctly guarded off. It is now covered by test
    rather than by hope.)
  • Untrusted-input hardening (third commit) — filenames from a PR diff are
    attacker-controllable, so: no ${{ }} interpolation of steps.* /
    github.event.* in any run: or script: body (asserted over all four run:
    bodies); the reason is flattened on \r and \n so a crafted path cannot
    inject a second post=true line into $GITHUB_OUTPUT; and the changed-file
    heredoc delimiter is randomised so a path containing a newline cannot truncate
    the file list. Each has its own test.

CI wall-time delta

+0 s on the critical path. ci.yml is untouched, so code PRs are byte-for-byte
unchanged. The new workflow is a separate job that fires only on PRs touching a
doc path — measured at 12 s on this PR — and nothing depends on it. The unit
test adds ~0.1 s to the unit lane. Docs-only PRs go from never mergeable to
~1 min (the G2 poll's absence window) instead of ~25 min of full suite.

Verification

  • flox activate -- task typecheck0 errors (3108 files).
  • actionlint 1.7.12 -shellcheck= — clean, whole .github/workflows/ tree.
  • REPEAT=3 task test:one PKG=scripts -- docs-only-gate — 3× green, 60 tests.
  • Full scripts/ suite — green (ledger + reconciliation unaffected; they walk
    packages/, e2e/, art/, not scripts/).
  • Attest hashes byte-identicalwebgl 0effdd16…, collab c2324ef7…,
    grand f0417877… each still resolve to their committed
    ci-*-attest/<hash>.json; all three attest gates passed on CI. Neither basis
    covers .github/** or scripts/*.mjs.
  • contract-lock.txt untouched (empty diff).

Known, pre-existing, unchanged

**/*.md / .myrobots/** / LICENSE feed nothing in the build or the test
suites — every reference in source is a comment (verified by grep). The one
exception is docs/testing/test-ledger.generated.md, a GENERATED golden asserted
by scripts/test-ledger.test.ts: hand-editing it alone is ungated. That is a
pre-existing consequence of ci.yml's paths-ignore, identical before and after
this PR, and the next code PR's unit lane catches the staleness. Closing it
would require paths-ignore to express a negation, which GitHub does not
support.

Follow-up once this is on main

#1184 should become mergeable: close+reopening it re-fires pull_request, the
bypass runs, G1 sees one .md, G2 finds no ci.yml run, and both contexts go
green — satisfying the armed auto-merge.

🤖 Generated with Claude Code

`ci.yml` carries `paths-ignore: ['**/*.md', '.myrobots/**', 'LICENSE']` so a
prose-only change doesn't burn a ~25-minute pipeline. But ruleset 16042163
REQUIRES `typecheck + unit + ART + E2E` and `vrt-strict (visual regression —
strict subset)`, and a path-skipped workflow reports NOTHING — GitHub treats a
never-reported required check as pending forever. A docs-only PR therefore sits
at mergeStateStatus=BLOCKED / mergeable=MERGEABLE with zero failures and can
never auto-merge (#1184, armed since 06:06Z, permanently stuck).

Adds `.github/workflows/docs-only-gate.yml`: fires on the EXACT INVERSE path
filter (same list P — ci.yml `paths-ignore: P`, this `paths: P`) and posts the
two required contexts as commit statuses, but only when TWO independent guards
agree — every changed file is a doc (G1) AND GitHub started no ci.yml run for
this head SHA (G2). A PR touching docs AND code fires both workflows; the bypass
posts nothing and the real suite gates it.

Posts statuses rather than naming jobs after the required contexts: a job-level
`if:` skip reports as SUCCESS to branch protection, which would satisfy the gate
on exactly the mixed docs+code case where it must not.

`scripts/docs-only-gate.test.ts` (unit lane, 52 tests) pins the complement
invariant, the context strings against ci.yml's job names, and the negative
controls — no changeset containing a source file can ever be bypassed.

ci.yml is untouched, so code PRs' wall time does not move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a docs-only GitHub Actions gate, shared decision logic, and Vitest coverage. The gate detects documentation-only pull requests without a ci.yml run and posts the required success commit statuses while rejecting code changes, existing CI runs, fork pull requests, and invalid file lists.

Changes

Docs-only CI bypass

Layer / File(s) Summary
Docs-only decision logic
scripts/docs-only-gate.mjs
Defines documentation path patterns, required contexts, glob matching, environment parsing, and guarded bypass decisions.
Workflow orchestration
.github/workflows/docs-only-gate.yml, CLAUDE.md
Adds the inverse path-filter workflow, changed-file and CI-run lookups, decision invocation, conditional status posting, and operating constraints.
Workflow invariants and regression coverage
scripts/docs-only-gate.test.ts
Validates path-filter complements, required context identity, bypass guards, glob behavior, environment inputs, and historical cases.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PullRequest
  participant DocsOnlyGate
  participant GitHubAPI
  participant DecideBypass
  participant CommitStatuses
  PullRequest->>DocsOnlyGate: open or update pull request
  DocsOnlyGate->>GitHubAPI: fetch changed files
  DocsOnlyGate->>GitHubAPI: query ci.yml runs for head SHA
  DocsOnlyGate->>DecideBypass: pass files, CI state, and repository state
  DecideBypass-->>DocsOnlyGate: return post decision and reason
  DocsOnlyGate->>CommitStatuses: post required success contexts when approved
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: unblocking docs-only PRs while preserving CI gate safety.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/docs-only-required-checks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`actions/github-script` evaluates `script:` inside a `new AsyncFunction(...)`
body, where dynamic `import()` has no reliable module referrer — the one code
path the live both-touched run on this PR could not exercise, because the
posting step is (correctly) guarded off.

The decide step now emits `contexts=<json>` from REQUIRED_CONTEXTS and the
posting step does `JSON.parse(process.env.CONTEXTS)`. docs-only-gate.mjs stays
the single source of truth, still pinned to ci.yml's job names by the unit test,
with no ESM-in-vm dependency.

Adds 4 tests: the workflow wiring (`CONTEXTS` env in, `JSON.parse` out, no
`await import(`), plus three real `node scripts/docs-only-gate.mjs decide`
invocations asserting the exact $GITHUB_OUTPUT the workflow reads — including
the JSON round-trip back to REQUIRED_CONTEXTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/docs-only-gate.yml:
- Around line 64-67: Update the Checkout step using actions/checkout@v4 to
disable credential persistence by setting persist-credentials to false, while
leaving the rest of the workflow unchanged.
- Around line 139-142: Update the “Real CI is authoritative — no bypass” step to
avoid interpolating steps.decide.outputs.reason directly into the shell script.
Pass the reason through the step’s environment and reference the environment
variable with shell-safe quoting in the notice command, preserving the existing
message and condition without allowing PR-controlled filenames to execute as
shell syntax.
- Around line 74-89: Update the Resolve the PR's changed files step to generate
a unique multiline-output delimiter for each invocation instead of using the
static CHANGED_FILES_EOF marker. Ensure the delimiter cannot collide with
PR-supplied filenames, then emit all resolved filenames and the matching closing
delimiter to the CHANGED_FILES output so decideBypass receives the complete file
list.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 11f61c20-edf5-4ac7-ab9a-fad16ed1a17a

📥 Commits

Reviewing files that changed from the base of the PR and between 3d0d7c4 and 35344f3.

📒 Files selected for processing (4)
  • .github/workflows/docs-only-gate.yml
  • CLAUDE.md
  • scripts/docs-only-gate.mjs
  • scripts/docs-only-gate.test.ts

Comment on lines +64 to +67
steps:
- name: Checkout
uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Disable credential persistence on checkout.

actions/checkout@v4 defaults to persist-credentials: true, leaving the job's token in .git/config for the rest of the job. Given this workflow subsequently runs run: steps that interpolate PR-controlled data (see the template-injection finding below), a persisted token compounds the blast radius of any code-execution primitive into token exfiltration.

🔒 Proposed fix
       - name: Checkout
         uses: actions/checkout@v4
+        with:
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
steps:
- name: Checkout
uses: actions/checkout@v4
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/docs-only-gate.yml around lines 64 - 67, Update the
Checkout step using actions/checkout@v4 to disable credential persistence by
setting persist-credentials to false, while leaving the rest of the workflow
unchanged.

Source: Linters/SAST tools

Comment thread .github/workflows/docs-only-gate.yml
Comment thread .github/workflows/docs-only-gate.yml Outdated
Adversarial pass over the bypass workflow. Filenames from a PR diff are
attacker-controllable (a branch can carry a path containing quotes, backticks or
even a newline — git allows it), and they flow into three places:

1. `run:` bodies — the "no bypass" step interpolated the decision reason, which
   embeds those filenames, directly into the shell via `${{ }}`. Now passed via
   `env:`, like every other step output. Guarded by a test that walks all four
   `run:` bodies plus the github-script `script:` body and rejects any `${{ }}`
   interpolation of `steps.*` / `github.event.*`.

2. `$GITHUB_OUTPUT` — an unflattened reason carrying CR/LF would inject a second
   `key=value` line, and the last value wins, so a crafted path could forge
   `post=true` past the guarded posting step. Reason is now flattened on `\r`
   AND `\n`; negative-control test included.

3. The changed-file heredoc — a fixed `CHANGED_FILES_EOF` delimiter appearing in
   the payload would TRUNCATE the file list, the one truncation that could make a
   mixed PR look docs-only to G1. Delimiter is now randomised per run (GitHub's
   documented practice). G2 would still have refused; not relying on the second
   guard for something this cheap.

Also converts the G2 poll's trailing `[ … ] && sleep` to an explicit `if` —
verified it does NOT trip `set -e` today (the failing test is a non-final command
of an AND-OR list, so it is exempt), but the exhaustion path is the docs-only
path, and it should not depend on that subtlety.

60 tests, 3× clean; actionlint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@2600hz-oscillator

Copy link
Copy Markdown
Owner Author

CI note — e2e (shard 1/10) went red on 7ac27e25, root-caused (not re-run blind, not absorbed as flake):

/usr/bin/docker pull public.ecr.aws/docker/library/postgres:17
17: Pulling from docker/library/postgres
toomanyrequests: Rate exceeded          ← ×3, all retries
##[error]Docker pull failed with exit code 1

The service container failed to start; zero tests executed (the upload steps confirm it: "No files were found with the provided path: e2e/test-results" / "e2e/blob-report"). This is not a test failure and it is unrelated to this PR, which touches only a workflow file, two scripts and CLAUDE.md.

Root cause: public.ecr.aws does rate-limit anonymous pulls, per source IP. This run starts a postgres service container in 15 jobs at once (10 e2e shards + collab + behavioral-smoke + webgl-smoke + vrt + vrt-strict), all from the same runner IP range within seconds. Shard 1 lost the race; the other 14 got through.

Worth noting for a follow-up: the comment above the unit job's service block asserts "ECR Public has no rate limits" — that is factually stale, and it is the reason the Docker Hub → ECR migration reduced but did not eliminate this class of failure. Real fixes are an authenticated ECR pull, a GHCR mirror, or not giving all 10 e2e shards their own Postgres. All are substantive ci.yml changes with a wall-time delta, so they belong in their own PR with owner sign-off rather than growing this one.

Re-running the failed job only (the diagnosis is complete and the cause is provably external to the repo).

@2600hz-oscillator
2600hz-oscillator merged commit c780199 into main Jul 27, 2026
72 of 74 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant