diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6f95c86ef..676e969c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,6 +249,17 @@ jobs: - name: Run silent-skip-gate tests run: bash scripts/check-silent-skips.test.sh + # 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; the detector self-test runs unconditionally so a broken detector + # cannot mask a regression behind a docs-only short-circuit, and detection is + # fail-closed toward running the full suite. plugin-gate: runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -257,19 +268,32 @@ 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: Set up Node + if: steps.scope.outputs.docs_only != 'true' uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .node-version cache: npm cache-dependency-path: package-lock.json - name: Set up Python + if: steps.scope.outputs.docs_only != 'true' uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.14' cache: pip cache-dependency-path: .github/requirements-ci.txt - name: Install and verify ShellCheck toolchain + if: steps.scope.outputs.docs_only != '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 @@ -283,6 +307,7 @@ jobs: # 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: steps.scope.outputs.docs_only != 'true' run: | npm ci python -m pip install --user --only-binary=:all: --require-hashes \ @@ -290,15 +315,24 @@ jobs: echo "$GITHUB_WORKSPACE/node_modules/.bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Run plugin contract tests + if: steps.scope.outputs.docs_only != 'true' run: scripts/run-plugin-tests.sh - name: Validate plugin and catalog manifests + if: steps.scope.outputs.docs_only != 'true' run: scripts/validate-plugins.sh + - name: Report 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 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 uses the same never-skip, self-test-first, fail-closed docs-only gate as + # plugin-gate. (Scoping this lane to plugins/miro/** specifically — skipping it + # on any non-miro diff — is a broader, separately-tracked optimization.) miro-plugin: runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -307,28 +341,45 @@ 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: Set up Node + if: steps.scope.outputs.docs_only != 'true' uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .node-version cache: npm cache-dependency-path: plugins/miro/package-lock.json - name: Install dependencies + if: steps.scope.outputs.docs_only != 'true' run: npm ci working-directory: plugins/miro - name: Typecheck + if: steps.scope.outputs.docs_only != 'true' run: npm run typecheck working-directory: plugins/miro - name: Lint + if: steps.scope.outputs.docs_only != 'true' run: npm run lint working-directory: plugins/miro - name: Test + if: steps.scope.outputs.docs_only != 'true' run: npm test working-directory: plugins/miro - name: Verify the committed bundle matches source + if: steps.scope.outputs.docs_only != 'true' run: npm run verify-bundle working-directory: plugins/miro - name: Smoke-test the bundled MCP server over stdio + if: steps.scope.outputs.docs_only != 'true' working-directory: plugins/miro run: | printf '%s\n%s\n' \ @@ -337,6 +388,9 @@ jobs: | 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: steps.scope.outputs.docs_only == 'true' + run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the miro plugin build 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 diff --git a/scripts/check-docs-only.sh b/scripts/check-docs-only.sh new file mode 100755 index 0000000000..bf0081a9ca --- /dev/null +++ b/scripts/check-docs-only.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Detect whether a PR's diff is confined to durably-inert documentation paths, so +# the heavy code lanes can report an HONEST evaluated-and-not-applicable success +# instead of running their full suites on a diff that cannot affect them. +# +# scripts/check-docs-only.sh +# +# Emits `docs_only=true|false` to $GITHUB_OUTPUT (and stdout). "true" means every +# path changed vs matches an allowlist prefix in +# scripts/docs-only-paths.txt — a small POSITIVE set of paths proven to feed no +# code lane. The allowlist (not a blocklist) is the design's safety property: any +# path NOT on it, including a brand-new code input, forces docs_only=false and the +# full suite runs. See that file for the inertness-proof contract. +# +# This never SKIPS a required job (a skipped required job's result is not +# `success`, which the ci-status aggregate rejects, and GitHub leaves a +# workflow-skipped required check Pending — troubleshooting-required-status-checks). +# The lane still runs and still reports success; only its inner expensive steps +# are gated on this output, with an explicit not-applicable log line. +# +# Fail-closed toward RUNNING the full suite: an unresolvable base ref, a missing +# or empty allowlist, or an empty diff all emit docs_only=false with a stderr +# note — never a skip we cannot justify. Exit status is 0 for the normal has-code +# case (a code PR is not an error) and for every fail-closed path; non-zero only +# on usage error (no base-ref argument) or an unwritable output file. +# +# DOCS_ONLY_ALLOWLIST overrides the allowlist path (test injection). +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 2 + +emit() { + printf 'docs_only=%s\n' "$1" + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf 'docs_only=%s\n' "$1" >>"$GITHUB_OUTPUT" + fi +} + +BASE="${1:?usage: check-docs-only.sh }" +ALLOWLIST="${DOCS_ONLY_ALLOWLIST:-scripts/docs-only-paths.txt}" + +if [[ ! -f "$ALLOWLIST" ]]; then + echo "check-docs-only: allowlist not found ($ALLOWLIST) — running full suite" >&2 + emit false + exit 0 +fi + +if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null; then + echo "check-docs-only: base ref '$BASE' is not a resolvable commit — running full suite" >&2 + emit false + exit 0 +fi + +# Active prefixes: strip inline comments and surrounding blank lines. +mapfile -t prefixes < <(sed -E 's/#.*//; s/^[[:space:]]+//; s/[[:space:]]+$//' "$ALLOWLIST" | grep -v '^$') + +if [[ ${#prefixes[@]} -eq 0 ]]; then + echo "check-docs-only: allowlist has no active entries — running full suite" >&2 + emit false + exit 0 +fi + +mapfile -t changed < <(git diff --name-only "$BASE") + +if [[ ${#changed[@]} -eq 0 ]]; then + echo "check-docs-only: no changed paths vs '$BASE' — running full suite" >&2 + emit false + exit 0 +fi + +for path in "${changed[@]}"; do + matched=0 + for prefix in "${prefixes[@]}"; do + if [[ "$path" == "$prefix"* ]]; then + matched=1 + break + fi + done + if [[ $matched -eq 0 ]]; then + echo "check-docs-only: '$path' is outside the docs-only allowlist — running full suite" >&2 + emit false + exit 0 + fi +done + +echo "check-docs-only: all ${#changed[@]} changed path(s) within the docs-only allowlist" >&2 +emit true +exit 0 diff --git a/scripts/check-docs-only.test.sh b/scripts/check-docs-only.test.sh new file mode 100755 index 0000000000..c0b26b123f --- /dev/null +++ b/scripts/check-docs-only.test.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Unit tests for check-docs-only.sh. Each scenario builds a throwaway git repo +# with the REAL shipped allowlist (scripts/docs-only-paths.txt), commits a base +# tree, commits a change, and asserts the emitted docs_only flag. Using the real +# allowlist makes these tests the honesty proof the #532 liveness convention +# wants: the README / protocol / taxonomy cases below fail the moment someone +# widens the allowlist to cover a doc a code lane actually reads. +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SELF_DIR/check-docs-only.sh" +ALLOWLIST="$SELF_DIR/docs-only-paths.txt" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +mk_repo() { + local dir + dir="$(mktemp -d)" + git -C "$dir" init -q + git -C "$dir" config user.email t@t.test + git -C "$dir" config user.name test + git -C "$dir" config commit.gpgsign false + git -C "$dir" config core.autocrlf false + mkdir -p "$dir/scripts" + cp "$SCRIPT" "$dir/scripts/check-docs-only.sh" + cp "$ALLOWLIST" "$dir/scripts/docs-only-paths.txt" + # A committed base tree spanning every path class the assertions touch. + mkdir -p "$dir/docs/topics/example" "$dir/docs" "$dir/plugins/p1/skills/alpha" \ + "$dir/plugins/miro" "$dir/.github/workflows" + printf 'seed\n' >"$dir/docs/topics/example/PLAN.md" + printf 'seed\n' >"$dir/README.md" + printf 'seed\n' >"$dir/docs/PLUGIN-ARTIFACT-PROTOCOL.md" + printf 'seed\n' >"$dir/docs/CATALOG-TAXONOMY.md" + printf 'seed\n' >"$dir/docs/MIGRATION-PLAYBOOK.md" + printf 'seed\n' >"$dir/plugins/p1/skills/alpha/SKILL.md" + printf 'seed\n' >"$dir/plugins/miro/index.ts" + printf 'seed\n' >"$dir/.github/workflows/ci.yml" + printf 'seed\n' >"$dir/package-lock.json" + git -C "$dir" add -A >/dev/null + git -C "$dir" commit -qm base + printf '%s' "$dir" +} + +# assert_flag