diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7327457ba..805c9f7aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1366,31 +1366,36 @@ jobs: if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} runs-on: ubuntu-24.04 timeout-minutes: 30 - # FOUR LEGS ON A PULL REQUEST, ONE ON EVERY OTHER EVENT. The job key does - # not change, so `ci-status.needs` and scripts/check-lane-coverage.sh are - # untouched; what changes is that the affected-suite selection is - # partitioned across four runners instead of executed by one. The dominant - # step was measured at 435 s p50 running 146 to 165 suites strictly - # sequentially, and the selector's own header records parallelism WITHIN a - # runner as sublinear, so more runners is the lever that is left. + # FOUR LEGS ON EVERY EVENT. The job key does not change, so + # `ci-status.needs` and scripts/check-lane-coverage.sh are untouched; what + # changes is that the contract suites are partitioned across four runners + # instead of executed by one: the affected selection on a pull request, the + # full corpus on a push (#3705). The dominant step was measured at 435 s p50 + # running 146 to 165 suites strictly sequentially, and the selector's own + # header records parallelism WITHIN a runner as sublinear, so more runners + # is the lever that is left. # # `fail-fast: false` because a leg is a slice of one test corpus: cancelling # the other three on the first failure would hide every other failure in the # same change set behind whichever leg lost the race. # - # The matrix is an expression rather than a literal so `push` keeps exactly - # the behaviour it has today: one leg, running the full corpus once through - # `run-plugin-tests.sh --jobs 3`. Four legs on `push` would run that corpus - # four times over. - # # The shard spec is read from `strategy.job-index` and `strategy.job-total`, # not from `matrix.leg`. The matrix key exists to SIZE the fan-out; the # runtime pair is what the shard arithmetic uses, so the numerator and the # denominator come from the same place and cannot drift from each other or # from the matrix that produced them. + # + # EVERY STEP AFTER THE CONTRACT SUITES RUNS ON LEG 0 ONLY, through a + # `[ "$LEG" = 0 ] || exit 0` first line rather than a step condition: + # scripts/check-docs-only-gate.sh accepts exactly one condition shape on a + # gated step, and a step that exits 0 reports success, never `skipped`. strategy: fail-fast: false - matrix: ${{ github.event_name == 'pull_request' && fromJSON('{"leg":[0,1,2,3]}') || fromJSON('{"leg":[0]}') }} + matrix: + leg: [0, 1, 2, 3] + env: + LEG: ${{ strategy.job-index }} + LEGS: ${{ strategy.job-total }} steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1480,18 +1485,17 @@ jobs: # instead, the matrix result would be `skipped` and `ci-status`, which is # fail-closed on `skipped`, would red the pull request. # - # The UNMAPPED fallback runs the full corpus, and run-plugin-tests.sh - # takes no shard, so it is confined to leg 0 rather than run four times. - # No run in the 23-run step-level capture hit that path. + # A push, and the UNMAPPED fallback, run the full corpus through + # run-plugin-tests.sh with the same `--shard`, which partitions its + # discovered suites the same way, so the four legs run it once between + # them. - name: Run plugin contract tests if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} - LEG: ${{ strategy.job-index }} - LEGS: ${{ strategy.job-total }} run: | if [ "$GITHUB_EVENT_NAME" != pull_request ]; then - scripts/run-plugin-tests.sh --jobs 3 + scripts/run-plugin-tests.sh --jobs 3 --shard "$LEG/$LEGS" exit 0 fi err="$RUNNER_TEMP/affected-tests.err" @@ -1531,13 +1535,9 @@ jobs: 1) if grep -q '^UNMAPPED:' "$err"; then summary=$(grep -m1 '^UNMAPPED:' "$err") - if [ "$LEG" != 0 ]; then - echo "::warning::$summary Leg $LEG defers the full-corpus fallback to leg 0." - exit 0 - fi echo "::warning::$summary Falling back to the full contract corpus." printf 'affected-tests fallback: %s\n' "$summary" >> "$GITHUB_STEP_SUMMARY" - scripts/run-plugin-tests.sh --jobs 3 + scripts/run-plugin-tests.sh --jobs 3 --shard "$LEG/$LEGS" else exit 1 fi @@ -1553,53 +1553,71 @@ jobs: # someone remembers to run it is exactly the rot it guards against. - name: Run cheat-sheet generator tests if: needs.changes.outputs.run_tests == 'true' - run: bash scripts/generate-cheatsheet.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/generate-cheatsheet.test.sh - name: Run affected-suite selector tests if: needs.changes.outputs.run_tests == 'true' - run: bash scripts/affected-tests.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/affected-tests.test.sh # The html-assets gate needs the root npm ci from this job (its linter is # the exact-pinned htmlhint in node_modules/.bin): a tracked rendered-view # asset must lint clean and be registered in the gate's manifest, per the # rendered-views convention. - name: Test the html-assets gate if: needs.changes.outputs.run_tests == 'true' - run: bash scripts/check-html-assets.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/check-html-assets.test.sh - name: Check rendered-view HTML assets if: needs.changes.outputs.run_tests == 'true' - run: scripts/check-html-assets.sh + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/check-html-assets.sh # The loop-closure snippet reference is copied verbatim by every adopting # lane, so its shared helpers get a behavioral suite on top of the markup # lint above: markup linting cannot tell whether esc still escapes the # single quote or whether safeHref still refuses a javascript: URL. - name: Test the loop-closure snippet helpers if: needs.changes.outputs.run_tests == 'true' - run: bash scripts/check-loop-closure-helpers.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/check-loop-closure-helpers.test.sh # scripts/lib/changed-files.sh is 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. - name: Run shared changed-file resolver tests if: needs.changes.outputs.run_tests == 'true' - run: bash scripts/lib/changed-files.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/lib/changed-files.test.sh # scripts/lib/read-list.sh is 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. - name: Run shared list-file reader tests if: needs.changes.outputs.run_tests == 'true' - run: bash scripts/lib/read-list.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/lib/read-list.test.sh # scripts/lib/test-harness.sh is 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.changes.outputs.run_tests == 'true' - run: bash scripts/lib/test-harness.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/lib/test-harness.test.sh # Self-test 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.changes.outputs.run_tests == 'true' - run: bash scripts/validate-plugin-contracts.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/validate-plugin-contracts.test.sh - name: Validate plugin and catalog manifests if: needs.changes.outputs.run_tests == 'true' # validate-plugins.sh runs scripts/validate-plugin-contracts.mjs, whose @@ -1609,7 +1627,9 @@ jobs: # origin/ on pull_request, so the ref resolves here. env: VALIDATE_CONTRACTS_BASE_REF: origin/${{ github.base_ref || 'main' }} - run: scripts/validate-plugins.sh + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/validate-plugins.sh # The shared-library sync gates. Each keeps its three steps: the byte # equality check against the canonical copy, that library's own suite, @@ -1618,15 +1638,20 @@ jobs: # on a push rather than diffing against a ref that does not exist. - name: Verify plugin copies match the shared lib if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-rewrite-guard.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-rewrite-guard.sh --check - name: Run shared lib tests if: needs.changes.outputs.run_tests == 'true' - run: bash lib/rewrite-guard.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash lib/rewrite-guard.test.sh - name: Verify carrying plugins bumped when the lib changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1635,15 +1660,20 @@ jobs: - name: Verify plugin copies match the shared parser if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-parse-concern-value.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-parse-concern-value.sh --check - name: Run shared parser tests if: needs.changes.outputs.run_tests == 'true' - run: bash lib/parse-concern-value.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash lib/parse-concern-value.test.sh - name: Verify consuming plugins bumped when the lib changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1652,15 +1682,20 @@ jobs: - name: Verify managed-scope cluster matches canonical if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-managed-scope.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-managed-scope.sh --check - name: Run managed-scope tests if: needs.changes.outputs.run_tests == 'true' - run: bash plugins/claude-config/lib/managed-scope.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/claude-config/lib/managed-scope.test.sh - name: Verify carrying plugins bumped when canonical changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1669,15 +1704,20 @@ jobs: - name: Verify state-key cluster matches canonical if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-state-key.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-state-key.sh --check - name: Run state-key tests if: needs.changes.outputs.run_tests == 'true' - run: bash plugins/claude-config/lib/state-key.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/claude-config/lib/state-key.test.sh - name: Verify carrying plugins bumped when canonical changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1686,15 +1726,20 @@ jobs: - name: Verify spawn-noise cluster matches canonical if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-spawn-noise.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-spawn-noise.sh --check - name: Run spawn-noise tests if: needs.changes.outputs.run_tests == 'true' - run: bash plugins/claude-ops/lib/spawn_noise.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/claude-ops/lib/spawn_noise.test.sh - name: Verify carrying plugins bumped when canonical changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1703,15 +1748,20 @@ jobs: - name: Verify check-retirements cluster matches canonical if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-check-retirements.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-check-retirements.sh --check - name: Run check-retirements tests if: needs.changes.outputs.run_tests == 'true' - run: bash plugins/claude-config/lib/check-retirements.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/claude-config/lib/check-retirements.test.sh - name: Verify carrying plugins bumped when canonical changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1720,12 +1770,15 @@ jobs: - name: Verify legacy-statusline-detect cluster matches canonical if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-legacy-statusline-detect.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-legacy-statusline-detect.sh --check - name: Verify carrying plugins bumped when canonical changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1734,12 +1787,15 @@ jobs: - name: Verify unwrap-before-compose cluster matches canonical if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-unwrap-before-compose.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-unwrap-before-compose.sh --check - name: Verify carrying plugins bumped when canonical changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1748,12 +1804,15 @@ jobs: - name: Verify context-zone cluster matches canonical if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-context-zone.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-context-zone.sh --check - name: Verify carrying plugins bumped when canonical changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1762,18 +1821,25 @@ jobs: - name: Verify resolve-convention-home cluster matches canonical if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-resolve-convention-home.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-resolve-convention-home.sh --check - name: Run resolve-convention-home tests if: needs.changes.outputs.run_tests == 'true' - run: bash plugins/claude-config/lib/resolve-convention-home.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/claude-config/lib/resolve-convention-home.test.sh - name: Run the plugin-quality pilot consumer-repo sim if: needs.changes.outputs.run_tests == 'true' - run: bash scripts/pilot-plugin-quality-sim.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/pilot-plugin-quality-sim.test.sh - name: Verify carrying plugins bumped when canonical changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1782,15 +1848,20 @@ jobs: - name: Verify plugin copies match the shared resolver if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-resolve-convention-pattern.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-resolve-convention-pattern.sh --check - name: Run shared resolver tests if: needs.changes.outputs.run_tests == 'true' - run: bash lib/resolve-convention-pattern.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash lib/resolve-convention-pattern.test.sh - name: Verify consuming plugins bumped when the lib changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1799,15 +1870,20 @@ jobs: - name: Verify plugin copies match the shared regen script if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-index-regen.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-index-regen.sh --check - name: Run shared regen-script tests if: needs.changes.outputs.run_tests == 'true' - run: bash lib/index-regen.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash lib/index-regen.test.sh - name: Verify consuming plugins bumped when the lib changed if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1816,15 +1892,20 @@ jobs: - name: Verify plugin binding copies match the canonical contract if: needs.changes.outputs.run_tests == 'true' - run: scripts/sync-standards-contract.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/sync-standards-contract.sh --check - name: Run sync-standards-contract tests if: needs.changes.outputs.run_tests == 'true' - run: bash scripts/sync-standards-contract.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/sync-standards-contract.test.sh - name: Verify contract, changelog, and carrying plugins bumped together if: needs.changes.outputs.run_tests == 'true' env: BASE_REF: ${{ github.base_ref }} run: | + [ "$LEG" = 0 ] || exit 0 if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." exit 0 @@ -1833,10 +1914,14 @@ jobs: - name: Check for unregistered or drifted cross-plugin source clusters if: needs.changes.outputs.run_tests == 'true' - run: scripts/check-cross-plugin-source-drift.sh --check + run: | + [ "$LEG" = 0 ] || exit 0 + scripts/check-cross-plugin-source-drift.sh --check - name: Run cross-plugin-source-drift tests if: needs.changes.outputs.run_tests == 'true' - run: bash scripts/check-cross-plugin-source-drift.test.sh + run: | + [ "$LEG" = 0 ] || exit 0 + bash scripts/check-cross-plugin-source-drift.test.sh # 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 @@ -1846,28 +1931,39 @@ jobs: # consumer's machine. - name: Install dependencies if: needs.changes.outputs.run_node == 'true' - run: npm ci + run: | + [ "$LEG" = 0 ] || exit 0 + npm ci working-directory: plugins/miro/server - name: Typecheck if: needs.changes.outputs.run_node == 'true' - run: npm run typecheck + run: | + [ "$LEG" = 0 ] || exit 0 + npm run typecheck working-directory: plugins/miro/server - name: Lint if: needs.changes.outputs.run_node == 'true' - run: npm run lint + run: | + [ "$LEG" = 0 ] || exit 0 + npm run lint working-directory: plugins/miro/server - name: Test if: needs.changes.outputs.run_node == 'true' - run: npm test + run: | + [ "$LEG" = 0 ] || exit 0 + npm test working-directory: plugins/miro/server - name: Verify the committed bundle matches source if: needs.changes.outputs.run_node == 'true' - run: npm run verify-bundle + run: | + [ "$LEG" = 0 ] || exit 0 + npm run verify-bundle working-directory: plugins/miro/server - name: Smoke-test the bundled MCP server over stdio if: needs.changes.outputs.run_node == 'true' working-directory: plugins/miro/server run: | + [ "$LEG" = 0 ] || exit 0 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":{}}' \ @@ -1882,13 +1978,19 @@ jobs: # drift breaks `npm ci` only on a clean install. - name: Install dependencies if: needs.changes.outputs.run_node == 'true' - run: bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh install + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh install - name: Typecheck if: needs.changes.outputs.run_node == 'true' - run: bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh build + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh build - name: Test if: needs.changes.outputs.run_node == 'true' - run: bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh test + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh test # ai-briefing's build/render pipeline is a Node package (native # `node --test` suite) no other lane installs or runs — including @@ -1899,10 +2001,14 @@ jobs: # CI (#1488). - name: Install dependencies if: needs.changes.outputs.run_node == 'true' - run: bash plugins/ai-briefing/skills/generate/scripts/run-tests.sh install + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/ai-briefing/skills/generate/scripts/run-tests.sh install - name: Test if: needs.changes.outputs.run_node == 'true' - run: bash plugins/ai-briefing/skills/generate/scripts/run-tests.sh test + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/ai-briefing/skills/generate/scripts/run-tests.sh test # The knowledge plugin's course-digest extraction pipeline is a Node # package (typecheck + vitest suite) no other lane installs or runs — @@ -1911,13 +2017,19 @@ jobs: # install-links=true (#1507). - name: Install dependencies if: needs.changes.outputs.run_node == 'true' - run: bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh install + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh install - name: Typecheck if: needs.changes.outputs.run_node == 'true' - run: bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh build + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh build - name: Test if: needs.changes.outputs.run_node == 'true' - run: bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh test + run: | + [ "$LEG" = 0 ] || exit 0 + bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh test # #2871: the full test_hygiene module. Its ten TestCase classes need a # check that cannot take hygiene.test.sh's SKIP-when-Python-absent path. @@ -1927,7 +2039,9 @@ jobs: - name: Run disk-hygiene tests if: needs.changes.outputs.run_python == 'true' working-directory: plugins/disk-hygiene/skills/clean/scripts - run: python -m unittest -v test_hygiene + run: | + [ "$LEG" = 0 ] || exit 0 + python -m unittest -v test_hygiene - name: Report not applicable to a docs-only diff if: needs.changes.outputs.run_tests == 'false' diff --git a/scripts/run-plugin-tests.sh b/scripts/run-plugin-tests.sh index 75c89887b..9ec9928df 100755 --- a/scripts/run-plugin-tests.sh +++ b/scripts/run-plugin-tests.sh @@ -3,7 +3,7 @@ # hook tests (.claude/hooks/*.test.sh) and fail if any fails. The repo-local # hooks are tracked policy with the same test conventions as plugin hooks. # -# scripts/run-plugin-tests.sh [--strict-skips] [--jobs N] [--root DIR] +# scripts/run-plugin-tests.sh [--strict-skips] [--jobs N] [--root DIR] [--shard I/N] # # Each test is self-contained and cwd-independent; an individual test SKIPs # (exit 0) when an optional tool it needs (shellcheck, shfmt, ...) is absent, so @@ -28,6 +28,12 @@ # is an error rather than an ignored line: an allowlist must not outlive what # it excuses. # +# SHARDING. --shard I/N keeps leg I of N of the sorted discovered suites, by +# index modulo N, with scripts/affected-tests.sh's semantics: the union of legs +# 0..N-1 is the whole corpus and no two legs share a suite, so N runners run it +# once between them. The allowlist's stale guard still reads the full discovery, +# each leg runs its own serial subset first, and a leg that draws nothing exits 0. +# # Output stays readable under parallelism. Every suite's output is captured to # a file and replayed as one block under a lock (`=== path ===`, the suite's own # lines, then `PASS:` or `FAIL:`), so blocks from concurrent suites never @@ -59,10 +65,24 @@ runner="$script_dir/${BASH_SOURCE[0]##*/}" SERIAL_LIST="${PLUGIN_TEST_SERIAL_LIST:-$script_dir/run-plugin-tests-serial.txt}" usage() { - echo "usage: run-plugin-tests.sh [--strict-skips] [--jobs N] [--root DIR]" >&2 + echo "usage: run-plugin-tests.sh [--strict-skips] [--jobs N] [--root DIR] [--shard I/N]" >&2 exit 2 } +# parse_shard : accept exactly `/` with n >= 1 and 0 <= i < n, +# mirroring scripts/affected-tests.sh. The default is 0/1 rather than unset, so +# an explicit `--shard ""` (an environment variable that expanded to nothing) +# is rejected instead of silently running every suite on every leg. +parse_shard() { + local spec="$1" i n + [[ "$spec" =~ ^[0-9]+/[0-9]+$ ]] || return 1 + i=$((10#${spec%%/*})) + n=$((10#${spec##*/})) + ((n >= 1 && i < n)) || return 1 + shard_index="$i" + shard_total="$n" +} + # --worker : internal. Runs ONE suite with its output captured # under $PLUGIN_TEST_LOG_DIR, records its exit status beside the capture, then # prints the whole block under the print lock. Reached only from the dispatch @@ -101,6 +121,7 @@ fi strict_skips=0 jobs="${PLUGIN_TEST_JOBS:-1}" root="" +shard_spec="0/1" while (($# > 0)); do case "$1" in --strict-skips) strict_skips=1 ;; @@ -115,6 +136,12 @@ while (($# > 0)); do root="$2" shift ;; + --shard) + [[ $# -ge 2 ]] || usage + shard_spec="$2" + shift + ;; + --shard=*) shard_spec="${1#--shard=}" ;; *) usage ;; esac shift @@ -123,6 +150,10 @@ if [[ ! "$jobs" =~ ^[1-9][0-9]*$ ]]; then echo "error: --jobs must be a positive integer (got '$jobs')" >&2 exit 2 fi +if ! parse_shard "$shard_spec"; then + echo "error: --shard wants / with total >= 1 and 0 <= index < total (got '$shard_spec')" >&2 + exit 2 +fi if [[ -n "$root" ]]; then cd "$root" || exit 2 @@ -165,6 +196,20 @@ for s in ${serial_entries[@]+"${serial_entries[@]}"}; do fi is_serial["$s"]=1 done +# The partition, AFTER the stale guard so an allowlist entry that lands on +# another leg is still matched against the full discovery. +if ((shard_total > 1)); then + leg=() + for ((si = shard_index; si < ${#tests[@]}; si += shard_total)); do + leg+=("${tests[$si]}") + done + echo "shard: leg $shard_index of $shard_total keeps ${#leg[@]} of ${#tests[@]} suite(s)." + if ((${#leg[@]} == 0)); then + echo "This leg has no suites to run; the other legs carry the corpus." + exit 0 + fi + tests=("${leg[@]}") +fi serial_suites=() parallel_suites=() suite_keys=() diff --git a/scripts/run-plugin-tests.test.sh b/scripts/run-plugin-tests.test.sh index 294c2161a..6ac75fd81 100755 --- a/scripts/run-plugin-tests.test.sh +++ b/scripts/run-plugin-tests.test.sh @@ -242,6 +242,43 @@ PLUGIN_TEST_SERIAL_LIST="$empty_list" run_runner 0 "a path containing a colon ru assert_output_has "the colon path is reported whole" "PASS: plugins/od:d/name.test.sh" assert_output_has "the colon path keeps its output" "marker from the colon path" +# --- --shard partitions the corpus -------------------------------------------- +# +# Sorted, the seven suites are a..g at indexes 0..6, so of three legs leg 0 +# draws a, d, g; leg 1 draws b, e; leg 2 draws c, f. The allowlist names b and +# c, neither of which is on leg 0: the stale guard must still read the full +# discovery there rather than call both entries stale. +r="$(make_root shard)" +for name in a b c d e f g; do + write_suite "$r" "plugins/$name/$name.test.sh" 'echo ok' +done +shard_list="$scratch/shard-serial.txt" +printf 'plugins/b/b.test.sh\nplugins/c/c.test.sh\n' >"$shard_list" +PLUGIN_TEST_SERIAL_LIST="$shard_list" run_runner 0 "unsharded reference run" --root "$r" +whole="$(grep '^PASS: ' <<<"$RUN_OUTPUT" | sort)" +legs="" +for leg in 0 1 2; do + PLUGIN_TEST_SERIAL_LIST="$shard_list" run_runner 0 "leg $leg of 3 exits 0" --root "$r" --jobs 2 --shard "$leg/3" + legs+="$(grep '^PASS: ' <<<"$RUN_OUTPUT")"$'\n' + [[ "$leg" == 1 ]] && assert_output_has "leg 1 runs its own serial subset" "Suites: 2 (1 serial, 1 across up to 2 job(s))" +done +assert_output_has "the leg reports what it kept" "shard: leg 2 of 3 keeps 2 of 7 suite(s)." +if [[ "$(grep . <<<"$legs" | sort)" == "$whole" ]]; then + ok "the legs are disjoint and their union is the unsharded corpus" +else + fail "legs do not partition the corpus; unsharded: $whole; legs: $legs" +fi + +r="$(make_root shard-empty)" +write_suite "$r" plugins/a/a.test.sh 'echo ok' +PLUGIN_TEST_SERIAL_LIST="$empty_list" run_runner 0 "a leg that draws nothing exits 0" --root "$r" --shard 1/2 +assert_output_lacks "the empty leg runs nothing" "=== plugins/" + +for spec in 2/2 x 1 1/0 ''; do + PLUGIN_TEST_SERIAL_LIST="$empty_list" run_runner 2 "--shard '$spec' is rejected" --root "$r" --shard "$spec" +done +assert_output_has "the bad shard spec is named" "--shard wants /" + # --- the shipped allowlist against the shipped corpus -------------------------- # # Every entry in scripts/run-plugin-tests-serial.txt must name a suite that