Skip to content

docs: promote stranded conventions to path-scoped rules and nested AGENTS.md #8876

docs: promote stranded conventions to path-scoped rules and nested AGENTS.md

docs: promote stranded conventions to path-scoped rules and nested AGENTS.md #8876

Workflow file for this run

name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
# The PR number scopes cancellation to exactly one PR (head_ref would collide
# across same-named fork branches); push runs fall back to the unique run_id,
# so default-branch runs are never cancelled.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
# Quality lanes run the ci-workflows composite actions / reusable workflows
# (referenced by SHA, bumped via Dependabot — never copied) against this repo's
# exact standards materializations. Tool configs live at their tool-native
# root paths; comment-hygiene's default policy is bundled with its action. The
# local ci-status gateway aggregates every lane into the
# single required check the org ci-gate ruleset keys on.
jobs:
# Single resolution of the docs-only diff scope. Every gated lane reads
# `needs.scope.outputs.run_full` instead of re-running the detector, so the
# detector runs once per workflow rather than once per lane and there is one
# place to reason about what the answer means.
#
# THE OUTPUT IS POSITIVE ON PURPOSE. A consumer writes
# `run_full == 'true'` to do work and `run_full == 'false'` to report the
# work not applicable. Both are plain equality against a value that
# `${{ <expr> != 'true' }}` can only ever render as the string `true` or the
# string `false`, so the two forms are exact complements and a new lane has
# no negation to spell wrong. The earlier shape asked each consumer to write
# `docs_only != 'true'` and relied on a prose comment to keep that polarity
# straight across every site.
#
# FAIL-CLOSED TOWARD RUNNING THE SUITE. `scripts/check-docs-only.sh` already
# emits `docs_only=false` on every condition it cannot resolve. This job
# covers the cases below the script: a detector that cannot complete — a
# non-zero exit, or an output file it could not write — leaves
# `steps.detect.outputs.docs_only` unset, which the `run_full` expression
# below resolves to `true`, and the full suite runs. The same unset-output
# path covers a push event, where the detect step does not run at all.
#
# Note precisely what each half contributes, because the two are easy to
# conflate. `continue-on-error` does NOT clear a step's outputs: a step that
# writes to $GITHUB_OUTPUT and then fails keeps what it wrote. What it does is
# keep a failed detector from failing THIS job and skipping every consumer.
# The output being unset on a failed run is a property of
# `scripts/check-docs-only.sh`, which follows every emit with an immediate
# `exit 0` and never emits `true` on a path that can subsequently fail. That
# invariant is what the consumers ultimately rest on; it is stated in that
# script's header and exercised by its suite.
#
# `scripts/check-docs-only-gate.sh` asserts the workflow-side properties
# against this file, so they are checked rather than asserted in prose.
#
# The detector self-test runs first and is NOT `continue-on-error`, so a
# broken detector turns this job red rather than resolving a scope nobody
# verified.
scope:
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
run_full: ${{ steps.detect.outputs.docs_only != 'true' }}
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Test the docs-only detector
run: bash scripts/check-docs-only.test.sh
- name: Detect a docs-only diff
id: detect
if: github.event_name == 'pull_request'
continue-on-error: true
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-docs-only.sh "origin/$BASE_REF"
# Public repository: every lane runs on GitHub-hosted ubuntu-24.04 (free for
# public repositories); the local-runner selector is not permitted here. The
# short hygiene checks still share one checkout to avoid per-check setup
# churn.
# Docs-linting lane. Markdownlint / typos / editorconfig / gitleaks /
# eol-renormalize / comment-hygiene / machine-specific-paths read the changed
# docs themselves and run on every diff. actionlint, the four
# check-jsonschema steps, and the manifest duplicate-key detector (which
# reads the exact same manifest files as the schema steps, just via
# object_pairs_hook instead of a schema — see #1498) read fixed, path-scoped
# inputs (.github/workflows/** and the named manifests) that a diff confined
# to the docs-only allowlist (scripts/docs-only-paths.txt) cannot touch, so
# on such a diff they report an honest evaluated-and-not-applicable success.
# ShellCheck, exec-bit, hook-wiring-liveness, and purged-em-dashes are NOT
# gated: ShellCheck lints every tracked *.sh/*.bash, exec-bit flags every
# tracked shebang file recorded 100644, hook-wiring-liveness greps
# .claude/hooks/*.sh against settings.json, and purged-em-dashes reads the
# markdown prose a docs-only diff is made of — a shell/shebang/hook file added
# under an otherwise docs-only prefix like docs/topics/ is real input they
# must still catch, and so is a docs-only diff that reintroduces an em dash on
# a surface already declared purged. The job NEVER skips — only
# the path-scoped steps are gated, on `needs.scope.outputs.run_full` from the
# `scope` lane, which resolves the diff once for the whole workflow and
# carries the self-test and fail-closed guarantees (see that job). The gated
# steps' intentional docs-only skip is mapped to `success` in the
# CHECK_RESULTS feed below (where the step provably did not run), never by
# weakening the aggregator — it still fails closed on any real non-`success`
# outcome.
hygiene:
needs: [scope]
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Lint markdown
id: markdown
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/markdown@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
config: .markdownlint-cli2.jsonc
- name: Spell-check
id: typos
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/typos@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
config: _typos.toml
- name: Scan for secrets
id: gitleaks
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/gitleaks@f2d5e06757201f2fce187096a2c6fa805836c3d2
with:
config: .gitleaks.toml
scan-mode: git
redact: true
- name: Check editorconfig conformance
id: editorconfig
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/editorconfig@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
config: .editorconfig-checker.json
- name: Lint shell scripts
id: shellcheck
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/shellcheck@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
rcfile: .shellcheckrc
- name: Lint workflows
id: actionlint
if: needs.scope.outputs.run_full == 'true'
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/actionlint@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
- name: Validate marketplace manifest
id: marketplace_schema
if: needs.scope.outputs.run_full == 'true'
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
schemafile: https://json.schemastore.org/claude-code-marketplace.json
files: .claude-plugin/marketplace.json
- name: Validate plugin manifests
id: plugin_schema
if: needs.scope.outputs.run_full == 'true'
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
schemafile: https://json.schemastore.org/claude-code-plugin-manifest.json
files: plugins/*/.claude-plugin/plugin.json
# JSON Schema validates the ALREADY-PARSED document, so it structurally
# cannot see a duplicate object key -- by the time a schema runs, the
# earlier member is already gone (every JSON parser resolves a repeated
# key last-wins). #1492 shipped plugins/skill-quality/.claude-plugin/
# plugin.json with two "version" members through this exact schema step
# green. scripts/check-manifest-duplicate-keys.py reads the raw
# key/value pairs via object_pairs_hook, BEFORE that collapse, so it
# catches what the schema step next to it cannot. See #1498.
- name: Test the manifest duplicate-key detector
run: bash scripts/check-manifest-duplicate-keys.test.sh
- name: Detect duplicate keys in plugin/marketplace manifests
id: duplicate_keys
if: needs.scope.outputs.run_full == 'true'
continue-on-error: true
run: python3 scripts/check-manifest-duplicate-keys.py
- name: Validate dependabot.yml
id: dependabot_schema
if: needs.scope.outputs.run_full == 'true'
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
builtin-schema: vendor.dependabot
files: .github/dependabot.yml
- name: Validate workflows
id: workflow_schema
if: needs.scope.outputs.run_full == 'true'
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
builtin-schema: vendor.github-workflows
files: >-
.github/workflows/ci.yml
.github/workflows/claude-review.yml
.github/workflows/claude-security-review.yml
.github/workflows/dependabot-miro-bundle.yml
.github/workflows/do-not-merge.yml
.github/workflows/link-check.yml
.github/workflows/pr-issue-linkage.yml
.github/workflows/pr-title.yml
.github/workflows/silent-revert-canary.yml
- name: Verify shebang files are executable
id: exec_bit
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/exec-bit@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
- name: Check for machine-specific paths
id: machine_paths
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/machine-specific-paths@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
# The guardrails plugin bundles a path-detection pattern lib whose
# regex bodies self-match this lane's own detector. 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. Its graded-fixture manifest is
# the same corpus: it pins each fixture's trusted --credential-roots
# (a real runner home) and expected not-under-root rejections, so it
# necessarily carries those same host paths.
# audit-permission-state is the third case of the same kind: its
# C6-winPath check EXISTS to find Windows paths in permission rules,
# so its detector message quotes one, its criteria file quotes the
# settings page quoting one, and its fixtures must carry them or the
# check has nothing to detect. The oracle fixtures reproduce Claude
# Code's own [DEBUG] narration, which prints native Windows settings
# paths — rewriting those would make the fixture stop matching what
# the parser has to survive. The paths are invented (`alice`, `x`),
# not any operator's. source-control's same-drive unit tests need
# literal `C:/` / `D:/` drive fixtures to exercise the gate (#2764).
exclude: >-
:(exclude)plugins/guardrails/lib/path-detection/**
:(exclude)plugins/autonomy/skills/setup/evals/fixtures/security-binding/**
:(exclude)plugins/autonomy/skills/setup/scripts/check-security-binding.fixtures.test.manifest.json
:(exclude)plugins/claude-config/skills/audit-permission-state/reference/criteria.md
:(exclude)plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.sh
:(exclude)plugins/claude-config/skills/audit-permission-state/scripts/permission-plane-lint.test.sh
:(exclude)plugins/claude-config/skills/audit-permission-state/scripts/automode-entry-diff.test.sh
:(exclude)plugins/source-control/scripts/worktree-create.test.sh
- name: Check index-level EOL drift
id: eol
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/eol-renormalize@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
- name: Scan comment hygiene
id: comment_hygiene
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/comment-hygiene@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
# The audit-comment-residue skill IS a code-comment linter: its detector
# fixtures and shape library necessarily contain the markers this policy
# bans (TODO/FIXME, PR/issue refs) as test corpus and as the documented
# TODO(#issue) sanctioned-exemption. Exempt that skill's scripts + evals
# (its SKILL.md is markdown, already outside the scan's extension set).
exclude: >-
:(exclude)plugins/code-tidying/skills/audit-comment-residue/scripts/**
:(exclude)plugins/code-tidying/skills/audit-comment-residue/evals/**
- name: Test the hook-wiring-liveness gate
run: bash scripts/check-hook-wiring-liveness.test.sh
- name: Check repo-local hooks are wired in settings.json
id: hook_wiring
continue-on-error: true
run: scripts/check-hook-wiring-liveness.sh
# Deliberately UNCONDITIONAL, like the other whole-repo scanners. This
# gate reads markdown prose, which is exactly what a docs-only diff
# changes, so gating it on run_full would disable it on precisely the pull
# requests most able to trip it. A regression would then reach main under
# a green check rather than an honest not-applicable. The self-test runs
# first and ungated so a broken gate cannot mask a regression.
- name: Test the purged-em-dash gate
run: bash scripts/check-purged-em-dashes.test.sh
- name: Check purged surfaces stayed free of em dashes
id: purged_em_dashes
continue-on-error: true
run: scripts/check-purged-em-dashes.sh
- name: Report docs-irrelevant checks not applicable to a docs-only diff
if: needs.scope.outputs.run_full == 'false'
run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the path-scoped linters (actionlint, check-jsonschema x4, the manifest duplicate-key detector) cannot be affected — reporting success for them. ShellCheck, exec-bit, hook-wiring-liveness, and purged-em-dashes scan the whole repo and stay unconditional."
- name: Test hygiene result aggregation
run: scripts/aggregate-hygiene-results.sh --self-test
- name: Aggregate hygiene results
if: always()
env:
# Display names (left) are annotations; step ids (right) use underscores.
# The path-scoped linters (actionlint, the four check-jsonschema steps,
# and the manifest duplicate-key detector) carry
# `if: run_full == 'true'`, so on a docs-only diff they are skipped
# (never run) — their intentional skip is mapped to `success` here
# BECAUSE the step provably did not run, so no real outcome is masked.
# The aggregator stays fail-closed on any real non-`success`
# (including `skipped`); it is never told to pass on `skipped`. The
# condition here (`run_full == 'false'`) tests the same single output
# against its only other value, so the two can never disagree.
# `scripts/check-docs-only-gate.sh` pins both halves: that this
# condition is the sanctioned complement of the gates, and that every
# step named on the right of an override is one that carries the gate
# — an override on an UNGATED step would mask a real failure as
# success, and that is the direction that fails open. ShellCheck,
# exec-bit, and hook-wiring-liveness are unconditional (whole-repo
# scanners), so they feed their raw outcome.
CHECK_RESULTS: |
markdown=${{ steps.markdown.outcome }}
typos=${{ steps.typos.outcome }}
gitleaks=${{ steps.gitleaks.outcome }}
editorconfig=${{ steps.editorconfig.outcome }}
shellcheck=${{ steps.shellcheck.outcome }}
actionlint=${{ needs.scope.outputs.run_full == 'false' && 'success' || steps.actionlint.outcome }}
marketplace-schema=${{ needs.scope.outputs.run_full == 'false' && 'success' || steps.marketplace_schema.outcome }}
plugin-schema=${{ needs.scope.outputs.run_full == 'false' && 'success' || steps.plugin_schema.outcome }}
manifest-duplicate-keys=${{ needs.scope.outputs.run_full == 'false' && 'success' || steps.duplicate_keys.outcome }}
dependabot-schema=${{ needs.scope.outputs.run_full == 'false' && 'success' || steps.dependabot_schema.outcome }}
workflow-schema=${{ needs.scope.outputs.run_full == 'false' && 'success' || steps.workflow_schema.outcome }}
exec-bit=${{ steps.exec_bit.outcome }}
machine-specific-paths=${{ steps.machine_paths.outcome }}
eol-renormalize=${{ steps.eol.outcome }}
comment-hygiene=${{ steps.comment_hygiene.outcome }}
hook-wiring-liveness=${{ steps.hook_wiring.outcome }}
purged-em-dashes=${{ steps.purged_em_dashes.outcome }}
run: scripts/aggregate-hygiene-results.sh
zizmor:
permissions:
contents: read
# Actions security lint; any regular-persona finding blocks the merge gate.
uses: melodic-software/ci-workflows/.github/workflows/zizmor.yml@31a5b76c4a0b663023dc1c944e2bcfc01d6f6c46 # 31a5b76 2026-07-21
with:
runner: ubuntu-24.04
paths: .
fail-on-severity: low
# The ONLY job on a Windows runner, and deliberately so. Every other suite in
# this repository is platform-agnostic bash string logic that a Linux runner
# exercises identically — `lib/powershell/ps-command.sh`, for instance, carries
# zero `OSTYPE`/`cygpath`/`uname` branches despite classifying PowerShell
# commands, so Linux tests it faithfully.
#
# `lib/hook-utils.sh` is the exception: it carries OSTYPE-gated branches that a
# Linux runner NEVER EXECUTES — case-insensitive path folding for the Windows
# filesystem, and `cygpath` short-name (8.3) resolution. Those branches were
# previously verified only by whatever a maintainer happened to run on a dev
# box. This job is what makes them a gate — but only because it is also listed
# in the `ci-status` needs graph below. `ci-status` is the single required merge
# check, so a lane missing from that list is informational no matter how loudly
# a comment here calls it a gate: the aggregate would report success while this
# lane was red.
#
# Free: GitHub Actions is free for public repositories on standard
# GitHub-hosted runners, Windows included; only larger runners are charged.
# https://docs.github.com/en/billing/managing-billing-for-your-products/managing-billing-for-github-actions/about-billing-for-github-actions
#
# Keep this job SMALL. Adding a platform-agnostic suite here buys no coverage
# and pays Windows' process-creation cost (~140ms/spawn vs ~3ms on Linux),
# which is the whole reason the full suite is unusable on a Windows dev box.
# Pinned, not `windows-latest`: the runner policy forbids the floating
# `*-latest` labels outright (policy.json forbiddenHostedRunnerLabels), for the
# same reason the Linux lanes pin ubuntu-24.04 — an image roll must be a
# reviewed commit, not a silent Tuesday.
hook-utils-windows:
runs-on: windows-2025
timeout-minutes: 20
defaults:
run:
shell: bash
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run shared lib tests on Windows
run: bash lib/hook-utils.test.sh
# #2774: the disk-hygiene read-only Bash allowlist is NT-specific (MSYS path
# mapping, .exe basename stripping, ProgramFiles trust roots). Linux CI cannot
# exercise those branches — Path.is_absolute() is True for /usr/bin/ls on
# POSIX — which is how an inert Windows allowlist shipped green. Keep this
# job SMALL: GuardTests only, not the full hygiene suite.
#
# #2871: a dedicated fast signal for the full module
# (`python -m unittest -v test_hygiene`) lives on `disk-hygiene-tests`
# (Linux). It is not invoked here because two tests fail on windows-2025
# (315/317, 9.96s on run 32477560257):
# - HygieneTests.test_preview_allows_root_children_os_managed_snapshot
# expects ready-for-explicit-approval; Windows returns blocked
# - HandoffVerifyTests.test_stash_must_exist_in_an_independent_checkout
# compares path strings; 8.3 `RUNNER~1` != long `runneradmin`
# Those two are already green on Linux. Do not silently omit a class here
# without this comment; the other nine classes gate on disk-hygiene-tests
# (dedicated fast signal; plugin-gate also runs the module on
# non-docs-only diffs via hygiene.test.sh).
disk-hygiene-guard-windows:
runs-on: windows-2025
timeout-minutes: 20
defaults:
run:
shell: bash
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
- name: Run disk-hygiene GuardTests on Windows
working-directory: plugins/disk-hygiene/skills/clean/scripts
run: python -m unittest -v test_hygiene.GuardTests
# #2871: dedicated required signal for the full test_hygiene module.
# plugin-gate already runs hygiene.test.sh on non-docs-only diffs (this
# PR's own plugin-gate did). That is the heavy lane (Node, ShellCheck,
# every plugins/**/*.test.sh). This job exists so the ten TestCase
# classes have a fast, always-on check that does not wait on that
# matrix and cannot take hygiene.test.sh's SKIP-when-Python-absent
# path. The Windows job above stays GuardTests-only (NT allowlist +
# two Windows failures named there). Suite is not host-destructive
# (tempdir fixtures; no live drive-root scans). Measured 317 OK.
disk-hygiene-tests:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
- name: Run disk-hygiene tests
working-directory: plugins/disk-hygiene/skills/clean/scripts
run: python -m unittest -v test_hygiene
# #2834: an MSYS path literal (`/d/...`) handed to a Windows-native consumer
# re-anchors to the CURRENT drive's root, so the consumer creates a phantom
# `<drive>:\<letter>\` tree and writes there. Nothing errors — the run just
# measures something other than what it claims. docs/conventions/
# windows-path-emit/ owns the rule; this lane holds its two artifacts to
# their contracts.
#
# What is REQUIRED here is the detectors' own unit contract — deterministic,
# fixture-scoped, and including the assertion that the drive-root scan is a
# reported no-op on a non-Windows host. Pointing the live scan at a runner's
# drive roots is deliberately NOT wired: it would put an unquantified
# false-positive tail on the required aggregate, which docs/adr/0003 rules
# out until a guard has measured precision. Self-test first, so a broken
# detector cannot mask a regression.
windows-path-emit-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the MSYS-to-Windows emit helper
run: bash scripts/emit-windows-path.test.sh
- name: Test the drive-root litter detector
run: bash scripts/check-drive-root-litter.test.sh
- name: Assert the drive-root scan is a no-op on a non-Windows host
run: scripts/check-drive-root-litter.sh
# The emit helper's whole reason to exist is its NT branch: cygpath's answer
# cannot be faked on Linux, so on a Linux runner those cases report NOT
# EXERCISED and the suite is green having proved nothing about them. That is
# exactly how an inert Windows-only surface shipped green in #2774. This job
# runs the same suites where the branch is real. Keep it SMALL: these two
# suites only, and never the live drive-root scan.
windows-path-emit-windows:
runs-on: windows-2025
timeout-minutes: 20
defaults:
run:
shell: bash
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the MSYS-to-Windows emit helper on Windows
run: bash scripts/emit-windows-path.test.sh
- name: Test the drive-root litter detector on Windows
run: bash scripts/check-drive-root-litter.test.sh
hook-utils-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Verify plugin copies match the shared lib
run: scripts/sync-hook-utils.sh --check
- name: Run shared lib tests
run: bash lib/hook-utils.test.sh
- name: Verify carrying plugins bumped when the lib changed
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-hook-utils.sh --check-bump "origin/$BASE_REF"
parse-concern-value-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Verify plugin copies match the shared parser
run: scripts/sync-parse-concern-value.sh --check
- name: Run shared parser tests
run: bash lib/parse-concern-value.test.sh
- name: Verify consuming plugins bumped when the lib changed
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-parse-concern-value.sh --check-bump "origin/$BASE_REF"
managed-scope-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Verify managed-scope cluster matches canonical
run: scripts/sync-managed-scope.sh --check
- name: Run managed-scope tests
run: bash plugins/claude-config/lib/managed-scope.test.sh
- name: Verify carrying plugins bumped when canonical changed
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-managed-scope.sh --check-bump "origin/$BASE_REF"
state-key-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Verify state-key cluster matches canonical
run: scripts/sync-state-key.sh --check
- name: Run state-key tests
run: bash plugins/claude-config/lib/state-key.test.sh
- name: Verify carrying plugins bumped when canonical changed
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-state-key.sh --check-bump "origin/$BASE_REF"
resolve-convention-pattern-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Verify plugin copies match the shared resolver
run: scripts/sync-resolve-convention-pattern.sh --check
- name: Run shared resolver tests
run: bash lib/resolve-convention-pattern.test.sh
- name: Verify consuming plugins bumped when the lib changed
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-resolve-convention-pattern.sh --check-bump "origin/$BASE_REF"
standards-contract-sync:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Verify plugin binding copies match the canonical contract
run: scripts/sync-standards-contract.sh --check
- name: Run sync-standards-contract tests
run: bash scripts/sync-standards-contract.test.sh
- name: Verify contract, changelog, and carrying plugins bumped together
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/sync-standards-contract.sh --check-bump "origin/$BASE_REF"
cross-plugin-source-drift:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check for unregistered or drifted cross-plugin source clusters
run: scripts/check-cross-plugin-source-drift.sh --check
- name: Run cross-plugin-source-drift tests
run: bash scripts/check-cross-plugin-source-drift.test.sh
detector-findings-crosswalk-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Self-test first, so a broken detector cannot mask a regression.
- name: Run detector-findings-crosswalk tests
run: bash scripts/check-detector-findings-crosswalk.test.sh
- name: Check every crosswalk row argues its disposition from a stated test
run: scripts/check-detector-findings-crosswalk.sh --check
skill-leaf-name-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Self-test first, so a broken detector cannot mask a regression.
- name: Run skill-leaf-name tests
run: bash scripts/check-skill-leaf-names.test.sh
- name: Check for unregistered cross-plugin skill leaf-name collisions
run: scripts/check-skill-leaf-names.sh --check
# A plugin's prose counts its own skills by hand ("bundling fourteen skills",
# "the other thirteen skills are zero-config") and nothing recomputes those
# numbers when a skill lands, so adding one silently falsifies every sentence
# that counted the old set. Measured: PR #3011 went stale on six such counts,
# each caught by a different reviewer rather than by anything mechanical, and
# session-flow's CHANGELOG records an "other eleven skills" line that was
# already off by one before that PR. Self-test first so a broken detector
# cannot mask a regression.
skill-count-claim-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run skill-count-claim tests
run: bash scripts/check-skill-count-claims.test.sh
- name: Check every skill-count claim against the tree
run: scripts/check-skill-count-claims.sh --check
# A ${user_config.*} token in a plugin hook config silently drops the whole
# hook entry whenever the key is unset (the declared userConfig `default` is
# unimplemented upstream — anthropics/claude-code#46477), so the hook never
# fires on a default install. The channel decision matrix
# (docs/conventions/hook-config-delivery/) rules argv out for hooks; this
# gate pins the exact regression #1242 fixed. Self-test first so a broken
# detector cannot mask a regression.
userconfig-argv-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the userconfig-argv gate
run: bash scripts/check-hook-userconfig-argv.test.sh
- name: Check plugin hook configs for bare userConfig argv tokens
run: scripts/check-hook-userconfig-argv.sh
# An exec-form hook (a hook object carrying `args`) resolves `command` as an
# executable through PATH, so a bare name is machine-dependent: on Windows
# `bash` resolves to the WSL relay System32\bash.exe and `python3` to a
# zero-length WindowsApps alias stub, and the hook never launches. A failed
# launch is a NON-BLOCKING error, so a PreToolUse guard wired this way
# silently enforces nothing. This class has shipped three times in
# disk-hygiene alone (#1006 fixed it, #1504 reintroduced it, #2570 fixed it
# again) while the claude-config audit checklist carried it as an `error` row
# the whole time — a checklist a human reads is not a gate. Repo-wide and
# static, covering both declaration surfaces (hook config JSON and skill/agent
# frontmatter). Self-test first so a broken detector cannot mask a regression.
hook-exec-form-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# PyYAML reads the frontmatter surface (YAML has no jq). Pinned and
# hash-locked in the same file every other Python pin lives in; the gate
# fails closed if it is missing rather than clearing files it never
# parsed, so this step is load-bearing, not convenience.
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: pip
cache-dependency-path: .github/requirements-ci.txt
- name: Install the pinned YAML reader
run: |
python -m pip install --only-binary=:all: --require-hashes \
--requirement .github/requirements-ci.txt
- name: Test the hook-exec-form gate
run: bash scripts/check-hook-exec-form.test.sh
- name: Check hook declarations for exec-form bare command names
run: scripts/check-hook-exec-form.sh
# Three separate readers judge `metadata.summary`, and they used to disagree:
# a malformed value reached CI twice in #3162, failing in opposite directions,
# each time after clearing the local check meant to catch it. The local bash
# reader enforced only a length cap, the cheat-sheet generator's guard
# enforced YAML-scalar safety, and a real YAML parser enforced neither but
# rejected the document outright. Making them agree is not sufficient on its
# own, because agreement on a wrong rule is still wrong, so this gate pins
# every verdict to a real YAML parse rather than to the other readers. It
# sweeps a shared case table (the rule set) and every SKILL.md in the tree
# (the tree), and it fails closed without PyYAML rather than clearing on the
# two regex readers alone.
summary-reader-parity-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: pip
cache-dependency-path: .github/requirements-ci.txt
- name: Install the pinned YAML reader
run: |
python -m pip install --only-binary=:all: --require-hashes \
--requirement .github/requirements-ci.txt
- name: Check that every summary reader returns the same verdict
run: bash scripts/check-summary-reader-parity.test.sh
# Every option a plugin declares must be documented in that plugin's README.
# The block is generated from the manifest, so the failure this catches is an
# option added to plugin.json whose README block was never regenerated -- an
# undocumented knob is indistinguishable from one that does not exist, and the
# only place a user can discover it is the manifest itself.
plugin-options-docs-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check plugin option docs are in sync with each manifest
run: python3 scripts/sync-plugin-options-docs.py --check
silent-skip-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check hook entry scripts for silent prerequisite skips
run: scripts/check-silent-skips.sh
- name: Run silent-skip-gate tests
run: bash scripts/check-silent-skips.test.sh
# #1547: a routine `git merge origin/main` silently dropped
# plugins/guardrails/.claude-plugin/plugin.json for ~14 minutes on a branch
# while .claude-plugin/marketplace.json still catalogued "guardrails" at that
# directory — no conflict markers, nothing to alert the author; an AI
# reviewer reading the diff caught it, not CI. check-jsonschema (the
# marketplace_schema/plugin_schema steps in `hygiene`) validates the SHAPE of
# a manifest that exists; it cannot see one that is simply absent. This gate
# asserts every catalog entry resolves to a present, name-matching plugin
# manifest, and (paired, lower severity) that every plugins/*/ directory is
# itself catalogued. Repo-wide and static, so it always runs (no docs-only or
# PR-diff scoping — a manifest going missing is not confined to a diff). The
# self-test runs first so a broken detector cannot mask a regression behind a
# green gate.
plugin-manifest-presence-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the plugin-manifest-presence gate
run: bash scripts/check-plugin-manifest-presence.test.sh
- name: Check every catalog entry resolves to a present, name-matching plugin manifest
run: scripts/check-plugin-manifest-presence.sh
# docs/CLOUD-SESSIONS.md promises `enabledPlugins` "turns on the whole
# catalog, so this repo dogfoods everything it publishes"; nothing enforced
# it, and ai-slop (#2892), context-budget (#2932) and improvement (#2985)
# each reached main catalogued but never enabled. The failure is silent by
# construction: .claude/cloud-bootstrap.sh computes its install set from that
# same enabledPlugins map, so a session comes up green with the plugin's
# skills simply absent and nothing naming what is missing. The consumer-side
# detector cannot cover this — check-plugin-drift.sh resolves marketplaces
# through `source.repo` and SKIPs one that declares none, which is exactly
# this repo's relative `directory` source. Repo-wide and static, so it always
# runs. The self-test runs first so a broken detector cannot mask a
# regression behind a green gate.
plugin-catalog-enablement-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the plugin-catalog-enablement gate
run: bash scripts/check-plugin-catalog-enablement.test.sh
- name: Check every catalogued plugin carries an enabledPlugins key
run: scripts/check-plugin-catalog-enablement.sh
# Every file under a skill's evals/fixtures/ must be consumed by a grader — an
# eval files[] entry or a test assertion — so an ungraded suite cannot sit in
# the tree reading as tested. The check is repo-wide and static; existing debt
# is grandfathered in scripts/orphaned-fixtures-baseline.txt with a stale
# guard, so the gate holds the line without red-lining fixtures a member issue
# already owns. The self-test runs unconditionally so a broken detector cannot
# mask a regression behind a green gate.
orphaned-fixture-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the orphaned-fixture detector
run: bash scripts/check-orphaned-fixtures.test.sh
- name: Check for orphaned eval fixtures
run: scripts/check-orphaned-fixtures.sh --check
# A test suite that BUILDS a git fixture must clear the inherited git
# environment, in shell and in Python alike. `git -C <fixture>` is a
# readability guard, not an isolation guarantee: an exported ABSOLUTE GIT_DIR
# overrides repository DISCOVERY, so the fixture identity lands in the
# CALLER's .git/config — shared by every worktree of the clone — and the
# caller's next commit is silently re-authored. That failed
# required_signatures with `no_user` and cost #2827 -> #2830 (#2840). The
# check is repo-wide and static. The grandfathered baseline was drained
# in #2872; a new fixture-building suite must isolate itself. The
# self-test runs unconditionally so a broken detector cannot mask a
# regression behind a green gate.
fixture-git-isolation-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the fixture-git-isolation detector
run: bash scripts/check-fixture-git-isolation.test.sh
- name: Test that the shared git test harness isolates its fixtures
run: bash scripts/test-git-helpers.test.sh
- name: Check fixture git isolation
run: scripts/check-fixture-git-isolation.sh --check
# Every finding kind audit-fleet.sh emits must have a Finding: <kind>
# collector-output assertion in audit-fleet.test.sh (or be grandfathered).
# Lives outside the suite so a stale-base squash that rewrites the test file
# alongside the collector cannot delete its own defender (#2656). Existing
# debt is grandfathered in scripts/fleet-finding-test-coverage-baseline.txt
# with a stale guard.
fleet-finding-test-coverage-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Test the fleet finding-kind test-coverage detector
run: bash scripts/check-fleet-finding-test-coverage.test.sh
- name: "Check every emitted fleet finding kind has a Finding: assertion"
run: scripts/check-fleet-finding-test-coverage.sh --check
# The audit skill's documented argument grammar must match what audit-fleet.sh
# actually accepts. Lives outside both SKILL.md and the collector so a
# docs-only silent revert (#2646) cannot delete its own defender (#2713).
# Probes the script (never pattern-matches the parser) under a sealed env.
fleet-audit-doc-grammar-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Test the fleet audit doc-grammar detector
run: bash scripts/check-fleet-audit-doc-grammar.test.sh
- name: Check audit skill grammar matches the parser
run: scripts/check-fleet-audit-doc-grammar.sh --check
# Stale-base squash merges can silently revert recently-landed fixes when a PR
# is cut from a base that has since moved, and the squash deletes the tests
# that would have caught the drop (claude-code-plugins#2691). This gate fails
# the PR while its merge-base is behind on overlapping paths. It covers the
# stale-BASE class only: a branch stale in content but current in history
# passes here, and that class belongs to the post-merge silent-revert canary
# (scripts/check-silent-revert.sh, silent-revert-canary.yml). The two are
# disjoint; neither subsumes the other.
# The job itself never skips (a skipped required lane's result is not
# `success`, which the ci-status aggregate rejects) — only the PR overlap
# steps are event-gated. The detector self-test runs on every event so a
# broken detector cannot mask a regression behind a push-to-main skip.
stale-base-overlap-gate:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# PR head, not the synthetic merge commit: overlap is computed against
# the branch tip the author will squash, so merge-base math must match
# that tip rather than an already-merged-with-base tree. Push falls
# back to github.sha so `ref` is never empty on main.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Test the stale-base overlap detector
run: bash scripts/check-stale-base-overlap.test.sh
- name: Fail when behind base on overlapping paths
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-stale-base-overlap.sh --check "origin/$BASE_REF"
# CHANGELOG parity: a versioned plugin must keep a CHANGELOG.md whose newest
# version heading does not outrun the manifest (static --check), a PR that
# changes a plugin's manifest version must update that plugin's CHANGELOG.md in
# the same diff (--check-bump, PR-only), and no PR may DROP a version heading a
# changelog already carried (--check-preserved, PR-only). Existing
# "versioned but changelog-less" debt is grandfathered by name in
# scripts/changelog-parity-baseline.txt with a stale guard; the bump gate is
# never relaxed by the baseline. The self-test runs unconditionally so a broken
# gate cannot mask a regression, and the PR-diff step is event-gated.
changelog-parity-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Test the changelog-parity gate
run: bash scripts/check-changelog-parity.test.sh
- name: Verify every versioned plugin keeps a CHANGELOG.md at or below its manifest version
run: scripts/check-changelog-parity.sh --check
- name: Verify a version bump updates the plugin's CHANGELOG.md
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-changelog-parity.sh --check-bump "origin/$BASE_REF"
# The other half of the same PR discipline: the bump gate polices what a
# change set ADDS and never what it REMOVES, so a merge-forward that writes
# the new release under the PREVIOUS release's heading deletes a released
# section with no conflict marker and no gate to catch it (#2264).
- name: Verify no changed changelog drops a version heading it carried at the fork point
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-changelog-parity.sh --check-preserved "origin/$BASE_REF"
# Whole-file, not per-diff: the other two modes reason about one version at
# a time, so neither can see that two branches staged the same number or
# that a stale-based entry landed below one already on main.
- name: Verify every changelog reads newest-first with no duplicate versions
run: scripts/check-changelog-parity.sh --check-order
# Contract-slice prune: docs/topics/<slug>/ is Contract tier per
# docs/conventions/topic-docs/README.md — committed on a task branch only,
# pruned before merge. The convention specified this required check but it was
# never wired, so 17 slices reached main (#1417). The gate keys on where a path
# LANDS, so the prune commit itself and a history-preserving graduation out of
# docs/topics/ both pass; only an add, edit, or rename-into is red-lined. The
# pre-existing 17 are grandfathered by slug in
# scripts/contract-slice-baseline.txt with a stale guard, and burn down under
# #1419. The self-test runs unconditionally so a broken gate cannot mask a
# regression, and the PR-diff step is event-gated.
contract-slice-prune-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Test the contract-slice prune gate
run: bash scripts/check-contract-slice-prune.test.sh
- name: Verify no baseline entry outlives its slice
run: scripts/check-contract-slice-prune.sh --check
- name: Verify the change set prunes its contract slice
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-contract-slice-prune.sh --check-diff "origin/$BASE_REF"
# The review-disposition-and-resolution contract (D4.6 grounding, D4.6
# provenance, D7.5 thread eligibility, and who may authorize a resolution
# that ships no fix) is canonical in ONE file and restated across half a
# dozen others; nothing checked that the copies agreed. #1633 shipped seven
# such defects across five review rounds — each an individually well-formed
# surface instructing a lane to do what another surface forbids — and the
# HAND-RUN sweep that finally converged then produced a false "no remaining
# gaps" of its own, because it asked whether a qualifier appeared ANYWHERE
# in a file rather than inside the passage that restates the rule. This gate
# is span-scoped for exactly that reason: a restatement declares itself with
# a `contract-restatement` marker and is held to the canonical's qualifiers
# within its own bounds, and text that restates a clause outside every span
# is itself reported (tag it, or reduce it to a pointer — the pointer is the
# stronger fix). The in-scope surface set is DERIVED from the clause
# vocabulary, never hardcoded, so a new restatement anywhere in the tree is
# in scope the moment it is written. Repo-wide and static, so it always runs.
# Self-test first, so a broken detector cannot mask a real gap behind a green
# gate. See #1659.
contract-clause-coverage-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the contract-clause coverage gate
run: bash scripts/check-contract-clause-coverage.test.sh
- name: Check every restatement carries the canonical clause's qualifiers
run: python3 scripts/check-contract-clause-coverage.py
# The docs-only short-circuit is a fail-open shape by construction: getting it
# wrong makes a heavy lane report success without running. Its safety
# properties — one resolution, an output whose unset value means "run the full
# suite", a detector failure absorbed rather than propagated, an unweakened
# self-test, and exactly one spelling for a consumer to copy — used to live in
# a prose comment repeated next to every gated step. This lane checks them
# against the workflow instead. Self-test first, so a broken gate cannot mask
# a regression.
docs-only-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the docs-only scope-contract gate
run: bash scripts/check-docs-only-gate.test.sh
- name: Check the docs-only scope contract holds in ci.yml
run: scripts/check-docs-only-gate.sh --check
# `ci-status` below is the single check the org ci-gate ruleset keys on, and
# its own comment calls its `needs` list "the single source of truth for the
# lane list" — but nothing enforced the other direction. A job defined in THIS
# file and absent from that list still runs and still turns red in the run
# list, while `ci-status` reports success and the merge proceeds:
# informational, not a gate. `hook-utils-windows` states exactly that in prose
# above, and prose is not a gate — `managed-scope-sync` and `state-key-sync`
# shipped outside the aggregate for their whole lifetime while
# scripts/cross-plugin-source-registry.txt advertised each as a dedicated
# check (#2856). Same false-green class as `liveness-assertion`, on the merge
# gate itself. This lane closes the loop mechanically: every job here is in
# `ci-status.needs`, or carries a `# lane-coverage-ok: <reason>` annotation
# recording why it stays out. Static, one file, no diff and no base ref, and
# unrecognized YAML shape exits 2 rather than reporting coverage it did not
# parse. Self-test first so a broken detector cannot mask a regression.
lane-coverage-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Test the lane-coverage gate
run: bash scripts/check-lane-coverage.test.sh
- name: Check every ci.yml job is reachable from the ci-status aggregate
run: scripts/check-lane-coverage.sh --check
# Deep plugin-contract lane and the heaviest suite (Node + Python installs,
# every plugins/**/*.test.sh, manifest + catalog validation). A PR whose diff
# is confined to the docs-only allowlist (scripts/docs-only-paths.txt) cannot
# affect any of it, so those runs report 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
# (troubleshooting-required-status-checks). Only the inner install/test steps
# are gated, on `needs.scope.outputs.run_full` from the `scope` lane, which
# carries the self-test and fail-closed guarantees for every lane that reads
# it.
plugin-gate:
needs: [scope]
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Set up Node
if: needs.scope.outputs.run_full == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: npm
cache-dependency-path: package-lock.json
- name: Set up Python
if: needs.scope.outputs.run_full == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: pip
cache-dependency-path: .github/requirements-ci.txt
- name: Install and verify ShellCheck toolchain
if: needs.scope.outputs.run_full == 'true'
# This canonical action also makes the exact ShellCheck version
# available to the later bash-format contract tests in this same job.
uses: melodic-software/ci-workflows/.github/actions/shellcheck@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
paths: plugins/bash-format
rcfile: .shellcheckrc
# Contract tests run first and gate independently of the CLI validate step
# below: a validate-infra problem (e.g. the CLI needing auth) can never mask
# a real contract-test failure. shfmt is optional in both environments, so
# shfmt-gated cases skip rather than fail. Node, Python, ShellCheck, Biome,
# and Ruff are declared here so hosted and local runs exercise the same
# contract suites instead of inheriting different image tool inventories.
- name: Install locked plugin test toolchains
if: needs.scope.outputs.run_full == 'true'
run: |
npm ci
python -m pip install --user --only-binary=:all: --require-hashes \
--requirement .github/requirements-ci.txt
echo "$GITHUB_WORKSPACE/node_modules/.bin" >> "$GITHUB_PATH"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Run plugin contract tests
if: needs.scope.outputs.run_full == 'true'
run: scripts/run-plugin-tests.sh
# Explicit step: run-plugin-tests.sh discovers only plugins/**/*.test.sh,
# so the cheat-sheet generator suite under scripts/ never runs without it.
- name: Run cheat-sheet generator tests
if: needs.scope.outputs.run_full == 'true'
run: bash scripts/generate-cheatsheet.test.sh
# Same reason as the step above — run-plugin-tests.sh never reaches
# scripts/. This suite is wired into CI rather than left to local runs
# because its load-bearing cases assert against the LIVE repository: that
# the shared-lib copy set affected-tests.sh derives still equals what each
# scripts/sync-*.sh manifest declares today. That equality is what stops
# the selector silently under-selecting after a manifest changes shape or
# a new carrying plugin appears, and a check that only ever runs when
# someone remembers to run it is exactly the rot it guards against.
- name: Run affected-suite selector tests
if: needs.scope.outputs.run_full == 'true'
run: bash scripts/affected-tests.test.sh
# Same reason again — run-plugin-tests.sh never reaches scripts/. This
# suite covers scripts/lib/changed-files.sh, the base-ref and changed-file
# resolver the checker gates share. Its NUL-safety cases are the invariant
# four of those gates had diverged on (#2914), and no caller suite
# exercises a C-quotable pathname, so this is the only place a regression
# there turns anything red.
- name: Run shared changed-file resolver tests
if: needs.scope.outputs.run_full == 'true'
run: bash scripts/lib/changed-files.test.sh
# Same reason again — run-plugin-tests.sh never reaches scripts/. This
# suite covers scripts/lib/read-list.sh, the list-file reader the gates
# share. Its two comment modes are semantically different on purpose
# (#3161) and each is asserted against the other's answer, so a change
# that quietly collapses them turns this red rather than silently
# truncating a token pattern.
- name: Run shared list-file reader tests
if: needs.scope.outputs.run_full == 'true'
run: bash scripts/lib/read-list.test.sh
# Same reason again — run-plugin-tests.sh never reaches scripts/. This
# suite covers scripts/lib/test-harness.sh, the ok/fail/report contract
# the scripts/*.test.sh suites share. A recorded failure that still
# exits 0 is the false-green this library exists to refuse (#3160).
- name: Run shared test-harness tests
if: needs.scope.outputs.run_full == 'true'
run: bash scripts/lib/test-harness.test.sh
# Same reason again — run-plugin-tests.sh never reaches scripts/. The
# self-test runs first so a broken validator cannot mask a regression
# behind a green gate: no shipping plugin violates the check-only
# carve-out assertions, so the shipping tree alone proves nothing about
# them and only these fixtures show the gate still goes red (#3137).
- name: Run plugin-contract validator tests
if: needs.scope.outputs.run_full == 'true'
run: bash scripts/validate-plugin-contracts.test.sh
- name: Validate plugin and catalog manifests
if: needs.scope.outputs.run_full == 'true'
run: scripts/validate-plugins.sh
- name: Report not applicable to a docs-only diff
if: needs.scope.outputs.run_full == 'false'
run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the plugin contract suite cannot be affected — reporting success."
# The miro plugin ships a bundled Node MCP server — the marketplace's first.
# Its TypeScript source is the source of truth; dist/index.min.js is committed
# generated output. This lane rebuilds from source with the pinned toolchain
# and fails on any drift, then runs the bundle over stdio so a build that
# compiles but cannot serve MCP is caught here, not on a consumer's machine.
# Every step reads only plugins/miro/**, so a docs-only diff cannot affect it;
# it reads the same `scope` lane output as plugin-gate and never skips as a
# job. (Scoping this lane to plugins/miro/** specifically — skipping it
# on any non-miro diff — is a broader, separately-tracked optimization.)
miro-plugin:
needs: [scope]
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Set up Node
if: needs.scope.outputs.run_full == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: npm
cache-dependency-path: plugins/miro/package-lock.json
- name: Install dependencies
if: needs.scope.outputs.run_full == 'true'
run: npm ci
working-directory: plugins/miro
- name: Typecheck
if: needs.scope.outputs.run_full == 'true'
run: npm run typecheck
working-directory: plugins/miro
- name: Lint
if: needs.scope.outputs.run_full == 'true'
run: npm run lint
working-directory: plugins/miro
- name: Test
if: needs.scope.outputs.run_full == 'true'
run: npm test
working-directory: plugins/miro
- name: Verify the committed bundle matches source
if: needs.scope.outputs.run_full == 'true'
run: npm run verify-bundle
working-directory: plugins/miro
- name: Smoke-test the bundled MCP server over stdio
if: needs.scope.outputs.run_full == 'true'
working-directory: plugins/miro
run: |
printf '%s\n%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0.0.0"}}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| MIRO_API_TOKEN=ci-smoke-token timeout 10 node dist/index.min.js > smoke-out.json
grep -q '"miro_create_board"' smoke-out.json
rm -f smoke-out.json
- name: Report not applicable to a docs-only diff
if: needs.scope.outputs.run_full == 'false'
run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the miro plugin build cannot be affected — reporting success."
# The knowledge plugin's video-digest extraction pipeline is a Node package
# with a typecheck and vitest suite no other lane installs or runs. Its
# committed lockfile pins the shared vendor/ packages as packed installs
# (install-links via the package's .npmrc), so manifest/lockfile drift breaks
# `npm ci` only on a clean install — without this lane that breakage stays
# latent until a consumer's machine hits it. Reads the same `scope` lane
# output as plugin-gate and miro-plugin. (Scoping this
# lane to its own paths — skipping it on unrelated diffs — is the same
# separately-tracked optimization noted on miro-plugin.)
video-extraction:
needs: [scope]
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Set up Node
if: needs.scope.outputs.run_full == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: npm
# Lock-file path for cache keying only (KIND-2 data reference under the
# encapsulation contract); logic goes through the skill's scripts/ facade below.
cache-dependency-path: plugins/knowledge/skills/video-digest/extraction/package-lock.json
- name: Install dependencies
if: needs.scope.outputs.run_full == 'true'
run: bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh install
- name: Typecheck
if: needs.scope.outputs.run_full == 'true'
run: bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh build
- name: Test
if: needs.scope.outputs.run_full == 'true'
run: bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh test
- name: Report not applicable to a docs-only diff
if: needs.scope.outputs.run_full == 'false'
run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the video extraction suite cannot be affected — reporting success."
# ai-briefing's build/render pipeline is a Node package (native `node --test`
# suite) no other lane installs or runs — including test/url-policy.test.js,
# which asserts the SSRF gate (lib/url-policy.js) that decides which URLs the
# briefing validator is allowed to dereference: loopback/private/link-local/
# CGN/benchmarking/documentation/multicast rejection, DNS-gate-time A/AAAA
# resolution, and the IPv6 2000::/3 allowlist inversion. Without this lane
# that suite runs locally only, so a future refactor could silently break the
# predicate with nothing red in CI (#1488). Reads the same `scope` lane
# output as plugin-gate, miro-plugin and
# video-extraction. (Scoping this lane to its own paths — skipping it on
# unrelated diffs — is the same separately-tracked optimization noted on
# miro-plugin.)
ai-briefing-build:
needs: [scope]
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Set up Node
if: needs.scope.outputs.run_full == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: npm
# Lock-file path for cache keying only (KIND-2 data reference under the
# encapsulation contract); logic goes through the skill's scripts/ facade below.
cache-dependency-path: plugins/ai-briefing/skills/generate/output/build/package-lock.json
- name: Install dependencies
if: needs.scope.outputs.run_full == 'true'
run: bash plugins/ai-briefing/skills/generate/scripts/run-tests.sh install
- name: Test
if: needs.scope.outputs.run_full == 'true'
run: bash plugins/ai-briefing/skills/generate/scripts/run-tests.sh test
- name: Report not applicable to a docs-only diff
if: needs.scope.outputs.run_full == 'false'
run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the ai-briefing build suite cannot be affected — reporting success."
# The knowledge plugin's course-digest extraction pipeline is a Node package
# (typecheck + vitest suite) no other lane installs or runs — same shape as
# video-extraction, including the shared vendor/ packages pulled in as
# file: dependencies via the package's .npmrc install-links=true. Without
# this lane the suite (utils, the adapter contract, the dometrain/teachable
# adapters, clerk/teachable-sso auth, config, and the hotmart/mux players)
# runs locally only, so a future refactor could silently break it with
# nothing red in CI (#1507). Reads the same `scope` lane output as
# plugin-gate, miro-plugin, video-extraction and
# ai-briefing-build. (Scoping this lane to its own paths — skipping it on
# unrelated diffs — is the same separately-tracked optimization noted on
# miro-plugin.)
course-digest-extraction:
needs: [scope]
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Set up Node
if: needs.scope.outputs.run_full == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: npm
# Lock-file path for cache keying only (KIND-2 data reference under the
# encapsulation contract); logic goes through the skill's scripts/ facade below.
cache-dependency-path: plugins/knowledge/skills/course-digest/extraction/package-lock.json
- name: Install dependencies
if: needs.scope.outputs.run_full == 'true'
run: bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh install
- name: Typecheck
if: needs.scope.outputs.run_full == 'true'
run: bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh build
- name: Test
if: needs.scope.outputs.run_full == 'true'
run: bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh test
- name: Report not applicable to a docs-only diff
if: needs.scope.outputs.run_full == 'false'
run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the course-digest extraction suite cannot be affected — reporting success."
# Skill-regression net: the only lane that invokes the skill-quality checker.
# On a PR it runs the static contract gate (trigger-keyword preservation vs
# the base ref, frontmatter, line caps, broken refs, evals presence) over each
# changed skill under plugins/*/skills/**, replacing the work lane's manual
# high-blast-radius skill-diff read with a deterministic check. Eval sets are
# schema-validated AND quality-linted (check-evals-quality.sh) on every
# event. The job itself never skips (a skipped required job reports success
# to branch protection) — only the PR-diff step is event-gated, and the
# self-test and eval steps give push a passing path.
skill-quality-gate:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
# Self-test first so a broken orchestrator can never mask a real skill
# regression behind a green gate.
- name: Test the changed-skill gate
run: bash scripts/check-changed-skills.test.sh
- name: Gate changed skills on the static skill-quality contract
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-changed-skills.sh "origin/$BASE_REF"
- name: Validate every eval set against the bundled schema
uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
schemafile: plugins/skill-quality/reference/evals.schema.json
files: plugins/*/skills/*/evals/evals.json
# Self-test first so a broken lint cannot mask a regression behind a
# green step.
- name: Test the eval-quality lint
run: bash plugins/skill-quality/scripts/check-evals-quality.test.sh
# Beyond-schema quality lint (FAIL tier: duplicate case ids/names, empty
# criterion items, unresolvable files fixtures; WARN tier stays advisory
# and never fails the step). Whole-repo and static, so it runs on every
# event like the schema step above.
- name: Lint every eval set for quality
run: bash plugins/skill-quality/scripts/check-evals-quality.sh plugins/*/skills/*/evals/evals.json
- name: Test the precompute-compose gate
run: bash scripts/check-skill-precompute-compose.test.sh
- name: Report precompute git+multi-line violations (warn-only)
run: scripts/check-skill-precompute-compose.sh --all
- name: Test the shared listing-budget reporter
run: bash plugins/skill-quality/scripts/check-listing-budget.test.sh
# Report-only (always exit 0 — see the script's own header): pools every
# plugin's skills root into ONE shared aggregate, the shape a consumer who
# installs the whole marketplace actually experiences. Runs on every
# event, unlike the PR-diff-gated step above, since the aggregate is a
# whole-repo property, not a per-change one.
- name: Report the shared skill-listing budget across every plugin
run: bash plugins/skill-quality/scripts/check-listing-budget.sh plugins/*/skills
# Portability lint: skills declared ecosystem/forge/tracker-agnostic must not
# ship bare hardcoded stack/forge/branch/tracker defaults — the coupling class
# the external reviewer re-caught PR after PR. On a PR it scans the skill files
# the change touches (not the whole corpus) against the coupling-token list, so
# enabling a token class prevents NEW coupling without red-lining existing
# violations that member issues own. The job itself never skips (a skipped
# required job reports success to branch protection) — only the PR-diff step is
# event-gated, and the self-test gives push a passing path and stops a broken
# detector masking a real violation behind a green gate.
portability-lint:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Test the portability gate
run: bash scripts/check-skill-portability.test.sh
- name: Gate changed skill files on the portability contract
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-skill-portability.sh "origin/$BASE_REF"
# Shell-portability lint (#1491 / #2704): flags GNU-only constructs in
# changed **/*.sh files and skill markdown under plugins/*/skills/ — a class
# neither shellcheck (syntax/style) nor portability-lint (skill-coupling
# tokens, skill files only) covers, and no runner here uses BSD userland
# (macOS system grep/sed/date/stat/mktemp/sort — a Windows runner's Git Bash
# still ships GNU grep/sed, so it would not help either). Same shape as
# portability-lint: changed-file scoped so enabling a class never red-lines
# main, self-test-first so a broken detector cannot mask a regression behind
# a green gate, job never skips. Skill-md backlog is grandfathered in
# scripts/shell-portability-skill-md-baseline.txt.
shell-portability-lint:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch base
uses: ./.github/actions/checkout-with-base
- name: Test the shell-portability gate
run: bash scripts/check-shell-portability.test.sh
- name: Gate changed shell and skill-md files for GNU-only constructs
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-shell-portability.sh "origin/$BASE_REF"
runner-policy:
name: Runner policy
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: npm
cache-dependency-path: .github/standards/runner-policy/package-lock.json
- name: Install pinned runner-policy dependencies
run: npm ci --prefix .github/standards/runner-policy
- name: Enforce runner policy
run: node .github/standards/runner-policy/runner-policy.mjs --root .
env:
CI_REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }}
ci-status:
needs:
- scope
- hygiene
- hook-utils-sync
- hook-utils-windows
- disk-hygiene-guard-windows
- disk-hygiene-tests
- windows-path-emit-gate
- windows-path-emit-windows
- parse-concern-value-sync
- managed-scope-sync
- state-key-sync
- resolve-convention-pattern-sync
- standards-contract-sync
- cross-plugin-source-drift
- detector-findings-crosswalk-gate
- skill-leaf-name-gate
- skill-count-claim-gate
- userconfig-argv-gate
- hook-exec-form-gate
- summary-reader-parity-gate
- plugin-options-docs-gate
- silent-skip-gate
- stale-base-overlap-gate
- plugin-manifest-presence-gate
- plugin-catalog-enablement-gate
- orphaned-fixture-gate
- fixture-git-isolation-gate
- fleet-finding-test-coverage-gate
- fleet-audit-doc-grammar-gate
- changelog-parity-gate
- contract-slice-prune-gate
- contract-clause-coverage-gate
- docs-only-gate
- lane-coverage-gate
- plugin-gate
- miro-plugin
- video-extraction
- ai-briefing-build
- course-digest-extraction
- runner-policy
- skill-quality-gate
- portability-lint
- shell-portability-lint
- zizmor
# Fail-closed through execution: !cancelled() (never a success-guard) so a
# lane failure still runs this required aggregate and the result join below
# turns it red. A success-guard would skip the job, and a skipped required
# check reports success to branch protection.
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Aggregate lane results
# Derived from the needs graph above — the single source of truth for the
# lane list. Adding a lane to needs automatically extends this check.
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
for r in $RESULTS; do
case "$r" in
success) ;;
*) echo "A required lane did not pass (result: $r)."; exit 1 ;;
esac
done
echo "All required lanes passed."