Skip to content

perf(ci): short-circuit plugin-gate and miro-plugin on docs-only diffs (#617) - #622

Merged
kyle-sexton merged 1 commit into
mainfrom
perf/617-scope-heavy-ci-docs-only
Jul 20, 2026
Merged

perf(ci): short-circuit plugin-gate and miro-plugin on docs-only diffs (#617)#622
kyle-sexton merged 1 commit into
mainfrom
perf/617-scope-heavy-ci-docs-only

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

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, every plugins/**/*.test.sh, manifest + catalog validation) and the full miro-plugin Node 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 the ci-status aggregate, which fails unless every lane's result is exactly success — a conditionally-skipped job's result is skipped, not success, so even a job-level if: 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, staying success and reporting to branch protection either way.

New files:

  • scripts/check-docs-only.sh — emits docs_only=true|false to $GITHUB_OUTPUT; true iff 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 emit false with 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 with docs/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-gate and miro-plugin each gain: fetch-depth: 0, an always-run detector self-test, a PR-only detect step (id: scope), an if: 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. On push the 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):

Lane Scoped? Rationale
plugin-gate Yes Heaviest lane. run-plugin-tests.sh is hermetic (tests build their own fixture repos); validate-plugins.sh reads only README.md, docs/PLUGIN-ARTIFACT-PROTOCOL.md, docs/CATALOG-TAXONOMY.md (all excluded from the allowlist) + plugins/** + toolchain files. A docs/topics/-only diff touches none of these.
miro-plugin Yes Every step is working-directory: plugins/miro; a docs-only diff provably cannot touch it.
hygiene No Markdownlint / typos / comment-hygiene do read the changed docs — this is the lane that should run on a docs PR.
skill-quality-gate No Already self-scopes to changed skills (empty set on a non-skill docs diff); eval-schema step is cheap.
zizmor No Advisory reusable workflow; scoping a reusable call partly belongs to ci-workflows.
hook-utils-sync, standards-contract-sync, cross-plugin-source-drift, silent-skip-gate, runner-policy No (deferred) Provably docs-inert, but off the critical path (fast bash tests, no heavy installs) — wrapping them adds surface for negligible latency gain. Trigger to revisit: if any becomes install-bearing.

Deferred with trigger: scoping miro-plugin to plugins/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.shPASS=16 FAIL=0. Cases pin: docs/topics/-only (incl. new nested files) → true; README.md, docs/PLUGIN-ARTIFACT-PROTOCOL.md, docs/CATALOG-TAXONOMY.mdfalse (the grep payoff — these are consumed by plugin-gate); any non-topics docs/**, any plugins/** incl. SKILL.md, scripts/, .github/, toolchain lockfiles, a sibling prefix (docs/topics-archive/), and mixed docs+code → false; unresolvable base ref → false exit 0; empty allowlist → false; $GITHUB_OUTPUT written.
  • shellcheck --rcfile .shellcheckrc on both scripts → clean. shfmt -d → no diff. actionlint .github/workflows/ci.yml → clean.
  • Provenance of the "inert" claim: git grep over plugins/**/*.test.sh, validate-plugin-contracts.mjs, generate-catalog.mjs, and the manifests confirmed generate-catalog.mjs --check consumes README.md and docs/CATALOG-TAXONOMY.md and validate-plugin-contracts.mjs requires docs/PLUGIN-ARTIFACT-PROTOCOL.md — which is exactly why those are excluded from the allowlist and the docs-only win is docs/topics/-scoped, not "all markdown".
  • zizmor: base ref flows via env: BASE_REF (no ${{ }} interpolation inside run:), matching the existing hook-utils-sync/skill-quality-gate steps; persist-credentials: false retained; 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.md edits (code), or plugin-CHANGELOG edits (code). Narrow-but-sound is the point.

Related

#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

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 34s —— View job


PR Review — perf(ci): short-circuit plugin-gate and miro-plugin on docs-only diffs

  • Gather context and understand request
  • Read changed files
  • Review scripts/check-docs-only.sh
  • Review scripts/check-docs-only.test.sh
  • Review scripts/docs-only-paths.txt
  • Review .github/workflows/ci.yml changes
  • Post final review

Summary

The design is sound and the implementation is careful. The allowlist-not-blocklist safety property, fail-closed semantics, unconditional self-test, PR-only detect step, and env:-based injection safety all match the stated rationale and the patterns already established by #593/#609. Two findings below — one minor gap worth fixing before merge, one observation with no action needed.


Finding 1 — Minor gap: "missing allowlist file" path is not test-covered

scripts/check-docs-only.test.sh covers empty allowlist (lines 107–118) but not a non-existent allowlist file. The two code paths are different:

# path 1 — file missing (L42-46 of check-docs-only.sh)
if [[ ! -f "$ALLOWLIST" ]]; then  emit false; exit 0; fi

# path 2 — file exists but empty (L57-61)
if [[ ${#prefixes[@]} -eq 0 ]]; then  emit false; exit 0; fi

The missing-file path is two lines and trivial, but it is the one that fires when DOCS_ONLY_ALLOWLIST is accidentally set to a wrong path. Given the PR's own two-part proof contract, this deserves a test case alongside the empty-allowlist one:

repo="$(mk_repo)"
base="$(git -C "$repo" rev-parse HEAD)"
printf 'changed\n' >"$repo/docs/topics/example/PLAN.md"
git -C "$repo" add -A >/dev/null && git -C "$repo" commit -qm change >/dev/null
out="$(cd "$repo" && DOCS_ONLY_ALLOWLIST=no-such-file.txt bash scripts/check-docs-only.sh "$base" 2>/dev/null)"
if [[ "$out" == "docs_only=false" ]]; then
  ok "missing allowlist file -> false (fail-closed)"
else
  fail "missing allowlist: expected docs_only=false, got '$out'"
fi
rm -rf "$repo"

Fix this →


Finding 2 — Observation: glob metacharacters in allowlist entries (no action needed)

In check-docs-only.sh line 74:

if [[ "$path" == "$prefix"* ]]; then

"$prefix" is quoted, so glob metacharacters inside the prefix itself are treated literally — only the trailing bare * is a glob. An allowlist entry like docs/topics/ is safe. Worth knowing if entries with [, ?, or * are ever added; they would be silently misinterpreted as glob patterns in the unquoted suffix position. The current seed (docs/topics/) is clean, and the comment block in docs-only-paths.txt doesn't warn about this. A one-liner comment in the script near L74 would be sufficient if the allowlist ever grows to include paths with special characters — skipping for now is fine given the narrow scope.


Code quality notes (no issues)

  • Fail-closed semantics correctly cover all edge cases: unresolvable base ref, missing allowlist, empty allowlist, empty diff, any unlisted path — all emit false and exit 0.
  • env: BASE_REF: ${{ github.base_ref }} correctly avoids expression injection inside run: steps, consistent with hook-utils-sync/skill-quality-gate.
  • fetch-depth: 0 is correctly added to both jobs — without it, git diff --name-only "origin/$BASE_REF" would fail to resolve the base commit, triggering fail-closed (safe but wasteful).
  • Self-test runs unconditionally (no if: github.event_name == 'pull_request'), honoring the #532 liveness-assertion convention. On push, docs_only is never set to true, so the full suite always runs on main.
  • miro-plugin not-applicable log step references docs-only-paths.txt by path — consistent with plugin-gate.
  • 16/16 test cases cover: true path, nested-new-file true path, three false-negative regression guards (README, PLUGIN-ARTIFACT-PROTOCOL, CATALOG-TAXONOMY), seven conservative-false paths, and two fail-closed paths. The sibling-prefix case (docs/topics-archive/) is a welcome inclusion.

Verdict: Approve pending the missing-allowlist-file test case (Finding 1). Everything else is clean.

@kyle-sexton
kyle-sexton merged commit 9932b27 into main Jul 20, 2026
16 checks passed
@kyle-sexton
kyle-sexton deleted the perf/617-scope-heavy-ci-docs-only branch July 20, 2026 06:15
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
…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>
kyle-sexton added a commit that referenced this pull request Jul 20, 2026
## 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>
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: path-scope heavy CI jobs so docs-only PRs skip them (merge-latency reduction)

1 participant