From d458f0a90611b65c03933c206cada7dd9b8df385 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 11 Nov 2026 00:21:06 +0530 Subject: [PATCH 1/4] Publish release binaries and the Axum image to GitHub Packages Mirror each published release into GHCR: trusted-server-cli per target and trusted-server-fastly as OCI artifacts that carry each binary with its SBOM and Sigstore bundles, after checking every file against SHA256SUMS. Tags are write-once and each pushed manifest is checked layer by layer. Build the Axum server as a non-root distroless image from the tagged source and attach GitHub build provenance to its digest. Axum gains TRUSTED_SERVER_LISTEN_ADDR so a container can listen outside loopback. Both jobs stay off until PACKAGE_PUBLICATION_ENABLED is true. --- .dockerignore | 16 ++ .github/workflows/format.yml | 2 +- .github/workflows/release.yml | 120 +++++++++ AGENTS.md | 2 +- crates/trusted-server-adapter-axum/Dockerfile | 50 ++++ .../trusted-server-adapter-axum/src/main.rs | 109 ++++++-- docs/guide/axum-dev.md | 32 +++ docs/guide/release-verification.md | 61 +++++ .../2026-11-11-release-packages-design.md | 57 ++++ scripts/release/packages.mjs | 216 +++++++++++++++ scripts/release/packages.test.mjs | 250 ++++++++++++++++++ 11 files changed, 893 insertions(+), 22 deletions(-) create mode 100644 .dockerignore create mode 100644 crates/trusted-server-adapter-axum/Dockerfile create mode 100644 docs/superpowers/specs/2026-11-11-release-packages-design.md create mode 100644 scripts/release/packages.mjs create mode 100644 scripts/release/packages.test.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..bc6dc229e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# Build context for crates/trusted-server-adapter-axum/Dockerfile. Keep the +# workspace sources; drop history, build output, local secrets and docs. +.git +**/target +**/node_modules +**/dist +.env* +!.env.example +trusted-server.toml +.ts-write-* +js-assets.toml +.edgezero +.claude +.wolf +docs +tinybird diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 0888cb6e60..6389ff5561 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -256,4 +256,4 @@ jobs: run: shellcheck scripts/release/phase1-build.sh scripts/release/bootstrap.sh scripts/release/build.sh scripts/release/build-js.sh - name: Test release producer scripts - run: node --test scripts/release/phase1.test.mjs + run: node --test scripts/release/phase1.test.mjs scripts/release/packages.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5c38bc6f0..1b7320f3b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,3 +103,123 @@ jobs: # would exit 0 without publishing. test "$(node --version)" = "v$NODE_VERSION" node publisher/scripts/release/publish.mjs + # Mirrors the published release into GHCR: one OCI artifact per binary with + # its SPDX SBOM and Sigstore bundles. Tags are write-once. + packages: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.PACKAGE_PUBLICATION_ENABLED == 'true' + needs: release + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + # Run only the package publisher and the asset list it imports. + - uses: actions/checkout@v5 + with: + path: publisher + persist-credentials: false + sparse-checkout: | + .tool-versions + scripts/release/publish.mjs + scripts/release/packages.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 and oras + env: + GH_VERSION: 2.102.0 + GH_SHA256: bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386 + ORAS_VERSION: 1.3.4 + ORAS_SHA256: f27adb935022d94df8dc77719c322dda592c78a0d57a6f7dcdd8d900b248c454 + run: | + set -euo pipefail + gh_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}/${gh_archive}" \ + --output "$RUNNER_TEMP/$gh_archive" + printf '%s %s\n' "$GH_SHA256" "$RUNNER_TEMP/$gh_archive" | sha256sum --check + tar -xzf "$RUNNER_TEMP/$gh_archive" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/gh_${GH_VERSION}_linux_amd64/bin" >> "$GITHUB_PATH" + oras_archive="oras_${ORAS_VERSION}_linux_amd64.tar.gz" + curl --fail --silent --show-error --location --retry 3 \ + "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/${oras_archive}" \ + --output "$RUNNER_TEMP/$oras_archive" + printf '%s %s\n' "$ORAS_SHA256" "$RUNNER_TEMP/$oras_archive" | sha256sum --check + mkdir "$RUNNER_TEMP/oras" + tar -xzf "$RUNNER_TEMP/$oras_archive" -C "$RUNNER_TEMP/oras" oras + echo "$RUNNER_TEMP/oras" >> "$GITHUB_PATH" + - name: Check the release, then push and verify each package + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + SOURCE_COMMIT: ${{ github.sha }} + PACKAGE_PUBLICATION_ENABLED: ${{ vars.PACKAGE_PUBLICATION_ENABLED }} + NODE_VERSION: ${{ steps.tools.outputs.node }} + run: | + set -euo pipefail + test "$(gh --version | head -1 | cut -d' ' -f3)" = 2.102.0 + test "$(oras version | sed -n 's/^Version: *//p')" = 1.3.4 + test "$(node --version)" = "v$NODE_VERSION" + printf '%s' "$GH_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + node publisher/scripts/release/packages.mjs + # Builds the Axum server image from the tagged source and pushes it to GHCR + # with GitHub build provenance. It is an ordinary build, outside the + # reproducible release cohort. + axum-image: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.PACKAGE_PUBLICATION_ENABLED == 'true' + needs: release + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Build, push and record the image digest + id: image + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + SOURCE_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/trusted-server-axum" + printf '%s' "$GH_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + if docker manifest inspect "$image:$RELEASE_TAG" > /dev/null 2>&1; then + echo "Refusing to overwrite $image:$RELEASE_TAG" >&2 + exit 1 + fi + docker buildx build \ + --file crates/trusted-server-adapter-axum/Dockerfile \ + --platform linux/amd64 \ + --provenance=false --sbom=false \ + --build-arg "TRUSTED_SERVER_VERSION=$RELEASE_TAG" \ + --label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \ + --label "org.opencontainers.image.revision=$SOURCE_COMMIT" \ + --label "org.opencontainers.image.version=$RELEASE_TAG" \ + --label "org.opencontainers.image.description=Trusted Server on Axum $RELEASE_TAG" \ + --tag "$image:$RELEASE_TAG" \ + --metadata-file "$RUNNER_TEMP/image.json" \ + --push . + digest="$(jq -r '."containerimage.digest"' "$RUNNER_TEMP/image.json")" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + test "$(docker buildx imagetools inspect "$image:$RELEASE_TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')" = "$digest" + echo "name=$image" >> "$GITHUB_OUTPUT" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + - name: Attest the image's build provenance + uses: actions/attest@v4 + with: + subject-name: ${{ steps.image.outputs.name }} + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true diff --git a/AGENTS.md b/AGENTS.md index 6f4ff34d04..9e36d9fc04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -377,7 +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 helpers: `node --test scripts/release/phase1.test.mjs && shellcheck scripts/release/phase1-build.sh scripts/release/bootstrap.sh scripts/release/build.sh scripts/release/build-js.sh` +16. Release producer helpers: `node --test scripts/release/phase1.test.mjs scripts/release/packages.test.mjs && shellcheck scripts/release/phase1-build.sh scripts/release/bootstrap.sh scripts/release/build.sh scripts/release/build-js.sh` 17. Native release producer tests and lint: `cargo test --package trusted-server-release --target --locked` and `cargo clippy --package trusted-server-release --target --all-targets --all-features --locked -- -D warnings` (CI uses `x86_64-unknown-linux-gnu`). 18. Native checked/ordinary JS release fixtures: `cargo test --package trusted-server-js --target --locked --test release_prebuilt` and `cargo clippy --package trusted-server-js --target --all-targets --all-features --locked -- -D warnings` (CI uses `x86_64-unknown-linux-gnu`). diff --git a/crates/trusted-server-adapter-axum/Dockerfile b/crates/trusted-server-adapter-axum/Dockerfile new file mode 100644 index 0000000000..249b91532c --- /dev/null +++ b/crates/trusted-server-adapter-axum/Dockerfile @@ -0,0 +1,50 @@ +# Trusted Server on Axum as a container image. +# +# Build from the repository root: +# docker build --file crates/trusted-server-adapter-axum/Dockerfile \ +# --build-arg TRUSTED_SERVER_VERSION=v1.2.3 --tag trusted-server-axum . +# +# The image is an ordinary build with build provenance only. It is not part of +# the reproducible release cohort that the binary release rebuild checks cover. + +# The tsjs build script runs npm; this is the `.tool-versions` Node. +FROM docker.io/library/node:24.20.0-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e AS node + +# Debian 12 builder and runtime share glibc 2.36. +FROM docker.io/library/rust:1.98.1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e AS build + +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm +RUN set -eu; \ + ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm; \ + ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx; \ + test "$(node --version)" = v24.20.0; \ + npm --version + +WORKDIR /src +COPY . . + +# The checkout has no `.git`, so the release tag names the embedded version. +ARG TRUSTED_SERVER_VERSION +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/src/target \ + set -eu; \ + if [ -n "${TRUSTED_SERVER_VERSION:-}" ]; then \ + export TRUSTED_SERVER__GIT_VERSION="$TRUSTED_SERVER_VERSION"; \ + fi; \ + host="$(rustc -vV | sed -n 's/^host: //p')"; \ + cargo build --release --locked --package trusted-server-adapter-axum \ + --bin trusted-server-axum --target "$host"; \ + install -D -m 0755 "target/$host/release/trusted-server-axum" \ + /out/trusted-server-axum + +# Distroless keeps glibc, CA certificates and a nonroot user, with no shell. +FROM gcr.io/distroless/cc-debian12:nonroot@sha256:9dac0a79194e45a7da0158a9c6da57b217585af0786db3845d1f0ec1a0dd182f + +COPY --from=build /out/trusted-server-axum /usr/local/bin/trusted-server-axum + +# Containers must accept connections from outside their own loopback. +ENV TRUSTED_SERVER_LISTEN_ADDR=0.0.0.0:8080 +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/usr/local/bin/trusted-server-axum"] diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 66d592e0b7..fd1f60e4d2 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,3 +1,5 @@ +use std::net::{Ipv4Addr, SocketAddr}; + use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; use edgezero_core::app::Hooks as _; use log::LevelFilter; @@ -9,6 +11,12 @@ const LOG_LEVEL_ENV: &str = "RUST_LOG"; /// Log level used when `RUST_LOG` is unset or unrecognized. const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info; +/// Environment variable carrying a full listen socket address. +const LISTEN_ADDR_ENV: &str = "TRUSTED_SERVER_LISTEN_ADDR"; + +/// Environment variable carrying a loopback listen port. +const PORT_ENV: &str = "PORT"; + #[allow(clippy::print_stderr)] fn main() { // `var_os` keeps a set but non-UTF-8 value visible, so it is rejected @@ -26,15 +34,21 @@ fn main() { eprintln!("warning: logger init failed: {e}"); } - let config = match port_from_env() { - // When PORT is set, bind to a specific address so integration tests - // can allocate a fresh OS port each run and avoid TIME_WAIT flakiness. - Some(port) => AxumDevServerConfig { - addr: std::net::SocketAddr::from(([127, 0, 0, 1], port)), + let listen = + std::env::var_os(LISTEN_ADDR_ENV).map(|value| value.to_string_lossy().into_owned()); + let port = std::env::var_os(PORT_ENV).map(|value| value.to_string_lossy().into_owned()); + let config = match listen_address(listen.as_deref(), port.as_deref()) { + Ok(Some(addr)) => AxumDevServerConfig { + addr, enable_ctrl_c: true, }, - // Normal development path: read bind address from axum.toml. - None => AxumDevServerConfig::default(), + Ok(None) => AxumDevServerConfig::default(), + // Silently falling back to another address would surprise tooling + // that expects the server where it asked for it. + Err(message) => { + eprintln!("error: {message}"); + std::process::exit(1); + } }; log::info!("Listening on http://{}", config.addr); @@ -65,21 +79,30 @@ fn parse_log_level(configured: Option<&str>) -> Result { } } -/// Read a port number from the `PORT` environment variable. +/// Selects the listen address from `TRUSTED_SERVER_LISTEN_ADDR` and `PORT`. /// -/// Returns `None` when the variable is unset. Exits non-zero if the value -/// is set but cannot be parsed — silently falling back to a different port -/// would surprise tooling that expects the server at the requested address. -#[allow(clippy::print_stderr)] -fn port_from_env() -> Option { - let raw = std::env::var("PORT").ok()?; - match raw.parse() { - Ok(port) => Some(port), - Err(e) => { - eprintln!("error: PORT env var '{raw}' is not a valid u16: {e}"); - std::process::exit(1); - } +/// `TRUSTED_SERVER_LISTEN_ADDR` takes a full socket address such as +/// `0.0.0.0:8080` or `[::]:8080`, which a container needs to accept outside +/// connections. Otherwise `PORT` binds the loopback address on that port, so +/// integration tests can allocate a fresh OS port each run. With neither set, +/// `Ok(None)` keeps the adapter's default loopback address. +/// +/// # Errors +/// +/// Returns a message naming the variable when a set value is not a valid +/// socket address or port. +fn listen_address(listen: Option<&str>, port: Option<&str>) -> Result, String> { + if let Some(raw) = listen { + return raw.parse().map(Some).map_err(|error| { + format!("{LISTEN_ADDR_ENV}={raw:?} is not a valid socket address: {error}") + }); } + let Some(raw) = port else { + return Ok(None); + }; + raw.parse::() + .map(|port| Some(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))) + .map_err(|error| format!("{PORT_ENV}={raw:?} is not a valid u16: {error}")) } #[cfg(test)] @@ -89,6 +112,52 @@ mod tests { #[test] fn crate_compiles() {} + #[test] + fn listen_address_prefers_the_full_socket_address() { + assert_eq!( + listen_address(Some("0.0.0.0:8080"), Some("9000")), + Ok(Some(SocketAddr::from(([0, 0, 0, 0], 8080)))), + "should bind every interface when the full address is set" + ); + assert_eq!( + listen_address(Some("[::]:8080"), None), + Ok(Some( + "[::]:8080".parse().expect("should parse IPv6 fixture") + )), + "should accept an IPv6 listen address" + ); + } + + #[test] + fn listen_address_keeps_port_on_loopback_and_default_when_unset() { + assert_eq!( + listen_address(None, Some("9000")), + Ok(Some(SocketAddr::from(([127, 0, 0, 1], 9000)))), + "should keep PORT on the loopback address" + ); + assert_eq!( + listen_address(None, None), + Ok(None), + "should keep the adapter default when nothing is set" + ); + } + + #[test] + fn listen_address_rejects_invalid_values_naming_the_variable() { + for (listen, port, variable) in [ + (Some("0.0.0.0"), None, LISTEN_ADDR_ENV), + (Some(""), Some("9000"), LISTEN_ADDR_ENV), + (None, Some("70000"), PORT_ENV), + ] { + let message = + listen_address(listen, port).expect_err("should reject an unusable listen setting"); + assert!( + message.starts_with(variable), + "should name {variable} in the error, got: {message}" + ); + } + } + #[test] fn parse_log_level_defaults_to_info_when_unset_or_blank() { assert_eq!( diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index 82c18cd5d8..d4b211c53f 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -45,6 +45,38 @@ rewrite its origin URL to the Axum listener, and remove the original URL. The exit trap stops the Axum process and stub origin, then removes the temporary config, local-push output, bodies, headers, and logs. +## Listen address + +Axum listens on `127.0.0.1:8787` by default. `PORT` keeps the loopback address +and changes only the port. `TRUSTED_SERVER_LISTEN_ADDR` takes a full socket +address, such as `0.0.0.0:8080` or `[::]:8080`, and wins over `PORT`. An invalid +value of either stops the process at startup instead of falling back. + +## Run the container image + +`crates/trusted-server-adapter-axum/Dockerfile` builds the server image from the +repository root. Releases with package publication enabled push it to GHCR as +`trusted-server-axum`; see +[Download from GitHub Packages](./release-verification#download-from-github-packages) +to verify it. The image runs as a non-root user without a shell, listens on +`0.0.0.0:8080`, and reads the same config and secret variables as the smoke +above: + +```bash +docker build --file crates/trusted-server-adapter-axum/Dockerfile \ + --tag trusted-server-axum . +docker run --rm --publish 8080:8080 \ + --env TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG \ + --env TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD \ + --env TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET \ + --env TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE \ + trusted-server-axum +``` + +`--env NAME` without a value passes that variable from your shell, so secrets +stay out of the command line. The support status above still applies inside the +container. + ## Logging Axum logs to standard output at `info` by default. Set `RUST_LOG` to a single diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index aae897be6c..dbf023dd3d 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -278,6 +278,61 @@ 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). +## Download from GitHub Packages + +When a maintainer enables package publication, each release also appears in +the repository's **Packages** list. These packages copy the release; they add no +new trust. Verify the files you pull exactly as you verify release downloads. + +| Package | Tag | Contents | +| ----------------------- | ---------------- | ------------------------------------------------------------------- | +| `trusted-server-cli` | `-` | `ts-`, its SPDX SBOM and both Sigstore bundles | +| `trusted-server-fastly` | `` | `trusted-server-fastly.wasm`, its SBOM, both bundles, rebuild check | +| `trusted-server-axum` | `` | Axum server container image for `linux/amd64` | + +The CLI and Fastly packages are OCI artifacts, not runnable images. Each file +is one layer, and each layer's digest is that file's SHA-256 from the release's +`SHA256SUMS`. The packages job downloads the published release, checks every +file against `SHA256SUMS`, then pushes. It never overwrites an existing tag. + +```bash +set -euo pipefail +# Take REPO, TAG and COMMIT from your independent source review. +REPO=Example-Org/trusted-server +TAG=v1.2.3 +COMMIT=0123456789abcdef0123456789abcdef01234567 +TARGET=x86_64-unknown-linux-gnu +OWNER=$(printf '%s' "${REPO%%/*}" | tr '[:upper:]' '[:lower:]') +asset="ts-$TARGET" +oras pull "ghcr.io/$OWNER/trusted-server-cli:$TAG-$TARGET" --output pulled +cd pulled +gh attestation verify "$asset" --repo "$REPO" --bundle "$asset.provenance.sigstore.json" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" \ + --deny-self-hosted-runners --predicate-type https://slsa.dev/provenance/v1 \ + --signer-workflow "$REPO/.github/workflows/release-build.yml" +gh release verify-asset "$TAG" "$asset" --repo "$REPO" +``` + +The final command confirms the pulled bytes are the published release asset. To +check the SBOM too, run the per-asset loop from +[Download and verify](#download-and-verify) in the `pulled` directory. `oras +pull` needs no login for a public package. + +The Axum image is an ordinary build of the tagged source. It is outside the +reproducible release cohort: no rebuild check, SBOM or release asset covers it, +only GitHub build provenance attached to the image digest. Verify it before +running it: + +```bash +image="ghcr.io/$OWNER/trusted-server-axum:$TAG" +gh attestation verify "oci://$image" --repo "$REPO" \ + --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" \ + --deny-self-hosted-runners --predicate-type https://slsa.dev/provenance/v1 \ + --signer-workflow "$REPO/.github/workflows/release.yml" +``` + +See [Axum development server](./axum-dev#run-the-container-image) for running it. + ## Deploy the verified Fastly wasm Keep the verified download unchanged. The ordinary source-build route does not @@ -514,6 +569,12 @@ commands below only inspect settings; this workflow applies none of them. 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. +- **Settings → Secrets and variables → Actions → Variables:** set + `PACKAGE_PUBLICATION_ENABLED` to `true` to mirror releases into GitHub + Packages. Without it, the `packages` and `axum-image` jobs are skipped and the + release is unaffected. After the first package publish, check each package's + visibility under the organization's **Packages** settings; make it public + only when the release is public. - 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 diff --git a/docs/superpowers/specs/2026-11-11-release-packages-design.md b/docs/superpowers/specs/2026-11-11-release-packages-design.md new file mode 100644 index 0000000000..ecbb8ffdac --- /dev/null +++ b/docs/superpowers/specs/2026-11-11-release-packages-design.md @@ -0,0 +1,57 @@ +# Release packages on GHCR + +## Intent + +The fork rehearsals publish a GitHub Release with 18 assets, and each run also +lists 12 internal workflow artifacts. Operators could not tell which files to +use. Releases now also appear in the repository's **Packages** list, with one +package per thing an operator runs, and the Axum server ships as a container +image. + +## Goals + +- Mirror each published release into GHCR as three packages: + `trusted-server-cli` (tag `-`), `trusted-server-fastly` + (tag ``) and `trusted-server-axum` (tag ``). +- Keep each CLI and Fastly package byte-identical to the release: every file is + one OCI layer whose digest is its `SHA256SUMS` entry. +- Link every package to the source repository and keep tags write-once. +- Ship Axum as a non-root container image with GitHub build provenance. + +## Non-goals + +- Package publication adds no trust. Verification stays the release + verification procedure, applied to pulled files. +- The Axum image does not join the reproducible release cohort: no rebuild + check, SBOM or release membership covers it. +- No `linux/arm64` image yet. +- The internal workflow artifacts are unchanged. + +## Approaches + +1. **OCI artifacts pushed by a publisher job after the release (chosen).** A + `packages` job downloads the published release by tag, checks it against + `SHA256SUMS`, then pushes with a pinned, checksum-verified `oras`. It reuses + the release's existing attestations; the files keep their digests. +2. **Push from the signing jobs.** Earlier, but gives signing jobs registry + authority and can publish packages for a release that later fails. +3. **Per-platform archives in the release.** Shortens the asset list but changes + the attested release format and every verification command. + +## Decisions + +- `PACKAGE_PUBLICATION_ENABLED=true` gates both new jobs, separately from + `RELEASE_PUBLICATION_ENABLED`; both need the `release` job to succeed. +- The publisher refuses drafts, any file outside `SHA256SUMS`, and existing + tags, and checks each pushed manifest's layers, order and annotations. +- The Axum image builds from the tagged source with the pinned Rust image and + `.tool-versions` Node (checksum-verified), runs on distroless `cc-debian12` + as `nonroot`, and listens on `0.0.0.0:8080` through the new + `TRUSTED_SERVER_LISTEN_ADDR` variable. `actions/attest` attaches build + provenance to the pushed image digest. +- Plain `docker buildx` and `oras` commands avoid new third-party actions. + +## Left to the builder + +Package visibility after the first publish is an organization setting; the +maintainer checklist covers it. diff --git a/scripts/release/packages.mjs b/scripts/release/packages.mjs new file mode 100644 index 0000000000..d62b568af3 --- /dev/null +++ b/scripts/release/packages.mjs @@ -0,0 +1,216 @@ +// Publishes a published GitHub Release's binaries as GHCR OCI artifacts. +// +// The packages job runs this file from the tagged commit after the release +// job. It downloads the release by tag, checks every file against the +// release's SHA256SUMS, and pushes each binary with its evidence files as one +// artifact. It never builds, signs or changes the release. +import * as fs from 'node:fs' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { join } from 'node:path' +import { isReleaseTag, releaseAssets } from './publish.mjs' + +const registry = 'ghcr.io' +const sigstoreBundle = 'application/vnd.dev.sigstore.bundle.v0.3+json' + +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') + +/** + * Returns the GHCR package and tag for one release binary. + */ +export function packageReference(owner, tag, asset) { + const namespace = owner.toLowerCase() + if (asset === 'trusted-server-fastly.wasm') + return `${registry}/${namespace}/trusted-server-fastly:${tag}` + const target = asset.match(/^ts-([a-z0-9_]+(?:-[a-z0-9_]+)+)$/)?.[1] + if (!target) throw Error(`No package for release asset ${asset}`) + return `${registry}/${namespace}/trusted-server-cli:${tag}-${target}` +} + +/** + * Lists the files one binary's artifact carries, with their media types. + */ +export function packageFiles(asset) { + const files = [ + { + name: asset, + mediaType: asset.endsWith('.wasm') + ? 'application/wasm' + : 'application/octet-stream', + }, + { name: `${asset}.spdx.json`, mediaType: 'application/spdx+json' }, + { name: `${asset}.provenance.sigstore.json`, mediaType: sigstoreBundle }, + { name: `${asset}.sbom.sigstore.json`, mediaType: sigstoreBundle }, + ] + if (asset === 'trusted-server-fastly.wasm') + files.push({ + name: `${asset}.rebuild-check.json`, + mediaType: 'application/json', + }) + return files +} + +/** + * Plans one artifact per release binary for `repo` at `tag`. + */ +export function packagePlan(repo, tag, commit) { + if (!isReleaseTag(tag)) throw Error(`Not a release tag: ${tag}`) + if (!/^[0-9a-f]{40}$/.test(commit)) throw Error('Invalid source commit') + const [owner] = repo.split('/') + const source = `https://github.com/${repo}` + return releaseAssets.map((asset) => ({ + asset, + reference: packageReference(owner, tag, asset), + artifactType: asset.endsWith('.wasm') + ? 'application/vnd.trusted-server.fastly.wasm.v1' + : 'application/vnd.trusted-server.cli.v1', + files: packageFiles(asset), + annotations: { + 'org.opencontainers.image.source': source, + 'org.opencontainers.image.revision': commit, + 'org.opencontainers.image.version': tag, + 'org.opencontainers.image.title': asset, + 'org.opencontainers.image.description': `${asset} from Trusted Server ${tag}, with its SPDX SBOM and Sigstore attestation bundles`, + }, + })) +} + +/** + * Checks a downloaded release directory against its SHA256SUMS. + * + * The directory must hold exactly the files SHA256SUMS lists plus SHA256SUMS + * itself, and every planned file must be listed. Returns name → sha256. + */ +export function checkDownload(directory, plan) { + const lines = fs + .readFileSync(join(directory, 'SHA256SUMS'), 'utf8') + .split('\n') + .filter(Boolean) + const sums = new Map() + for (const line of lines) { + const match = line.match(/^([0-9a-f]{64}) {2}([A-Za-z0-9._-]+)$/) + if (!match || sums.has(match[2])) + throw Error(`Malformed SHA256SUMS line: ${line}`) + sums.set(match[2], match[1]) + } + const present = fs.readdirSync(directory).sort() + const expected = [...sums.keys(), 'SHA256SUMS'].sort() + if (JSON.stringify(present) !== JSON.stringify(expected)) + throw Error('Downloaded release files differ from SHA256SUMS') + for (const [name, digest] of sums) { + const stat = fs.lstatSync(join(directory, name)) + if (!stat.isFile()) throw Error(`Not a regular file: ${name}`) + if (hash(fs.readFileSync(join(directory, name))) !== digest) + throw Error(`Digest mismatch: ${name}`) + } + for (const { files } of plan) + for (const { name } of files) + if (!sums.has(name)) throw Error(`Release lacks ${name}`) + return sums +} + +/** + * Checks a pushed manifest carries exactly the planned files and digests. + */ +export function checkManifest(manifest, entry, sums) { + const layers = manifest?.layers + if ( + manifest?.artifactType !== entry.artifactType || + !Array.isArray(layers) || + layers.length !== entry.files.length + ) + throw Error(`Unexpected manifest for ${entry.reference}`) + entry.files.forEach(({ name, mediaType }, index) => { + const layer = layers[index] + if ( + layer.mediaType !== mediaType || + layer.digest !== `sha256:${sums.get(name)}` || + layer.annotations?.['org.opencontainers.image.title'] !== name + ) + throw Error(`Layer ${name} differs in ${entry.reference}`) + }) + for (const [key, value] of Object.entries(entry.annotations)) + if (manifest.annotations?.[key] !== value) + throw Error(`Annotation ${key} differs in ${entry.reference}`) +} + +/** + * Reports whether a reference already resolves, keeping tags write-once. + */ +function exists(run, reference) { + try { + run('oras', ['manifest', 'fetch', '--descriptor', reference]) + return true + } catch { + return false + } +} + +/** + * Downloads the published release, checks it and pushes every artifact. + */ +export function publishPackages({ env, run = defaultRun, log = console.log }) { + const repo = env.GH_REPO + const tag = env.RELEASE_TAG + const commit = env.SOURCE_COMMIT + if (env.PACKAGE_PUBLICATION_ENABLED !== 'true') + throw Error('Package publication is not enabled') + const plan = packagePlan(repo, tag, commit) + const release = JSON.parse( + run('gh', [ + 'release', + 'view', + tag, + '--repo', + repo, + '--json', + 'tagName,isDraft', + ]) + ) + if (release.tagName !== tag || release.isDraft !== false) + throw Error(`Release ${tag} is not published`) + const directory = fs.mkdtempSync(join(env.RUNNER_TEMP ?? '/tmp', 'release-')) + run('gh', ['release', 'download', tag, '--repo', repo, '--dir', directory]) + const sums = checkDownload(directory, plan) + for (const entry of plan) + if (exists(run, entry.reference)) + throw Error(`Refusing to overwrite ${entry.reference}`) + for (const entry of plan) { + run( + 'oras', + [ + 'push', + entry.reference, + '--artifact-type', + entry.artifactType, + ...Object.entries(entry.annotations).flatMap(([key, value]) => [ + '--annotation', + `${key}=${value}`, + ]), + ...entry.files.map(({ name, mediaType }) => `${name}:${mediaType}`), + ], + { cwd: directory } + ) + const manifest = JSON.parse( + run('oras', ['manifest', 'fetch', entry.reference]) + ) + checkManifest(manifest, entry, sums) + log('Published package:', entry.reference) + } +} + +function defaultRun(command, args, options = {}) { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + ...options, + }) +} + +if (import.meta.main) + try { + publishPackages({ env: process.env }) + } catch (error) { + console.error(error) + process.exitCode = 1 + } diff --git a/scripts/release/packages.test.mjs b/scripts/release/packages.test.mjs new file mode 100644 index 0000000000..1b011fa389 --- /dev/null +++ b/scripts/release/packages.test.mjs @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { + checkDownload, + checkManifest, + packagePlan, + packageReference, + publishPackages, +} from './packages.mjs' +import { releaseAssets } from './publish.mjs' + +const repo = 'Example-Org/trusted-server' +const tag = 'v1.2.3' +const commit = 'a'.repeat(40) +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex') + +/** Writes a complete fictional release and its SHA256SUMS. */ +function releaseDirectory() { + const directory = fs.mkdtempSync(join(tmpdir(), 'packages-')) + const names = packagePlan(repo, tag, commit).flatMap(({ files }) => + files.map(({ name }) => name) + ) + const sums = names + .map((name) => { + const body = `fictional ${name}\n` + fs.writeFileSync(join(directory, name), body) + return `${hash(body)} ${name}\n` + }) + .join('') + fs.writeFileSync(join(directory, 'SHA256SUMS'), sums) + return directory +} + +/** Builds the manifest a correct push of `entry` would store. */ +function manifestFor(entry, sums) { + return { + artifactType: entry.artifactType, + annotations: { + ...entry.annotations, + 'org.opencontainers.image.created': '2026-01-01T00:00:00Z', + }, + layers: entry.files.map(({ name, mediaType }) => ({ + mediaType, + digest: `sha256:${sums.get(name)}`, + annotations: { 'org.opencontainers.image.title': name }, + })), + } +} + +test('maps each release binary to a lowercase GHCR package', () => { + assert.equal( + packageReference('Example-Org', tag, 'trusted-server-fastly.wasm'), + 'ghcr.io/example-org/trusted-server-fastly:v1.2.3', + 'should name the Fastly package without a target suffix' + ) + assert.equal( + packageReference('Example-Org', tag, 'ts-x86_64-unknown-linux-gnu'), + 'ghcr.io/example-org/trusted-server-cli:v1.2.3-x86_64-unknown-linux-gnu', + 'should tag the CLI package by target' + ) + assert.throws( + () => packageReference('Example-Org', tag, 'unexpected.bin'), + /No package/, + 'should refuse an unknown asset' + ) +}) + +test('plans every release binary with its evidence files', () => { + const plan = packagePlan(repo, tag, commit) + assert.deepEqual( + plan.map(({ asset }) => asset), + releaseAssets, + 'should publish exactly the release binaries' + ) + const wasm = plan.find(({ asset }) => asset.endsWith('.wasm')) + assert.deepEqual( + wasm.files.map(({ name }) => name), + [ + 'trusted-server-fastly.wasm', + 'trusted-server-fastly.wasm.spdx.json', + 'trusted-server-fastly.wasm.provenance.sigstore.json', + 'trusted-server-fastly.wasm.sbom.sigstore.json', + 'trusted-server-fastly.wasm.rebuild-check.json', + ], + 'should carry the rebuild check with the Fastly wasm' + ) + assert.equal( + wasm.annotations['org.opencontainers.image.source'], + 'https://github.com/Example-Org/trusted-server', + 'should link the package to its source repository' + ) + assert.throws( + () => packagePlan(repo, 'latest', commit), + /Not a release tag/, + 'should refuse a non-release tag' + ) + assert.throws( + () => packagePlan(repo, tag, 'HEAD'), + /Invalid source commit/, + 'should refuse a symbolic commit' + ) +}) + +test('accepts a release that matches its SHA256SUMS exactly', () => { + const directory = releaseDirectory() + const sums = checkDownload(directory, packagePlan(repo, tag, commit)) + assert.equal(sums.size, 17, 'should list every non-checksum release file') +}) + +test('rejects changed, extra and missing release files', () => { + const plan = packagePlan(repo, tag, commit) + const changed = releaseDirectory() + fs.appendFileSync(join(changed, 'ts-aarch64-apple-darwin'), 'tampered') + assert.throws( + () => checkDownload(changed, plan), + /Digest mismatch: ts-aarch64-apple-darwin/, + 'should reject bytes that differ from SHA256SUMS' + ) + const extra = releaseDirectory() + fs.writeFileSync(join(extra, 'unlisted.txt'), 'extra') + assert.throws( + () => checkDownload(extra, plan), + /differ from SHA256SUMS/, + 'should reject a file SHA256SUMS does not list' + ) + const missing = releaseDirectory() + const sums = fs.readFileSync(join(missing, 'SHA256SUMS'), 'utf8') + fs.writeFileSync( + join(missing, 'SHA256SUMS'), + sums.replace(/^.* ts-x86_64-apple-darwin\.spdx\.json\n/m, '') + ) + fs.rmSync(join(missing, 'ts-x86_64-apple-darwin.spdx.json')) + assert.throws( + () => checkDownload(missing, plan), + /Release lacks ts-x86_64-apple-darwin\.spdx\.json/, + 'should require every planned evidence file' + ) +}) + +test('checks the pushed manifest layers, order and annotations', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const [entry] = plan + checkManifest(manifestFor(entry, sums), entry, sums) + const swapped = manifestFor(entry, sums) + swapped.layers.reverse() + assert.throws( + () => checkManifest(swapped, entry, sums), + /differs/, + 'should reject reordered layers' + ) + const unlinked = manifestFor(entry, sums) + delete unlinked.annotations['org.opencontainers.image.source'] + assert.throws( + () => checkManifest(unlinked, entry, sums), + /Annotation org\.opencontainers\.image\.source/, + 'should require the repository link' + ) +}) + +test('publishes every package once from the published release', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const pushed = new Map() + const calls = [] + const run = (command, args, options = {}) => { + calls.push([command, ...args]) + if (command === 'gh' && args[1] === 'view') + return JSON.stringify({ tagName: tag, isDraft: false }) + if (command === 'gh' && args[1] === 'download') { + const target = args[args.indexOf('--dir') + 1] + for (const name of fs.readdirSync(directory)) + fs.copyFileSync(join(directory, name), join(target, name)) + return '' + } + if (command === 'oras' && args[0] === 'push') { + assert.equal( + options.cwd !== undefined, + true, + 'should push from the download' + ) + const entry = plan.find(({ reference }) => reference === args[1]) + pushed.set(args[1], manifestFor(entry, sums)) + return '' + } + if (command === 'oras' && args[0] === 'manifest') { + const reference = args.at(-1) + if (!pushed.has(reference)) throw Error('not found') + return JSON.stringify(pushed.get(reference)) + } + throw Error(`unexpected call ${command} ${args.join(' ')}`) + } + const env = { + GH_REPO: repo, + RELEASE_TAG: tag, + SOURCE_COMMIT: commit, + PACKAGE_PUBLICATION_ENABLED: 'true', + RUNNER_TEMP: tmpdir(), + } + publishPackages({ env, run, log: () => {} }) + assert.deepEqual( + [...pushed.keys()], + plan.map(({ reference }) => reference), + 'should push one artifact per release binary' + ) + assert.throws( + () => publishPackages({ env, run, log: () => {} }), + /Refusing to overwrite/, + 'should keep published tags write-once' + ) + assert.throws( + () => + publishPackages({ + env: { ...env, PACKAGE_PUBLICATION_ENABLED: 'false' }, + run, + log: () => {}, + }), + /not enabled/, + 'should stay closed until a maintainer enables publication' + ) +}) + +test('refuses a draft release', () => { + const run = (command, args) => { + if (command === 'gh' && args[1] === 'view') + return JSON.stringify({ tagName: tag, isDraft: true }) + throw Error('should stop before any other call') + } + assert.throws( + () => + publishPackages({ + env: { + GH_REPO: repo, + RELEASE_TAG: tag, + SOURCE_COMMIT: commit, + PACKAGE_PUBLICATION_ENABLED: 'true', + }, + run, + log: () => {}, + }), + /is not published/, + 'should publish packages only from a published release' + ) +}) From 387f41b0bc7f4a6c36b2c291b3cf806bab812c19 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 11 Nov 2026 00:24:32 +0530 Subject: [PATCH 2/4] Bound the publisher workflow test to its own job and cover the package jobs The publisher isolation test read everything after the release job, so the new packages and axum-image jobs made it count three checkouts. It now reads each job alone and pins the packages job's sparse checkout, pinned oras and lack of release-writing permission. --- scripts/release/phase1.test.mjs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/release/phase1.test.mjs b/scripts/release/phase1.test.mjs index 45685e7e54..b3137879e5 100644 --- a/scripts/release/phase1.test.mjs +++ b/scripts/release/phase1.test.mjs @@ -664,7 +664,9 @@ test('workflow signs only tags, separates privileged jobs, and never checks out 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] + const jobs = (name) => + release.split(`\n ${name}:`)[1]?.split(/\n {2}(?=[\w-]+:\n)/)[0] + const publisher = jobs('release') assert.equal((publisher.match(/actions\/checkout@/g) ?? []).length, 1) assert.match(publisher, /persist-credentials: false/) assert.deepEqual( @@ -683,6 +685,35 @@ test('workflow signs only tags, separates privileged jobs, and never checks out 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/) + // The package job mirrors only the published release; it never builds. + const packages = jobs('packages') + assert.ok(packages, 'should define the package publication job') + assert.match(packages, /needs: release\n/) + assert.match(packages, /vars\.PACKAGE_PUBLICATION_ENABLED == 'true'/) + assert.equal((packages.match(/actions\/checkout@/g) ?? []).length, 1) + assert.match(packages, /persist-credentials: false/) + assert.deepEqual( + packages + .match(/sparse-checkout: \|\n((?: {12}\S.*\n)+)/)[1] + .trim() + .split(/\s+/), + [ + '.tool-versions', + 'scripts/release/publish.mjs', + 'scripts/release/packages.mjs', + 'scripts/release/release-notes.md', + ] + ) + assert.doesNotMatch(packages, /cargo |npm |phase1-build|contents: write/) + assert.match(packages, /ORAS_SHA256: [a-f0-9]{64}\n/) + assert.match(packages, /node publisher\/scripts\/release\/packages\.mjs\n/) + // The Axum image job may build, but holds no release-writing authority. + const image = jobs('axum-image') + assert.ok(image, 'should define the Axum image job') + assert.match(image, /needs: release\n/) + assert.match(image, /vars\.PACKAGE_PUBLICATION_ENABLED == 'true'/) + assert.doesNotMatch(image, /contents: write|--clobber/) + assert.match(image, /persist-credentials: false/) // Match the repository convention: actions are referenced by major tag. for (const line of [workflow, release] .join('\n') From dc1a063d893de2861c3ddf1872a0b7c85e675558 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 11 Nov 2026 13:46:30 +0530 Subject: [PATCH 3/4] Align the Axum image with the proposed self-hosting contract Run the image on Debian slim as UID 10001 with a read-only root, stop it with SIGINT, and read the listen address from EdgeZero's EDGEZERO__ADAPTER__HOST and EDGEZERO__ADAPTER__PORT instead of a new variable. A test.yml job now builds the image on every PR and runs the Axum smoke against it, including one case on the image's own bridge defaults. Make package publication fail closed on registry errors, complete a partial publish on re-run, authenticate SHA256SUMS first, and record two rounds of spec review. --- .github/workflows/release.yml | 17 +- .github/workflows/test.yml | 33 +++ crates/trusted-server-adapter-axum/Dockerfile | 33 ++- .../trusted-server-adapter-axum/src/main.rs | 174 +++++++----- docs/guide/axum-dev.md | 25 +- docs/guide/release-verification.md | 41 ++- .../2026-11-11-release-packages-design.md | 258 +++++++++++++++--- scripts/release/packages.mjs | 62 ++++- scripts/release/packages.test.mjs | 196 +++++++++++-- scripts/smoke-axum.sh | 77 +++++- 10 files changed, 735 insertions(+), 181 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b7320f3b8..960c7b2068 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,8 @@ jobs: test "$(node --version)" = "v$NODE_VERSION" node publisher/scripts/release/publish.mjs # Mirrors the published release into GHCR: one OCI artifact per binary with - # its SPDX SBOM and Sigstore bundles. Tags are write-once. + # its SPDX SBOM and Sigstore bundles. An existing tag is refused unless it + # already holds exactly the release files; GHCR tags stay mutable. packages: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.PACKAGE_PUBLICATION_ENABLED == 'true' needs: release @@ -171,7 +172,8 @@ jobs: node publisher/scripts/release/packages.mjs # Builds the Axum server image from the tagged source and pushes it to GHCR # with GitHub build provenance. It is an ordinary build, outside the - # reproducible release cohort. + # reproducible release cohort. If a push or attestation fails, delete the + # package version before re-running; see the maintainer checklist. axum-image: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.PACKAGE_PUBLICATION_ENABLED == 'true' needs: release @@ -196,19 +198,24 @@ jobs: set -euo pipefail image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/trusted-server-axum" printf '%s' "$GH_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin - if docker manifest inspect "$image:$RELEASE_TAG" > /dev/null 2>&1; then + # GHCR tags are mutable; refuse to replace one at publish time. Only + # the registry's not-found answer counts as absent. + if lookup="$(docker buildx imagetools inspect "$image:$RELEASE_TAG" 2>&1)"; then echo "Refusing to overwrite $image:$RELEASE_TAG" >&2 exit 1 + elif ! grep -qF -- "$image:$RELEASE_TAG: not found" <<< "$lookup"; then + echo "Cannot check whether $image:$RELEASE_TAG exists: $lookup" >&2 + exit 1 fi docker buildx build \ --file crates/trusted-server-adapter-axum/Dockerfile \ --platform linux/amd64 \ --provenance=false --sbom=false \ - --build-arg "TRUSTED_SERVER_VERSION=$RELEASE_TAG" \ + --build-arg "TRUSTED_SERVER_VERSION=$SOURCE_COMMIT" \ --label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \ --label "org.opencontainers.image.revision=$SOURCE_COMMIT" \ --label "org.opencontainers.image.version=$RELEASE_TAG" \ - --label "org.opencontainers.image.description=Trusted Server on Axum $RELEASE_TAG" \ + --label "org.opencontainers.image.description=Trusted Server on Axum $RELEASE_TAG (development status)" \ --tag "$image:$RELEASE_TAG" \ --metadata-file "$RUNNER_TEMP/image.json" \ --push . diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dba5dc8bb9..fb9b10f9dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -124,6 +124,39 @@ jobs: path: ${{ runner.temp }}/trusted-server-finalize-fastly-*/ if-no-files-found: ignore + axum-image: + name: Axum image smoke + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: ./.github/actions/setup-rust + with: + cache-key: cargo-axum-image + + - name: Read pinned Node.js version + id: node-version + shell: bash + run: echo "version=$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + + # The smoke builds the `ts` CLI, whose core dependency runs the TSJS + # build with the `npm` on PATH, so pin Node before any cargo step. + - name: Use Node.js for the TSJS build + uses: actions/setup-node@v4 + with: + node-version: ${{ steps.node-version.outputs.version }} + + - name: Build the Axum image + run: | + docker buildx build --load \ + --file crates/trusted-server-adapter-axum/Dockerfile \ + --tag trusted-server-axum:smoke . + + - name: Run the Axum smoke against the image + run: AXUM_SMOKE_IMAGE=trusted-server-axum:smoke ./scripts/smoke-axum.sh + test-axum: name: cargo test (axum native) runs-on: ubuntu-latest diff --git a/crates/trusted-server-adapter-axum/Dockerfile b/crates/trusted-server-adapter-axum/Dockerfile index 249b91532c..d07ccada86 100644 --- a/crates/trusted-server-adapter-axum/Dockerfile +++ b/crates/trusted-server-adapter-axum/Dockerfile @@ -2,10 +2,12 @@ # # Build from the repository root: # docker build --file crates/trusted-server-adapter-axum/Dockerfile \ -# --build-arg TRUSTED_SERVER_VERSION=v1.2.3 --tag trusted-server-axum . +# --build-arg TRUSTED_SERVER_VERSION="$(git rev-parse HEAD)" \ +# --tag trusted-server-axum . # # The image is an ordinary build with build provenance only. It is not part of # the reproducible release cohort that the binary release rebuild checks cover. +# Axum is development status; see docs/guide/axum-dev.md. # The tsjs build script runs npm; this is the `.tool-versions` Node. FROM docker.io/library/node:24.20.0-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e AS node @@ -21,10 +23,15 @@ RUN set -eu; \ test "$(node --version)" = v24.20.0; \ npm --version +# Use the installed toolchain as is: rust-toolchain.toml would otherwise make +# rustup download its extra wasm targets, an unpinned network input. +ENV RUSTUP_TOOLCHAIN=1.98.1 + WORKDIR /src COPY . . -# The checkout has no `.git`, so the release tag names the embedded version. +# The checkout has no `.git`, so pass the source commit as the embedded +# version, as the release binaries do. ARG TRUSTED_SERVER_VERSION RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/src/target \ @@ -38,13 +45,25 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ install -D -m 0755 "target/$host/release/trusted-server-axum" \ /out/trusted-server-axum -# Distroless keeps glibc, CA certificates and a nonroot user, with no shell. -FROM gcr.io/distroless/cc-debian12:nonroot@sha256:9dac0a79194e45a7da0158a9c6da57b217585af0786db3845d1f0ec1a0dd182f +# Debian slim with CA certificates only: no Node, compiler, config or secrets. +# Trusted Server's origin fetches use compiled-in webpki roots; only EdgeZero's +# own client reads this system store. +FROM docker.io/library/debian:bookworm-slim@sha256:734a72b3d6142a71993b4bc580ca01d8ca687f77a2c422d96a961d3376b179bc + +RUN set -eu; \ + apt-get update; \ + apt-get install --yes --no-install-recommends ca-certificates; \ + rm -rf /var/lib/apt/lists/* COPY --from=build /out/trusted-server-axum /usr/local/bin/trusted-server-axum -# Containers must accept connections from outside their own loopback. -ENV TRUSTED_SERVER_LISTEN_ADDR=0.0.0.0:8080 +# Containers must accept connections from outside their own loopback. The +# process writes nothing, so it runs with a read-only root filesystem. +ENV EDGEZERO__ADAPTER__HOST=0.0.0.0 \ + EDGEZERO__ADAPTER__PORT=8080 EXPOSE 8080 -USER nonroot:nonroot +# The server shuts down on SIGINT only, and a PID 1 without a SIGTERM handler +# would ignore `docker stop` until the kill timeout. +STOPSIGNAL SIGINT +USER 10001:10001 ENTRYPOINT ["/usr/local/bin/trusted-server-axum"] diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index fd1f60e4d2..07f10adbe2 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,6 +1,5 @@ -use std::net::{Ipv4Addr, SocketAddr}; - use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; +use edgezero_core::addr::{BindAddrResolution, resolve_bind_addr}; use edgezero_core::app::Hooks as _; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; @@ -11,10 +10,13 @@ const LOG_LEVEL_ENV: &str = "RUST_LOG"; /// Log level used when `RUST_LOG` is unset or unrecognized. const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Info; -/// Environment variable carrying a full listen socket address. -const LISTEN_ADDR_ENV: &str = "TRUSTED_SERVER_LISTEN_ADDR"; +/// `EdgeZero` environment variable carrying the listen IP address. +const HOST_ENV: &str = "EDGEZERO__ADAPTER__HOST"; + +/// `EdgeZero` environment variable carrying the listen port. +const EDGEZERO_PORT_ENV: &str = "EDGEZERO__ADAPTER__PORT"; -/// Environment variable carrying a loopback listen port. +/// Environment variable carrying a listen port for local tooling and tests. const PORT_ENV: &str = "PORT"; #[allow(clippy::print_stderr)] @@ -34,22 +36,25 @@ fn main() { eprintln!("warning: logger init failed: {e}"); } - let listen = - std::env::var_os(LISTEN_ADDR_ENV).map(|value| value.to_string_lossy().into_owned()); - let port = std::env::var_os(PORT_ENV).map(|value| value.to_string_lossy().into_owned()); - let config = match listen_address(listen.as_deref(), port.as_deref()) { - Ok(Some(addr)) => AxumDevServerConfig { - addr, - enable_ctrl_c: true, - }, - Ok(None) => AxumDevServerConfig::default(), - // Silently falling back to another address would surprise tooling + let env = |name: &str| std::env::var_os(name).map(|value| value.to_string_lossy().into_owned()); + let (host, edgezero_port, port) = (env(HOST_ENV), env(EDGEZERO_PORT_ENV), env(PORT_ENV)); + let resolution = match bind_address(host.as_deref(), edgezero_port.as_deref(), port.as_deref()) + { + Ok(resolution) => resolution, + // Silently falling back to another port would surprise tooling // that expects the server where it asked for it. Err(message) => { eprintln!("error: {message}"); std::process::exit(1); } }; + for warning in &resolution.warnings { + log::warn!("{warning}"); + } + let config = AxumDevServerConfig { + addr: resolution.addr, + enable_ctrl_c: true, + }; log::info!("Listening on http://{}", config.addr); let router = TrustedServerApp::routes(); @@ -79,83 +84,128 @@ fn parse_log_level(configured: Option<&str>) -> Result { } } -/// Selects the listen address from `TRUSTED_SERVER_LISTEN_ADDR` and `PORT`. +/// Resolves the listen address with `EdgeZero`'s precedence. /// -/// `TRUSTED_SERVER_LISTEN_ADDR` takes a full socket address such as -/// `0.0.0.0:8080` or `[::]:8080`, which a container needs to accept outside -/// connections. Otherwise `PORT` binds the loopback address on that port, so -/// integration tests can allocate a fresh OS port each run. With neither set, -/// `Ok(None)` keeps the adapter's default loopback address. +/// `EDGEZERO__ADAPTER__HOST` and `EDGEZERO__ADAPTER__PORT` win, so a container +/// can listen on `0.0.0.0`. `PORT` is the port fallback local tooling uses to +/// allocate a fresh port per run. Otherwise the address is `127.0.0.1:8787`. +/// An invalid `EdgeZero` value falls back with a warning, as `EdgeZero` does. +/// `PORT` is read only when it is the fallback, so a valid +/// `EDGEZERO__ADAPTER__PORT` makes any `PORT` irrelevant. /// /// # Errors /// -/// Returns a message naming the variable when a set value is not a valid -/// socket address or port. -fn listen_address(listen: Option<&str>, port: Option<&str>) -> Result, String> { - if let Some(raw) = listen { - return raw.parse().map(Some).map_err(|error| { - format!("{LISTEN_ADDR_ENV}={raw:?} is not a valid socket address: {error}") - }); - } - let Some(raw) = port else { - return Ok(None); +/// Returns a message naming `PORT` when it is the fallback and is not a +/// nonzero port number. +fn bind_address( + host: Option<&str>, + edgezero_port: Option<&str>, + port: Option<&str>, +) -> Result { + let edgezero_port_valid = + edgezero_port.is_some_and(|raw| raw.parse::().is_ok_and(|value| value != 0)); + let fallback_port = match port { + Some(raw) if !edgezero_port_valid => match raw.parse::() { + Ok(0) => { + return Err(format!( + "{PORT_ENV}=\"0\" is not supported (would bind to a random OS port)" + )); + } + Ok(value) => Some(value), + Err(error) => return Err(format!("{PORT_ENV}={raw:?} is not a valid u16: {error}")), + }, + _ => None, }; - raw.parse::() - .map(|port| Some(SocketAddr::from((Ipv4Addr::LOCALHOST, port)))) - .map_err(|error| format!("{PORT_ENV}={raw:?} is not a valid u16: {error}")) + Ok(resolve_bind_addr(host, edgezero_port, None, fallback_port)) } #[cfg(test)] mod tests { + use std::net::SocketAddr; + use super::*; #[test] fn crate_compiles() {} #[test] - fn listen_address_prefers_the_full_socket_address() { + fn bind_address_uses_edgezero_host_and_port_first() { + let resolution = bind_address(Some("0.0.0.0"), Some("8080"), Some("9000")) + .expect("should accept valid listen settings"); assert_eq!( - listen_address(Some("0.0.0.0:8080"), Some("9000")), - Ok(Some(SocketAddr::from(([0, 0, 0, 0], 8080)))), - "should bind every interface when the full address is set" + resolution.addr, + SocketAddr::from(([0, 0, 0, 0], 8080)), + "should bind every interface on the EdgeZero port" + ); + assert!( + resolution.warnings.is_empty(), + "should not warn on valid values" ); + let resolution = + bind_address(Some("::"), None, Some("9000")).expect("should accept an IPv6 host"); assert_eq!( - listen_address(Some("[::]:8080"), None), - Ok(Some( - "[::]:8080".parse().expect("should parse IPv6 fixture") - )), - "should accept an IPv6 listen address" + resolution.addr, + "[::]:9000".parse().expect("should parse IPv6 fixture"), + "should fall back to PORT when no EdgeZero port is set" ); } #[test] - fn listen_address_keeps_port_on_loopback_and_default_when_unset() { + fn bind_address_keeps_port_and_default_on_loopback() { + let resolution = bind_address(None, None, Some("9000")).expect("should accept PORT"); assert_eq!( - listen_address(None, Some("9000")), - Ok(Some(SocketAddr::from(([127, 0, 0, 1], 9000)))), + resolution.addr, + SocketAddr::from(([127, 0, 0, 1], 9000)), "should keep PORT on the loopback address" ); + let resolution = bind_address(None, None, None).expect("should accept no settings"); assert_eq!( - listen_address(None, None), - Ok(None), - "should keep the adapter default when nothing is set" + resolution.addr, + SocketAddr::from(([127, 0, 0, 1], 8787)), + "should keep the EdgeZero loopback default" ); } #[test] - fn listen_address_rejects_invalid_values_naming_the_variable() { - for (listen, port, variable) in [ - (Some("0.0.0.0"), None, LISTEN_ADDR_ENV), - (Some(""), Some("9000"), LISTEN_ADDR_ENV), - (None, Some("70000"), PORT_ENV), - ] { - let message = - listen_address(listen, port).expect_err("should reject an unusable listen setting"); - assert!( - message.starts_with(variable), - "should name {variable} in the error, got: {message}" - ); - } + fn bind_address_warns_on_invalid_edgezero_values_and_rejects_invalid_port() { + let resolution = bind_address(Some("localhost"), Some("0"), None) + .expect("should fall back from invalid EdgeZero values"); + assert_eq!( + resolution.addr, + SocketAddr::from(([127, 0, 0, 1], 8787)), + "should fall back to the loopback default" + ); + assert!( + resolution + .warnings + .iter() + .any(|warning| warning.starts_with(HOST_ENV)) + && resolution + .warnings + .iter() + .any(|warning| warning.starts_with(EDGEZERO_PORT_ENV)), + "should warn naming each invalid EdgeZero variable, got: {:?}", + resolution.warnings + ); + let resolution = bind_address(Some("0.0.0.0"), Some("8080"), Some("not-a-port")) + .expect("should ignore PORT when the EdgeZero port is valid"); + assert_eq!( + resolution.addr, + SocketAddr::from(([0, 0, 0, 0], 8080)), + "should not read a platform-injected PORT inside the image" + ); + let message = bind_address(None, None, Some("0")) + .expect_err("should reject PORT=0 instead of binding a random port"); + assert!( + message.starts_with(PORT_ENV), + "should name PORT for the zero port, got: {message}" + ); + let message = bind_address(Some("0.0.0.0"), None, Some("70000")) + .expect_err("should reject an invalid PORT"); + assert!( + message.starts_with(PORT_ENV), + "should name PORT in the error, got: {message}" + ); } #[test] diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index d4b211c53f..c88a4273cb 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -47,10 +47,13 @@ config, local-push output, bodies, headers, and logs. ## Listen address -Axum listens on `127.0.0.1:8787` by default. `PORT` keeps the loopback address -and changes only the port. `TRUSTED_SERVER_LISTEN_ADDR` takes a full socket -address, such as `0.0.0.0:8080` or `[::]:8080`, and wins over `PORT`. An invalid -value of either stops the process at startup instead of falling back. +Axum listens on `127.0.0.1:8787` by default. It reads EdgeZero's +`EDGEZERO__ADAPTER__HOST` and `EDGEZERO__ADAPTER__PORT` first, so a container +can listen on `0.0.0.0`. `PORT` is the port fallback local tooling uses. An +invalid EdgeZero value falls back with a startup warning, as EdgeZero does; an +invalid `PORT` stops the process. The container image sets +`EDGEZERO__ADAPTER__PORT=8080`, so a `PORT` injected by a container platform is +ignored there; set `EDGEZERO__ADAPTER__PORT` instead. ## Run the container image @@ -58,14 +61,20 @@ value of either stops the process at startup instead of falling back. repository root. Releases with package publication enabled push it to GHCR as `trusted-server-axum`; see [Download from GitHub Packages](./release-verification#download-from-github-packages) -to verify it. The image runs as a non-root user without a shell, listens on -`0.0.0.0:8080`, and reads the same config and secret variables as the smoke -above: +to verify it. The image is Debian slim with CA certificates and the server binary +only. It runs as UID `10001`, writes nothing, listens on `0.0.0.0:8080`, and +reads the same config and secret variables as the smoke above. CI builds it and +runs that smoke against it with a read-only root filesystem: +`AXUM_SMOKE_IMAGE= ./scripts/smoke-axum.sh` (Linux host networking). It +stops on `SIGINT`, the signal the server handles, so `docker stop` shuts it +down without waiting for the kill timeout. SIGTERM draining and readiness are +not implemented yet (#1236). ```bash docker build --file crates/trusted-server-adapter-axum/Dockerfile \ + --build-arg TRUSTED_SERVER_VERSION="$(git rev-parse HEAD)" \ --tag trusted-server-axum . -docker run --rm --publish 8080:8080 \ +docker run --rm --read-only --cap-drop ALL --publish 8080:8080 \ --env TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG \ --env TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD \ --env TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET \ diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index dbf023dd3d..328d771aee 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -288,12 +288,18 @@ new trust. Verify the files you pull exactly as you verify release downloads. | ----------------------- | ---------------- | ------------------------------------------------------------------- | | `trusted-server-cli` | `-` | `ts-`, its SPDX SBOM and both Sigstore bundles | | `trusted-server-fastly` | `` | `trusted-server-fastly.wasm`, its SBOM, both bundles, rebuild check | -| `trusted-server-axum` | `` | Axum server container image for `linux/amd64` | +| `trusted-server-axum` | `` | Axum server container image for `linux/amd64` (development status) | -The CLI and Fastly packages are OCI artifacts, not runnable images. Each file +Pulling the CLI and Fastly packages needs ORAS 1.2 or +later; the image needs Docker with Buildx. Neither tool is trusted: the +commands below verify what they fetched. The CLI and Fastly packages are OCI +artifacts, not runnable images. Each file is one layer, and each layer's digest is that file's SHA-256 from the release's -`SHA256SUMS`. The packages job downloads the published release, checks every -file against `SHA256SUMS`, then pushes. It never overwrites an existing tag. +`SHA256SUMS`. The packages job downloads the published release, authenticates +its `SHA256SUMS`, checks every file against it, then pushes. At publish time it +refuses to replace a tag that holds anything else, but GHCR tags stay mutable: +always verify the files you pulled, or an image by its digest, never a tag +alone. ```bash set -euo pipefail @@ -318,17 +324,21 @@ check the SBOM too, run the per-asset loop from [Download and verify](#download-and-verify) in the `pulled` directory. `oras pull` needs no login for a public package. -The Axum image is an ordinary build of the tagged source. It is outside the -reproducible release cohort: no rebuild check, SBOM or release asset covers it, -only GitHub build provenance attached to the image digest. Verify it before -running it: +The Axum image is an ordinary build of the tagged source, and Axum itself is +still development status. The image is outside the reproducible release cohort: +no rebuild check, SBOM or release asset covers it, only GitHub build provenance +attached to the image digest. Resolve the digest once, verify it, then run that +digest, so a tag moved in between cannot swap the image: ```bash -image="ghcr.io/$OWNER/trusted-server-axum:$TAG" -gh attestation verify "oci://$image" --repo "$REPO" \ +image="ghcr.io/$OWNER/trusted-server-axum" +digest=$(docker buildx imagetools inspect "$image:$TAG" --format '{{json .Manifest.Digest}}' | tr -d '"') +[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || exit 1 +gh attestation verify "oci://$image@$digest" --repo "$REPO" \ --source-ref "refs/tags/$TAG" --source-digest "$COMMIT" --signer-digest "$COMMIT" \ --deny-self-hosted-runners --predicate-type https://slsa.dev/provenance/v1 \ --signer-workflow "$REPO/.github/workflows/release.yml" +echo "Run $image@$digest" ``` See [Axum development server](./axum-dev#run-the-container-image) for running it. @@ -571,10 +581,17 @@ commands below only inspect settings; this workflow applies none of them. tagging its commit. Phase 1 does not enforce source eligibility. - **Settings → Secrets and variables → Actions → Variables:** set `PACKAGE_PUBLICATION_ENABLED` to `true` to mirror releases into GitHub - Packages. Without it, the `packages` and `axum-image` jobs are skipped and the + Packages. First rehearse the exact commit in a separate repository with the + variable set there, and confirm the `packages` and `axum-image` jobs pass on + packages that did not exist before. Without it, the `packages` and `axum-image` jobs are skipped and the release is unaffected. After the first package publish, check each package's visibility under the organization's **Packages** settings; make it public - only when the release is public. + only when the release is public. If the `packages` job fails partway, re-run + it: tags that already hold exactly the release files are kept and the rest + are pushed, and any tag with other content stops the job. If `axum-image` + fails after its push (for example in attestation), delete that package + version under **Packages** before re-running, because the job refuses an + existing tag. - 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 diff --git a/docs/superpowers/specs/2026-11-11-release-packages-design.md b/docs/superpowers/specs/2026-11-11-release-packages-design.md index ecbb8ffdac..f3622c1b50 100644 --- a/docs/superpowers/specs/2026-11-11-release-packages-design.md +++ b/docs/superpowers/specs/2026-11-11-release-packages-design.md @@ -1,57 +1,237 @@ # Release packages on GHCR -## Intent +- **Issues:** part of [#1235](https://github.com/IABTechLab/trusted-server/issues/1235) + (attested releases); implements the image and bind items proposed in + [#1236](https://github.com/IABTechLab/trusted-server/issues/1236) ahead of its + spec +- **Date:** 2026-11-11 +- **Status:** proposed, in review with its implementation PR +- **Stacked on:** #1449 (Phase 2 attested releases) -The fork rehearsals publish a GitHub Release with 18 assets, and each run also -lists 12 internal workflow artifacts. Operators could not tell which files to -use. Releases now also appear in the repository's **Packages** list, with one -package per thing an operator runs, and the Axum server ships as a container -image. +## Problem + +A rehearsal release publishes 18 assets, and each run also lists 12 internal +workflow artifacts. Operators could not tell which files to use. There was also +no way to run the Axum server as a container: it only listened on loopback, and +no image existed. ## Goals -- Mirror each published release into GHCR as three packages: - `trusted-server-cli` (tag `-`), `trusted-server-fastly` - (tag ``) and `trusted-server-axum` (tag ``). -- Keep each CLI and Fastly package byte-identical to the release: every file is - one OCI layer whose digest is its `SHA256SUMS` entry. -- Link every package to the source repository and keep tags write-once. -- Ship Axum as a non-root container image with GitHub build provenance. +- Mirror each published release into GHCR as `trusted-server-cli` (tag + `-`) and `trusted-server-fastly` (tag ``). Each package + holds one binary with its SPDX SBOM and Sigstore bundles, and the Fastly + package also holds the rebuild check. Every file is one OCI layer whose digest + is its `SHA256SUMS` entry. +- Publish the Axum server as `trusted-server-axum:`, a container image with + GitHub build provenance, built to the image contract proposed in #1236. +- Link each package to the source repository. +- Keep the release itself unchanged: packages add no trust, and verification + stays the release verification procedure. ## Non-goals -- Package publication adds no trust. Verification stays the release - verification procedure, applied to pulled files. -- The Axum image does not join the reproducible release cohort: no rebuild - check, SBOM or release membership covers it. -- No `linux/arm64` image yet. -- The internal workflow artifacts are unchanged. +- Changing the internal workflow artifacts. +- Making Axum a supported production target. Axum stays development status, and + the image says so. +- The rest of #1236: Edge Cookie KV, client IP and geo, SIGTERM drain and + readiness, key rotation, and the decisions its spec must settle. That spec may + still change the image and bind choices made here. +- A `linux/arm64` image. +- Adding the image to the reproducible release cohort: no rebuild check or SBOM + covers it. + +## Scope note + +#1236 lists image publishing as a non-goal owned by #897 (`ts up`, using the +stackpop/edgezero#399 recipe). Publishing the Axum image here takes on that part +of #897 early, so release tags ship a matching image. If #897 or #1236's spec +chooses another recipe, the `axum-image` job is the one place to change. ## Approaches -1. **OCI artifacts pushed by a publisher job after the release (chosen).** A - `packages` job downloads the published release by tag, checks it against - `SHA256SUMS`, then pushes with a pinned, checksum-verified `oras`. It reuses - the release's existing attestations; the files keep their digests. +1. **A publisher job after the release (chosen).** A `packages` job downloads + the published release by tag, authenticates `SHA256SUMS`, checks every file + against it, and pushes with a pinned, checksum-verified `oras`. It reuses the + release's attestations; the files keep their digests. 2. **Push from the signing jobs.** Earlier, but gives signing jobs registry authority and can publish packages for a release that later fails. -3. **Per-platform archives in the release.** Shortens the asset list but changes +3. **Per-platform archives in the release.** Shortens the asset list, but changes the attested release format and every verification command. -## Decisions +## Design + +### Binary packages - `PACKAGE_PUBLICATION_ENABLED=true` gates both new jobs, separately from - `RELEASE_PUBLICATION_ENABLED`; both need the `release` job to succeed. -- The publisher refuses drafts, any file outside `SHA256SUMS`, and existing - tags, and checks each pushed manifest's layers, order and annotations. -- The Axum image builds from the tagged source with the pinned Rust image and - `.tool-versions` Node (checksum-verified), runs on distroless `cc-debian12` - as `nonroot`, and listens on `0.0.0.0:8080` through the new - `TRUSTED_SERVER_LISTEN_ADDR` variable. `actions/attest` attaches build - provenance to the pushed image digest. -- Plain `docker buildx` and `oras` commands avoid new third-party actions. - -## Left to the builder - -Package visibility after the first publish is an organization setting; the -maintainer checklist covers it. + `RELEASE_PUBLICATION_ENABLED`. Both need the `release` job to succeed. +- The `packages` job has only `contents: read` and `packages: write`. It + sparse-checks out `.tool-versions`, `publish.mjs`, `packages.mjs` and the notes + template. +- `packages.mjs`: + - refuses drafts; + - authenticates `SHA256SUMS` with `gh release verify-asset`; + - requires the download to equal `SHA256SUMS` exactly. +- **Existing tags.** + - Only the registry's `: not found` answer means a tag is + absent. Any other + lookup error stops the job. + - A tag already holding exactly the planned files (the same layers and + annotations, an empty config, and no `subject`) is kept. This makes a + re-run finish a partial publish. + - A tag holding anything else stops the job before it pushes. + - Each pushed manifest is checked layer by layer: media type, digest, title, + order and annotations. +- GHCR tags are mutable. The refusal applies only at publish time, so the docs + tell users to verify pulled files, or an image by its digest. + +### Axum image + +- **Build.** It builds from the tagged source with `--locked`. It uses the + pinned Rust image and copies Node from the `.tool-versions` Node image (both + pinned by digest). `RUSTUP_TOOLCHAIN` pins the installed channel, so rustup + downloads nothing. +- **JS bundles.** #1236 proposes a separate Node stage that builds the JS + before Cargo. Here Node is copied into the Rust stage, and the existing tsjs + build script runs `npm ci` and the bundle build. The effect is the same, and + Node does not reach the final image. +- **Runtime.** + - Debian slim with CA certificates, UID `10001`, and an exec-form + entrypoint. Nothing runs a shell at runtime. + - No config, secrets or keys are baked in. Runtime inputs are the documented + `TRUSTED_SERVER_CONFIG_*` and `TRUSTED_SERVER_SECRET_*` environment + variables. + - The process writes nothing and needs no writable mounts, so it runs with a + read-only root filesystem. +- **TLS roots.** Trusted Server's origin fetches use compiled-in webpki roots. + Only EdgeZero's own client reads the system CA store. Switching to native + roots is left to #1236. +- **Shutdown.** `STOPSIGNAL SIGINT`, because EdgeZero v0.0.11 shuts down on + SIGINT only, and PID 1 ignores an unhandled SIGTERM. Drain and readiness wait + for stackpop/edgezero#392 and #1142. +- **Bind address.** Axum now reads EdgeZero's `EDGEZERO__ADAPTER__HOST` and + `EDGEZERO__ADAPTER__PORT` through `edgezero_core::addr::resolve_bind_addr`. + `PORT` is the port fallback, and the default stays `127.0.0.1:8787`. + - `PORT` is read only when no valid EdgeZero port is set. The image sets + `0.0.0.0:8080`, so a platform-injected `PORT`, valid or not, is ignored + there. + - Natively, `PORT` with `EDGEZERO__ADAPTER__HOST` now binds that host. + - `PORT=0` used to bind a random port. It is now rejected, as EdgeZero + rejects port 0; nothing in the repository used it. +- **Embedded version.** The source commit, as for the release binaries. +- **Publishing job.** `axum-image` refuses an existing tag at publish time. It + checks the pushed digest, then attaches build provenance to it with + `actions/attest`. + - The job holds `packages`, `id-token` and `attestations` write while + BuildKit runs the build. BuildKit does not expose those credentials to + build steps. + - Splitting the build from the push is a possible hardening. +- **Recipe drift.** The Dockerfile is not generated from the + stackpop/edgezero#401 recipe. A test keeps its Node and Rust tags equal to + the repository pins, and #1236's spec decides how it tracks that recipe. + +## Test plan + +- **`packages.test.mjs`:** + - mapping and plan; + - the exact `SHA256SUMS` check; + - the manifest check; + - first publish, re-run and partial-publish completion; + - refusal of differing tags; + - failing closed on registry errors; + - drafts; + - Dockerfile pins. +- **`phase1.test.mjs`:** each job is checked on its own: + - the publisher's checkout; + - the `packages` job's sparse checkout, pinned oras and lack of release-write + permission; + - the `axum-image` gate. +- **Axum unit tests:** bind precedence, fallback warnings and invalid `PORT`. +- **`test.yml` `axum-image` job** (every PR and push to `main`): + - builds the image; + - runs `scripts/smoke-axum.sh` against it with a read-only root and no + capabilities, covering the four missing-input cases and the positive + origin request; + - runs one case with the image's own listen defaults on a bridge network; + - checks the image user. +- **A fork rehearsal of a release tag.** + - It publishes all packages and the image. + - Each layer digest matches `SHA256SUMS`, and the packages are pullable + anonymously. + - `gh attestation verify` passes for a pulled CLI binary and for the image + digest. + +## Rollout + +1. **Rehearse.** Before enabling publication, a fork rehearsal of the exact + commit must pass, including a first publish to packages that did not exist. +2. **Merge.** Both jobs stay skipped until a maintainer sets + `PACKAGE_PUBLICATION_ENABLED`. +3. **After the first publish.** A maintainer checks each package's visibility + and repository link under the organization's Packages settings. + +## Failure and recovery + +- **`packages` fails partway.** Re-run the failed job. Matching tags are kept + and the rest are pushed. +- **A tag holds other content.** The job stops before pushing. A maintainer + investigates, deletes the wrong version, then re-runs. +- **`axum-image` fails after pushing** (for example during attestation). Delete + that package version, then re-run. The job refuses an existing tag rather + than attesting an image it cannot prove it built. + +## Review log + +### Round 1 (agent review, 2026-11-11) + +Nothing blocking. These findings were applied: + +- **TLS roots.** The "system CA store" claim was wrong; it is now corrected. +- **Bridge-network smoke case.** Added. It proves the image listens + off-loopback with its own defaults, and the smoke checks the image user. +- **Shutdown.** `STOPSIGNAL SIGINT`, because PID 1 ignored SIGTERM. +- **Existing tags.** "Absent" now means only not-found, and a re-run completes a + partial publish. The "write-once" wording now states that tags are mutable. +- **Recovery.** Steps for partial publishes and failed attestations, in the + spec and in the maintainer checklist. +- **Image verification.** The docs now verify an image by its digest, then run + that digest. +- **Scope.** The overlap with #897 and #1236 is stated, and the image is marked + development status. +- **Spec gaps filled.** Header, test plan, rollout, failure, and the + differences from #1236's proposed contract. +- **Smaller fixes.** + - A test pins the Dockerfile's Node and Rust tags to the repository pins. + - The image embeds the source commit. + - `SHA256SUMS` is authenticated before mirroring. + - The docs say `PORT` is ignored inside the image. + - `RUSTUP_TOOLCHAIN` stops rustup from downloading targets. + +Not changed: + +- Splitting the image build from its push. Recorded above as a possible + hardening. +- GHCR repository linking on the organization. Rollout checks it. + +### Round 2 (agent review, 2026-11-11) + +Nothing blocking. Round 2 confirmed every round 1 change in the code. These +findings were applied: + +- **Stale comment.** The workflow comment still said "write-once"; it is now + corrected. +- **`PORT` precedence.** `PORT` is now read only as the fallback, so a + malformed `PORT` no longer stops the image. `PORT=0` gets an error named for + `PORT`. +- **Not-found detection.** It now matches only `: not found`, in + both the script and the workflow. A test feeds it an unrelated + "credentials not found" message. +- **Exact match on re-run.** A kept tag must also have the empty config and no + `subject`. The `.37` manifest confirms what oras pushes. +- **ORAS prerequisite.** The guide now names ORAS and Docker Buildx. +- **First-publish rehearsal.** Rollout and the maintainer checklist now require + one before enabling publication. In rehearsal `.37`, GHCR answered + `: not found` for packages that did not exist yet, so the + anchored check accepts a first publish. + +Not changed: when a wait times out, the smoke does not print the container log. +The existing native cases behave the same way. diff --git a/scripts/release/packages.mjs b/scripts/release/packages.mjs index d62b568af3..fceb1694bf 100644 --- a/scripts/release/packages.mjs +++ b/scripts/release/packages.mjs @@ -3,7 +3,8 @@ // The packages job runs this file from the tagged commit after the release // job. It downloads the release by tag, checks every file against the // release's SHA256SUMS, and pushes each binary with its evidence files as one -// artifact. It never builds, signs or changes the release. +// artifact. It never builds, signs or changes the release. GHCR tags are +// mutable, so the write-once rule here applies only at publish time. import * as fs from 'node:fs' import { createHash } from 'node:crypto' import { execFileSync } from 'node:child_process' @@ -116,6 +117,8 @@ export function checkManifest(manifest, entry, sums) { const layers = manifest?.layers if ( manifest?.artifactType !== entry.artifactType || + manifest.config?.mediaType !== 'application/vnd.oci.empty.v1+json' || + manifest.subject !== undefined || !Array.isArray(layers) || layers.length !== entry.files.length ) @@ -135,19 +138,37 @@ export function checkManifest(manifest, entry, sums) { } /** - * Reports whether a reference already resolves, keeping tags write-once. + * Returns the manifest a reference resolves to, or null when it is absent. + * + * Only the registry's not-found answer means absent. Any other failure, such + * as a network or server error, stops the publish instead of risking an + * overwrite. */ -function exists(run, reference) { +function existingManifest(run, reference) { try { - run('oras', ['manifest', 'fetch', '--descriptor', reference]) - return true - } catch { - return false + return JSON.parse( + run('oras', ['manifest', 'fetch', reference], { + stdio: ['ignore', 'pipe', 'pipe'], + }) + ) + } catch (error) { + const stderr = String(error?.stderr ?? '') + // oras reports a registry 404 as `: not found`; any other + // message containing "not found", such as missing credentials, is not + // an answer about this tag. + const escaped = reference.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + if (new RegExp(`${escaped}: not found\\s*$`, 'm').test(stderr)) return null + throw Error(`Cannot check whether ${reference} exists: ${stderr.trim()}`, { + cause: error, + }) } } /** * Downloads the published release, checks it and pushes every artifact. + * + * A tag that already holds exactly the planned files is kept, so a re-run + * completes a partial publish. A tag with any other content is refused. */ export function publishPackages({ env, run = defaultRun, log = console.log }) { const repo = env.GH_REPO @@ -171,11 +192,32 @@ export function publishPackages({ env, run = defaultRun, log = console.log }) { throw Error(`Release ${tag} is not published`) const directory = fs.mkdtempSync(join(env.RUNNER_TEMP ?? '/tmp', 'release-')) run('gh', ['release', 'download', tag, '--repo', repo, '--dir', directory]) + // Authenticate SHA256SUMS against the release attestation, so the digests + // below come from the published release, not only from the download. + run('gh', [ + 'release', + 'verify-asset', + tag, + join(directory, 'SHA256SUMS'), + '--repo', + repo, + ]) const sums = checkDownload(directory, plan) - for (const entry of plan) - if (exists(run, entry.reference)) - throw Error(`Refusing to overwrite ${entry.reference}`) + const pending = [] for (const entry of plan) { + const existing = existingManifest(run, entry.reference) + if (existing === null) { + pending.push(entry) + continue + } + try { + checkManifest(existing, entry, sums) + } catch (error) { + throw Error(`Refusing to overwrite ${entry.reference}`, { cause: error }) + } + log('Package already published with the expected files:', entry.reference) + } + for (const entry of pending) { run( 'oras', [ diff --git a/scripts/release/packages.test.mjs b/scripts/release/packages.test.mjs index 1b011fa389..2398edaef0 100644 --- a/scripts/release/packages.test.mjs +++ b/scripts/release/packages.test.mjs @@ -39,6 +39,7 @@ function releaseDirectory() { function manifestFor(entry, sums) { return { artifactType: entry.artifactType, + config: { mediaType: 'application/vnd.oci.empty.v1+json' }, annotations: { ...entry.annotations, 'org.opencontainers.image.created': '2026-01-01T00:00:00Z', @@ -154,6 +155,15 @@ test('checks the pushed manifest layers, order and annotations', () => { /differs/, 'should reject reordered layers' ) + const referring = manifestFor(entry, sums) + referring.subject = { + mediaType: 'application/vnd.oci.image.manifest.v1+json', + } + assert.throws( + () => checkManifest(referring, entry, sums), + /Unexpected manifest/, + 'should reject a manifest that refers to another one' + ) const unlinked = manifestFor(entry, sums) delete unlinked.annotations['org.opencontainers.image.source'] assert.throws( @@ -163,11 +173,18 @@ test('checks the pushed manifest layers, order and annotations', () => { ) }) -test('publishes every package once from the published release', () => { - const directory = releaseDirectory() - const plan = packagePlan(repo, tag, commit) - const sums = checkDownload(directory, plan) - const pushed = new Map() +/** Returns an error shaped like a failed `oras` call with this stderr. */ +function registryError(stderr) { + return Object.assign(Error('Command failed'), { stderr }) +} + +/** Builds a fake `gh`/`oras` runner over an in-memory registry. */ +function fakeRunner( + directory, + plan, + sums, + { registry = new Map(), fail } = {} +) { const calls = [] const run = (command, args, options = {}) => { calls.push([command, ...args]) @@ -179,45 +196,67 @@ test('publishes every package once from the published release', () => { fs.copyFileSync(join(directory, name), join(target, name)) return '' } + if (command === 'gh' && args[1] === 'verify-asset') { + assert.match(args[3], /SHA256SUMS$/, 'should authenticate SHA256SUMS') + return '' + } if (command === 'oras' && args[0] === 'push') { - assert.equal( - options.cwd !== undefined, - true, - 'should push from the download' - ) + assert.ok(options.cwd, 'should push from the download') const entry = plan.find(({ reference }) => reference === args[1]) - pushed.set(args[1], manifestFor(entry, sums)) + registry.set(args[1], manifestFor(entry, sums)) return '' } if (command === 'oras' && args[0] === 'manifest') { const reference = args.at(-1) - if (!pushed.has(reference)) throw Error('not found') - return JSON.stringify(pushed.get(reference)) + if (fail?.(reference)) + throw registryError('Error: 503 Service Unavailable') + if (!registry.has(reference)) + throw registryError( + `Error response from registry: ${reference}: not found` + ) + return JSON.stringify(registry.get(reference)) } throw Error(`unexpected call ${command} ${args.join(' ')}`) } - const env = { - GH_REPO: repo, - RELEASE_TAG: tag, - SOURCE_COMMIT: commit, - PACKAGE_PUBLICATION_ENABLED: 'true', - RUNNER_TEMP: tmpdir(), - } - publishPackages({ env, run, log: () => {} }) + return { run, calls, registry } +} + +const enabledEnv = { + GH_REPO: repo, + RELEASE_TAG: tag, + SOURCE_COMMIT: commit, + PACKAGE_PUBLICATION_ENABLED: 'true', + RUNNER_TEMP: tmpdir(), +} + +test('publishes every package once from the authenticated release', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const { run, calls, registry } = fakeRunner(directory, plan, sums) + publishPackages({ env: enabledEnv, run, log: () => {} }) assert.deepEqual( - [...pushed.keys()], + [...registry.keys()], plan.map(({ reference }) => reference), 'should push one artifact per release binary' ) - assert.throws( - () => publishPackages({ env, run, log: () => {} }), - /Refusing to overwrite/, - 'should keep published tags write-once' + const verify = calls.findIndex((call) => call[2] === 'verify-asset') + const push = calls.findIndex((call) => call[1] === 'push') + assert.ok( + verify >= 0 && verify < push, + 'should authenticate SHA256SUMS before pushing' + ) + calls.length = 0 + publishPackages({ env: enabledEnv, run, log: () => {} }) + assert.equal( + calls.filter((call) => call[1] === 'push').length, + 0, + 'should keep matching tags on a re-run instead of pushing again' ) assert.throws( () => publishPackages({ - env: { ...env, PACKAGE_PUBLICATION_ENABLED: 'false' }, + env: { ...enabledEnv, PACKAGE_PUBLICATION_ENABLED: 'false' }, run, log: () => {}, }), @@ -226,6 +265,72 @@ test('publishes every package once from the published release', () => { ) }) +test('completes a partial publish and refuses differing tags', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const registry = new Map([[plan[0].reference, manifestFor(plan[0], sums)]]) + const partial = fakeRunner(directory, plan, sums, { registry }) + publishPackages({ env: enabledEnv, run: partial.run, log: () => {} }) + assert.deepEqual( + partial.calls.filter((call) => call[1] === 'push').map((call) => call[2]), + plan.slice(1).map(({ reference }) => reference), + 'should push only the missing packages' + ) + const changed = manifestFor(plan[1], sums) + changed.layers[0].digest = `sha256:${'0'.repeat(64)}` + const differing = fakeRunner(directory, plan, sums, { + registry: new Map([[plan[1].reference, changed]]), + }) + assert.throws( + () => + publishPackages({ env: enabledEnv, run: differing.run, log: () => {} }), + /Refusing to overwrite/, + 'should refuse a tag that holds other content' + ) + assert.equal( + differing.calls.filter((call) => call[1] === 'push').length, + 0, + 'should refuse before pushing anything' + ) +}) + +test('treats only the registry not-found answer for the tag as absent', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const { run } = fakeRunner(directory, plan, sums) + const credentials = (command, args, options) => { + if (command === 'oras' && args[0] === 'manifest') + throw registryError('Error: credentials not found in native keychain') + return run(command, args, options) + } + assert.throws( + () => publishPackages({ env: enabledEnv, run: credentials, log: () => {} }), + /Cannot check whether/, + 'should not read an unrelated "not found" as an absent tag' + ) +}) + +test('stops when the registry cannot say whether a tag exists', () => { + const directory = releaseDirectory() + const plan = packagePlan(repo, tag, commit) + const sums = checkDownload(directory, plan) + const flaky = fakeRunner(directory, plan, sums, { + fail: (reference) => reference === plan[2].reference, + }) + assert.throws( + () => publishPackages({ env: enabledEnv, run: flaky.run, log: () => {} }), + /Cannot check whether .* exists: Error: 503/, + 'should not treat a registry error as an absent tag' + ) + assert.equal( + flaky.calls.filter((call) => call[1] === 'push').length, + 0, + 'should push nothing after an unknown existence check' + ) +}) + test('refuses a draft release', () => { const run = (command, args) => { if (command === 'gh' && args[1] === 'view') @@ -248,3 +353,40 @@ test('refuses a draft release', () => { 'should publish packages only from a published release' ) }) + +test('keeps the Axum image on the repository tool pins', () => { + const read = (path) => + fs.readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8') + const dockerfile = read('crates/trusted-server-adapter-axum/Dockerfile') + const node = read('.tool-versions').match(/^nodejs\s+(\S+)$/m)[1] + const rust = read('rust-toolchain.toml').match(/^channel = "([^"]+)"$/m)[1] + assert.match( + dockerfile, + new RegExp( + `library/node:${node.replaceAll('.', '\\.')}-bookworm-slim@sha256:[0-9a-f]{64} ` + ), + 'should build with the .tool-versions Node, pinned by digest' + ) + assert.ok( + dockerfile.includes(`test "$(node --version)" = v${node};`), + 'should assert the copied Node version' + ) + assert.match( + dockerfile, + new RegExp( + `library/rust:${rust.replaceAll('.', '\\.')}-bookworm@sha256:[0-9a-f]{64} ` + ), + 'should build with the rust-toolchain.toml channel, pinned by digest' + ) + assert.ok( + dockerfile.includes(`ENV RUSTUP_TOOLCHAIN=${rust}\n`), + 'should pin rustup to the installed channel' + ) + assert.match( + dockerfile, + /library\/debian:bookworm-slim@sha256:[0-9a-f]{64}\n/, + 'should pin the runtime base by digest' + ) + assert.match(dockerfile, /\nUSER 10001:10001\n/, 'should run as UID 10001') + assert.match(dockerfile, /\nSTOPSIGNAL SIGINT\n/, 'should stop with SIGINT') +}) diff --git a/scripts/smoke-axum.sh b/scripts/smoke-axum.sh index 3632dc3ff7..baad564690 100755 --- a/scripts/smoke-axum.sh +++ b/scripts/smoke-axum.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash # Exercise the complete Axum local config and secret handoff. +# +# With AXUM_SMOKE_IMAGE set, every case runs that container image instead of +# the native binary: read-only root filesystem, no capabilities, host network +# so the stub origin and per-case ports stay on 127.0.0.1. Config and secrets +# reach the container by variable name, never on its command line. set -euo pipefail REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) @@ -18,20 +23,39 @@ ORIGIN_PORT=${AXUM_SMOKE_ORIGIN_PORT:-18889} BASE_PORT=${AXUM_SMOKE_PORT:-18890} ORIGIN_PID="" APP_PID="" +IMAGE=${AXUM_SMOKE_IMAGE:-} +CONTAINER="" -cleanup() { +stop_app() { + if [ -n "$CONTAINER" ]; then + docker rm --force "$CONTAINER" >/dev/null 2>&1 || true + CONTAINER="" + fi smoke_stop_process "$APP_PID" + APP_PID="" +} + +cleanup() { + stop_app smoke_stop_process "$ORIGIN_PID" smoke_remove_workspace "$WORKSPACE" } trap cleanup EXIT INT TERM smoke_resolve_ts_binary "$REPO_ROOT" -AXUM_BINARY=${AXUM_BINARY_PATH:-$REPO_ROOT/target/debug/trusted-server-axum} -if [ ! -x "$AXUM_BINARY" ]; then - cargo build --manifest-path "$REPO_ROOT/Cargo.toml" -p trusted-server-adapter-axum +if [ -n "$IMAGE" ]; then + smoke_require_command docker + IMAGE_USER=$(docker image inspect --format '{{.Config.User}}' "$IMAGE") || + smoke_die "image not found: $IMAGE" + [ "$IMAGE_USER" = "10001:10001" ] || + smoke_die "image must run as 10001:10001, found: ${IMAGE_USER:-root}" +else + AXUM_BINARY=${AXUM_BINARY_PATH:-$REPO_ROOT/target/debug/trusted-server-axum} + if [ ! -x "$AXUM_BINARY" ]; then + cargo build --manifest-path "$REPO_ROOT/Cargo.toml" -p trusted-server-adapter-axum + fi + [ -x "$AXUM_BINARY" ] || smoke_die "Axum binary not executable: $AXUM_BINARY" fi -[ -x "$AXUM_BINARY" ] || smoke_die "Axum binary not executable: $AXUM_BINARY" smoke_start_origin "$WORKSPACE" "$ORIGIN_PORT" ORIGIN_PID=$SMOKE_ORIGIN_PID @@ -80,16 +104,28 @@ run_case() { smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" - "${env_args[@]}" \ - PORT="$port" \ - "$AXUM_BINARY" >"$log_path" 2>&1 & + if [ -n "$IMAGE" ]; then + local name + local docker_env=(--env "EDGEZERO__ADAPTER__PORT=$port") + for name in "$SMOKE_CONFIG_ENV" "$SMOKE_HANDLER_ENV" "$SMOKE_PROXY_ENV" "$SMOKE_EC_ENV"; do + docker_env+=(--env "$name") + done + CONTAINER="trusted-server-axum-smoke-$case_name-$$" + "${env_args[@]}" docker run --rm --init --pull never --name "$CONTAINER" \ + --network host --read-only --cap-drop ALL \ + --security-opt no-new-privileges \ + "${docker_env[@]}" "$IMAGE" >"$log_path" 2>&1 & + else + "${env_args[@]}" \ + PORT="$port" \ + "$AXUM_BINARY" >"$log_path" 2>&1 & + fi APP_PID=$! smoke_wait_http "http://127.0.0.1:$port/" "$expected_status" \ "$body_path" "$headers_path" smoke_assert_process_alive "$APP_PID" "Axum" "$log_path" smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" - smoke_stop_process "$APP_PID" - APP_PID="" + stop_app if [ -n "$expected_diagnostic" ]; then smoke_assert_failure "$expected_status" "$expected_diagnostic" "$log_path" @@ -113,4 +149,23 @@ run_case missing-ec "$((BASE_PORT + 3))" "$ENVELOPE" \ run_case positive "$((BASE_PORT + 4))" "$ENVELOPE" \ "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 200 -echo "Axum first-success smoke passed" +# The cases above use the host network and override the port. This one keeps +# the image's own listen defaults on a bridge network, so it only answers if +# the server listens outside the container's loopback on port 8080. +if [ -n "$IMAGE" ]; then + DEFAULT_PORT=$((BASE_PORT + 5)) + CONTAINER="trusted-server-axum-smoke-default-$$" + docker run --rm --init --pull never --name "$CONTAINER" \ + --read-only --cap-drop ALL --security-opt no-new-privileges \ + --publish "127.0.0.1:$DEFAULT_PORT:8080" \ + "$IMAGE" >"$WORKSPACE/image-defaults.log" 2>&1 & + APP_PID=$! + smoke_wait_http "http://127.0.0.1:$DEFAULT_PORT/" 500 \ + "$WORKSPACE/image-defaults.body" "$WORKSPACE/image-defaults.headers" + smoke_assert_process_alive "$APP_PID" "Axum image" "$WORKSPACE/image-defaults.log" + stop_app + smoke_assert_failure 500 "env var '$SMOKE_CONFIG_ENV' not set" \ + "$WORKSPACE/image-defaults.log" +fi + +echo "Axum first-success smoke passed${IMAGE:+ in image $IMAGE}" From 321b1340431e8a15c8e073d5d9de8332856ac042 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 12 Nov 2026 16:00:21 +0530 Subject: [PATCH 4/4] Publish the Fastly deploy package with the Fastly wasm Phase 2 added trusted-server-fastly.tar.gz to the release and made it the documented Fastly deploy path, but the trusted-server-fastly GHCR artifact still carried only the wasm and its evidence. Add the archive as a gzip layer; the wasm's provenance and SBOM bundles already cover it. checkDownload now also refuses a release file that no package carries, so a new release asset stops the packages job instead of being silently left out. The tests built their release from the plan, so they could not see an omission; a new case adds an unplanned file. --- docs/guide/release-verification.md | 17 +++++++++---- .../2026-11-11-release-packages-design.md | 22 ++++++++++++---- scripts/release/packages.mjs | 25 ++++++++++++++++--- scripts/release/packages.test.mjs | 21 ++++++++++++++-- 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/docs/guide/release-verification.md b/docs/guide/release-verification.md index ef28b19775..bba1e3abf2 100644 --- a/docs/guide/release-verification.md +++ b/docs/guide/release-verification.md @@ -308,11 +308,11 @@ When a maintainer enables package publication, each release also appears in the repository's **Packages** list. These packages copy the release; they add no new trust. Verify the files you pull exactly as you verify release downloads. -| Package | Tag | Contents | -| ----------------------- | ---------------- | ------------------------------------------------------------------- | -| `trusted-server-cli` | `-` | `ts-`, its SPDX SBOM and both Sigstore bundles | -| `trusted-server-fastly` | `` | `trusted-server-fastly.wasm`, its SBOM, both bundles, rebuild check | -| `trusted-server-axum` | `` | Axum server container image for `linux/amd64` (development status) | +| Package | Tag | Contents | +| ----------------------- | ---------------- | ------------------------------------------------------------------ | +| `trusted-server-cli` | `-` | `ts-`, its SPDX SBOM and both Sigstore bundles | +| `trusted-server-fastly` | `` | the wasm, its `.tar.gz` package, SBOM, both bundles, rebuild check | +| `trusted-server-axum` | `` | Axum server container image for `linux/amd64` (development status) | Pulling the CLI and Fastly packages needs ORAS 1.2 or later; the image needs Docker with Buildx. Neither tool is trusted: the @@ -348,6 +348,13 @@ check the SBOM too, run the per-asset loop from [Download and verify](#download-and-verify) in the `pulled` directory. `oras pull` needs no login for a public package. +The Fastly package carries `trusted-server-fastly.tar.gz`, the archive that +[Deploy the verified Fastly package](#deploy-the-verified-fastly-package) +deploys. The wasm's provenance and SBOM bundles cover it. Pull +`trusted-server-fastly:$TAG` the same way, then complete that section's checks +on the pulled files, including `gh release verify-asset` for the archive, before +deployment. + The Axum image is an ordinary build of the tagged source, and Axum itself is still development status. The image is outside the reproducible release cohort: no rebuild check, SBOM or release asset covers it, only GitHub build provenance diff --git a/docs/superpowers/specs/2026-11-11-release-packages-design.md b/docs/superpowers/specs/2026-11-11-release-packages-design.md index f3622c1b50..8b54685178 100644 --- a/docs/superpowers/specs/2026-11-11-release-packages-design.md +++ b/docs/superpowers/specs/2026-11-11-release-packages-design.md @@ -10,7 +10,7 @@ ## Problem -A rehearsal release publishes 18 assets, and each run also lists 12 internal +A rehearsal release publishes 19 assets, and each run also lists 12 internal workflow artifacts. Operators could not tell which files to use. There was also no way to run the Axum server as a container: it only listened on loopback, and no image existed. @@ -19,9 +19,11 @@ no image existed. - Mirror each published release into GHCR as `trusted-server-cli` (tag `-`) and `trusted-server-fastly` (tag ``). Each package - holds one binary with its SPDX SBOM and Sigstore bundles, and the Fastly - package also holds the rebuild check. Every file is one OCI layer whose digest - is its `SHA256SUMS` entry. + holds one binary with its SPDX SBOM and Sigstore bundles. The Fastly package + also holds `trusted-server-fastly.tar.gz`, the package operators deploy, which + the wasm's bundles cover, and the rebuild check. Every release file except + `SHA256SUMS` is in exactly one package, and every file is one OCI layer whose + digest is its `SHA256SUMS` entry. - Publish the Axum server as `trusted-server-axum:`, a container image with GitHub build provenance, built to the image contract proposed in #1236. - Link each package to the source repository. @@ -70,7 +72,9 @@ chooses another recipe, the `axum-image` job is the one place to change. - `packages.mjs`: - refuses drafts; - authenticates `SHA256SUMS` with `gh release verify-asset`; - - requires the download to equal `SHA256SUMS` exactly. + - requires the download to equal `SHA256SUMS` exactly; + - refuses a release file that no package carries, so a new release asset + stops the job instead of being left out. - **Existing tags.** - Only the registry's `: not found` answer means a tag is absent. Any other @@ -235,3 +239,11 @@ findings were applied: Not changed: when a wait times out, the smoke does not print the container log. The existing native cases behave the same way. + +### Base update (2026-11-12) + +Phase 2 added `trusted-server-fastly.tar.gz` to the release and made it the +documented Fastly deploy path. The Fastly package now carries it, and +`checkDownload` refuses any release file that no package carries. Before this, +the plan omitted the archive and the tests could not notice, because their +fixture release was built from the plan itself. diff --git a/scripts/release/packages.mjs b/scripts/release/packages.mjs index fceb1694bf..d275580805 100644 --- a/scripts/release/packages.mjs +++ b/scripts/release/packages.mjs @@ -30,6 +30,9 @@ export function packageReference(owner, tag, asset) { /** * Lists the files one binary's artifact carries, with their media types. + * + * The Fastly artifact also carries `trusted-server-fastly.tar.gz`, the + * package operators deploy; the wasm's provenance and SBOM bundles cover it. */ export function packageFiles(asset) { const files = [ @@ -39,6 +42,14 @@ export function packageFiles(asset) { ? 'application/wasm' : 'application/octet-stream', }, + ...(asset === 'trusted-server-fastly.wasm' + ? [ + { + name: 'trusted-server-fastly.tar.gz', + mediaType: 'application/gzip', + }, + ] + : []), { name: `${asset}.spdx.json`, mediaType: 'application/spdx+json' }, { name: `${asset}.provenance.sigstore.json`, mediaType: sigstoreBundle }, { name: `${asset}.sbom.sigstore.json`, mediaType: sigstoreBundle }, @@ -80,7 +91,9 @@ export function packagePlan(repo, tag, commit) { * Checks a downloaded release directory against its SHA256SUMS. * * The directory must hold exactly the files SHA256SUMS lists plus SHA256SUMS - * itself, and every planned file must be listed. Returns name → sha256. + * itself, every planned file must be listed, and every listed file must be + * planned, so a new release asset cannot be left out of the packages + * unnoticed. Returns name → sha256. */ export function checkDownload(directory, plan) { const lines = fs @@ -104,9 +117,13 @@ export function checkDownload(directory, plan) { if (hash(fs.readFileSync(join(directory, name))) !== digest) throw Error(`Digest mismatch: ${name}`) } - for (const { files } of plan) - for (const { name } of files) - if (!sums.has(name)) throw Error(`Release lacks ${name}`) + const planned = new Set( + plan.flatMap(({ files }) => files.map(({ name }) => name)) + ) + for (const name of planned) + if (!sums.has(name)) throw Error(`Release lacks ${name}`) + for (const name of sums.keys()) + if (!planned.has(name)) throw Error(`No package carries ${name}`) return sums } diff --git a/scripts/release/packages.test.mjs b/scripts/release/packages.test.mjs index 2398edaef0..7b752e6e5f 100644 --- a/scripts/release/packages.test.mjs +++ b/scripts/release/packages.test.mjs @@ -82,12 +82,18 @@ test('plans every release binary with its evidence files', () => { wasm.files.map(({ name }) => name), [ 'trusted-server-fastly.wasm', + 'trusted-server-fastly.tar.gz', 'trusted-server-fastly.wasm.spdx.json', 'trusted-server-fastly.wasm.provenance.sigstore.json', 'trusted-server-fastly.wasm.sbom.sigstore.json', 'trusted-server-fastly.wasm.rebuild-check.json', ], - 'should carry the rebuild check with the Fastly wasm' + 'should carry the deployable package and rebuild check with the Fastly wasm' + ) + assert.equal( + wasm.files.find(({ name }) => name.endsWith('.tar.gz')).mediaType, + 'application/gzip', + 'should type the Fastly package archive as gzip' ) assert.equal( wasm.annotations['org.opencontainers.image.source'], @@ -109,7 +115,7 @@ test('plans every release binary with its evidence files', () => { test('accepts a release that matches its SHA256SUMS exactly', () => { const directory = releaseDirectory() const sums = checkDownload(directory, packagePlan(repo, tag, commit)) - assert.equal(sums.size, 17, 'should list every non-checksum release file') + assert.equal(sums.size, 18, 'should list every non-checksum release file') }) test('rejects changed, extra and missing release files', () => { @@ -140,6 +146,17 @@ test('rejects changed, extra and missing release files', () => { /Release lacks ts-x86_64-apple-darwin\.spdx\.json/, 'should require every planned evidence file' ) + const unplanned = releaseDirectory() + fs.writeFileSync(join(unplanned, 'new-asset.bin'), 'new') + fs.appendFileSync( + join(unplanned, 'SHA256SUMS'), + `${hash('new')} new-asset.bin\n` + ) + assert.throws( + () => checkDownload(unplanned, plan), + /No package carries new-asset\.bin/, + 'should refuse a release file that no package would publish' + ) }) test('checks the pushed manifest layers, order and annotations', () => {