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
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -283,22 +307,32 @@ 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 \
--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: 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
Expand All @@ -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' \
Expand All @@ -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
Expand Down
88 changes: 88 additions & 0 deletions scripts/check-docs-only.sh
Original file line number Diff line number Diff line change
@@ -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 <base-ref>
#
# Emits `docs_only=true|false` to $GITHUB_OUTPUT (and stdout). "true" means every
# path changed vs <base-ref> 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 <base-ref>}"
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
135 changes: 135 additions & 0 deletions scripts/check-docs-only.test.sh
Original file line number Diff line number Diff line change
@@ -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 <label> <expected true|false> <repo-relpath-to-touch...>
# Commits an edit to each given path, runs the detector against the base sha,
# and asserts the emitted docs_only value.
assert_flag() {
local label="$1" expected="$2"
shift 2
local repo base p out
repo="$(mk_repo)"
base="$(git -C "$repo" rev-parse HEAD)"
for p in "$@"; do
mkdir -p "$repo/$(dirname "$p")"
printf 'changed %s\n' "$RANDOM" >"$repo/$p"
done
git -C "$repo" add -A >/dev/null
git -C "$repo" commit -qm change >/dev/null
out="$(cd "$repo" && bash scripts/check-docs-only.sh "$base" 2>/dev/null)"
if [[ "$out" == "docs_only=$expected" ]]; then
ok "$label -> $expected"
else
fail "$label: expected docs_only=$expected, got '$out'"
fi
rm -rf "$repo"
}

# --- the honest win: a diff confined to docs/topics/ ------------------------
assert_flag "docs/topics-only" true "docs/topics/example/PLAN.md"
assert_flag "docs/topics new nested file" true "docs/topics/new-precedent/NOTES.md"

# --- the grep payoff: docs a code lane actually consumes are NOT docs-only --
assert_flag "README.md (catalog --check reads it)" false "README.md"
assert_flag "docs/PLUGIN-ARTIFACT-PROTOCOL.md (validator reads it)" false "docs/PLUGIN-ARTIFACT-PROTOCOL.md"
assert_flag "docs/CATALOG-TAXONOMY.md (catalog reads it)" false "docs/CATALOG-TAXONOMY.md"

# --- conservative: any non-topics doc, and every code class, run full ------
assert_flag "non-topics docs/ file" false "docs/MIGRATION-PLAYBOOK.md"
assert_flag "plugin SKILL.md is code" false "plugins/p1/skills/alpha/SKILL.md"
assert_flag "plugin source" false "plugins/miro/index.ts"
assert_flag "scripts/ change" false "scripts/run-plugin-tests.sh"
assert_flag ".github/ workflow change" false ".github/workflows/ci.yml"
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"

# --- 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)"
rc=$?
if [[ "$out" == "docs_only=false" && $rc -eq 0 ]]; then
ok "unresolvable base ref -> false, exit 0"
else
fail "bad base ref: expected docs_only=false exit 0, got '$out' rc=$rc"
fi
rm -rf "$repo"

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

# --- GITHUB_OUTPUT is written for the step to consume ----------------------
repo="$(mk_repo)"
base="$(git -C "$repo" rev-parse HEAD)"
printf 'changed\n' >"$repo/docs/topics/example/PLAN.md"
git -C "$repo" add -A >/dev/null && git -C "$repo" commit -qm change >/dev/null
gho="$(mktemp)"
(cd "$repo" && GITHUB_OUTPUT="$gho" bash scripts/check-docs-only.sh "$base" >/dev/null 2>&1)
if grep -qx 'docs_only=true' "$gho"; then
ok "writes docs_only to GITHUB_OUTPUT"
else
fail "GITHUB_OUTPUT missing docs_only=true, got: $(cat "$gho")"
fi
rm -rf "$repo" "$gho"

printf '\nPASS=%d FAIL=%d\n' "$PASS" "$FAIL"
((FAIL == 0))
26 changes: 26 additions & 0 deletions scripts/docs-only-paths.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 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.
#
# ALLOWLIST, NOT BLOCKLIST — by design. A blocklist ("skip on any doc the
# validators don't currently read") silently turns false-green the day a
# validator starts reading a new doc. This positive list fails safe: any path
# NOT listed here — including a brand-new code input — forces the full suite to
# 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.
# 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
# docs/topics/ never matches a sibling like docs/topics-archive/.

# Operator precedent-codification & session working docs. Read by humans and by
# the docs-hygiene audit skills' OWN runtime (never by a CI lane); the audit
# lanes here scan changed files in place, not this tree.
docs/topics/
Loading