perf(ci): short-circuit plugin-gate and miro-plugin on docs-only diffs (#617) - #622
Conversation
#617) Docs-only PRs (operator precedent codifications and other docs/topics/ churn) ran the full plugin contract suite and the miro Node build for nothing. Both lanes now consult a docs-only detector and short-circuit their expensive steps when a PR's diff is confined to a small, positive, provably-inert allowlist, reporting an honest evaluated-and-not-applicable success. The job never skips: a skipped required lane's result is not `success` (the ci-status aggregate rejects it) and a workflow skipped by a path filter leaves its required check Pending. Only the inner install/test steps are gated; the detector self-test runs unconditionally so a broken detector cannot mask a regression; detection is fail-closed toward running the full suite. The allowlist (scripts/docs-only-paths.txt) is positive, not a blocklist, so a new code input lands outside it and forces the full suite. It is seeded with docs/topics/ only — README.md, docs/PLUGIN-ARTIFACT-PROTOCOL.md, and docs/CATALOG-TAXONOMY.md are consumed by plugin-gate and are deliberately NOT docs-only, pinned by the self-test. Closes #617 🤖 Generated with a Claude Code implementation subagent (issue #617) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 34s —— View job PR Review —
|
…y diffs (#628) (#659) ## Summary Docs-only PRs still paid the `hygiene` job's path-scoped linter cost — `actionlint` and four `check-jsonschema` steps — for a diff that provably cannot affect any of them. #617 (via #622) short-circuited `plugin-gate` and `miro-plugin`, which cut runner-minutes but not the merge-latency bottleneck. This applies the SAME self-test-first, fail-closed, inner-step-gated discipline to `hygiene`'s genuinely path-scoped subset, so those runs report an honest evaluated-and-not-applicable success instead of running. `ShellCheck` and `exec-bit` are deliberately NOT gated: both scan the whole repo extension- and directory-agnostically (`ShellCheck` lints every tracked `*.sh`/`*.bash` via `git ls-files`; `exec-bit` flags every tracked shebang file recorded `100644` via `git grep -- .`), so a shell or shebang file added under an otherwise docs-only prefix like `docs/topics/` is real input they must still catch. They run on every diff alongside the docs-linting checks that read the changed docs directly: `markdown`, `typos`, `editorconfig`, `gitleaks`, `eol-renormalize`, `comment-hygiene`, `machine-specific-paths`. > **Scope of the perf win (honest):** the initial revision of this PR also gated `ShellCheck` (~53s, the dominant cost) and `exec-bit`. Bot review (P2, thread on `ci.yml`) surfaced that both are whole-repo scanners, so gating them opened a **fail-closed hole** — a docs-only PR adding a mode-`100644` shebang file (or a `*.sh`) under `docs/topics/` skipped both and the ternary mapped them to `success`, letting a file the commit procedure requires `exec-bit` to catch merge unlinted. Ungating them (commit `f73be27`) closes the hole but removes the dominant ShellCheck skip, so the remaining docs-only win is limited to `actionlint` + the four `check-jsonschema` steps. A perf-recovery follow-up (cache/pin the ShellCheck install, which downloads a release tarball every run) is tracked separately and lives in `melodic-software/ci-workflows`, not this repo. ## Fix Reuses the existing `scripts/check-docs-only.sh` detector and `scripts/docs-only-paths.txt` allowlist from #622 unchanged — no new detector. In the `hygiene` job (`.github/workflows/ci.yml`): - Added the always-run `Test the docs-only detector` self-test and a PR-only `Detect a docs-only diff` step (`id: scope`), mirroring `plugin-gate`/`miro-plugin`. The checkout already had `fetch-depth: 0`, so the base ref resolves. - Gated exactly the five path-scoped steps with `if: steps.scope.outputs.docs_only != 'true'`: `actionlint`, `marketplace_schema`, `plugin_schema`, `dependabot_schema`, `workflow_schema`. `shellcheck` and `exec_bit` stay unconditional. - The job never skips and the aggregator is never weakened. The `hygiene` job uses `continue-on-error: true` per step plus a `CHECK_RESULTS` feed into `scripts/aggregate-hygiene-results.sh`, which fails closed on any non-`success` outcome (including `skipped`). A gated step that skips would therefore red the job — so each gated entry in `CHECK_RESULTS` maps the intentional docs-only skip to `success` in the expression layer: ```yaml actionlint=${{ steps.scope.outputs.docs_only == 'true' && 'success' || steps.actionlint.outcome }} ``` This is honest, not a swallow: the ternary condition (`docs_only == 'true'`) is the exact inverse of each step's gate, so `success` is fed ONLY for a step that provably did not run — no real outcome is ever masked. The two whole-repo scanners (`shellcheck`, `exec-bit`) feed their raw `.outcome`. The aggregator script is untouched and still fails closed on any real `failure` (or a `skipped` it isn't told to tolerate). This is the same evaluated-and-not-applicable success `plugin-gate` reports with its log step, expressed here through the feed the hygiene job already uses. - Added an explicit `Report docs-irrelevant checks not applicable` log step (gated on `docs_only == 'true'`) so a human reading a green run understands why those checks show skipped. Also extended the two non-workflow artifacts per the established two-part-proof discipline: - `scripts/docs-only-paths.txt` — updated the inertness-proof comment to name only the genuinely path-scoped gated subset (`actionlint` + the four schema checks, none of which read `docs/topics/`) and to state explicitly that `ShellCheck` and `exec-bit` scan the whole repo and stay unconditional (as do the docs-linters). Allowlist itself unchanged (still `docs/topics/` only — no growth). - `scripts/check-docs-only.test.sh` — added three cases pinning the remaining `check-jsonschema` inputs to `false` (`.claude-plugin/marketplace.json`, `plugins/*/.claude-plugin/plugin.json`, `.github/dependabot.yml`); the `actionlint` / workflow-schema inputs (`.github/workflows/ci.yml`) were already pinned false. These fail the moment someone widens the allowlist to cover an input a gated check reads. ## Verification Run in the worktree (Git Bash): - **`bash scripts/check-docs-only.test.sh` → `PASS=19 FAIL=0`.** Confirms `docs/topics/`-only → `true` and every gated check's input (`.github/workflows/ci.yml`, `.claude-plugin/marketplace.json`, `plugins/*/.claude-plugin/plugin.json`, `.github/dependabot.yml`, lockfiles) → `false`. - **`scripts/aggregate-hygiene-results.sh --self-test` → `Hygiene result aggregation contract passed.`** The aggregator is unchanged and still fails closed on a named empty/`failure`/non-`success` outcome. - **`actionlint .github/workflows/ci.yml` → clean.** Validates the five added gate conditions and the five ternary expressions in `CHECK_RESULTS`. - **`shellcheck --rcfile .shellcheckrc` and `shfmt -d`** on the edited/reused scripts → clean, no diff. Honest scope of what this PR's own CI proves: this PR touches `.github/` and `scripts/`, so on it `docs_only=false` and the full hygiene suite runs — a live proof only of the run-path (nothing is wrongly skipped on a code diff). The skip-path is not live-observable here by construction; it is proven by the detector unit test (`docs/topics/`-only → `true`) plus the gate/expression logic being the exact inverse of each other. Correctness of the intentional-skip → `success` mapping rests on that unit test and the expression, not on a fabricated observation. **Versioning/changelog:** none. No `plugins/<name>/` directory is touched — this is a repo-root CI-only change — so per the precedent #622 followed (matching CI-only #490/#593) there is no plugin version bump or CHANGELOG entry. Closes #628 ## Related - #628 — this issue. - #617 (closed) / #622 (merged) — the established precedent this extends: same `scripts/check-docs-only.sh` detector, same self-test-first / fail-closed / inner-step-gated discipline, applied to `hygiene` instead of `plugin-gate`/`miro-plugin`. - #532 — the false-green / liveness class this must not reintroduce. A job-level skip (or teaching the aggregator to pass on `skipped`) is exactly that shape and is deliberately avoided: the job always runs, the detector self-tests first, and only inner steps are gated with their skip reported as an honest not-applicable success. 🤖 Generated with a Claude Code implementation subagent (issue #628) --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
## Summary Two repo-local CI gates from the 2026-07-20 merged-PR quality audit, both wired into `.github/workflows/ci.yml` as new required lanes. Each follows the established repo-local gate precedent (#593/#609/#622/#628): a self-testable script, a dedicated CI step, fail-closed behavior, and clear diagnostics — with a stale-guarded baseline that grandfathers pre-existing debt without red-lining fixtures/plugins a member issue already owns. Placement is repo-local only; promotion to `ci-workflows` is explicitly deferred ("if it stabilizes") and out of scope. No plugin version bump: the CI scripts and `ci.yml` carry no plugin version to bump. The only `CHANGELOG.md` edits in this PR are a format-only normalization of five pre-existing unbracketed changelogs (see Gate 2) — no version fields change. This repo has no repo-root version artifact. ## Fix **Gate 1 — orphaned-fixture (`scripts/check-orphaned-fixtures.sh`, lane `orphaned-fixture-gate`).** Repo-wide static scan of every file under a skill's `**/evals/fixtures/`. A fixture is *consumed* when its skill-relative path appears in the sibling `evals/evals.json` (an eval `files[]` entry), or its basename appears — **bounded by non-filename characters, so a referenced `valid.json` does not also mark a `valid.json.bak` sibling consumed** — in that `evals.json` or any `*.test.*` file in the plugin. Consumption matching errs toward not-blocking a legitimate fixture. Scope is `evals/fixtures/` specifically — unit-test fixture dirs (`tests/fixtures`, `scripts/fixtures`) are excluded because those are often generated or loaded by directory, not named, and a name-matcher cannot honestly grade them. `--check` fails on an un-grandfathered orphan **and** on a stale baseline entry (one shadowing no orphan), so the baseline cannot outlive its debt. Existing debt grandfathered in `scripts/orphaned-fixtures-baseline.txt` — **exact fixture paths, matched by full-string equality (not prefix)**, so the baseline is a snapshot of today's known orphans: a new orphan under an already-listed directory, or a `<name>.json.bak`/`<name>.jsonl` sibling of a listed file, matches no line and is red-lined rather than silently grandfathered. Every entry cites an owning issue: - The `plugins/autonomy/skills/setup/evals/fixtures/` corpus (security-binding golden suite + `otlp-demo` sample corpus), enumerated file-by-file — tracked by #662; autonomy is WP-lane-owned. - `plugins/knowledge/skills/youtube-digest/evals/fixtures/variation-matrix-backlog.json` — a data file named only from `SKILL.md`/vendor prose, consumed by no grader — tracked by #688 (grade-or-demote). **Gate 2 — CHANGELOG-parity (`scripts/check-changelog-parity.sh`, lane `changelog-parity-gate`).** Covers both gaps the audit named: - `--check` (static, every event): a `plugins/<name>/.claude-plugin/plugin.json` carrying a `version` must ship `plugins/<name>/CHANGELOG.md`. Catches the cited violator — `autonomy` shipped 5 minor bumps with no CHANGELOG.md. - `--check-bump <base>` (PR-only, mirrors the `sync-*.sh --check-bump` gates): a manifest version change must **add** a `## [<version>]` release heading for the new version — present at head, absent at `<base>`. The heading is matched as a fixed string anchored to line start, so the version appearing in prose or a fenced example never satisfies (or falsely pre-exists) the entry, and SemVer build metadata (`1.0.1+build.1`) never leaks into a regex. Three distinct failures, never conflated: `UNDOCUMENTED BUMP` (no entry — an unrelated whitespace/title/old-release edit cannot satisfy the gate), `CHANGELOG FORMAT` (the version is present but written unbracketed, `## <version>`), and `PRE-EXISTING CHANGELOG ENTRY` (the heading already existed at `<base>`, so the bump shipped no fresh note). Applies to **every** plugin, grandfathered or not. Enforcing the bracketed `## [<version>]` Keep-a-Changelog heading surfaced five pre-existing changelogs still on the unbracketed `## <version>` form (`discovery`, `docs-hygiene`, `knowledge`, `playbooks`, `session-flow`); this PR normalizes them to the documented convention (format-only, headings only — no version or entry-content changes). Reconstructing `autonomy`'s history is autonomy-plugin (WP-lane) work, so it is grandfathered by name in `scripts/changelog-parity-baseline.txt` (stale-guarded: `--check` fails if it later gains a CHANGELOG.md or drops its version). The baseline never relaxes `--check-bump`. Both lanes are registered in the `ci-status` `needs:` aggregate (the single source of truth for required lanes). ## Verification All output below is real, run in the worktree against `origin/main`. **Self-tests (run unconditionally in CI so a broken detector cannot mask a regression):** ``` =========== ORPHAN GATE SELF-TEST =========== ok: files[]-referenced fixture passes --check ok: test-asserted fixture passes --check ok: suffix sibling of a referenced fixture red-lines (bounded basename match) ok: un-consumed fixture fails --check (synthetic orphan caught) ok: grandfathered orphan (exact path) passes --check ok: prefix-sibling of a baselined path red-lines (exact-match, no grandfather leak) ok: stale baseline entry fails --check ok: discover labels CONSUMED and ORPHAN ok: bad mode -> exit 2 PASS=9 FAIL=0 =========== CHANGELOG GATE SELF-TEST =========== ok: versioned plugin with CHANGELOG passes --check ok: versioned plugin without CHANGELOG fails --check (synthetic gap caught) ok: grandfathered missing-changelog passes --check ok: stale baseline entry fails --check (reported exactly once) ok: bump + '## [x.y.z]' entry passes --check-bump ok: bump + unbracketed heading -> FORMAT error (not UNDOCUMENTED) ok: bump adding a NEW '## [x.y.z]' entry (absent at base) passes --check-bump ok: bump reusing a base-pre-existing '## [x.y.z]' entry fails --check-bump ok: SemVer build-metadata version with a proper entry passes (no regex leak) ok: version string in prose/indented example does not satisfy the anchored heading match ok: bump + unrelated changelog edit (no new-version entry) fails --check-bump ok: bump without changelog fails --check-bump (synthetic undocumented bump caught) ok: unchanged version passes --check-bump ok: new plugin skipped by --check-bump ok: unresolvable base ref -> exit 2 ok: --check-bump without base ref -> exit 2 ok: bad mode -> exit 2 PASS=17 FAIL=0 ``` **Both gates pass on current HEAD (existing debt grandfathered):** ``` --- orphaned-fixture-gate: --check --- No orphaned eval fixtures (every file under **/evals/fixtures/ is consumed by a grader or grandfathered). --- changelog-parity-gate: --check --- Every versioned plugin has a CHANGELOG.md (or a stale-guarded baseline entry). --- changelog-parity-gate: --check-bump vs origin/main --- Every plugin whose version changed vs origin/main has a '## [<version>]' CHANGELOG.md entry. ``` **Live synthetic catch — Gate 1 (orphaned fixture, no baseline):** ``` ORPHANED FIXTURE: plugins/demo/skills/s/evals/fixtures/never-referenced.json is under evals/fixtures/ but no eval case references it and no test asserts on it. Reference it from a grader (an eval files[] entry or a test), delete it, or grandfather it in /nonexistent with the owning issue. exit=1 ``` **Live synthetic catch — Gate 2 (missing changelog, no baseline):** ``` MISSING CHANGELOG: plugins/demo carries a versioned plugins/demo/.claude-plugin/plugin.json but no plugins/demo/CHANGELOG.md. Add plugins/demo/CHANGELOG.md, or grandfather 'demo' in /nonexistent with its owning issue. exit=1 ``` **Static analysis:** `shellcheck --rcfile .shellcheckrc` clean on all four new scripts; `actionlint .github/workflows/ci.yml` clean; new `.sh` files committed mode 100755, all files LF. Closes #663 ## Related - #663 — this issue. - #662 — sibling autonomy issue owning the security-binding orphan burn-down (grandfathered by Gate 1's baseline). - #688 — grade-or-demote triage for the `knowledge` youtube-digest orphan (grandfathered by Gate 1's baseline). - #593 / #609 / #622 / #628 — the repo-local gate precedent (self-testable script + dedicated `ci.yml` lane + fail-closed) this follows structurally. 🤖 Generated with a Claude Code implementation subagent (issue #663) --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Closes #617
Summary
Docs-only PRs — operator precedent codifications and other
docs/topics/churn, a large share of current throughput per #617 — ran the full plugin contract suite (plugin-gate: Node + Python installs, everyplugins/**/*.test.sh, manifest + catalog validation) and the fullmiro-pluginNode build for a diff that cannot affect either. Both lanes now short-circuit their expensive steps when the diff is confined to a small, positive, provably-inert allowlist, reporting an honest evaluated-and-not-applicable success.Fix
Mechanism — in-job detection, never a job skip. GitHub's docs are explicit: "If a workflow is skipped due to path filtering, branch filtering or a commit message, then checks associated with that workflow will remain in a 'Pending' state. A pull request that requires those checks to be successful will be blocked from merging." whereas "If, however, a job within a workflow is skipped due to a conditional, it will report its status as 'Success.'" (troubleshooting-required-status-checks). So workflow-level
paths:/paths-ignore:is ruled out for required checks. It is doubly ruled out here: the sole required check is theci-statusaggregate, which fails unless every lane'sresultis exactlysuccess— a conditionally-skipped job's result isskipped, notsuccess, so even a job-levelif:would red the aggregate. The safe pattern is therefore the one #593/#609 already use: the job always runs, self-tests its detector first, and gates only its inner expensive steps, stayingsuccessand reporting to branch protection either way.New files:
scripts/check-docs-only.sh— emitsdocs_only=true|falseto$GITHUB_OUTPUT;trueiff every path changed vs the base ref matches an allowlist prefix. Fail-closed toward running the full suite: an unresolvable base ref, an empty/missing allowlist, or an empty diff all emitfalsewith a stderr note and exit 0 (a code PR is not an error — "has code" is never encoded as a non-zero exit that would red every code PR).scripts/docs-only-paths.txt— the allowlist as external, extensible data (same shape as feat(ci): add portability-lint gate for ecosystem/forge-agnostic skills (#531) #609's token list). Allowlist, not blocklist, by design: a blocklist ("skip on any doc the validators don't currently read") silently turns false-green the day a validator starts reading a new doc; a positive list fails safe — any unlisted path, including a brand-new code input, forces the full suite. Seeded withdocs/topics/only. Adding an entry is a two-part proof (inert + a self-test case).scripts/check-docs-only.test.sh— the honesty proof, run unconditionally in both wrapped jobs before anything expensive.In
ci.yml,plugin-gateandmiro-plugineach gain:fetch-depth: 0, an always-run detector self-test, a PR-only detect step (id: scope), anif: steps.scope.outputs.docs_only != 'true'guard on every install/test step, and an explicit "not applicable to a docs-only diff — reporting success" log step. Onpushthe detect step doesn't run, the output is empty, and every real step runs — main always gets the full suite.Job classification (only jobs that cannot be affected by a docs-only diff are scoped; when unsure, not scoped):
plugin-gaterun-plugin-tests.shis hermetic (tests build their own fixture repos);validate-plugins.shreads onlyREADME.md,docs/PLUGIN-ARTIFACT-PROTOCOL.md,docs/CATALOG-TAXONOMY.md(all excluded from the allowlist) +plugins/**+ toolchain files. Adocs/topics/-only diff touches none of these.miro-pluginworking-directory: plugins/miro; a docs-only diff provably cannot touch it.hygieneskill-quality-gatezizmorci-workflows.hook-utils-sync,standards-contract-sync,cross-plugin-source-drift,silent-skip-gate,runner-policyDeferred with trigger: scoping
miro-plugintoplugins/miro/**specifically (skipping it on any non-miro diff, not just docs-only) is a larger, separate optimization broader than #617's docs-only framing; trigger: a dedicated follow-up issue for per-lane path scoping.Verification
In the worktree (
ubuntu/Git Bash):bash scripts/check-docs-only.test.sh→PASS=16 FAIL=0. Cases pin:docs/topics/-only (incl. new nested files) →true;README.md,docs/PLUGIN-ARTIFACT-PROTOCOL.md,docs/CATALOG-TAXONOMY.md→false(the grep payoff — these are consumed byplugin-gate); any non-topicsdocs/**, anyplugins/**incl.SKILL.md,scripts/,.github/, toolchain lockfiles, a sibling prefix (docs/topics-archive/), and mixed docs+code →false; unresolvable base ref →falseexit 0; empty allowlist →false;$GITHUB_OUTPUTwritten.shellcheck --rcfile .shellcheckrcon both scripts → clean.shfmt -d→ no diff.actionlint .github/workflows/ci.yml→ clean.git grepoverplugins/**/*.test.sh,validate-plugin-contracts.mjs,generate-catalog.mjs, and the manifests confirmedgenerate-catalog.mjs --checkconsumesREADME.mdanddocs/CATALOG-TAXONOMY.mdandvalidate-plugin-contracts.mjsrequiresdocs/PLUGIN-ARTIFACT-PROTOCOL.md— which is exactly why those are excluded from the allowlist and the docs-only win isdocs/topics/-scoped, not "all markdown".env: BASE_REF(no${{ }}interpolation insiderun:), matching the existinghook-utils-sync/skill-quality-gatesteps;persist-credentials: falseretained; no new permissions.Honest scope note: the win is narrow by construction — it covers
docs/topics/-only PRs and deliberately does not cover README/philosophy edits,SKILL.mdedits (code), or plugin-CHANGELOGedits (code). Narrow-but-sound is the point.Related
skill-quality-gatewhose never-skip, self-test-first, event-gated job shape this mirrors.ci.yml(adds theportability-lintlane + one line to theci-statusneeds list). Different file regions from this PR (new job block + needs line vs. theplugin-gate/miro-pluginstep bodies); composes without conflict. Whichever merges second rebases.plugins/<name>/directory is touched, so no version bump / CHANGELOG entry (matching CI-only precedent fix(repo): add pull_request_template scaffolding ## Related contract #490/feat(ci): add skill-quality static gate lane for changed skills (#530) #593).