diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 5419097d86..299768ffda 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -229,3 +229,25 @@ jobs: - name: Test proposal scripts run: scripts/docs-proposal/test.sh + + release-scripts: + name: release scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Retrieve Node.js version + id: node-version + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.node-version }} + + - name: Run shellcheck + run: shellcheck scripts/release/phase1-build.sh + + - name: Test release producer scripts + run: node --test scripts/release/phase1.test.mjs diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000000..309198d73a --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,197 @@ +name: Release build and signing +on: + workflow_call: +permissions: + contents: read +jobs: + checks: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + # Full history, so the branch check below can see every branch. + - uses: actions/checkout@v5 + with: + persist-credentials: false + fetch-depth: 0 + - name: Read pinned Node version + id: tools + run: echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v5 + with: + node-version: ${{ steps.tools.outputs.node }} + - run: node --test scripts/release/phase1.test.mjs + # Refuse a tag the publisher would reject before building and signing. + - name: Refuse non-SemVer release tags + if: startsWith(github.ref, 'refs/tags/') + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + node --input-type=module -e " + import { isReleaseTag } from './scripts/release/publish.mjs' + if (!isReleaseTag(process.env.RELEASE_TAG)) throw Error('Release tags must be vMAJOR.MINOR.PATCH[-PRERELEASE]') + " + # The publisher requires a lightweight tag at the built commit. Refuse an + # annotated tag here, before signing writes public attestations for it. + - name: Refuse annotated release tags + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + ref="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.type + " " + .object.sha')" + if [ "$ref" != "commit $GITHUB_SHA" ]; then + echo "::error::Release tags must be lightweight tags at $GITHUB_SHA; found $ref" + exit 1 + fi + # Draft creation failed with HTTP 403 in rehearsals when the tagged commit + # was on no branch. Refuse that before building and signing. Which + # branches may be released stays a maintainer decision. + - name: Refuse tags on commits outside every branch + if: startsWith(github.ref, 'refs/tags/') + run: | + branches="$(git branch -r --contains "$GITHUB_SHA")" + if [ -z "$branches" ]; then + echo "::error::Push $GITHUB_SHA to a branch before tagging it" + exit 1 + fi + printf 'Tagged commit is on:\n%s\n' "$branches" + build: + needs: checks + name: Build ${{ matrix.asset }} + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + package: trusted-server-adapter-fastly + target: wasm32-wasip1 + asset: trusted-server-fastly.wasm + - os: ubuntu-24.04 + package: trusted-server-cli + target: x86_64-unknown-linux-gnu + asset: ts-x86_64-unknown-linux-gnu + - os: macos-15 + package: trusted-server-cli + target: aarch64-apple-darwin + asset: ts-aarch64-apple-darwin + - os: macos-15-intel + package: trusted-server-cli + target: x86_64-apple-darwin + asset: ts-x86_64-apple-darwin + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Read pinned toolchains + id: tools + shell: bash + run: | + set -euo pipefail + echo "node=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "rust=$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v5 + with: + node-version: ${{ steps.tools.outputs.node }} + - name: Install the pinned Rust toolchain without build caches + env: + RUST_VERSION: ${{ steps.tools.outputs.rust }} + BUILD_TARGET: ${{ matrix.target }} + run: rustup toolchain install "$RUST_VERSION" --profile minimal --target "$BUILD_TARGET" + - name: Build and generate the build inventory in a clean environment + shell: bash + env: + BUILD_PACKAGE: ${{ matrix.package }} + BUILD_TARGET: ${{ matrix.target }} + RELEASE_ASSET: ${{ matrix.asset }} + SOURCE_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + env -i PATH="$PATH" HOME="$HOME" \ + CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" \ + RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" \ + TMPDIR="${TMPDIR:-/tmp}" RUNNER_TEMP="$RUNNER_TEMP" \ + SOURCE_COMMIT="$SOURCE_COMMIT" \ + bash scripts/release/phase1-build.sh "$BUILD_PACKAGE" "$BUILD_TARGET" "$RELEASE_ASSET" + # Only the tag-triggered signing job reads these; a main push is a build + # and smoke check. + - uses: actions/upload-artifact@v6 + if: startsWith(github.ref, 'refs/tags/v') + with: + name: release-${{ matrix.asset }} + path: release-assets/* + if-no-files-found: error + retention-days: 7 + + sign: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.RELEASE_PUBLICATION_ENABLED == 'true' + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + asset: + [ + trusted-server-fastly.wasm, + ts-aarch64-apple-darwin, + ts-x86_64-apple-darwin, + ts-x86_64-unknown-linux-gnu, + ] + steps: + # Only fixed inline checks execute here; never checkout or execute build inputs. + - uses: actions/download-artifact@v7 + with: + name: release-${{ matrix.asset }} + path: release-assets + - name: Validate signing inputs and retain their digests + env: + ASSET: ${{ matrix.asset }} + run: | + set -euo pipefail + cd release-assets + test "$(find . -mindepth 1 -maxdepth 1 | wc -l)" -eq 2 + for file in "$ASSET" "$ASSET.spdx.json"; do test -f "$file" && test ! -L "$file" && test -s "$file" || exit 1; done + sha256sum "$ASSET" "$ASSET.spdx.json" > ../inputs.sha256 + - name: Attest SLSA build provenance + id: provenance + uses: actions/attest@v4 + with: + subject-path: | + release-assets/${{ matrix.asset }} + release-assets/${{ matrix.asset }}.spdx.json + - name: Attest the binary's SPDX SBOM + id: sbom + uses: actions/attest@v4 + with: + subject-path: release-assets/${{ matrix.asset }} + sbom-path: release-assets/${{ matrix.asset }}.spdx.json + - name: Retain only this run's real bundles and signed-input digests + env: + ASSET: ${{ matrix.asset }} + PROVENANCE: ${{ steps.provenance.outputs.bundle-path }} + SBOM: ${{ steps.sbom.outputs.bundle-path }} + run: | + set -euo pipefail + (cd release-assets && sha256sum --check ../inputs.sha256) + test -s "$PROVENANCE" + test -s "$SBOM" + mkdir signed-assets + cp "$PROVENANCE" "signed-assets/$ASSET.provenance.sigstore.json" + cp "$SBOM" "signed-assets/$ASSET.sbom.sigstore.json" + cp inputs.sha256 signed-assets/SHA256SUMS + (cd signed-assets && sha256sum "$ASSET.provenance.sigstore.json" "$ASSET.sbom.sigstore.json" >> SHA256SUMS) + - uses: actions/upload-artifact@v6 + with: + name: signed-${{ matrix.asset }} + path: signed-assets/* + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..5552a2206e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Binary attestations +# Pull requests run the release Node suite in format.yml. The release builds +# run on every push to main, so they need no input path list, and on tags. +on: + push: + branches: [main] + tags: ['v*'] +permissions: + contents: read +concurrency: + group: binary-attestations-${{ github.ref }} + cancel-in-progress: false +jobs: + build: + uses: ./.github/workflows/release-build.yml + permissions: + contents: read + id-token: write + attestations: write + release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.RELEASE_PUBLICATION_ENABLED == 'true' + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v7 + with: + name: release-trusted-server-fastly.wasm + path: build/trusted-server-fastly.wasm + - uses: actions/download-artifact@v7 + with: + name: signed-trusted-server-fastly.wasm + path: signed/trusted-server-fastly.wasm + - uses: actions/download-artifact@v7 + with: + name: release-ts-aarch64-apple-darwin + path: build/ts-aarch64-apple-darwin + - uses: actions/download-artifact@v7 + with: + name: signed-ts-aarch64-apple-darwin + path: signed/ts-aarch64-apple-darwin + - uses: actions/download-artifact@v7 + with: + name: release-ts-x86_64-apple-darwin + path: build/ts-x86_64-apple-darwin + - uses: actions/download-artifact@v7 + with: + name: signed-ts-x86_64-apple-darwin + path: signed/ts-x86_64-apple-darwin + - uses: actions/download-artifact@v7 + with: + name: release-ts-x86_64-unknown-linux-gnu + path: build/ts-x86_64-unknown-linux-gnu + - uses: actions/download-artifact@v7 + with: + name: signed-ts-x86_64-unknown-linux-gnu + path: signed/ts-x86_64-unknown-linux-gnu + # Run only the publisher and its notes template from the tagged commit; + # never check out build inputs here. + - uses: actions/checkout@v5 + with: + path: publisher + persist-credentials: false + sparse-checkout: | + .tool-versions + scripts/release/publish.mjs + scripts/release/release-notes.md + sparse-checkout-cone-mode: false + - name: Read pinned Node version + id: tools + run: echo "node=$(awk '$1 == "nodejs" { print $2 }' publisher/.tool-versions)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v5 + with: + node-version: ${{ steps.tools.outputs.node }} + - name: Install the pinned GitHub CLI + env: + GH_VERSION: 2.102.0 + GH_SHA256: bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386 + run: | + set -euo pipefail + archive="gh_${GH_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${archive}" \ + --output "$RUNNER_TEMP/$archive" + printf '%s %s\n' "$GH_SHA256" "$RUNNER_TEMP/$archive" | sha256sum --check + tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/gh_${GH_VERSION}_linux_amd64/bin" >> "$GITHUB_PATH" + - name: Verify handoff, create once, publish by release ID and verify + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + SOURCE_COMMIT: ${{ github.sha }} + RELEASE_PUBLICATION_ENABLED: ${{ vars.RELEASE_PUBLICATION_ENABLED }} + NODE_VERSION: ${{ steps.tools.outputs.node }} + run: | + set -euo pipefail + test "$(gh --version | head -1 | cut -d' ' -f3)" = 2.102.0 + # publish.mjs runs only where import.meta.main exists; an older Node + # would exit 0 without publishing. + test "$(node --version)" = "v$NODE_VERSION" + node publisher/scripts/release/publish.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 60af0bb5d1..4b0a26c648 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -361,6 +361,34 @@ jobs: run: | cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" + # Release builds take their facts from these inputs; check that the + # actual CLI binary embeds them unchanged. The fictional commit differs + # from the checkout, so a fallback to local git cannot pass. + - name: cargo test (release build facts) + if: runner.os == 'Linux' + env: + TRUSTED_SERVER__RELEASE_BUILD: "1" + TRUSTED_SERVER__GIT_VERSION: abcdef0123456789abcdef0123456789abcdef01 + run: | + cargo test --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" --test release_build_facts + + # A valid commit also passes when the release gate is bypassed, so check + # that core's build script refuses a branch name in release mode. + - name: cargo check (release build refuses a branch source) + if: runner.os == 'Linux' + env: + TRUSTED_SERVER__RELEASE_BUILD: "1" + TRUSTED_SERVER__GIT_VERSION: main + run: | + if cargo check --manifest-path crates/trusted-server-cli/Cargo.toml --target "$(rustc -vV | sed -n 's/host: //p')" 2> "$RUNNER_TEMP/release-refusal.log"; then + echo "release build accepted a branch source" >&2 + exit 1 + fi + if ! grep -F 'release source must be exactly 40 lowercase ASCII hexadecimal bytes' "$RUNNER_TEMP/release-refusal.log"; then + cat "$RUNNER_TEMP/release-refusal.log" >&2 + exit 1 + fi + - name: cargo test (browser fixtures) if: runner.os == 'macOS' run: ./scripts/test-cli.sh diff --git a/AGENTS.md b/AGENTS.md index d90eda1f2d..bf31f54858 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -377,6 +377,7 @@ Every PR must pass: 13. 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) 14. Fastly absolute-deadline network fixtures under Viceroy (the harness builds the adapter test binary itself): `node crates/trusted-server-adapter-fastly/tests/deadline-origin.mjs "$(command -v viceroy)"` 15. Worker auction-deadline runtime fixtures (`worker-auction-deadlines.yml`): build and lint the runtime fixture, then run it under the local runtime. For Cloudflare, `cargo build --locked -p trusted-server-adapter-cloudflare --example deadline-runtime --target wasm32-unknown-unknown --features cloudflare` and `node scripts/test-worker-auction-deadlines.mjs cloudflare --runtime "$(command -v wrangler)"` (requires wrangler and the `Cargo.lock` wasm-bindgen CLI). For Spin, `cargo build --locked -p trusted-server-adapter-spin --example deadline-runtime --target wasm32-wasip1 --features spin` and `node scripts/test-worker-auction-deadlines.mjs spin --runtime "$(command -v spin)"`. CI lints each fixture with the same `cargo clippy` arguments plus `-- -D warnings` +16. Release producer checks: `node --test scripts/release/phase1.test.mjs && shellcheck scripts/release/phase1-build.sh` CI also runs these HTML EOF checks. Run them locally only when changing EOF finalization, publisher or HTML-processor code: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5d0b9ea8..7f99b6d800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Phase 1 attested release binaries: the Fastly wasm and the `ts` CLI for Linux x86-64 and macOS (ARM64 and x86-64), with SPDX 2.3 inventories, provenance/SBOM bundles, immutable verification and a GNU glibc 2.34 startup check. Reproducibility, other adapters and the custom verifier remain later work; production publication requires maintainer settings. + ### Changed +- The attested Fastly wasm reports `x-ts-version` as the full 40-character source commit; deploy-pipeline builds keep the tag, branch or short-commit value described below. +- **Breaking:** `ts --version` now prints `ts package= source= target= variant=default` instead of `ts `. `source` is the full commit in attested release binaries, and otherwise the development ref, short commit or `unknown`. Update scripts that parse the old output. - The Lockr browser module no longer polls for private SDK state or mutates its API host, removing the "Lockr SDK not detected" timeout warning (#1247). Root-relative Lockr XHR API requests and rewritten SDK URLs now stay on the page origin even with an external ``. Custom or unreplaced legacy SDKs that relied on the removed host rewrite must route their own API calls through `/integrations/lockr/api`; `rewrite_sdk = false` leaves static SDK tags unchanged but does not disable dynamic interception. See the [Lockr migration guide](docs/guide/integrations/lockr.md#migration-from-the-sdk-host-shim). - Auction response collection now bounds network waiting by `[auction].timeout_ms` measured from dispatch, including delayed headers and trickling bodies on all diff --git a/crates/trusted-server-cli/build.rs b/crates/trusted-server-cli/build.rs new file mode 100644 index 0000000000..e3c730445d --- /dev/null +++ b/crates/trusted-server-cli/build.rs @@ -0,0 +1,13 @@ +//! Compile immutable facts owned by the CLI product. Release source inputs are +//! validated once by `trusted-server-core`'s build script. + +#![allow( + clippy::print_stdout, + reason = "Cargo build script directives use stdout" +)] + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + let target = std::env::var("TARGET").expect("should set the owning Cargo target"); + println!("cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET={target}"); +} diff --git a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs index 8978af61c0..f1bf758d9d 100644 --- a/crates/trusted-server-cli/src/commands/dev/lint/domains.rs +++ b/crates/trusted-server-cli/src/commands/dev/lint/domains.rs @@ -111,9 +111,15 @@ pub const REFERENCE_HOSTS: &[&str] = &[ "iabtechlab.com", "iabtechlab.github.io", "iabeurope.github.io", + // GitHub CLI: canonical verification command documentation. + "cli.github.com", // Specs (supply chain) "in-toto.io", "rslstandard.org", + // SLSA: canonical provenance predicate in the release verification guide. + "slsa.dev", + // SPDX: canonical document predicate in the release verification guide. + "spdx.dev", // Specs (other) "webassembly.org", // Fastly docs @@ -311,6 +317,22 @@ mod allow_check_tests { fn reference_hosts_allowed_everywhere() { assert!(is_allowed("github.com", ¬hing_suppressed())); assert!(is_allowed("docs.rs", ¬hing_suppressed())); + assert!( + is_allowed("slsa.dev", ¬hing_suppressed()), + "should allow the canonical slsa.dev reference" + ); + assert!( + is_allowed("spdx.dev", ¬hing_suppressed()), + "should allow the canonical spdx.dev reference" + ); + assert!( + is_allowed("cli.github.com", ¬hing_suppressed()), + "should allow the canonical cli.github.com reference" + ); + assert!( + !is_allowed("unapproved.slsa.dev", ¬hing_suppressed()), + "should reject unapproved slsa.dev subdomains" + ); // But NOT subdomains of REFERENCE_HOSTS (exact-match). assert!(!is_allowed("other.github.com", ¬hing_suppressed())); } diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index fa91e0a44d..65906422ef 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -1,4 +1,5 @@ use std::process; +use std::sync::LazyLock; use clap::{Parser, Subcommand}; use edgezero_cli::args::{ @@ -6,6 +7,7 @@ use edgezero_cli::args::{ ConfigValidateArgs, DeployArgs, HealthcheckArgs, ProvisionArgs, RollbackArgs, ServeArgs, }; use trusted_server_core::config::TrustedServerAppConfig; +use trusted_server_core::constants::TS_GIT_VERSION; use crate::commands::audit::{AuditArgs, run_audit}; use crate::commands::config::ad_templates::{AdTemplatesCommand, run_ad_templates}; @@ -13,8 +15,18 @@ use crate::commands::config::init::{ConfigInitArgs, run_config_init}; use crate::commands::pbs::{self, PbsArgs}; use crate::prebid_bundle::{NpmPrebidBundleGenerator, PrebidBundleArgs, run_bundle}; +// Labels the fields so the package version is not read as the release tag. +static VERSION_TEXT: LazyLock = LazyLock::new(|| { + format!( + "package={} source={} target={} variant=default", + env!("CARGO_PKG_VERSION"), + TS_GIT_VERSION.unwrap_or("unknown"), + env!("TRUSTED_SERVER__BUILD_TARGET"), + ) +}); + #[derive(Debug, Parser)] -#[command(name = "ts", version, about = "Trusted Server CLI")] +#[command(name = "ts", version = VERSION_TEXT.as_str(), about = "Trusted Server CLI")] struct Args { #[command(subcommand)] command: Command, diff --git a/crates/trusted-server-cli/tests/release_build_facts.rs b/crates/trusted-server-cli/tests/release_build_facts.rs new file mode 100644 index 0000000000..9c165133ba --- /dev/null +++ b/crates/trusted-server-cli/tests/release_build_facts.rs @@ -0,0 +1,151 @@ +//! Host checks of the CLI build script and the compiled CLI binary. + +#![cfg(not(target_arch = "wasm32"))] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tempfile::TempDir; +use trusted_server_core::constants::TS_GIT_VERSION; + +const SOURCE: &str = "abcdef0123456789abcdef0123456789abcdef01"; + +fn workspace() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("should resolve crates") + .parent() + .expect("should resolve workspace") +} + +fn compile_build_script(owner: &str, directory: &TempDir) -> PathBuf { + let output = directory.path().join(format!("{owner}-build")); + let compiled = Command::new("rustc") + .args(["--edition=2024"]) + .arg(workspace().join("crates").join(owner).join("build.rs")) + .arg("-o") + .arg(&output) + .output() + .expect("should run rustc"); + assert!( + compiled.status.success(), + "should compile actual owner script: {}", + String::from_utf8_lossy(&compiled.stderr) + ); + output +} + +fn owner_command(script: &Path) -> Command { + let mut command = Command::new(script); + command + .env("TARGET", "example-target") + .env_remove("TRUSTED_SERVER__RELEASE_BUILD") + .env_remove("TRUSTED_SERVER__GIT_VERSION"); + command +} + +// Release control and source validation belongs to core's build script, which +// every product build runs; `git_version_resolve.rs` covers that rule. +#[test] +fn cli_build_script_emits_only_its_target() { + let directory = tempfile::tempdir().expect("should create fixture"); + let script = compile_build_script("trusted-server-cli", &directory); + let mut outputs = Vec::new(); + for tag in ["v1.2.3", "v1.2.4"] { + let output = owner_command(&script) + .env("TRUSTED_SERVER__RELEASE_BUILD", "1") + .env("TRUSTED_SERVER__GIT_VERSION", SOURCE) + .env("GITHUB_REF_NAME", tag) + .env("GITHUB_RUN_ID", tag) + .output() + .expect("should run release build script"); + assert!(output.status.success(), "should run the CLI build script"); + outputs.push(String::from_utf8(output.stdout).expect("should emit UTF-8 directives")); + } + outputs.push( + String::from_utf8( + owner_command(&script) + .output() + .expect("should run development script") + .stdout, + ) + .expect("should emit UTF-8 directives"), + ); + for directives in &outputs { + assert_eq!( + directives, + "cargo:rerun-if-changed=build.rs\n\ + cargo:rustc-env=TRUSTED_SERVER__BUILD_TARGET=example-target\n", + "should emit only the target owned by Cargo TARGET" + ); + } +} + +#[test] +fn actual_cli_version_reads_all_owning_facts_and_ignores_external_tags() { + // CI compiles this test once in release mode; the supplied commit must + // reach the actual CLI unchanged. + if option_env!("TRUSTED_SERVER__RELEASE_BUILD").is_some() { + assert_eq!( + TS_GIT_VERSION, + option_env!("TRUSTED_SERVER__GIT_VERSION"), + "should compile the exact supplied release source into the actual CLI" + ); + } + let expected = format!( + "ts package={} source={} target={} variant=default\n", + env!("CARGO_PKG_VERSION"), + TS_GIT_VERSION.unwrap_or("unknown"), + env!("TRUSTED_SERVER__BUILD_TARGET"), + ); + let directory = tempfile::tempdir().expect("should create outside-checkout fixture"); + for tag in ["v1.2.3", "v1.2.4"] { + let output = Command::new(env!("CARGO_BIN_EXE_ts")) + .current_dir(directory.path()) + .arg("--version") + .env("GITHUB_REF_NAME", tag) + .env("GITHUB_RUN_ID", tag) + .output() + .expect("should run actual CLI binary"); + assert!( + output.status.success(), + "should print version outside checkout" + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + expected, + "should label all four actual immutable facts" + ); + } +} + +#[test] +fn help_and_config_init_work_outside_the_checkout() { + let directory = tempfile::tempdir().expect("should create outside-checkout fixture"); + let binary = env!("CARGO_BIN_EXE_ts"); + let output = Command::new(binary) + .current_dir(directory.path()) + .arg("--help") + .output() + .expect("should run portable CLI"); + assert!( + output.status.success(), + "should print help outside checkout" + ); + let output = Command::new(binary) + .current_dir(directory.path()) + .args(["config", "init"]) + .output() + .expect("should initialize scaffold outside checkout"); + assert!( + output.status.success(), + "should initialize embedded example config" + ); + assert_eq!( + fs::read_to_string(directory.path().join("trusted-server.toml")) + .expect("should read config scaffold"), + include_str!("../../../trusted-server.example.toml"), + "should use tracked fictional scaffold" + ); +} diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index db9c31d27c..46fe792cd0 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use edgezero_core::manifest::ManifestLoader; -use git_version::{Candidates, is_usable, resolve_git_version}; +use git_version::{Candidates, is_usable, resolve_build_source, resolve_git_version}; use sha2::{Digest as _, Sha256}; /// Crate-relative build inputs that are both watched and hashed besides `src/`. @@ -160,24 +160,30 @@ fn main() { fn emit_git_version() { println!("cargo:rerun-if-env-changed={OVERRIDE_ENV}"); - let override_value = std::env::var(OVERRIDE_ENV).ok(); - let resolved = match override_value.as_deref() { - Some(value) if is_usable(value) => resolve_git_version(&Candidates { - override_value: Some(value), - exact_tag: None, - branch: None, - commit: None, - }), - Some(value) => { - if !value.trim().is_empty() { - println!( - "cargo:warning={OVERRIDE_ENV}={value:?} is not visible ASCII; \ - falling back to local git for x-ts-version" - ); - } - resolve_from_local_git() + println!("cargo:rerun-if-env-changed=TRUSTED_SERVER__RELEASE_BUILD"); + let control = env::var_os("TRUSTED_SERVER__RELEASE_BUILD"); + let source = env::var_os(OVERRIDE_ENV); + if control.is_none() + && let Some(value) = source.as_deref() + && value + .to_str() + .is_none_or(|value| !is_usable(value) && !value.trim().is_empty()) + { + println!( + "cargo:warning={OVERRIDE_ENV}={value:?} is not visible ASCII; \ + falling back to local git for x-ts-version" + ); + } + let resolved = match resolve_build_source( + control.as_deref(), + source.as_deref(), + resolve_from_local_git, + ) { + Ok(source) => source, + Err(message) => { + println!("cargo::error={message}"); + std::process::exit(1); } - None => resolve_from_local_git(), }; // Always emitted, even when empty, so the raw pipeline value never reaches diff --git a/crates/trusted-server-core/build_support/git_version.rs b/crates/trusted-server-core/build_support/git_version.rs index f4cf2d3688..36f426372a 100644 --- a/crates/trusted-server-core/build_support/git_version.rs +++ b/crates/trusted-server-core/build_support/git_version.rs @@ -3,6 +3,62 @@ //! Shared by `build.rs` and `tests/git_version_resolve.rs` via `#[path]`, so it //! must stay dependency-free. +use std::ffi::OsStr; + +/// Returns the exact source commit in release mode, or `None` in development. +/// +/// # Errors +/// +/// Rejects any present release control except `1`, and release sources other +/// than exactly 40 lowercase ASCII hexadecimal bytes. +pub fn release_source( + control: Option<&OsStr>, + source: Option<&OsStr>, +) -> Result, &'static str> { + match control { + None => return Ok(None), + Some(value) if value == OsStr::new("1") => {} + Some(_) => return Err("TRUSTED_SERVER__RELEASE_BUILD must be exactly 1 when present"), + } + let source = source + .and_then(OsStr::to_str) + .ok_or("release builds require TRUSTED_SERVER__GIT_VERSION as a full lowercase commit")?; + if source.len() != 40 + || !source + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("release source must be exactly 40 lowercase ASCII hexadecimal bytes"); + } + Ok(Some(source.to_owned())) +} + +/// Resolve the actual build source, calling local Git only in development. +/// +/// # Errors +/// +/// Returns release input errors before invoking the fallback. +pub fn resolve_build_source( + control: Option<&OsStr>, + source: Option<&OsStr>, + local_git: impl FnOnce() -> Option, +) -> Result, &'static str> { + if let Some(commit) = release_source(control, source)? { + return Ok(Some(commit)); + } + let override_value = source.and_then(OsStr::to_str); + if override_value.is_some_and(is_usable) { + Ok(resolve_git_version(&Candidates { + override_value, + exact_tag: None, + branch: None, + commit: None, + })) + } else { + Ok(local_git()) + } +} + /// Raw candidates for the deployed git version, in priority order. pub struct Candidates<'a> { /// `TRUSTED_SERVER__GIT_VERSION`, supplied by the deploy pipeline. diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index 1fd3c2382c..ae6d953004 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -32,14 +32,15 @@ pub const HEADER_X_COMPRESS_HINT: HeaderName = HeaderName::from_static("x-compre pub const HEADER_X_DEBUG_FASTLY_POP: HeaderName = HeaderName::from_static("x-debug-fastly-pop"); // Staging / version identification headers -/// Deployed git version: tag, else branch, else 6-char commit (see [`TS_GIT_VERSION`]). +/// Release source commit, or development ref/short commit (see [`TS_GIT_VERSION`]). pub const HEADER_X_TS_VERSION: HeaderName = HeaderName::from_static("x-ts-version"); /// Fastly service version (`FASTLY_SERVICE_VERSION`), formerly sent as `x-ts-version`. pub const HEADER_X_TS_FASTLY_VERSION: HeaderName = HeaderName::from_static("x-ts-fastly-version"); pub const HEADER_X_TS_ENV: HeaderName = HeaderName::from_static("x-ts-env"); /// Deployed git version compiled in by `build.rs`, from the deploy pipeline's -/// `TRUSTED_SERVER__GIT_VERSION` or local git. `None` when unknown. +/// `TRUSTED_SERVER__GIT_VERSION` or local git. Release mode requires the full +/// lowercase commit; development retains ref/short-commit display. `None` when unknown. pub const TS_GIT_VERSION: Option<&str> = match option_env!("TRUSTED_SERVER__GIT_VERSION") { Some(version) if !version.is_empty() => Some(version), _ => None, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 754acfc262..e0db193753 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -34,10 +34,13 @@ pub const ENVIRONMENT_VARIABLE_PREFIX: &str = "TRUSTED_SERVER"; pub const ENVIRONMENT_VARIABLE_SEPARATOR: &str = "__"; /// Build inputs that share the settings prefix but are not settings. /// -/// The core build script exports these with `cargo:rustc-env`, and Cargo sets -/// them on every `cargo test` run of this crate. +/// Cargo sets the source version through `cargo:rustc-env`; release controls +/// may also be inherited by test processes. Neither is runtime configuration. #[cfg(test)] -const BUILD_ENVIRONMENT_VARIABLES: [&str; 1] = ["TRUSTED_SERVER__GIT_VERSION"]; +const BUILD_ENVIRONMENT_VARIABLES: [&str; 2] = [ + "TRUSTED_SERVER__GIT_VERSION", + "TRUSTED_SERVER__RELEASE_BUILD", +]; #[derive(Debug, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] @@ -7000,19 +7003,21 @@ source_domain = "partner.example.com" } #[test] - fn legacy_env_loader_ignores_build_time_git_version() { + fn legacy_env_loader_ignores_build_time_inputs() { let toml_str = crate_test_settings_str(); for key in [ "TRUSTED_SERVER__GIT_VERSION", "trusted_server__git_version", "Trusted_Server__Git_Version", + "TRUSTED_SERVER__RELEASE_BUILD", + "trusted_server__release_build", + "Trusted_Server__Release_Build", ] { - for value in ["v0.0.0-example", ""] { + for value in ["v0.0.0-example", "1", ""] { temp_env::with_var(key, Some(value), || { - Settings::from_toml_and_env(&toml_str).expect( - "should ignore the build-time git version input regardless of case", - ); + Settings::from_toml_and_env(&toml_str) + .expect("should ignore build-time inputs regardless of case"); }); } } diff --git a/crates/trusted-server-core/tests/git_version_resolve.rs b/crates/trusted-server-core/tests/git_version_resolve.rs index 9fb77a2b54..71309df804 100644 --- a/crates/trusted-server-core/tests/git_version_resolve.rs +++ b/crates/trusted-server-core/tests/git_version_resolve.rs @@ -4,6 +4,9 @@ mod git_version; use git_version::{Candidates, resolve_git_version}; +use std::ffi::OsStr; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt as _; const COMMIT: &str = "abcdef0123456789abcdef0123456789abcdef01"; @@ -139,3 +142,114 @@ fn is_usable_accepts_only_visible_ascii() { ); assert!(!git_version::is_usable("v1-été"), "should reject non-ASCII"); } + +#[test] +fn release_source_requires_an_exact_full_lowercase_commit() { + let commit = "abcdef0123456789abcdef0123456789abcdef01"; + assert_eq!( + git_version::release_source(Some(OsStr::new("1")), Some(OsStr::new(commit))), + Ok(Some(commit.to_owned())), + "should preserve the complete release source" + ); + for source in [ + None, + Some(""), + Some("main"), + Some("v1.2.3"), + Some("abcdef"), + Some("ABCDEF0123456789abcdef0123456789abcdef01"), + Some(" abcdef0123456789abcdef0123456789abcdef01"), + Some("abcdef0123456789abcdef0123456789abcdef01\n"), + Some("abcdef0123456789abcdef0123456789abcdef0g"), + Some("abcdef0123456789abcdef0123456789abcdef012"), + ] { + assert!( + git_version::release_source(Some(OsStr::new("1")), source.map(OsStr::new)).is_err(), + "should reject malformed release source {source:?}" + ); + } +} + +#[test] +fn release_control_accepts_only_absence_or_exact_one() { + assert_eq!( + git_version::release_source(None, Some(OsStr::new("main"))), + Ok(None), + "should retain development resolution when release control is absent" + ); + for control in ["", "0", "true", " 1", "1\n"] { + assert!( + git_version::release_source( + Some(OsStr::new(control)), + Some(OsStr::new("abcdef0123456789abcdef0123456789abcdef01")) + ) + .is_err(), + "should reject invalid control even with valid source" + ); + } +} + +#[cfg(unix)] +#[test] +fn non_unicode_release_inputs_fail_closed() { + let non_unicode = OsStr::from_bytes(b"\xff"); + assert!( + git_version::release_source(Some(non_unicode), None).is_err(), + "should recognize non-Unicode control as present and invalid" + ); + assert!( + git_version::release_source(Some(OsStr::new("1")), Some(non_unicode)).is_err(), + "should reject non-Unicode source" + ); +} + +#[test] +fn actual_build_source_resolution_never_calls_git_in_release_mode() { + let source = + git_version::resolve_build_source(Some(OsStr::new("1")), Some(OsStr::new(COMMIT)), || { + panic!("should not call Git in release mode") + }) + .expect("should resolve valid release source"); + assert_eq!( + source.as_deref(), + Some(COMMIT), + "should retain the complete release commit" + ); + let result = + git_version::resolve_build_source(Some(OsStr::new("1")), Some(OsStr::new("main")), || { + panic!("should not fall back after invalid release source") + }); + assert!( + result.is_err(), + "should reject malformed release source without Git fallback" + ); +} + +#[test] +fn actual_build_source_resolution_preserves_development_override_and_fallback() { + for identity in ["main", " v1.2.3 ", COMMIT] { + let source = git_version::resolve_build_source(None, Some(OsStr::new(identity)), || { + panic!("should not call Git with a usable development override") + }) + .expect("should resolve development override"); + assert_eq!( + source.as_deref(), + Some(identity.trim()), + "should preserve development override" + ); + } + for identity in [None, Some(OsStr::new("")), Some(OsStr::new("bad identity"))] { + let mut called = false; + let source = git_version::resolve_build_source(None, identity, || { + called = true; + Some("local-branch".to_owned()) + }) + .expect("should resolve development fallback"); + assert!(called, "should call actual fallback for unusable override"); + assert_eq!( + source.as_deref(), + Some("local-branch"), + "should use fallback result" + ); + } +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 13c3c3c984..555be5ccfb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -101,6 +101,10 @@ export default withMermaid( { text: 'EdgeZero Lifecycle', link: '/guide/edgezero' }, { text: 'Configuration', link: '/guide/configuration' }, { text: 'CLI', link: '/guide/cli' }, + { + text: 'Release Verification', + link: '/guide/release-verification', + }, { text: 'Dev Proxy', link: '/guide/ts-dev-proxy' }, { text: 'Testing', link: '/guide/testing' }, { text: 'Auction Testing', link: '/guide/auction-testing' }, diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 70227ca493..099c046341 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -1,5 +1,7 @@ # Trusted Server CLI +Prebuilt release binaries must pass the [release verification procedure](/guide/release-verification) before execution or deployment. That guide lists the Linux/macOS baselines and the manual Fastly route that preserves verified wasm bytes. + The Trusted Server CLI binary is `ts`. It is a host-target operator tool for configuration, page audits, and EdgeZero-backed lifecycle commands. @@ -57,6 +59,13 @@ commands are shown explicitly. ## Install from source +`cargo install-cli` builds `ts` from source. The resulting local binary is not +covered by the release attestation. Operators who need an attested CLI should +download the matching `ts-` release asset, verify it with the documented +`gh attestation verify` and `gh release verify-asset` commands in the +[release verification guide](/guide/release-verification), and install that +verified file. + From the repository root, install the `ts` binary with the workspace Cargo alias: ```bash diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md new file mode 100644 index 0000000000..7a214c9c6d --- /dev/null +++ b/docs/guide/release-verification.md @@ -0,0 +1,445 @@ +# Verify release binaries + +Phase 1 publishes four binaries, four SPDX 2.3 SBOMs, eight Sigstore bundles +and `SHA256SUMS`: **17 immutable release assets**. Each binary has SLSA build +provenance and an SBOM attestation. Verify the downloaded bytes before execution +or deployment. Reproducibility, complete composition and the custom Trusted +Server verifier remain later phases. + +The tag caller is `.github/workflows/release.yml`. Its local reusable +`.github/workflows/release-build.yml` owns builds and signing at the same source +revision. The reusable workflow is the certificate signer and provenance builder; +the caller remains the publication owner. Build jobs have no signing authority, +and signing jobs do not check out, compile or execute the downloaded binaries. +The caller's publication job checks out only `scripts/release/publish.mjs`, its +release-notes template and `.tool-versions` from the tagged commit, and runs +them with the pinned Node and a checksum-verified GitHub CLI 2.102.0. + +## Platforms and coverage + +| Asset | Phase 1 baseline and qualification limit | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `trusted-server-fastly.wasm` | Fastly Compute, `wasm32-wasip1`, default features; reproducibility and hosted runtime qualification remain pending | +| `ts-x86_64-unknown-linux-gnu` | GNU x86-64; UBI 9 builder; maximum required glibc 2.34; Debian 12, Ubuntu 22.04 and UBI 9 startup smokes | +| `ts-aarch64-apple-darwin` | macOS 15 Apple Silicon runner, deployment target 13.0; runtime compatibility qualification remains pending | +| `ts-x86_64-apple-darwin` | macOS 15 Intel runner, deployment target 13.0; runtime compatibility qualification remains pending | + +The build checks 64-bit Mach-O magic, target CPU and minimum macOS 13.0 load +commands. Actual execution on macOS 13 remains later qualification. The +executables are not notarized. The Linux build checks for a 64-bit x86-64 ELF +and requires no GLIBC symbol newer than 2.34; glibc 2.34 and newer are the +selected baseline. This includes Debian 12/bookworm-slim, Ubuntu 22.04+ and +RHEL/UBI 9+. All smoke images are digest-pinned; build logs record their image +IDs. These startup checks do not establish complete platform qualification. +Native libraries and provider tools needed by individual `ts` commands retain +their existing prerequisites. Axum, Cloudflare, Spin, Windows and Linux ARM64 +releases follow separately. + +Each `.spdx.json` is a **conservative build dependency inventory** of +the selected Cargo target and the installed npm build tree used for that build. +It records the final executable's SHA-256 and Cargo's package version; the +release tag is not part of the SBOM. SPDX 2.3 is the published format. The +inventory records package identities but no dependency relationships; the +document adds only the binary-root `DESCRIBES` relationship. + +The inventory includes npm build tools and may include packages removed by +linking, tree-shaking or minification. The installed `prebid.js` package is a +build-tree entry; the external Prebid bundle is not embedded in tsjs. Native +C/assembly, system libraries, Rust's standard library and compiler runtimes do +not have complete coverage. Exact retained-code attribution and complete native +and tool closure remain Phase 2 work. An SBOM or provenance result is not a +vulnerability scan. + +## Download and verify + +Use an independently trusted GitHub CLI 2.102.0 or later with `gh attestation verify`, +`gh release verify`, and `gh release verify-asset`, authenticated with +`gh auth login` or `GH_TOKEN`. The SBOM comparison below also uses `jq`, and the +checksum steps use `shasum` (use `sha256sum --check` where `shasum` is absent). +Verification needs GitHub network access. Obtain the approved tag +and its 40-character source commit through an independent source-review process. +Phase 1 requires a strict SemVer `v` tag (such as `v1.2.3` or `v1.2.3-rc.1`; +build metadata such as `+build.1` is refused) that is lightweight and points +directly to that source commit. The release notes link to this guide but stay +editable after publication, so never take `REPO`, `TAG` or `COMMIT` from them. +A checksum or commit supplied only with an untrusted download is not source +approval. + +Run this in a new private directory, using Bash. Replace the fictional tag and +commit. For rehearsal, change `REPO` to the approved public sandbox and use the +individually approved, never-reused rehearsal tag. + + + +```bash +set -euo pipefail +# Take REPO, TAG and COMMIT from your independent source review. +REPO=IABTechLab/trusted-server +TAG=v1.2.3 +COMMIT=0123456789abcdef0123456789abcdef01234567 +[[ -n "$REPO" && -n "$TAG" && "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || exit 1 +ASSETS=(trusted-server-fastly.wasm ts-aarch64-apple-darwin ts-x86_64-apple-darwin ts-x86_64-unknown-linux-gnu) +WORKFLOW="$REPO/.github/workflows/release-build.yml" +gh release download "$TAG" --repo "$REPO" --dir . +test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq .immutable)" = true +gh release verify "$TAG" --repo "$REPO" +for asset in "${ASSETS[@]}"; do + for suffix in "" .spdx.json .provenance.sigstore.json .sbom.sigstore.json; do gh release verify-asset "$TAG" "$asset$suffix" --repo "$REPO"; done + for kind in provenance sbom; do + predicate=https://slsa.dev/provenance/v1; if [[ "$kind" == sbom ]]; then predicate=https://spdx.dev/Document/v2.3; fi + gh attestation verify "$asset" --repo "$REPO" --bundle "$asset.$kind.sigstore.json" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" \ + --deny-self-hosted-runners --predicate-type "$predicate" --signer-workflow "$WORKFLOW" \ + --format json > "$asset.$kind.verified.json" + if [[ "$kind" == sbom ]]; then jq -e --slurpfile sbom "$asset.spdx.json" '[.[].verificationResult.statement.predicate] | length == 1 and .[0] == $sbom[0]' "$asset.$kind.verified.json"; fi + done +done +gh release verify-asset "$TAG" SHA256SUMS --repo "$REPO" +shasum -a 256 --check SHA256SUMS +``` + + + +Provenance covers the binary and its SPDX document; the SBOM attestation covers +the binary. The verified SBOM predicate must equal the shipped SPDX document. +`--signer-workflow` pins the reusable signer; with the source ref and both +digests pinned to the reviewed tag and commit, it identifies the exact signing +workflow run's source. + +The checksums cover the other 16 assets, never `SHA256SUMS` itself. Immutable +release verification establishes membership; provenance establishes the +reviewed signer and source. Both are required. + +Stop if any check fails. Keep the outputs, reviewed source identity, bundles +and checksums with your deployment record. These checks do not approve a +publisher service, runtime configuration or deployment operation. See the +[GitHub CLI verification reference](https://cli.github.com/manual/gh_attestation_verify) +and [immutable asset verification reference](https://cli.github.com/manual/gh_release_verify-asset). + +## Verify bundles offline + +While online, obtain the trust roots using an independently trusted GitHub CLI +installation. GitHub CLI 2.102.0 supports the flags below; retain its version, +the roots and their digest with the independently approved source identity. +Do not accept replacement roots supplied only alongside an untrusted binary. + +```bash +gh --version +gh attestation trusted-root > trusted_root.jsonl +shasum -a 256 trusted_root.jsonl +``` + +Transfer the binaries, their bundles, published SPDX documents, approved source +pins and trusted roots to the offline machine. Shell variables do not transfer: +set `REPO`, `TAG`, `COMMIT`, `ASSETS` and `WORKFLOW` again on the offline +machine, from the same independently reviewed values. Supplying both the bundle +and custom roots avoids network acquisition; there is no `--offline` flag. + +Before disconnecting, obtain an independent trusted SHA-256 pin for each binary +(for example from a separately authenticated release verification record). +Do not treat SHA256SUMS delivered with the same untrusted download as that pin. +Transfer that approved pin file as `APPROVED_BINARY_SHA256SUMS`. + +```bash +set -euo pipefail +test "${#ASSETS[@]}" -eq 4 +shasum -a 256 --check APPROVED_BINARY_SHA256SUMS +for asset in "${ASSETS[@]}"; do + for kind in provenance sbom; do + predicate=https://slsa.dev/provenance/v1 + if [[ "$kind" == sbom ]]; then predicate=https://spdx.dev/Document/v2.3; fi + gh attestation verify "$asset" --repo "$REPO" --bundle "$asset.$kind.sigstore.json" \ + --custom-trusted-root trusted_root.jsonl \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" \ + --signer-digest "$COMMIT" --deny-self-hosted-runners --predicate-type "$predicate" \ + --signer-workflow "$WORKFLOW" --format json > "$asset.offline-$kind-verification.json" + if [[ "$kind" == sbom ]]; then + jq -e --slurpfile document "$asset.spdx.json" \ + 'length == 1 and .[0].verificationResult.statement.predicate == $document[0]' \ + "$asset.offline-$kind-verification.json" + fi + done +done +``` + +Offline success establishes signed build evidence for those retained bytes. +It does not establish current release membership, immutability or deployment +authorization. Retain the separate online release and asset verification record; +checksums alone cannot replace it. + +## Use the verified CLI + +`cargo install-cli` builds `ts` from source. The resulting local binary is not +covered by the release attestation. Operators who need an attested CLI should +download the `ts-` release asset matching their platform, verify it with +the `gh attestation verify` and `gh release verify-asset` commands above, then +copy that verified file into a directory on their `PATH` as `ts` and make it +executable. Verify before first execution. Individual commands may still require +a source checkout and provider tools; see [CLI](./cli.md). + +## Deploy the verified Fastly wasm + +Keep the verified download unchanged. The ordinary source-build route does not +establish equality with this release, and Phase 1 adds no `ts deploy --release` +command. Use a prebuilt package route for the verified bytes. + +For a manual deployment to an **existing** Fastly service, use the Fastly CLI +16.0.0 pinned in the release tag's `.tool-versions`. Prepare a fresh private +packaging directory containing only the verified wasm and a minimal manifest: + +```toml +manifest_version = 3 +name = "example-app" +``` + +Do not copy operator config, secrets, local fixtures or build hooks into that +directory. Approve the destination service and base version, and inspect their +Config, KV and Secret Store links beforehand. The wasm opens its Config Store +and Secret Store by the logical IDs in the release tag's tracked +`edgezero.toml` (`trusted_server_config` and `trusted_server_secrets`), so the +base version must already link them under those names; physical store names +and actual configuration are runtime resources. This manual route does not +create links, push configuration, implement staging, or supply recovery +guarantees. + +From the private directory: + +```bash +fastly compute pack --wasm-binary ./trusted-server-fastly.wasm +fastly compute validate --package ./pkg/example-app.tar.gz +tar -tzf ./pkg/example-app.tar.gz +tar -xOzf ./pkg/example-app.tar.gz example-app/bin/main.wasm > packaged.wasm +cmp trusted-server-fastly.wasm packaged.wasm +tar -xOzf ./pkg/example-app.tar.gz example-app/fastly.toml > packaged.toml +cmp fastly.toml packaged.toml +``` + +Inspect the archive inventory: it must contain only the expected named package, +its directories, `fastly.toml`, and `bin/main.wasm`. Check the extracted manifest +against the approved minimal manifest. Select the named archive explicitly; +packing can also produce a generic `package.tar.gz`. Do not guess an archive +name or use a previously populated `pkg/` directory. + +After verifying the package member is byte-identical, use your reviewed service +ID and integer base version in place of these fictional values: + +```bash +fastly compute deploy --package ./pkg/example-app.tar.gz \ + --service-id '99999' --version '7' --no-default-domain +``` + +This command can activate production: it clones an active or locked base +version, uploads the package to the new version and activates it. Confirm its +behavior for the pinned CLI before use. Retain the +verified wasm, manifest, package digest, previous active version and observed +result. No build step may replace or annotate the verified wasm. Automated +EdgeZero prepared-payload deployment and operation recovery are later phases. + +### Local runtime evidence + +Run verified Fastly bytes with pinned Viceroy and external example configuration +for local testing. A page request can check the origin response, injected +JavaScript and source SHA in `x-ts-version`. Local execution does not establish +provider submission or production operation. + +## Build inputs, policy and release administration + +Builds use the tracked source, manifests, `Cargo.lock`, pinned Rust and Node, +the JS lockfile and fresh JS bundles. The tracked `edgezero.toml` supplies the +logical config-store ID and participates in the template build digest. Build +facts include the source commit. The CLI carries the fictional example-config +scaffold, not an operator config. + +Release Fastly responses report `x-ts-version` as the full 40-character source +commit supplied by the release build. Development display fallbacks are not the +release identity. `ts --version` prints +`ts package= source= target= variant=default`. Release +mode requires the full 40-character lowercase source commit; the package version +is not the release tag. Attestation pins authenticate these inputs externally. + +The executable's SHA-256 is checked externally against the retained release +bytes; exposing that digest as a runtime build fact belongs to +[#161](https://github.com/IABTechLab/trusted-server/issues/161). + +The [specification](https://github.com/IABTechLab/trusted-server/blob/main/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md#r-inputs--build-inputs) +records the input classes found by a one-time audit against an earlier main; +it is not re-run automatically. It does not claim a complete native tool closure +or hermetic build. + +Phase 1 does not ship `release-index.json`, `tsjs-inventory.json`, build-evidence, +build-recipe or rebuild-check files. Their names remain reserved for later +phases. + +The build scrubs inherited environment values and scans every executable for +planted fictional config values. These are regression checks, not +hermetic-build qualification. Operator configuration, credentials, physical +bindings and variable permission policy remain runtime inputs. + +Before publication, a maintainer must enable immutable releases, protect `v*` +tags and approve tag-push authority and required checks. Immutability remains a +human precondition. Set repository variable `RELEASE_PUBLICATION_ENABLED=true` +only after confirming those settings with the read-only commands below. Without +it, both signing and publication are skipped; an early tag push creates no +public attestations. There is no repository-administration token or +prepublication settings API call, following the reviewed decision to check the +published release instead. A violation can expose a mutable release before the +workflow reports failure. + +Tags with a SemVer prerelease part (such as `-rc.1`) publish as prereleases and +never become Latest. Stable tags publish as normal releases with +`make_latest: legacy`; GitHub chooses Latest by version/date rather than forcing +a backport release to Latest. Whether to authorize tags from rc branches is a +maintainer decision; Phase 1 requires the tagged commit to be on some branch but +does not check which one, and does not read rulesets. + +Before creating anything, the publisher runs `gh attestation verify` for every +binary and predicate against the signing bundles it is about to publish, with +the same pins as the commands above, and requires each signed SBOM predicate to +equal the shipped SPDX document. A failure there creates nothing. + +Publication then lists releases, including drafts, and refuses every existing +matching tag. It creates one draft, logs and retains its release ID, and binds +uploads and the final publish mutation to that ID. It checks the exact +signing-approved asset set, validates each upload response's size and SHA-256 +digest before publication. After publication it checks that the release is +immutable and that its verified release attestation lists exactly those assets +and digests. +The reviewed publication owner remains later work. There are no automatic +mutation retries or draft-resume claims. + +The eight pre-creation `gh attestation verify` runs, the tag-reference read, the +release listing before creation and every create/upload/publish mutation remain +single-shot. Each pre-creation verification times out after 60s. Every API read, +including that listing, times out after at most 10s. Mutations have no +client-side timeout and are bounded by the 15-minute job limit: aborting a +create, upload or publish cannot cancel a change GitHub already accepted. + +Only the two post-publish reads retry: the release read by ID, then +`gh release verify` for the tag. GitHub creates the release attestation after +publication, so it may not be listed yet. Each read makes at most five attempts, +waiting 2s/5s/10s/20s between them, inside one three-minute window that starts +at publication. An API read times out after at most 10s and a verification after +at most 30s, and neither runs past the window. `Connection: close` remains +enabled for API calls. + +Any failed read is retried. GitHub CLI 2.102.0 reports a record that is not yet +listed and a bundle-fetch or network failure as `no attestations for tag …`, +and a trusted-root failure the same way as a failed signature, so the publisher +does not classify its errors. A returned result that fails a check stops at +once: the release must have the created ID and expected release type, be +published and be immutable, and the verified release attestation must name this +repository, tag, release ID and source commit and list exactly the published +assets with their SHA-256 digests. When the attempts or the window run out, the +job fails with `Published release read failed; the release is already +published` or `Release attestation verification failed; the release is already +published`, with the last failure, if any, as its cause. The job log records every wait. + +GitHub CLI fetches every bundle returned for the commit before filtering by tag. +An unavailable older bundle, for example from an earlier prerelease of the same +commit, can therefore fail `gh release verify` for the current release, in the +publisher, in the procedure above and in the recovery checks below, until that +bundle can be fetched again. Preserve the immutable release, rerun the read-only +checks later, and do not rerun publication. Filter-before-fetch behavior remains +a dependency follow-up: track it upstream and qualify a GitHub CLI version that +supplies it before claiming isolation from other tags. + +If any post-publish check fails, the release has already been published; a +failure does not undo publication or establish that immutability was verified. +Confirm its state using the recorded release ID. For an immutable release, keep +its tag and assets unchanged. Set `RELEASE_ID` to the ID from the release job +log, and `REPO` and `TAG` to the reviewed values, then run these read-only +checks: + +```bash +set -euo pipefail +gh api "repos/$REPO/releases/$RELEASE_ID" \ + --jq '{id, tag_name, draft, immutable, assets: [.assets[] | {name, size, digest}]}' +test "$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" = "$RELEASE_ID" +test "$(gh api "repos/$REPO/releases/$RELEASE_ID" --jq '.immutable')" = true +``` + +Then run the [download and verify](#download-and-verify) block above in a new +private directory, changing only its reviewed pins. Preserve the successful +verification outputs with the release record; do not rerun the publication job +to recover a failed read-only check. + +After a failed or indeterminate mutation, inspect the recorded release ID and +all matching drafts read-only. Do not rerun uploads, delete a release, or reuse +the tag without an independently approved recovery decision. Published immutable +releases are never overwritten. A fresh release needs a fresh approved tag; +Phase 2's full reconciliation and incident owner remains deferred. + +Phase 1 accepts only lightweight tags whose raw tag reference points directly +to the expected source commit. Annotated tags have a separate tag-object identity +and are refused before building, and again before release creation. + +Before the first production release, rehearse the reviewed commit in a separate +public repository with Actions and immutable releases enabled and the +`RELEASE_PUBLICATION_ENABLED=true` variable set. Push the commit to a branch +there first. Use a fresh, lightweight `v0.0.0-rehearsal.N` tag for each attempt +and retain verification outputs in the release PR. Keep rehearsal tags and releases out of the upstream +repository. For production, confirm the required settings and checks, then +follow the maintainer release authorization process before tagging or deploying. + +## Maintainer settings checklist + +An administrator must configure these settings before a production tag. The +commands below only inspect settings; this workflow applies none of them. + +- **Settings → General → Releases:** enable immutable releases. See + [GitHub's immutability instructions](https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/establish-provenance-and-integrity/prevent-release-changes). +- **Settings → Rules → Rulesets → New tag ruleset:** set enforcement to **Active**, + target `v*` (API pattern `refs/tags/v*`), and enable **Restrict creations**, + **Restrict updates** and **Restrict deletions**. Add only the designated release + maintainers' team to the bypass list, with **Always allow** bypass. Record the + team's name and ID in the maintainer release record; avoid broad role bypasses. + See [repository ruleset configuration](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository). +- Before tagging, confirm that the release build checks passed on the `main` push + of the commit you intend to tag: the `Binary attestations` workflow's + `build / checks` and four `build / Build ` check runs, which the + `check-runs` command below lists. Release builds run only on `main` pushes and + `v*` tags, so commits on other branches have no such checks, and a + tag-triggered check cannot pass before its own tag exists. Before authorizing + a tag on another branch, such as an rc branch, rehearse that exact commit in a + separate repository as described above; its tag build is otherwise the first + release build it gets. A later `main` push cancels an older run that is still + waiting in the workflow's concurrency group; re-run a cancelled run before + tagging its commit. Phase 1 does not enforce source eligibility. +- Push the commit to a branch, normally `main`, before pushing its lightweight + tag. The build `checks` job refuses a tagged commit that is on no branch, + before anything is built or signed: in fork rehearsals, draft creation + returned HTTP 403 for such a commit. The check reads the branches when the job + runs, so after that refusal push the commit to a branch and re-run the failed + jobs; nothing was built or signed. A release job that fails before creating a + draft (handoff or signing-bundle verification, or the tag reference), or whose + draft creation returns HTTP 403 (for example, because the branch was deleted + after the check), also creates nothing. If the release listing then shows no + release or draft for the tag, re-run only the failed release job within the + 7-day artifact retention, after pushing the commit to a branch again for a 403. + This rerun is the approved recovery for those failures only; other failed + mutations follow the recovery rules above. An annotated tag is refused before + building. + +```bash +REPO=IABTechLab/trusted-server +# Replace with the full commit you intend to tag. +COMMIT=0123456789abcdef0123456789abcdef01234567 +gh api "repos/$REPO/immutable-releases" +gh api "repos/$REPO/rulesets?includes_parents=true" \ + --jq '.[] | {id, name, target, enforcement}' +RULESET_ID=12345 +gh api "repos/$REPO/rulesets/$RULESET_ID?includes_parents=true" \ + --jq '{name, target, enforcement, conditions, rules, bypass_actors}' +gh api --paginate "repos/$REPO/commits/$COMMIT/check-runs?per_page=100" \ + --jq '.check_runs[] | {name, status, conclusion}' +``` + +The immutable-releases endpoint must return `enabled: true`. It requires admin +read access: a disabled setting can return `enabled: false`, and HTTP 404 can +also mean the caller lacks that access, so treat any other result as +unconfirmed. Inspect the ruleset's tag target, active enforcement, exact +patterns, creation/update/deletion rules and team bypass actors. The API exposes +bypass actors only to callers with sufficient repository access; an omitted field +is not proof of an empty bypass list. Also review inherited rulesets. These are +maintainer gates, not evidence that Phase 1's publication token can administer +repository settings. diff --git a/docs/superpowers/specs/2026-01-15-attestation-design.md b/docs/superpowers/specs/2026-01-15-attestation-design.md index 87b135bdbc..d836c4e671 100644 --- a/docs/superpowers/specs/2026-01-15-attestation-design.md +++ b/docs/superpowers/specs/2026-01-15-attestation-design.md @@ -1,6 +1,6 @@ # Runtime Integration Attestation Architecture -> **Status:** Proposal +> **Status:** Historical proposal. Binary-attestation sections are superseded by the [Phase 1 release specification](2026-10-09-1235-release-attestation-design.md) and, for later phases, the frozen destination design it links. Runtime/configuration proposals remain reference material for #161. > **Author:** Architecture Team > **Date:** January 2026 @@ -563,7 +563,7 @@ Sigstore uses DSSE envelopes carrying in-toto Statements. The runtime attestatio "attested_by": { "identity": "iab-tech-lab", "oidc_issuer": "https://token.actions.githubusercontent.com", - "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release.yml@refs/tags/v1.5.0" + "certificate_subject": "https://github.com/IABTechLab/trusted-server/.github/workflows/release-build.yml@refs/tags/v1.5.0" } }, "integrations": [ @@ -1164,6 +1164,12 @@ This section provides detailed code examples and implementation patterns for the ### GitHub Artifact Attestations (CI/CD) +This binary workflow example is historical and superseded by the [October +release contract](2026-10-09-1235-release-attestation-design.md#r-sign--signer). Its +current certificate SAN names `release-build.yml`; the caller `release.yml` +does not satisfy that signer identity. Runtime/configuration and publisher +proposals below remain historical #161 material. + Use [GitHub Artifact Attestations](https://docs.github.com/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) to create SLSA Build Level 2/3 provenance for the WASM binary. ```yaml diff --git a/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md new file mode 100644 index 0000000000..cececb0713 --- /dev/null +++ b/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md @@ -0,0 +1,268 @@ +# Phase 1 attested release binaries + +**Issue:** [#1235 — Publish attested release binaries with build provenance and SBOMs](https://github.com/IABTechLab/trusted-server/issues/1235) + +**Date:** 2026-10-09. **Phase 1 revision:** 2026-11-05. + +**Status:** Draft for maintainer review. PR #1435 carries this specification +and its implementation. Official release enablement additionally requires the +maintainer settings in [Maintainer gates](#maintainer-gates). + +## Background and later phases + +The original combined proposal, PR #1262, is closed and frozen at +`122b78664b1235c717e9208fd200f9a9b0c335df`. Its reviewed +[destination design](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/specs/2026-10-09-1235-release-attestation-design.md) +and [implementation plan](https://github.com/IABTechLab/trusted-server/blob/122b78664b1235c717e9208fd200f9a9b0c335df/docs/superpowers/plans/2026-10-13-attested-release-implementation.md) +remain the reference for later phases. Each later phase lands as its own PR +with its own specification. Requirement names below (R-CONFIG, R-SIGN and so +on) match the destination design, so its full clauses can be found there. + +This document specifies only Phase 1. Phase 1 makes no reproducibility claim and +has no custom verifier. The phase boundaries follow +[the round-1 architecture review](https://github.com/IABTechLab/trusted-server/pull/1262#pullrequestreview-5484754857): + +| Reviewer boundary | Phase 1 | Later | +| ----------------------------------------------------- | ----------------------------------------- | ------------------------------------------- | +| 1. Controlled build and composition evidence | Pinned, locked builds; conservative SBOMs | Controlled recipes, qualification, evidence | +| 2. Signing and immutable publication | `actions/attest` and an immutable release | A reviewed publication owner | +| 3. Verifier library, local policy and source approval | Operators run GitHub CLI verification | The custom consumer and policy | +| 4. Opaque verified artifact with retained bytes | Not started | Custom consumer phase | +| 5. EdgeZero prepared payload and resolved context | Not started | Later phase; does not block #1235 | +| 6. Observed operation state and recovery record | Not started | Later phase; does not block #1235 | + +## Scope + +Phase 1 publishes four binaries from one tag-triggered workflow run: + +| Asset | Package | Target | Runner | +| ----------------------------- | ------------------------------- | -------------------------- | -------------- | +| `trusted-server-fastly.wasm` | `trusted-server-adapter-fastly` | `wasm32-wasip1` | ubuntu-24.04 | +| `ts-x86_64-unknown-linux-gnu` | `trusted-server-cli` | `x86_64-unknown-linux-gnu` | ubuntu-24.04 | +| `ts-aarch64-apple-darwin` | `trusted-server-cli` | `aarch64-apple-darwin` | macos-15 | +| `ts-x86_64-apple-darwin` | `trusted-server-cli` | `x86_64-apple-darwin` | macos-15-intel | + +Each binary ships with an SPDX 2.3 SBOM and two Sigstore bundles (provenance +and SBOM). With `SHA256SUMS` the release has **17 immutable assets**. + +Out of scope for Phase 1, and owned by later phases: two-build +reproducibility, confined recipes, platform qualification beyond the startup +checks below, complete composition, an evidence index, the custom verifier, +the publication owner, source-branch eligibility, and release distributions for +Axum, Cloudflare and Spin. + +## Release flow + +`.github/workflows/release.yml` is the caller. It runs on pushes to `main` and +`v*` tags, never on pull requests, with `concurrency` grouped by ref and +`cancel-in-progress: false`. Pull requests run the release Node suite in the +`release scripts` job of `format.yml`. + +The caller invokes the local reusable `.github/workflows/release-build.yml`, +which owns building and signing: + +1. **checks** (`contents: read`) runs `node --test scripts/release/phase1.test.mjs`. + On a tag it refuses a tag that is not strict SemVer, a tag reference that is + not a lightweight tag at the built commit, and a tagged commit that is on no + branch. All three fail before any build or signing writes public + attestations. +2. **build** (`needs: checks`, `contents: read`) builds each matrix entry with + `scripts/release/phase1-build.sh` under `env -i`. Only tag builds upload + their binary and SPDX document. +3. **sign** (tag pushes with `RELEASE_PUBLICATION_ENABLED == 'true'` only; + `id-token: write`, `attestations: write`) downloads each binary's two files, + accepts only two regular, non-symlink, non-empty files, records their digests, + and runs two `actions/attest` calls: SLSA provenance over the binary and its + SPDX file, then an SPDX SBOM attestation for the binary. It never checks out + or runs source. + +The caller's **release** job (same gate, `contents: write` only) checks out only +`scripts/release/publish.mjs`, `scripts/release/release-notes.md` and +`.tool-versions`. It installs a checksum-verified GitHub CLI 2.102.0, asserts +the pinned Node version, and runs the publisher. + +A `main` push runs checks and builds, including the startup checks, without +signing or publication. + +## Requirements + +### R-CONFIG — no operator configuration in the binaries + +The build script refuses `TSJS_SKIP_BUILD`, `TSJS_PREBUILT_DIR` and `TSJS_TEST`, +and runs under `env -i`. GNU builds run in a container that receives only an +explicit `--env` list. The script plants fictional canary values in the +deployment environment variables and in a temporary `trusted-server.toml`, and +every final binary is scanned for each canary. `Cargo.lock`, the JS +`package-lock.json` and `edgezero.toml` must be unchanged after the build. + +These are regression checks, not a hermeticity claim. There is no Rust source +audit in Phase 1; source-level auditing belongs to the later recipe phases. + +### R-INPUTS — build inputs + +An input audit was made against main at +`f9281917a7260b723f5f22175911c6d2147ef650`. It is not re-run automatically, and +locked versions have moved since (for example, the EdgeZero CLI is now locked at +tag `v0.0.11`). The input classes it found are: + +- Pinned Rust 1.98.1, the tracked `Cargo.toml`, `Cargo.lock`, + `.cargo/config.toml`, tool pins, source tree and release profile. +- Core's `build.rs`, `build_support/git_version.rs`, source tree, root + manifest and lockfile, and tracked `edgezero.toml`. The last supplies the + logical `trusted_server_config` store ID, not a physical binding. +- The JS build: `build/bundle_set.rs`, its declared sources, manifests and + lockfile, `lib/build-all.mjs` and `lib/build-module-options.mjs`. A fresh + `npm ci` precedes compilation. Cargo embeds modules from a private + `OUT_DIR/tsjs-dist`; `crates/trusted-server-js/dist/` is not a release input. +- Embedded assets: the GPT bootstrap, the CLI consent stub and ad-template + collector, and the fictional example-config scaffold. +- Dependency-generated code, including the EdgeZero CLI's adapter registry. + +No record of these inputs is published. A complete native tool closure is not +claimed. + +### R-SBOM — one SPDX document per binary + +Each `.spdx.json` is SPDX 2.3. Its single described root has the final +binary's SHA-256 and Cargo's package version (the release tag stays external). +It conservatively inventories the selected Cargo target and the installed npm +build tree. The pipeline is checksum-pinned cargo-cyclonedx 0.5.9 and +`npm sbom`, merged in `phase1.mjs`, converted by checksum-pinned cyclonedx-cli +0.33.1 and completed and validated by `phase1.mjs validate-spdx`. The merged +inventory records no dependency relationships. The signed SBOM predicate must equal the +shipped SPDX file. + +### R-FACTS — build facts + +Core's build script requires `TRUSTED_SERVER__RELEASE_BUILD=1` with a full +40-character lowercase `TRUSTED_SERVER__GIT_VERSION`, and refuses anything else +in release mode. Release `x-ts-version` is the full source commit. `ts --version` +prints `ts package= source= target= variant=default`; +the CLI's build script supplies the Cargo target. Tags and run IDs stay external. + +### R-PLATFORM — targets and startup checks + +All builds use `--release --locked` and strip native debug info +(`CARGO_PROFILE_RELEASE_STRIP=debuginfo`). The wasm keeps its profile. + +- **Linux GNU:** built in a container from a digest-pinned UBI 9.6 (glibc 2.34) + base image; its compilers and build tools come from an unpinned `dnf install`, + and the build log records the resulting image ID. The final bytes must be a + 64-bit x86-64 ELF with no `GLIBC_` requirement above 2.34. `ts --version` must + match in digest-pinned Debian 12, Ubuntu 22.04 and UBI 9 containers. +- **macOS:** `MACOSX_DEPLOYMENT_TARGET=13.0`. The final Mach-O must match the + target CPU and carry a 13.0 minimum. `ts --version` must match on the runner. + The binaries are not notarized. +- **Fastly:** wasm magic and version are checked. Hosted runtime qualification + is later work. + +### R-SOURCE — tags + +Release tags are strict SemVer with a `v` prefix and no build metadata +(`vMAJOR.MINOR.PATCH[-PRERELEASE]`), and must be lightweight tags at the built +commit. The build `checks` job and the publisher both enforce these rules. The +`checks` job also requires the tagged commit to be on at least one branch: in +fork rehearsals, draft creation returned HTTP 403 for a commit reachable only +through its tag. Which branches may be released (for example, whether rc +branches may be tagged) is a maintainer decision; Phase 1 does not check which +branch holds the commit, and does not read rulesets. + +### R-SIGN — signer + +The reusable `release-build.yml` is the certificate signer. Operators pin it +with `gh attestation verify --signer-workflow +/.github/workflows/release-build.yml`, together with the source ref, +source and signer digests and the predicate type. Actions are referenced by +major version tag. + +### R-PUBLISH — immutable publication + +Publication requires the repository variable `RELEASE_PUBLICATION_ENABLED=true`. +There is no settings token. The publisher: + +1. Checks that each signed handoff's `SHA256SUMS` receipt matches its four files. +2. Runs `gh attestation verify` for every binary and predicate with the signing + bundles it is about to publish, and requires the signed SBOM predicate to + equal the shipped SPDX file. Nothing exists yet, so a failure is safe to rerun. +3. Checks the tag reference, then refuses any existing release or draft for the + tag. +4. Creates one draft, logs its ID before checking it, and uploads the 17 assets + by that ID, checking each upload's size and SHA-256. +5. Publishes by ID. SemVer prereleases are prereleases and never Latest; stable + tags use `make_latest: legacy`. +6. Reads the release by ID and requires its identity, release type and + `immutable == true`. +7. Verifies the release attestation with `gh release verify`. Its verified + statement must name this repository, tag, release ID and commit, and list + exactly the 17 published assets with their local SHA-256 digests. + +Mutations are never retried. The two post-publish reads retry any failure, at +most five attempts within one three-minute window; a returned result that fails +a check stops at once. The guide lists the exact bounds and the read-only +recovery procedure. + +### R-MANUAL — operator route + +The [verification guide](../../guide/release-verification.md) documents online +and offline verification, using the verified CLI, and a manual Fastly deployment +that packs the verified wasm unchanged. Local Viceroy execution proves a local +route only; no provider submission has been qualified. + +## Interim implementation + +The release producer is Node (`scripts/release/phase1.mjs`) and Bash +(`scripts/release/phase1-build.sh`), and the publisher is Node +(`scripts/release/publish.mjs`). These are interim. The destination design's +Rust producer and publication owner replace them in later phases. + +## Maintainer gates + +Before `RELEASE_PUBLICATION_ENABLED` is set, a maintainer must enable immutable +releases, add an active `v*` tag ruleset restricting creation, update and +deletion to the release team, and approve tag-push authority and required +checks. Until the tag ruleset is active, any writer who can push a `v*` tag can +start that tag's own `release.yml`. The guide's checklist gives read-only +commands that confirm each setting. + +## Rehearsal + +Rehearse the reviewed commit in a separate public repository with Actions and +immutable releases enabled and `RELEASE_PUBLICATION_ENABLED=true`. Push the +commit to a branch there, then use a fresh lightweight `v0.0.0-rehearsal.N` tag +for each attempt. A rehearsal qualifies only the commit, workflows and binaries +it ran; a later change to any of them needs a new rehearsal before the first +production tag. A fork rehearsal does not qualify the upstream repository's own +signer identity, settings or immutable-release state. + +## Test plan + +`node --test scripts/release/phase1.test.mjs` (the `release scripts` CI job) +and `shellcheck scripts/release/phase1-build.sh` cover: + +- canary scanning, SBOM merge, SPDX completion and validation; +- the build script's preflight refusals, cleanup and step order, run under + `/bin/bash` (Bash 3.2 on macOS, where a failing `[[ ]]` alone does not stop + `set -e`); +- final-byte format checks for each target, including a glibc ceiling read from + `readelf` output; +- workflow structure: triggers, job permissions, gates, tag checks, the asset + matrix and the publisher's sparse checkout; +- the publisher against a fixture GitHub API and GitHub CLI, including the exact + create and publish bodies, signing-bundle verification, every refusal before + creation, and the post-publish deadlines. + +The CLI's `release_build_facts` test checks the embedded facts in the real +binary, and CI checks that core's build script refuses a branch name in release +mode. + +## Acceptance criteria + +#1235 can close when: + +1. A `v*` tag push builds the four binaries, attests provenance and an SPDX SBOM + for each, and publishes all 17 assets in one immutable release. +2. The guide's online and offline commands verify every binary against the + reusable signer, source commit and SBOM. +3. No operator configuration reaches the binaries (R-CONFIG). +4. The maintainer gates are in place and a rehearsal of the merged commit + passes. diff --git a/scripts/release/phase1-build.sh b/scripts/release/phase1-build.sh new file mode 100644 index 0000000000..75aef98cd0 --- /dev/null +++ b/scripts/release/phase1-build.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# Phase 1 ordinary hosted build; no reproducibility or complete composition claim. +set -euo pipefail +build_package=${1:?package required} +build_target=${2:?target required} +release_asset=${3:?asset required} +case "$build_package/$build_target/$release_asset" in + trusted-server-adapter-fastly/wasm32-wasip1/trusted-server-fastly.wasm) executable=trusted-server-adapter-fastly.wasm ;; + trusted-server-cli/x86_64-unknown-linux-gnu/ts-x86_64-unknown-linux-gnu|trusted-server-cli/aarch64-apple-darwin/ts-aarch64-apple-darwin|trusted-server-cli/x86_64-apple-darwin/ts-x86_64-apple-darwin) executable=ts ;; + *) echo 'Unsupported release build selection' >&2; exit 1 ;; +esac +: "${RUNNER_TEMP:?}" +: "${SOURCE_COMMIT:?}" +[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ ]] || { echo 'Invalid source commit' >&2; exit 1; } +[[ "$(git rev-parse HEAD)" == "$SOURCE_COMMIT" ]] || { echo 'Source commit mismatch' >&2; exit 1; } +for variable in TSJS_SKIP_BUILD TSJS_PREBUILT_DIR TSJS_TEST; do + if printenv "$variable" >/dev/null; then + echo "Refusing $variable in the release build" >&2 + exit 1 + fi +done +# No operator values enter through inherited environment; these fictional canaries +# additionally catch known config embedding regressions in the final executable. +export TRUSTED_SERVER__RELEASE_BUILD=1 +export TRUSTED_SERVER__GIT_VERSION="$SOURCE_COMMIT" +export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://phase1-origin-canary.example.com +export TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=phase1-secret-canary-32-characters +export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=phase1-physical-store-canary +export FASTLY_SERVICE_ID=phase1-service-account-canary +export MACOSX_DEPLOYMENT_TARGET=13.0 +# The workspace release profile keeps line-level debug info, which stays inside +# Linux executables. Strip it from native release binaries; the symbol table +# remains for backtraces. The Fastly wasm is unchanged. +if [[ "$build_target" != wasm32-wasip1 ]]; then + export CARGO_PROFILE_RELEASE_STRIP=debuginfo +fi +inventory="crates/$build_package/phase1.json" +# The generator writes phase1.json beside every workspace member manifest. +# Refuse existing paths, including dangling symlinks, before claiming cleanup ownership. +for owned_path in trusted-server.toml crates/*/phase1.json; do + if [[ -e "$owned_path" || -L "$owned_path" ]]; then + echo "Refusing existing release temporary path: $owned_path" >&2 + exit 1 + fi +done +trap 'rm -f trusted-server.toml crates/*/phase1.json; if [[ -n "${build_container:-}" ]]; then docker rm -f "$build_container" >/dev/null 2>&1 || true; fi' EXIT +cat > trusted-server.toml <<'CONFIG' +# Fictional config must never become a build input. +[publisher] +origin_url = "https://phase1-origin-canary.example.com" +proxy_secret = "phase1-secret-canary-32-characters" +[permissions] +policy = "phase1-permission-policy-canary" +CONFIG +export CARGO_TARGET_DIR="$RUNNER_TEMP/phase1-target" +# Exit explicitly: under bash 3.2 (macOS /bin/bash) a failing standalone [[ ]] +# does not stop set -e. +[[ ! -e "$CARGO_TARGET_DIR" ]] || { echo "Refusing existing target directory: $CARGO_TARGET_DIR" >&2; exit 1; } +mkdir -p release-assets +[[ -z "$(ls -A release-assets)" ]] || { echo "Refusing non-empty release-assets" >&2; exit 1; } +# The pinned Node distribution includes npm. npm verifies lockfile integrity for +# fetched packages; no generator dependencies are installed with unpinned npx. +npm --prefix crates/trusted-server-js/lib ci +if [[ "$build_target" == *-linux-gnu ]]; then + builder_base=registry.access.redhat.com/ubi9/ubi:9.6@sha256:dec374e05cc13ebbc0975c9f521f3db6942d27f8ccdf06b180160490eef8bdbc + docker build --build-arg "BASE_IMAGE=$builder_base" --iidfile "$RUNNER_TEMP/phase1-builder-id" - <<'DOCKERFILE' +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +RUN dnf install -y gcc gcc-c++ glibc-devel git-core cmake make perl && dnf clean all +DOCKERFILE + builder=$(cat "$RUNNER_TEMP/phase1-builder-id") + echo "GNU builder: $builder_base $builder" + build_container=phase1-build-$RANDOM-$$ + mkdir "$CARGO_TARGET_DIR" + docker run --rm --name "$build_container" --workdir /source \ + --mount "type=bind,src=$PWD,dst=/source" \ + --mount "type=bind,src=$CARGO_TARGET_DIR,dst=/target" \ + --mount "type=bind,src=$(dirname "$(dirname "$(node -p process.execPath)")"),dst=/opt/node,readonly" \ + --mount "type=bind,src=$(readlink -f "$(command -v rustup)"),dst=/usr/local/bin/rustup,readonly" \ + --env TRUSTED_SERVER__RELEASE_BUILD --env TRUSTED_SERVER__GIT_VERSION --env TRUSTED_SERVER__PUBLISHER__ORIGIN_URL \ + --env TRUSTED_SERVER__PUBLISHER__PROXY_SECRET --env EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME \ + --env FASTLY_SERVICE_ID --env CARGO_PROFILE_RELEASE_STRIP --env CARGO_TARGET_DIR=/target \ + "$builder" bash -euc ' + export PATH=/usr/local/bin:/opt/node/bin:/usr/bin:/bin CARGO_HOME=/tmp/cargo RUSTUP_HOME=/tmp/rustup + git config --global --add safe.directory /source + ldd --version | head -1 | grep -F "2.34" + rust_version=$(sed -n '\''s/^channel = "\([^"]*\)"/\1/p'\'' rust-toolchain.toml) + rustup toolchain install "$rust_version" --profile minimal --no-self-update + rustup target add --toolchain "$rust_version" "$2" + export PATH="$(dirname "$(rustup which --toolchain "$rust_version" rustc)"):$PATH" + rustup run "$rust_version" cargo build --package "$1" --release --target "$2" --locked + ' phase1 "$build_package" "$build_target" +else + cargo build --package "$build_package" --release --target "$build_target" --locked +fi +cp "$CARGO_TARGET_DIR/$build_target/release/$executable" "release-assets/$release_asset" +shasum -a 256 "release-assets/$release_asset" > "$RUNNER_TEMP/binary.sha256" +node scripts/release/phase1.mjs check "release-assets/$release_asset" +node scripts/release/phase1.mjs smoke "release-assets/$release_asset" "$build_target" +# Pin and checksum the generator source before extraction or execution. Cargo's +# --locked installation authenticates its transitive crates via its shipped lock. +tool_root="$RUNNER_TEMP/phase1-cyclonedx" +mkdir "$tool_root" +curl --fail --silent --show-error --location --retry 3 \ + https://crates.io/api/v1/crates/cargo-cyclonedx/0.5.9/download \ + --output "$tool_root/generator.crate" +printf '%s %s\n' 5d162f67705f0f5038759d73bf546a083bf30e8677c2e944b416bca48d9d69a8 "$tool_root/generator.crate" | shasum -a 256 --check +tar -xf "$tool_root/generator.crate" -C "$tool_root" +CARGO_TARGET_DIR="$tool_root/target" cargo install \ + --path "$tool_root/cargo-cyclonedx-0.5.9" --locked --root "$tool_root/install" +# The generator resolves the whole workspace, including unbuilt members. Acquire +# their selected sources under the lock before denying network resolution. +cargo metadata --locked --format-version 1 --filter-platform "$build_target" > /dev/null +# Pinned generator has no --locked flag. Resolve offline after the locked build +# with identical target/default features, then require the lock to be unchanged. +CARGO_NET_OFFLINE=true "$tool_root/install/bin/cargo-cyclonedx" cyclonedx \ + --manifest-path "crates/$build_package/Cargo.toml" --target "$build_target" \ + --format json --spec-version 1.5 --override-filename phase1 +npm --prefix crates/trusted-server-js/lib sbom --sbom-format cyclonedx > "$tool_root/npm.cdx.json" +node scripts/release/phase1.mjs merge \ + "$inventory" "$tool_root/npm.cdx.json" \ + "release-assets/$release_asset" "$tool_root/combined.cdx.json" +# Convert the internal dependency graph into real SPDX 2.3 before publication. +case "$build_target" in + wasm32-wasip1|x86_64-unknown-linux-gnu) + converter_asset=cyclonedx-linux-x64 + converter_sha=bfc8b2538da86fe239bc53658bbb63c1c8c510a293c1e6891aa5bea5d3c58746 ;; + aarch64-apple-darwin) + converter_asset=cyclonedx-osx-arm64 + converter_sha=750c148780154833f6401f9067d08c5a4c31567b6ee3c26c062c3a95c62d741c ;; + x86_64-apple-darwin) + converter_asset=cyclonedx-osx-x64 + converter_sha=0ea92306fc7d7c30ed112a7463781a200585a3bf1cdaf77229181c5aabd1d4b6 ;; +esac +curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.33.1/$converter_asset" \ + --output "$tool_root/converter" +printf '%s %s\n' "$converter_sha" "$tool_root/converter" | shasum -a 256 --check +chmod +x "$tool_root/converter" +"$tool_root/converter" convert --input-file "$tool_root/combined.cdx.json" \ + --input-format json --output-format spdxjson \ + --output-file "release-assets/$release_asset.spdx.json" +node scripts/release/phase1.mjs validate-spdx \ + "release-assets/$release_asset.spdx.json" "release-assets/$release_asset" \ + "$tool_root/combined.cdx.json" +shasum -a 256 --check "$RUNNER_TEMP/binary.sha256" +git diff --exit-code -- Cargo.lock crates/trusted-server-js/lib/package-lock.json edgezero.toml diff --git a/scripts/release/phase1.mjs b/scripts/release/phase1.mjs new file mode 100644 index 0000000000..aefe068465 --- /dev/null +++ b/scripts/release/phase1.mjs @@ -0,0 +1,382 @@ +/** Small Phase 1 build-inventory and configuration checks, without release authority. */ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { readFileSync, statSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +/** Check minimal wasm headers and final Linux loader requirements. */ +export function checkReleaseFormat(bytes, target, elf = '') { + if (target === 'wasm32-wasip1') { + if (!bytes.subarray(0, 8).equals(Buffer.from('0061736d01000000', 'hex'))) + throw Error('Invalid wasm magic or version') + } else if (target === 'x86_64-unknown-linux-gnu') { + // 64-bit little-endian ELF whose e_machine is EM_X86_64. + if ( + bytes.length < 20 || + !bytes.subarray(0, 6).equals(Buffer.from('7f454c460201', 'hex')) || + bytes.readUInt16LE(18) !== 0x3e + ) + throw Error('Expected target-matched 64-bit ELF') + if (/GLIBC_[A-Z]/.test(elf)) throw Error('Unknown GLIBC requirement') + for (const [, major, minor, patch] of elf.matchAll( + /GLIBC_(\d+)\.(\d+)(?:\.(\d+))?/g + )) + if ( + +major > 2 || + (+major === 2 && (+minor > 34 || (+minor === 34 && +patch > 0))) + ) + throw Error('GLIBC requirement exceeds 2.34') + } else if (target.endsWith('-apple-darwin')) { + if ( + bytes.length < 32 || + bytes.readUInt32LE(0) !== 0xfeedfacf || + bytes.readUInt32LE(4) !== + (target.startsWith('aarch64-') ? 0x0100000c : 0x01000007) + ) + throw Error('Expected target-matched 64-bit Mach-O') + const end = 32 + bytes.readUInt32LE(20), + count = bytes.readUInt32LE(16) + if (end > bytes.length || count > Math.floor((end - 32) / 8)) + throw Error('Truncated Mach-O commands') + let offset = 32, + minimum + for (let i = 0; i < count; i++) { + if (offset + 8 > end) throw Error('Truncated Mach-O command') + const command = bytes.readUInt32LE(offset), + size = bytes.readUInt32LE(offset + 4) + if (size < 8 || offset + size > end) + throw Error('Invalid Mach-O command size') + // LC_BUILD_VERSION for PLATFORM_MACOS; a 13.0 deployment target emits + // no older LC_VERSION_MIN_MACOSX command. + if (command === 0x32) { + if ( + minimum !== undefined || + size < 24 || + bytes.readUInt32LE(offset + 8) !== 1 + ) + throw Error('Invalid macOS deployment command') + minimum = bytes.readUInt32LE(offset + 12) + } + offset += size + } + if (offset !== end || minimum === undefined || minimum !== 0x000d0000) + throw Error('Expected macOS minimum OS 13.0') + console.log('SUCCESS: Mach-O target and macOS minimum OS 13.0:', target) + } else throw Error('Unsupported release target') +} + +/** Complete converter output with the actual binary's SPDX package. */ +export function completeSpdx(document, binary, original) { + document.creationInfo ??= {} + if (typeof document.creationInfo.created === 'string') + document.creationInfo.created = document.creationInfo.created.replace( + /\.\d+Z$/, + 'Z' + ) + document.creationInfo.creators = [ + ...new Set([ + ...(document.creationInfo.creators ?? []), + 'Tool: cargo-cyclonedx-0.5.9', + 'Tool: cyclonedx-cli-0.33.1', + 'Tool: trusted-server-phase1', + ]), + ] + for (const item of document.packages) + for (const key of ['licenseDeclared', 'licenseConcluded']) + if (typeof item[key] === 'string') + item[key] = item[key].replace(/\s+/g, ' ').trim() + const root = original.metadata.component + document.packages.push({ + SPDXID: 'SPDXRef-Artifact', + name: root.name, + versionInfo: root.version, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'NOASSERTION', + licenseDeclared: 'NOASSERTION', + copyrightText: 'NOASSERTION', + externalRefs: root.purl + ? [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: root.purl, + }, + ] + : [], + checksums: [ + { + algorithm: 'SHA256', + checksumValue: createHash('sha256').update(binary).digest('hex'), + }, + ], + }) + document.documentDescribes = ['SPDXRef-Artifact'] + document.relationships = [ + { + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: 'SPDXRef-Artifact', + }, + ] + return validateSpdx(document, binary, original) +} + +/** + * Rescan the binary and check that every inventory component keeps its name, + * version and PURL in the SPDX packages. + */ +function validateSpdx(document, binary, original) { + checkBinary(binary) + // The signed predicate type names SPDX 2.3. + if ( + document.spdxVersion !== 'SPDX-2.3' || + document.SPDXID !== 'SPDXRef-DOCUMENT' + ) + throw new Error('Expected an SPDX 2.3 document') + const packages = document.packages + const ids = new Set(packages.map((entry) => entry.SPDXID)) + if (ids.size !== packages.length) + throw new Error('Duplicate SPDX package identity') + if ([...ids].some((id) => !/^SPDXRef-[A-Za-z0-9.-]+$/.test(id))) + throw new Error('Invalid SPDX package identifier') + const flatten = (items) => + items.flatMap((item) => [item, ...flatten(item.components ?? [])]) + const used = new Set() + for (const component of flatten(original.components ?? [])) { + const match = packages.find( + (entry) => + !used.has(entry.SPDXID) && + entry.name === component.name && + (!component.version || entry.versionInfo === component.version) && + (!component.purl || + entry.externalRefs?.some( + (ref) => + ref.referenceType === 'purl' && + ref.referenceLocator === component.purl + )) + ) + if (!match) throw new Error('SPDX lost component identity or PURL') + used.add(match.SPDXID) + } + return document +} + +// Adapted from #1262's native recipe scanner at 10167c6017b725d0a4d5ad0a0c8e6a96fd4aa807. +export const sentinels = [ + 'phase1-origin-canary.example.com', + 'phase1-physical-store-canary', + 'phase1-service-account-canary', + 'phase1-secret-canary-32-characters', + 'phase1-permission-policy-canary', +] + +export function checkBinary(bytes) { + if (bytes.length === 0) throw new Error('Release binary is empty') + for (const marker of sentinels) { + if (bytes.includes(Buffer.from(marker))) + throw new Error('Release binary contains planted operator configuration') + } +} + +function namespace(bom, prefix) { + if ( + bom.bomFormat !== 'CycloneDX' || + bom.specVersion !== '1.5' || + !bom.metadata?.component?.['bom-ref'] + ) { + throw new Error('Expected CycloneDX 1.5 with a root component') + } + const rename = (value, key) => { + if (Array.isArray(value)) return value.map((item) => rename(item, key)) + if (value && typeof value === 'object') + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, rename(v, k)]) + ) + // Hashed references keep valid SPDX identifiers after conversion. + return typeof value === 'string' && key === 'bom-ref' + ? `${prefix}-${createHash('sha256').update(value).digest('hex')}` + : value + } + return rename(bom) +} + +export function mergeSboms(cargo, npm, binary, name) { + checkBinary(binary) + const rust = namespace(cargo, 'cargo') + const javascript = namespace(npm, 'npm') + const root = rust.metadata.component + const npmRoot = javascript.metadata.component + // Dependency relationships are dropped: SPDX conversion would not keep them. + return { + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + metadata: { + ...rust.metadata, + component: { + ...root, + // The root's nested components are listed once, below. + components: undefined, + name, + hashes: [ + { + alg: 'SHA-256', + content: createHash('sha256').update(binary).digest('hex'), + }, + ], + }, + properties: [ + { + name: 'trusted-server:coverage', + value: + 'Selected Cargo packages plus the installed npm build tree used for this release build. Conservative inventory: components may be removed by linking, tree-shaking or minification. External Prebid is not bundled; its installed package is a build-tree entry only. The inventory does not record dependency relationships. Native C/assembly, system libraries, Rust standard library and compiler runtimes are not completely inventoried. Complete composition is Phase 2.', + }, + ], + }, + components: [ + ...(rust.components ?? []), + ...(root.components ?? []), + npmRoot, + ...(javascript.components ?? []), + ], + } +} + +/** + * Checks `ts --version` output against the workspace package version, the + * release source commit and the build target. + */ +export function checkCliVersion(output, cargoManifest, source, target) { + const expected = + 'ts package=' + + cargoManifest.match( + /\[workspace\.package\][\s\S]*?^version = "([^"]+)"/m + )[1] + + ` source=${source} target=${target} variant=default` + if (output.trim() !== expected) throw Error('Unexpected CLI version') +} + +async function smokeRelease(file, target) { + const linux = target === 'x86_64-unknown-linux-gnu' + const elf = linux + ? execFileSync('readelf', ['--version-info', file], { + encoding: 'utf8', + maxBuffer: 8388608, + }) + : '' + checkReleaseFormat(readFileSync(file), target, elf) + console.log( + 'SUCCESS: final-byte checks', + target, + [...new Set(elf.match(/GLIBC_[\w.]+/g) ?? [])].join(' ') + ) + if (target === 'wasm32-wasip1') return + const version = (output) => { + checkCliVersion( + output, + readFileSync('Cargo.toml', 'utf8'), + process.env.SOURCE_COMMIT, + target + ) + console.log('SUCCESS:', output.trim()) + } + version( + execFileSync(resolve(file), ['--version'], { + encoding: 'utf8', + timeout: 10000, + }) + ) + if (!linux) return + const docker = (args, input) => + execFileSync('docker', args, { + input, + encoding: 'utf8', + timeout: args[0] === 'pull' ? 180000 : 10000, + maxBuffer: 8388608, + }) + for (const reference of [ + 'debian:12-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8', + 'ubuntu:22.04@sha256:0a9887931b0e38af2e3487ed39e29436b0535c4f3f52e57745baaf79d1898956', + 'registry.access.redhat.com/ubi9/ubi-minimal:9.6@sha256:34880b64c07f28f64d95737f82f891516de9a3b43583f39970f7bf8e4cfa48b7', + ]) { + docker(['pull', reference]) + const image = docker([ + 'image', + 'inspect', + '--format', + '{{.Id}}', + reference, + ]).trim() + console.log('Runtime image:', reference, image) + version( + docker([ + 'run', + '--rm', + '--network=none', + '--read-only', + '--cap-drop=ALL', + '--security-opt=no-new-privileges', + '--user=65534:65534', + '--entrypoint=/release', + '--mount', + `type=bind,src=${resolve(file)},dst=/release,readonly`, + image, + '--version', + ]) + ) + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + const [command, ...args] = process.argv.slice(2) + if (command === 'check' && args.length > 0) { + for (const file of args) { + if (!statSync(file).isFile()) + throw new Error('Expected regular release binary') + checkBinary(readFileSync(file)) + } + } else if (command === 'smoke' && args.length === 2) { + await smokeRelease(...args) + } else if (command === 'merge' && args.length === 4) { + const [cargo, npm, binary, output] = args + writeFileSync( + output, + JSON.stringify( + mergeSboms( + JSON.parse(readFileSync(cargo)), + JSON.parse(readFileSync(npm)), + readFileSync(binary), + binary.split('/').at(-1) + ), + null, + 2 + ) + '\n' + ) + } else if (command === 'validate-spdx' && args.length === 3) { + const [file, binary, original] = args + const document = JSON.parse(readFileSync(file)) + const inventory = JSON.parse(readFileSync(original)) + completeSpdx(document, readFileSync(binary), inventory) + document.creationInfo.creators.push( + `Tool: npm-${execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim()}` + ) + const coverage = inventory.metadata.properties?.find( + (entry) => entry.name === 'trusted-server:coverage' + )?.value + if (typeof coverage !== 'string' || coverage.length === 0) + throw new Error('Missing conservative inventory coverage') + document.creationInfo.comment = [document.creationInfo.comment, coverage] + .filter(Boolean) + .join('\n') + writeFileSync(file, JSON.stringify(document, null, 2) + '\n') + } else { + throw new Error( + 'Expected check , merge , or validate-spdx ' + ) + } +} diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs new file mode 100644 index 0000000000..60be5506db --- /dev/null +++ b/scripts/release/phase1.test.mjs @@ -0,0 +1,1892 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync, spawnSync } from 'node:child_process' +import { completeSpdx, checkReleaseFormat } from './phase1.mjs' +import { test } from 'node:test' +import { + readFileSync, + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, +} from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import * as fs from 'node:fs' +import { fileURLToPath } from 'node:url' +import { isDeepStrictEqual } from 'node:util' +import { + checkBinary, + checkCliVersion, + mergeSboms, + sentinels, +} from './phase1.mjs' +import { publish, releaseAssets, releaseNotes } from './publish.mjs' +test('rejects every planted operator value, including inside binary data', () => { + for (const marker of sentinels) { + assert.throws( + () => + checkBinary( + Buffer.concat([ + Buffer.from([0]), + Buffer.from(marker), + Buffer.from([255]), + ]) + ), + /operator configuration/ + ) + } + assert.doesNotThrow(() => + checkBinary(Buffer.from('fictional clean executable')) + ) + assert.throws(() => checkBinary(Buffer.alloc(0)), /empty/) +}) +test('merges independent graphs without colliding references and binds binary bytes', () => { + const bom = (name) => ({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + version: 1, + metadata: { + component: { + type: 'application', + name, + version: '1.2.3', + 'bom-ref': 'root', + components: [ + { type: 'library', name: `${name}-nested`, 'bom-ref': 'nested' }, + ], + }, + }, + components: [ + { type: 'library', name: `${name}-dependency`, 'bom-ref': 'dependency' }, + ], + dependencies: [ + { ref: 'root', dependsOn: ['dependency'] }, + { ref: 'dependency', dependsOn: [] }, + ], + }) + const output = mergeSboms( + bom('cargo'), + bom('npm'), + Buffer.from('binary'), + 'example.wasm' + ) + assert.equal(output.metadata.component.name, 'example.wasm') + // The release tag stays external; the root keeps Cargo's package version, + // matching its purl and `ts --version`. + assert.equal(output.metadata.component.version, '1.2.3') + // The root's nested components are listed once, at the top level. + assert.equal(output.metadata.component.components, undefined) + assert.equal(output.metadata.component.hashes[0].alg, 'SHA-256') + assert.equal(output.metadata.component.hashes[0].content.length, 64) + assert.equal(output.dependencies, undefined) + assert.equal( + new Set(output.components.map((c) => c['bom-ref'])).size, + output.components.length + ) + assert.ok( + output.components.every((entry) => + /^[A-Za-z0-9.-]+$/.test(entry['bom-ref']) + ) + ) + assert.throws( + () => mergeSboms({}, bom('npm'), Buffer.from('binary'), 'example'), + /CycloneDX/ + ) + // Both inventories reach the merged components that SPDX validation checks. + const names = (items) => + items.flatMap((item) => [item.name, ...names(item.components ?? [])]) + assert.deepEqual( + names(output.components).sort(), + ['cargo-dependency', 'cargo-nested', 'npm', 'npm-dependency', 'npm-nested'], + 'should keep every Cargo and npm component' + ) +}) +test('workflow signs only tags, separates privileged jobs, and never checks out in signing', () => { + const workflow = readFileSync( + new URL('../../.github/workflows/release-build.yml', import.meta.url), + 'utf8' + ) + const sign = workflow.split('\n sign:')[1]?.split('\n release:')[0] + assert.ok(sign, 'should define the isolated signing job') + assert.match(sign, /github.event_name == 'push'/) + assert.doesNotMatch( + sign, + /checkout@|cargo |npm |contents: write|artifact-metadata:/ + ) + assert.equal((sign.match(/uses: actions\/attest@/g) ?? []).length, 2) + const release = readFileSync('.github/workflows/release.yml', 'utf8') + const triggers = release.split('\npermissions:')[0] + assert.doesNotMatch( + triggers, + /pull_request/, + 'should not run release builds or privileged callers on pull requests' + ) + assert.match(triggers, /branches: \[main\]/) + assert.match(triggers, /tags: \['v\*'\]/) + assert.doesNotMatch(workflow, /workflow_dispatch|--clobber/) + // The publisher job checks out only its own script, template and tool pins. + const publisher = release.split('\n release:')[1] + assert.equal((publisher.match(/actions\/checkout@/g) ?? []).length, 1) + assert.match(publisher, /persist-credentials: false/) + assert.deepEqual( + publisher + .match(/sparse-checkout: \|\n((?: {12}\S.*\n)+)/)[1] + .trim() + .split(/\s+/), + [ + '.tool-versions', + 'scripts/release/publish.mjs', + 'scripts/release/release-notes.md', + ] + ) + assert.match(publisher, /sparse-checkout-cone-mode: false/) + assert.doesNotMatch(publisher, /cargo |npm |phase1-build/) + assert.match(publisher, /GH_SHA256: [a-f0-9]{64}\n/) + assert.match(publisher, /sha256sum --check/) + assert.match(publisher, /node publisher\/scripts\/release\/publish\.mjs\n/) + // Match the repository convention: actions are referenced by major tag. + for (const line of [workflow, release] + .join('\n') + .split('\n') + .filter((line) => /uses: .*@/.test(line))) { + assert.match(line, /uses: actions\/[\w-]+@v\d+$/) + } + // Every job that runs Node first installs the pinned version. + for (const text of [workflow, release]) + for (const job of text.split(/\n {2}(?=[\w-]+:\n)/).slice(1)) + if (/^\s*(?:- )?(?:run: )?node /m.test(job)) + assert.ok( + job.indexOf('actions/setup-node@') >= 0 && + job.indexOf('actions/setup-node@') < + job.search(/^\s*(?:- )?(?:run: )?node /m), + `should set up pinned Node before running it: ${job.split(':')[0]}` + ) +}) +const runBlock = (workflow, step) => { + const lines = workflow.split('\n') + const start = lines.findIndex((line) => line.trim() === `- name: ${step}`) + assert.ok(start >= 0, `should define step ${step}`) + const run = lines.findIndex( + (line, index) => index > start && line.trim() === 'run: |' + ) + const indent = lines[run + 1].match(/^ */)[0] + const body = [] + for (const line of lines.slice(run + 1)) { + if (line && !line.startsWith(indent)) break + body.push(line.slice(indent.length)) + } + return body.join('\n') +} +test('signing job shell refuses symlinked, extra, empty and changed inputs', () => { + const workflow = readFileSync('.github/workflows/release-build.yml', 'utf8') + const validate = runBlock( + workflow, + 'Validate signing inputs and retain their digests' + ) + const retain = runBlock( + workflow, + "Retain only this run's real bundles and signed-input digests" + ) + const asset = 'example-asset' + for (const mode of [ + 'success', + 'symlink-binary', + 'symlink-spdx', + 'directory-binary', + 'extra', + 'missing', + 'empty', + 'changed', + 'empty-bundle', + 'empty-provenance', + ]) { + const root = mkdtempSync(join(tmpdir(), 'phase1-sign-')) + try { + const inputs = join(root, 'release-assets') + mkdirSync(inputs) + writeFileSync(join(root, 'outside'), 'outside') + for (const file of [asset, `${asset}.spdx.json`]) + writeFileSync(join(inputs, file), `fixture ${file}`) + if (mode.startsWith('symlink-')) { + const file = join( + inputs, + mode === 'symlink-binary' ? asset : `${asset}.spdx.json` + ) + rmSync(file) + fs.symlinkSync(join(root, 'outside'), file) + } + if (mode === 'directory-binary') { + rmSync(join(inputs, asset)) + mkdirSync(join(inputs, asset)) + writeFileSync(join(inputs, asset, 'nested'), 'nested') + } + if (mode === 'extra') writeFileSync(join(inputs, 'extra'), 'extra') + if (mode === 'missing') rmSync(join(inputs, `${asset}.spdx.json`)) + if (mode === 'empty') writeFileSync(join(inputs, asset), '') + const env = { PATH: process.env.PATH, ASSET: asset } + const run = (script, extra = {}) => + spawnSync('bash', ['-c', script], { + cwd: root, + env: { ...env, ...extra }, + encoding: 'utf8', + }) + const validated = run(validate) + const validFixture = [ + 'success', + 'changed', + 'empty-bundle', + 'empty-provenance', + ].includes(mode) + assert.equal(validated.status === 0, validFixture, mode) + if (!validFixture) continue + // Simulate the two attest steps between the shell steps. + const provenance = join(root, 'provenance.json') + const sbom = join(root, 'sbom.json') + writeFileSync( + provenance, + mode === 'empty-provenance' ? '' : 'provenance bundle' + ) + writeFileSync(sbom, mode === 'empty-bundle' ? '' : 'sbom bundle') + if (mode === 'changed') writeFileSync(join(inputs, asset), 'changed') + const retained = run(retain, { PROVENANCE: provenance, SBOM: sbom }) + assert.equal(retained.status === 0, mode === 'success', mode) + if (mode !== 'success') continue + const signed = join(root, 'signed-assets') + assert.deepEqual(fs.readdirSync(signed).sort(), [ + 'SHA256SUMS', + `${asset}.provenance.sigstore.json`, + `${asset}.sbom.sigstore.json`, + ]) + const sums = readFileSync(join(signed, 'SHA256SUMS'), 'utf8') + for (const [file, dir] of [ + [asset, inputs], + [`${asset}.spdx.json`, inputs], + [`${asset}.provenance.sigstore.json`, signed], + [`${asset}.sbom.sigstore.json`, signed], + ]) + assert.ok( + sums.includes(`${hash(readFileSync(join(dir, file)))} ${file}\n`), + `should retain the digest of ${file}` + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +}) +const assets = releaseAssets +// The publisher fixture shadows `execFileSync`; this runs a real child process. +const realExecFileSync = execFileSync +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') +const canonicalBlock = (markdown) => { + const block = markdown.match( + /\n\n```bash\n([\s\S]*?)\n```\n\n/ + ) + assert.ok(block, 'should mark the canonical verification block') + return block[1] +} +test('release notes link to the guide at the tag and carry no commands', () => { + const body = releaseNotes('example/project', 'v1.2.3-rc.1') + assert.ok( + body.includes( + '(https://github.com/example/project/blob/v1.2.3-rc.1/docs/guide/release-verification.md)' + ), + 'should link to the guide at the tag' + ) + assert.match(body, /stay editable after publication/) + assert.doesNotMatch(body, /\{\{|```/) + // The guide's block is the only copy of the verification commands. + execFileSync('bash', ['-n'], { + input: canonicalBlock( + readFileSync('docs/guide/release-verification.md', 'utf8') + ), + }) +}) +test('publication binds exact signing handoffs and refuses drafts, corruption and partial uploads', async (t) => { + // Record each read's timeout so every read can be shown to be bounded. + const timeouts = new WeakMap() + const timeout = AbortSignal.timeout.bind(AbortSignal) + t.mock.method(AbortSignal, 'timeout', (delay) => { + const signal = timeout(delay) + timeouts.set(signal, delay) + return signal + }) + for (const mode of [ + 'success', + 'idle-socket', + 'stable', + 'build-metadata-tag', + 'leading-zero-tag', + 'non-semver-tag', + 'annotated-tag', + 'wrong-tag-commit', + 'missing-tag', + 'malformed-tag', + 'bundle-verification-failure', + 'bundle-sbom-mismatch', + 'bundle-sbom-duplicate', + 'identity-late', + 'identity-transient', + 'identity-never', + 'large-sbom-output', + 'publication-disabled', + 'prerelease-mismatch', + 'draft', + 'draft-page-2', + 'api-error', + 'missing', + 'extra', + 'changed', + 'empty-bundle', + 'symlink-input', + 'signed-extra', + 'created-wrong-tag', + 'created-not-draft', + 'created-not-prerelease', + 'upload-error', + 'upload-digest', + 'upload-size', + 'mutable', + 'wrong-id', + 'published-wrong-id', + 'published-wrong-tag', + 'published-draft', + 'published-prerelease-flipped', + 'attestation-late', + 'attestation-never', + 'attestation-past-deadline', + 'attestation-malformed', + 'attestation-wrong-tag', + 'attestation-wrong-tag-shape', + 'attestation-wrong-id', + 'attestation-wrong-repository', + 'attestation-wrong-source', + 'attestation-wrong-assets', + 'attestation-missing-asset', + 'attestation-extra-asset', + 'attestation-duplicate-subject', + ]) { + const root = mkdtempSync(join(tmpdir(), 'phase1-publish-')) + const calls = [] + const uploaded = [] + const stable = mode === 'stable' + const tag = + mode === 'build-metadata-tag' + ? 'v1.0.0+build-1' + : mode === 'leading-zero-tag' + ? 'v01.0.0' + : mode === 'non-semver-tag' + ? 'v1.0' + : stable + ? 'v1.0.0' + : 'v0.0.0-example' + // Real SBOM results embed the whole SPDX document and exceed 1 MiB. + const spdxDocument = (asset) => + mode === 'large-sbom-output' + ? { fixture: asset, padding: 'x'.repeat(2 * 1024 * 1024) } + : { fixture: asset } + const waits = [], + logs = [], + // Retried reads catch every error, so a failed stub assertion is also + // recorded here. + violations = [] + const check = (condition, message) => { + if (!condition) violations.push(message) + assert.ok(condition, message) + } + // Whether a gh command ran since the last API call. + let ghSinceFetch = false + let verifies = 0, + identityReads = 0, + now = 0 + try { + for (const asset of assets) { + const build = join(root, 'build', asset) + const signed = join(root, 'signed', asset) + mkdirSync(build, { recursive: true }) + mkdirSync(signed, { recursive: true }) + const entries = [] + for (const suffix of [ + '', + '.spdx.json', + '.provenance.sigstore.json', + '.sbom.sigstore.json', + ]) { + const bytes = Buffer.from( + suffix === '.spdx.json' + ? JSON.stringify(spdxDocument(asset)) + : `fixture ${asset}${suffix}` + ) + writeFileSync( + join(suffix.includes('sigstore') ? signed : build, asset + suffix), + bytes + ) + entries.push(`${hash(bytes)} ${asset}${suffix}\n`) + } + writeFileSync(join(signed, 'SHA256SUMS'), entries.join('')) + } + if (mode === 'missing') + fs.unlinkSync(join(root, 'build', assets[0], assets[0])) + if (mode === 'extra') + writeFileSync(join(root, 'build', assets[0], 'unexpected'), 'extra') + if (mode === 'changed') + writeFileSync(join(root, 'build', assets[0], assets[0]), 'different') + if (mode === 'empty-bundle') + writeFileSync( + join(root, 'signed', assets[0], assets[0] + '.sbom.sigstore.json'), + '' + ) + if (mode === 'signed-extra') + writeFileSync(join(root, 'signed', assets[0], 'unexpected'), 'extra') + if (mode === 'symlink-input') { + const input = join(root, 'build', assets[0], assets[0]) + fs.renameSync(input, join(root, 'elsewhere')) + fs.symlinkSync(join(root, 'elsewhere'), input) + } + const release = (draft) => ({ + id: 42, + tag_name: tag, + prerelease: !stable && mode !== 'prerelease-mismatch', + draft, + immutable: mode !== 'mutable', + assets: uploaded, + }) + const statement = () => { + const attested = mode === 'attestation-wrong-tag' ? 'v9.9.9' : tag + const result = { + _type: 'https://in-toto.io/Statement/v1', + predicateType: 'https://in-toto.io/attestation/release/v0.2', + predicate: { + databaseId: '42', + repository: 'example/project', + tag: attested, + }, + subject: [ + { + uri: `pkg:github/example/project@${attested}`, + digest: { sha1: 'a'.repeat(40) }, + }, + ...uploaded.map((a) => ({ + name: a.name, + digest: { sha256: a.digest.slice(7) }, + })), + ], + } + if (mode === 'attestation-wrong-id') result.predicate.databaseId = '43' + if (mode === 'attestation-wrong-repository') + result.predicate.repository = 'example/other' + if (mode === 'attestation-wrong-source') + result.subject[0].digest.sha1 = 'b'.repeat(40) + if (mode === 'attestation-wrong-assets') + result.subject[1].digest.sha256 = '0'.repeat(64) + if (mode === 'attestation-missing-asset') result.subject.pop() + if (mode === 'attestation-extra-asset') + result.subject.push({ + name: 'unexpected', + digest: { sha256: '0'.repeat(64) }, + }) + if (mode === 'attestation-wrong-tag-shape') result.predicate.tag = 42 + if (mode === 'attestation-duplicate-subject') + result.subject.push(result.subject[1]) + return result + } + const fetch = async (url, options = {}) => { + const u = new URL(url) + const method = options.method ?? 'GET' + check( + Boolean(options.signal) === (method === 'GET'), + `should time-bound every read and only reads: ${method} ${u.pathname}` + ) + // A synchronous gh check between API calls leaves pooled sockets + // idle, and the server may close them meanwhile. + if ( + mode === 'idle-socket' && + ghSinceFetch && + options.headers.Connection !== 'close' + ) + throw new TypeError('other side closed') + ghSinceFetch = false + calls.push([method, u.pathname + u.search]) + const response = (body, status = 200, headers = {}) => + new Response( + typeof body === 'string' || Buffer.isBuffer(body) + ? body + : JSON.stringify(body), + { status, headers } + ) + if (u.pathname.includes('/git/ref/tags/')) { + assert.equal(method, 'GET') + assert.equal(decodeURIComponent(u.pathname.split('/').at(-1)), tag) + assert.equal( + timeouts.get(options.signal), + 10000, + 'should bound the tag reference read to 10s' + ) + if (mode === 'missing-tag') return response({}, 404) + if (mode === 'malformed-tag') return response({}) + return response({ + object: { + type: mode === 'annotated-tag' ? 'tag' : 'commit', + sha: ['annotated-tag', 'wrong-tag-commit'].includes(mode) + ? 'b'.repeat(40) + : 'a'.repeat(40), + }, + }) + } + if (u.pathname.endsWith('/releases') && method === 'GET') { + assert.equal( + timeouts.get(options.signal), + 10000, + 'should bound the release listing read to 10s' + ) + if (mode === 'api-error') return response('denied', 503) + const page = Number(u.searchParams.get('page')) + if (mode === 'draft-page-2') + return response( + page === 1 + ? Array.from({ length: 100 }, (_, index) => ({ + tag_name: `v0.0.${index}`, + })) + : [release(true)] + ) + assert.equal(page, 1, 'should stop after a short page') + return response(mode === 'draft' ? [release(true)] : []) + } + if (u.pathname.endsWith('/releases') && method === 'POST') { + assert.deepEqual( + JSON.parse(options.body), + { + tag_name: tag, + target_commitish: 'a'.repeat(40), + name: tag, + draft: true, + prerelease: !stable, + make_latest: stable ? 'legacy' : 'false', + body: releaseNotes('example/project', tag), + }, + 'should create exactly one draft for the tag' + ) + return response( + mode === 'created-wrong-tag' + ? { ...release(true), tag_name: 'v9.9.9' } + : mode === 'created-not-prerelease' + ? { ...release(true), prerelease: false } + : release(mode !== 'created-not-draft'), + 201 + ) + } + if (u.pathname.endsWith('/assets') && method === 'POST') { + if (mode === 'upload-error') return response('upload failed', 500) + const bytes = Buffer.from(options.body) + const name = u.searchParams.get('name') + const item = { + id: 100 + uploaded.length, + name, + size: bytes.length, + state: 'uploaded', + digest: `sha256:${hash(bytes)}`, + bytes, + } + uploaded.push(item) + return response( + mode === 'upload-digest' + ? { ...item, digest: `sha256:${'0'.repeat(64)}` } + : mode === 'upload-size' + ? { ...item, size: item.size + 1 } + : item, + 201 + ) + } + if (u.pathname.endsWith('/releases/42')) { + if (method === 'PATCH') { + assert.deepEqual( + JSON.parse(options.body), + { + draft: false, + prerelease: !stable, + make_latest: stable ? 'legacy' : 'false', + }, + 'should publish with the release type and Latest policy' + ) + return response({ + ...release(false), + id: mode === 'published-wrong-id' ? 43 : 42, + }) + } + identityReads++ + check( + timeouts.get(options.signal) === Math.min(10000, 180000 - now), + 'should bound every identity read by the post-publish window' + ) + if ( + mode === 'identity-never' || + (mode === 'identity-transient' && identityReads === 1) + ) + return response({}, 503) + // The identity read ends late in the post-publish window. + if (['identity-late', 'attestation-past-deadline'].includes(mode)) + now = 175000 + return response({ + ...release(mode === 'published-draft'), + id: mode === 'wrong-id' ? 43 : 42, + ...(mode === 'published-wrong-tag' ? { tag_name: 'v9.9.9' } : {}), + ...(mode === 'published-prerelease-flipped' + ? { prerelease: stable } + : {}), + }) + } + throw Error(`Unexpected ${method} ${url}`) + } + const execFileSync = (...args) => { + calls.push(['gh', args]) + ghSinceFetch = true + if (args[1][0] === 'attestation') { + const index = + calls.filter( + ([kind, call]) => kind === 'gh' && call[1][0] === 'attestation' + ).length - 1 + const name = assets[Math.floor(index / 2)] + const kind = index % 2 ? 'sbom' : 'provenance' + assert.deepEqual( + args, + [ + 'gh', + [ + 'attestation', + 'verify', + join(root, 'release-assets', name), + '--repo', + 'example/project', + '--bundle', + join(root, 'release-assets', `${name}.${kind}.sigstore.json`), + '--source-ref', + `refs/tags/${tag}`, + '--source-digest', + 'a'.repeat(40), + '--signer-digest', + 'a'.repeat(40), + '--deny-self-hosted-runners', + '--predicate-type', + kind === 'sbom' + ? 'https://spdx.dev/Document/v2.3' + : 'https://slsa.dev/provenance/v1', + '--signer-workflow', + 'example/project/.github/workflows/release-build.yml', + '--format', + 'json', + ], + { + timeout: 60000, + stdio: 'pipe', + maxBuffer: 256 * 1024 * 1024, + }, + ], + 'should verify each signing bundle for its asset and predicate' + ) + assert.ok( + !calls.some(([method]) => method !== 'gh'), + 'should verify signing bundles before any API call' + ) + if (mode === 'bundle-verification-failure' && index === 3) + throw Error('Signature verification failed') + // A provenance result carries an SLSA predicate, never the SPDX. + const result = { + verificationResult: { + statement: { + predicate: + kind === 'provenance' + ? { buildDefinition: { buildType: 'fixture' } } + : mode === 'bundle-sbom-mismatch' + ? { fixture: 'other' } + : spdxDocument(name), + }, + }, + } + const output = JSON.stringify( + mode === 'bundle-sbom-duplicate' ? [result, result] : [result] + ) + if (mode !== 'large-sbom-output') return Buffer.from(output) + // Pass the large result through a real child process with the + // publisher's own options, so Node's output buffer limit applies. + const file = join(root, 'gh-output.json') + writeFileSync(file, output) + return realExecFileSync( + process.execPath, + [ + '-e', + "process.stdout.write(require('node:fs').readFileSync(process.argv[1]))", + file, + ], + args[2] + ) + } + verifies++ + check( + isDeepStrictEqual(args, [ + 'gh', + [ + 'release', + 'verify', + '--repo', + 'example/project', + '--format', + 'json', + '--', + tag, + ], + { timeout: Math.min(30000, 180000 - now), stdio: 'pipe' }, + ]), + 'should verify only the current tag within the post-publish window' + ) + // The failed verification runs until its command timeout. + if (mode === 'attestation-past-deadline') { + now += args[2].timeout + throw Error('no attestations for tag') + } + if ( + mode === 'attestation-never' || + (mode === 'attestation-late' && verifies <= 2) + ) + throw Error('no attestations for tag') + if (mode === 'attestation-malformed') return Buffer.from('{}') + return Buffer.from( + JSON.stringify({ verificationResult: { statement: statement() } }) + ) + } + const execute = () => + publish({ + env: { + GH_REPO: 'example/project', + GH_TOKEN: 'fixture-token', + RELEASE_TAG: tag, + RELEASE_PUBLICATION_ENABLED: + mode === 'publication-disabled' ? 'false' : 'true', + SOURCE_COMMIT: 'a'.repeat(40), + }, + root, + fetch, + execFileSync, + now: () => now, + sleep: async (delay) => { + waits.push(delay) + now += delay + }, + log: (...args) => logs.push(args.join(' ')), + warn: () => {}, + }) + const successful = [ + 'success', + 'idle-socket', + 'stable', + 'identity-late', + 'identity-transient', + 'large-sbom-output', + 'attestation-late', + ].includes(mode) + // Post-publish failures name the stage and keep the last failure. + const exhausted = (label, cause) => (error) => + error.message === `${label} failed; the release is already published` && + cause.test(error.cause?.message) + const expectedError = { + 'non-semver-tag': /Invalid repository or tag/, + 'build-metadata-tag': /Invalid repository or tag/, + 'leading-zero-tag': /Invalid repository or tag/, + 'upload-size': /Upload mismatch/, + 'bundle-verification-failure': /Signature verification failed/, + 'bundle-sbom-mismatch': /Signed SBOM differs from the shipped SPDX/, + 'bundle-sbom-duplicate': /Signed SBOM differs from the shipped SPDX/, + 'draft-page-2': /already exists/, + 'symlink-input': /Invalid file/, + 'upload-digest': /Upload mismatch/, + 'signed-extra': /Unexpected artifact contents/, + 'created-wrong-tag': /Invalid created release identity/, + 'created-not-draft': /Invalid created release identity/, + 'created-not-prerelease': /Invalid created release identity/, + mutable: /identity or immutability mismatch/, + 'wrong-id': /identity or immutability mismatch/, + 'published-wrong-id': /identity or immutability mismatch/, + 'published-wrong-tag': /identity or immutability mismatch/, + 'published-draft': /identity or immutability mismatch/, + 'published-prerelease-flipped': /identity or immutability mismatch/, + 'prerelease-mismatch': /Invalid created release identity/, + 'identity-never': exhausted('Published release read', /503/), + 'attestation-never': exhausted( + 'Release attestation verification', + /no attestations/ + ), + 'attestation-past-deadline': exhausted( + 'Release attestation verification', + /no attestations/ + ), + 'attestation-malformed': /Malformed release attestation statement/, + 'attestation-wrong-tag-shape': + /Malformed release attestation statement/, + 'attestation-wrong-repository': + /Malformed release attestation statement/, + 'attestation-wrong-source': /Invalid release attestation subjects/, + 'attestation-duplicate-subject': /Invalid release attestation subjects/, + 'attestation-wrong-tag': /identity or assets mismatch/, + 'attestation-wrong-id': /identity or assets mismatch/, + 'attestation-wrong-assets': /identity or assets mismatch/, + 'attestation-missing-asset': /identity or assets mismatch/, + 'attestation-extra-asset': /identity or assets mismatch/, + }[mode] + if (successful) await execute() + else await assert.rejects(execute, expectedError, mode) + assert.deepEqual(violations, [], mode) + if ( + [ + 'draft', + 'draft-page-2', + 'non-semver-tag', + 'build-metadata-tag', + 'leading-zero-tag', + 'annotated-tag', + 'wrong-tag-commit', + 'missing-tag', + 'malformed-tag', + 'publication-disabled', + 'api-error', + 'missing', + 'extra', + 'changed', + 'empty-bundle', + 'symlink-input', + 'signed-extra', + 'bundle-verification-failure', + 'bundle-sbom-mismatch', + 'bundle-sbom-duplicate', + ].includes(mode) + ) + assert.ok( + !calls.some(([method]) => ['POST', 'PATCH'].includes(method)), + mode + ) + if (mode.startsWith('bundle-')) + assert.ok( + !calls.some(([method]) => method !== 'gh'), + 'should call no API after a signing bundle fails' + ) + if (mode === 'draft-page-2') + assert.ok( + calls.some(([, path]) => + /\/releases\?per_page=100&page=2$/.test(path) + ), + 'should read the second release page' + ) + const bundleChecks = releaseAssets.length * 2 + if (mode === 'api-error' || mode === 'upload-error') + assert.equal( + calls.length, + bundleChecks + (mode === 'api-error' ? 2 : 4) + ) + if (mode.startsWith('created-')) + assert.ok( + !calls.some(([method, path]) => + String(path).split('?')[0].endsWith('/assets') + ), + 'should upload nothing to a release with the wrong identity' + ) + if (['upload-error', 'upload-digest', 'upload-size'].includes(mode)) + assert.ok(!calls.some(([method]) => method === 'PATCH'), mode) + // Recovery starts from the logged ID, so it is logged before the + // created identity is checked. + if (successful || mode.startsWith('created-')) + assert.ok(logs.includes('Created release ID: 42'), mode) + // Only failed reads wait; a returned result that fails a check, such + // as a mutable release or a wrong statement, stops at once. + assert.deepEqual( + waits, + { + 'identity-transient': [2000], + 'identity-never': [2000, 5000, 10000, 20000], + 'attestation-late': [2000, 5000], + 'attestation-never': [2000, 5000, 10000, 20000], + }[mode] ?? [], + mode + ) + assert.equal( + verifies, + { 'attestation-late': 3, 'attestation-never': 5 }[mode] ?? + (successful || mode.startsWith('attestation-') ? 1 : 0), + mode + ) + if (successful) { + assert.equal(uploaded.length, assets.length * 4 + 1) + // Two-space lines are what `shasum -a 256 --check` accepts. + assert.equal( + uploaded.find((item) => item.name === 'SHA256SUMS').bytes.toString(), + uploaded + .filter((item) => item.name !== 'SHA256SUMS') + .map((item) => `${hash(item.bytes)} ${item.name}\n`) + .join(''), + 'should publish checksums in the checker format' + ) + assert.equal( + calls.filter( + ([kind, args]) => kind === 'gh' && args[1][0] === 'attestation' + ).length, + bundleChecks + ) + assert.equal( + calls.filter( + ([method, path]) => method === 'POST' && path.endsWith('/releases') + ).length, + 1, + mode + ) + assert.equal( + calls.filter(([method]) => method === 'PATCH').length, + 1, + mode + ) + assert.ok( + logs.includes( + `Verified release: ${tag} signature and attestation statement match` + ), + mode + ) + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +}) +test('publisher entry point exits non-zero on failure', () => { + const root = mkdtempSync(join(tmpdir(), 'phase1-entry-')) + try { + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL('./publish.mjs', import.meta.url))], + { + cwd: root, + env: { + PATH: process.env.PATH, + GH_REPO: 'example/project', + RELEASE_TAG: 'v1.0.0', + SOURCE_COMMIT: 'a'.repeat(40), + RELEASE_PUBLICATION_ENABLED: 'false', + }, + encoding: 'utf8', + } + ) + assert.equal(result.status, 1, 'should fail the publication step') + assert.match(result.stderr, /not enabled by maintainers/) + assert.deepEqual(fs.readdirSync(root), [], 'should not stage any asset') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) +test('acquires the locked workspace graph before offline SBOM resolution', () => { + const script = readFileSync( + new URL('./phase1-build.sh', import.meta.url), + 'utf8' + ) + const acquisition = script.indexOf( + 'cargo metadata --locked --format-version 1 --filter-platform' + ) + assert.ok( + acquisition >= 0, + 'should populate the locked workspace dependency sources' + ) + assert.ok( + acquisition < script.indexOf('CARGO_NET_OFFLINE=true'), + 'should acquire before offline generation' + ) +}) +// SPDX conversion retains the binary root and conservative package inventory. +const binary = Buffer.from('fictional binary') +// Converter output: package entries only, before the binary root is added. +function spdxFixture() { + const component = { + name: 'example-package', + version: '1.0.0', + purl: 'pkg:npm/example-package@1.0.0', + } + const original = { + metadata: { component: { name: 'example.wasm' } }, + components: [component], + } + const document = { + spdxVersion: 'SPDX-2.3', + SPDXID: 'SPDXRef-DOCUMENT', + packages: [ + { + SPDXID: 'SPDXRef-Package', + name: component.name, + versionInfo: component.version, + externalRefs: [ + { referenceType: 'purl', referenceLocator: component.purl }, + ], + }, + ], + } + return { original, document } +} +test('SPDX rejects lost or ambiguous package identities', () => { + for (const mutate of [ + (d) => { + d.packages.push(d.packages[0]) + }, + (d) => { + d.packages.pop() + }, + (d) => { + d.packages[0].SPDXID = 'SPDXRef-path+file:///example' + }, + (d) => { + d.packages[0].versionInfo = '2.0.0' + }, + (d) => { + d.packages[0].externalRefs = [] + }, + (d) => { + d.spdxVersion = 'SPDX-2.2' + }, + ]) { + const { document, original } = spdxFixture() + mutate(document) + assert.throws( + () => completeSpdx(document, binary, original), + /SPDX (2\.3 document|package identity|package identifier|lost component)/ + ) + } +}) +test('SPDX completion rescans the final binary for planted operator values', () => { + const planted = Buffer.concat([binary, Buffer.from(sentinels[0])]) + const { original, document } = spdxFixture() + assert.throws( + () => completeSpdx(document, planted, original), + /operator configuration/ + ) +}) +test('completes real converter package output with a binary-bound root', () => { + const { original, document } = spdxFixture() + Object.assign(original.metadata.component, { + version: '1.0.0', + purl: 'pkg:cargo/example@1.0.0', + }) + assert.equal(completeSpdx(document, binary, original), document) + // Pin the whole described root and its DESCRIBES relationship. + assert.deepEqual(document.packages.at(-1), { + SPDXID: 'SPDXRef-Artifact', + name: 'example.wasm', + versionInfo: '1.0.0', + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: 'NOASSERTION', + licenseDeclared: 'NOASSERTION', + copyrightText: 'NOASSERTION', + externalRefs: [ + { + referenceCategory: 'PACKAGE-MANAGER', + referenceType: 'purl', + referenceLocator: 'pkg:cargo/example@1.0.0', + }, + ], + checksums: [{ algorithm: 'SHA256', checksumValue: hash(binary) }], + }) + assert.deepEqual(document.documentDescribes, ['SPDXRef-Artifact']) + assert.deepEqual(document.relationships, [ + { + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: 'SPDXRef-Artifact', + }, + ]) + assert.throws(() => completeSpdx(document, binary, original), /Duplicate/) +}) +test('release command line checks binaries, final bytes and SPDX output', () => { + const root = mkdtempSync(join(tmpdir(), 'phase1-cli-')) + const run = (...args) => + spawnSync( + process.execPath, + [fileURLToPath(new URL('./phase1.mjs', import.meta.url)), ...args], + { cwd: root, encoding: 'utf8' } + ) + const write = (name, bytes) => { + writeFileSync(join(root, name), bytes) + return join(root, name) + } + try { + const clean = write('clean', binary) + const planted = write( + 'planted', + Buffer.concat([binary, Buffer.from(sentinels.at(-1))]) + ) + assert.equal(run('check', clean).status, 0, 'should accept a clean binary') + const refused = run('check', clean, planted) + assert.equal(refused.status, 1, 'should refuse a planted binary') + assert.match(refused.stderr, /planted operator configuration/) + + const wasm = write('valid.wasm', Buffer.from('0061736d01000000', 'hex')) + const accepted = run('smoke', wasm, 'wasm32-wasip1') + assert.equal(accepted.status, 0, accepted.stderr) + assert.match(accepted.stdout, /SUCCESS: final-byte checks wasm32-wasip1/) + const invalid = run('smoke', clean, 'wasm32-wasip1') + assert.equal(invalid.status, 1, 'should refuse non-wasm final bytes') + assert.match(invalid.stderr, /wasm/) + + const { original, document } = spdxFixture() + original.metadata.properties = [ + { name: 'trusted-server:coverage', value: 'Fixture coverage.' }, + ] + const spdx = write('sbom.spdx.json', JSON.stringify(document)) + const inventory = write('combined.cdx.json', JSON.stringify(original)) + const completed = run('validate-spdx', spdx, clean, inventory) + assert.equal(completed.status, 0, completed.stderr) + const output = JSON.parse(readFileSync(spdx, 'utf8')) + assert.deepEqual(output.documentDescribes, ['SPDXRef-Artifact']) + assert.match(output.creationInfo.comment, /Fixture coverage\./) + write('sbom.spdx.json', JSON.stringify(document)) + const rescanned = run('validate-spdx', spdx, planted, inventory) + assert.equal(rescanned.status, 1, 'should rescan the final binary') + assert.match(rescanned.stderr, /planted operator configuration/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) +test('every canary the release build plants is a scanned sentinel', () => { + const script = readFileSync('scripts/release/phase1-build.sh', 'utf8') + const canary = 'phase1-[a-z0-9-]*canary[a-z0-9.-]*' + const exported = [ + ...script.matchAll(new RegExp(`^export (\\w+)=\\S*?(${canary})$`, 'gm')), + ].map(([, name, value]) => `${name}=${value}`) + const config = script.slice( + script.indexOf("cat > trusted-server.toml <<'CONFIG'\n"), + script.indexOf('\nCONFIG\n') + ) + const configured = [ + ...config.matchAll(new RegExp(`^(\\w+) = "\\S*?(${canary})"$`, 'gm')), + ].map(([, key, value]) => `${key}=${value}`) + assert.deepEqual( + exported, + [ + 'TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=phase1-origin-canary.example.com', + 'TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=phase1-secret-canary-32-characters', + 'EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=phase1-physical-store-canary', + 'FASTLY_SERVICE_ID=phase1-service-account-canary', + ], + 'should plant every deployment variable canary' + ) + assert.deepEqual( + configured, + [ + 'origin_url=phase1-origin-canary.example.com', + 'proxy_secret=phase1-secret-canary-32-characters', + 'policy=phase1-permission-policy-canary', + ], + 'should plant every config file canary' + ) + assert.deepEqual( + [ + ...new Set( + [...exported, ...configured].map((entry) => entry.split('=')[1]) + ), + ].sort(), + [...sentinels].sort(), + 'should scan for exactly the planted canaries' + ) + // The GNU builds run in a container that sees only the variables passed in. + for (const name of [ + 'TRUSTED_SERVER__RELEASE_BUILD', + 'TRUSTED_SERVER__GIT_VERSION', + ...exported.map((entry) => entry.split('=')[0]), + ]) + assert.match( + script, + new RegExp(`--env ${name} `), + `should pass ${name} into the GNU builder` + ) +}) +test('rejects invalid wasm, foreign ELF and GNU symbols above 2.34', () => { + const wasm = Buffer.from('0061736d01000000', 'hex') + checkReleaseFormat(wasm, 'wasm32-wasip1') + for (const bytes of [ + wasm.subarray(0, 7), + Buffer.alloc(8), + Buffer.from('0061736d02000000', 'hex'), + ]) + assert.throws(() => checkReleaseFormat(bytes, 'wasm32-wasip1'), /wasm/) + // A 64-bit little-endian ELF header with the given e_machine. + const elf = (machine = 0x3e, elfClass = 2) => { + const bytes = Buffer.alloc(64) + Buffer.from('7f454c46', 'hex').copy(bytes) + bytes[4] = elfClass + bytes[5] = 1 + bytes.writeUInt16LE(machine, 18) + return bytes + } + const gnu = 'x86_64-unknown-linux-gnu' + checkReleaseFormat(elf(), gnu, 'GLIBC_2.2.5 GLIBC_2.34') + for (const versions of [ + 'GLIBC_2.35', + 'GLIBC_3.0', + 'GLIBC_2.34.1', + 'GLIBC_ABI_DT_RELR', + ]) + assert.throws(() => checkReleaseFormat(elf(), gnu, versions), /GLIBC/) + // aarch64 (EM_AARCH64) and 32-bit ELF are refused for the x86_64 target. + for (const bytes of [elf(0xb7), elf(0x3e, 1), wasm]) + assert.throws(() => checkReleaseFormat(bytes, gnu, 'GLIBC_2.34'), /ELF/) + assert.throws( + () => checkReleaseFormat(elf(), 'x86_64-unknown-linux-musl'), + /Unsupported release target/ + ) +}) +test('smoke reads glibc version needs from the final Linux bytes', () => { + const root = mkdtempSync(join(tmpdir(), 'phase1-smoke-')) + try { + const bin = join(root, 'bin') + mkdirSync(bin) + // Report a glibc 2.35 need only when version information is requested. + writeFileSync( + join(bin, 'readelf'), + '#!/bin/sh\ncase " $* " in *" --version-info "*) echo "Name: GLIBC_2.35" ;; esac\n', + { mode: 0o755 } + ) + const header = Buffer.alloc(64) + Buffer.from('7f454c460201', 'hex').copy(header) + header.writeUInt16LE(0x3e, 18) + const asset = join(root, 'ts-x86_64-unknown-linux-gnu') + writeFileSync(asset, header) + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL('./phase1.mjs', import.meta.url)), + 'smoke', + asset, + 'x86_64-unknown-linux-gnu', + ], + { + cwd: root, + encoding: 'utf8', + env: { PATH: `${bin}:${process.env.PATH}` }, + } + ) + assert.equal(result.status, 1, 'should refuse a glibc 2.35 need') + assert.match(result.stderr, /GLIBC requirement exceeds 2\.34/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) +test('rejects non-Mach-O, wrong CPU and newer or absent macOS floor', () => { + const macho = (cpu = 0x0100000c, minimum = 0x000d0000) => { + const b = Buffer.alloc(56) + b.writeUInt32LE(0xfeedfacf, 0) + b.writeUInt32LE(cpu, 4) + b.writeUInt32LE(1, 16) + b.writeUInt32LE(24, 20) + b.writeUInt32LE(0x32, 32) + b.writeUInt32LE(24, 36) + b.writeUInt32LE(1, 40) + b.writeUInt32LE(minimum, 44) + return b + } + assert.doesNotThrow(() => checkReleaseFormat(macho(), 'aarch64-apple-darwin')) + assert.doesNotThrow(() => + checkReleaseFormat(macho(0x01000007), 'x86_64-apple-darwin') + ) + for (const b of [ + Buffer.alloc(8), + macho(0x01000007), + macho(0x0100000c, 0x000e0000), + macho().subarray(0, 40), + ]) + assert.throws( + () => checkReleaseFormat(b, 'aarch64-apple-darwin'), + /Mach-O|macOS/ + ) +}) +test('workflows build, sign and publish exactly the release assets and restrict their jobs', () => { + const names = [...releaseAssets].sort() + const build = readFileSync('.github/workflows/release-build.yml', 'utf8') + const release = readFileSync('.github/workflows/release.yml', 'utf8') + assert.deepEqual( + [...build.matchAll(/^ asset: (\S+)/gm)].map((m) => m[1]).sort(), + names + ) + const sign = build.split('\n sign:')[1] + for (const name of names) assert.equal(sign.split(name).length - 1, 1) + for (const prefix of ['release-', 'signed-']) + assert.deepEqual( + [ + ...release.matchAll( + new RegExp(' name: ' + prefix + '(\\S+)', 'g') + ), + ] + .map((m) => m[1]) + .sort(), + names + ) + const actualBuild = build.split('\n build:')[1].split('\n sign:')[0] + assert.doesNotMatch(actualBuild, /id-token|attestations:/) + // Signing and publication need a push of a `v*` tag and the enablement variable. + // Anchor to the job-level indentation so a matching step-level `if:` cannot satisfy it. + const gate = + "\n if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.RELEASE_PUBLICATION_ENABLED == 'true'\n" + assert.ok( + release.split('\n release:')[1]?.includes(gate), + 'should gate publication on a v* tag push and enablement' + ) + assert.ok( + build.split('\n sign:')[1]?.split('\n release:')[0].includes(gate), + 'should gate signing on a v* tag push and enablement' + ) + // Tag checks run before any build; only tag builds upload signing inputs. + const checks = build.split('\n checks:')[1].split('\n build:')[0] + for (const step of [ + 'Refuse non-SemVer release tags', + 'Refuse annotated release tags', + 'Refuse tags on commits outside every branch', + ]) + assert.ok( + checks.includes( + `\n - name: ${step}\n if: startsWith(github.ref, 'refs/tags/')\n` + ), + `should run on every tag: ${step}` + ) + assert.ok( + checks.includes( + '\n - run: node --test scripts/release/phase1.test.mjs\n' + ), + 'should run the release suite before the tag checks' + ) + // The build inherits only the toolchain paths and the source commit. + const clean = runBlock( + build, + 'Build and generate the build inventory in a clean environment' + ) + assert.match(clean, /^env -i PATH="\$PATH" /m) + assert.deepEqual( + [...clean.matchAll(/\b([A-Z_]+)="/g)].map((match) => match[1]), + [ + 'PATH', + 'HOME', + 'CARGO_HOME', + 'RUSTUP_HOME', + 'TMPDIR', + 'RUNNER_TEMP', + 'SOURCE_COMMIT', + ] + ) + // Provenance covers the binary and its SPDX document. + assert.match( + sign, + /subject-path: \|\n {12}release-assets\/\$\{\{ matrix\.asset \}\}\n {12}release-assets\/\$\{\{ matrix\.asset \}\}\.spdx\.json\n/ + ) + // The branch check needs every branch's history. + assert.match( + checks, + /- uses: actions\/checkout@v\d+\n {8}with:\n {10}persist-credentials: false\n {10}fetch-depth: 0\n/ + ) + assert.ok(actualBuild.startsWith('\n needs: checks\n')) + assert.match( + actualBuild, + /- uses: actions\/upload-artifact@v\d+\n {8}if: startsWith\(github\.ref, 'refs\/tags\/v'\)\n/ + ) + // Each job keeps exactly its own permissions. + const permissions = (job) => + job + .match(/\n {4}permissions:\n((?: {6}\S.*\n)+)/)[1] + .trim() + .split(/\s+/) + assert.deepEqual(permissions(checks), ['contents:', 'read']) + // A queued run must never cancel a publication in progress. + assert.match(release, /\n cancel-in-progress: false\n/) + assert.deepEqual(permissions(release.split('\n release:')[1]), [ + 'contents:', + 'write', + ]) +}) +test('release build keeps locked builds, pinned generators and final integrity checks', () => { + const script = readFileSync('scripts/release/phase1-build.sh', 'utf8') + const commands = script.split('\n').filter((line) => !/^\s*#/.test(line)) + // Under bash 3.2 (macOS /bin/bash) a failing standalone [[ ]] does not stop + // set -e, and CI runs this suite on Linux, so each one must exit explicitly. + for (const line of commands.filter((line) => line.startsWith('[['))) + assert.match(line, /\]\] \|\| \{ .+; exit 1; \}$/, line) + const builds = commands.filter((line) => /\bcargo build\b/.test(line)) + assert.equal( + builds.length, + 2, + 'should build in the GNU container and natively' + ) + for (const line of builds) assert.match(line, /--locked/) + // Native binaries drop line-level debug info in both build paths. + assert.match( + script, + /if \[\[ "\$build_target" != wasm32-wasip1 \]\]; then\n {2}export CARGO_PROFILE_RELEASE_STRIP=debuginfo\nfi\n/ + ) + assert.match(script, /--env CARGO_PROFILE_RELEASE_STRIP /) + assert.ok( + script.indexOf('export CARGO_PROFILE_RELEASE_STRIP=debuginfo') < + script.search(/\bcargo build\b/), + 'should set the strip before either build' + ) + // The copied release bytes are scanned and smoke-checked before any + // inventory is generated for them. + const step = (command) => { + const index = commands.indexOf(command) + assert.ok(index >= 0, `should run: ${command}`) + return index + } + const copied = step( + 'cp "$CARGO_TARGET_DIR/$build_target/release/$executable" "release-assets/$release_asset"' + ) + const checked = step( + 'node scripts/release/phase1.mjs check "release-assets/$release_asset"' + ) + const smoked = step( + 'node scripts/release/phase1.mjs smoke "release-assets/$release_asset" "$build_target"' + ) + assert.ok( + copied < checked && + checked < smoked && + smoked < commands.findIndex((line) => line.startsWith('tool_root=')), + 'should check and smoke the copied binary before generating inventories' + ) + // The published SPDX gains its binary-bound root and is validated only by + // the completion step after conversion. + const merged = step('node scripts/release/phase1.mjs merge \\') + const converted = commands.findIndex((line) => + line.startsWith('"$tool_root/converter" convert ') + ) + const completed = step('node scripts/release/phase1.mjs validate-spdx \\') + assert.ok( + smoked < merged && merged < converted && converted < completed, + 'should merge, convert, then complete and validate the SPDX' + ) + assert.match(script, /cargo install \\\n[^\n]*--locked/) + assert.match(script, /cargo metadata --locked/) + // The generator defaults to the host target; inventory the built target and + // the whole installed npm build tree. + step( + ' --manifest-path "crates/$build_package/Cargo.toml" --target "$build_target" \\' + ) + step( + 'npm --prefix crates/trusted-server-js/lib sbom --sbom-format cyclonedx > "$tool_root/npm.cdx.json"' + ) + // Generator crate and every converter binary are checksum-pinned before use. + assert.match( + script, + /printf '%s {2}%s\\n' [a-f0-9]{64} "\$tool_root\/generator\.crate" \| shasum -a 256 --check\n/ + ) + assert.equal((script.match(/converter_sha=[a-f0-9]{64} ;;/g) ?? []).length, 3) + assert.match( + script, + /printf '%s {2}%s\\n' "\$converter_sha" "\$tool_root\/converter" \| shasum -a 256 --check\n/ + ) + // The released bytes must be unchanged since checks ran, and the build must + // not have rewritten the lockfiles or manifest. + const tail = commands.filter(Boolean).slice(-2) + assert.deepEqual(tail, [ + 'shasum -a 256 --check "$RUNNER_TEMP/binary.sha256"', + 'git diff --exit-code -- Cargo.lock crates/trusted-server-js/lib/package-lock.json edgezero.toml', + ]) +}) +test('release preflight rejects inherited controls in an isolated script fixture', () => { + const root = mkdtempSync(join(tmpdir(), 'phase1-preflight-')) + const script = join(root, 'scripts/release/phase1-build.sh') + const bin = join(root, 'bin'), + marker = join(root, 'tools-ran') + const source = 'a'.repeat(40) + const args = [ + 'trusted-server-cli', + 'x86_64-unknown-linux-gnu', + 'ts-x86_64-unknown-linux-gnu', + ] + try { + mkdirSync(join(root, 'scripts/release'), { recursive: true }) + mkdirSync(bin) + fs.copyFileSync('scripts/release/phase1-build.sh', script) + const stub = (name, body) => + writeFileSync(join(bin, name), '#!/bin/sh\n' + body + '\n', { + mode: 0o755, + }) + stub( + 'git', + `test "$*" = "rev-parse HEAD" || exit 96; printf '%s\\n' "$PREFLIGHT_FIXTURE_COMMIT"` + ) + stub('printenv', 'exec /usr/bin/printenv "$@"') + // The cleanup trap removes the partly written config. + stub('rm', 'exec /bin/rm "$@"') + for (const tool of [ + 'cat', + 'node', + 'npm', + 'docker', + 'cargo', + 'rustup', + 'curl', + ]) + stub(tool, `printf '%s\\n' "$0" >> "$PREFLIGHT_TOOL_LOG"; exit 97`) + const execute = (extra) => { + try { + execFileSync('/bin/bash', [script, ...args], { + cwd: root, + stdio: 'pipe', + env: { + PATH: bin, + RUNNER_TEMP: root, + SOURCE_COMMIT: source, + PREFLIGHT_FIXTURE_COMMIT: source, + PREFLIGHT_TOOL_LOG: marker, + ...extra, + }, + }) + assert.fail('should stop before any real tool executes') + } catch (error) { + return error + } + } + for (const variable of [ + 'TSJS_SKIP_BUILD', + 'TSJS_PREBUILT_DIR', + 'TSJS_TEST', + ]) { + const error = execute({ [variable]: '' }) + assert.equal(error.status, 1) + assert.match(error.stderr.toString(), new RegExp('Refusing ' + variable)) + assert.equal(fs.existsSync(marker), false) + } + for (const invalid of ['0'.repeat(40), 'A'.repeat(40), 'invalid']) + assert.equal(execute({ SOURCE_COMMIT: invalid }).status, 1) + assert.equal(fs.existsSync(marker), false) + assert.equal( + execute({}).status, + 97, + 'should reach the first safe tool stub when preflight passes' + ) + assert.equal(readFileSync(marker, 'utf8').trim(), join(bin, 'cat')) + assert.equal(fs.existsSync(join(root, 'trusted-server.toml')), false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) +test('SPDX identifies all producing tools and normalizes license spacing', () => { + const { document, original } = spdxFixture() + document.packages[0].licenseDeclared = 'Apache-2.0 OR MIT' + original.metadata.component.version = '1.2.3' + const output = completeSpdx(document, binary, original) + assert.equal( + output.packages.find((p) => p.SPDXID === 'SPDXRef-Artifact').versionInfo, + '1.2.3' + ) + assert.equal(output.packages[0].licenseDeclared, 'Apache-2.0 OR MIT') + assert.ok(output.creationInfo.creators.includes('Tool: cyclonedx-cli-0.33.1')) + assert.ok( + output.creationInfo.creators.includes('Tool: trusted-server-phase1') + ) +}) +test('SPDX creation timestamps use whole-second UTC without changing the instant second', () => { + for (const created of [ + '2026-01-01T12:34:56.9290415Z', + '2026-01-01T12:34:56.000Z', + '2026-01-01T12:34:56Z', + ]) { + const { document, original } = spdxFixture() + document.creationInfo = { created, creators: ['Tool: example-converter'] } + const output = completeSpdx(document, binary, original) + assert.equal(output.creationInfo.created, '2026-01-01T12:34:56Z') + assert.ok(output.creationInfo.creators.includes('Tool: example-converter')) + } +}) + +test('release build cleans owned config and inventory on success and failure', () => { + for (const mode of [ + 'success', + 'config-write-failure', + 'build-failure', + 'generator-failure', + 'converter-failure', + 'existing-config', + 'existing-inventory', + 'inventory-symlink', + 'config-symlink', + 'member-inventory', + 'existing-target', + 'nonempty-assets', + ]) { + const root = mkdtempSync(join(tmpdir(), 'phase1-cleanup-')) + const bin = join(root, 'bin') + const inventory = join(root, 'crates/trusted-server-cli/phase1.json') + // The generator also writes beside every other workspace member. + const member = join(root, 'crates/trusted-server-core/phase1.json') + const config = join(root, 'trusted-server.toml') + try { + mkdirSync(bin) + mkdirSync(join(root, 'crates/trusted-server-cli'), { recursive: true }) + mkdirSync(join(root, 'crates/trusted-server-core'), { recursive: true }) + const script = join(root, 'build.sh') + fs.copyFileSync('scripts/release/phase1-build.sh', script) + const stub = (name, body) => + writeFileSync(join(bin, name), '#!/bin/bash\nset -eu\n' + body + '\n', { + mode: 0o755, + }) + stub( + 'git', + 'if [[ "$1" == rev-parse ]]; then printf "%s\\n" "$SOURCE_COMMIT"; fi' + ) + stub('node', 'exit 0') + stub('npm', 'echo "{}"') + // Read the piped checksum line like the real shasum, so printf never + // writes into a closed pipe. + stub('shasum', '/bin/cat >/dev/null') + stub('tar', 'exit 0') + stub( + 'cat', + 'if [[ "$FIXTURE_MODE" == config-write-failure ]]; then printf partial; exit 97; fi; exec /bin/cat "$@"' + ) + stub( + 'cargo', + `case "$1" in + build) + [[ "$FIXTURE_MODE" != build-failure ]] || exit 97 + mkdir -p "$CARGO_TARGET_DIR/aarch64-apple-darwin/release" + printf binary > "$CARGO_TARGET_DIR/aarch64-apple-darwin/release/ts" ;; + install) + mkdir -p "$RUNNER_TEMP/phase1-cyclonedx/install/bin" + printf '%s\\n' '#!/bin/bash' 'printf "{}" > crates/trusted-server-cli/phase1.json' 'printf "{}" > crates/trusted-server-core/phase1.json' 'if [[ "$FIXTURE_MODE" == generator-failure ]]; then exit 98; fi' > "$RUNNER_TEMP/phase1-cyclonedx/install/bin/cargo-cyclonedx" + chmod +x "$RUNNER_TEMP/phase1-cyclonedx/install/bin/cargo-cyclonedx" ;; + metadata) echo '{}' ;; + *) exit 96 ;; + esac` + ) + stub( + 'curl', + `while [[ "$1" != --output ]]; do shift; done + printf '%s\\n' '#!/bin/bash' 'if [[ "$FIXTURE_MODE" == converter-failure ]]; then exit 99; fi' > "$2"` + ) + if (mode === 'existing-config') writeFileSync(config, 'operator config') + if (mode === 'config-symlink') + fs.symlinkSync('missing-user-config', config) + if (mode === 'existing-inventory') + writeFileSync(inventory, 'operator inventory') + if (mode === 'inventory-symlink') + fs.symlinkSync('missing-user-file', inventory) + if (mode === 'member-inventory') + fs.symlinkSync('missing-user-file', member) + if (mode === 'existing-target') mkdirSync(join(root, 'phase1-target')) + if (mode === 'nonempty-assets') { + mkdirSync(join(root, 'release-assets')) + writeFileSync(join(root, 'release-assets/stale'), 'stale') + } + let error + try { + execFileSync( + '/bin/bash', + [ + script, + 'trusted-server-cli', + 'aarch64-apple-darwin', + 'ts-aarch64-apple-darwin', + ], + { + cwd: root, + stdio: 'pipe', + env: { + PATH: `${bin}:/usr/bin:/bin`, + RUNNER_TEMP: root, + SOURCE_COMMIT: 'a'.repeat(40), + FIXTURE_MODE: mode, + }, + } + ) + } catch (failure) { + error = failure + } + const expectedStatus = + { + success: undefined, + 'config-write-failure': 97, + 'build-failure': 97, + 'generator-failure': 98, + 'converter-failure': 99, + }[mode] ?? (mode === 'success' ? undefined : 1) + assert.equal(error?.status, expectedStatus, mode) + if (mode === 'config-symlink') + assert.equal(fs.readlinkSync(config), 'missing-user-config') + assert.equal( + fs.existsSync(config), + mode === 'existing-config', + `${mode}: config cleanup` + ) + if (mode === 'existing-config') + assert.equal(readFileSync(config, 'utf8'), 'operator config') + if (mode === 'existing-inventory') + assert.equal(readFileSync(inventory, 'utf8'), 'operator inventory') + else if (mode === 'inventory-symlink') + assert.equal(fs.readlinkSync(inventory), 'missing-user-file') + else + assert.equal( + fs.existsSync(inventory), + false, + `${mode}: inventory cleanup` + ) + if (mode === 'member-inventory') + assert.equal(fs.readlinkSync(member), 'missing-user-file') + else + assert.equal( + fs.existsSync(member), + false, + `${mode}: member inventory cleanup` + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +}) +test('smoke accepts only the release build facts in ts --version', () => { + const manifest = '[workspace.package]\nversion = "1.2.3"\n' + const source = 'a'.repeat(40) + const target = 'x86_64-unknown-linux-gnu' + const facts = `ts package=1.2.3 source=${source} target=${target} variant=default` + checkCliVersion(`${facts}\n`, manifest, source, target) + for (const output of [ + `ts package=1.2.3 source=unknown target=${target} variant=default`, + `ts package=1.2.4 source=${source} target=${target} variant=default`, + `ts package=1.2.3 source=${source} target=aarch64-apple-darwin variant=default`, + `${facts} extra`, + 'ts 1.2.3', + ]) + assert.throws( + () => checkCliVersion(output, manifest, source, target), + /Unexpected CLI version/, + output + ) +}) +// Runs a workflow step's shell with stub tools. GitHub runs steps without +// `shell:` as `bash -e {0}`; this helper adds pipefail, which is stricter, and +// no step depends on it. +const runStep = (workflow, step, stubs, env) => { + const bin = mkdtempSync(join(tmpdir(), 'phase1-step-')) + try { + for (const [name, body] of Object.entries(stubs)) + writeFileSync(join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }) + return spawnSync( + 'bash', + [ + '--noprofile', + '--norc', + '-eo', + 'pipefail', + '-c', + runBlock(workflow, step), + ], + { + cwd: bin, + encoding: 'utf8', + env: { PATH: `${bin}:${process.env.PATH}`, ...env }, + } + ) + } finally { + rmSync(bin, { recursive: true, force: true }) + } +} +test('build checks refuse a tag reference that is not a lightweight tag at the commit', () => { + const workflow = readFileSync('.github/workflows/release-build.yml', 'utf8') + const commit = 'a'.repeat(40) + for (const [reference, status] of [ + [`commit ${commit}`, 0], + [`tag ${commit}`, 1], + [`commit ${'b'.repeat(40)}`, 1], + [null, 1], + ]) { + const result = runStep( + workflow, + 'Refuse annotated release tags', + { + gh: + reference === null + ? 'echo "HTTP 404: Not Found" >&2; exit 1' + : `printf '%s\\n' '${reference}'`, + }, + { + GITHUB_REPOSITORY: 'example/project', + GITHUB_SHA: commit, + RELEASE_TAG: 'v1.2.3', + } + ) + assert.equal(result.status, status, `${reference}: ${result.stderr}`) + if (reference?.startsWith('tag')) + assert.match(result.stdout, /::error::Release tags must be lightweight/) + } +}) +test('build checks refuse a tag the publisher would reject', () => { + const workflow = readFileSync('.github/workflows/release-build.yml', 'utf8') + const script = runBlock(workflow, 'Refuse non-SemVer release tags') + for (const [tag, status] of [ + ['v1.2.3', 0], + ['v1.2.3-rc.1', 0], + ['v1.2', 1], + ['v1.2.3+build.1', 1], + ]) { + const result = spawnSync('bash', ['-e', '-c', script], { + encoding: 'utf8', + env: { PATH: process.env.PATH, RELEASE_TAG: tag }, + }) + assert.equal(result.status, status, `${tag}: ${result.stderr}`) + } +}) +test('build checks refuse a tagged commit that is on no branch', () => { + const workflow = readFileSync('.github/workflows/release-build.yml', 'utf8') + const commit = 'a'.repeat(40) + for (const [branches, status] of [ + [' origin/main', 0], + [' origin/main\n origin/release/v1', 0], + ['', 1], + [null, 1], + ]) { + const result = runStep( + workflow, + 'Refuse tags on commits outside every branch', + { + // Answer only the exact containment query for the tagged commit. + git: + branches === null + ? 'echo "fatal: malformed object name" >&2; exit 129' + : `test "$*" = "branch -r --contains ${commit}" || exit 97; printf '%s' '${branches}'`, + }, + { GITHUB_SHA: commit } + ) + // A failing git query stops the step with git's own status. + if (branches === null) + assert.notEqual(result.status, 0, 'should stop when git fails') + else assert.equal(result.status, status, `${branches}: ${result.stderr}`) + if (branches === '') + assert.match( + result.stdout, + new RegExp(`::error::Push ${commit} to a branch before tagging it`) + ) + } +}) +test('publisher step refuses a Node other than the pinned version before publishing', () => { + const release = readFileSync('.github/workflows/release.yml', 'utf8') + const step = 'Verify handoff, create once, publish by release ID and verify' + assert.match( + release.split(`- name: ${step}`)[1], + /\n {10}NODE_VERSION: \$\{\{ steps\.tools\.outputs\.node \}\}\n/, + 'should compare against the Node version the job installed' + ) + for (const [installed, status] of [ + ['v24.20.0', 0], + ['v20.20.2', 1], + ]) { + const result = runStep( + release, + step, + { + gh: 'echo "gh version 2.102.0 (2026-01-01)"', + node: `if [ "$1" = --version ]; then echo ${installed}; else echo published; fi`, + }, + { NODE_VERSION: '24.20.0' } + ) + assert.equal(result.status, status, `${installed}: ${result.stderr}`) + assert.equal( + result.stdout.includes('published'), + status === 0, + `${installed}: should run the publisher only on the pinned Node` + ) + } +}) diff --git a/scripts/release/publish.mjs b/scripts/release/publish.mjs new file mode 100644 index 0000000000..6c9b919eca --- /dev/null +++ b/scripts/release/publish.mjs @@ -0,0 +1,376 @@ +// Phase 1 release publisher. The release job runs this file from the tagged +// commit; it never checks out or executes build inputs. +import * as fs from 'node:fs' +import { createHash } from 'node:crypto' +import { execFileSync as nodeExecFileSync } from 'node:child_process' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { isDeepStrictEqual } from 'node:util' + +export const releaseAssets = [ + 'trusted-server-fastly.wasm', + 'ts-aarch64-apple-darwin', + 'ts-x86_64-apple-darwin', + 'ts-x86_64-unknown-linux-gnu', +] + +// Strict SemVer 2.0.0 with a `v` prefix. Phase 1 refuses build metadata +// (`+...`): its release-attestation subject URI form is unrehearsed. +const semverTag = + /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$/ + +/** + * Reports whether `tag` is an accepted Phase 1 release tag. + */ +export const isReleaseTag = (tag) => semverTag.test(tag) + +const notesTemplate = fileURLToPath( + new URL('release-notes.md', import.meta.url) +) + +const apiOrigin = 'https://api.github.com' // allow-domain: api.github.com +const uploadOrigin = 'https://uploads.github.com' // allow-domain: uploads.github.com + +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') + +/** + * Renders the release body from the checked-in template. + */ +export function releaseNotes(repo, tag) { + return fs + .readFileSync(notesTemplate, 'utf8') + .replaceAll('{{REPOSITORY}}', repo) + .replaceAll('{{TAG}}', tag) + .trimEnd() +} + +/** + * Checks a release attestation statement for this repository and source + * commit, and returns its asset subjects. + */ +function checkStatement(statement, repo, source) { + const predicate = statement?.predicate + if ( + statement?._type !== 'https://in-toto.io/Statement/v1' || + statement.predicateType !== 'https://in-toto.io/attestation/release/v0.2' || + predicate?.repository !== repo || + typeof predicate.tag !== 'string' || + !predicate.tag || + !/^[1-9][0-9]*$/.test(predicate.databaseId) || + !Array.isArray(statement.subject) + ) + throw Error('Malformed release attestation statement') + const sources = statement.subject.filter( + (subject) => subject.uri === `pkg:github/${repo}@${predicate.tag}` + ) + const subjects = statement.subject.filter( + (subject) => !sources.includes(subject) + ) + if ( + sources.length !== 1 || + sources[0].digest?.sha1 !== source || + Object.keys(sources[0].digest).length !== 1 || + !subjects.length || + subjects.some( + (subject) => + typeof subject.name !== 'string' || + !subject.name || + !/^[a-f0-9]{64}$/.test(subject.digest?.sha256) || + Object.keys(subject.digest).length !== 1 + ) || + new Set(subjects.map((subject) => subject.name)).size !== subjects.length + ) + throw Error('Invalid release attestation subjects') + return { statement, subjects } +} + +/** + * Verifies the signing handoff and its bundles, creates one draft, uploads, + * publishes it as an immutable release and verifies the published result. + * + * Every dependency with an outside effect is injected so tests can drive the + * real program. + */ +export async function publish({ + env, + root = process.cwd(), + fetch = globalThis.fetch, + execFileSync = nodeExecFileSync, + now = Date.now, + sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay)), + log = console.log, + warn = console.warn, +}) { + const { + GH_REPO: repo, + RELEASE_TAG: tag, + GH_TOKEN: token, + SOURCE_COMMIT: source, + } = env + const version = semverTag.exec(tag ?? '') + if (!/^[\w-]+\/[\w.-]+$/.test(repo ?? '') || !version) + throw Error('Invalid repository or tag') + if (!/^[a-f0-9]{40}$/.test(source ?? '')) throw Error('Invalid source commit') + if (env.RELEASE_PUBLICATION_ENABLED !== 'true') + throw Error('Release publication is not enabled by maintainers') + const prerelease = version[4] !== undefined + const make_latest = prerelease ? 'false' : 'legacy' + const path = (...parts) => join(root, ...parts) + const regular = (file) => { + const stat = fs.lstatSync(file) + if (!stat.isFile() || stat.size === 0) throw Error(`Invalid file: ${file}`) + return fs.readFileSync(file) + } + const exact = (dir, expected) => { + if ( + JSON.stringify(fs.readdirSync(dir).sort()) !== + JSON.stringify([...expected].sort()) + ) + throw Error(`Unexpected artifact contents: ${dir}`) + } + fs.mkdirSync(path('release-assets')) + const files = [] + for (const name of releaseAssets) { + const build = path('build', name) + const signed = path('signed', name) + const inputs = [name, `${name}.spdx.json`] + const bundles = [ + `${name}.provenance.sigstore.json`, + `${name}.sbom.sigstore.json`, + ] + exact(build, inputs) + exact(signed, [...bundles, 'SHA256SUMS']) + const receipt = [ + ...inputs.map((file) => [build, file]), + ...bundles.map((file) => [signed, file]), + ] + .map(([dir, file]) => { + const bytes = regular(join(dir, file)) + fs.writeFileSync(path('release-assets', file), bytes) + files.push(file) + return `${hash(bytes)} ${file}\n` + }) + .join('') + if (regular(join(signed, 'SHA256SUMS')).toString() !== receipt) + throw Error('Signing handoff digest mismatch') + } + fs.writeFileSync( + path('release-assets', 'SHA256SUMS'), + files + .map( + (file) => `${hash(regular(path('release-assets', file)))} ${file}\n` + ) + .join('') + ) + files.push('SHA256SUMS') + const asset = (file) => regular(path('release-assets', file)) + // Verify every signing bundle before anything is created: a defective bundle + // would otherwise become a permanent part of the immutable release. Nothing + // exists yet, so a failure here is safe to rerun. + for (const name of releaseAssets) + for (const [kind, predicate] of [ + ['provenance', 'https://slsa.dev/provenance/v1'], + ['sbom', 'https://spdx.dev/Document/v2.3'], + ]) { + const verified = JSON.parse( + execFileSync( + 'gh', + [ + 'attestation', + 'verify', + path('release-assets', name), + '--repo', + repo, + '--bundle', + path('release-assets', `${name}.${kind}.sigstore.json`), + '--source-ref', + `refs/tags/${tag}`, + '--source-digest', + source, + '--signer-digest', + source, + '--deny-self-hosted-runners', + '--predicate-type', + predicate, + '--signer-workflow', + `${repo}/.github/workflows/release-build.yml`, + '--format', + 'json', + ], + // The SBOM result embeds the whole SPDX document, which exceeds + // Node's 1 MiB default output buffer. + { timeout: 60000, stdio: 'pipe', maxBuffer: 256 * 1024 * 1024 } + ) + ) + // The signed SBOM predicate must be the SPDX document shipped beside it. + if ( + kind === 'sbom' && + (!Array.isArray(verified) || + verified.length !== 1 || + !isDeepStrictEqual( + verified[0]?.verificationResult?.statement?.predicate, + JSON.parse(asset(`${name}.spdx.json`)) + )) + ) + throw Error(`Signed SBOM differs from the shipped SPDX: ${name}`) + } + // Close API connections before synchronous gh checks can leave pooled + // sockets idle. Every read is time-bounded. Mutations are single-shot and + // rely on the job timeout: a client-side abort cannot cancel a create, + // upload or publish GitHub already accepted. + const api = async (endpoint, method = 'GET', body, timeout = 10000) => { + const response = await fetch(`${apiOrigin}/repos/${repo}/${endpoint}`, { + method, + headers: { + Connection: 'close', + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + }, + ...(method === 'GET' ? { signal: AbortSignal.timeout(timeout) } : {}), + ...(body ? { body: JSON.stringify(body) } : {}), + }) + if (!response.ok) + throw Object.assign( + Error(`GitHub API failed: ${method} ${endpoint} ${response.status}`), + { status: response.status } + ) + return response.json() + } + // Phase 1 uses the commit digest for both provenance and release membership. + // Annotated tags have a distinct tag-object digest, so reject them before + // creating a draft or uploading/publishing any release assets. + const tagRef = await api(`git/ref/tags/${encodeURIComponent(tag)}`) + if (tagRef.object?.type !== 'commit' || tagRef.object.sha !== source) + throw Error('Phase 1 requires a lightweight tag at SOURCE_COMMIT') + // List releases, including drafts. Never resume, overwrite or delete a + // matching tag. + for (let page = 1; ; page++) { + if (page > 1000) throw Error('Release listing exceeded bound') + const releases = await api(`releases?per_page=100&page=${page}`) + if (!Array.isArray(releases)) throw Error('Invalid release listing') + if (releases.some((release) => release.tag_name === tag)) + throw Error('Release or draft already exists') + if (releases.length < 100) break + } + const created = await api('releases', 'POST', { + tag_name: tag, + target_commitish: source, + name: tag, + draft: true, + prerelease, + make_latest, + body: releaseNotes(repo, tag), + }) + // Log the ID before checking it: recovery starts from this line. + log(`Created release ID: ${created.id}`) + if ( + !Number.isSafeInteger(created.id) || + created.id <= 0 || + created.tag_name !== tag || + created.draft !== true || + created.prerelease !== prerelease + ) + throw Error('Invalid created release identity') + const id = created.id + for (const file of files) { + const bytes = asset(file) + const response = await fetch( + `${uploadOrigin}/repos/${repo}/releases/${id}/assets?name=${encodeURIComponent(file)}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/octet-stream', + }, + body: bytes, + } + ) + if (!response.ok) throw Error(`Asset upload failed: ${file}`) + const uploaded = await response.json() + if ( + uploaded.name !== file || + uploaded.state !== 'uploaded' || + uploaded.size !== bytes.length || + uploaded.digest !== `sha256:${hash(bytes)}` + ) + throw Error(`Upload mismatch: ${file}`) + } + const published = await api(`releases/${id}`, 'PATCH', { + draft: false, + prerelease, + make_latest, + }) + // Post-publish reads can lag publication. Each read retries any failure at + // most five times within one shared three-minute window; a returned result + // that fails a check stops at once. + const deadline = now() + 180000 + const poll = async (label, limit, read) => { + let failure + for (const delay of [0, 2000, 5000, 10000, 20000]) { + if (now() + delay >= deadline) break + if (delay) { + warn(`${label}: ${failure.message}; retry in ${delay}ms`) + await sleep(delay) + } + try { + return await read(Math.max(1, Math.min(limit, deadline - now()))) + } catch (error) { + failure = error + } + } + throw Error(`${label} failed; the release is already published`, { + cause: failure, + }) + } + const release = await poll('Published release read', 10000, (timeout) => + api(`releases/${id}`, 'GET', undefined, timeout) + ) + if ( + published.id !== id || + release.id !== id || + release.tag_name !== tag || + release.draft !== false || + release.immutable !== true || + release.prerelease !== prerelease + ) + throw Error('Published release identity or immutability mismatch') + // GitHub creates the release attestation from the published release, so it + // can appear after the release. Its verified statement must name this + // release, commit and the exact uploaded asset digests. + const proof = await poll( + 'Release attestation verification', + 30000, + (timeout) => + JSON.parse( + execFileSync( + 'gh', + ['release', 'verify', '--repo', repo, '--format', 'json', '--', tag], + { timeout, stdio: 'pipe' } + ) + ) + ) + const { statement, subjects } = checkStatement( + proof?.verificationResult?.statement, + repo, + source + ) + if ( + statement.predicate.tag !== tag || + statement.predicate.databaseId !== String(id) || + subjects.length !== files.length || + subjects.some( + (subject) => + !files.includes(subject.name) || + subject.digest.sha256 !== hash(asset(subject.name)) + ) + ) + throw Error('Current release attestation identity or assets mismatch') + log('Verified release:', tag, 'signature and attestation statement match') +} + +if (import.meta.main) + publish({ env: process.env }).catch((error) => { + console.error(error) + process.exitCode = 1 + }) diff --git a/scripts/release/release-notes.md b/scripts/release/release-notes.md new file mode 100644 index 0000000000..33532cee5a --- /dev/null +++ b/scripts/release/release-notes.md @@ -0,0 +1,6 @@ +Verify these assets before use with the +[verification guide](https://github.com/{{REPOSITORY}}/blob/{{TAG}}/docs/guide/release-verification.md) +at this tag. + +Release notes stay editable after publication. Take the repository, tag and +source commit from your independent source review, never from these notes.