Skip to content

feat(ci): add skill-quality static gate lane for changed skills (#530)#593

Merged
kyle-sexton merged 1 commit into
mainfrom
feat/530-skill-quality-ci-gate
Jul 20, 2026
Merged

feat(ci): add skill-quality static gate lane for changed skills (#530)#593
kyle-sexton merged 1 commit into
mainfrom
feat/530-skill-quality-ci-gate

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

This repo edits its own skills daily via the autonomous pipeline, yet no CI lane invoked the skill-quality checker — 120 evals/evals.json went unexecuted and the 17-check static gate (skill-quality:check) was invoked by nothing. This wires that checker into CI as a deterministic skill-regression net.

Implements stage 1 only of #530 (the "cheap, now" deterministic lane). Stages 2-5 (model-graded judges, frozen regression suite, pass^k autopilot-unlock metric, clean-state isolation) remain a separate decompose target per the ratified operator decision on the issue.

Fix

New skill-quality-gate job in .github/workflows/ci.yml, added to the ci-status required aggregate:

  • Static gate (scripts/check-changed-skills.sh) — on a PR, for each skill under plugins/*/skills/** the diff touches, runs plugins/skill-quality/scripts/check-skill.sh with CHECK_SKILL_SKILLS_ROOT set to the owning plugin's skills/ dir and CHECK_SKILL_BASE_REF set to the PR base. Because the checker's trigger-keyword-preservation check diffs against that base, a rewrite that silently drops a description trigger phrase (an auto-invocation regression) fails the check — replacing the work lane's manual high-blast-radius diff read with a deterministic one. Vendor subtrees map to the owning skill; deleted skills are filtered; any FAIL (or env error) exits non-zero. markdownlint (check 6) is deliberately skipped here (CHECK_SKILL_SKIP_MARKDOWNLINT=1) — SKILL.md markdown is already gated by the hygiene lane.
  • validate-evals — the existing check-jsonschema composite action schema-validates every evals/evals.json against the bundled plugins/skill-quality/reference/evals.schema.json.
  • Self-test (scripts/check-changed-skills.test.sh) — runs first so a broken orchestrator cannot mask a real regression behind a green gate.

No silent-skip (#445 precedent): the job itself never skips (a skipped required job reports success to branch protection) — only the inner PR-diff step is event-gated (mirroring hook-utils-sync's --check-bump), and the self-test + eval-schema steps give push-to-main a passing path. Missing base ref or missing checker fails closed (exit 2).

No new reusable action: reuses this repo's own local script plus the already-referenced check-jsonschema composite action — no change to melodic-software/ci-workflows.

No plugin modified → no version bump (consistent with CI-only PR #490, which bumped nothing). No plugin frontmatter, triggers, hooks, or cross-plugin contracts are touched.

Verification

Local, in the worktree:

  • Orchestrator unit testsbash scripts/check-changed-skills.test.shPASS=8 FAIL=0 (covers changed-skill detection, vendor-subtree mapping, dedupe, deletion filtering, env passthrough, pass/fail aggregation, fail-closed on bad base ref / missing checker).
  • ShellCheckshellcheck --rcfile .shellcheckrc scripts/check-changed-skills.sh scripts/check-changed-skills.test.sh → clean.
  • End-to-end, real check-skill.sh against real skills (scripts/check-changed-skills.sh origin/main):
    • Happy path (a changed skill with no regression) → PASS, exit 0.
    • Simulated regression (dropped the 'lint my skill' trigger from skill-quality/check) → FAIL: dropped trigger keyword(s) vs origin/main (auto-invocation regression): 'lint my skill', exit 1.
  • validate-evalscheck-jsonschema --schemafile plugins/skill-quality/reference/evals.schema.json over all plugins/*/skills/*/evals/evals.json → 120/120 pass today (glob matches exactly the 120 eval sets, excludes vendor).

Closes #603

Related

Wire the previously-uninvoked skill-quality checker into CI as a
deterministic skill-regression net (issue #530, stage 1).

A new `skill-quality-gate` job runs `scripts/check-changed-skills.sh` on
every PR: for each skill under `plugins/*/skills/**` the PR touches, it runs
`plugins/skill-quality/scripts/check-skill.sh` with the PR base as
`CHECK_SKILL_BASE_REF`, so a rewrite that silently drops a `description`
trigger phrase (auto-invocation regression) fails the check instead of the
work lane's manual diff read. The job also schema-validates every
`evals/evals.json` via the existing `check-jsonschema` composite action
(validate-evals), and self-tests the orchestrator.

The job never skips (a skipped required job reports success to branch
protection); only the PR-diff step is event-gated, and it is added to the
`ci-status` required aggregate. No plugin is modified, so no version bump.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
@kyle-sexton kyle-sexton added the automated Opened by automation. label Jul 20, 2026
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 56s —— View job


PR Review: feat/530-skill-quality-ci-gate

  • Gather context and understand request
  • Read changed files
  • Review .github/workflows/ci.yml additions
  • Review scripts/check-changed-skills.sh
  • Review scripts/check-changed-skills.test.sh
  • Post findings

Overall

This is a well-structured, correctness-first implementation. The no-silent-skip design, fail-closed exit codes, self-test-first ordering, and explicit env-var handling for script injection prevention are all solid. The issues below are all minor robustness concerns — none are blockers.


.github/workflows/ci.yml

Positive: skill-quality-gate correctly follows the established pattern — persist-credentials: false, fetch-depth: 0, timeout-minutes: 15, SHA-pinned actions, if: ${{ !cancelled() }} aggregate. The event-gating on only the diff step (not the whole job) is the right call for the required-lane invariant.

One flag: The validate-evals step uses files: plugins/*/skills/*/evals/evals.json. This glob skips evals nested under vendor subtrees (e.g. plugins/*/skills/*/vendor/*/evals/evals.json). If a vendor-bundled tool inside a skill happens to carry its own evals.json, it goes unvalidated. This is almost certainly intentional today, but it is silently different from the static gate's vendor-subtree-mapping behaviour (which does map vendor-subtree file touches back to the owning skill). A one-line YAML comment acknowledging "vendor subtrees excluded" would prevent future "why isn't this validated?" questions.


scripts/check-changed-skills.sh

Positive: Fail-closed design (exit 2 for environment errors), proper quoting throughout, "${changed[@]}" safely handles the empty-array case under bash 5 (ubuntu-24.04), and the [[ -f "$skill_dir/SKILL.md" ]] || continue deletion filter is correctly placed after the diff expansion.

Minor — working-tree vs HEAD diff (line 49):

git diff --name-only "$BASE" -- 'plugins/' |

git diff "$BASE" compares the base ref against the working tree, not HEAD. In CI's clean checkout this is equivalent, but explicitly writing git diff --name-only "$BASE" HEAD -- 'plugins/' would be self-documenting and robust against a future step earlier in the job leaving working-tree side-effects. Fix this →


scripts/check-changed-skills.test.sh

Positive: 8 scenarios cover the main paths well. The throwaway-repo pattern, git config isolation, and CHECK_SKILL_BIN injection are all clean. The self-test runs in a completely isolated git context so it can't pollute the real repo.

1. grep -q without -F makes the env-passthrough assertion fragile (line 134):

if grep -q "name=beta root=$r/plugins/p2/skills base=$b" "$r/checklog" 2>/dev/null; then

$r expands to something like /tmp/tmp.abc123; the . is a regex metachar and could match any character. $b is a 40-char hex SHA so it's fine, but the path comparison is technically a regex match, not an exact string match. grep -qF (fixed-string) is the correct tool here. Low actual risk, but a literal period in a temp-dir name matching any char is the kind of thing that produces false-pass tests on unusual systems. Fix this →

2. No cleanup trap on the stub temp file (line 27):

STUB="$(mktemp)"

If the test script exits early on an unexpected error (e.g. set -uo pipefail fires on a failed mktemp -d), $STUB is left in /tmp. Adding trap 'rm -f "$STUB"' EXIT immediately after the mktemp line handles this. Individual $r repos are each cleaned up at the end of their scenario, but if a scenario crashes mid-flight, that repo also leaks — a trap at scenario level or a global cleanup is the hardening step. Fix this →

3. Missing scenario: new skill added (no base commit):

All 6 scenarios that involve a checker invocation commit the skill into the base and then modify it. There is no test for the case where a skill is added for the first time (does not exist in the base commit at all). The behaviour should be correct (new files appear in git diff --name-only "$BASE", SKILL.md exists in the working tree, checker runs), but a dedicated test makes that explicit and would catch any future regression where "new skill has no base ref anchor" causes the trigger-preservation check to behave differently.

4. Missing scenario: partial failure across multiple skills:

There is a single-failing-skill test (scenario 3) and a dedupe test (scenario 4), but no test that verifies the summary line when two skills are changed, one passes and one fails — e.g. "2 skill(s) checked, 1 failed" with exit 1. This is the core aggregation logic (failed=$((failed + 1)) / ((failed == 0))) and the only test that would catch an off-by-one or logic inversion there.


Summary

File Verdict
.github/workflows/ci.yml ✅ Correct — minor doc comment suggestion
scripts/check-changed-skills.sh ✅ Correct — one HEAD explicitness nit
scripts/check-changed-skills.test.sh ✅ Correct — grep -qF, trap, two missing scenarios

None of the issues above would cause the gate to incorrectly pass or fail in production CI. The grep -q / grep -qF issue is the only one that could produce a false-pass in the test suite itself (in theory). I'd consider items 1 and 2 in the test file as the highest-priority follow-ups.

@kyle-sexton
kyle-sexton marked this pull request as ready for review July 20, 2026 03:02
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 5m 7s —— View job


PR Review: feat/530-skill-quality-ci-gate

  • Gather context and understand request
  • Read changed files
  • Review .github/workflows/ci.yml additions
  • Review scripts/check-changed-skills.sh
  • Review scripts/check-changed-skills.test.sh
  • Post findings

Overall

The core implementation is correct and well-structured: no-silent-skip design, fail-closed exit codes, self-test-first ordering, and explicit env-var forwarding are all solid. There is one merge-readiness issue caused by branch divergence, plus a few minor robustness concerns in the test file.


⚠️ Branch divergence — unintentional CI exclusion removal

This branch was cut before PR #377 (feat(autonomy): guardrail matrix, security binding, setup slice (WP5)) landed on main. That PR added ~100 adversarial fixture files under plugins/autonomy/skills/setup/evals/fixtures/security-binding/ and added the CI exclusion for them in the same commit.

This PR removes that exclusion:

-          exclude: >-
-            :(exclude)plugins/guardrails/lib/path-detection/**
-            :(exclude)plugins/autonomy/skills/setup/evals/fixtures/security-binding/**
+          exclude: ':(exclude)plugins/guardrails/lib/path-detection/**'

The original exclusion comment explains why it exists:

"The autonomy security-binding fixtures carry ADVERSARIAL host paths on purpose (invented /home users the checker must reject) — sanitizing them would gut the regressions they pin."

Because the PR branch predates those fixtures, the author didn't know they were there. GitHub's auto-merge commit (9e278c2) correctly restores the exclusion (the merge diff adds it back from main), so the merged result is safe. But the PR diff itself is misleading — it shows a deletion that isn't intentional and that the merge silently undoes.

Recommendation: rebase onto current main before merging. This makes the diff show only the intended changes (the three new/modified CI files), avoids confusion for future git blame readers, and removes a trap for anyone who cherry-picks the CI commit later.

Permalink: .github/workflows/ci.yml L123–131 on main


.github/workflows/ci.yml — new job

Positive: persist-credentials: false, fetch-depth: 0, timeout-minutes: 15, SHA-pinned actions, event-gating only on the diff step (not the whole job), and skill-quality-gate correctly added to ci-status needs. The no-silent-skip rationale (mirroring the hook-utils-sync --check-bump pattern) is sound.

Minor — validate-evals glob excludes vendor-bundled evals (informational):

files: plugins/*/skills/*/evals/evals.json

This glob skips plugins/*/skills/*/vendor/*/evals/evals.json. If a vendor-bundled tool inside a skill carries its own evals.json it goes unvalidated — silently different from the static gate's vendor-subtree-mapping behaviour (which does map vendor touches back to the owning skill). Almost certainly intentional today, but a one-line YAML comment (# vendor subtrees excluded) would prevent future "why isn't this validated?" questions.


scripts/check-changed-skills.sh

Positive: fail-closed design (exit 2 for env errors), proper quoting throughout, mapfile -t with < <(...) safe subshell, and [[ -f "$skill_dir/SKILL.md" ]] || continue deletion filter correctly placed after diff expansion. ((failed == 0)) as the script's final expression correctly propagates exit status without set -e.

Minor — working-tree vs HEAD diff (line 49):

git diff --name-only "$BASE" -- 'plugins/' |

git diff "$BASE" compares the base ref against the working tree, not HEAD. In a clean CI checkout these are identical, but writing git diff --name-only "$BASE" HEAD -- 'plugins/' is explicit and defensive against a future step that leaves working-tree side-effects before this one runs. Fix this →


scripts/check-changed-skills.test.sh

Positive: throwaway-repo pattern, git config isolation (commit.gpgsign false), CHECK_SKILL_BIN injection, and 8 scenarios covering the main paths are all clean. The self-test runs in a completely isolated git context.

1. grep -q without -F makes the env-passthrough assertion fragile (line 134):

if grep -q "name=beta root=$r/plugins/p2/skills base=$b" "$r/checklog" 2>/dev/null; then

$r expands to something like /tmp/tmp.abc123; the . is a regex metacharacter and could match any character in the path comparison. Use grep -qF (fixed-string) to make the match literal. Low actual risk today, but the canonical failure mode is a false-pass on an unusual temp-dir name. Fix this →

2. No cleanup trap on the stub temp file (line 27):

STUB="$(mktemp)"

If the script exits early (e.g. set -uo pipefail fires on a failed mktemp -d), $STUB is left in /tmp. Adding trap 'rm -f "$STUB"' EXIT immediately after the mktemp line handles this. Individual $r repos each clean up at the end of their scenario, but a mid-scenario crash leaks the repo too — a global REPOS=() accumulator + trap 'rm -rf "${REPOS[@]}"' EXIT would cover both. Fix this →

3. Missing scenario — new skill added (no base commit anchor):

All 6 checker-invocation scenarios commit the skill into the base and then modify it. There is no test for a skill added for the first time (not present in the base commit at all). The expected behaviour is correct (new files appear in git diff --name-only "$BASE", SKILL.md exists in the working tree, checker runs), but a dedicated test makes that explicit and catches any future edge where "new skill has no base ref anchor" causes the trigger-preservation check to behave unexpectedly.

4. Missing scenario — partial failure across multiple skills:

There is a single-failing-skill test (scenario 3) and a deduplication test (scenario 4), but no test verifying the summary line when two skills are changed, one passes and one fails — e.g. "2 skill(s) checked, 1 failed" with exit 1. This is the core aggregation logic (failed=$((failed + 1)) / ((failed == 0))) and the only test that would catch an off-by-one or logic inversion there.


Summary

Area Verdict
Branch state ⚠️ Needs rebase onto current main before merging
.github/workflows/ci.yml new job ✅ Correct — one informational doc comment suggestion
scripts/check-changed-skills.sh ✅ Correct — one HEAD explicitness nit
scripts/check-changed-skills.test.sh ✅ Correct — grep -qF, trap, two missing scenarios

The branch divergence / exclusion removal is the only merge-blocking concern. The test robustness items (grep -qF, trap, two missing scenarios) are the highest-priority code improvements; none would cause the gate to incorrectly pass or fail in production CI.

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Classification of each finding (fixes in c97269cd; self-test now PASS=10 FAIL=0):

# Finding Classification Evidence Reacted
1 ci.yml validate-evals glob excludes vendor-bundled evals — add a doc comment VALID — fixed Added a rationale comment on the files: line noting vendor subtrees are intentionally excluded (a bundled tool owns its own eval contract). 👍
2 check-changed-skills.sh L49: git diff "$BASE""$BASE" HEAD INCORRECT (remedy declined) Observation is technically true, but the literal fix breaks 6 self-test scenarios that apply uncommitted working-tree changes and rely on git diff <base> seeing them — with HEAD added, HEAD==base → empty diff → "nothing to gate". No production upside either: the CI checkout is clean and the self-test runs in isolated /tmp repos (never mutates the checkout), so working-tree==HEAD in CI regardless. Kept as-is. 👍
3 test L134: grep -qgrep -qF VALID — fixed Switched to grep -qF; the temp-dir path is now matched literally, not as a regex. 👍
4 test L27: add cleanup trap for $STUB VALID — fixed Added trap 'rm -f "$STUB"' EXIT right after the mktemp; removed the now-redundant final rm -f "$STUB". 👍
5 Missing scenario: new skill added (no base anchor) VALID — fixed Added "newly added skill is gated". The new skill is committed onto the head (an untracked file never appears in git diff <base>), matching how a real PR carries it. 👍
6 Missing scenario: partial failure across multiple skills VALID — fixed Added "two changed skills, one pass one fail → gate fails, 2 skill(s) checked, 1 failed". 👍

(👍 is per-comment; GitHub allows one reaction per user.)

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Thanks — second round. Classification (code fixes in c97269cd; self-test now PASS=10 FAIL=0):

# Finding Classification Evidence Reacted
1 Branch divergence — PR diff shows the security-binding exclusion being removed VALID (defer to human merger) Confirmed: this branch predates #377, so main carries both the exclusion and the ~100 fixtures while this branch has neither. API reports MERGEABLE (no conflict) and the auto-merge restores the exclusion, so the merged result is safe. Per babysit policy this lane does not rebase/merge main in absent a real conflict; restoring only the exclusion line here would add an exclusion for fixtures that do not exist on this branch (doing part of the forbidden rebase). Left for the human at merge — an optional rebase would clean up the diff. 👍
2 ci.yml validate-evals glob excludes vendor-bundled evals — add a doc comment VALID — fixed Added a rationale comment on the files: line. 👍
3 check-changed-skills.sh L49: git diff "$BASE""$BASE" HEAD INCORRECT (remedy declined) True observation, but the literal fix breaks 6 self-test scenarios that diff uncommitted working-tree changes (HEAD==base → empty diff → "nothing to gate"). CI checkout is clean and the self-test runs in isolated /tmp repos, so working-tree==HEAD in CI anyway — no upside. Kept as-is. 👍
4 test L134: grep -qgrep -qF VALID — fixed Switched to grep -qF (literal match). 👍
5 test L27: add cleanup trap for $STUB VALID — fixed Added trap 'rm -f "$STUB"' EXIT; removed the redundant final rm. 👍
6 Missing scenario: new skill added VALID — fixed Added "newly added skill is gated" (new skill committed onto head). 👍
7 Missing scenario: partial failure across multiple skills VALID — fixed Added "2 checked, 1 failed" scenario. 👍

(👍 is per-comment; GitHub allows one reaction per user.)

kyle-sexton added a commit that referenced this pull request Jul 20, 2026
#617) (#622)

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](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/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 #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.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 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

- #532 — false-green / liveness-assertion convention this honors
(evaluated-and-not-applicable success, not a silent skip).
- #593 — the merged `skill-quality-gate` whose never-skip,
self-test-first, event-gated job shape this mirrors.
- #609 — open PR also touching `ci.yml` (adds the `portability-lint`
lane + one line to the `ci-status` needs list). Different file regions
from this PR (new job block + needs line vs. the
`plugin-gate`/`miro-plugin` step bodies); composes without conflict.
Whichever merges second rebases.
- No `plugins/<name>/` directory is touched, so no version bump /
CHANGELOG entry (matching CI-only precedent #490/#593).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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

automated Opened by automation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quality-net stage 1: deterministic skill-quality CI gate lane (child of #530)

1 participant