From 2b1914286d53d01a768850c237b7274865b24037 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 8 Oct 2026 02:29:25 +0530 Subject: [PATCH 1/4] Set up pinned Node in every Rust CI job and drop unused trusted-server-js deps trusted-server-core depends on trusted-server-js, whose build script runs the TSJS build with the npm on PATH. Most Rust CI jobs never set up the .tool-versions Node, so they built the embedded bundles with the runner image Node (22 on ubuntu-24.04). Add setup-node with node-version-file and npm caching to test-cloudflare, test-spin, test-parity and format-rust, and to the shared integration-test composite action before its Rust builds. Drop the now-redundant setup-node step from the integration-tests job. Remove the unused direct trusted-server-js dependency from the Cloudflare and Spin adapters; both still get it through trusted-server-core. Closes #1204 Signed-off-by: dhruv8sh --- .../setup-integration-test-env/action.yml | 9 +++++++ .github/workflows/format.yml | 9 +++++++ .github/workflows/integration-tests.yml | 5 ---- .github/workflows/test.yml | 27 +++++++++++++++++++ Cargo.lock | 2 -- .../Cargo.toml | 1 - crates/trusted-server-adapter-spin/Cargo.toml | 1 - 7 files changed, 45 insertions(+), 9 deletions(-) diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index 841d8d5bd9..6919e580d9 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -44,6 +44,15 @@ runs: shell: bash run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" + # The Rust builds below compile trusted-server-core, whose dependency on + # trusted-server-js runs the TSJS build with the `npm` on PATH. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + - name: Retrieve Viceroy version id: viceroy-version if: ${{ inputs.install-viceroy == 'true' }} diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 93bc992290..71218dc4a9 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -58,6 +58,15 @@ jobs: components: "clippy, rustfmt" cache-shared-key: cargo-${{ runner.os }} + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + - name: Run cargo fmt uses: actions-rust-lang/rustfmt@v1 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 157a3582d3..7e6e639fac 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -90,11 +90,6 @@ jobs: - name: Load integration test Docker images run: docker load --input "$DOCKER_ARTIFACT_PATH" - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ steps.shared-setup.outputs.node-version }} - - name: Install wrangler run: npm install -g wrangler diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d06a1b8ff0..e96c049597 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,6 +140,15 @@ jobs: target: wasm32-unknown-unknown cache-shared-key: cargo-${{ runner.os }} + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + - name: Check Cloudflare adapter (native host) run: cargo check -p trusted-server-adapter-cloudflare @@ -167,6 +176,15 @@ jobs: target: wasm32-wasip1 cache-shared-key: cargo-${{ runner.os }} + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + - name: Check Spin adapter (native host) run: cargo check -p trusted-server-adapter-spin @@ -206,6 +224,15 @@ jobs: components: clippy, rustfmt cache-shared-key: cargo-${{ runner.os }} + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + cache: npm + cache-dependency-path: crates/trusted-server-js/lib/package-lock.json + - name: Format check (parity test crate) run: cargo fmt --manifest-path crates/trusted-server-integration-tests/Cargo.toml -- --check diff --git a/Cargo.lock b/Cargo.lock index f1fff157d9..610421e4c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5416,7 +5416,6 @@ dependencies = [ "tokio", "toml", "trusted-server-core", - "trusted-server-js", "worker", ] @@ -5465,7 +5464,6 @@ dependencies = [ "spin-sdk", "tokio", "trusted-server-core", - "trusted-server-js", ] [[package]] diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 2ce969ef79..347b97647c 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -31,7 +31,6 @@ futures = { workspace = true } log = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } -trusted-server-js = { workspace = true } worker = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 81eeb94307..5d81031ec9 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -32,7 +32,6 @@ futures = { workspace = true } http-body-util = { workspace = true } log = { workspace = true } trusted-server-core = { workspace = true } -trusted-server-js = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] spin-sdk = { workspace = true } From 9f139a0a532f73a548de80916d58dd33a5f7827a Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Sat, 10 Oct 2026 06:13:27 +0530 Subject: [PATCH 2/4] Guard the pinned Node setup in CI with a step-order check Add scripts/check-ci-node-pin.py and a CI Node pin guard job that fail when a job compiles Rust before an unconditional actions/setup-node step pinned to .tool-versions, inlining local composite actions. Name and comment the existing Node steps in test-rust, test-axum and test-cli for the TSJS build, and note in the integration setup action that its Node step must stay unconditional. Signed-off-by: dhruv8sh --- .../setup-integration-test-env/action.yml | 7 +- .github/workflows/format.yml | 11 + .github/workflows/test.yml | 18 +- scripts/README.md | 1 + scripts/check-ci-node-pin.py | 265 ++++++++++++++++++ 5 files changed, 295 insertions(+), 7 deletions(-) create mode 100755 scripts/check-ci-node-pin.py diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index 6919e580d9..183c4e590d 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -44,8 +44,11 @@ runs: shell: bash run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" - # The Rust builds below compile trusted-server-core, whose dependency on - # trusted-server-js runs the TSJS build with the `npm` on PATH. + # Pin Node before anything compiles trusted-server-core: its dependency on + # trusted-server-js runs the TSJS build with the `npm` on PATH. That covers + # the Rust builds below and the callers' later cargo steps, and callers use + # this Node directly too (e.g. `npm install -g wrangler`), so keep the step + # unconditional. - name: Use Node.js for the TSJS build uses: actions/setup-node@v4 with: diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 1fb5e63cc3..5425e0f9e2 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -39,6 +39,17 @@ jobs: exit 1 fi + check-ci-node-pin: + name: CI Node pin guard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Every job that compiles trusted-server-core also runs the TSJS build, so + # it must set up the `.tool-versions` Node first (see issue #1204). + - name: Verify jobs pin Node before compiling Rust + run: python3 scripts/check-ci-node-pin.py + format-rust: name: cargo fmt runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6cd2c3289e..971b694686 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,7 +51,9 @@ jobs: if: steps.cache-viceroy.outputs.cache-hit != 'true' run: cargo install viceroy --version "${{ steps.viceroy-version.outputs.viceroy-version }}" --locked --force - - name: Use Node.js for the served-seam contract + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build and the served-seam contract uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.node-version }} @@ -91,12 +93,15 @@ jobs: target: wasm32-wasip1 cache-shared-key: cargo-${{ runner.os }} - - name: Read pinned Node.js version for CLI fixtures + - name: Read pinned Node.js version id: node-version shell: bash run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v4 + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build and the CLI fixtures + uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.version }} @@ -267,12 +272,15 @@ jobs: components: "clippy, rustfmt" cache-shared-key: cargo-cli-${{ runner.os }} - - name: Read pinned Node.js version for CLI fixtures + - name: Read pinned Node.js version id: node-version shell: bash run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v4 + # trusted-server-core's dependency on trusted-server-js runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build and the CLI fixtures + uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.version }} diff --git a/scripts/README.md b/scripts/README.md index ad4ecf9262..84de4f611d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -7,6 +7,7 @@ their documented orchestration handles a probe deliberately. | ----------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `batch-sync.sh` | Endpoint, bearer token, EC ID, partner UID; `curl` and Python | Sends one authenticated batch-sync request. Its temporary response file is removed on exit. | | `benchmark.sh` | A running server; `curl`, `bc`, and optionally `hey` | May install `hey` through Homebrew. `--save` writes under `benchmark-results/`; it does not stop the server. | +| `check-ci-node-pin.py` | Python 3 (standard library only) | Read-only. Fails when a CI job compiles Rust before an unconditional `actions/setup-node` step pinned to `.tool-versions`; `--root` checks another checkout. | | `profile.sh` | Fastly CLI, Rust WASM target, `curl`; endpoint and request options | Builds and starts the Fastly app, stops the owned process, and retains a profile under `benchmark-results/profiles/`. `--open` launches the local viewer. | | `generate-integration-viceroy-configs.sh` | Rust toolchain; optional origin port and artifact directory | Builds the native generator and writes Viceroy config under `target/integration-test-artifacts/`; generated files persist. | | `integration-tests.sh` | Docker, Viceroy, Rust WASM target, pinned Node | Builds WASM/native artifacts and two Docker images, generates Viceroy config, and runs native integration tests serially. Build products and images persist. | diff --git a/scripts/check-ci-node-pin.py b/scripts/check-ci-node-pin.py new file mode 100755 index 0000000000..8400875def --- /dev/null +++ b/scripts/check-ci-node-pin.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Check that every CI job pins Node before it compiles Rust. + +trusted-server-core depends on trusted-server-js, whose build script runs the +TSJS build with the `npm` on PATH. A job that compiles core before setting up +the `.tool-versions` Node builds the embedded bundles with the runner image's +Node instead (see #1204). + +For each job in `.github/workflows/*.yml`, the check walks the steps in order, +inlining local composite actions (`uses: ./...`). It fails when a step that +compiles Rust comes before an unconditional `actions/setup-node` step pinned +to `.tool-versions`. A step compiles Rust when it runs a `cargo` build, check, +test, clippy, bench, run or doc command (including the `cargo -` +aliases) or `worker-build`, directly or through a repository shell script. +A compile step counts even when it has an `if:`, but a conditional setup-node +step does not pin Node. A `node-version` taken from a step output counts as +pinned, since the workflows read that output from `.tool-versions`. + +The check uses only the standard library and reads YAML by indentation, so it +expects the block-style YAML the repository's workflows use. +""" + +import argparse +from dataclasses import dataclass +from pathlib import Path +import re +import sys + +COMPILE = re.compile( + r"\bcargo\s+(?:build|check|test|clippy|bench|run|doc|rustc)(?:-[\w-]+)?\b" + r"|\bworker-build\b" +) +SCRIPT = re.compile(r"(? Date: Thu, 15 Oct 2026 14:40:56 +0530 Subject: [PATCH 3/4] Harden CI Node pin guard and list it in CI gates Count any cargo subcommand as a compile except fmt, metadata, tree and registry installs, follow setup-node's precedence of node-version over node-version-file, let a later unpinned setup-node undo a pin, accept ./.tool-versions and trailing comments, and report a missing local action as a ParseError. List the guard in AGENTS.md and the check-ci command. Signed-off-by: dhruv8sh --- .claude/commands/check-ci.md | 1 + AGENTS.md | 1 + scripts/check-ci-node-pin.py | 34 +++++++++++++++++++++++----------- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/.claude/commands/check-ci.md b/.claude/commands/check-ci.md index 62b6281400..68339893ac 100644 --- a/.claude/commands/check-ci.md +++ b/.claude/commands/check-ci.md @@ -7,5 +7,6 @@ Run all CI checks locally, in order. Stop and report if any step fails. 5. `cd crates/trusted-server-js/lib && npx vitest run` 6. `cd crates/trusted-server-js/lib && npm run format` 7. `cd docs && npm run format` +8. `python3 scripts/check-ci-node-pin.py` Report a summary of all results when done. diff --git a/AGENTS.md b/AGENTS.md index dd1dd52337..79709e7aba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -363,6 +363,7 @@ Every PR must pass: 8. Markdown format outside `docs/` (requires `cd docs && npm ci` first): `docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md"`; fix with `--write` in place of `--check` 9. Native core build-digest test and lint (`cargo test -p trusted-server-core --test template_build_digest` and `cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings`) 10. Wasm clock lint: `scripts/lint-wasm-clock.sh` (crates built into the Cloudflare Worker must use `web_time`, not the `std::time::{Instant,SystemTime}` `now`/`elapsed` methods, which panic there) +11. CI Node pin guard: `python3 scripts/check-ci-node-pin.py` (every CI job that compiles Rust sets up the `.tool-versions` Node before its first cargo step) --- diff --git a/scripts/check-ci-node-pin.py b/scripts/check-ci-node-pin.py index 8400875def..682ad71c38 100755 --- a/scripts/check-ci-node-pin.py +++ b/scripts/check-ci-node-pin.py @@ -9,11 +9,12 @@ For each job in `.github/workflows/*.yml`, the check walks the steps in order, inlining local composite actions (`uses: ./...`). It fails when a step that compiles Rust comes before an unconditional `actions/setup-node` step pinned -to `.tool-versions`. A step compiles Rust when it runs a `cargo` build, check, -test, clippy, bench, run or doc command (including the `cargo -` -aliases) or `worker-build`, directly or through a repository shell script. -A compile step counts even when it has an `if:`, but a conditional setup-node -step does not pin Node. A `node-version` taken from a step output counts as +to `.tool-versions`. A step compiles Rust when it runs `worker-build` or any +`cargo` subcommand except `fmt`, `metadata`, `tree` and a registry `install` +(so aliases, toolchain overrides and unknown subcommands count), directly or +through a repository shell script. A compile step counts even when it has an +`if:`, but a conditional setup-node step does not pin Node, and a later +setup-node step that does not pin Node undoes an earlier pin. A `node-version` taken from a step output counts as pinned, since the workflows read that output from `.tool-versions`. The check uses only the standard library and reads YAML by indentation, so it @@ -27,11 +28,13 @@ import sys COMPILE = re.compile( - r"\bcargo\s+(?:build|check|test|clippy|bench|run|doc|rustc)(?:-[\w-]+)?\b" + r"\bcargo(?:[\s\\]+[+-]\S+)*[\s\\]+" + r"(?!(?:fmt|metadata|tree|install(?![^\n]*--path))(?:\s|$))\w" r"|\bworker-build\b" ) SCRIPT = re.compile(r"(? Date: Mon, 19 Oct 2026 17:38:19 +0530 Subject: [PATCH 4/4] Port the CI Node pin guard to Node and add its tests Python is not pinned in .tool-versions, so CI ran the guard on whatever python3 the runner shipped. The guard is now a dependency-free Node script run with the .tool-versions Node, and a node:test suite covers it, including the pre-guard tree. Only a setup-node step that names a version resets the pin, and quoted values followed by comments now parse correctly. Signed-off-by: dhruv8sh --- .claude/commands/check-ci.md | 2 +- .github/workflows/format.yml | 15 +- AGENTS.md | 2 +- scripts/README.md | 2 +- scripts/check-ci-node-pin.mjs | 451 ++++++++++++++++++++++++ scripts/check-ci-node-pin.py | 277 --------------- scripts/check-ci-node-pin.test.mjs | 533 +++++++++++++++++++++++++++++ 7 files changed, 1001 insertions(+), 281 deletions(-) create mode 100644 scripts/check-ci-node-pin.mjs delete mode 100755 scripts/check-ci-node-pin.py create mode 100644 scripts/check-ci-node-pin.test.mjs diff --git a/.claude/commands/check-ci.md b/.claude/commands/check-ci.md index 68339893ac..99fb4b504a 100644 --- a/.claude/commands/check-ci.md +++ b/.claude/commands/check-ci.md @@ -7,6 +7,6 @@ Run all CI checks locally, in order. Stop and report if any step fails. 5. `cd crates/trusted-server-js/lib && npx vitest run` 6. `cd crates/trusted-server-js/lib && npm run format` 7. `cd docs && npm run format` -8. `python3 scripts/check-ci-node-pin.py` +8. `node --test scripts/check-ci-node-pin.test.mjs && node scripts/check-ci-node-pin.mjs` Report a summary of all results when done. diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index e5853d82ee..d38644f659 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -45,10 +45,23 @@ jobs: steps: - uses: actions/checkout@v4 + # The guard's tests check the tree before the guard landed; fetch only + # that commit instead of the full history. + - name: Fetch the pre-guard commit + run: git fetch --no-tags --depth=1 origin 7a0ecb4cbfa39af3d83f7e1ed2733d7eabdc4cb1 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + + - name: Test the guard + run: node --test scripts/check-ci-node-pin.test.mjs + # Every job that compiles trusted-server-core also runs the TSJS build, so # it must set up the `.tool-versions` Node first (see issue #1204). - name: Verify jobs pin Node before compiling Rust - run: python3 scripts/check-ci-node-pin.py + run: node scripts/check-ci-node-pin.mjs format-rust: name: cargo fmt diff --git a/AGENTS.md b/AGENTS.md index e0b1f998c0..11730f2618 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -364,7 +364,7 @@ Every PR must pass: 8. Markdown format outside `docs/` (requires `cd docs && npm ci` first): `docs/node_modules/.bin/prettier --config docs/.prettierrc --check "*.md" ".claude/**/*.md" ".github/**/*.md" "crates/**/*.md" "scripts/**/*.md" "tinybird/**/*.md"`; fix with `--write` in place of `--check` 9. Native core build-digest test and lint (`cargo test -p trusted-server-core --test template_build_digest` and `cargo clippy -p trusted-server-core --test template_build_digest -- -D warnings`) 10. Wasm clock lint: `scripts/lint-wasm-clock.sh` (crates built into the Cloudflare Worker must use `web_time`, not the `std::time::{Instant,SystemTime}` `now`/`elapsed` methods, which panic there) -11. CI Node pin guard: `python3 scripts/check-ci-node-pin.py` (every CI job that compiles Rust sets up the `.tool-versions` Node before its first cargo step) +11. CI Node pin guard: `node --test scripts/check-ci-node-pin.test.mjs && node scripts/check-ci-node-pin.mjs` with the `.tool-versions` Node (every CI job that compiles Rust sets up the `.tool-versions` Node before its first cargo step) --- diff --git a/scripts/README.md b/scripts/README.md index 84de4f611d..d8ee33335b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -7,7 +7,7 @@ their documented orchestration handles a probe deliberately. | ----------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `batch-sync.sh` | Endpoint, bearer token, EC ID, partner UID; `curl` and Python | Sends one authenticated batch-sync request. Its temporary response file is removed on exit. | | `benchmark.sh` | A running server; `curl`, `bc`, and optionally `hey` | May install `hey` through Homebrew. `--save` writes under `benchmark-results/`; it does not stop the server. | -| `check-ci-node-pin.py` | Python 3 (standard library only) | Read-only. Fails when a CI job compiles Rust before an unconditional `actions/setup-node` step pinned to `.tool-versions`; `--root` checks another checkout. | +| `check-ci-node-pin.mjs` | Pinned Node; tests in `check-ci-node-pin.test.mjs` | Read-only. Fails when a CI job compiles Rust before an unconditional `actions/setup-node` step pinned to `.tool-versions`; `--root` checks another checkout. | | `profile.sh` | Fastly CLI, Rust WASM target, `curl`; endpoint and request options | Builds and starts the Fastly app, stops the owned process, and retains a profile under `benchmark-results/profiles/`. `--open` launches the local viewer. | | `generate-integration-viceroy-configs.sh` | Rust toolchain; optional origin port and artifact directory | Builds the native generator and writes Viceroy config under `target/integration-test-artifacts/`; generated files persist. | | `integration-tests.sh` | Docker, Viceroy, Rust WASM target, pinned Node | Builds WASM/native artifacts and two Docker images, generates Viceroy config, and runs native integration tests serially. Build products and images persist. | diff --git a/scripts/check-ci-node-pin.mjs b/scripts/check-ci-node-pin.mjs new file mode 100644 index 0000000000..44243951c7 --- /dev/null +++ b/scripts/check-ci-node-pin.mjs @@ -0,0 +1,451 @@ +#!/usr/bin/env node +// Check that every CI job pins Node before it compiles Rust. +// +// trusted-server-core depends on trusted-server-js, whose build script runs the +// TSJS build with the `npm` on PATH. A job that compiles core before setting up +// the `.tool-versions` Node builds the embedded bundles with the runner image's +// Node instead (see #1204). +// +// For each job in `.github/workflows/*.yml`, the check walks the steps in order, +// inlining local composite actions (`uses: ./...`). It fails when a step that +// compiles Rust comes before an unconditional `actions/setup-node` step pinned +// to `.tool-versions`. A step compiles Rust when it runs `worker-build` or any +// `cargo` subcommand except `fmt`, `metadata`, `tree` and a registry `install` +// (so aliases, toolchain overrides and unknown subcommands count), directly or +// through a repository shell script. A compile step counts even when it has an +// `if:`, but a conditional setup-node step does not pin Node. A later setup-node +// step that installs another version, even conditionally, undoes an earlier +// pin; one that names no version changes nothing. A `node-version` taken from a +// step output counts as pinned, since the workflows read that output from +// `.tool-versions`. +// +// The check uses only the Node standard library and reads YAML by indentation, +// so it expects the block-style YAML the repository's workflows use. Run it +// with the `.tool-versions` Node: `node scripts/check-ci-node-pin.mjs`. + +import { + existsSync, + readdirSync, + readFileSync, + realpathSync, + statSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +const COMPILE = new RegExp( + String.raw`\bcargo(?:[\s\\]+[+-]\S+)*[\s\\]+` + + String.raw`(?!(?:fmt|metadata|tree|install(?![^\n]*--path))(?:\s|$))\w` + + String.raw`|\bworker-build\b`, +); +const SCRIPT = + /(? start && chars.includes(value[end - 1])) { + end -= 1; + } + return value.slice(start, end); +} + +function firstContent(lines, start, end) { + for (let index = start; index < end; index += 1) { + if (isContent(lines[index])) { + return index; + } + } + return null; +} + +/** + * Return the keys at the shallowest indent of `lines[start:end]` as a `Map` of + * `{ line, value, start, end }` entries. + */ +export function mapping(lines, start, end) { + const entries = new Map(); + const first = firstContent(lines, start, end); + if (first === null) { + return entries; + } + const base = indentOf(lines[first]); + // A sequence may sit at its parent key's indent (`steps:` then `- run:`), + // so its items belong to that key's block rather than ending it. + const heads = []; + for (let index = first; index < end; index += 1) { + const line = lines[index]; + if ( + isContent(line) && + indentOf(line) <= base && + !line.trimStart().startsWith("-") + ) { + heads.push(index); + } + } + heads.forEach((head, position) => { + const match = KEY.exec(lines[head].trim()); + if (match === null) { + return; + } + const stop = position + 1 < heads.length ? heads[position + 1] : end; + let value = (match[2] ?? "").trim(); + // Keep a quoted scalar whole and drop any comment after it. A quote that + // does not close on this line starts a multi-line scalar, which is kept + // as is. + const quoted = QUOTED.exec(value); + if (quoted !== null) { + value = quoted[0]; + } else if (!value.startsWith("'") && !value.startsWith('"')) { + value = value.replace(TRAILING_COMMENT, ""); + } + entries.set(match[1], { line: head, value, start: head + 1, end: stop }); + }); + return entries; +} + +/** Return `[start, end]` for each item of the block sequence in range. */ +export function sequence(lines, start, end) { + const first = firstContent(lines, start, end); + if (first === null) { + return []; + } + const base = indentOf(lines[first]); + const heads = []; + for (let index = first; index < end; index += 1) { + const line = lines[index]; + if ( + isContent(line) && + indentOf(line) === base && + line.trimStart().startsWith("-") + ) { + heads.push(index); + } + } + return heads.map((head, position) => [ + head, + position + 1 < heads.length ? heads[position + 1] : end, + ]); +} + +/** Return the inline value and block lines of an entry, without comments. */ +function blockText(lines, entry) { + return [ + entry.value, + ...lines.slice(entry.start, entry.end).filter(isContent), + ].join("\n"); +} + +function scriptPaths(text) { + return Array.from(text.matchAll(SCRIPT), (match) => match[1]); +} + +/** Return whether a repository script runs a compile command. */ +function scriptCompiles(root, relative, seen) { + const file = path.join(root, relative); + if (seen.has(relative) || !isFile(file)) { + return false; + } + seen.add(relative); + const text = readLines(file).filter(isContent).join("\n"); + if (COMPILE.test(text)) { + return true; + } + return scriptPaths(text).some((nested) => scriptCompiles(root, nested, seen)); +} + +/** Return whether a `run:` script compiles Rust, directly or through a repository script. */ +export function runsCompile(root, text) { + if (COMPILE.test(text)) { + return true; + } + return scriptPaths(text).some((relative) => + scriptCompiles(root, relative, new Set()), + ); +} + +function setupNodeOptions(lines, step) { + const options = step.get("with"); + return options === undefined + ? null + : mapping(lines, options.start, options.end); +} + +/** Return whether a setup-node step installs the `.tool-versions` Node. */ +function pinsNode(lines, step) { + const options = setupNodeOptions(lines, step); + if (options === null) { + return false; + } + // setup-node uses `node-version` and ignores the file when both are set. + if (options.has("node-version")) { + return STEP_OUTPUT.test(options.get("node-version").value); + } + if (options.has("node-version-file")) { + let value = stripChars(options.get("node-version-file").value, "'\""); + if (value.startsWith("./")) { + value = value.slice(2); + } + return value === ".tool-versions"; + } + return false; +} + +/** Return whether a setup-node step names a Node version to install. */ +function installsNode(lines, step) { + const options = setupNodeOptions(lines, step); + if (options === null) { + return false; + } + return ["node-version", "node-version-file"].some( + (key) => options.has(key) && options.get(key).value !== "", + ); +} + +/** Yield the steps in range, inlining local composite actions. */ +function* expandSteps(root, file, lines, start, end, conditional, active) { + for (const [itemStart, itemEnd] of sequence(lines, start, end)) { + // Read the item as a mapping by blanking out its `-` marker. + const item = lines.slice(); + item[itemStart] = item[itemStart].replace("-", " "); + const step = mapping(item, itemStart, itemEnd); + const uses = step.has("uses") + ? stripChars(step.get("uses").value, "'\"") + : ""; + const stepConditional = conditional || step.has("if"); + if (uses.startsWith("./")) { + let action = path.join(root, uses, "action.yml"); + if (!isFile(action)) { + action = path.join(root, uses, "action.yaml"); + } + if (!isFile(action)) { + throw new ParseError( + `${file}:${itemStart + 1}: no action.yml for \`${uses}\``, + ); + } + if (active.has(action)) { + throw new ParseError(`${action}: composite action includes itself`); + } + yield* actionSteps( + root, + action, + stepConditional, + new Set([...active, action]), + ); + continue; + } + const run = step.has("run") ? blockText(item, step.get("run")) : ""; + const setupNode = uses.startsWith("actions/setup-node@"); + yield { + file, + line: itemStart + 1, + name: step.has("name") + ? step.get("name").value + : uses || run.trim().split("\n")[0], + setsNode: setupNode && installsNode(item, step), + pinsNode: setupNode && pinsNode(item, step), + conditional: stepConditional, + compiles: runsCompile(root, run), + }; + } +} + +function* actionSteps(root, file, conditional, active) { + const lines = readLines(file); + const top = mapping(lines, 0, lines.length); + if (!top.has("runs")) { + throw new ParseError(`${file}: no \`runs\` key`); + } + const runs = mapping(lines, top.get("runs").start, top.get("runs").end); + if (!runs.has("steps")) { + return; + } + const steps = runs.get("steps"); + yield* expandSteps( + root, + file, + lines, + steps.start, + steps.end, + conditional, + active, + ); +} + +/** + * Check one workflow file. + * + * Returns the number of jobs checked and a violation message per bad job. + * Throws a {@link ParseError} when the workflow does not have the expected shape. + */ +export function checkWorkflow(root, file) { + const lines = readLines(file); + const top = mapping(lines, 0, lines.length); + if (!top.has("jobs")) { + throw new ParseError(`${file}: no \`jobs\` key`); + } + const jobs = mapping(lines, top.get("jobs").start, top.get("jobs").end); + if (jobs.size === 0) { + throw new ParseError(`${file}: no jobs found`); + } + const violations = []; + for (const [jobId, job] of jobs) { + const keys = mapping(lines, job.start, job.end); + if (!keys.has("steps")) { + continue; + } + const steps = keys.get("steps"); + let pinned = false; + for (const step of expandSteps( + root, + file, + lines, + steps.start, + steps.end, + false, + new Set(), + )) { + if (step.setsNode) { + // A conditional step that pins either runs and re-pins or is skipped. + if (!step.pinsNode) { + pinned = false; + } else if (!step.conditional) { + pinned = true; + } + } else if (step.compiles && !pinned) { + violations.push( + `${path.relative(root, step.file)}:${step.line}: job \`${jobId}\` in ` + + `${path.relative(root, file)} compiles Rust in step \`${step.name}\` before ` + + "an unconditional actions/setup-node step pinned to .tool-versions", + ); + break; + } + } + } + return { jobCount: jobs.size, violations }; +} + +/** Return the workflow files under `root`, sorted by name. */ +export function workflowFiles(root) { + const directory = path.join(root, ".github", "workflows"); + if (!existsSync(directory)) { + return []; + } + return readdirSync(directory) + .filter((name) => /^.*\.y.*ml$/s.test(name)) + .sort() + .map((name) => path.join(directory, name)); +} + +/** + * Check every workflow under `root`. + * + * Returns the workflow and job counts and every violation found. Throws a + * {@link ParseError} when a workflow or local action has an unexpected shape. + */ +export function checkRepository(root) { + const workflows = workflowFiles(root); + let jobCount = 0; + const violations = []; + for (const workflow of workflows) { + const result = checkWorkflow(root, workflow); + jobCount += result.jobCount; + violations.push(...result.violations); + } + return { workflowCount: workflows.length, jobCount, violations }; +} + +function resolveRoot(root) { + const resolved = path.resolve(root); + return existsSync(resolved) ? realpathSync(resolved) : resolved; +} + +/** Run the check from the command line and return the exit code. */ +export function main(argv = process.argv.slice(2)) { + const { values } = parseArgs({ + args: argv, + options: { + root: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + }); + if (values.help) { + console.log( + "usage: check-ci-node-pin.mjs [--root ROOT]\n\n" + + "Check that every CI job pins Node before it compiles Rust.\n\n" + + " --root ROOT repository root to check (default: this script's repository)", + ); + return 0; + } + const root = resolveRoot( + values.root ?? + path.join(path.dirname(fileURLToPath(import.meta.url)), ".."), + ); + const workflowsDirectory = path.join(root, ".github", "workflows"); + if (workflowFiles(root).length === 0) { + console.error(`no workflows found under ${workflowsDirectory}`); + return 1; + } + let result; + try { + result = checkRepository(root); + } catch (error) { + if (error instanceof ParseError) { + console.error(`error: ${error.message}`); + return 1; + } + throw error; + } + for (const violation of result.violations) { + console.error(violation); + } + if (result.violations.length > 0) { + console.error( + `${result.violations.length} job(s) compile Rust before pinning Node`, + ); + return 1; + } + console.log( + `ok: ${result.jobCount} jobs in ${result.workflowCount} workflows pin Node before compiling Rust`, + ); + return 0; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href +) { + process.exitCode = main(); +} diff --git a/scripts/check-ci-node-pin.py b/scripts/check-ci-node-pin.py deleted file mode 100755 index 682ad71c38..0000000000 --- a/scripts/check-ci-node-pin.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env python3 -"""Check that every CI job pins Node before it compiles Rust. - -trusted-server-core depends on trusted-server-js, whose build script runs the -TSJS build with the `npm` on PATH. A job that compiles core before setting up -the `.tool-versions` Node builds the embedded bundles with the runner image's -Node instead (see #1204). - -For each job in `.github/workflows/*.yml`, the check walks the steps in order, -inlining local composite actions (`uses: ./...`). It fails when a step that -compiles Rust comes before an unconditional `actions/setup-node` step pinned -to `.tool-versions`. A step compiles Rust when it runs `worker-build` or any -`cargo` subcommand except `fmt`, `metadata`, `tree` and a registry `install` -(so aliases, toolchain overrides and unknown subcommands count), directly or -through a repository shell script. A compile step counts even when it has an -`if:`, but a conditional setup-node step does not pin Node, and a later -setup-node step that does not pin Node undoes an earlier pin. A `node-version` taken from a step output counts as -pinned, since the workflows read that output from `.tool-versions`. - -The check uses only the standard library and reads YAML by indentation, so it -expects the block-style YAML the repository's workflows use. -""" - -import argparse -from dataclasses import dataclass -from pathlib import Path -import re -import sys - -COMPILE = re.compile( - r"\bcargo(?:[\s\\]+[+-]\S+)*[\s\\]+" - r"(?!(?:fmt|metadata|tree|install(?![^\n]*--path))(?:\s|$))\w" - r"|\bworker-build\b" -) -SCRIPT = re.compile(r"(? rmSync(scratch, { recursive: true, force: true })); + +let fixtureCount = 0; + +/** Write `files` (relative path to text) into a new fixture repository. */ +function fixture(files) { + fixtureCount += 1; + const root = path.join(scratch, `repo-${fixtureCount}`); + for (const [relative, text] of Object.entries(files)) { + const file = path.join(root, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, text); + } + return root; +} + +/** Return a workflow with one `build` job whose steps are `steps`, indented as items. */ +function workflow(steps) { + const body = steps + .join("\n") + .split("\n") + .map((line) => (line === "" ? line : ` ${line}`)) + .join("\n"); + return `name: CI\non:\n pull_request:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n${body}\n`; +} + +/** Check a fixture repository holding one workflow with `steps`, plus `files`. */ +function check(steps, files = {}) { + const root = fixture({ + ".github/workflows/ci.yml": workflow(steps), + ...files, + }); + return checkRepository(root).violations; +} + +const CHECKOUT = "- uses: actions/checkout@v4"; +const PIN = + "- uses: actions/setup-node@v4\n with:\n node-version-file: .tool-versions"; +const BUILD = "- run: cargo build"; + +function assertClean(violations, message) { + assert.deepEqual(violations, [], message); +} + +function assertFlagged(violations, message) { + assert.equal(violations.length, 1, message); + assert.match( + violations[0], + /job `build` in \.github\/workflows\/ci\.yml compiles Rust/, + message, + ); +} + +// Ordering and conditions. + +test("flags a compile before the pin", () => { + assertFlagged( + check([CHECKOUT, BUILD, PIN]), + "should flag cargo build before setup-node", + ); +}); + +test("accepts a compile after the pin", () => { + assertClean( + check([CHECKOUT, PIN, BUILD]), + "should accept cargo build after setup-node", + ); +}); + +test("flags a compile when no step pins Node", () => { + assertFlagged( + check([CHECKOUT, BUILD]), + "should flag a job without setup-node", + ); +}); + +test("flags a compile with an if: before the pin", () => { + assertFlagged( + check([ + CHECKOUT, + "- if: github.event_name == 'push'\n run: cargo build", + PIN, + ]), + "should count a conditional compile step", + ); +}); + +test("does not count a conditional pin", () => { + assertFlagged( + check([ + CHECKOUT, + `- if: runner.os == 'Linux'\n uses: actions/setup-node@v4\n with:\n node-version-file: .tool-versions`, + BUILD, + ]), + "should not treat a conditional setup-node step as pinning Node", + ); +}); + +test("names the first compile step and reports each job once", () => { + const violations = check([ + CHECKOUT, + "- name: First build\n run: cargo build", + "- run: cargo test", + ]); + assert.equal(violations.length, 1, "should report the job once"); + assert.match( + violations[0], + /^\.github\/workflows\/ci\.yml:9: .*step `First build`/, + "should point at the first compile step", + ); +}); + +test("skips jobs without steps", () => { + const root = fixture({ + ".github/workflows/ci.yml": + "on: push\njobs:\n call:\n uses: ./.github/workflows/other.yml\n", + }); + assert.deepEqual( + checkRepository(root), + { workflowCount: 1, jobCount: 1, violations: [] }, + "should count but not check a reusable-workflow job", + ); +}); + +// What counts as a compile. + +test("counts cargo aliases, toolchain overrides and line continuations", () => { + for (const run of [ + "cargo clippy-fastly", + "cargo test-axum", + "cargo +nightly build", + "cargo -Zunstable-options check", + "|\n cargo \\\n build --release", + "cargo install --path crates/trusted-server-cli", + "worker-build --release", + ]) { + assertFlagged( + check([CHECKOUT, `- run: ${run}`, PIN]), + `should flag \`${run}\``, + ); + } +}); + +test("ignores cargo commands that do not compile", () => { + for (const run of [ + "cargo fmt --all -- --check", + "cargo metadata --format-version 1", + "cargo tree", + "cargo install cargo-nextest", + "cargo", + ]) { + assertClean( + check([CHECKOUT, `- run: ${run}`, PIN]), + `should not flag \`${run}\``, + ); + } +}); + +test("ignores cargo in comments", () => { + assertClean( + check([CHECKOUT, "- run: |\n # cargo build\n echo hi", PIN]), + "should skip comment lines in run blocks", + ); +}); + +test("follows repository shell scripts, including nested ones", () => { + const files = { + "scripts/build.sh": "#!/usr/bin/env bash\nscripts/inner.sh\n", + "scripts/inner.sh": "#!/usr/bin/env bash\ncargo build\n", + "scripts/quiet.sh": "#!/usr/bin/env bash\n# cargo build\necho hi\n", + }; + assertFlagged( + check([CHECKOUT, "- run: ./scripts/build.sh", PIN], files), + "should flag a script that runs another compiling script", + ); + assertClean( + check([CHECKOUT, "- run: scripts/quiet.sh", PIN], files), + "should ignore cargo in script comments", + ); + assertClean( + check([CHECKOUT, "- run: scripts/missing.sh", PIN], files), + "should ignore a script that does not exist", + ); +}); + +test("stops at script cycles", () => { + const files = { + "scripts/a.sh": "scripts/b.sh\n", + "scripts/b.sh": "scripts/a.sh\n", + }; + assertClean( + check([CHECKOUT, "- run: scripts/a.sh", PIN], files), + "should terminate on mutually recursive scripts", + ); +}); + +// What counts as a pin. + +test("accepts a ./ prefix, quotes and a trailing comment on node-version-file", () => { + for (const value of [ + "./.tool-versions", + "'.tool-versions'", + '".tool-versions" # pinned', + ".tool-versions # pinned", + "'./.tool-versions' # pinned", + ]) { + const pin = `- uses: actions/setup-node@v4\n with:\n node-version-file: ${value}`; + assertClean( + check([CHECKOUT, pin, BUILD]), + `should accept node-version-file: ${value}`, + ); + } +}); + +test("accepts a node-version read from a step output", () => { + const pin = + "- uses: actions/setup-node@v4\n with:\n node-version: ${{ steps.node-version.outputs.node-version }}"; + assertClean( + check([CHECKOUT, pin, BUILD]), + "should accept a step-output node-version", + ); +}); + +test("does not count a literal node-version or another version file", () => { + for (const options of [ + "node-version: 24.12.0", + "node-version-file: .nvmrc", + "node-version: 20\n node-version-file: .tool-versions", + ]) { + const pin = `- uses: actions/setup-node@v4\n with:\n ${options}`; + assertFlagged( + check([CHECKOUT, pin, BUILD]), + `should not treat \`${options}\` as pinned`, + ); + } +}); + +// Later setup-node steps (inline comment 4308405296). + +test("resets the pin when a later step installs another version", () => { + const other = "- uses: actions/setup-node@v4\n with:\n node-version: 20"; + assertFlagged( + check([CHECKOUT, PIN, other, BUILD]), + "should flag a later version change", + ); + assertFlagged( + check([ + CHECKOUT, + PIN, + `- if: runner.os == 'macOS'\n uses: actions/setup-node@v4\n with:\n node-version: 20`, + BUILD, + ]), + "should flag a later conditional version change", + ); +}); + +test("keeps the pin across a conditional re-pin of the same file", () => { + const repin = `- if: runner.os == 'macOS'\n uses: actions/setup-node@v4\n with:\n node-version-file: .tool-versions`; + assertClean( + check([CHECKOUT, PIN, repin, BUILD]), + "should keep the pin when a conditional step re-pins it", + ); +}); + +test("keeps the pin across setup-node steps that name no version", () => { + assertClean( + check([CHECKOUT, PIN, "- uses: actions/setup-node@v4", BUILD]), + "should ignore a bare setup-node step", + ); + assertClean( + check([ + CHECKOUT, + PIN, + "- uses: actions/setup-node@v4\n with:\n cache: npm\n cache-dependency-path: package-lock.json", + BUILD, + ]), + "should ignore a cache-only setup-node step", + ); +}); + +test("a versionless setup-node step does not pin on its own", () => { + assertFlagged( + check([ + CHECKOUT, + "- uses: actions/setup-node@v4\n with:\n cache: npm", + BUILD, + ]), + "should not treat a cache-only step as a pin", + ); +}); + +// Local composite actions. + +const COMPOSITE_PIN = + "name: Pin\nruns:\n using: composite\n steps:\n - uses: actions/setup-node@v4\n with:\n node-version-file: .tool-versions\n"; +const COMPOSITE_BUILD = + "name: Build\nruns:\n using: composite\n steps:\n - name: Build in action\n shell: bash\n run: cargo build\n"; + +test("inlines a composite action that pins Node", () => { + assertClean( + check([CHECKOUT, "- uses: ./.github/actions/pin", BUILD], { + ".github/actions/pin/action.yml": COMPOSITE_PIN, + }), + "should see the pin inside the action", + ); +}); + +test("inlines a composite action that compiles and reports the action's line", () => { + const violations = check([CHECKOUT, "- uses: ./.github/actions/build", PIN], { + ".github/actions/build/action.yaml": COMPOSITE_BUILD, + }); + assertFlagged(violations, "should flag a compile inside the action"); + assert.match( + violations[0], + /^\.github\/actions\/build\/action\.yaml:5: .*step `Build in action`/, + "should point at the action file", + ); +}); + +test("does not count a pin inside a conditional composite action", () => { + assertFlagged( + check( + [ + CHECKOUT, + "- if: runner.os == 'Linux'\n uses: ./.github/actions/pin", + BUILD, + ], + { ".github/actions/pin/action.yml": COMPOSITE_PIN }, + ), + "should carry the caller's if: into the action's steps", + ); +}); + +test("fails on a missing or self-including local action", () => { + assert.throws( + () => check([CHECKOUT, "- uses: ./.github/actions/missing"]), + (error) => + error instanceof ParseError && + /no action\.yml for `\.\/\.github\/actions\/missing`/.test(error.message), + "should raise ParseError for a missing action", + ); + const loop = + "name: Loop\nruns:\n using: composite\n steps:\n - uses: ./.github/actions/loop\n"; + assert.throws( + () => + check([CHECKOUT, "- uses: ./.github/actions/loop"], { + ".github/actions/loop/action.yml": loop, + }), + (error) => + error instanceof ParseError && /includes itself/.test(error.message), + "should raise ParseError for a recursive action", + ); +}); + +test("fails on a workflow without jobs", () => { + const root = fixture({ ".github/workflows/ci.yml": "on: push\n" }); + assert.throws( + () => checkRepository(root), + ParseError, + "should raise ParseError without a jobs key", + ); +}); + +// Quoted scalars and comments (inline comment 4308405292). + +test("resolves a quoted local action followed by a comment", () => { + assertClean( + check([CHECKOUT, '- uses: "./.github/actions/pin" # shared', BUILD], { + ".github/actions/pin/action.yml": COMPOSITE_PIN, + }), + "should strip the comment after a quoted uses:", + ); +}); + +test("keeps YAML escapes inside quoted run values", () => { + assertFlagged( + check([CHECKOUT, "- run: 'echo ''hi'' && cargo build' # note", PIN]), + "should keep text after a '' escape", + ); + assertFlagged( + check([CHECKOUT, '- run: "echo \\"hi\\" && cargo build" # note', PIN]), + 'should keep text after a \\" escape', + ); +}); + +test("mapping reads quoted, commented and unterminated values", () => { + const lines = [ + "plain: value # comment", + "single: 'it''s # kept' # dropped", + 'double: "say \\"hi\\" # kept" # dropped', + "hash: a#b", + 'open: "cargo build # kept', + ' still open"', + "empty:", + ]; + const values = Object.fromEntries( + Array.from(mapping(lines, 0, lines.length), ([key, entry]) => [ + key, + entry.value, + ]), + ); + assert.deepEqual( + values, + { + plain: "value", + single: "'it''s # kept'", + double: '"say \\"hi\\" # kept"', + hash: "a#b", + open: '"cargo build # kept', + empty: "", + }, + "should keep quoted scalars whole, drop trailing comments and leave unterminated quotes as is", + ); +}); + +// Command line and real trees. + +test("the command line exits 0 on a clean tree and 1 with violations", () => { + const clean = fixture({ + ".github/workflows/ci.yml": workflow([CHECKOUT, PIN, BUILD]), + }); + const dirty = fixture({ + ".github/workflows/ci.yml": workflow([CHECKOUT, BUILD]), + }); + const empty = fixture({ "README.md": "empty\n" }); + + const ok = spawnSync(process.execPath, [SCRIPT, "--root", clean], { + encoding: "utf8", + }); + assert.equal(ok.status, 0, "should exit 0 on a clean tree"); + assert.equal( + ok.stdout, + "ok: 1 jobs in 1 workflows pin Node before compiling Rust\n", + "should report the counts", + ); + + const bad = spawnSync(process.execPath, [SCRIPT, "--root", dirty], { + encoding: "utf8", + }); + assert.equal(bad.status, 1, "should exit 1 with violations"); + assert.match( + bad.stderr, + /1 job\(s\) compile Rust before pinning Node\n$/, + "should summarize the violations", + ); + + const none = spawnSync(process.execPath, [SCRIPT, "--root", empty], { + encoding: "utf8", + }); + assert.equal(none.status, 1, "should exit 1 without workflows"); + assert.match( + none.stderr, + /^no workflows found under /, + "should explain the failure", + ); +}); + +test("the current tree pins Node in every job", () => { + const result = checkRepository(REPOSITORY); + assert.deepEqual( + result.violations, + [], + "should report no violations on this checkout", + ); + assert.ok(result.jobCount > 0, "should check at least one job"); +}); + +function hasCommit(commit) { + return ( + spawnSync("git", ["-C", REPOSITORY, "cat-file", "-e", `${commit}^{commit}`]) + .status === 0 + ); +} + +test("the pre-guard tree has 8 jobs that compile before pinning Node", (context) => { + if (!hasCommit(PRE_GUARD_COMMIT)) { + // CI fetches full history for this job, so only skip in a local shallow clone. + assert.ok( + !process.env.CI, + `should have ${PRE_GUARD_COMMIT} in CI; check out with fetch-depth: 0`, + ); + context.skip(`${PRE_GUARD_COMMIT} is not in this clone`); + return; + } + const root = path.join(scratch, "pre-guard"); + mkdirSync(root); + const archive = execFileSync( + "git", + ["-C", REPOSITORY, "archive", PRE_GUARD_COMMIT, ".github", "scripts"], + { maxBuffer: 64 * 1024 * 1024 }, + ); + execFileSync("tar", ["-x", "-C", root], { input: archive }); + + const jobs = checkRepository(root).violations.map((violation) => + violation + .match(/job `([\w-]+)` in (\S+)/) + .slice(1) + .join(" "), + ); + assert.deepEqual( + jobs, + [ + "format-rust .github/workflows/format.yml", + "prepare-artifacts .github/workflows/integration-tests.yml", + "integration-tests .github/workflows/integration-tests.yml", + "integration-tests-fastly-ec .github/workflows/integration-tests.yml", + "browser-tests .github/workflows/integration-tests.yml", + "test-cloudflare .github/workflows/test.yml", + "test-spin .github/workflows/test.yml", + "test-parity .github/workflows/test.yml", + ], + "should flag the 8 jobs that compiled before pinning Node", + ); +});