Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ jobs:
# 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 and the four
# check-jsonschema steps 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 and exec-bit are NOT
# gated: both scan the whole repo extension- and directory-agnostically
# (ShellCheck lints every tracked *.sh/*.bash, exec-bit flags every tracked
# shebang file recorded 100644), so a shell/shebang file added under an
# otherwise docs-only prefix like docs/topics/ is real input they must still
# catch — gating them would open a fail-closed hole. The job NEVER skips — only
# the path-scoped steps are gated, via the same never-skip, self-test-first,
# fail-closed detector plugin-gate/miro-plugin use. The detector self-test runs
# unconditionally so a broken detector cannot mask a regression, and detection
# is fail-closed toward running the full suite. 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:
runs-on: ubuntu-24.04
timeout-minutes: 15
Expand All @@ -34,7 +53,16 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Full history so the PR base ref resolves for the docs-only diff.
fetch-depth: 0
- name: Test the docs-only detector
run: bash scripts/check-docs-only.test.sh
- name: Detect a docs-only diff
id: scope
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: scripts/check-docs-only.sh "origin/$BASE_REF"
- name: Lint markdown
id: markdown
continue-on-error: true
Expand Down Expand Up @@ -74,32 +102,37 @@ jobs:

- name: Lint workflows
id: actionlint
if: steps.scope.outputs.docs_only != '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: steps.scope.outputs.docs_only != '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: steps.scope.outputs.docs_only != '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
- name: Validate dependabot.yml
id: dependabot_schema
if: steps.scope.outputs.docs_only != '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: steps.scope.outputs.docs_only != 'true'
continue-on-error: true
uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@c2654182bc2d78f7909795df78304d482aa69226 # c265418 2026-07-13
with:
Expand Down Expand Up @@ -150,24 +183,37 @@ jobs:
:(exclude)plugins/code-tidying/skills/audit-comment-residue/scripts/**
:(exclude)plugins/code-tidying/skills/audit-comment-residue/evals/**

- name: Report docs-irrelevant checks not applicable to a docs-only diff
if: steps.scope.outputs.docs_only == 'true'
run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the path-scoped linters (actionlint, check-jsonschema x4) cannot be affected — reporting success for them. ShellCheck and exec-bit 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)
# carry `if: docs_only != '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 (docs_only == 'true') is the exact inverse of each gate, so
# the two can never disagree. ShellCheck and exec-bit 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=${{ steps.actionlint.outcome }}
marketplace-schema=${{ steps.marketplace_schema.outcome }}
plugin-schema=${{ steps.plugin_schema.outcome }}
dependabot-schema=${{ steps.dependabot_schema.outcome }}
workflow-schema=${{ steps.workflow_schema.outcome }}
actionlint=${{ steps.scope.outputs.docs_only == 'true' && 'success' || steps.actionlint.outcome }}
marketplace-schema=${{ steps.scope.outputs.docs_only == 'true' && 'success' || steps.marketplace_schema.outcome }}
plugin-schema=${{ steps.scope.outputs.docs_only == 'true' && 'success' || steps.plugin_schema.outcome }}
dependabot-schema=${{ steps.scope.outputs.docs_only == 'true' && 'success' || steps.dependabot_schema.outcome }}
workflow-schema=${{ steps.scope.outputs.docs_only == 'true' && 'success' || steps.workflow_schema.outcome }}
exec-bit=${{ steps.exec_bit.outcome }}
machine-specific-paths=${{ steps.machine_paths.outcome }}
eol-renormalize=${{ steps.eol.outcome }}
Expand Down
8 changes: 8 additions & 0 deletions scripts/check-docs-only.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ assert_flag "toolchain lockfile" false "package-lock.json"
assert_flag "sibling of an allowed prefix (docs/topics-archive/)" false "docs/topics-archive/old.md"
assert_flag "mixed docs+code" false "docs/topics/example/PLAN.md" "plugins/p1/skills/alpha/SKILL.md"

# --- hygiene job's docs-irrelevant step set: their inputs are NOT docs-only --
# ShellCheck (scripts/, plugins/**), actionlint + workflow-schema (.github/
# workflows/) are already pinned false above; these pin the remaining check-
# jsonschema inputs so a widened allowlist that let one through fails the suite.
assert_flag "marketplace manifest (marketplace-schema reads it)" false ".claude-plugin/marketplace.json"
assert_flag "plugin manifest (plugin-schema reads it)" false "plugins/p1/.claude-plugin/plugin.json"
assert_flag "dependabot.yml (dependabot-schema reads it)" false ".github/dependabot.yml"

# --- fail-closed paths: emit false, exit 0 (run full, never block) ---------
repo="$(mk_repo)"
out="$(cd "$repo" && bash scripts/check-docs-only.sh "does-not-exist" 2>/dev/null)"
Expand Down
31 changes: 23 additions & 8 deletions scripts/docs-only-paths.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Docs-only allowlist — path prefixes that feed NO code lane, so a PR whose diff
# is confined to them lets the heavy lanes (plugin-gate, miro-plugin) report an
# honest evaluated-and-not-applicable success instead of running their full
# suites. Consumed by scripts/check-docs-only.sh.
# is confined to them lets the heavy lanes (plugin-gate, miro-plugin) and the
# hygiene job's path-scoped linters report an honest
# evaluated-and-not-applicable success instead of running their full suites.
# Consumed by scripts/check-docs-only.sh.
#
# ALLOWLIST, NOT BLOCKLIST — by design. A blocklist ("skip on any doc the
# validators don't currently read") silently turns false-green the day a
Expand All @@ -10,11 +11,25 @@
# run. That is why the seed is deliberately narrow.
#
# Adding an entry is a two-part proof, enforced by the self-test:
# 1. The prefix must feed no code lane. Today the only lanes this gate wraps
# are plugin-gate (runs hermetic plugins/**/*.test.sh, then validates
# manifests + catalog: reads README.md, docs/PLUGIN-ARTIFACT-PROTOCOL.md,
# docs/CATALOG-TAXONOMY.md) and miro-plugin (reads only plugins/miro/**).
# NONE of those read the paths below — that is the inertness proof.
# 1. The prefix must feed no code lane. Today the lanes this gate wraps are:
# - plugin-gate: hermetic plugins/**/*.test.sh, then manifest + catalog
# validation (reads README.md, docs/PLUGIN-ARTIFACT-PROTOCOL.md,
# docs/CATALOG-TAXONOMY.md);
# - miro-plugin: reads only plugins/miro/**;
# - hygiene's path-scoped subset — actionlint + the workflow
# check-jsonschema (read .github/workflows/**), and the marketplace /
# plugin / dependabot check-jsonschema steps (read
# .claude-plugin/marketplace.json, plugins/*/.claude-plugin/plugin.json,
# .github/dependabot.yml).
# NONE of those read the paths below — that is the inertness proof. The
# remaining hygiene checks stay UNCONDITIONAL and are not gated on this
# list: markdown, typos, editorconfig, gitleaks, eol-renormalize,
# comment-hygiene, machine-specific-paths read the changed docs directly;
# ShellCheck (lints every tracked *.sh/*.bash) and exec-bit (flags every
# tracked shebang file recorded 100644) scan the whole repo extension- and
# directory-agnostically, so a shell/shebang file added under docs/topics/
# IS input they must still catch — gating them would open a fail-closed
# hole.
# 2. A matching case in scripts/check-docs-only.test.sh must pin it.
#
# Prefixes match by literal string prefix; keep the trailing slash so
Expand Down
Loading