diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fce915e8f..91cd98fbee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -301,14 +301,62 @@ jobs: path: .turbo/cache key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }} + # ── This shard's positive attestation (#6082) ───────────────────────── + # The credential test-gate counts: "shard N of 3 ran, and every step of it + # passed". These two steps are LAST and carry NO `if:` — that is what + # makes the statement true, because an unguarded step runs only when every + # preceding step of the job succeeded, and nothing after them can fail the + # job while the credential already counts as a pass. + # check:shard-attestation enforces both properties statically, so a step + # appended below here, or an `if:` added to either, is a red lint job + # rather than a silently weakened credential. + - name: Attest this shard ran and passed + run: | + node scripts/check-shard-attestation.mjs --emit \ + --job test --shard ${{ matrix.shard }} --total 3 \ + --out "$RUNNER_TEMP/shard-attestation" + + - name: Publish this shard's attestation + uses: actions/upload-artifact@v7 + with: + name: shard-attest-test-${{ matrix.shard }}-of-3 + path: ${{ runner.temp }}/shard-attestation/ + if-no-files-found: error + retention-days: 1 + overwrite: true + test-gate: # Stable required-check name for the sharded Test Core matrix — the exact # contract dogfood-gate documents below (#3622): branch protection requires # the bare "Test Core" context, and once the job is a matrix that context # can never appear again, deadlocking every PR. Keeping the contract HERE - # means a future shard-count change cannot deadlock the repo. See - # dogfood-gate for why `cancelled` passes and why this must not be - # `if: !cancelled()` on the job. + # means a future shard-count change cannot deadlock the repo. `name:` and + # `if: always()` are therefore both load-bearing: this must not become + # `if: !cancelled()` (see dogfood-gate), and it must not be renamed. + # + # ── It COUNTS credentials; it does not read one aggregate word (#6082) ── + # + # This gate used to decide from `needs.test.result` alone, passing on + # `success|skipped|cancelled` and failing on everything else. One datum + # cannot carry three shards' verdicts, and two measured defects followed: + # + # - run 31120902911: the queue discarded shards under runner starvation + # (runner_id 0, no `steps`, zero tests executed), the aggregate read the + # undocumented `abandoned`, the `*)` fallthrough painted red, and #6010 + # was evicted 31 seconds later. Whitelisting `abandoned` was REJECTED + # (maintainer, 2026-08-07): that run was NOT moot — the queue was still + # consuming its verdicts — so passing it would publish `Test Core: + # success` over zero test runs, exactly what #4928 blocks. + # - run 31114735713: shard `Test Core (3/3)` concluded `failure` while + # this gate's read of the same matrix was `abandoned`. A lifecycle value + # in the aggregate SWALLOWS whatever the siblings concluded — so the + # bug was never really about one missing word. + # + # So the verdict is now: every shard the matrix DECLARES must publish an + # "I ran and passed" artifact, and the gate must count all of them. A shard + # that was never scheduled publishes nothing and cannot be counted; a shard + # that failed publishes nothing either. `cancelled` (#3668) and + # filter-`skipped` (#4928) keep their existing meanings — see the script. name: Test Core needs: [test, filter] if: always() @@ -317,36 +365,37 @@ jobs: permissions: contents: read steps: + - name: Checkout repository + uses: actions/checkout@v7 + + # Same-run artifact download needs no permission beyond the `contents: + # read` above (`actions: read` is for cross-run/cross-repo only), so this + # gate's permission block is unchanged. continue-on-error: a legitimate + # filter-skipped run has zero artifacts to match, and an artifact-service + # fault must reach the verdict as "credentials missing" — fail-closed, + # with the step's own outcome printed — instead of an opaque red. + - name: Download test shard attestations + id: attestations + continue-on-error: true + uses: actions/download-artifact@v7 + with: + pattern: shard-attest-test-* + path: ${{ runner.temp }}/shard-attestations + merge-multiple: true + - name: Verify test shard results + env: + OS_ATTEST_DIR: ${{ runner.temp }}/shard-attestations + OS_TEST_RESULT: ${{ needs.test.result }} + OS_FILTER_RESULT: ${{ needs.filter.result }} + OS_DOWNLOAD_OUTCOME: ${{ steps.attestations.outcome }} run: | - result="${{ needs.test.result }}" - filter_result="${{ needs.filter.result }}" - echo "test matrix aggregate result: $result (filter job: $filter_result)" - # `skipped` passes this gate, but only when `filter` is the thing that - # decided it. `skipped` alone cannot tell "the path filter said no - # core paths changed" apart from "the path filter itself exploded and - # took every downstream job with it" — #4928, where the second case - # published a green Test Core over zero test runs. The `if:` on the - # `test` job above now makes a filter failure RUN the suite rather - # than skip it, so this branch should be unreachable; it is kept as - # the standing assertion of that invariant, because the failure mode - # it guards is silent and the `if:` is one careless edit from coming - # back. `filter` is in `needs` for exactly this read. - # - # `cancelled` on either side stays a pass, for #3668's reason spelled - # out in dogfood-gate below: cancellation is a run-lifecycle state - # (cancel-in-progress supersession), not a verdict, and failing here - # would paint a false red on the superseded SHA. - if [ "$result" = "skipped" ] \ - && [ "$filter_result" != "success" ] \ - && [ "$filter_result" != "cancelled" ]; then - echo "::error::Test Core shards were skipped while the filter job did not succeed (filter result: $filter_result). Refusing to report a pass over zero test runs — see #4928." - exit 1 - fi - case "$result" in - success|skipped|cancelled) echo "Test Core gate satisfied ($result)." ;; - *) echo "::error::Test Core shards did not pass (aggregate result: $result)"; exit 1 ;; - esac + node scripts/check-shard-attestation.mjs --verify \ + --gate 'Test Core' \ + --dir "$OS_ATTEST_DIR" \ + --filter-result "$OS_FILTER_RESULT" \ + --download-outcome "$OS_DOWNLOAD_OUTCOME" \ + --leg "test/3:$OS_TEST_RESULT" # ── Temporal conformance against live, non-UTC servers (ADR-0053 D-A3) ───── @@ -683,6 +732,23 @@ jobs: path: .turbo/cache key: ${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-${{ github.sha }} + # This shard's positive attestation (#6082) — see the identical pair at + # the end of the test job for why these are LAST and carry no `if:`. + - name: Attest this shard ran and passed + run: | + node scripts/check-shard-attestation.mjs --emit \ + --job dogfood --shard ${{ matrix.shard }} --total 3 \ + --out "$RUNNER_TEMP/shard-attestation" + + - name: Publish this shard's attestation + uses: actions/upload-artifact@v7 + with: + name: shard-attest-dogfood-${{ matrix.shard }}-of-3 + path: ${{ runner.temp }}/shard-attestation/ + if-no-files-found: error + retention-days: 1 + overwrite: true + # Replaces the former auto-verify dogfood tests: runs the published # `objectstack verify` engine over each example app through the CLI — # auto-derived CRUD round-trip fidelity + the cross-owner RLS invariant. @@ -769,6 +835,25 @@ jobs: echo "::endgroup::" done + # This leg's positive attestation (#6082). Not a matrix, so its declared + # roster is the single 1-of-1 credential — but dogfood-gate counts it + # exactly like a shard, which is what keeps the one required context + # covering everything it covered before the split. + - name: Attest this leg ran and passed + run: | + node scripts/check-shard-attestation.mjs --emit \ + --job dogfood-verify --shard 1 --total 1 \ + --out "$RUNNER_TEMP/shard-attestation" + + - name: Publish this leg's attestation + uses: actions/upload-artifact@v7 + with: + name: shard-attest-dogfood-verify-1-of-1 + path: ${{ runner.temp }}/shard-attestation/ + if-no-files-found: error + retention-days: 1 + overwrite: true + dogfood-gate: # Stable required-check name for a SHARDED job (#3622 follow-up). # @@ -784,10 +869,35 @@ jobs: # so the one required context still covers everything it covered before # the split. # - # `if: always()` + result inspection so a legitimately skipped matrix (the - # `filter` job says no core paths changed) still satisfies the gate — + # `if: always()` + attestation counting so a legitimately skipped matrix + # (the `filter` job says no core paths changed) still satisfies the gate — # LEGITIMATELY being the operative word since #4928: `filter` must have # concluded success (or the run been cancelled) for a skip to count. + # Deliberately NOT `if: !cancelled()` on the job instead: a skipped gate + # publishes no required-check context on the SHA, which is the #3622 + # merge-deadlock all over again. + # + # ── It COUNTS credentials; it does not read one aggregate word (#6082) ── + # + # Same rework as test-gate above, for the same two measured defects (runs + # 31120902911 and 31114735713): one `needs..result` cannot carry + # three shards' verdicts, so any run-lifecycle value in it — the + # undocumented `abandoned` that runner starvation produces, in both of + # those runs — either paints a false red or, once whitelisted, swallows a + # sibling's real `failure`. Each of the FOUR legs this context covers + # (3 dogfood shards + the CLI pass) now publishes its own "I ran and + # passed" artifact, and this gate passes only when it counts all four. + # + # `cancelled` still passes without counting, for #3668's reason: it is a + # run-lifecycle state, not a shard verdict — with cancel-in-progress on, + # every superseded push cancels the in-flight dogfood matrix (the longest + # job, so almost always the one still running), and failing here would + # paint a false red on the old SHA. Verified experimentally there (run + # 30271824408, a fail-fast matrix with one real failure + one cancelled + # sibling): a real shard failure DOMINATES the aggregate — it reads + # "failure", never "cancelled" — so passing `cancelled` masks no + # regression. That dominance is what does NOT hold for `abandoned`, which + # is why `abandoned` gets counting rather than a place in a word list. name: Dogfood Regression Gate needs: [dogfood, dogfood-verify, filter] if: always() @@ -796,50 +906,37 @@ jobs: permissions: contents: read steps: + - name: Checkout repository + uses: actions/checkout@v7 + + # One pattern covers both legs: `shard-attest-dogfood-*` matches the three + # `…-dogfood--of-3` shard credentials and `…-dogfood-verify-1-of-1` + # alike. See test-gate for why continue-on-error and why no new + # permission is needed. + - name: Download dogfood attestations + id: attestations + continue-on-error: true + uses: actions/download-artifact@v7 + with: + pattern: shard-attest-dogfood-* + path: ${{ runner.temp }}/shard-attestations + merge-multiple: true + - name: Verify dogfood shard results + env: + OS_ATTEST_DIR: ${{ runner.temp }}/shard-attestations + OS_DOGFOOD_RESULT: ${{ needs.dogfood.result }} + OS_VERIFY_RESULT: ${{ needs['dogfood-verify'].result }} + OS_FILTER_RESULT: ${{ needs.filter.result }} + OS_DOWNLOAD_OUTCOME: ${{ steps.attestations.outcome }} run: | - result="${{ needs.dogfood.result }}" - echo "dogfood matrix aggregate result: $result" - # cancelled is a run-lifecycle state, not a shard verdict (#3668): - # with cancel-in-progress on, every superseded push cancelled the - # in-flight dogfood matrix — the longest job, so almost always the - # one still running — and the old `*)` fallthrough painted a false - # red on the old SHA. Verified experimentally (run 30271824408, a - # fail-fast matrix with one real failure + one cancelled sibling): - # a real shard failure DOMINATES the aggregate — it reads "failure", - # never "cancelled" — so "cancelled" here can only mean the whole - # run was stopped from outside (supersession, or a manual cancel — - # accepted trade-off), and passing it masks no regression. - # Deliberately NOT `if: !cancelled()` on the job instead: a skipped - # gate publishes no required-check context on the SHA, which is the - # #3622 merge-deadlock all over again. - verify_result="${{ needs['dogfood-verify'].result }}" - echo "dogfood-verify result: $verify_result" - # `skipped` is only a legitimate pass when the `filter` job is what - # decided it — the same #4928 hole test-gate documents above. A leg - # that is skipped while `filter` FAILED means the regression suite - # never ran and nothing anywhere is red. The `if:` conditions on - # dogfood / dogfood-verify make that unreachable today; this is the - # standing assertion that keeps it so. `cancelled` on `filter` still - # passes, same lifecycle reasoning as the paragraph above. - filter_result="${{ needs.filter.result }}" - echo "filter job result: $filter_result" - fail=0 - for r in "dogfood:$result" "dogfood-verify:$verify_result"; do - case "${r#*:}" in - skipped) - if [ "$filter_result" != "success" ] && [ "$filter_result" != "cancelled" ]; then - echo "::error::Gate leg ${r%%:*} was skipped while the filter job did not succeed (filter result: $filter_result). Refusing to report a pass over zero runs — see #4928." - fail=1 - else - echo "Gate leg ${r%%:*} satisfied (skipped by filter)." - fi - ;; - success|cancelled) echo "Gate leg ${r%%:*} satisfied (${r#*:})." ;; - *) echo "::error::Gate leg ${r%%:*} did not pass (result: ${r#*:})"; fail=1 ;; - esac - done - exit "$fail" + node scripts/check-shard-attestation.mjs --verify \ + --gate 'Dogfood Regression Gate' \ + --dir "$OS_ATTEST_DIR" \ + --filter-result "$OS_FILTER_RESULT" \ + --download-outcome "$OS_DOWNLOAD_OUTCOME" \ + --leg "dogfood/3:$OS_DOGFOOD_RESULT" \ + --leg "dogfood-verify/1:$OS_VERIFY_RESULT" build-core: name: Build Core diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a034876256..b48f8d01b7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -335,6 +335,26 @@ jobs: - name: Workflow status-function guard run: pnpm check:workflow-status-functions + # Shard positive-attestation gate (#6082). ci.yml's two aggregate gates + # used to decide from one `needs..result` word, which cannot carry + # three shards' verdicts: run 31120902911 read the undocumented + # `abandoned` produced by runner starvation and painted a false red (PR + # #6010 evicted 31s later), and run 31114735713 had a shard at + # conclusion=failure while that same read said `abandoned` — a real + # failure swallowed. Both gates now COUNT one "I ran and passed" artifact + # per declared shard. This guard holds that arrangement to its own + # declaration: the roster each gate counts must equal the matrix each job + # runs (GitHub cannot share one literal between `strategy.matrix` and a + # downstream step, so the two are reconciled here rather than remembered), + # the credential steps must be the LAST steps of their job with no `if:` + # (anything after them can fail the job while the credential already + # counts as a pass), and every attesting job must be counted by exactly + # one gate. Runs its own --self-test first — which is also where the + # dominance experiment lives, since a dev cannot fabricate a real + # runner-starved CI run. + - name: Shard attestation gate + run: pnpm check:shard-attestation + # #4248 packaging-hygiene guard. Without a `files` whitelist npm packs the # whole package directory, and 20 of the 49 publishable packages declared # none — so consumers installed TypeScript sources, unit tests and build diff --git a/package.json b/package.json index 33c0c2d8b9..1693f4f9ba 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "check:release-body": "node scripts/release-github-releases.mjs --self-test", "check:node-version": "node scripts/check-node-version.mjs", "check:workflow-status-functions": "node scripts/check-workflow-status-functions.mjs --self-test && node scripts/check-workflow-status-functions.mjs", + "check:shard-attestation": "node scripts/check-shard-attestation.mjs --self-test && node scripts/check-shard-attestation.mjs", "check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs", "check:type-check-coverage": "node scripts/check-type-check-coverage.mjs --self-test && node scripts/check-type-check-coverage.mjs", "check:type-check-debt": "node scripts/check-type-check-coverage.mjs --self-test && node scripts/check-type-check-coverage.mjs --re-measure", diff --git a/scripts/check-shard-attestation.mjs b/scripts/check-shard-attestation.mjs new file mode 100644 index 0000000000..492e6b6455 --- /dev/null +++ b/scripts/check-shard-attestation.mjs @@ -0,0 +1,702 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shard positive-attestation gate (#6082) — an aggregate gate COUNTS + * credentials; it does not scan the aggregate reading for bad words. + * + * node scripts/check-shard-attestation.mjs # static drift guard (lint) + * node scripts/check-shard-attestation.mjs --self-test # verify the checker itself + * node scripts/check-shard-attestation.mjs --emit … # a shard publishes its credential + * node scripts/check-shard-attestation.mjs --verify … # a gate counts the credentials + * + * ## The defect this replaces + * + * `test-gate` / `dogfood-gate` in ci.yml used to decide from ONE datum: the + * matrix job's aggregate `needs..result`, passed when it read + * `success|skipped|cancelled` and failed on everything else. That is an + * ABSENCE-OF-NEGATIVE test, and it has two measured defects (#6082): + * + * 1. `abandoned` — an undocumented runner value for "this job was discarded + * and never dispatched to a runner" (runner_id 0, `steps` absent, zero + * tests executed). It is not one of the four documented `needs.*.result` + * values, so it landed in the `*)` fallthrough. Run 31120902911 painted a + * false red on a run with zero test failures, and the queue evicted #6010 + * 31 seconds later. The tempting fix — whitelist `abandoned` — was + * REJECTED by the maintainer (2026-08-07), because the discarded job is + * NOT moot: the queue was still consuming that run's verdicts, so passing + * it would publish `Test Core: success` over zero test runs — precisely + * the shape #4928 exists to block. + * + * 2. The wider one, which is why counting had to replace whitelisting: the + * aggregate reading can SWALLOW A REAL FAILURE. Run 31114735713 has shard + * `Test Core (3/3)` at job-level `conclusion: failure`, while the very + * same run's aggregate gate read `abandoned`. One datum cannot carry three + * shards' verdicts, so any lifecycle value in it hides whatever the + * siblings actually concluded. Under whitelisting-by-word that hole is one + * word away from live ammunition; under counting it cannot exist, because + * a failed shard publishes no credential and the count comes up short. + * + * ## What replaces it: declared = enforced, counted positively + * + * Every real shard job ends with two steps that carry no `if:` — so they run + * only when EVERY preceding step of that job succeeded, and nothing runs after + * them that could later turn the job red without also invalidating them: + * + * `--emit` writes `--of-.json` ("I ran and I passed"), + * `actions/upload-artifact` publishes it as `shard-attest---of-`. + * + * The gate downloads every matching artifact of ITS OWN RUN and requires the + * attested set to equal the DECLARED roster. A shard that was never scheduled + * publishes nothing, so it cannot be counted — which is exactly the property + * asked for: no attestation, no pass. + * + * Artifacts, not job `outputs`, deliberately. GitHub documents matrix job + * outputs as OVERWRITING each other (the map a consumer sees is the last leg to + * finish), so `needs..outputs.*` structurally cannot carry three legs' + * credentials — and building this gate on the undocumented "an empty value does + * not clobber" merge behaviour would repeat the exact mistake #6082 is about: + * resting a merge decision on an unspecified runner detail. Same-run artifact + * download needs no permission beyond the job's existing `contents: read` + * (`actions: read` is required only for cross-run/cross-repo downloads), so the + * gates keep their permission block. `actions/cache` was the other per-leg + * channel and is rejected on this repo's own grounds: the 10 GB pool is + * carefully rationed (see "Restore Turbo cache" in ci.yml — PR-side saves + * evicting main's turbo seeds is a measured incident). + * + * ## What is deliberately NOT changed + * + * - `cancelled` still passes without counting (#3668). Cancellation is a + * run-lifecycle state: with cancel-in-progress on, every superseded push + * cancels the in-flight matrix, and #3668 measured (run 30271824408) that a + * real shard failure DOMINATES the aggregate over a cancelled sibling — it + * reads `failure`, never `cancelled`. So `cancelled` masks no regression, + * and demanding credentials there would paint the false red on the + * superseded SHA that #3668 removed. + * - `skipped` still passes ONLY when the `filter` job itself succeeded + * (#4928). `skipped` alone cannot separate "the path filter said no core + * paths changed" from "the path filter exploded and took every downstream + * job with it", and the second published a green Test Core over zero test + * runs. Expected-N is 0 in exactly that case — the roster adjusts, the + * guard does not move. + * - The gates keep `if: always()` and their `name:`. Both are contracts: + * branch protection requires the bare `Test Core` / `Dogfood Regression + * Gate` contexts, and a gate that skips publishes no context at all, which + * is the #3622 repo-wide merge deadlock. + * + * ## Why a declared negative is still a veto + * + * Counting is necessary but not sufficient. A leg can publish its credential + * and only afterwards be marked `failure` by a post-step (a cache save's post + * action, say). So a leg whose aggregate result is `failure` fails the gate + * even with a full roster. Every OTHER value — `success`, `abandoned`, and + * whatever the runner leaks next — is decided purely by the count, which is + * what makes this robust to the next undocumented lifecycle value instead of + * needing another word added to a list. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Artifact-name prefix shared by every shard credential. */ +const ARTIFACT_PREFIX = 'shard-attest-'; + +/** The gate jobs that carry a branch-protection-required context. */ +const REQUIRED_GATE_JOBS = ['test-gate', 'dogfood-gate']; + +const SCRIPT_BASENAME = 'check-shard-attestation.mjs'; + +/** Repository root, resolved from this file rather than from the cwd. */ +function scriptRepoRoot() { + return resolve(dirname(fileURLToPath(import.meta.url)), '..'); +} + +// ── The credential ────────────────────────────────────────────────────────── + +/** The attestation id (and file stem) for one leg of one job. */ +export function attestationId(job, shard, total) { + return `${job}-${shard}-of-${total}`; +} + +/** The artifact name a shard publishes its credential under. */ +export function artifactName(job, shard, total) { + return `${ARTIFACT_PREFIX}${attestationId(job, shard, total)}`; +} + +/** Every attestation id a leg is required to produce when it actually runs. */ +function rosterFor(job, total) { + return Array.from({ length: total }, (_, i) => attestationId(job, i + 1, total)); +} + +// ── The verdict, as a pure function ───────────────────────────────────────── + +/** + * Judge one gate from its legs' declared rosters and the credentials on disk. + * + * Pure: every input is an argument, so --self-test exercises the real decision + * and not a parallel imitation of it. + * + * @param {{ + * gate: string, + * legs: { job: string, total: number, result: string }[], + * filterResult: string, + * present: Map>, + * runId: string, + * downloadOutcome?: string, + * }} input + * @returns {{ ok: boolean, log: string[], errors: string[] }} + */ +export function judge({ gate, legs, filterResult, present, runId, downloadOutcome }) { + const log = []; + const errors = []; + const allowed = new Set(); + let counted = 0; + + log.push(`${gate}: counting per-shard positive attestations (#6082), not absence of negatives.`); + log.push(` filter job result: ${filterResult || '(unreadable)'}`); + if (downloadOutcome && downloadOutcome !== 'success') { + log.push(` NOTE: the attestation download step itself did not succeed (outcome: ${downloadOutcome}).`); + } + + // A gate with no legs has verified nothing; reporting OK for it is #4690's + // defect. Same for a leg whose result did not interpolate. + if (legs.length === 0) { + errors.push(`${gate}: no legs were declared — the gate cannot verify anything (see #4690).`); + } + + for (const leg of legs) { + const roster = rosterFor(leg.job, leg.total); + const label = leg.total > 1 ? `${leg.job} (declared roster 1..${leg.total}/${leg.total})` : `${leg.job} (single leg)`; + + if (!leg.result) { + errors.push(`${gate}: leg ${leg.job} has an empty aggregate result — refusing to guess (see #4690).`); + for (const id of roster) allowed.add(id); + continue; + } + + log.push(` leg ${label} — aggregate result: ${leg.result}`); + + if (leg.result === 'cancelled') { + // #3668: a run-lifecycle state, not a shard verdict. Legs that finished + // before the cancellation may legitimately have attested, so their + // credentials are ALLOWED but none are REQUIRED. + for (const id of roster) allowed.add(id); + log.push(` satisfied (cancelled — run-lifecycle state, #3668; expected attestations: 0)`); + continue; + } + + if (leg.result === 'skipped') { + if (filterResult === 'cancelled') { + for (const id of roster) allowed.add(id); + log.push(` satisfied (skipped while the run was cancelled — #3668; expected attestations: 0)`); + continue; + } + if (filterResult !== 'success') { + errors.push( + `${gate}: leg ${leg.job} was skipped while the filter job did not succeed (filter result: ${filterResult}). ` + + `Refusing to report a pass over zero runs — see #4928.`, + ); + continue; + } + // Expected-N adjusts to 0: the filter legitimately said this family has + // nothing to run. Nothing is allowed either — a credential here would + // contradict the skip. + log.push(` satisfied (skipped by the filter — #4928; expected attestations: 0)`); + continue; + } + + for (const id of roster) allowed.add(id); + const missing = roster.filter((id) => !present.has(id)); + for (const id of roster) { + const record = present.get(id); + log.push(record ? ` + ${id} (run ${record.run_id}, attempt ${record.run_attempt})` : ` - ${id} MISSING`); + } + log.push(` attested ${roster.length - missing.length} / ${roster.length} declared shard(s)`); + + counted += roster.length; + if (missing.length > 0) { + errors.push( + `${gate}: ${missing.length} of ${roster.length} declared shard(s) of ${leg.job} published no positive attestation ` + + `(${missing.join(', ')}). A shard that never ran cannot be counted as passing — see #6082.`, + ); + } + if (leg.result === 'failure') { + // Declared negative: a leg can attest and only then be failed by a post + // step. Counting is necessary, not sufficient. + errors.push(`${gate}: leg ${leg.job} reported result 'failure' — a declared negative is never overridden by a full roster.`); + } + } + + // Foreign or stale credentials: the run must not be judged on someone else's. + for (const [id, record] of present) { + if (!allowed.has(id)) { + errors.push(`${gate}: unexpected attestation '${id}' that no declared leg accounts for.`); + continue; + } + if (runId && record.run_id !== undefined && String(record.run_id) !== String(runId)) { + errors.push(`${gate}: attestation '${id}' belongs to run ${record.run_id}, not this run (${runId}).`); + } + if (record.attestation !== attestationId(String(record.job), record.shard, record.total)) { + errors.push(`${gate}: attestation '${id}' does not describe itself consistently (payload: ${JSON.stringify(record)}).`); + } + } + + if (errors.length === 0) { + log.push( + counted > 0 + ? `${gate}: satisfied — all ${counted} declared shard(s) published a positive attestation.` + : `${gate}: satisfied — no shard was expected to run, and none claimed to.`, + ); + } + return { ok: errors.length === 0, log, errors }; +} + +// ── Reading the credentials off disk ──────────────────────────────────────── + +/** + * Every attestation in `dir`, keyed by id. A missing directory is zero + * attestations, not an error: the legitimate filter-skipped run downloads + * nothing at all. + * + * @param {string} dir + * @returns {{ present: Map>, problems: string[] }} + */ +export function readAttestations(dir) { + const present = new Map(); + const problems = []; + if (!existsSync(dir)) return { present, problems }; + for (const entry of readdirSync(dir)) { + if (!entry.endsWith('.json')) continue; + const id = entry.slice(0, -'.json'.length); + try { + const record = JSON.parse(readFileSync(join(dir, entry), 'utf8')); + present.set(id, record); + } catch (error) { + problems.push(`attestation '${entry}' is not readable JSON: ${error.message}`); + } + } + return { present, problems }; +} + +// ── The static drift guard: the gate's roster IS the declared matrix ──────── + +/** `--leg /:` as the gate spells it. */ +const LEG_TOKEN = /--leg\s+['"]?([A-Za-z0-9_-]+)\/(\d+):/g; + +/** + * Scan `.github/workflows/ci.yml` and hold the gates to their own declaration. + * + * The whole point of "declared = enforced" is that the roster the gate counts + * against cannot drift from the matrix the shards actually run. GitHub gives no + * way to share one literal between `strategy.matrix` and a downstream job's + * step (the `env` context is not available in `strategy`), so the two literals + * are reconciled HERE instead of being remembered. + * + * Missing input is a failure, never a pass (#4690): no workflow file, no gate, + * an unparseable document — every one of them is a problem, because a scan that + * reads nothing is indistinguishable from a scan that found nothing wrong. + * + * @param {string} root repository root (or a fixture root in --self-test) + * @returns {Promise<{ problems: string[], gates: number, legs: number, attesters: number }>} + */ +export async function scanWorkflow(root) { + const { parse } = await import('yaml'); + const problems = []; + const file = join(root, '.github', 'workflows', 'ci.yml'); + if (!existsSync(file)) { + return { problems: [`${file} does not exist — nothing was verified (see #4690).`], gates: 0, legs: 0, attesters: 0 }; + } + + let doc; + try { + doc = parse(readFileSync(file, 'utf8')); + } catch (error) { + return { problems: [`${file} does not parse as YAML: ${error.message}`], gates: 0, legs: 0, attesters: 0 }; + } + const jobs = doc && typeof doc === 'object' ? doc.jobs : undefined; + if (!jobs || typeof jobs !== 'object') { + return { problems: [`${file} has no jobs: map — nothing was verified (see #4690).`], gates: 0, legs: 0, attesters: 0 }; + } + + const stepsOf = (job) => (Array.isArray(job?.steps) ? job.steps : []); + const runTextOf = (job) => + stepsOf(job) + .map((step) => (typeof step?.run === 'string' ? step.run : '')) + .join('\n'); + + /** The shard count a job DECLARES: a matrix's length, or 1 for a single job. */ + const declaredTotal = (job) => { + const shard = job?.strategy?.matrix?.shard; + if (shard === undefined) return 1; + return Array.isArray(shard) ? shard.length : NaN; + }; + + const gates = new Map(); + const claimed = new Map(); + for (const [id, job] of Object.entries(jobs)) { + const text = runTextOf(job); + if (!text.includes(SCRIPT_BASENAME) || !text.includes('--verify')) continue; + const legs = [...text.matchAll(LEG_TOKEN)].map((m) => ({ job: m[1], total: Number(m[2]) })); + gates.set(id, legs); + if (job.if !== 'always()') { + problems.push(`job '${id}' is an aggregate gate but its job-level if: is ${JSON.stringify(job.if)}, not always() — a gate that skips publishes no required context (#3622).`); + } + const needs = Array.isArray(job.needs) ? job.needs : job.needs ? [job.needs] : []; + if (!needs.includes('filter')) { + problems.push(`gate '${id}' does not list 'filter' in needs:, so it cannot apply the #4928 skipped-only-when-filter-succeeded guard.`); + } + const patterns = stepsOf(job) + .filter((step) => typeof step?.uses === 'string' && step.uses.startsWith('actions/download-artifact@')) + .map((step) => String(step?.with?.pattern ?? '')); + if (patterns.length === 0) { + problems.push(`gate '${id}' never downloads the shard attestations it claims to count.`); + } + + for (const leg of legs) { + const target = jobs[leg.job]; + if (!target) { + problems.push(`gate '${id}' counts a leg '${leg.job}' that is not a job in this workflow.`); + continue; + } + if (claimed.has(leg.job)) { + problems.push(`job '${leg.job}' is counted by both '${claimed.get(leg.job)}' and '${id}' — one attesting job, one gate.`); + } + claimed.set(leg.job, id); + if (!needs.includes(leg.job)) { + problems.push(`gate '${id}' counts leg '${leg.job}' but does not list it in needs:, so it cannot read its result.`); + } + const total = declaredTotal(target); + if (!Number.isInteger(total)) { + problems.push(`job '${leg.job}' declares a strategy.matrix.shard that is not a list — the roster cannot be derived.`); + continue; + } + if (total !== leg.total) { + problems.push( + `gate '${id}' expects ${leg.total} attestation(s) from '${leg.job}', which declares ${total} shard(s). ` + + `Change the matrix and the gate's --leg together, or the gate counts against a roster that no longer exists (#6082).`, + ); + } + // The credential must mean "every step of this job passed": emit + upload + // are the LAST two steps and neither may carry an `if:`. + const steps = stepsOf(target); + const [emit, upload] = steps.slice(-2); + const emitsHere = typeof emit?.run === 'string' && emit.run.includes(SCRIPT_BASENAME) && emit.run.includes('--emit'); + const uploadsHere = typeof upload?.uses === 'string' && upload.uses.startsWith('actions/upload-artifact@'); + if (!emitsHere || !uploadsHere) { + problems.push( + `job '${leg.job}' must END with the attestation pair (${SCRIPT_BASENAME} --emit, then actions/upload-artifact). ` + + `Anything after them can fail the job while its credential already counts as a pass (#6082).`, + ); + continue; + } + if (emit.if !== undefined || upload.if !== undefined) { + problems.push(`job '${leg.job}' guards its attestation steps with an if: — the credential must mean "every earlier step succeeded", which only an unguarded step says (#6082).`); + } + const declaredName = String(upload?.with?.name ?? ''); + if (!declaredName.startsWith(`${ARTIFACT_PREFIX}${leg.job}-`) || !declaredName.endsWith(`-of-${leg.total}`)) { + problems.push( + `job '${leg.job}' uploads its attestation as '${declaredName}', which the gate cannot count — it must read ` + + `'${ARTIFACT_PREFIX}${leg.job}--of-${leg.total}'.`, + ); + } + if (!patterns.some((p) => p.endsWith('*') && declaredName.startsWith(p.slice(0, -1)))) { + problems.push(`gate '${id}' downloads pattern(s) ${JSON.stringify(patterns)}, none of which matches '${declaredName}'.`); + } + } + } + + for (const required of REQUIRED_GATE_JOBS) { + if (!gates.has(required)) { + problems.push(`job '${required}' carries a branch-protection-required context but no longer counts shard attestations (#6082/#3622).`); + } + } + // Any job that publishes a credential must be counted by exactly one gate, + // or the credential is decoration. + for (const [id, job] of Object.entries(jobs)) { + if (runTextOf(job).includes('--emit') && !claimed.has(id)) { + problems.push(`job '${id}' publishes an attestation that no gate counts — declared but not enforced.`); + } + } + + const legs = [...gates.values()].reduce((n, l) => n + l.length, 0); + return { problems, gates: gates.size, legs, attesters: claimed.size }; +} + +// ── Modes ─────────────────────────────────────────────────────────────────── + +function argValue(flag, fallback) { + const i = process.argv.indexOf(flag); + if (i === -1 || i + 1 >= process.argv.length) return fallback; + return process.argv[i + 1]; +} + +function argValues(flag) { + const out = []; + for (let i = 0; i < process.argv.length; i += 1) if (process.argv[i] === flag && i + 1 < process.argv.length) out.push(process.argv[i + 1]); + return out; +} + +/** A shard publishes its credential. */ +function emit() { + const job = argValue('--job'); + const shard = Number(argValue('--shard')); + const total = Number(argValue('--total')); + const out = argValue('--out'); + if (!job || !Number.isInteger(shard) || !Number.isInteger(total) || !out) { + console.error(`::error::${SCRIPT_BASENAME} --emit needs --job --shard --total --out `); + process.exit(1); + } + const id = attestationId(job, shard, total); + const payload = { + attestation: id, + job, + shard, + total, + run_id: process.env.GITHUB_RUN_ID ?? '', + run_attempt: process.env.GITHUB_RUN_ATTEMPT ?? '', + sha: process.env.GITHUB_SHA ?? '', + workflow_job: process.env.GITHUB_JOB ?? '', + attested_at: new Date().toISOString(), + }; + mkdirSync(out, { recursive: true }); + writeFileSync(join(out, `${id}.json`), `${JSON.stringify(payload, null, 2)}\n`); + console.log(`Attested: ${id} ran to completion with every step green (run ${payload.run_id}, attempt ${payload.run_attempt}).`); +} + +/** A gate counts the credentials. */ +function verify() { + const gate = argValue('--gate', 'gate'); + const dir = argValue('--dir'); + if (!dir) { + console.error(`::error::${SCRIPT_BASENAME} --verify needs --dir `); + process.exit(1); + } + const legs = argValues('--leg').map((token) => { + const at = token.indexOf(':'); + const [job, total] = (at === -1 ? token : token.slice(0, at)).split('/'); + return { job, total: Number(total), result: at === -1 ? '' : token.slice(at + 1).trim() }; + }); + const { present, problems } = readAttestations(dir); + const verdict = judge({ + gate, + legs, + filterResult: argValue('--filter-result', ''), + present, + runId: process.env.GITHUB_RUN_ID ?? '', + downloadOutcome: argValue('--download-outcome', ''), + }); + for (const line of verdict.log) console.log(line); + const errors = [...problems.map((p) => `${gate}: ${p}`), ...verdict.errors]; + if (errors.length > 0) { + for (const error of errors) console.log(`::error::${error}`); + process.exit(1); + } +} + +/** The static drift guard. */ +async function main() { + const { problems, gates, legs, attesters } = await scanWorkflow(scriptRepoRoot()); + if (problems.length > 0) { + console.error(`✗ check-shard-attestation — ${problems.length} problem(s) in .github/workflows/ci.yml\n`); + for (const problem of problems) console.error(` • ${problem}`); + process.exit(1); + } + console.log(`✓ check-shard-attestation: ${gates} aggregate gate(s) count ${legs} declared leg(s) across ${attesters} attesting job(s).`); +} + +// ── Self-test ─────────────────────────────────────────────────────────────── + +/** + * The dominance experiment, in the shape #3668 set for `cancelled` — run as + * fixtures because a dev cannot fabricate a real runner-starved CI run. + */ +async function selfTest() { + const failures = []; + let checked = 0; + const assert = (condition, description) => { + checked += 1; + if (!condition) failures.push(description); + }; + + const attest = (job, shard, total, runId = '99') => + [attestationId(job, shard, total), { attestation: attestationId(job, shard, total), job, shard, total, run_id: runId, run_attempt: '1' }]; + + const testGate = (results, ids, filterResult = 'success') => + judge({ + gate: 'Test Core', + legs: [{ job: 'test', total: 3, result: results }], + filterResult, + present: new Map(ids), + runId: '99', + }); + + // ── (i) all N positives ⇒ green ─────────────────────────────────────────── + assert(testGate('success', [attest('test', 1, 3), attest('test', 2, 3), attest('test', 3, 3)]).ok, 'all 3 declared shards attested ⇒ green'); + + // ── (ii) N-1 positives, one never scheduled ⇒ red ───────────────────────── + // COUNTER-EXAMPLE PIN 1 — "abandoned is not moot" (#6082 comment 5208599210, + // run 31120902911). A discarded shard has runner_id 0 and no `steps`: it + // executed zero tests while the queue was still consuming this run's + // verdicts. Whitelisting the word (option A) would have published `Test + // Core: success` over that. Counting cannot: the credential is absent. + const abandonedNotMoot = testGate('abandoned', [attest('test', 1, 3), attest('test', 2, 3)]); + assert(!abandonedNotMoot.ok, 'abandoned-not-moot: 2 of 3 attested + aggregate `abandoned` ⇒ red'); + assert( + abandonedNotMoot.errors.some((e) => e.includes('test-3-of-3') && e.includes('#6082')), + 'abandoned-not-moot: the error names the shard that published nothing', + ); + // The same shape must stay red no matter what word the runner leaks, which is + // the whole reason the verdict no longer consults a word list. + for (const leaked of ['abandoned', 'success', 'neutral', 'stale', '']) { + assert(!testGate(leaked, [attest('test', 1, 3), attest('test', 2, 3)]).ok, `a missing credential is red even when the aggregate reads '${leaked || '(empty)'}'`); + } + + // ── (iii) N-1 positives + one explicit failure ⇒ red ────────────────────── + assert(!testGate('failure', [attest('test', 1, 3), attest('test', 2, 3)]).ok, 'a genuinely failing shard ⇒ red'); + // COUNTER-EXAMPLE PIN 2 — "a real failure swallowed by a sibling's lifecycle + // value" (#6082 comment 5208599210, run 31114735713: shard `Test Core (3/3)` + // at job-level conclusion=failure while the aggregate gate read `abandoned`). + // Under whitelisting-by-word, adding `abandoned` would have turned this run's + // only required red green with a real failure in it. Here the failing shard + // and the discarded shard are both simply absent from the count. + const swallowedFailure = testGate('abandoned', [attest('test', 1, 3)]); + assert(!swallowedFailure.ok, 'real-failure-swallowed: aggregate `abandoned` over a failed + a discarded shard ⇒ red'); + assert( + swallowedFailure.errors.some((e) => e.includes('test-2-of-3') && e.includes('test-3-of-3')), + 'real-failure-swallowed: BOTH unattested shards are named, so the failure cannot hide behind the lifecycle value', + ); + // A declared negative is a veto even on a full roster — a leg can attest and + // then be failed by a post step. + assert(!testGate('failure', [attest('test', 1, 3), attest('test', 2, 3), attest('test', 3, 3)]).ok, 'a full roster never overrides a declared `failure`'); + + // ── (iv) filter-skipped family ⇒ green with expected-N adjusted to 0 ────── + assert(testGate('skipped', []).ok, '#4928: skipped legs + filter success ⇒ green, expected-N adjusts to 0'); + // ── the #4928 guard itself must not regress ─────────────────────────────── + for (const bad of ['failure', 'skipped', '']) { + const guarded = testGate('skipped', [], bad); + assert(!guarded.ok, `#4928 guard: skipped legs while filter result is '${bad || '(empty)'}' ⇒ red`); + assert(guarded.errors.some((e) => e.includes('#4928')), `#4928 guard names its issue for filter result '${bad || '(empty)'}'`); + } + assert(testGate('skipped', [], 'cancelled').ok, '#3668: a cancelled filter still lets a skipped leg pass'); + assert(!testGate('skipped', [attest('test', 1, 3)]).ok, 'a credential from a leg that was reported skipped is a contradiction ⇒ red'); + + // ── #3668 lifecycle: cancelled passes without counting ──────────────────── + assert(testGate('cancelled', []).ok, '#3668: a cancelled matrix passes with zero credentials (superseded SHA)'); + assert(testGate('cancelled', [attest('test', 1, 3)]).ok, '#3668: a leg that attested before the cancellation is allowed, not required'); + + // ── dogfood-gate: a matrix leg and a single leg under one context ───────── + const dogfood = (dogfoodResult, verifyResult, ids) => + judge({ + gate: 'Dogfood Regression Gate', + legs: [ + { job: 'dogfood', total: 3, result: dogfoodResult }, + { job: 'dogfood-verify', total: 1, result: verifyResult }, + ], + filterResult: 'success', + present: new Map(ids), + runId: '99', + }); + const fullDogfood = [attest('dogfood', 1, 3), attest('dogfood', 2, 3), attest('dogfood', 3, 3), attest('dogfood-verify', 1, 1)]; + assert(dogfood('success', 'success', fullDogfood).ok, 'dogfood: 3 shards + the CLI leg all attested ⇒ green'); + assert(!dogfood('success', 'abandoned', fullDogfood.slice(0, 3)).ok, 'dogfood: the single CLI leg is counted too — its absence is red'); + assert(dogfood('skipped', 'skipped', []).ok, 'dogfood: both legs skipped by the filter ⇒ green (#4928)'); + assert(dogfood('success', 'skipped', fullDogfood.slice(0, 3)).ok, 'dogfood: each leg is judged on its own roster — a full matrix + a filter-skipped CLI leg ⇒ green'); + assert(!dogfood('success', 'skipped', fullDogfood).ok, 'dogfood: a credential from the leg reported skipped contradicts the skip ⇒ red'); + + // ── foreign / stale credentials ─────────────────────────────────────────── + assert(!testGate('success', [attest('test', 1, 3), attest('test', 2, 3), attest('test', 3, 3), attest('test', 4, 4)]).ok, 'a credential no declared leg accounts for ⇒ red'); + assert( + !testGate('success', [attest('test', 1, 3), attest('test', 2, 3), attest('test', 3, 3, '1234')]).ok, + 'a credential from another run ⇒ red', + ); + const forged = new Map([attest('test', 1, 3), attest('test', 2, 3), attest('test', 3, 3)]); + forged.get('test-3-of-3').attestation = 'test-1-of-3'; + assert(!judge({ gate: 'Test Core', legs: [{ job: 'test', total: 3, result: 'success' }], filterResult: 'success', present: forged, runId: '99' }).ok, 'a credential that does not describe itself ⇒ red'); + + // ── missing input is a failure, never a pass (#4690) ────────────────────── + assert(!judge({ gate: 'Test Core', legs: [], filterResult: 'success', present: new Map(), runId: '99' }).ok, 'a gate with no legs verifies nothing ⇒ red'); + assert(!testGate('', []).ok, 'an aggregate result that did not interpolate ⇒ red'); + + // ── readAttestations over a real directory ─────────────────────────────── + const dir = mkdtempSync(join(tmpdir(), 'shard-attest-')); + try { + assert(readAttestations(join(dir, 'never-created')).present.size === 0, 'a missing download directory is zero credentials, not a crash'); + mkdirSync(join(dir, 'a'), { recursive: true }); + process.env.GITHUB_RUN_ID = '99'; + process.env.GITHUB_RUN_ATTEMPT = '1'; + const argv = process.argv; + process.argv = ['node', SCRIPT_BASENAME, '--emit', '--job', 'test', '--shard', '2', '--total', '3', '--out', join(dir, 'a')]; + emit(); + process.argv = argv; + const round = readAttestations(join(dir, 'a')); + assert(round.present.has('test-2-of-3'), '--emit writes the id the gate looks for'); + assert(round.present.get('test-2-of-3').run_id === '99', '--emit stamps the run id the gate cross-checks'); + writeFileSync(join(dir, 'a', 'test-1-of-3.json'), 'not json'); + assert(readAttestations(join(dir, 'a')).problems.length === 1, 'an unreadable credential is a problem, not a silent skip'); + + // ── the static drift guard, over fixture workflows ────────────────────── + const good = readFileSync(join(scriptRepoRoot(), '.github', 'workflows', 'ci.yml'), 'utf8'); + const fixture = async (mutate) => { + const root = mkdtempSync(join(tmpdir(), 'shard-attest-wf-')); + mkdirSync(join(root, '.github', 'workflows'), { recursive: true }); + writeFileSync(join(root, '.github', 'workflows', 'ci.yml'), mutate(good)); + try { + return await scanWorkflow(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }; + assert((await fixture((s) => s)).problems.length === 0, 'the checked-in ci.yml passes the static drift guard'); + assert((await fixture((s) => s.replace('shard: [1, 2, 3]', 'shard: [1, 2, 3, 4]'))).problems.some((p) => p.includes('declares 4 shard')), 'growing the matrix without the gate ⇒ red'); + assert((await fixture((s) => s.replace('--leg "test/3', '--leg "test/2'))).problems.some((p) => p.includes('expects 2 attestation')), 'shrinking the gate roster without the matrix ⇒ red'); + assert((await fixture((s) => s.replace(/^jobs:$/m, 'jobs:\n intruder:\n runs-on: ubuntu-latest\n steps:\n - run: node scripts/check-shard-attestation.mjs --emit'))).problems.some((p) => p.includes('no gate counts')), 'an attestation no gate counts ⇒ red'); + assert((await fixture((s) => s.replace(' test-gate:', ' test-gate-renamed:'))).problems.some((p) => p.includes("'test-gate'")), 'losing a required-context gate ⇒ red'); + assert((await fixture((s) => s.replace('name: shard-attest-test-', 'name: shard-attest-typo-'))).problems.some((p) => p.includes('cannot count')), 'an artifact name the gate cannot match ⇒ red'); + assert( + (await fixture((s) => s.replace(' overwrite: true\n\n test-gate:', ' overwrite: true\n\n - name: after the credential\n run: echo late\n\n test-gate:'))).problems.some((p) => + p.includes('must END with the attestation pair'), + ), + 'a step appended after the credential ⇒ red', + ); + assert( + (await fixture((s) => s.replace(' pattern: shard-attest-test-*', ' pattern: shard-attest-nothing-*'))).problems.some((p) => p.includes('none of which matches')), + 'a gate whose download pattern cannot match its legs ⇒ red', + ); + assert( + (await fixture((s) => s.replace(' - name: Attest this shard ran and passed\n run: |\n node scripts/check-shard-attestation.mjs --emit \\\n --job test', ' - name: Attest this shard ran and passed\n if: always()\n run: |\n node scripts/check-shard-attestation.mjs --emit \\\n --job test'))).problems.some((p) => p.includes('guards its attestation steps with an if:')), + 'an `if:` on the credential step ⇒ red (the credential would stop meaning "every earlier step passed")', + ); + const noFile = mkdtempSync(join(tmpdir(), 'shard-attest-empty-')); + try { + assert((await scanWorkflow(noFile)).problems.some((p) => p.includes('does not exist')), '#4690: a missing ci.yml is a failure, never a pass'); + mkdirSync(join(noFile, '.github', 'workflows'), { recursive: true }); + writeFileSync(join(noFile, '.github', 'workflows', 'ci.yml'), 'name: CI\non: push\n'); + assert((await scanWorkflow(noFile)).problems.some((p) => p.includes('no jobs')), '#4690: a workflow with no jobs is a failure, never a pass'); + writeFileSync(join(noFile, '.github', 'workflows', 'ci.yml'), 'jobs: [oops\n - :\n'); + assert((await scanWorkflow(noFile)).problems.length > 0, '#4690: an unparseable workflow is a failure, never a pass'); + } finally { + rmSync(noFile, { recursive: true, force: true }); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + if (failures.length > 0) { + console.error(`✗ check-shard-attestation --self-test -- ${failures.length} failure(s)\n`); + for (const failure of failures) console.error(` • ${failure}`); + process.exit(1); + } + console.log(`✓ check-shard-attestation --self-test: ${checked} assertions (dominance experiment + both #6082 counter-examples + the #4928 guard).`); +} + +if (process.argv.includes('--self-test')) { + await selfTest(); +} else if (process.argv.includes('--emit')) { + emit(); +} else if (process.argv.includes('--verify')) { + verify(); +} else { + await main(); +}