diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6632c3a..14c2c88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,9 @@ # Release pipeline: tag-triggered publish of the five per-platform binary # packages plus the root launcher, all with npm OIDC provenance (no static -# tokens). Version comes only from the tag (KTD5); platform packages are -# published before the root so the root's optionalDependencies resolve. +# tokens). Version comes only from the tag (KTD5). The root launcher is +# published FIRST so the platform packages' exact peerDependencies +# (launcher@VERSION) always resolve; a final verify job re-checks metadata, +# digests and pins only after every publish and the GitHub-release upload. # # Fork safety: `push: tags` — only users with push access can push tags. # pull_request_target is intentionally not used. The npm trusted-publisher @@ -19,20 +21,23 @@ on: permissions: {} -# Keyed on the commit, not the ref: a force-moved tag must run in a fresh -# lane instead of serializing behind the stale run it replaced (issue #5). +# Keyed on the tag ref, not the commit: a force-moved tag re-enters the same +# group and cancels the stale run it replaces (fresh lane for the new commit, +# issue #5 refit); two different tags are different groups (separate +# versions); two runs of the SAME tag can never publish the same version +# twice because cancel-in-progress: true kills the earlier run. concurrency: - group: release-${{ github.sha }} - cancel-in-progress: false + group: release-${{ github.ref }} + cancel-in-progress: true jobs: - # One job per target in the matrix: build, gate, smoke, upload artifacts, - # publish the platform package with provenance (OIDC, no static token). + # One job per target in the matrix: build, gate, smoke, stage, upload. The + # platform publish itself happens AFTER the root launcher exists + # (publish-platform-packages job) so peerDependencies resolve. release: name: release-${{ matrix.suffix }} runs-on: ${{ matrix.runner }} permissions: - id-token: write contents: read strategy: fail-fast: false @@ -76,7 +81,7 @@ jobs: - name: Linux arm64 cross-linker # Only needed when building aarch64 from an x64 runner. The native - # ubuntu-24.04-arm runner builds aarch64 without a cross toolchain. + # ubuntu-24.04-arm runner targets aarch64 without a cross toolchain. if: matrix.target == 'aarch64-unknown-linux-gnu' && matrix.runner == 'ubuntu-latest' shell: bash run: | @@ -103,19 +108,6 @@ jobs: - name: Install Deno (release scripts) uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2 - - name: Setup Node 24 (publish; npm >= 11.5.1 for trusted publishing) - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - # NODE_AUTH_TOKEN intentionally unset — the registry-url wiring - # performs the OIDC exchange for trusted publishing. - - - name: Install pnpm - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 11.21.0 - - name: "Gate: check-matrix (table vs workflow agreement)" run: | deno run --allow-read=scripts/lib/targets.json,npm/packages/comment-checker/package.json,.github/workflows/release.yml scripts/tools/check-matrix.ts @@ -131,11 +123,12 @@ jobs: printf '%s\n' '{"tool_name":"Write","tool_input":{"file_path":"src/client.py","content":"# SPDX-License-Identifier: Apache-2.0\ndef load(path):\n return open(path).read()\n"}}' | "$BIN" rc=$? test "$rc" -eq 0 || { echo "clean payload exited $rc, expected 0" >&2; exit 1; } - printf '%s\n' '{"tool_name":"Write","tool_input":{"file_path":"src/load_config.py","content":"def load_config(path):\n # Parse the config file\n data = json.load(open(path))\n # TODO: fix this later\n return data\n"}}' | "$BIN" >/dev/null 2>&1 + printf '%s\n' '{"tool_name":"Write","tool_input":{"file_path":"src/load_config.py","content":"def load_config(path):\n # TODO: fix this later\n return json.load(open(path))\n"}}' | "$BIN" >/dev/null 2>&1 rc=$? # Exit contract (2 = blocked) asserted by - # crates/comment-checker/tests/exit_codes.rs (issue #6). - test "$rc" -eq 2 || { echo "flagged payload exit $rc, expected 2" >&2; exit 1; } + # crates/comment-checker/tests/exit_codes.rs (issue #6) and pinned + # by scripts/tools/release-smoke.test.ts against this exact payload. + test "$rc" -eq 2 || { echo "smoke payload exited $rc, expected 2" >&2; exit 1; } - name: Stage platform package (outside workspace) + record binarySha256 shell: bash @@ -155,7 +148,7 @@ jobs: cp "$BIN" "$STAGE/${BINARY_NAME}" SHA="$(sha256_of "$STAGE/${BINARY_NAME}")" # Issue #7: persist the digest as a sidecar artifact. The - # publish-time cross-check reads this file; without a writer here + # verify-release cross-check reads this file; without a writer here # the digest guard reads nothing and cannot run. echo "$SHA" > "$RUNNER_TEMP/binary-${{ matrix.suffix }}.sha256" deno run \ @@ -182,17 +175,19 @@ jobs: ${{ runner.temp }}/comment-checker-${{ matrix.target }}.tar.gz ${{ runner.temp }}/binary-${{ matrix.suffix }}.sha256 - - name: Publish platform package (OIDC provenance) - shell: bash - run: | - cd "$STAGE" - pnpm publish --provenance --access public --no-git-checks - - publish-npm-main: + - name: Upload staged platform package (published by publish-platform-packages) + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: platform-stage-${{ matrix.suffix }} + path: ${{ runner.temp }}/platform-${{ matrix.suffix }} + if-no-files-found: error + + # The root launcher must exist at VERSION before any platform package that + # pins it as an exact peerDependency is published (issue #4 resolver + # contract). Root publish happens here, BEFORE the platforms. + publish-root: name: Publish root launcher - # After upload-gh-release-assets: the cross-check re-fetches the platform - # tarballs from the GitHub release, so they must exist first (issue #7). - needs: [release, upload-gh-release-assets] + needs: release runs-on: ubuntu-latest permissions: id-token: write @@ -239,6 +234,92 @@ jobs: - name: Install Deno (release scripts) uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2 + - name: Build launcher (frozen) + run: | + pnpm install --frozen-lockfile --registry https://registry.npmjs.org + pnpm -r build + + - name: Sync root version + optionalDependencies from tag + shell: bash + run: | + # --allow-env required: VERSION arrives via the environment (KTD5: + # the git tag is the single version source). The deno.jsonc + # workspace root pins in optionalDependencies are injected here. + VERSION="$VERSION" deno run \ + --allow-env=VERSION \ + --allow-read=scripts/lib/targets.json,npm/packages/comment-checker/package.json \ + --allow-write=npm/packages/comment-checker/package.json \ + scripts/tools/sync-root-version.ts + - name: Publish root launcher (OIDC provenance) + shell: bash + run: | + cd npm/packages/comment-checker + pnpm publish --provenance --access public --no-git-checks + + # Now that launcher@VERSION is on the registry the platform packages can be + # published (their peerDependencies resolve). Each staged package came from + # a matrix runner; re-uploaded as platform-stage-* artifacts. + publish-platforms: + name: Publish platform packages (after root) + needs: publish-root + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup Node 24 (npm >= 11.5.1 for trusted publishing) + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.21.0 + + - name: Download staged platform packages + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: platform-stage-* + path: stages + + - name: Publish platform packages (OIDC provenance) + shell: bash + run: | + COUNT="$(find stages -mindepth 1 -maxdepth 1 -type d | wc -l)" + test "$COUNT" -eq 5 || { + echo "expected 5 staged platform packages, found $COUNT" >&2 + exit 1 + } + for STAGE in stages/*/; do + [ -d "$STAGE" ] || continue + (cd "$STAGE" && pnpm publish --provenance --access public --no-git-checks) || exit 1 + done + echo "all platform packages published" + + # Separate-verify job: the final gate. Runs the metadata gate, the + # three-way digest cross-check, and the root pin check AFTER every publish + # and after the GitHub release exists, so a failure means a real mismatch. + verify-release: + name: Verify release (metadata + digests + pins) + needs: [publish-platforms, upload-gh-release-assets] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup Node 24 (npm view/pack) + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - name: Download recorded binary sha sidecars uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -246,30 +327,35 @@ jobs: merge-multiple: true path: sidecars - - name: "Gate: every platform package published with table os/cpu/libc + launcher peer" + - name: "Gate: every platform package with table os/cpu/libc + launcher peer" shell: bash run: | # Shape-normalize BOTH sides: npm view reports libc as an array # ("libc":["glibc"]) while the table stores a bare string, and - # darwin/win32 rows have no libc at all. Comparing through the same + # darwin/win32 rows never carry libc. Comparing through the same # normalization (libc as array, absent when null) makes the deep - # equality meaningful instead of always-true or always-false. - # peerDependencies must name the launcher at the same version - # (issue #4). + # equality meaningful instead of always-false against a string + # (expectation is passed via --argjson so it compares as an object). + # peerDependencies must name the launcher at the same version. libc_norm='{os, cpu} + (if (.libc // null) != null then {libc} else {} end)' LAUNCHER_NAME="@systemfsoftware/claude-code-comment-checker" + VERSION="${GITHUB_REF#refs/tags/v}" + test -n "$LAUNCHER_NAME" || { echo "LAUNCHER_NAME empty" >&2; exit 1; } + test -n "$VERSION" || { echo "VERSION empty (ref: $GITHUB_REF)" >&2; exit 1; } + echo "VERSION=$VERSION" >> "$GITHUB_ENV" SUFFIXES="$(jq -r '.[].suffix' scripts/lib/targets.json)" test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; } for SUFFIX in $SUFFIXES; do - PKG="${LAUNCHER}-${SUFFIX}" + PKG="${LAUNCHER_NAME}-${SUFFIX}" META="$(npm view "$PKG@$VERSION" version os cpu libc peerDependencies --json)" || { echo "platform package $PKG@$VERSION missing" >&2 exit 1 } EXPECTED="$(jq -c --arg suffix "$SUFFIX" '.[] | select(.suffix == $suffix) | {os: [.os], cpu: [.cpu]} + (if (.libc // null) != null then {libc: [.libc]} else {} end)' scripts/lib/targets.json)" + test -n "$EXPECTED" || { echo "no targets.json row for suffix $SUFFIX" >&2; exit 1; } echo "$META" | jq -e -c \ --arg v "$VERSION" \ - --arg want "$EXPECTED" \ + --argjson want "$EXPECTED" \ --arg launcher "$LAUNCHER_NAME" \ '.version == $v and ('"$libc_norm"') == $want and .peerDependencies[$launcher] == $v' \ >/dev/null || { @@ -279,7 +365,7 @@ jobs: echo "$SUFFIX ok" done - - name: Cross-check release + published tarballs vs recorded binary sha + - name: "Cross-check: release + published tarballs vs recorded binary sha" shell: bash run: | # Three-way digest agreement: the GitHub release asset (re-fetched @@ -288,7 +374,9 @@ jobs: # # npm registry read-after-write is eventually consistent: the # platform publish finished only seconds ago, so a first `npm pack` - # may still 404. Retry a bounded number of times before failing. + # or `gh release download` may still fail. Both are retried a + # bounded number of times before failing. + set -euo pipefail sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 @@ -300,17 +388,35 @@ jobs: test -n "$SUFFIXES" || { echo "targets.json empty" >&2; exit 1; } for SUFFIX in $SUFFIXES; do SHA_RECORDED="$(cat "sidecars/binary-${SUFFIX}.sha256")" + test -s "sidecars/binary-${SUFFIX}.sha256" || { + echo "sidecar missing/empty for $SUFFIX" >&2 + exit 1 + } TARGET="$(jq -r --arg suffix "$SUFFIX" '.[] | select(.suffix == $suffix) | .target' scripts/lib/targets.json)" test -n "$TARGET" || { echo "no targets.json row for $SUFFIX" >&2; exit 1; } - # Issue #7: bind the recorded digest to a source independent of - # this runner's staging — the tarball attached to the GitHub + # Issue #7 refit: bind the recorded digest to a source independent + # of this runner's staging — the tarball attached to the GitHub # release — before trusting the sidecar against the registry. - gh release download "$GITHUB_REF_NAME" \ - --pattern "comment-checker-${TARGET}.tar.gz" --dir "$RUNNER_TEMP" --clobber + for attempt in 1 2 3 4 5; do + gh release download "$GITHUB_REF_NAME" \ + --pattern "comment-checker-${TARGET}.tar.gz" --dir "$RUNNER_TEMP" --clobber && break + echo "gh release download $SUFFIX attempt $attempt/5 failed; retrying" >&2 + sleep 5 + done + test -s "$RUNNER_TEMP/comment-checker-${TARGET}.tar.gz" || { + echo "release asset missing for $SUFFIX after 5 attempts" >&2 + exit 1 + } rm -rf "$RUNNER_TEMP/release-unpack" mkdir -p "$RUNNER_TEMP/release-unpack" tar -xzf "$RUNNER_TEMP/comment-checker-${TARGET}.tar.gz" -C "$RUNNER_TEMP/release-unpack" - BIN_RELEASED="$(find "$RUNNER_TEMP/release-unpack" -type f \( -name 'comment-checker' -o -name 'comment-checker.exe' \) | head -1)" + MATCH_COUNT="$(find "$RUNNER_TEMP/release-unpack" -type f \( -name 'comment-checker' -o -name 'comment-checker.exe' \) | wc -l)" + test "$MATCH_COUNT" -eq 1 || { + echo "expected exactly one binary in release asset for $SUFFIX, found $MATCH_COUNT" >&2 + exit 1 + } + BIN_RELEASED="$(find "$RUNNER_TEMP/release-unpack" -type f \( -name 'comment-checker' -o -name 'comment-checker.exe' \))" + test -s "$BIN_RELEASED" || { echo "release binary empty for $SUFFIX" >&2; exit 1; } SHA_RELEASED="$(sha256_of "$BIN_RELEASED")" if [ "$SHA_RECORDED" != "$SHA_RELEASED" ]; then echo "release-asset binary sha mismatch for $SUFFIX: recorded $SHA_RECORDED, got $SHA_RELEASED" >&2 @@ -334,7 +440,13 @@ jobs: rm -rf "$RUNNER_TEMP/unpack" mkdir -p "$RUNNER_TEMP/unpack" tar -xzf "$RUNNER_TEMP/$TARBALL" -C "$RUNNER_TEMP/unpack" - BIN="$(find "$RUNNER_TEMP/unpack" -type f \( -name 'comment-checker' -o -name 'comment-checker.exe' \) | head -1)" + MATCH_COUNT="$(find "$RUNNER_TEMP/unpack" -type f \( -name 'comment-checker' -o -name 'comment-checker.exe' \) | wc -l)" + test "$MATCH_COUNT" -eq 1 || { + echo "expected exactly one binary in npm tarball for $SUFFIX, found $MATCH_COUNT" >&2 + exit 1 + } + BIN="$(find "$RUNNER_TEMP/unpack" -type f \( -name 'comment-checker' -o -name 'comment-checker.exe' \))" + test -s "$BIN" || { echo "published binary empty for $SUFFIX" >&2; exit 1; } SHA_PUBLISHED="$(sha256_of "$BIN")" if [ "$SHA_RECORDED" != "$SHA_PUBLISHED" ]; then echo "binary sha mismatch for $SUFFIX: recorded $SHA_RECORDED, got $SHA_PUBLISHED" >&2 @@ -343,28 +455,7 @@ jobs: echo "$SUFFIX ok" done - - name: Build launcher (frozen) - run: | - pnpm install --frozen-lockfile --registry https://registry.npmjs.org - pnpm -r build - - - name: Sync root version + optionalDependencies from tag - shell: bash - run: | - # --allow-env required: VERSION arrives via the environment (KTD5: - # the git tag is the single version source). The deno.jsonc - VERSION="$VERSION" deno run \ - --allow-env=VERSION \ - --allow-read=scripts/lib/targets.json,npm/packages/comment-checker/package.json \ - --allow-write=npm/packages/comment-checker/package.json \ - scripts/tools/sync-root-version.ts - - name: Publish root launcher (OIDC provenance) - shell: bash - run: | - cd npm/packages/comment-checker - pnpm publish --provenance --access public --no-git-checks - - - name: Verify root publish + exact pins + - name: "Verify root publish + exact pins" shell: bash run: | ROOT_META="$(npm view "@systemfsoftware/claude-code-comment-checker@$VERSION" version optionalDependencies --json)" diff --git a/scripts/lib/distribution-set.ts b/scripts/lib/distribution-set.ts index 4a3c146..2949a1a 100644 --- a/scripts/lib/distribution-set.ts +++ b/scripts/lib/distribution-set.ts @@ -1,4 +1,9 @@ -import { LAUNCHER_MANIFEST_PATH, type LauncherManifest, type Target, TARGETS_PATH } from './shared.ts' +import { + LAUNCHER_MANIFEST_PATH, + type LauncherManifest, + type Target, + TARGETS_PATH, +} from './shared.ts' export interface PackageTarget { name: string diff --git a/scripts/lib/matrix-rows.ts b/scripts/lib/matrix-rows.ts index 38e9a4d..86c90f3 100644 --- a/scripts/lib/matrix-rows.ts +++ b/scripts/lib/matrix-rows.ts @@ -3,40 +3,70 @@ import { parse as parseYaml } from '@std/yaml' export interface MatrixRow { target: string suffix: string + runner: string } /** - * Extract the release matrix include rows (target + suffix) from a workflow - * document by parsing YAML, not scraping text (issue #8). + * Extract the release matrix include rows (target + suffix + runner) from a + * workflow document by parsing YAML, not scraping text (issue #8). * * Formatting cannot break agreement: flow-style lists, quoted keys or values, - * key reordering, and comments all parse to the same typed rows. Returns an - * empty list when no job carries a `strategy.matrix.include` list — callers - * must treat that as a failure, since an empty matrix cannot agree with the - * targets table. + * key reordering, and comments all parse to the same typed rows. Only the + * job named `release` is consulted, so a decoy matrix-bearing job elsewhere + * in the workflow cannot satisfy the gate (issue #8 refit). Every include + * row must carry string target/suffix/runner; a missing or non-conforming + * row, an empty include, a missing `release` job, or malformed YAML throws — + * the gate must fail loudly rather than compare against a silent subset. */ export function matrixRows(workflowText: string): MatrixRow[] { const doc: unknown = parseYaml(workflowText) - if (typeof doc !== 'object' || doc === null) return [] + if (typeof doc !== 'object' || doc === null) { + throw new Error('workflow document is not a mapping') + } const jobs = (doc as Record).jobs - if (typeof jobs !== 'object' || jobs === null) return [] - for (const job of Object.values(jobs as Record)) { - if (typeof job !== 'object' || job === null) continue - const strategy = (job as Record).strategy - if (typeof strategy !== 'object' || strategy === null) continue - const matrix = (strategy as Record).matrix - if (typeof matrix !== 'object' || matrix === null) continue - const include = (matrix as Record).include - if (!Array.isArray(include)) continue - const rows: MatrixRow[] = [] - for (const row of include) { - if (typeof row !== 'object' || row === null) continue - const record = row as Record - if (typeof record.target === 'string' && typeof record.suffix === 'string') { - rows.push({ target: record.target, suffix: record.suffix }) - } + if (typeof jobs !== 'object' || jobs === null) { + throw new Error('workflow has no jobs mapping') + } + const releaseJob = (jobs as Record).release + if (typeof releaseJob !== 'object' || releaseJob === null) { + throw new Error('workflow has no release job') + } + const strategy = (releaseJob as Record).strategy + if (typeof strategy !== 'object' || strategy === null) { + throw new Error('release job has no strategy') + } + const matrix = (strategy as Record).matrix + if (typeof matrix !== 'object' || matrix === null) { + throw new Error('release job has no strategy.matrix') + } + const include = (matrix as Record).include + if (!Array.isArray(include)) { + throw new Error('release job has no strategy.matrix.include') + } + if (include.length === 0) { + throw new Error('release matrix include is empty') + } + const rows: MatrixRow[] = [] + for (const row of include) { + if (typeof row !== 'object' || row === null) { + throw new Error(`malformed include row (not a mapping): ${JSON.stringify(row)}`) + } + const record = row as Record + const target = record.target + const suffix = record.suffix + const runner = record.runner + if ( + typeof target !== 'string' || + typeof suffix !== 'string' || + typeof runner !== 'string' + ) { + throw new Error( + `malformed include row: target/suffix/runner must be strings, got ${ + JSON.stringify(record) + }`, + ) } - if (rows.length > 0) return rows + rows.push({ target, suffix, runner }) } - return [] + return rows } diff --git a/scripts/lib/shared.ts b/scripts/lib/shared.ts index 2ae4eeb..2ed0ba6 100644 --- a/scripts/lib/shared.ts +++ b/scripts/lib/shared.ts @@ -18,6 +18,7 @@ export interface Target { os: string cpu: string libc?: string + runner: string bin: string } diff --git a/scripts/lib/targets.json b/scripts/lib/targets.json index 68b3dc5..64b7f14 100644 --- a/scripts/lib/targets.json +++ b/scripts/lib/targets.json @@ -5,6 +5,7 @@ "os": "linux", "cpu": "x64", "libc": "glibc", + "runner": "ubuntu-latest", "bin": "comment-checker" }, { @@ -13,6 +14,7 @@ "os": "linux", "cpu": "arm64", "libc": "glibc", + "runner": "ubuntu-24.04-arm", "bin": "comment-checker" }, { @@ -20,6 +22,7 @@ "suffix": "darwin-x64", "os": "darwin", "cpu": "x64", + "runner": "macos-14", "bin": "comment-checker" }, { @@ -27,6 +30,7 @@ "suffix": "darwin-arm64", "os": "darwin", "cpu": "arm64", + "runner": "macos-14", "bin": "comment-checker" }, { @@ -34,6 +38,7 @@ "suffix": "win32-x64", "os": "win32", "cpu": "x64", + "runner": "windows-2022", "bin": "comment-checker.exe" } ] diff --git a/scripts/tools/check-matrix.test.ts b/scripts/tools/check-matrix.test.ts index 36d789e..acec228 100644 --- a/scripts/tools/check-matrix.test.ts +++ b/scripts/tools/check-matrix.test.ts @@ -1,10 +1,12 @@ import { assertEquals, assertThrows } from '@std/assert' import { matrixRows } from '../lib/matrix-rows.ts' -// Issue #8 regression fixture: the workflow must be parsed as YAML, so +// Issue #8 regression fixtures: the workflow must be parsed as YAML, so // formatting variants (flow-style include list, quoted target, reordered -// keys, comments) resolve to the same typed rows the regex scraper used to -// miss — which let the gate pass vacuously. +// keys, comments) resolve to the same typed rows. The gate now reads ONLY +// the named `release` job, requires every row to carry target/suffix/runner, +// and throws (fails loudly) on any malformed/empty matrix. + const FLOW_STYLE = `name: Release on: push: @@ -18,9 +20,9 @@ jobs: include: [ { target: "x86_64-unknown-linux-gnu", suffix: linux-x64, runner: ubuntu-latest }, { target: aarch64-unknown-linux-gnu, runner: "ubuntu-24.04-arm", suffix: linux-arm64 }, - { suffix: darwin-x64, target: x86_64-apple-darwin }, - { target: aarch64-apple-darwin, suffix: darwin-arm64 }, - { target: x86_64-pc-windows-msvc, suffix: win32-x64 }, + { suffix: darwin-x64, target: x86_64-apple-darwin, runner: macos-14 }, + { target: aarch64-apple-darwin, suffix: darwin-arm64, runner: macos-14 }, + { target: x86_64-pc-windows-msvc, suffix: win32-x64, runner: windows-2022 }, ] ` @@ -43,7 +45,7 @@ jobs: runner: ubuntu-24.04-arm - target: x86_64-apple-darwin suffix: darwin-x64 - runner: macos-13 + runner: macos-14 - target: aarch64-apple-darwin suffix: darwin-arm64 runner: macos-14 @@ -52,30 +54,88 @@ jobs: runner: windows-2022 ` -const EXPECTED: Array<[string, string]> = [ - ['x86_64-unknown-linux-gnu', 'linux-x64'], - ['aarch64-unknown-linux-gnu', 'linux-arm64'], - ['x86_64-apple-darwin', 'darwin-x64'], - ['aarch64-apple-darwin', 'darwin-arm64'], - ['x86_64-pc-windows-msvc', 'win32-x64'], +const EXPECTED: Array<[string, string, string]> = [ + ['x86_64-unknown-linux-gnu', 'linux-x64', 'ubuntu-latest'], + ['aarch64-unknown-linux-gnu', 'linux-arm64', 'ubuntu-24.04-arm'], + ['x86_64-apple-darwin', 'darwin-x64', 'macos-14'], + ['aarch64-apple-darwin', 'darwin-arm64', 'macos-14'], + ['x86_64-pc-windows-msvc', 'win32-x64', 'windows-2022'], ] Deno.test('matrixRows parses the block-style workflow', () => { const rows = matrixRows(BLOCK_STYLE) - assertEquals(rows.map((r) => [r.target, r.suffix]), EXPECTED) + assertEquals( + rows.map((r) => [r.target, r.suffix, r.runner]), + EXPECTED, + ) }) Deno.test('matrixRows parses the flow-style workflow (issue #8 regression)', () => { const rows = matrixRows(FLOW_STYLE) - assertEquals(rows.map((r) => [r.target, r.suffix]), EXPECTED) + assertEquals( + rows.map((r) => [r.target, r.suffix, r.runner]), + EXPECTED, + ) }) Deno.test('matrixRows throws on malformed YAML (gate must fail loudly)', () => { - // A parse error must propagate so the check-matrix CLI reports FAIL rather - // than comparing against an empty row set. assertThrows(() => matrixRows('jobs: [unclosed')) }) -Deno.test('matrixRows ignores jobs without a matrix include', () => { - assertEquals(matrixRows('jobs:\n lint:\n runs-on: ubuntu-latest\n'), []) +Deno.test('matrixRows throws when the release job is missing (issue #8 refit)', () => { + // A decoy job with a matrix must NOT satisfy the gate: only the named + // `release` job is authoritative, so a workflow without it throws. + assertThrows(() => + matrixRows( + 'jobs:\n' + + ' build:\n' + + ' strategy:\n' + + ' matrix:\n' + + ' include:\n' + + ' - target: x86_64-unknown-linux-gnu\n' + + ' suffix: linux-x64\n' + + ' runner: ubuntu-latest\n', + ) + ) +}) + +Deno.test('matrixRows throws when the release matrix include is empty', () => { + assertThrows(() => + matrixRows( + 'jobs:\n' + + ' release:\n' + + ' strategy:\n' + + ' matrix:\n' + + ' include: []\n', + ) + ) +}) + +Deno.test('matrixRows throws when a release row lacks a runner (#5)', () => { + assertThrows(() => + matrixRows( + 'jobs:\n' + + ' release:\n' + + ' strategy:\n' + + ' matrix:\n' + + ' include:\n' + + ' - target: x86_64-unknown-linux-gnu\n' + + ' suffix: linux-x64\n', + ) + ) +}) + +Deno.test('matrixRows throws when a release row has a non-string suffix (#11)', () => { + assertThrows(() => + matrixRows( + 'jobs:\n' + + ' release:\n' + + ' strategy:\n' + + ' matrix:\n' + + ' include:\n' + + ' - target: x86_64-unknown-linux-gnu\n' + + ' suffix: [linux, x64]\n' + + ' runner: ubuntu-latest\n', + ) + ) }) diff --git a/scripts/tools/check-matrix.ts b/scripts/tools/check-matrix.ts index 12709fc..325f79c 100755 --- a/scripts/tools/check-matrix.ts +++ b/scripts/tools/check-matrix.ts @@ -14,6 +14,11 @@ import { // derived from the table under check. const EXPECTED_SUFFIXES = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64'] +// Known-good hosted runners. The release workflow's per-row runner must match +// the table's canonical runner AND be one of these — a retired or mistyped +// label (e.g. macos-13) must fail the gate, not pass it (issue #8 refit). +const KNOWN_RUNNERS = new Set(['ubuntu-latest', 'ubuntu-24.04-arm', 'macos-14', 'windows-2022']) + const failures: string[] = [] const fail = (reason: string) => failures.push(reason) const note = (message: string) => console.error(`check-matrix: note: ${message}`) @@ -60,6 +65,13 @@ function checkTable(targets: Target[]) { `target ${entry.target}: suffix "${entry.suffix}" must equal os-cpu "${entry.os}-${entry.cpu}"`, ) } + if (!KNOWN_RUNNERS.has(entry.runner)) { + fail( + `target ${entry.target}: runner "${entry.runner}" is not a known runner; known are ${ + [...KNOWN_RUNNERS].join(', ') + }`, + ) + } if ((entry.os === 'win32') !== (entry.bin === 'comment-checker.exe')) { fail( `target ${entry.target}: bin must be comment-checker.exe iff os is win32 (os: ${entry.os}, bin: ${entry.bin})`, @@ -117,30 +129,38 @@ async function checkWorkflow(workflowPath: string, targets: Target[]) { await Deno.lstat(workflowPath) const content = await Deno.readTextFile(workflowPath) // Typed YAML parse (issue #8): formatting variants (flow style, quoting, - // key order) must not change what rows are seen, and malformed YAML must - // fail the gate instead of yielding an empty match set. - const workflowPairs = new Map(matrixRows(content).map((row) => [row.target, row.suffix])) - const tablePairs = new Map(targets.map((t) => [t.target, t.suffix])) - for (const [target, suffix] of tablePairs) { - if (!workflowPairs.has(target)) { - fail(`release.yml does not list release target ${target}`) - } else if (workflowPairs.get(target) !== suffix) { + // key order) must not change what rows are seen, and any malformed or + // empty matrix — or a missing release job — throws inside matrixRows and + // fails the gate instead of yielding an empty match set. + const workflowRows = matrixRows(content) + const tableRows = new Map(targets.map((t) => [t.target, t])) + if (workflowRows.length !== targets.length) { + fail( + `release.yml lists ${workflowRows.length} matrix rows; targets.json has ${targets.length}`, + ) + } + for (const row of workflowRows) { + const entry = tableRows.get(row.target) + if (!entry) { + fail(`release.yml lists ${row.target}, which is not a row in targets.json`) + continue + } + if (row.suffix !== entry.suffix) { fail( - `release.yml lists ${target} with suffix ${ - workflowPairs.get(target) - }, table says ${suffix}`, + `release.yml lists ${row.target} with suffix ${row.suffix}, table says ${entry.suffix}`, ) } - } - for (const [target, suffix] of workflowPairs) { - if (!tablePairs.has(target)) { - fail(`release.yml lists ${target}, which is not a row in targets.json`) - } else if (suffix !== tablePairs.get(target)) { + if (row.runner !== entry.runner) { fail( - `release.yml lists ${target} with suffix ${suffix}, table says ${tablePairs.get(target)}`, + `release.yml lists ${row.target} on runner ${row.runner}, table says ${entry.runner}`, ) } } + for (const entry of targets) { + if (!workflowRows.some((row) => row.target === entry.target)) { + fail(`release.yml does not list release target ${entry.target}`) + } + } } catch (error) { if (error instanceof Deno.errors.NotFound) { note(`skipped: ${workflowPath} not found (workflow agreement not checked)`) diff --git a/scripts/tools/release-smoke.test.ts b/scripts/tools/release-smoke.test.ts new file mode 100644 index 0000000..ae8ad15 --- /dev/null +++ b/scripts/tools/release-smoke.test.ts @@ -0,0 +1,47 @@ +import { assertEquals } from '@std/assert' +import { parse as parseYaml } from '@std/yaml' +import { join } from '@std/path' + +// Issue #12: the release workflow's smoke step hard-codes the exit-code +// contract (`rc -eq 0` / `rc -eq 2`) that crates/comment-checker/tests/ +// exit_codes.rs also asserts. Previously the two could drift silently and +// the mismatch surfaced only at the first tagged publish. This test parses +// release.yml and pins the workflow's own copy of the contract AND the exact +// payload bytes so any change to either side breaks CI here, not at tag time. + +const ROOT = join(import.meta.dirname!, '..', '..') +const WORKFLOW = join(ROOT, '.github', 'workflows', 'release.yml') + +const CLEAN_PAYLOAD = + '{"tool_name":"Write","tool_input":{"file_path":"src/client.py","content":"# SPDX-License-Identifier: Apache-2.0\\ndef load(path):\\n return open(path).read()\\n"}}' + +// Must stay byte-identical to FLAGGED_PAYLOAD in +// crates/comment-checker/tests/exit_codes.rs. +const FLAGGED_PAYLOAD = + '{"tool_name":"Write","tool_input":{"file_path":"src/load_config.py","content":"def load_config(path):\\n # TODO: fix this later\\n return json.load(open(path))\\n"}}' + +const workflowText = Deno.readTextFileSync(WORKFLOW) +const doc = parseYaml(workflowText) as { + jobs: Record }> +} +const jobs = doc.jobs ?? {} +const smokeStep = Object.values(jobs) + .flatMap((job) => job.steps ?? []) + .find((step) => step.name === 'Gate: binary smoke tests') + +Deno.test('release.yml smoke step exists in every job set', () => { + assertEquals(typeof smokeStep, 'object') + assertEquals(typeof smokeStep?.run, 'string') +}) + +Deno.test('release.yml smoke step pins exit contract 0 (clean) and 2 (flagged)', () => { + const run = smokeStep!.run! + assertEquals(run.includes('test "$rc" -eq 0'), true, 'missing -eq 0 assertion') + assertEquals(run.includes('test "$rc" -eq 2'), true, 'missing -eq 2 assertion') +}) + +Deno.test('release.yml smoke payloads are byte-identical to exit_codes.rs constants', () => { + const run = smokeStep!.run! + assertEquals(run.includes(FLAGGED_PAYLOAD), true, 'flagged payload drifted from exit_codes.rs') + assertEquals(run.includes(CLEAN_PAYLOAD), true, 'clean payload drifted from exit_codes.rs') +})