Skip to content

refactor(ci): resolve the docs-only scope once and gate the contract - #3237

Merged
kyle-sexton merged 2 commits into
mainfrom
refactor/3159-hoist-docs-only-scope
Aug 23, 2026
Merged

refactor(ci): resolve the docs-only scope once and gate the contract#3237
kyle-sexton merged 2 commits into
mainfrom
refactor/3159-hoist-docs-only-scope

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #3159

Summary

The docs-only short-circuit lets the heavy CI lanes report an honest evaluated-and-not-applicable success on a diff confined to the documentation allowlist. That contract lived as a step output re-derived independently inside six jobs — hygiene, plugin-gate, miro-plugin, video-extraction, ai-briefing-build, course-digest-extraction — each running scripts/check-docs-only.sh as its own id: scope step and gating its work on steps.scope.outputs.docs_only != 'true'.

That put the contract at 48 references in one file, every one an inverse-polarity test against a string flag. Each consumer had to independently know three things: that the value is the string true, that the safe direction is to run the suite, and that the negation must be spelled that exact way. Nothing executable held that knowledge; a prose comment did.

The asymmetry is what made it worth fixing. A consumer that wrote == 'false' where it meant != 'true' would skip its lane whenever the detector emitted anything unexpected — a lane reporting success without having run, the false-green shape docs/conventions/liveness-assertion/ names. A consumer that forgot the gate entirely would merely run always, which is only wasteful. The comment existed because of that asymmetry, and 48 hand-maintained copies of a comment is the wrong mechanism for it.

Fix

A new scope job resolves the diff once per workflow and publishes run_full. Every former reference now reads needs.scope.outputs.run_full; the six duplicated detector invocations and their six duplicated self-test steps are gone.

The output is positive on purpose. A consumer writes run_full == 'true' to do the work and run_full == 'false' to report it not applicable. Both are plain equality against a value that ${{ <expr> != 'true' }} can render only as true or false, so the two forms are exact complements and a new lane has no negation to spell wrong. This is the fork the triage brief left open, resolved the way the brief defaulted it.

The fail-safe direction is preserved, and now checked rather than asserted:

Condition Resolves to Mechanism
Detector emits docs_only=true short-circuit run_full=false
Detector emits docs_only=false run full suite run_full=true
Detector exits non-zero / cannot write its output run full suite the flag never reaches $GITHUB_OUTPUT, and != 'true' renders an unset flag as true
Push event (detect step not meant to run) run full suite same unset-output path
Detector itself is broken lane turns red self-test is deliberately not continue-on-error

scripts/check-docs-only.sh's behavior is unchanged: it still emits docs_only=false for every condition it can resolve but cannot classify. Its header comment is corrected, though — see Verification.

scripts/check-docs-only-gate.sh is a new gate asserting nine properties against ci.yml: the detector is resolved exactly once; the published expression is exactly the fail-closed one; the step that absorbs failure is the step that actually invokes the detector; the self-test is unweakened; every consumer reference is one of two whole sanctioned shapes; every aggregator-feed override names a step that actually carries the gate; no consumer carries a job-level condition; every reader declares the needs edge; and at least one consumer actually reads the output. It runs in its own docs-only-gate lane, wired into ci-status.needs.

Three of those are worth spelling out, because they are the asymmetric ones — the direction that fails open is not the direction you would guess:

  • A feed override on an ungated step. Forgetting an override is fail-closed: the step's raw skipped reaches the aggregator and reds it. Adding one for a step that is not gated is fail-open — it replaces that step's real outcome with success on every docs-only diff. Only the second direction needed a check.
  • A job-level condition on a consumer. The two sanctioned forms are exact complements only while the output is set, which holds because a consumer reaches it through needs and therefore runs only when the resolver succeeded. An if: always() or if: ${{ !cancelled() }} — an idiom this very file uses on the aggregate — lets the job run anyway, with an empty output. Both forms are then false, so every gated step skips alongside its own not-applicable reporter, and the lane reports success having run nothing. The condition does not have to mention the output to do this, so the check does not read what it says.
  • A missing needs edge produces the same empty output by a different route.

The gate is deliberately hostile to being satisfied by anything other than the real thing, because a gate that can be evaded is the same nominal closure it was written to remove:

  • Comments never stand in for the expressions they quote.
  • Block-scalar bodies are read for content but never for structure, so a run: script can neither masquerade as a step condition nor hide a second resolution.
  • Consumer forms are matched as whole shapes rather than searched for as substrings, so a negation wrapped around the sanctioned string or an index-syntax spelling fails rather than passing unnoticed.
  • Zero references is a defect, not a vacuous pass.
  • Any workflow shape it cannot parse exits 2 (inconclusive), never 0 — the same principle as check-lane-coverage.sh.

Behavior is unchanged in which jobs run and which steps they execute. The aggregator is untouched: the intentional docs-only step skip is still mapped to success in the CHECK_RESULTS feed, now keyed on the same single output as the gates it mirrors.

The trade-off this makes, stated plainly. Six previously independent jobs now declare needs: [scope], so they are no longer fault-isolated from each other: if scope fails, all six are skipped rather than running and failing on their own terms. That is the unavoidable cost of resolving the scope once, and it was weighed rather than overlooked:

  • It is fail-closed. A skipped lane's result is skipped, which ci-status rejects, and ci-status carries if: ${{ !cancelled() }} so a failed dependency does not skip the aggregate itself — it still runs and turns red. There is no input under which this change turns a red gate green.
  • The blast radius is small by construction. scope is a checkout, one continue-on-error detector call, and the detector's own suite. The only thing that can fail it is the self-test, and a broken detector should stop the lanes that would otherwise trust it.
  • The critical path grows by one short job, and loses six duplicated detector invocations and six duplicated self-test runs.

The cost is real, and if it proves annoying in practice the honest fix is to make scope cheaper still, not to re-scatter the resolution.

Verification

Gate Result
scripts/check-docs-only-gate.test.sh PASS — 46 cases
scripts/check-docs-only.test.sh (detector behavior unchanged) PASS — 21 cases
scripts/check-lane-coverage.test.sh PASS
scripts/check-manifest-duplicate-keys.py, scripts/check-hook-wiring-liveness.sh clean
scripts/check-lane-coverage.sh --check PASS — all 42 lanes reachable from ci-status.needs
scripts/aggregate-hygiene-results.sh --self-test PASS — script byte-identical to main
actionlint on ci.yml clean
shellcheck -x on both new scripts clean
scripts/check-shell-portability.sh --paths (both new scripts) clean
scripts/check-silent-skips.sh clean

Each of the gate's 46 cases breaks exactly one property of a known-good fixture and requires the gate to name that property, so the gate is proven to detect what it claims rather than merely passing. The known-good fixture deliberately carries the shapes a structural reader is fragile on — a comment inside a job body, a job key with a trailing comment, a block scalar whose body looks like workflow structure, a quoted flow needs:, a reusable-workflow job — because a gate only ever tested against tidy input is only ever known to work on tidy input.

Two cases are behavioral rather than structural and close the half of the fail-closed guarantee that lives below the workflow: against the real check-docs-only.sh, both an outright usage abort and an unwritable $GITHUB_OUTPUT leave docs_only genuinely unset, which is exactly what run_full relies on.

Writing that second case turned up a false claim in the detector's own header: it said the script exits non-zero on an unwritable output file. It does not — emit() does not check its redirect and the script does not use set -e. The behavior is correct and the fail-closed guarantee is unaffected, because the guarantee rests on the flag being unset rather than on an exit status; only the comment was wrong. Since this change is precisely about replacing a load-bearing comment with something executable, shipping it next to a comment known to be false would be self-defeating, so the header is corrected here — the only edit to that file, and a comment-only one. The property it now describes is the one the new test asserts.

actionlint is also what caught the missing needs edges during development — it rejects needs.scope.* from a job with no such edge — so that class cannot reach a run silently.

What local runs cannot prove. GitHub expression evaluation and job-level needs semantics do not execute locally. That needs.scope.outputs.run_full renders as expected on a real pull-request event, on a push-to-main event where the detect step does not run, and on a genuinely docs-only diff is argued from the documented semantics and asserted structurally by the new gate — it is not observed until this PR's own CI run. This PR is not itself a docs-only diff, so its own run does not exercise the short-circuit branch either; that path is covered by the detector's suite and the gate's fixtures rather than by a live run. Reviewers should treat the first green run on this PR as the evidence for the run-full branch, and the fixtures as the evidence for the rest.

Independent review. Three fresh-context reviewers checked the change with the author's rationale withheld, on two different models to decorrelate blind spots: two successive passes on the new gate's parser soundness and test quality, and one on GitHub Actions semantics and behavioral equivalence.

The semantics review returned nothing blocking, and its evidence is worth repeating because it is stronger than a reading: per-job gate counts are byte-identical across the refactor (34 work gates before and after, 6 not-applicable reporters, 6 feed overrides), so no step gained or lost a gate. It also confirmed the one genuine behavior change is in the safe direction — a detector hard-exit used to red the owning job and now runs the full suite instead — and that every abnormal scope outcome resolves the merge gate red. Its two should-fix findings are addressed below and in the follow-up note.

Both gate reviews found the gate evadable, and both were right. The first draft could be satisfied by prose: a commented-out output expression standing in for the real one, a sibling step lending its continue-on-error to the detect step, zero references reading as "all references well formed". It was rewritten so that attribution happens during a single structural pass, consumer forms are whitelisted as whole shapes, and the liveness floor is explicit.

The second review then broke the rewrite, and found the deeper error. A whitelist that only fires on the spellings it recognizes does not reject the ones it does not — it cannot see them, which is the opposite of rejecting them. needs['scope']['outputs']['run_full'] and needs.SCOPE.outputs.run_full are both legal Actions, both resolve at runtime, and both were invisible: a lane could be switched to an inverted-polarity gate in either spelling and sail through while the innocent lanes kept the reference count healthy. Detection is now deliberately loose — any mention of the output, in any spelling or case — and only then held to the exact sanctioned shape, so an unmodelled spelling lands in the "not sanctioned" bucket instead of vanishing. The same review found a commented-out detector call satisfying the "the detector actually runs" property, an expression-valued continue-on-error evading the self-test guarantee, and two false positives that would have red-lined legitimate edits.

Every one of those cases is now a named test. That churn is the point of the exercise: a gate written to replace a load-bearing comment is worth nothing if it can be satisfied by a comment.

The semantics review then found two more, both now closed and both listed among the nine properties above: the feed-override check was validating only the template's shape and never that the step it names is actually gated, which is the fail-open direction; and the "no polarity to get wrong" claim was silently conditional on scope succeeding, since a consumer carrying if: always() would run with an empty output and skip every branch. Two comments that overstated what was mechanically pinned were corrected rather than left to be believed.

Related

Sequencing. #3158 (hoist fetch-depth into a checkout wrapper) touched the same file and landed first. This branch is rebased on it: the scope job uses the ./.github/actions/checkout-with-base composite that change introduced, rather than carrying its own fetch-depth. Nothing here re-litigates a checkout call site.

Left for a follow-up, deliberately — and it is worth someone's time. The six consumer lanes still run the Fetch base composite (git fetch --prune --unshallow --no-tags plus a base fetch) even though, after this change, the detector was their only consumer of the base ref. The semantics reviewer checked the four pure-build lanes independently and found no remaining need: plugins/knowledge/skills/video-digest/scripts/run-tests.sh, plugins/ai-briefing/skills/generate/scripts/run-tests.sh and plugins/knowledge/skills/course-digest/scripts/run-tests.sh contain zero git / origin/ / BASE_REF references, and miro's four npm scripts are tsc, vitest, biome and a bundle check.

That matters more than it looks: the unshallow is the expensive step in each of those lanes, so this refactor removed the cheap per-lane duplicate (a self-test and a detector call, seconds) and left the costly one, while adding one serialized job that pays the same unshallow up front. Dropping Fetch base — or passing fetch-base: 'false' — on those four lanes is what would pay for the new serialization. plugin-gate looks equally dead but spans the whole plugins/**/*.test.sh corpus and should be verified before touching. hygiene genuinely needs history, for the git-history secret scan among others.

None of that is done here: it changes checkout call sites, which is a different concern from the scope contract and squarely the surface #3158 just reworked. Filing it as its own item with the evidence above is the right next step.


Generated by Claude Code

@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 32s —— View job


Security review (/review:security-review)

  • Gather context (PR body, diff via gh pr diff, changed files)
  • Review .github/workflows/ci.yml changes (new scope / docs-only-gate jobs, needs edges, trigger/permissions)
  • Review scripts/check-docs-only-gate.sh (new, 673 lines) and its test suite
  • Review scripts/check-docs-only.sh header comment fix
  • Post findings

No security issues found.

This is a pure CI-orchestration refactor (hoisting a docs-only scope check into a shared job and adding a structural gate that asserts the contract). Checked specifically for the classes this lane owns:

  • Trigger / trust boundary: on: is still push (main only) + pull_request (not pull_request_target/workflow_run), and top-level permissions: contents: read is unchanged — the two new jobs (scope, docs-only-gate) declare no permissions: block of their own, so they inherit read-only. No privilege escalation or secret exposure introduced.
  • Script injection via the github context: the BASE_REF value (github.base_ref, attacker-influenceable branch name on a PR) is still passed through env: and referenced as "$BASE_REF" inside run:, exactly as before this PR — never interpolated directly into the run: template string. No new injection surface.
  • New gate script (scripts/check-docs-only-gate.sh): operates entirely on the repo's own checked-out ci.yml (not attacker-supplied input at runtime), uses awk/bash string ops with no eval, and quotes all variable expansions ("$WORKFLOW", etc.). It's read-only static analysis, not a runtime trust boundary.
  • Supply chain / unpinned actions / excessive permissions / template injection: out of scope for this lane per the skill's instructions — deferred to zizmor's advisory lane, which already runs on this workflow (zizmor.yml job, unaffected by this diff).
  • Fail-closed logic: the refactor's actual purpose (collapsing 48 inverse-polarity != 'true' checks into one job output run_full) is a correctness/architecture concern, not a security one — the PR's own extensive verification (46-case gate self-test, unchanged detector behavior, independent semantics review) already covers whether the fail-closed guarantee holds. Nothing here weakens it in a way that crosses into a security finding (e.g., no path where a docs-only diff can widen what a lane trusts or run untrusted code with elevated permissions).

No CRITICAL / IMPORTANT / SUGGESTION findings to report.

@github-actions

Copy link
Copy Markdown
Contributor

Last security-reviewed head: 66e100acdf90dce36b39c051f9accae14970ad0c. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66e100acdf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/check-docs-only-gate.sh
Comment thread scripts/check-docs-only-gate.sh
@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task

  • Gather context (PR body, CLAUDE.md, changed files)
  • Load /review:code-review skill instructions
  • Review .github/workflows/ci.yml diff
  • Review scripts/check-docs-only-gate.sh (new)
  • Review scripts/check-docs-only-gate.test.sh (new)
  • Review scripts/check-docs-only.sh diff
  • Post findings

View job run

kyle-sexton and others added 2 commits August 23, 2026 22:13
The docs-only short-circuit let six jobs each run scripts/check-docs-only.sh
as their own `id: scope` step and gate their inner work on
`steps.scope.outputs.docs_only != 'true'`. That put the contract at 48
references in one file, every one of them an inverse-polarity test against a
string flag. Each consumer had to independently know that the value is the
string `true`, that the safe direction is to RUN the suite, and that the
negation must be spelled that exact way. Nothing executable held that
knowledge; a prose comment did.

The asymmetry is what made it worth fixing. A consumer that wrote
`== 'false'` where it meant `!= 'true'` would skip its lane whenever the
detector emitted anything unexpected — a lane reporting success without
running, which is the false-green shape docs/conventions/liveness-assertion/
names. A consumer that forgot the gate entirely would merely run always.

A new `scope` job now runs the detector once and publishes `run_full`. The
output is positive on purpose: a consumer writes `run_full == 'true'` to do
the work and `run_full == 'false'` to report it not applicable. Both are
plain equality against a value that `${{ <expr> != 'true' }}` can render only
as `true` or `false`, so the two forms are exact complements and a new lane
has no negation to spell wrong.

The fail-safe direction is preserved. The detector already emitted
`docs_only=false` for every condition it could resolve but not classify, and
follows every emit with an immediate `exit 0`, so a run that cannot complete
leaves the flag unset. `run_full` renders an unset flag as `true`, and the
full suite runs; the same path covers push events, where the detect step does
not run at all. `continue-on-error` on that step contributes the other half —
it keeps a failed detector from failing the resolving job and skipping every
consumer. The self-test is deliberately NOT `continue-on-error`, so a
detector nobody verified still turns the lane red.

scripts/check-docs-only-gate.sh asserts nine properties against ci.yml rather
than restating them in a comment: the detector is resolved exactly once; the
published expression is exactly the fail-closed one; the step that absorbs
failure is the step that actually invokes the detector; the self-test is
unweakened; every consumer reference is one of two whole sanctioned shapes;
every aggregator-feed override names a step that actually carries the gate;
no consumer carries a job-level condition; every reader declares the `needs`
edge; and at least one consumer actually reads the output.

The last three are the ones that are easy to miss. An override on an UNGATED
step would replace a real failure with `success` on every docs-only diff —
forgetting an override is fail-closed, adding an unpaired one is not. A
job-level `if: always()` on a consumer lets it run when the resolver did not
succeed, where the output is empty, both sanctioned forms are false, and
every gated step skips alongside its own not-applicable reporter: green
having run nothing. A missing `needs` edge produces the same empty output by
a different route.

The gate is deliberately hostile to being satisfied by anything other than
the real thing. Comments never stand in for the expressions they quote.
Block-scalar bodies are read for content but never for structure, so a `run:`
script can neither masquerade as a step condition nor hide a second
resolution. References are detected loosely — any mention of the output, in
any spelling or case — and only then held to the exact sanctioned shape, so a
spelling the gate does not model reads as unsanctioned rather than becoming
invisible to it. Zero references is a defect, not a vacuous pass. Any shape
it cannot parse exits 2 rather than 0.

Its suite breaks each property in turn and requires the gate to name it, and
proves against the real detector that both an outright abort and an
unwritable output file leave the flag unset — the half of the guarantee that
lives below the workflow.

Writing that last case turned up a false sentence in the detector's own
header, which claimed a non-zero exit on an unwritable output file. The
behavior is correct and the guarantee is unaffected — it rests on the flag
being unset, not on an exit status — but a change whose whole subject is
replacing a load-bearing comment with something executable should not ship
beside a comment known to be false, so that header is corrected here. It is
the only edit to that file and it changes no behavior.

Behavior is unchanged in which jobs run and which steps they execute. The
aggregator is untouched: the intentional docs-only step skip is still mapped
to `success` in the CHECK_RESULTS feed, now keyed on the same single output
as the gates it mirrors.

Refs #2914

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtbWChCVfUWAv1Pi5Qk2hA
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot force-pushed the refactor/3159-hoist-docs-only-scope branch from 66e100a to 4607369 Compare August 23, 2026 22:13
@kyle-sexton
kyle-sexton enabled auto-merge (squash) August 23, 2026 22:21
@kyle-sexton
kyle-sexton merged commit 8f2b45c into main Aug 23, 2026
53 checks passed
@kyle-sexton
kyle-sexton deleted the refactor/3159-hoist-docs-only-scope branch August 23, 2026 22:23
@claude

claude Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cursor[bot]'s task in 3m 22s —— View job


Code review (/review:code-review)

  • Gather context (PR body, diff via gh pr diff / git diff origin/main...HEAD, changed files)
  • Review .github/workflows/ci.yml diff (new scope / docs-only-gate jobs, needs edges)
  • Review scripts/check-docs-only-gate.sh (new, 677 lines)
  • Review scripts/check-docs-only-gate.test.sh (new, 452 lines)
  • Review scripts/check-docs-only.sh diff (header comment fix)
  • Cross-check the two Codex findings already posted
  • Post findings

Summary

This is a well-scoped, heavily self-verified refactor — the run_full contract (single resolution, fail-closed default, no negation for a new lane to misspell) is sound, and the new gate correctly catches the classes of drift its header claims (comment-as-structure, job-level if:, missing needs, un-sanctioned spellings, zero-reference vacuity).

One confirmed finding, posted inline on scripts/check-docs-only-gate.sh:556:

  • Codex's two prior P2 findings were checked against the current HEAD (4607369b), not just the commit (66e100a) they were originally posted against:
    • "Count detector invocations instead of invoking jobs" — already fixed. The gate now tracks invoke_count separately from invoker_count (lines 434-449) and has a dedicated test case (check-docs-only-gate.test.sh:183, "a second invocation in the resolver job is a second resolution"). No longer an issue.
    • "Reject skip polarity on work steps" — still valid at current HEAD, and I'm re-confirming it. The stepif branch (§5, "ONE CONSUMER FORM") accepts WORK_FORM and SKIP_FORM as equally sanctioned syntax for any step; only §5b ("THE FEED MIRRORS A REAL GATE") ties a step's identity to which form it must carry, and that only fires for steps with a CHECK_RESULTS feed entry (i.e. hygiene's steps). Jobs without a feed at all — plugin-gate, miro-plugin, video-extraction, ai-briefing-build, course-digest-extraction — have no such cross-check, so flipping one of their real work-step conditions from == 'true' to == 'false' (e.g. plugin-gate's "Set up Node", ci.yml:1115) still passes --check. This is a gap in what the gate proves (correct-shape, not correct-polarity-per-step), not a flaw in the core contract the PR set out to fix. Details and a concrete repro are in the inline comment.

No other correctness or maintainability issues found. scripts/check-docs-only.sh's header fix (the unwritable-$GITHUB_OUTPUT claim) reads accurately against the actual emit() implementation, and the aggregator feed / ci-status wiring in ci.yml is unchanged in shape aside from the needs.scope swap.

Note: this lane's Bash execution is sandboxed to a narrow allowlist here (git/read-only commands), so the polarity-flip repro above was verified by tracing the parser logic and the test suite's coverage rather than by re-running --check against a mutated fixture live in this session.


case "$kind" in
stepif)
if [[ "$bare" != "$WORK_FORM" && "$bare" != "$SKIP_FORM" ]]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirming Codex's P2 finding here (originally posted against 66e100a; still applies at the current HEAD 4607369, so I'm re-anchoring it to today's line numbers).

The stepif branch treats WORK_FORM (== 'true') and SKIP_FORM (== 'false') as equally sanctioned for any step — it has no notion of which of the two a given step is supposed to be. The only place that distinction gets checked is §5b (THE FEED MIRRORS A REAL GATE, lines 591-615), and that only fires for steps that also have a CHECK_RESULTS feed entry — i.e. hygiene's steps. For jobs with no feed at all (plugin-gate, miro-plugin, video-extraction, ai-briefing-build, course-digest-extraction), a step that's supposed to run the real work (if: ... == 'true') can be silently flipped to == 'false' and --check still exits 0, because SKIP_FORM is unconditionally accepted as sanctioned syntax regardless of which step carries it.

I verified this by reading the parser rather than re-running the harness (this lane's Bash execution is sandboxed to a narrow allowlist that doesn't include ad-hoc script execution here), but the logic is unambiguous: nothing outside §5b ties a step's identity to which of the two forms it's allowed to carry, and §5b's gated_ordinals is only populated from WORK_FORM matches, so a step that got flipped away from WORK_FORM simply drops out of that set rather than tripping a defect.

Concretely: flip plugin-gate's "Set up Node" step (line 1115) from run_full == 'true' to run_full == 'false' and check-docs-only-gate.sh --check should still report the contract satisfied, while the lane now only sets up Node on docs-only diffs — the inverse of what the step needs.

This doesn't undermine the PR's core claim (the contract — one resolution, fail-closed default, no negation to misspell — is real and well-verified), but it's a gap in what the gate can prove: it verifies syntactic conformance to one of two legal shapes, not that a given step picked the correct one of the two. Worth either a follow-up gate property (e.g., a per-job invariant that at least one WORK_FORM step exists whenever the job also has a SKIP_FORM reporter, or an explicit reporter/work-step naming convention the gate can key on) or an explicit call-out in the header's "WHAT IS CHECKED" list that this class of error is out of scope.

@github-actions

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

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.

ci: hoist docs_only scope resolution into one job instead of 46 references

1 participant