From b012273f5f39c461ca716d5c2438c029e4e8798b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:30:37 -0700 Subject: [PATCH 01/10] build-app-cli cache: fail-closed image.json digest-pin validator First increment of the build-caching feature (sub-plan 1, Task 1) per docs/specs/edgezero-deploy-build-caching.md (v6.14) and docs/superpowers/plans/2026-08-20-build-cache-container.md. The pinned build container's platform-id keys the whole feature on a sha256 manifest digest, so check-image-pin.sh fails closed on a tag, missing digest, or malformed JSON. Colocated unit test: 6 cases, all green; shellcheck clean. --- .../deploy-core/tests/check-image-pin.test.sh | 45 +++++++++++++++++++ .../docker/build-app-cli/check-image-pin.sh | 40 +++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100755 .github/actions/deploy-core/tests/check-image-pin.test.sh create mode 100755 .github/docker/build-app-cli/check-image-pin.sh diff --git a/.github/actions/deploy-core/tests/check-image-pin.test.sh b/.github/actions/deploy-core/tests/check-image-pin.test.sh new file mode 100755 index 00000000..d363c2b9 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-image-pin.test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Unit tests for the build-container digest-pin validator (spec §3.6/§5). The +# validator must accept a sha256-digest-pinned image.json and FAIL CLOSED on a +# tag, a missing digest, or malformed JSON. +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +CHECK="$DIR/../../../docker/build-app-cli/check-image-pin.sh" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" + fail=$((fail + 1)) +} +run() { bash "$CHECK" "$1" >/dev/null 2>&1; } + +echo "== build container image.json digest-pin validator ==" + +printf '{"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/ok.json" +if run "$WORK/ok.json"; then ok "a digest-pinned reference passes"; else no "a digest-pinned reference passes"; fi + +printf '{"repository":"ghcr.io/x","tag":"v1","digest":"v1"}\n' >"$WORK/tag.json" +if run "$WORK/tag.json"; then no "a non-digest (tag) reference is rejected"; else ok "a non-digest (tag) reference is rejected"; fi + +printf '{"repository":"ghcr.io/x","tag":"v1","digest":"sha256:deadbeef"}\n' >"$WORK/short.json" +if run "$WORK/short.json"; then no "a short/invalid digest is rejected"; else ok "a short/invalid digest is rejected"; fi + +printf '{"repository":"ghcr.io/x","tag":"v1"}\n' >"$WORK/nodigest.json" +if run "$WORK/nodigest.json"; then no "a missing digest is rejected"; else ok "a missing digest is rejected"; fi + +printf '{"tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/norepo.json" +if run "$WORK/norepo.json"; then no "a missing repository is rejected"; else ok "a missing repository is rejected"; fi + +printf 'not json\n' >"$WORK/bad.json" +if run "$WORK/bad.json"; then no "malformed JSON fails closed"; else ok "malformed JSON fails closed"; fi + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/.github/docker/build-app-cli/check-image-pin.sh b/.github/docker/build-app-cli/check-image-pin.sh new file mode 100755 index 00000000..be6e068b --- /dev/null +++ b/.github/docker/build-app-cli/check-image-pin.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Fail-closed: the build container reference must be pinned by a sha256 manifest +# digest, never a mutable tag (spec docs/specs/edgezero-deploy-build-caching.md +# §3.6/§5). image.json records the canonical repository, tag, and pinned digest; +# the rest of the build-caching feature keys `platform-id` on that digest, so a +# non-digest or malformed pin must never pass. +# +# Usage: check-image-pin.sh +set -euo pipefail + +file="${1:?usage: check-image-pin.sh }" + +if ! command -v jq >/dev/null 2>&1; then + echo "::error::check-image-pin.sh requires jq" >&2 + exit 2 +fi + +# FAIL CLOSED on unreadable JSON: a file jq cannot parse must be rejected, never +# silently passed. +if ! json=$(jq -e . "$file" 2>/dev/null); then + echo "::error::$file is not valid JSON — refusing to pass an unreadable image pin" >&2 + exit 1 +fi + +repo=$(jq -r '.repository // empty' <<<"$json") +tag=$(jq -r '.tag // empty' <<<"$json") +digest=$(jq -r '.digest // empty' <<<"$json") + +if [[ -z "$repo" || -z "$tag" ]]; then + echo "::error::$file must set a non-empty string 'repository' and 'tag'" >&2 + exit 1 +fi + +# A sha256 manifest digest, never a tag. +if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::$file 'digest' must be a sha256 manifest digest (sha256:<64-hex>), not a tag: '$digest'" >&2 + exit 1 +fi + +echo "build container reference is pinned: $repo@$digest" From 55dcf07f1b2049fb4999933675096f4f0da80e80 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:58:02 -0700 Subject: [PATCH 02/10] Move build-caching spec to docs/superpowers/specs (superpowers convention) The build-caching design spec was authored via the brainstorming flow, whose specs live under docs/superpowers/specs alongside their plans (the container sub-plan is already in docs/superpowers/plans). Relocate it there from docs/specs and update the two references (the plan's Spec: link and the validator's comment). Vitepress builds clean; the validator test stays green. --- .github/docker/build-app-cli/check-image-pin.sh | 2 +- docs/superpowers/plans/2026-08-20-build-cache-container.md | 2 +- docs/{ => superpowers}/specs/edgezero-deploy-build-caching.md | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/{ => superpowers}/specs/edgezero-deploy-build-caching.md (100%) diff --git a/.github/docker/build-app-cli/check-image-pin.sh b/.github/docker/build-app-cli/check-image-pin.sh index be6e068b..3d3b2cc3 100755 --- a/.github/docker/build-app-cli/check-image-pin.sh +++ b/.github/docker/build-app-cli/check-image-pin.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Fail-closed: the build container reference must be pinned by a sha256 manifest -# digest, never a mutable tag (spec docs/specs/edgezero-deploy-build-caching.md +# digest, never a mutable tag (spec docs/superpowers/specs/edgezero-deploy-build-caching.md # §3.6/§5). image.json records the canonical repository, tag, and pinned digest; # the rest of the build-caching feature keys `platform-id` on that digest, so a # non-digest or malformed pin must never pass. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-container.md b/docs/superpowers/plans/2026-08-20-build-cache-container.md index eb9a0df6..93013f32 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -8,7 +8,7 @@ **Tech Stack:** Docker (BuildKit), GitHub Actions (`docker/build-push-action`), GHCR, Bash, `jq`. -**Spec:** `docs/specs/edgezero-deploy-build-caching.md` (v6.14, sccache pivot) — §2 (single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). +**Spec:** `docs/superpowers/specs/edgezero-deploy-build-caching.md` (v6.14, sccache pivot) — §2 (single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). ## Global Constraints diff --git a/docs/specs/edgezero-deploy-build-caching.md b/docs/superpowers/specs/edgezero-deploy-build-caching.md similarity index 100% rename from docs/specs/edgezero-deploy-build-caching.md rename to docs/superpowers/specs/edgezero-deploy-build-caching.md From aac4448578eb3df07d9ccde049ed2e4b4e7e01c5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:03:04 -0700 Subject: [PATCH 03/10] Timestamp the build-caching spec filename to match the superpowers convention Siblings in docs/superpowers/specs are dated YYYY-MM-DD--design.md; rename edgezero-deploy-build-caching.md to 2026-08-20-edgezero-deploy-build-caching-design.md (its authoring/plan date) and update the plan link + validator comment. --- .github/docker/build-app-cli/check-image-pin.sh | 2 +- docs/superpowers/plans/2026-08-20-build-cache-container.md | 2 +- ...ng.md => 2026-08-20-edgezero-deploy-build-caching-design.md} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/superpowers/specs/{edgezero-deploy-build-caching.md => 2026-08-20-edgezero-deploy-build-caching-design.md} (100%) diff --git a/.github/docker/build-app-cli/check-image-pin.sh b/.github/docker/build-app-cli/check-image-pin.sh index 3d3b2cc3..df9176e8 100755 --- a/.github/docker/build-app-cli/check-image-pin.sh +++ b/.github/docker/build-app-cli/check-image-pin.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Fail-closed: the build container reference must be pinned by a sha256 manifest -# digest, never a mutable tag (spec docs/superpowers/specs/edgezero-deploy-build-caching.md +# digest, never a mutable tag (spec docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md # §3.6/§5). image.json records the canonical repository, tag, and pinned digest; # the rest of the build-caching feature keys `platform-id` on that digest, so a # non-digest or malformed pin must never pass. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-container.md b/docs/superpowers/plans/2026-08-20-build-cache-container.md index 93013f32..ba89d5e6 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -8,7 +8,7 @@ **Tech Stack:** Docker (BuildKit), GitHub Actions (`docker/build-push-action`), GHCR, Bash, `jq`. -**Spec:** `docs/superpowers/specs/edgezero-deploy-build-caching.md` (v6.14, sccache pivot) — §2 (single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` (v6.14, sccache pivot) — §2 (single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). ## Global Constraints diff --git a/docs/superpowers/specs/edgezero-deploy-build-caching.md b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md similarity index 100% rename from docs/superpowers/specs/edgezero-deploy-build-caching.md rename to docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md From 599ffefb1d399806a701f2b4bc0b8a5c4bd136e6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:14:35 -0700 Subject: [PATCH 04/10] check-image-pin: reject non-string repository/tag/digest (jq -r coercion gap) The validator used jq -r, which coerces a numeric field to a string, so a {"repository": 123, "tag": 1} would pass despite the contract requiring strings. Assert the JSON type is string for repository, tag, and digest before the value checks, and add a wrong-type test case. 7/7 green, shellcheck clean. --- .../deploy-core/tests/check-image-pin.test.sh | 3 +++ .github/docker/build-app-cli/check-image-pin.sh | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/actions/deploy-core/tests/check-image-pin.test.sh b/.github/actions/deploy-core/tests/check-image-pin.test.sh index d363c2b9..c34fd98d 100755 --- a/.github/actions/deploy-core/tests/check-image-pin.test.sh +++ b/.github/actions/deploy-core/tests/check-image-pin.test.sh @@ -38,6 +38,9 @@ if run "$WORK/nodigest.json"; then no "a missing digest is rejected"; else ok "a printf '{"tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/norepo.json" if run "$WORK/norepo.json"; then no "a missing repository is rejected"; else ok "a missing repository is rejected"; fi +printf '{"repository":123,"tag":1,"digest":"sha256:%064d"}\n' 0 >"$WORK/numeric.json" +if run "$WORK/numeric.json"; then no "numeric (non-string) repository/tag is rejected"; else ok "numeric (non-string) repository/tag is rejected"; fi + printf 'not json\n' >"$WORK/bad.json" if run "$WORK/bad.json"; then no "malformed JSON fails closed"; else ok "malformed JSON fails closed"; fi diff --git a/.github/docker/build-app-cli/check-image-pin.sh b/.github/docker/build-app-cli/check-image-pin.sh index df9176e8..ec619940 100755 --- a/.github/docker/build-app-cli/check-image-pin.sh +++ b/.github/docker/build-app-cli/check-image-pin.sh @@ -22,12 +22,21 @@ if ! json=$(jq -e . "$file" 2>/dev/null); then exit 1 fi -repo=$(jq -r '.repository // empty' <<<"$json") -tag=$(jq -r '.tag // empty' <<<"$json") -digest=$(jq -r '.digest // empty' <<<"$json") +# Require string TYPES: `jq -r` would coerce a numeric repository/tag/digest to a +# string, so a `"repository": 123` would otherwise slip through. Check the JSON type. +if [[ "$(jq -r '.repository | type' <<<"$json")" != "string" || + "$(jq -r '.tag | type' <<<"$json")" != "string" || + "$(jq -r '.digest | type' <<<"$json")" != "string" ]]; then + echo "::error::$file 'repository', 'tag', and 'digest' must all be JSON strings" >&2 + exit 1 +fi + +repo=$(jq -r '.repository' <<<"$json") +tag=$(jq -r '.tag' <<<"$json") +digest=$(jq -r '.digest' <<<"$json") if [[ -z "$repo" || -z "$tag" ]]; then - echo "::error::$file must set a non-empty string 'repository' and 'tag'" >&2 + echo "::error::$file must set a non-empty 'repository' and 'tag'" >&2 exit 1 fi From 9f7526b7d240e814c32df889975b25db10988ed3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:14:47 -0700 Subject: [PATCH 05/10] build-caching spec v6.15 + plan: address the v6.14 review's findings Harden the sccache design toward plan-ready. env: add PATH and RUSTUP_HOME and an absolute RUSTC_WRAPPER so rustc starts under env -i (rustup-image layout) (1). Narrow the sccache correctness claim (it hashes dep-info/args/deps/env/cwd) and make the undeclared-input proc-macro/build.rs risk an explicit cache opt-in (2). Bounded, collision-free generation: run_id-run_attempt-artifact, SCCACHE_CACHE_SIZE 2G, sccache --stop-server before save, aggregate bounded by GitHub's LRU (3). A complete FIXED mount table with a constant /work/app cwd so sccache's cwd hash is stable across host paths (4). Prove the writable /work/app is a faithful copy (content/modes/symlinks/ submodules, hardlinks broken) and state build/deploy use separate container instances (5). Warm test via sccache --show-stats ONLINE (dependency sources are not cached, so the network cannot be disabled for the fetch) (6). Public, anonymously-fetchable sources only; private auth is out of scope (7). RFC 8785 (JCS) canonical JSON and ustar-only archive with binary-size equality (8). Full 40-hex app-ref and length-framed hash encodings with golden vectors (9). Hardened validator smoke: --cap-drop=ALL, no-new-privileges, memory/pids/ timeout (10). Plan: fix the first-publish deadlock (authenticated smoke in the workflow; anonymous pull is the operator's post-make-public step) and drop the stale four-root-prune language (11). Design only. --- .../plans/2026-08-20-build-cache-container.md | 14 +- ...20-edgezero-deploy-build-caching-design.md | 235 +++++++++++------- 2 files changed, 159 insertions(+), 90 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-build-cache-container.md b/docs/superpowers/plans/2026-08-20-build-cache-container.md index ba89d5e6..9ba6e18b 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -277,8 +277,10 @@ jobs: n=$(docker buildx imagetools inspect "$REF" --format '{{json .}}' \ | jq '[.. | .manifests? // empty | .[] | select(.platform.os != "unknown")] | length') [ "${n:-1}" -le 1 ] || { echo "::error::not single-manifest ($n)"; exit 1; } - # Anonymous pull (the package must be public) + the runtime smoke contract. - docker logout ghcr.io || true + # Runtime smoke, pulled with the AUTHENTICATED session (a GHCR package is + # PRIVATE on first publish, so an anonymous pull here would deadlock the very + # first release). The anonymous-pull check is the operator's post-make-public + # step below, once the package visibility is public. docker run --rm --platform linux/amd64 "$REF" rustc --version | grep -F '1.95.0' docker run --rm --platform linux/amd64 "$REF" sh -c 'rustc --print target-list | grep -qx wasm32-wasip1' docker run --rm --platform linux/amd64 "$REF" fastly version @@ -301,10 +303,10 @@ jobs: git push -u origin "$br" gh pr create --fill --base main --head "$br" \ --title "Pin build container ${GITHUB_REF_NAME}" \ - --body "Digest verified by the publish workflow (single-manifest, anonymous pull, runtime smoke)." + --body "Digest verified by the publish workflow (single-manifest + authenticated runtime smoke). Anonymous-pull verification is the operator's post-make-public step." ``` -The publish thus **pushes → inspects by digest → verifies single-manifest + anonymous pull + the runtime smoke → then opens a reviewable `image.json` PR** — the pin the rest of the feature keys on is never recorded until it has been proven against the actual pushed digest. +The publish thus **pushes → inspects by digest → verifies single-manifest + the runtime smoke (authenticated) → then opens a reviewable `image.json` PR** — the pin the rest of the feature keys on is never recorded until it has been proven against the actual pushed digest. The **anonymous** pull is verified separately, after the operator makes the package public (below), avoiding a first-publish deadlock. - [ ] **Step 2: Actionlint the workflow** @@ -320,7 +322,7 @@ git commit -m "build-cache container: GHCR publish workflow recording the manife - [ ] **Step 4: Publish (operator step, out of band)** -Tag `build-container-v1` and push it. The workflow pushes the image, **verifies it by digest** (single-manifest, anonymous pull, runtime smoke), and **opens a PR** updating `image.json` to the real `sha256` digest. Review and merge that PR — the digest is the pin the rest of the feature keys on, and it is only recorded after passing verification against the actual pushed image. +Tag `build-container-v1` and push it. The workflow pushes the image, **verifies it by digest** (single-manifest + an **authenticated** runtime smoke — the package is private on first publish), and **opens a PR** updating `image.json` to the real `sha256` digest. **Make the GHCR package public** (below), then verify the **anonymous** pull. Review and merge the PR — the digest is the pin the rest of the feature keys on, and it is only recorded after passing verification against the actual pushed image. **One-time GHCR visibility + retention (operator):** GHCR packages are **private on first publish** and there is no clean REST endpoint to flip a container package public, so set the package `edgezero-build-app-cli` to **public** in its GHCR package settings (or set the org's default package visibility) so consumers can **anonymously** pull by digest (spec §3.7), and enable a retention policy that never prunes a digest referenced by a committed `image.json`. Verify anonymous access: ```bash @@ -390,4 +392,4 @@ git commit -m "build-cache container: gate the build-container digest pin in the ## Downstream sub-plans (not written yet) -2. Cached build path (reusable workflow + `prepare`/`compile` split + **owned `actions/cache` restore+save with the four-root prune** + config/source closure, spec §3.4/§3.8). 3. Provenance (JSON Schema + procedural validation, `validate-app-cli-provenance`, `compute-app-cli-identity`, `ExpectedIdentity`). 4. Consumer integration (`active-version-fastly`, per-consumer `ExpectedIdentity` inputs, the Docker launcher, production-only recovery). Each is its own plan; sub-plan 2 consumes this container's digest as `platform-id`. +2. Cached build path (reusable workflow + `prepare`/`compile` split + **an action-owned `sccache` disk cache**: fresh `CARGO_TARGET_DIR` + owned `actions/cache` restore/save over `SCCACHE_DIR` under a bounded rolling generation key + the constructed minimal env + config/source closure, spec §3.1–§3.4/§3.8). 3. Provenance (JCS canonical JSON + JSON Schema + procedural validation, `validate-app-cli-provenance`, `compute-app-cli-identity`, `ExpectedIdentity`). 4. Consumer integration (`active-version-fastly`, per-consumer `ExpectedIdentity` inputs, the Docker launcher, production-only recovery). Each is its own plan; sub-plan 2 consumes this container's digest as `platform-id`. diff --git a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md index 6dee7a89..5586a6b2 100644 --- a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md +++ b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md @@ -1,6 +1,6 @@ # EdgeZero Deploy Actions — Build Caching Spec -**Status:** Design (proposed) — v6.14 (sccache pivot) +**Status:** Design (proposed) — v6.15 (sccache pivot, hardened) **Related:** `docs/specs/edgezero-deploy-github-action.md`, `docs/specs/edgezero-deploy-action-implementation-plan.md`, @@ -39,61 +39,90 @@ for shared dependency acceleration: - **`CARGO_TARGET_DIR` is FRESH every run** (an action-owned path under `RUNNER_TEMP`, never cached, never inside the checkout) — so there is no stale-`target/`, no source-in-target, no workspace-crate-output, and no unit-graph classification problem. -- **`RUSTC_WRAPPER` is set (action-owned) to a pinned `sccache`** baked into the container. - `sccache` stores compiled rustc outputs in `SCCACHE_DIR` (an action-owned path), **keyed by the - content of the preprocessed source + compiler + flags**. Correctness is content-addressed: - restoring an older `SCCACHE_DIR` is always safe (a cached object is used only when its inputs - match), so there is no immutable-cache staleness and **no custom pruning**. `sccache` bounds its - own size (`SCCACHE_CACHE_SIZE`, LRU) — the cached directory is self-managing. +- **`RUSTC_WRAPPER` is set (action-owned) to the pinned `sccache`** (an **absolute path**, + `/usr/local/bin/sccache`, §3.3) baked into the container. sccache keys a rustc invocation on its + **preprocessed source, `dep-info` inputs, compiler arguments, dependency artifacts, a subset of + the environment, and the working directory** (v0.10) — so a cached object is reused only when all + of those match, and **restoring an older `SCCACHE_DIR` never yields an incorrect object**. + **Correctness caveat (opt-in risk):** sccache's own Rust guidance warns it may **not** cache + correctly when a **`build.rs` or a proc-macro reads files or environment not declared as inputs** + (undeclared inputs). v1 does not detect this; enabling `cache: true` is an **explicit acceptance** + that the app's build scripts/proc-macros declare their inputs (documented on the input). No custom + pruning; `SCCACHE_CACHE_SIZE` bounds each snapshot (§3.2). - **Cache contents = `SCCACHE_DIR` only** (compiled objects + sccache's index). **No `.crate` sources, no `registry/src`, no `git/*`, no `CARGO_HOME/bin`, no config, no credentials** are cached — so a cold build's `registry/src` extraction is irrelevant to the audit, and **no dependency source is ever cached** (only compiled objects). Re-downloading crates each run is the small remaining cost; caching `.crate` archives is §7. -- **Any dependency source is supported** (crates.io, the public **EdgeZero git repo** the generator - emits, other git deps) — sccache caches their compilation regardless of source. The old - crates.io-only restriction is **removed**; `cache: false` and `cache: true` resolve dependencies +- **Public, anonymously-fetchable sources only.** sccache caches the compilation of any source, but + the minimal build environment (§3.3) carries **no credentials**, so the dependency graph must be + **anonymously fetchable** — `crates.io` and **public git** (e.g. the public EdgeZero repo the + generator emits). Private git/registries, SSH auth, `.netrc`, and credential providers are **not + supported** (a credential design is §7); `cache: false` and `cache: true` resolve dependencies identically (caching never changes resolution). ### 3.2 Own restore + save, coarse rolling key `actions/cache/restore` + `save` over **`SCCACHE_DIR` only**: -- **Key** = `edgezero-sccache-v1---`, restore-keys prefix - `edgezero-sccache-v1---`. `` is `github.run_id` (unique per - run), so each run **saves a fresh generation** (never colliding with an immutable prior entry) - and **restores the newest matching prefix**. `platform-id` = the container digest (which encodes - toolchain + ABI); `suffix-hash` = the validated `cache-key-suffix`. No lockfile/manifest hashing - is needed — sccache content-addresses internally. -- **Restore → audit → build → best-effort save.** After restore, **audit** that the restored path - is exactly `SCCACHE_DIR` and contains only sccache's blob/index layout (fail closed / **discard - and build cold once** on a corrupt or unexpected restore). After the build, `actions/cache/save` - under the run's `` key is **best-effort** (its failures are warnings). Bump the - `-v1-` namespace whenever the mechanism changes. +- **Key** = `-`, `` = `edgezero-sccache-v1--`, + restore-keys prefix `-`. `` = `--` + — `run_attempt` distinguishes **re-runs** (which keep the same `run_id`) and `app-cli-artifact` + (unique per matrix leg, §3.8) distinguishes **matrix legs**, so no two saving jobs collide on a + key, and each restores the newest entry in its ``. `platform-id` = the container digest; + `suffix-hash` = the validated `cache-key-suffix` (§3.8). No lockfile/manifest hashing — sccache + content-addresses internally. +- **Bounded storage.** `SCCACHE_CACHE_SIZE` is a fixed **2 GiB** (action-owned), so each saved + snapshot is bounded well under GitHub's **10 GiB per-repository** cache limit; aggregate storage + is bounded by GitHub's own LRU eviction over the family's generations (older generations are + evicted; a busy repo may re-warm occasionally — an accepted cost of the rolling scheme). +- **Restore → audit → build → stop-server → best-effort save.** After restore, **audit** that the + restored path is exactly `SCCACHE_DIR` and contains only sccache's blob/index layout (**discard + and build cold once** on a corrupt/unexpected restore). Run `sccache --show-stats` for + observability. Before save, **`sccache --stop-server`** flushes and shuts the server down so + `SCCACHE_DIR` is consistent on disk. `actions/cache/save` under the run's `` key is + **best-effort** (failures are warnings). Bump the `-v1-` family namespace whenever the mechanism + changes. ### 3.3 Action-owned Cargo/sccache environment The build runs under a **constructed minimal environment** (`env -i` + an explicit allowlist), not scrub-then-reject, so there is nothing to miss: only the action-owned variables and an -allowlist of benign ones exist. Action-owned (fixed, exact): `CARGO_HOME`, `CARGO_TARGET_DIR` -(fresh), `HOME`, `TMPDIR`, `SCCACHE_DIR`, `SCCACHE_CACHE_SIZE`, `RUSTC_WRAPPER=sccache`, -`RUSTUP_TOOLCHAIN`, `CARGO_ENCODED_RUSTFLAGS=""`, `CARGO_INCREMENTAL=0`. A **caller-supplied** -`RUSTC`/`RUSTC_WRAPPER`/`RUSTC_WORKSPACE_WRAPPER`/`RUSTDOC`/`RUSTFLAGS`/native-tool var simply is -**not present** in the constructed env (never inherited). The effective **Cargo config** over the -full chain (cwd → `/`, incl. the working directory, plus `CARGO_HOME`) must contain only benign -allowlisted keys (registry index URLs, `net.retry`, `http.timeout`/`check-revoke`); anything else -fails closed. Default-features-only; `Cargo.lock` must be a tracked, regular file. External path -deps outside the workspace root are rejected. Fixed internal container paths (`CARGO_HOME`, -`CARGO_TARGET_DIR`, `HOME`, writable `/tmp`). +allowlist of benign ones exist. Action-owned (fixed, exact values — the rustup-image layout means +`PATH` and `RUSTUP_HOME` are **required** for rustc to start): `PATH=/usr/local/cargo/bin:/usr/bin:/bin`, +`RUSTUP_HOME=/usr/local/rustup`, `CARGO_HOME` (§below), `RUSTC_WRAPPER=/usr/local/bin/sccache` +(absolute), `RUSTUP_TOOLCHAIN`, `CARGO_TARGET_DIR` (fresh), `SCCACHE_DIR`, `SCCACHE_CACHE_SIZE=2G`, +`HOME`, `TMPDIR`, `CARGO_ENCODED_RUSTFLAGS=""`, `CARGO_INCREMENTAL=0`. A **caller-supplied** +`RUSTC`/`RUSTC_WRAPPER`/`RUSTC_WORKSPACE_WRAPPER`/`RUSTDOC`/`RUSTFLAGS`/native-tool/`PATH` var simply +is **not present** in the constructed env (never inherited). + +**Cache-hit stability requires ALL sccache hash inputs to be fixed across runs** (v0.10 hashes the +**cwd** too, so a varying path turns every warm build cold). The container therefore fixes, at +**constant in-container paths regardless of the host checkout location**: the writable working copy +at **`/work/app`** (the compile **cwd**, §3.6), `CARGO_TARGET_DIR=/work/target`, +`CARGO_HOME=/work/cargo-home`, `SCCACHE_DIR=/work/sccache`, `HOME=/work/home`, `TMPDIR=/work/tmp` +(writable tmpfs). Identical source built from different host paths must produce sccache hits (§4). + +The effective **Cargo config** over the full chain (cwd → `/`, incl. the working directory, plus +`CARGO_HOME`) must contain only benign allowlisted keys (registry index URLs, `net.retry`, +`http.timeout`/`check-revoke`); anything else fails closed. Default-features-only; `Cargo.lock` must +be a tracked, regular file. External path deps outside the workspace root are rejected. ### 3.4 Identity `git-root` (path, confinement); `app-repo` (`owner/repo`); **`app-repo-id`** (canonical decimal **string**, always required, **verified via the GitHub REST API to belong to `app-repository`**). -`workspace-root` canonicalized, confined beneath `git-root`, `working-directory` beneath it, -asserted `== cargo metadata.workspace_root`. `workspace-id` = hash(`app-repo-id`, workspace-root -rel `git-root`). `platform-id` = the container digest, **read inside every action from `image.json` -at the same EdgeZero SHA — never caller-supplied**; `container-ref` = `@`. +`app-ref` must be a **full 40-hex commit SHA** (short refs/branches/tags rejected). `workspace-root` +canonicalized, confined beneath `git-root`, `working-directory` beneath it, asserted +`== cargo metadata.workspace_root`. + +**All identity hashes are SHA-256 over a canonical, length-framed encoding** — each field encoded as +its UTF-8 bytes prefixed by its byte length as a fixed-width decimal (so no field boundary is +ambiguous), fields concatenated in a fixed order. `workspace-id` = that hash over +(`app-repo-id`, workspace-root path relative to `git-root`); `suffix-hash` = that hash over the +validated `cache-key-suffix`. **Golden vectors** for each hash are committed with the plan. +`platform-id` = the container digest, **read inside every action from `image.json` at the same +EdgeZero SHA — never caller-supplied**; `container-ref` = `@`. ### 3.5 Writer fidelity vs. source authorization @@ -111,52 +140,70 @@ identity, and every writer of the deployer's **current-/default-branch** cache s mounts only. `platform-id` = its digest. - **Runner: GitHub-hosted `linux/amd64` only** (fail closed on self-hosted). Host-level job, local Docker daemon. -- **One launcher `run-app-cli-in-container`** with **enumerated mounts** (never `RUNNER_TEMP` - wholesale): - - **Writable working COPY of the checkout.** The CLI runs arbitrary manifest commands via - `sh -c` in the manifest root and may create `dist/`, `node_modules/`, generated manifests, - etc. — so the working directory is a **disposable writable copy (or overlay)** of the app - checkout, not read-only source. The **read-only original** is used for the before/after source - checks (§3.7). (v1 alternative: prohibit manifest-command overrides; the writable overlay is - preferred.) - - **Other writable (specific):** `CARGO_TARGET_DIR`, `CARGO_HOME`, `SCCACHE_DIR`, a Fastly/ - provider `HOME`, a package/output dir. **Read-only:** the validated CLI binary, and — for - config-push — the **specific inline-config temp file** (by exact path). UID/GID mapping so the - non-root container user owns the mounts. - - **env:** only the required provider token + `EDGEZERO_*`; no GitHub file-command channels - inside the container. +- **Separate container instances.** The credential-free **build** and the token-bearing **deploy** + run in **distinct container instances** (never one long-lived container); the build instance holds + no provider token. +- **One launcher `run-app-cli-in-container`** with a **complete fixed mount table** (constant + in-container paths, so sccache's cwd/path hashing is stable regardless of the host checkout + location; never `RUNNER_TEMP` wholesale): + + | In-container path | Mode | Source | + | --- | --- | --- | + | `/work/app` (compile cwd) | **writable** | a **verified faithful copy** of the app checkout | + | `/work/target` | writable | fresh `CARGO_TARGET_DIR` | + | `/work/cargo-home` | writable | `CARGO_HOME` | + | `/work/sccache` | writable | `SCCACHE_DIR` (restored) | + | `/work/home`, `/work/tmp` | writable (tmpfs) | provider/Fastly `HOME`, `TMPDIR` | + | the package/output dir | writable | staged CLI / Fastly `pkg/` | + | the validated CLI binary | read-only | consumer input | + | the specific inline-config temp file | read-only | config-push only, by exact path | + + UID/GID mapping so the non-root container user owns the writable mounts. + - **Writable working COPY.** The CLI runs arbitrary manifest commands via `sh -c` in the manifest + root and may create `dist/`, `node_modules/`, generated manifests — so `/work/app` is a + disposable writable copy. The copy is a **verified faithful copy of the read-only original** — + equivalent in content, file modes, symlink targets, and submodule state, with **hardlinks broken** + (a real copy, e.g. `cp -a` + a content-hash comparison, not a bind of the original) — so the bytes + compiled are exactly the frozen source (§3.7). + - **env:** only the required provider token + `EDGEZERO_*`; no GitHub file-command channels inside + the container. - **signals/outputs:** host↔container readiness handshake; **`mutation-attempted` published host-side to `$GITHUB_OUTPUT` before launching the mutating CLI**; named container + host-side - signal forwarding (`docker stop -t ` → `docker rm`) - - post-cancel reconciliation. + signal forwarding (`docker stop -t ` → `docker rm`) + + post-cancel reconciliation. ### 3.7 Source freezing, provenance, disclosure, actions -- **Source freezing:** on the **read-only original** checkout, assert the initial `HEAD` SHA - unchanged + tree clean (tracked + untracked + recursive submodules) **before and after** all - app-controlled commands (commands run in the writable copy); reject escaping symlinks. Consumers - additionally **verify their mounted checkout's repository id, `HEAD`, and workspace against the - artifact before and after commands**. +- **Source freezing:** the writable `/work/app` copy is proven a **faithful copy** of the read-only + original (§3.6) before compilation, so the frozen source and the executed bytes are the same. On + the **read-only original**, assert the initial `HEAD` SHA unchanged + tree clean (tracked + + untracked + recursive submodules) **before and after** all app-controlled commands; reject escaping + symlinks. Consumers additionally **verify their mounted checkout's repository id, `HEAD`, and + workspace against the artifact before and after commands**. - **`ExpectedIdentity`:** `app-repo-id` (decimal string), `source-revision` (full SHA, explicit), `app-cli-package`, `app-cli-bin`, `workspace-id` — **caller-supplied and checkout-verified**; `platform-id`/`container-ref` are **derived inside every action from same-SHA `image.json`, not accepted from the caller**. -- **Schema/canonicalization (normative, with golden vectors):** `app-cli-meta.json` is - **canonical JSON** — UTF-8, keys **lexicographically sorted at every level**, no duplicate keys - (a duplicate-key-rejecting parser is required; JSON Schema cannot do this), minimal number/string - forms — validated by a committed **JSON Schema 2020-12** file **plus** the procedural - canonical/dup-key pass. Numeric caps: meta ≤ **64 KiB**. Fields = `ExpectedIdentity` + - `app-cli-version` (informational) + `binary-sha256` + `binary-size` + `abi` - (`{ machine, interp, needed: [sorted str] }`). -- **Archive contract (normative):** a **`ustar`/`pax` tar** with **exactly two** regular members, - `app-cli-meta.json` then the `app-cli-bin` binary — **any extra/duplicate/renamed member, - symlink, hardlink, device, or path-traversal header is rejected**; total logical size ≤ **512 - MiB**, binary ≤ `binary-size`, meta ≤ 64 KiB; the extracted binary's sha256/size re-verified. -- **`validate-app-cli-provenance`** (fresh pinned container, minimal env): enforce the archive - contract; canonical-JSON + JSON-Schema validate; re-verify binary digest/size; **ABI loadability +- **Schema/canonicalization (normative, with golden vectors):** `app-cli-meta.json` is **canonical + JSON per RFC 8785 (JCS)** — the exact escaping, number serialization, key ordering, and whitespace + rules are JCS's, not "minimal forms" — and duplicate keys are **rejected before parse** (JSON + Schema cannot). It is validated by a committed **JSON Schema 2020-12** file **plus** the JCS + + dup-key procedural pass. Meta ≤ **64 KiB**. Fields = `ExpectedIdentity` + `app-cli-version` + (informational) + `binary-sha256` + `binary-size` + `abi` (`{ machine, interp, needed: [sorted str] }`). +- **Archive contract (normative):** a **deterministic `ustar` tar** (POSIX ustar **only** — `pax` + extended headers are **rejected**, so there is no ambiguous PAX extension surface) with **exactly + two** regular members, `app-cli-meta.json` then the `app-cli-bin` binary — any extra/duplicate/ + renamed member, any symlink/hardlink/device/global-extended header, trailing bytes, or + path-traversal name is **rejected**; total logical size ≤ **512 MiB**, meta ≤ 64 KiB, and the + binary member size **equals** `binary-size` exactly, with its sha256 re-verified. +- **`validate-app-cli-provenance`** (fresh pinned container, minimal env, hardened): enforce the + archive contract; JCS + JSON-Schema validate; re-verify binary digest/size; **ABI loadability proof** — recompute `PT_INTERP`, `DT_NEEDED`, and search paths from the binary, **resolve every - required library inside the immutable image**, then run a **credential-free, network-disabled - `--help` smoke**; compare every caller `ExpectedIdentity` field. Output `app-cli-path`. + required library inside the immutable image**, then run a **credential-free `--help` smoke**. The + smoke runs the archive-supplied binary under **`--network=none --read-only --user 1001 + --cap-drop=ALL --security-opt=no-new-privileges`, a bounded `--memory`/`--pids-limit`, and a wall + timeout** (Docker enforces these directly). Compare every caller `ExpectedIdentity` field. Output + `app-cli-path`. - **`active-version-fastly`** — inputs: `artifact-tar`, `ExpectedIdentity`, `fastly-service-id`, `fastly-api-token`; validates, runs `active-version` via the launcher; output `version` (empty on a first-ever **production** deploy = success). **Recovery is PRODUCTION-only.** @@ -184,19 +231,28 @@ leg's `ExpectedIdentity` via `compute-app-cli-identity`** — it does not consum ## 4. Testing -sccache (fresh `CARGO_TARGET_DIR` each run; `RUSTC_WRAPPER=sccache` action-owned; cold-to-warm shows -a sccache hit-rate rise and reduced compile with **network disabled** on the warm run; a corrupt +sccache — **cross-run warm reuse is asserted via `sccache --show-stats`, not by disabling the +network** (only `SCCACHE_DIR` is cached, so Cargo still needs to fetch dependency **sources** before +invoking rustc): the warm run does `cargo fetch` **online**, then asserts the compile's sccache cache +**hit rate rose** and wall-time dropped versus cold. (If an offline compile is wanted, `cargo fetch` +**prefetches sources before** the network is disabled for the rustc phase only.) Also: a corrupt restored `SCCACHE_DIR` triggers one cold rebuild; the audited cache path is exactly `SCCACHE_DIR`; -**a git dependency (the EdgeZero repo) builds and caches**). Container/runner/launcher (self-hosted -fails closed; read-only rootfs; manifest command creating `dist/` succeeds in the writable copy while -the original stays clean; enumerated mounts only; host-side `mutation-attempted` before mutation; -cancellation `docker stop -t`+reconcile). Env/config (constructed minimal env — a caller -`RUSTC_WRAPPER` is absent, not merely rejected; non-allowlisted config anywhere fails). Identity -(`app-repo-id` API-verified against `app-repository`; `platform-id` from `image.json`, not caller; -consumer re-verifies checkout id/HEAD/workspace before+after). Provenance (canonical-JSON + dup-key; -archive exactly-two-members/format/size; **ABI loadability** — resolve `DT_NEEDED` in the image + a -network-disabled `--help`; a real wrong-runtime rejected; provenance documented consistency-only). -Disclosure required for every cross-repo build (equal-id exempt). Recovery production-only. +**identical source built from two different host checkout paths yields sccache hits** (fixed +`/work/app` cwd); **a public git dependency (the EdgeZero repo) builds and caches**; `sccache +--stop-server` runs before save. Container/runner/launcher (self-hosted fails closed; read-only +rootfs; separate build/deploy container instances; the faithful `/work/app` copy matches the original +in content/modes/symlinks/submodules with hardlinks broken; a manifest command creating `dist/` +succeeds in the copy while the original stays clean; enumerated fixed mount table only; host-side +`mutation-attempted` before mutation; cancellation `docker stop -t`+reconcile). Env/config +(constructed minimal env includes `PATH`/`RUSTUP_HOME` and an absolute `RUSTC_WRAPPER`; a caller +`RUSTC_WRAPPER`/`PATH` is absent, not merely rejected; non-allowlisted config anywhere fails). +Identity (`app-repo-id` API-verified; `app-ref` rejected unless a full 40-hex SHA; hash golden +vectors; `platform-id` from `image.json`, not caller; consumer re-verifies checkout id/HEAD/workspace +before+after). Provenance (JCS canonical + dup-key rejection; ustar-only exactly-two-members, `pax` +rejected, binary size equality; **ABI loadability** — resolve `DT_NEEDED` in the image + a hardened +`--help` smoke (`--network=none --cap-drop=ALL --no-new-privileges`, memory/pids/timeout); a real +wrong-runtime rejected; provenance documented consistency-only). Disclosure required for every +cross-repo build (equal-id exempt). Recovery production-only. ## 5. Rollout, docs, migration @@ -235,7 +291,18 @@ for manifest commands (read-only original for the freeze checks); `app-repo-id` **disclosure required for every cross-repo build** (equal-id exempt); **ABI loadability** via resolved `DT_NEEDED` + a network-disabled `--help`; normative **canonical-JSON + tar** contracts with golden vectors; **matrix caller computes per-leg identity**; container plan gains a **verify-by-digest-then-PR** -publish (§ container sub-plan). +publish (§ container sub-plan). → **v6.15 (hardened)**: add `PATH`/`RUSTUP_HOME` + an absolute +`RUSTC_WRAPPER` so rustc starts under `env -i`; narrow the sccache correctness claim (dep-info/args/ +env/**cwd** hashing) and make the **undeclared-input (proc-macro/build.rs) risk** an explicit +cache opt-in; a **bounded, collision-free generation** (`run_id`-`run_attempt`-`artifact`, `SCCACHE_CACHE_SIZE=2G`, +`--stop-server` before save); a **complete fixed mount table** with a constant `/work/app` cwd (so +sccache's cwd hash is stable across host paths) and a **verified faithful working copy** (content/ +modes/symlinks/submodules, hardlinks broken); **separate build/deploy container instances**; a **full +40-hex `app-ref`** and **length-framed hash encodings** with golden vectors; **RFC 8785 (JCS)** JSON + +**ustar-only** archive with binary-size equality; a **hardened validator smoke** (`--cap-drop=ALL`, +`no-new-privileges`, memory/pids/timeout); and a **warm test via `sccache --show-stats`** (online, since +dependency sources are not cached). Public, anonymously-fetchable sources only. Validator string-type +fix + publish-visibility ordering land in the container sub-plan. ## 9. Deferred to the implementation plan (mechanics only) From e040db4fbd802b2526793fb0234b5b37fa7f95a9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:49:12 -0700 Subject: [PATCH 06/10] check-image-pin: require the canonical EdgeZero GHCR repository The validator accepted any non-empty repository, so a pin naming a foreign repository could become platform-id. Require repository == the canonical ghcr.io/stackpop/edgezero-build-app-cli and add a foreign-repository reject case (8/8). A trusted digest is only trustworthy for the repository we publish. --- .../deploy-core/tests/check-image-pin.test.sh | 9 ++++++--- .../docker/build-app-cli/check-image-pin.sh | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/actions/deploy-core/tests/check-image-pin.test.sh b/.github/actions/deploy-core/tests/check-image-pin.test.sh index c34fd98d..fac43bbc 100755 --- a/.github/actions/deploy-core/tests/check-image-pin.test.sh +++ b/.github/actions/deploy-core/tests/check-image-pin.test.sh @@ -26,18 +26,21 @@ echo "== build container image.json digest-pin validator ==" printf '{"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/ok.json" if run "$WORK/ok.json"; then ok "a digest-pinned reference passes"; else no "a digest-pinned reference passes"; fi -printf '{"repository":"ghcr.io/x","tag":"v1","digest":"v1"}\n' >"$WORK/tag.json" +printf '{"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":"v1","digest":"v1"}\n' >"$WORK/tag.json" if run "$WORK/tag.json"; then no "a non-digest (tag) reference is rejected"; else ok "a non-digest (tag) reference is rejected"; fi -printf '{"repository":"ghcr.io/x","tag":"v1","digest":"sha256:deadbeef"}\n' >"$WORK/short.json" +printf '{"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":"v1","digest":"sha256:deadbeef"}\n' >"$WORK/short.json" if run "$WORK/short.json"; then no "a short/invalid digest is rejected"; else ok "a short/invalid digest is rejected"; fi -printf '{"repository":"ghcr.io/x","tag":"v1"}\n' >"$WORK/nodigest.json" +printf '{"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":"v1"}\n' >"$WORK/nodigest.json" if run "$WORK/nodigest.json"; then no "a missing digest is rejected"; else ok "a missing digest is rejected"; fi printf '{"tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/norepo.json" if run "$WORK/norepo.json"; then no "a missing repository is rejected"; else ok "a missing repository is rejected"; fi +printf '{"repository":"ghcr.io/attacker/edgezero-build-app-cli","tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/foreign.json" +if run "$WORK/foreign.json"; then no "a foreign repository is rejected"; else ok "a foreign repository is rejected"; fi + printf '{"repository":123,"tag":1,"digest":"sha256:%064d"}\n' 0 >"$WORK/numeric.json" if run "$WORK/numeric.json"; then no "numeric (non-string) repository/tag is rejected"; else ok "numeric (non-string) repository/tag is rejected"; fi diff --git a/.github/docker/build-app-cli/check-image-pin.sh b/.github/docker/build-app-cli/check-image-pin.sh index ec619940..3aacbb1d 100755 --- a/.github/docker/build-app-cli/check-image-pin.sh +++ b/.github/docker/build-app-cli/check-image-pin.sh @@ -1,13 +1,18 @@ #!/usr/bin/env bash -# Fail-closed: the build container reference must be pinned by a sha256 manifest -# digest, never a mutable tag (spec docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md +# Fail-closed: the build container reference must be the canonical EdgeZero GHCR +# repository, pinned by a sha256 manifest digest, never a mutable tag (spec +# docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md # §3.6/§5). image.json records the canonical repository, tag, and pinned digest; # the rest of the build-caching feature keys `platform-id` on that digest, so a -# non-digest or malformed pin must never pass. +# non-digest, malformed, or foreign-repository pin must never pass. # # Usage: check-image-pin.sh set -euo pipefail +# The one repository the build-caching feature trusts; a pin naming any other +# repository is rejected so a foreign image can never become `platform-id`. +EXPECTED_REPO="ghcr.io/stackpop/edgezero-build-app-cli" + file="${1:?usage: check-image-pin.sh }" if ! command -v jq >/dev/null 2>&1; then @@ -40,6 +45,13 @@ if [[ -z "$repo" || -z "$tag" ]]; then exit 1 fi +# The repository must be the canonical EdgeZero build container, not merely +# non-empty: `platform-id` is trusted, so a foreign repository must never pass. +if [[ "$repo" != "$EXPECTED_REPO" ]]; then + echo "::error::$file 'repository' must be '$EXPECTED_REPO', not '$repo'" >&2 + exit 1 +fi + # A sha256 manifest digest, never a tag. if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "::error::$file 'digest' must be a sha256 manifest digest (sha256:<64-hex>), not a tag: '$digest'" >&2 From 3e1ae389033117a049ad8a211d586d69c182ff18 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:49:26 -0700 Subject: [PATCH 07/10] build-caching spec v6.16 + plan: address the v6.15 review's contract findings Stable host cache path: actions/cache folds the on-disk path into the cache version, so a per-run mktemp path forces permanent misses; use one fixed ${RUNNER_TEMP}/edgezero-sccache-v1, emptied before restore, mounted at /work/sccache (1). Whole-repo /work/repo working copy with the compile cwd at the relative working-directory, so a nested working-directory (apps/api under a parent workspace) keeps its enclosing Cargo config and sibling path-deps; the flattened /work/app is gone (2). Frozen source: git-ignored files excluded from the copy and initialized submodules validated, and the SAME copy is reused across the separate build/deploy container instances so build outputs reach deploy as derived state (3). Storage restated as repository-global LRU that can evict unrelated caches and may be billable, not family-local (4). Generation keyed on an app-cli-artifact unique across every cache-writing invocation (fail-closed on a detectable collision), with concurrent lineages forked, not merged (accepted) (5). PATH includes /usr/local/bin where Fastly and sccache live; enumerated compile/validation/deploy env profiles listing EDGEZERO_* by name, not the namespace (6). app-checkout-token assigned to the host-side app-repo-id API check and barred from containers/copies/artifacts/caches (7). Exact byte contracts: length-framed : hash encoding with normalized relative paths, normalized ustar headers (zero mtime/uid/gid, fixed names), and abi as recomputed ELF metadata (machine/interp=null-if-static/direct-DT_NEEDED; transitive resolved, dlopen out of scope) (8). sccache undeclared-input risk stated as accepted (no proc-macro input-declaration mechanism exists); fail-cold restore/audit/read failures; skip-save on --stop-server failure (9). Plan: two-tier pin policy (major action tags per the repo's own check-action-pins gate, image digests) resolving the apparent checkout@v7 inconsistency; validator canonical-repo requirement reflected; image.json rigor scoped (the JCS/schema/dup-key provenance machinery is for produced artifacts, sub-plan 3) (10). Design only. --- .../plans/2026-08-20-build-cache-container.md | 37 ++- ...20-edgezero-deploy-build-caching-design.md | 283 ++++++++++++------ 2 files changed, 220 insertions(+), 100 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-build-cache-container.md b/docs/superpowers/plans/2026-08-20-build-cache-container.md index 9ba6e18b..3cd88d56 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -8,7 +8,7 @@ **Tech Stack:** Docker (BuildKit), GitHub Actions (`docker/build-push-action`), GHCR, Bash, `jq`. -**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` (v6.14, sccache pivot) — §2 (single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` (v6.16, sccache pivot) — §2 (single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). ## Global Constraints @@ -17,7 +17,7 @@ - **Runtime posture:** consumed **read-only root filesystem, non-root user**, explicit writable mounts only (spec §3.7). - **Single-manifest `linux/amd64` only** — no multi-arch index (an index digest can select another architecture). - **No Python in CI tooling** — Bash + `jq` only. -- **Pin policy:** every referenced image/action is pinned; the base image is pinned by `sha256` digest, and the published image is recorded by `sha256` digest. +- **Pin policy (two-tier, matching the repo's `check-action-pins.sh` gate):** **actions** are pinned to a **released version tag** — a major tag such as `@v7` — per the repo's standing convention (`actions/checkout@v7` passes the gate; the gate accepts a major tag or a full commit SHA, never a floating `@main`/`@latest`); **images** are pinned by `sha256` digest (the base image's digest in the `FROM`, and the published image's digest recorded in `image.json`). Digest immutability is required only where the toolchain/ABI identity depends on it — i.e. the container. - **No AI bylines** in commits or PR bodies. - **Bash 3.2-compatible** scripts (macOS dev parity); scripts are `shellcheck -S warning` clean. @@ -40,7 +40,7 @@ **Interfaces:** - Consumes: nothing (leaf). -- Produces: `check-image-pin.sh ` — exit `0` iff the JSON has string `repository`, string `tag`, and a `digest` matching `^sha256:[0-9a-f]{64}$`; prints `::error::` and exits `1` otherwise. Reused by the pin gate and the publish workflow. +- Produces: `check-image-pin.sh ` — exit `0` iff the JSON has string-typed `repository`/`tag`/`digest`, `repository` **equals the canonical `ghcr.io/stackpop/edgezero-build-app-cli`** (a foreign repository can never become `platform-id`), and `digest` matches `^sha256:[0-9a-f]{64}$`; prints `::error::` and exits `1` otherwise. Reused by the pin gate and the publish workflow. (`image.json` is a committed, PR-reviewed 3-field pin record; its rigor is this type+repo+digest gate. The JCS/JSON-Schema/duplicate-key **provenance** machinery is for *produced* artifacts — `app-cli-meta.json`, spec §3.7 — and belongs to sub-plan 3, not this committed record.) - [ ] **Step 1: Write the failing test** @@ -57,15 +57,19 @@ ok(){ printf ' ok %s\n' "$1"; pass=$((pass+1)); } no(){ printf ' FAIL %s\n' "$1"; fail=$((fail+1)); } run(){ bash "$CHECK" "$1" >/dev/null 2>&1; } -printf '{"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/ok.json" +R="ghcr.io/stackpop/edgezero-build-app-cli" +printf '{"repository":"%s","tag":"v1","digest":"sha256:%064d"}\n' "$R" 0 >"$WORK/ok.json" run "$WORK/ok.json" && ok "a digest-pinned reference passes" || no "a digest-pinned reference passes" -printf '{"repository":"ghcr.io/x","tag":"v1","digest":"v1"}\n' >"$WORK/tag.json" +printf '{"repository":"%s","tag":"v1","digest":"v1"}\n' "$R" >"$WORK/tag.json" run "$WORK/tag.json" && no "a non-digest (tag) reference is rejected" || ok "a non-digest (tag) reference is rejected" -printf '{"repository":"ghcr.io/x","tag":"v1"}\n' >"$WORK/nodigest.json" +printf '{"repository":"%s","tag":"v1"}\n' "$R" >"$WORK/nodigest.json" run "$WORK/nodigest.json" && no "a missing digest is rejected" || ok "a missing digest is rejected" +printf '{"repository":"ghcr.io/attacker/edgezero-build-app-cli","tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/foreign.json" +run "$WORK/foreign.json" && no "a foreign repository is rejected" || ok "a foreign repository is rejected" + printf 'not json\n' >"$WORK/bad.json" run "$WORK/bad.json" && no "malformed JSON fails closed" || ok "malformed JSON fails closed" @@ -87,6 +91,7 @@ Expected: FAIL (the `check-image-pin.sh` file does not exist yet). # never a mutable tag (spec §3.7/§5). Requires mikefarah yq/jq-free: uses jq. set -euo pipefail +EXPECTED_REPO="ghcr.io/stackpop/edgezero-build-app-cli" file="${1:?usage: check-image-pin.sh }" if ! command -v jq >/dev/null 2>&1; then echo "::error::check-image-pin.sh requires jq" >&2 @@ -96,11 +101,21 @@ if ! json=$(jq -e . "$file" 2>/dev/null); then echo "::error::$file is not valid JSON — refusing to pass an unreadable image pin" >&2 exit 1 fi -repo=$(jq -r '.repository // empty' <<<"$json") -tag=$(jq -r '.tag // empty' <<<"$json") -digest=$(jq -r '.digest // empty' <<<"$json") +# String TYPES (jq -r would coerce a numeric value to a string). +if [[ "$(jq -r '.repository|type' <<<"$json")" != string || + "$(jq -r '.tag|type' <<<"$json")" != string || + "$(jq -r '.digest|type' <<<"$json")" != string ]]; then + echo "::error::$file 'repository', 'tag', 'digest' must be JSON strings" >&2 + exit 1 +fi +repo=$(jq -r '.repository' <<<"$json"); tag=$(jq -r '.tag' <<<"$json"); digest=$(jq -r '.digest' <<<"$json") if [[ -z "$repo" || -z "$tag" ]]; then - echo "::error::$file must set string 'repository' and 'tag'" >&2 + echo "::error::$file must set non-empty 'repository' and 'tag'" >&2 + exit 1 +fi +# The repository must be the canonical EdgeZero build container, not merely non-empty. +if [[ "$repo" != "$EXPECTED_REPO" ]]; then + echo "::error::$file 'repository' must be '$EXPECTED_REPO', not '$repo'" >&2 exit 1 fi if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then @@ -113,7 +128,7 @@ echo "build container reference is pinned: $repo@$digest" - [ ] **Step 4: Run the test to verify it passes** Run: `chmod +x .github/docker/build-app-cli/check-image-pin.sh && bash .github/actions/deploy-core/tests/check-image-pin.test.sh` -Expected: `Passed: 4 Failed: 0`. +Expected: `Passed: N Failed: 0` (the committed test carries the full case set — string-type, foreign-repo, tag, short/missing digest, missing repository, malformed JSON). - [ ] **Step 5: Shellcheck** diff --git a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md index 5586a6b2..b1bdda8b 100644 --- a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md +++ b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md @@ -1,6 +1,6 @@ # EdgeZero Deploy Actions — Build Caching Spec -**Status:** Design (proposed) — v6.15 (sccache pivot, hardened) +**Status:** Design (proposed) — v6.16 (sccache pivot, hardened) **Related:** `docs/specs/edgezero-deploy-github-action.md`, `docs/specs/edgezero-deploy-action-implementation-plan.md`, @@ -44,11 +44,14 @@ for shared dependency acceleration: **preprocessed source, `dep-info` inputs, compiler arguments, dependency artifacts, a subset of the environment, and the working directory** (v0.10) — so a cached object is reused only when all of those match, and **restoring an older `SCCACHE_DIR` never yields an incorrect object**. - **Correctness caveat (opt-in risk):** sccache's own Rust guidance warns it may **not** cache - correctly when a **`build.rs` or a proc-macro reads files or environment not declared as inputs** - (undeclared inputs). v1 does not detect this; enabling `cache: true` is an **explicit acceptance** - that the app's build scripts/proc-macros declare their inputs (documented on the input). No custom - pruning; `SCCACHE_CACHE_SIZE` bounds each snapshot (§3.2). + **Correctness caveat (accepted risk, not a condition apps satisfy):** sccache's own Rust guidance + warns it may **not** cache correctly when a **`build.rs` or a proc-macro reads files or environment + not declared as inputs** (undeclared inputs). Rust has **no general mechanism for a proc-macro to + declare its filesystem inputs**, so this cannot be posed as a precondition an application meets — it + is simply the risk `cache: true` **accepts**. v1 does not detect it; enabling `cache: true` is an + **explicit acceptance** of possible staleness for build scripts / proc-macros with undeclared + inputs (documented on the input), with the fallback that a wrong object still fails the downstream + provenance/ABI checks. No custom pruning; `SCCACHE_CACHE_SIZE` bounds each snapshot (§3.2). - **Cache contents = `SCCACHE_DIR` only** (compiled objects + sccache's index). **No `.crate` sources, no `registry/src`, no `git/*`, no `CARGO_HOME/bin`, no config, no credentials** are cached — so a cold build's `registry/src` extraction is irrelevant to the audit, and **no @@ -63,43 +66,77 @@ for shared dependency acceleration: ### 3.2 Own restore + save, coarse rolling key -`actions/cache/restore` + `save` over **`SCCACHE_DIR` only**: +`actions/cache/restore` + `save` over **one stable host path** (below): +- **Stable host cache path (required).** `actions/cache` folds the **on-disk path** it archives into + the cache **version**, so a per-run `mktemp` path would make *every* restore miss regardless of a + matching key. The action therefore uses **one fixed host path — `${RUNNER_TEMP}/edgezero-sccache-v1`** + (constant across runs of a given runner-arch), **emptied before restore**, and bind-mounted at the + constant in-container `SCCACHE_DIR=/work/sccache` (§3.6). Only `SCCACHE_DIR` is archived. - **Key** = `-`, `` = `edgezero-sccache-v1--`, - restore-keys prefix `-`. `` = `--` - — `run_attempt` distinguishes **re-runs** (which keep the same `run_id`) and `app-cli-artifact` - (unique per matrix leg, §3.8) distinguishes **matrix legs**, so no two saving jobs collide on a - key, and each restores the newest entry in its ``. `platform-id` = the container digest; - `suffix-hash` = the validated `cache-key-suffix` (§3.8). No lockfile/manifest hashing — sccache - content-addresses internally. -- **Bounded storage.** `SCCACHE_CACHE_SIZE` is a fixed **2 GiB** (action-owned), so each saved - snapshot is bounded well under GitHub's **10 GiB per-repository** cache limit; aggregate storage - is bounded by GitHub's own LRU eviction over the family's generations (older generations are - evicted; a busy repo may re-warm occasionally — an accepted cost of the rolling scheme). -- **Restore → audit → build → stop-server → best-effort save.** After restore, **audit** that the - restored path is exactly `SCCACHE_DIR` and contains only sccache's blob/index layout (**discard - and build cold once** on a corrupt/unexpected restore). Run `sccache --show-stats` for + restore-keys prefix `-`. `` = `--`, + where **`invocation-id` is unique across every cache-writing invocation** — not merely per matrix + leg but per reusable-workflow call in a run (two calls in one run share `run_id`/`run_attempt` and + can share a default `app-cli-artifact`, so the artifact **name alone is insufficient**). It is the + **`suffix-hash`-bound `app-cli-artifact`** (required unique per writer, §3.8) **hashed into the key**; + `run_attempt` additionally distinguishes **re-runs** (same `run_id`). Each writer thus saves a + **distinct immutable entry** and restores the **newest** in its ``. `platform-id` = the + container digest; `suffix-hash` = the validated `cache-key-suffix` (§3.8). No lockfile/manifest + hashing — sccache content-addresses internally. +- **Concurrent lineages (accepted).** Concurrent matrix/sibling writers each restore the same newest + snapshot and **fork** it; entries are immutable and **not merged**, so only one lineage's warmth is + carried forward per family and the others' incremental warmth is **lost** (re-warmed next run). v1 + **accepts** this rather than partitioning per-leg families (which would multiply cold starts); + partitioned lineages are §7. +- **Bounded snapshot, repository-global eviction (accepted).** `SCCACHE_CACHE_SIZE` is a fixed + **2 GiB** (action-owned), bounding **each snapshot** well under GitHub's **10 GiB per-repository** + cache limit. **Aggregate storage is not family-local:** every successful run saves a **new immutable + entry**, and GitHub's eviction is **repository-wide LRU** — it can evict **unrelated** caches (other + workflows' entries) once the repo total is exceeded, and raising the repo cache quota may be + **billable**. v1 **explicitly accepts** repository-global LRU/thrashing under the rolling scheme (no + action-side cleanup; the actor lacks a cross-workflow cache-delete permission by default). Bump the + `-v1-` family namespace when the mechanism changes. +- **Restore → audit → build → stop-server → best-effort save, with fail-cold contracts.** After + restore, **audit** that the restored path is exactly `SCCACHE_DIR` and contains only sccache's + blob/index layout. **Any restore, audit, or sccache-read failure resets to a cold build** (discard + the restored dir, build once from empty) rather than aborting. Run `sccache --show-stats` for observability. Before save, **`sccache --stop-server`** flushes and shuts the server down so - `SCCACHE_DIR` is consistent on disk. `actions/cache/save` under the run's `` key is - **best-effort** (failures are warnings). Bump the `-v1-` family namespace whenever the mechanism - changes. + `SCCACHE_DIR` is consistent on disk; **if `--stop-server` fails, the save is SKIPPED** (never + archive a live/again-mutating cache). `actions/cache/save` under the run's `` key is + otherwise **best-effort** (failures are warnings). ### 3.3 Action-owned Cargo/sccache environment -The build runs under a **constructed minimal environment** (`env -i` + an explicit allowlist), +Every action runs under a **constructed minimal environment** (`env -i` + an explicit allowlist), not scrub-then-reject, so there is nothing to miss: only the action-owned variables and an -allowlist of benign ones exist. Action-owned (fixed, exact values — the rustup-image layout means -`PATH` and `RUSTUP_HOME` are **required** for rustc to start): `PATH=/usr/local/cargo/bin:/usr/bin:/bin`, -`RUSTUP_HOME=/usr/local/rustup`, `CARGO_HOME` (§below), `RUSTC_WRAPPER=/usr/local/bin/sccache` -(absolute), `RUSTUP_TOOLCHAIN`, `CARGO_TARGET_DIR` (fresh), `SCCACHE_DIR`, `SCCACHE_CACHE_SIZE=2G`, -`HOME`, `TMPDIR`, `CARGO_ENCODED_RUSTFLAGS=""`, `CARGO_INCREMENTAL=0`. A **caller-supplied** -`RUSTC`/`RUSTC_WRAPPER`/`RUSTC_WORKSPACE_WRAPPER`/`RUSTDOC`/`RUSTFLAGS`/native-tool/`PATH` var simply -is **not present** in the constructed env (never inherited). +enumerated allowlist exist. **`PATH` = `/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin`** — it +**must include `/usr/local/bin`**, where the container installs the **Fastly CLI** and **`sccache`** +(the deploy/validation profiles otherwise cannot find `fastly`). The rustup-image layout means +`PATH` and `RUSTUP_HOME` are **required** for rustc to start. + +**Enumerated env profiles** (each an exact, closed set — no inherited namespace): + +- **compile/build:** `PATH` (above), `RUSTUP_HOME=/usr/local/rustup`, `CARGO_HOME` (§below), + `RUSTC_WRAPPER=/usr/local/bin/sccache` (absolute), `RUSTUP_TOOLCHAIN`, `CARGO_TARGET_DIR` (fresh), + `SCCACHE_DIR`, `SCCACHE_CACHE_SIZE=2G`, `HOME`, `TMPDIR`, `CARGO_ENCODED_RUSTFLAGS=""`, + `CARGO_INCREMENTAL=0`. **No** `sccache`/wrapper vars in the deploy/validation profiles. +- **validation (`validate-app-cli-provenance`):** `PATH`, `HOME`, `TMPDIR` only (no cargo/sccache, no + token) — it recomputes ELF metadata and runs the hardened smoke (§3.7). +- **deploy (`active-version-fastly`, config-push):** `PATH`, `HOME`, `TMPDIR`, the **single** provider + token (`FASTLY_API_TOKEN`), and an **enumerated** `EDGEZERO_*` allowlist — the specific public + variables the deploy CLI reads are **listed by name** (not the whole `EDGEZERO_*` namespace); an + unlisted `EDGEZERO_*` is not present. + +A **caller-supplied** `RUSTC`/`RUSTC_WRAPPER`/`RUSTC_WORKSPACE_WRAPPER`/`RUSTDOC`/`RUSTFLAGS`/ +native-tool/`PATH` var simply is **not present** in any constructed profile (never inherited). **Cache-hit stability requires ALL sccache hash inputs to be fixed across runs** (v0.10 hashes the **cwd** too, so a varying path turns every warm build cold). The container therefore fixes, at **constant in-container paths regardless of the host checkout location**: the writable working copy -at **`/work/app`** (the compile **cwd**, §3.6), `CARGO_TARGET_DIR=/work/target`, +of the **whole repository** at **`/work/repo`** (preserving its layout, §3.6), the compile **cwd** at +**`/work/repo/`** (a **constant** path for a given app, so +enclosing Cargo config, parent workspaces, and sibling path-dependencies are all preserved — a +flattened single-directory mount would break `working-directory: apps/api`), `CARGO_TARGET_DIR=/work/target`, `CARGO_HOME=/work/cargo-home`, `SCCACHE_DIR=/work/sccache`, `HOME=/work/home`, `TMPDIR=/work/tmp` (writable tmpfs). Identical source built from different host paths must produce sccache hits (§4). @@ -112,15 +149,23 @@ be a tracked, regular file. External path deps outside the workspace root are re `git-root` (path, confinement); `app-repo` (`owner/repo`); **`app-repo-id`** (canonical decimal **string**, always required, **verified via the GitHub REST API to belong to `app-repository`**). -`app-ref` must be a **full 40-hex commit SHA** (short refs/branches/tags rejected). `workspace-root` -canonicalized, confined beneath `git-root`, `working-directory` beneath it, asserted -`== cargo metadata.workspace_root`. - -**All identity hashes are SHA-256 over a canonical, length-framed encoding** — each field encoded as -its UTF-8 bytes prefixed by its byte length as a fixed-width decimal (so no field boundary is -ambiguous), fields concatenated in a fixed order. `workspace-id` = that hash over -(`app-repo-id`, workspace-root path relative to `git-root`); `suffix-hash` = that hash over the -validated `cache-key-suffix`. **Golden vectors** for each hash are committed with the plan. +**Credential for the repo-id lookup:** the API verification uses the **`app-checkout-token`** secret +(§3.8) — the only credential able to read a **private** app repo's metadata — and runs **host-side +only** in `compute-app-cli-identity`. It is **never forwarded into any container, working copy, +artifact, or cache**: the build/validate containers carry no GitHub token (§3.3 profiles), so the +token cannot leak into compiled output or the sccache archive. `app-ref` must be a **full 40-hex +commit SHA** (short refs/branches/tags rejected). `workspace-root` canonicalized, confined beneath +`git-root`, `working-directory` beneath it, asserted `== cargo metadata.workspace_root`. + +**All identity hashes are SHA-256 over a canonical, length-framed encoding.** Each field is encoded +as its UTF-8 bytes prefixed by a length frame: the byte length as **ASCII decimal with no leading +zeros** followed by a single `:` separator (`:`), fields concatenated in a fixed order — +so no field boundary is ambiguous and no fixed width can overflow. **Path fields are normalized +first** — expressed **relative to `git-root`**, `/`-separated, no `.`/`..`/empty segments, no +trailing slash, NFC — so the same logical path hashes identically across runners. `workspace-id` = +that hash over (`app-repo-id`, normalized workspace-root path relative to `git-root`); +`suffix-hash` = that hash over the validated `cache-key-suffix`. **Golden vectors** (including the +exact `:` framing) for each hash are committed with the plan. `platform-id` = the container digest, **read inside every action from `image.json` at the same EdgeZero SHA — never caller-supplied**; `container-ref` = `@`. @@ -140,31 +185,41 @@ identity, and every writer of the deployer's **current-/default-branch** cache s mounts only. `platform-id` = its digest. - **Runner: GitHub-hosted `linux/amd64` only** (fail closed on self-hosted). Host-level job, local Docker daemon. -- **Separate container instances.** The credential-free **build** and the token-bearing **deploy** - run in **distinct container instances** (never one long-lived container); the build instance holds - no provider token. +- **Separate container instances, one shared working copy.** The credential-free **build** and the + token-bearing **deploy** run in **distinct container instances** (never one long-lived container); + the build instance holds no provider token. They **share a single `/work/repo` working copy**: it is + made **once** as a faithful copy of the checkout, the build instance compiles into it (and into the + fresh `/work/target`), and the **same copy — now carrying the build's derived outputs — is remounted + into the deploy instance** (as derived build state, not re-copied), so generated files (`dist/`, + staged `pkg/`, produced manifests) reach the deploy step without a lossy re-clone. Freeze + assertions (§3.7) run against the **read-only original**, never this mutated copy. - **One launcher `run-app-cli-in-container`** with a **complete fixed mount table** (constant in-container paths, so sccache's cwd/path hashing is stable regardless of the host checkout location; never `RUNNER_TEMP` wholesale): | In-container path | Mode | Source | | --- | --- | --- | - | `/work/app` (compile cwd) | **writable** | a **verified faithful copy** of the app checkout | + | `/work/repo` (repo root; compile cwd = `/work/repo/`) | **writable** | a **verified faithful copy** of the whole app checkout, layout preserved | | `/work/target` | writable | fresh `CARGO_TARGET_DIR` | | `/work/cargo-home` | writable | `CARGO_HOME` | - | `/work/sccache` | writable | `SCCACHE_DIR` (restored) | + | `/work/sccache` | writable | `SCCACHE_DIR` (restored from the stable host path, §3.2) | | `/work/home`, `/work/tmp` | writable (tmpfs) | provider/Fastly `HOME`, `TMPDIR` | | the package/output dir | writable | staged CLI / Fastly `pkg/` | | the validated CLI binary | read-only | consumer input | | the specific inline-config temp file | read-only | config-push only, by exact path | UID/GID mapping so the non-root container user owns the writable mounts. - - **Writable working COPY.** The CLI runs arbitrary manifest commands via `sh -c` in the manifest - root and may create `dist/`, `node_modules/`, generated manifests — so `/work/app` is a - disposable writable copy. The copy is a **verified faithful copy of the read-only original** — - equivalent in content, file modes, symlink targets, and submodule state, with **hardlinks broken** - (a real copy, e.g. `cp -a` + a content-hash comparison, not a bind of the original) — so the bytes - compiled are exactly the frozen source (§3.7). + - **Writable working COPY (whole repo, layout preserved).** The CLI runs arbitrary manifest commands + via `sh -c` in the manifest root and may create `dist/`, `node_modules/`, generated manifests — so + `/work/repo` is a disposable writable copy of the **entire repository** (not the flattened working + directory), preserving parent Cargo config, enclosing workspaces, and sibling path-dependencies. + The copy is a **verified faithful copy of the read-only original** — equivalent in content, file + modes, symlink targets, and **initialized-submodule** state (submodules must be checked out at + their recorded commits; an uninitialized/dirty submodule fails closed), with **hardlinks broken** + (a real copy, e.g. `cp -a` + a content-hash comparison, not a bind of the original). **Ignored + files are excluded:** the copy carries only what `source-revision` represents — tracked files plus + initialized submodules; git-ignored/untracked build detritus is **absent** (excluded before the + copy), so the compiled bytes are exactly the frozen source (§3.7). - **env:** only the required provider token + `EDGEZERO_*`; no GitHub file-command channels inside the container. - **signals/outputs:** host↔container readiness handshake; **`mutation-attempted` published @@ -174,12 +229,15 @@ identity, and every writer of the deployer's **current-/default-branch** cache s ### 3.7 Source freezing, provenance, disclosure, actions -- **Source freezing:** the writable `/work/app` copy is proven a **faithful copy** of the read-only - original (§3.6) before compilation, so the frozen source and the executed bytes are the same. On - the **read-only original**, assert the initial `HEAD` SHA unchanged + tree clean (tracked + - untracked + recursive submodules) **before and after** all app-controlled commands; reject escaping - symlinks. Consumers additionally **verify their mounted checkout's repository id, `HEAD`, and - workspace against the artifact before and after commands**. +- **Source freezing:** the writable `/work/repo` copy is proven a **faithful copy** of the read-only + original (§3.6) before compilation — **tracked files + initialized submodules only, git-ignored/ + untracked detritus excluded**, so the copy is exactly what `source-revision` represents — and that + **same copy (now with build outputs) is reused for the deploy instance** (§3.6), so the frozen + source, the executed bytes, and the deployed artifacts are one lineage. On the **read-only + original**, assert the initial `HEAD` SHA unchanged + tree clean (tracked + untracked + recursive + submodules) **before and after** all app-controlled commands; reject escaping symlinks. Consumers + additionally **verify their mounted checkout's repository id, `HEAD`, and workspace against the + artifact before and after commands**. - **`ExpectedIdentity`:** `app-repo-id` (decimal string), `source-revision` (full SHA, explicit), `app-cli-package`, `app-cli-bin`, `workspace-id` — **caller-supplied and checkout-verified**; `platform-id`/`container-ref` are **derived inside every action from same-SHA `image.json`, not @@ -189,13 +247,24 @@ identity, and every writer of the deployer's **current-/default-branch** cache s rules are JCS's, not "minimal forms" — and duplicate keys are **rejected before parse** (JSON Schema cannot). It is validated by a committed **JSON Schema 2020-12** file **plus** the JCS + dup-key procedural pass. Meta ≤ **64 KiB**. Fields = `ExpectedIdentity` + `app-cli-version` - (informational) + `binary-sha256` + `binary-size` + `abi` (`{ machine, interp, needed: [sorted str] }`). -- **Archive contract (normative):** a **deterministic `ustar` tar** (POSIX ustar **only** — `pax` - extended headers are **rejected**, so there is no ambiguous PAX extension surface) with **exactly - two** regular members, `app-cli-meta.json` then the `app-cli-bin` binary — any extra/duplicate/ - renamed member, any symlink/hardlink/device/global-extended header, trailing bytes, or - path-traversal name is **rejected**; total logical size ≤ **512 MiB**, meta ≤ 64 KiB, and the - binary member size **equals** `binary-size` exactly, with its sha256 re-verified. + (informational) + `binary-sha256` + `binary-size` + `abi`. **`abi` is recomputed ELF metadata**, + each field an exact form: `machine` = the ELF `e_machine` **as its canonical string name** + (e.g. `"x86_64"`); `interp` = the `PT_INTERP` path **as a string, or JSON `null` for a static + binary** (no `PT_INTERP`); `needed` = the **direct** `DT_NEEDED` entries **as a sorted string array** + (`[]` for a static binary) — **transitive** libraries are not listed (they are resolved, not + recorded, by the loadability proof). `dlopen`-at-runtime libraries are **out of scope** (not in + `DT_NEEDED`, not asserted). `abi` is a **consistency/loadability** contract, not a full ABI model. +- **Archive contract (normative), with normalized headers:** a **deterministic `ustar` tar** (POSIX + ustar **only** — `pax` extended headers are **rejected**, so there is no ambiguous PAX extension + surface) with **exactly two** regular members in fixed order, `app-cli-meta.json` then the + `app-cli-bin` binary. **Header fields are normalized to fixed values** so byte-equality is + reproducible: `uid`/`gid` = `0`, `uname`/`gname` = empty, `mtime` = `0`, `mode` = `0644` (meta) / + `0755` (binary), `typeflag` = `0` (regular), `prefix` = empty and each `name` a fixed literal + (`app-cli-meta.json`, the `app-cli-bin` basename) — **not** the producer's path. Any extra/ + duplicate/renamed member, any symlink/hardlink/device/global-extended header, non-zero `mtime`/ + non-zero `uid`/`gid`, trailing bytes, or path-traversal name is **rejected**; total logical size ≤ + **512 MiB**, meta ≤ 64 KiB, and the binary member size **equals** `binary-size` exactly, with its + sha256 re-verified. - **`validate-app-cli-provenance`** (fresh pinned container, minimal env, hardened): enforce the archive contract; JCS + JSON-Schema validate; re-verify binary digest/size; **ABI loadability proof** — recompute `PT_INTERP`, `DT_NEEDED`, and search paths from the binary, **resolve every @@ -208,8 +277,11 @@ identity, and every writer of the deployer's **current-/default-branch** cache s `fastly-api-token`; validates, runs `active-version` via the launcher; output `version` (empty on a first-ever **production** deploy = success). **Recovery is PRODUCTION-only.** - **`compute-app-cli-identity`** — inputs: `app-repository`/`app-repo-id`, `source-revision`, - `workspace-root`, `app-cli-package`/`app-cli-bin`; reads `platform-id`/`container-ref` from - same-SHA `image.json`; outputs the full `ExpectedIdentity`. + `workspace-root`, `app-cli-package`/`app-cli-bin`, and the **`app-checkout-token`** secret used + **host-side** to API-verify `app-repo-id` belongs to `app-repository` (the only credential that can + read a private repo's metadata); the token is **never** passed to a container, working copy, + artifact, or cache. Reads `platform-id`/`container-ref` from same-SHA `image.json`; outputs the full + `ExpectedIdentity`. - **Disclosure (enforceable):** because the action cannot compare reader sets, require **`disclosure-acknowledged: true` for every cross-repository build** (`app-repo-id` ≠ the deployer repo id), **exempting only equal repository ids**. The sccache cache holds **compiled objects** @@ -220,14 +292,19 @@ identity, and every writer of the deployer's **current-/default-branch** cache s Inputs: `app-repository`, `app-ref`, **`app-repo-id`** (string, always required), `working-directory` (`.`), `workspace-root` (required), `app-cli-package` (required), `app-cli-bin`, `app-cli-artifact` -(**unique per matrix leg**), `cache` (default `false`), `cache-key-suffix`, `disclosure-acknowledged` -(required-true for cross-repo), `timeout-minutes` (30). **No `rust-toolchain`/feature inputs.** Secret -`app-checkout-token`. Job `permissions: { contents: read }` (caller grants ≥ that); -`persist-credentials: false`. **Runner floor 2.336.0** (self-repo `$/`). +(**required unique across every cache-writing invocation** — not only per matrix leg but per +reusable-workflow call in a run; the action **fails closed** on a collision it can detect, since two +calls sharing `run_id`/`run_attempt` and a default artifact name would otherwise write the same key), +`cache` (default `false`), `cache-key-suffix`, `disclosure-acknowledged` (required-true for cross-repo), +`timeout-minutes` (30). **No `rust-toolchain`/feature inputs.** Secret `app-checkout-token`. Job +`permissions: { contents: read }` (caller grants ≥ that); `persist-credentials: false`. **Runner floor +2.336.0** (self-repo `$/`). **Matrix:** v1's shared workflow outputs are **single-build** (GitHub returns only the last matrix -leg's outputs). A **matrix caller uses unique per-leg `app-cli-artifact` names and computes each -leg's `ExpectedIdentity` via `compute-app-cli-identity`** — it does not consume the shared outputs. +leg's outputs). A **matrix caller uses unique per-leg `app-cli-artifact` names** (which also key each +leg's distinct cache lineage, §3.2) **and computes each leg's `ExpectedIdentity` via +`compute-app-cli-identity`** — it does not consume the shared outputs. Concurrent legs each restore +the newest snapshot and fork it without merging (§3.2, accepted). ## 4. Testing @@ -235,21 +312,31 @@ sccache — **cross-run warm reuse is asserted via `sccache --show-stats`, not b network** (only `SCCACHE_DIR` is cached, so Cargo still needs to fetch dependency **sources** before invoking rustc): the warm run does `cargo fetch` **online**, then asserts the compile's sccache cache **hit rate rose** and wall-time dropped versus cold. (If an offline compile is wanted, `cargo fetch` -**prefetches sources before** the network is disabled for the rustc phase only.) Also: a corrupt -restored `SCCACHE_DIR` triggers one cold rebuild; the audited cache path is exactly `SCCACHE_DIR`; -**identical source built from two different host checkout paths yields sccache hits** (fixed -`/work/app` cwd); **a public git dependency (the EdgeZero repo) builds and caches**; `sccache ---stop-server` runs before save. Container/runner/launcher (self-hosted fails closed; read-only -rootfs; separate build/deploy container instances; the faithful `/work/app` copy matches the original -in content/modes/symlinks/submodules with hardlinks broken; a manifest command creating `dist/` -succeeds in the copy while the original stays clean; enumerated fixed mount table only; host-side -`mutation-attempted` before mutation; cancellation `docker stop -t`+reconcile). Env/config -(constructed minimal env includes `PATH`/`RUSTUP_HOME` and an absolute `RUSTC_WRAPPER`; a caller -`RUSTC_WRAPPER`/`PATH` is absent, not merely rejected; non-allowlisted config anywhere fails). -Identity (`app-repo-id` API-verified; `app-ref` rejected unless a full 40-hex SHA; hash golden -vectors; `platform-id` from `image.json`, not caller; consumer re-verifies checkout id/HEAD/workspace -before+after). Provenance (JCS canonical + dup-key rejection; ustar-only exactly-two-members, `pax` -rejected, binary size equality; **ABI loadability** — resolve `DT_NEEDED` in the image + a hardened +**prefetches sources before** the network is disabled for the rustc phase only.) Also: a **stable host cache path** (`${RUNNER_TEMP}/edgezero-sccache-v1`, emptied before restore) — +a matching key **restores across runs** (proving the path is not a per-run `mktemp` that would force +version misses); a corrupt/failed restore **resets cold** (one rebuild from empty); a failed +`sccache --stop-server` **skips the save** (no live-cache archive); the audited cache path is exactly +`SCCACHE_DIR`; **identical source built from two different host checkout paths yields sccache hits** +(fixed `/work/repo/` cwd); a **nested working directory** (`working-directory: +apps/api` under a parent workspace) builds with its enclosing Cargo config/sibling path-deps intact; +**a public git dependency (the EdgeZero repo) builds and caches**; two writers with distinct +`app-cli-artifact` names save **distinct entries** (no key collision). Container/runner/launcher +(self-hosted fails closed; read-only rootfs; **separate build/deploy container instances sharing one +`/work/repo` copy** so build outputs reach deploy; the faithful `/work/repo` copy matches the original +in content/modes/symlinks/**initialized-submodule** state with hardlinks broken and **git-ignored +files excluded**; a manifest command creating `dist/` succeeds in the copy while the original stays +clean; enumerated fixed mount table only; host-side `mutation-attempted` before mutation; cancellation +`docker stop -t`+reconcile). Env/config (constructed minimal env; **`PATH` includes `/usr/local/bin`** +so `fastly` resolves; `RUSTUP_HOME` set and an absolute `RUSTC_WRAPPER`; the deploy profile exposes +only the **enumerated** `EDGEZERO_*` allowlist + the single token; a caller `RUSTC_WRAPPER`/`PATH` is +absent, not merely rejected; non-allowlisted config anywhere fails). Identity (`app-repo-id` +API-verified **with `app-checkout-token` host-side, never forwarded into a container/copy/artifact/ +cache**; `app-ref` rejected unless a full 40-hex SHA; **length-framed `:` hash golden +vectors** with normalized paths; `platform-id` from `image.json`, not caller; consumer re-verifies +checkout id/HEAD/workspace before+after). Provenance (JCS canonical + dup-key rejection; ustar-only +exactly-two-members with **normalized headers** — zero `mtime`/`uid`/`gid`, fixed names — `pax` +rejected, binary size equality; **ABI loadability** — `abi` = recomputed `machine`/`interp`(`null` if +static)/direct-`DT_NEEDED`, transitive resolved in the image, `dlopen` out of scope — + a hardened `--help` smoke (`--network=none --cap-drop=ALL --no-new-privileges`, memory/pids/timeout); a real wrong-runtime rejected; provenance documented consistency-only). Disclosure required for every cross-repo build (equal-id exempt). Recovery production-only. @@ -302,7 +389,25 @@ modes/symlinks/submodules, hardlinks broken); **separate build/deploy container **ustar-only** archive with binary-size equality; a **hardened validator smoke** (`--cap-drop=ALL`, `no-new-privileges`, memory/pids/timeout); and a **warm test via `sccache --show-stats`** (online, since dependency sources are not cached). Public, anonymously-fetchable sources only. Validator string-type -fix + publish-visibility ordering land in the container sub-plan. +fix + publish-visibility ordering land in the container sub-plan. → **v6.16 (contract revision)**: a +**stable host cache path** (`${RUNNER_TEMP}/edgezero-sccache-v1`, emptied before restore) so +`actions/cache`'s path-in-version rule cannot force permanent misses; **whole-repo `/work/repo`** +working copy with the compile cwd at the relative `working-directory` (preserving nested-workspace +parent config/sibling path-deps — the flattened `/work/app` is gone), **git-ignored files excluded** +and **initialized submodules validated**, and the **same copy reused across the separate build/deploy +container instances** so build outputs reach deploy; storage restated as **repository-global LRU** +(evicts unrelated caches, may be billable) — not family-local; **generation keyed on an +`app-cli-artifact` unique across every cache-writing invocation** (fail-closed on a detectable +collision) with concurrent lineages **forked, not merged** (accepted); the **sccache undeclared-input +risk stated as accepted** (no proc-macro input-declaration mechanism exists) with **fail-cold** restore/ +audit/read failures and a **skip-save on `--stop-server` failure**; **`PATH` includes `/usr/local/bin`** +(Fastly/sccache) with **enumerated compile/validation/deploy env profiles** (named `EDGEZERO_*`, not the +namespace); **`app-checkout-token` assigned to the host-side `app-repo-id` API check** and barred from +containers/copies/artifacts/caches; **length-framed `:` hash encoding** with normalized +relative paths, **normalized ustar headers** (zero `mtime`/`uid`/`gid`, fixed names), and **`abi` as +recomputed ELF metadata** (`machine`/`interp`=`null`-if-static/direct-`DT_NEEDED`; transitive resolved, +`dlopen` out of scope). Container sub-plan: two-tier pin policy (major action tags, image digests) and a +**canonical-repository** check in `check-image-pin.sh`. ## 9. Deferred to the implementation plan (mechanics only) From 3f81105b04c891dbaae5388ecd665ece8340e74e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:09:13 -0700 Subject: [PATCH 08/10] build-caching spec v6.17 + plan: address the v6.16 review's contract findings Reusable workflow is BUILD-ONLY: no provider inputs, emits the artifact plus every ExpectedIdentity field as outputs; the shared-copy build->deploy lifecycle moves to the consumer's own deploy job, resolving the one-container-builds-and-deploys contradiction (1). Undeclared-input staleness restated as may-pass-every-downstream- check: a stale proc-macro result can be internally consistent and pass digest/ELF/--help, so provenance/ABI is not a staleness safety net (2). A deploy-compile env/mount profile: fastly compute deploy compiles the wasm, so it carries pinned Rustup/Cargo + fresh target/cargo but NO RUSTC_WRAPPER, SCCACHE_DIR, or cache save, with the token (3). The shared writable copy's tracked files/modes/symlinks/gitlinks are re-verified before the token-bearing deploy-compile; derived state only in declared output paths, so a build.rs that mutates tracked source fails closed (4). Per-operation mount profiles instead of one common table: the unauthenticated validator --help smoke gets no writable repo/target/ cargo/sccache; only cached-compile mounts sccache (5). Cache holds compiled results incl. replayed compiler stdout/stderr (warnings, paths, source excerpts), widening cross-repo disclosure to build diagnostics (6). Cross-repo topology predicates split: deployer ref, called-workflow SHA, and app-checkout SHA checked separately (deployer HEAD != app SHA is fine); path deps permitted anywhere beneath git-root, only a git-root escape rejected (7). Byte-exact path hashing: drop NFC (Linux/Git paths are byte strings; NFC vs NFD are distinct files), define the '.' root, reject non-UTF-8; NFC/NFD golden vectors that must differ (8). job.check_run_id generation, SCCACHE_IGNORE_SERVER_IO_ERROR=1 (per-object IO error -> miss not cold reset), name reserved before save, compiler errors never retried; full recursive non-sparse checkout with LFS/filter content materialized; wall-time is telemetry (11,12 spec parts). Plan: baked project-owned validator (JCS/dup-key/schema/ ustar/ELF, smoke-tested at publish) since jq/tar cannot do it (10); SHA-pin the actions in the write-privileged publish workflow (contents/packages/PRs write) while leaving the repo-wide migration of low-privilege references as a separate decision (9); single-manifest check rejects a one-entry OCI index (leaf manifest required) and the anonymous-pull check reads the merged digest, not the placeholder (12 plan parts). Design only. --- .../plans/2026-08-20-build-cache-container.md | 37 +- ...20-edgezero-deploy-build-caching-design.md | 317 ++++++++++++------ 2 files changed, 240 insertions(+), 114 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-build-cache-container.md b/docs/superpowers/plans/2026-08-20-build-cache-container.md index 3cd88d56..ca2cbe2a 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -8,16 +8,17 @@ **Tech Stack:** Docker (BuildKit), GitHub Actions (`docker/build-push-action`), GHCR, Bash, `jq`. -**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` (v6.16, sccache pivot) — §2 (single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` (v6.17, sccache pivot) — §2 (build-only single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI + baked provenance validator, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). ## Global Constraints - **Rust toolchain baked = `1.95.0`** (verbatim from `.tool-versions`); a build that resolves a different toolchain must fail closed downstream, so this image is the single source of truth. - **Full build+deploy runtime baked** (spec §3.6): `1.95.0` + `wasm32-wasip1` + a pinned **`sccache`** (the cache mechanism, spec §3.1) + the pinned **Fastly CLI `15.1.0`** (`.tool-versions`) + `git jq tar curl cc` — the container is the deploy runtime, not only the CLI-compile runtime. +- **Baked provenance validator** (spec §3.7): the image also bakes a single pinned, **project-owned validator binary** (a small Rust tool built from this repo at the same SHA — not a network-fetched helper) that performs JCS canonicalization, duplicate-key detection, JSON-Schema-2020-12 validation, strict `ustar` parsing, and ELF inspection (`jq`/`tar` cannot). Its capabilities are smoke-tested **before the digest is published** (a downstream sub-plan wires the validator itself; this plan reserves its place in the image and the publish smoke). - **Runtime posture:** consumed **read-only root filesystem, non-root user**, explicit writable mounts only (spec §3.7). - **Single-manifest `linux/amd64` only** — no multi-arch index (an index digest can select another architecture). - **No Python in CI tooling** — Bash + `jq` only. -- **Pin policy (two-tier, matching the repo's `check-action-pins.sh` gate):** **actions** are pinned to a **released version tag** — a major tag such as `@v7` — per the repo's standing convention (`actions/checkout@v7` passes the gate; the gate accepts a major tag or a full commit SHA, never a floating `@main`/`@latest`); **images** are pinned by `sha256` digest (the base image's digest in the `FROM`, and the published image's digest recorded in `image.json`). Digest immutability is required only where the toolchain/ABI identity depends on it — i.e. the container. +- **Pin policy (risk-tiered, at or above the repo's `check-action-pins.sh` gate):** **images** are pinned by `sha256` digest (the base image's digest in the `FROM`, and the published image's digest recorded in `image.json`). **Actions in this write-privileged publish workflow are pinned to a full 40-hex commit SHA** — GitHub identifies a full commit SHA as the only immutable action reference, and this workflow holds `contents: write` + `packages: write` + `pull-requests: write`, a supply-chain-sensitive privilege class where a re-tagged major version is an unacceptable risk. (Elsewhere in the repo, low-privilege read-only actions follow the standing major-tag convention the gate accepts; **whether to migrate those existing references to SHAs is a separate, repo-wide decision** — see the review note — not made by this container plan.) - **No AI bylines** in commits or PR bodies. - **Bash 3.2-compatible** scripts (macOS dev parity); scripts are `shellcheck -S warning` clean. @@ -263,7 +264,11 @@ jobs: publish: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 + # SHA-PINNED (not @v7): this job is write-privileged (contents/packages/PRs), + # so every action is pinned to a full 40-hex commit SHA — the only immutable + # action reference. Replace with the pinned actions/checkout + # release SHA (recorded in a comment as its version, e.g. # v4.3.0). + - uses: actions/checkout@ # vX.Y.Z # Trusted publish job (no app code runs here); keep the token so the # pin-record PR branch can be pushed. with: @@ -288,10 +293,22 @@ jobs: run: | set -euo pipefail REF="$REPO@$DIGEST" - # Single-manifest linux/amd64 (reject a multi-arch index). - n=$(docker buildx imagetools inspect "$REF" --format '{{json .}}' \ - | jq '[.. | .manifests? // empty | .[] | select(.platform.os != "unknown")] | length') - [ "${n:-1}" -le 1 ] || { echo "::error::not single-manifest ($n)"; exit 1; } + # Require a LEAF image manifest, not an index — reject ANY manifest list, + # including a one-entry OCI index (a count `<= 1` would wrongly accept it, + # and an index digest can be repointed to select a different image). The + # digest must resolve to an image manifest (has .config + .layers, no + # .manifests), whose platform is linux/amd64. + mt=$(docker buildx imagetools inspect "$REF" --raw | jq -r '.mediaType // ""') + case "$mt" in + *"image.index"*|*"manifest.list"*) + echo "::error::$REF is an index/manifest-list ($mt), not a leaf image manifest"; exit 1 ;; + esac + docker buildx imagetools inspect "$REF" --raw \ + | jq -e '(.config != null) and (.layers != null) and (.manifests == null)' >/dev/null \ + || { echo "::error::$REF is not a leaf image manifest (config+layers, no manifests)"; exit 1; } + plat=$(docker buildx imagetools inspect "$REF" --format '{{json .Image.Platform}}') + echo "$plat" | jq -e '.os=="linux" and .architecture=="amd64"' >/dev/null \ + || { echo "::error::$REF is not linux/amd64 ($plat)"; exit 1; } # Runtime smoke, pulled with the AUTHENTICATED session (a GHCR package is # PRIVATE on first publish, so an anonymous pull here would deadlock the very # first release). The anonymous-pull check is the operator's post-make-public @@ -325,7 +342,9 @@ The publish thus **pushes → inspects by digest → verifies single-manifest + - [ ] **Step 2: Actionlint the workflow** -Run: `actionlint .github/workflows/publish-build-container.yml` +Run: `actionlint .github/workflows/publish-build-container.yml` (after substituting the real +`actions/checkout` release SHA for the `` placeholder, as with the +Dockerfile's base-image digest). Expected: no output. - [ ] **Step 3: Commit** @@ -337,7 +356,7 @@ git commit -m "build-cache container: GHCR publish workflow recording the manife - [ ] **Step 4: Publish (operator step, out of band)** -Tag `build-container-v1` and push it. The workflow pushes the image, **verifies it by digest** (single-manifest + an **authenticated** runtime smoke — the package is private on first publish), and **opens a PR** updating `image.json` to the real `sha256` digest. **Make the GHCR package public** (below), then verify the **anonymous** pull. Review and merge the PR — the digest is the pin the rest of the feature keys on, and it is only recorded after passing verification against the actual pushed image. +Tag `build-container-v1` and push it. The workflow pushes the image, **verifies it by digest** (leaf image manifest, linux/amd64 + an **authenticated** runtime smoke — the package is private on first publish), and **opens a PR** updating `image.json` to the real `sha256` digest. Ordering matters: **review and merge the PR FIRST** — only then does the committed `image.json` carry the real digest — **then make the GHCR package public and verify the anonymous pull reading the merged `image.json`** (verifying before merge would read the still-placeholder digest). The digest is the pin the rest of the feature keys on, and it is only recorded after passing verification against the actual pushed image. **One-time GHCR visibility + retention (operator):** GHCR packages are **private on first publish** and there is no clean REST endpoint to flip a container package public, so set the package `edgezero-build-app-cli` to **public** in its GHCR package settings (or set the org's default package visibility) so consumers can **anonymously** pull by digest (spec §3.7), and enable a retention policy that never prunes a digest referenced by a committed `image.json`. Verify anonymous access: ```bash diff --git a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md index b1bdda8b..5763745e 100644 --- a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md +++ b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md @@ -1,6 +1,6 @@ # EdgeZero Deploy Actions — Build Caching Spec -**Status:** Design (proposed) — v6.16 (sccache pivot, hardened) +**Status:** Design (proposed) — v6.17 (sccache pivot, hardened) **Related:** `docs/specs/edgezero-deploy-github-action.md`, `docs/specs/edgezero-deploy-action-implementation-plan.md`, @@ -22,10 +22,15 @@ git dependencies** (so a crates.io-only rule is unusable). - **The deployer owns and writes its repo-scoped cache**; every writer that can write the deployer's **current-/default-branch** cache is trusted; the deployer's protected workflow allowlists `app-repository`/`app-ref`. -- **The reusable workflow is the only SUPPORTED producer** (build + deploy in one **pinned - container**, §3.6). Provenance is a **consistency check, not producer authentication** (an - other-job archive can self-assert; attestation is §7). The direct composite is an internal `$/` - step only. +- **The reusable workflow is the only SUPPORTED producer, and it is BUILD-ONLY.** It compiles the app + CLI in the **pinned container** (§3.6), caches via sccache, and **emits an artifact plus every + `ExpectedIdentity` field as workflow outputs** (§3.8) — it takes **no provider inputs and never + deploys**. Deployment is the **consumer's own job**: it validates the artifact + (`validate-app-cli-provenance`) then runs the CLI, whose `fastly compute deploy` compiles the wasm + target under a token-bearing **deploy-compile** profile (§3.3) and deploys. The shared writable + working copy / build→deploy lifecycle (§3.6) lives in that **consumer deployment job**, not the + reusable workflow. Provenance is a **consistency check, not producer authentication** (an other-job + archive can self-assert; attestation is §7). The direct composite is an internal `$/` step only. - **GitHub-hosted `linux/amd64` runners only** (no reliable ephemeral self-hosted predicate). ## 3. Design @@ -40,23 +45,28 @@ for shared dependency acceleration: cached, never inside the checkout) — so there is no stale-`target/`, no source-in-target, no workspace-crate-output, and no unit-graph classification problem. - **`RUSTC_WRAPPER` is set (action-owned) to the pinned `sccache`** (an **absolute path**, - `/usr/local/bin/sccache`, §3.3) baked into the container. sccache keys a rustc invocation on its - **preprocessed source, `dep-info` inputs, compiler arguments, dependency artifacts, a subset of - the environment, and the working directory** (v0.10) — so a cached object is reused only when all - of those match, and **restoring an older `SCCACHE_DIR` never yields an incorrect object**. - **Correctness caveat (accepted risk, not a condition apps satisfy):** sccache's own Rust guidance - warns it may **not** cache correctly when a **`build.rs` or a proc-macro reads files or environment - not declared as inputs** (undeclared inputs). Rust has **no general mechanism for a proc-macro to - declare its filesystem inputs**, so this cannot be posed as a precondition an application meets — it - is simply the risk `cache: true` **accepts**. v1 does not detect it; enabling `cache: true` is an - **explicit acceptance** of possible staleness for build scripts / proc-macros with undeclared - inputs (documented on the input), with the fallback that a wrong object still fails the downstream - provenance/ABI checks. No custom pruning; `SCCACHE_CACHE_SIZE` bounds each snapshot (§3.2). -- **Cache contents = `SCCACHE_DIR` only** (compiled objects + sccache's index). **No `.crate` - sources, no `registry/src`, no `git/*`, no `CARGO_HOME/bin`, no config, no credentials** are - cached — so a cold build's `registry/src` extraction is irrelevant to the audit, and **no - dependency source is ever cached** (only compiled objects). Re-downloading crates each run is the - small remaining cost; caching `.crate` archives is §7. + `/usr/local/bin/sccache`, §3.3) baked into the container. sccache keys a rustc invocation on the + inputs it **observes** — **preprocessed source, `dep-info` inputs, compiler arguments, dependency + artifacts, a subset of the environment, and the working directory** (v0.10) — so a cached result is + reused only when all of *those* match. **Correctness is guaranteed only for observed inputs.** + **Undeclared-input caveat (accepted risk, may pass every downstream check):** sccache's own Rust + guidance warns it may **not** cache correctly when a **`build.rs` or a proc-macro reads files or + environment not among those observed inputs** (undeclared inputs). Rust has **no general mechanism + for a proc-macro to declare its filesystem inputs**, so this cannot be posed as a precondition an + application meets — it is simply the risk `cache: true` **accepts**. A stale result from an + undeclared input can be **internally consistent** and therefore **pass digest, ELF, and `--help` + validation** — the downstream provenance/ABI checks are consistency checks, **not** a staleness + detector, so they are **not** a safety net for this. v1 does not detect it; enabling `cache: true` + is an **explicit acceptance** of that staleness risk (documented on the input). No custom pruning; + `SCCACHE_CACHE_SIZE` bounds each snapshot (§3.2). +- **Cache contents = `SCCACHE_DIR` only** — sccache stores each cached compilation's **object output, + its index, AND the compiler's stdout/stderr** (which sccache **replays** on a hit). That replayed + diagnostic text can contain **warning messages, absolute paths, source excerpts, and compile-time + values**, so the cache holds **more than object files** (this widens the disclosure surface, §3.7). + **No `.crate` sources, no `registry/src`, no `git/*`, no `CARGO_HOME/bin`, no config, no + credentials** are cached — so a cold build's `registry/src` extraction is irrelevant to the audit, + and **no dependency source is ever cached** (only compiled results). Re-downloading crates each run + is the small remaining cost; caching `.crate` archives is §7. - **Public, anonymously-fetchable sources only.** sccache caches the compilation of any source, but the minimal build environment (§3.3) carries **no credentials**, so the dependency graph must be **anonymously fetchable** — `crates.io` and **public git** (e.g. the public EdgeZero repo the @@ -74,15 +84,15 @@ for shared dependency acceleration: (constant across runs of a given runner-arch), **emptied before restore**, and bind-mounted at the constant in-container `SCCACHE_DIR=/work/sccache` (§3.6). Only `SCCACHE_DIR` is archived. - **Key** = `-`, `` = `edgezero-sccache-v1--`, - restore-keys prefix `-`. `` = `--`, - where **`invocation-id` is unique across every cache-writing invocation** — not merely per matrix - leg but per reusable-workflow call in a run (two calls in one run share `run_id`/`run_attempt` and - can share a default `app-cli-artifact`, so the artifact **name alone is insufficient**). It is the - **`suffix-hash`-bound `app-cli-artifact`** (required unique per writer, §3.8) **hashed into the key**; - `run_attempt` additionally distinguishes **re-runs** (same `run_id`). Each writer thus saves a - **distinct immutable entry** and restores the **newest** in its ``. `platform-id` = the - container digest; `suffix-hash` = the validated `cache-key-suffix` (§3.8). No lockfile/manifest - hashing — sccache content-addresses internally. + restore-keys prefix `-`. `` = `` — GitHub's **per-job unique + check-run id**, which differs for every job in a run (so two reusable-workflow calls in one run, and + every matrix leg, get distinct generations without relying on `run_id`/`run_attempt`/artifact-name + collision reasoning). The validated `app-cli-artifact` (unique per writer, §3.8) is **hashed into + ``** so distinct writers also occupy distinct families. Each writer saves a **distinct + immutable entry** and restores the **newest** in its ``; the immutable artifact/cache name + is **reserved (the save key computed and committed to) before save**, so a late collision fails + closed rather than clobbering. `platform-id` = the container digest; `suffix-hash` = the validated + `cache-key-suffix` (§3.8). No lockfile/manifest hashing — sccache content-addresses internally. - **Concurrent lineages (accepted).** Concurrent matrix/sibling writers each restore the same newest snapshot and **fork** it; entries are immutable and **not merged**, so only one lineage's warmth is carried forward per family and the others' incremental warmth is **lost** (re-warmed next run). v1 @@ -96,14 +106,21 @@ for shared dependency acceleration: **billable**. v1 **explicitly accepts** repository-global LRU/thrashing under the rolling scheme (no action-side cleanup; the actor lacks a cross-workflow cache-delete permission by default). Bump the `-v1-` family namespace when the mechanism changes. -- **Restore → audit → build → stop-server → best-effort save, with fail-cold contracts.** After - restore, **audit** that the restored path is exactly `SCCACHE_DIR` and contains only sccache's - blob/index layout. **Any restore, audit, or sccache-read failure resets to a cold build** (discard - the restored dir, build once from empty) rather than aborting. Run `sccache --show-stats` for - observability. Before save, **`sccache --stop-server`** flushes and shuts the server down so - `SCCACHE_DIR` is consistent on disk; **if `--stop-server` fails, the save is SKIPPED** (never - archive a live/again-mutating cache). `actions/cache/save` under the run's `` key is - otherwise **best-effort** (failures are warnings). +- **Restore → audit → build → stop-server → best-effort save, with executable failure contracts.** + Two distinct failure classes, handled differently: + - **Restore/audit failure → clear and build cold.** After restore, **audit** that the restored path + is exactly `SCCACHE_DIR` and contains only sccache's blob/index layout. A **restore download + failure or a failed audit** discards the restored dir and **builds once from empty** (the whole + cache is suspect). + - **An sccache per-object read/IO error → that object MISSES, the build continues** (not a cold + reset): `SCCACHE_IGNORE_SERVER_IO_ERROR=1` makes sccache treat a storage IO error as a cache miss + and compile directly, so one unreadable object does not fail the build. + - **Ordinary compiler failures are NEVER retried** — a `rustc` error is the app's, surfaced as-is; + the cache layer does not re-invoke it. + Run `sccache --show-stats` for observability. Before save, **`sccache --stop-server`** flushes and + shuts the server down so `SCCACHE_DIR` is consistent on disk; **if `--stop-server` fails, the save + is SKIPPED** (never archive a live/again-mutating cache). `actions/cache/save` under the run's + reserved `` key is otherwise **best-effort** (failures are warnings). ### 3.3 Action-owned Cargo/sccache environment @@ -116,16 +133,24 @@ enumerated allowlist exist. **`PATH` = `/usr/local/bin:/usr/local/cargo/bin:/usr **Enumerated env profiles** (each an exact, closed set — no inherited namespace): -- **compile/build:** `PATH` (above), `RUSTUP_HOME=/usr/local/rustup`, `CARGO_HOME` (§below), - `RUSTC_WRAPPER=/usr/local/bin/sccache` (absolute), `RUSTUP_TOOLCHAIN`, `CARGO_TARGET_DIR` (fresh), - `SCCACHE_DIR`, `SCCACHE_CACHE_SIZE=2G`, `HOME`, `TMPDIR`, `CARGO_ENCODED_RUSTFLAGS=""`, - `CARGO_INCREMENTAL=0`. **No** `sccache`/wrapper vars in the deploy/validation profiles. +- **cached compile/build (credential-free):** `PATH` (above), `RUSTUP_HOME=/usr/local/rustup`, + `CARGO_HOME` (§below), `RUSTC_WRAPPER=/usr/local/bin/sccache` (absolute), + `SCCACHE_IGNORE_SERVER_IO_ERROR=1`, `RUSTUP_TOOLCHAIN`, `CARGO_TARGET_DIR` (fresh), `SCCACHE_DIR`, + `SCCACHE_CACHE_SIZE=2G`, `HOME`, `TMPDIR`, `CARGO_ENCODED_RUSTFLAGS=""`, `CARGO_INCREMENTAL=0`. + **No** provider token. +- **deploy-compile (`fastly compute deploy`, token-bearing):** `fastly compute deploy` **compiles the + wasm target**, so this profile carries the **pinned Rustup/Cargo state** — `PATH`, + `RUSTUP_HOME=/usr/local/rustup`, `RUSTUP_TOOLCHAIN`, a **fresh** `CARGO_TARGET_DIR` and a **fresh** + `CARGO_HOME` (no restored state), `CARGO_ENCODED_RUSTFLAGS=""`, `CARGO_INCREMENTAL=0`, `HOME`, + `TMPDIR` — **but NO `RUSTC_WRAPPER`, NO `SCCACHE_DIR`, and no cache save** (the deploy compile is not + cached; the token must never touch the sccache path), plus the **single** provider token + (`FASTLY_API_TOKEN`) and the enumerated `EDGEZERO_*` allowlist (below). - **validation (`validate-app-cli-provenance`):** `PATH`, `HOME`, `TMPDIR` only (no cargo/sccache, no token) — it recomputes ELF metadata and runs the hardened smoke (§3.7). -- **deploy (`active-version-fastly`, config-push):** `PATH`, `HOME`, `TMPDIR`, the **single** provider - token (`FASTLY_API_TOKEN`), and an **enumerated** `EDGEZERO_*` allowlist — the specific public - variables the deploy CLI reads are **listed by name** (not the whole `EDGEZERO_*` namespace); an - unlisted `EDGEZERO_*` is not present. +- **read-only provider query / config-push (`active-version-fastly`, config-push):** `PATH`, `HOME`, + `TMPDIR`, the **single** provider token (`FASTLY_API_TOKEN`), and an **enumerated** `EDGEZERO_*` + allowlist — the specific public variables the deploy CLI reads are **listed by name** (not the whole + `EDGEZERO_*` namespace); an unlisted `EDGEZERO_*` is not present. No cargo/sccache. A **caller-supplied** `RUSTC`/`RUSTC_WRAPPER`/`RUSTC_WORKSPACE_WRAPPER`/`RUSTDOC`/`RUSTFLAGS`/ native-tool/`PATH` var simply is **not present** in any constructed profile (never inherited). @@ -143,7 +168,9 @@ flattened single-directory mount would break `working-directory: apps/api`), `CA The effective **Cargo config** over the full chain (cwd → `/`, incl. the working directory, plus `CARGO_HOME`) must contain only benign allowlisted keys (registry index URLs, `net.retry`, `http.timeout`/`check-revoke`); anything else fails closed. Default-features-only; `Cargo.lock` must -be a tracked, regular file. External path deps outside the workspace root are rejected. +be a tracked, regular file. **Path dependencies are permitted anywhere beneath `git-root`** (so a +sibling crate such as `../shared` in the same repository resolves — the whole repo is mounted, §3.6); +only a path that **escapes `git-root`** (a repository escape) is rejected. ### 3.4 Identity @@ -160,21 +187,35 @@ commit SHA** (short refs/branches/tags rejected). `workspace-root` canonicalized **All identity hashes are SHA-256 over a canonical, length-framed encoding.** Each field is encoded as its UTF-8 bytes prefixed by a length frame: the byte length as **ASCII decimal with no leading zeros** followed by a single `:` separator (`:`), fields concatenated in a fixed order — -so no field boundary is ambiguous and no fixed width can overflow. **Path fields are normalized -first** — expressed **relative to `git-root`**, `/`-separated, no `.`/`..`/empty segments, no -trailing slash, NFC — so the same logical path hashes identically across runners. `workspace-id` = -that hash over (`app-repo-id`, normalized workspace-root path relative to `git-root`); -`suffix-hash` = that hash over the validated `cache-key-suffix`. **Golden vectors** (including the -exact `:` framing) for each hash are committed with the plan. +so no field boundary is ambiguous and no fixed width can overflow. **Path fields are byte-exact, not +Unicode-folded.** A path is expressed **relative to `git-root`** with a **defined root +representation** — the root itself encodes as the single byte `.` (never the empty string) — is +`/`-separated with **no `.`/`..`/empty segments and no trailing slash**, and is hashed as its **exact +UTF-8 bytes with NO Unicode normalization** (no NFC/NFD): Linux and Git treat a path as a byte string, +so two byte-distinct paths (e.g. an NFC vs. NFD spelling of the same character) are **distinct files** +and must hash **distinctly** — folding them would collide two real workspaces onto one `workspace-id`. +A **non-UTF-8 path byte sequence is rejected** (fail closed). `workspace-id` = that hash over +(`app-repo-id`, workspace-root path relative to `git-root`); `suffix-hash` = that hash over the +validated `cache-key-suffix`. **Golden vectors** — including the `:` framing, the `.` +root, and an **NFC-vs-NFD pair that must produce different hashes** — are committed with the plan. `platform-id` = the container digest, **read inside every action from `image.json` at the same EdgeZero SHA — never caller-supplied**; `container-ref` = `@`. ### 3.5 Writer fidelity vs. source authorization -Cache runs only on `push`/`workflow_dispatch`/`schedule` on a **protected deployer ref** with -`HEAD == resolved app SHA` (action fidelity); the deployer's protected workflow allowlists the app -identity, and every writer of the deployer's **current-/default-branch** cache scope is trusted -(deployer authorization). Normative in the guide. +Cache runs only on `push`/`workflow_dispatch`/`schedule` on a **protected deployer ref**. Because the +**deployer and the app can be separate repositories**, the deployer's `HEAD` is **not** the app SHA — +the predicates are **checked separately**, never conflated into one equality: + +1. **Deployer workflow identity** — the calling workflow runs on a protected deployer ref + (`push`/`dispatch`/`schedule`), whose protected config allowlists the app identity. +2. **Called-workflow SHA** — the reusable workflow is called at a pinned EdgeZero SHA (the `$/` + self-repo floor, §3.8). +3. **App-checkout SHA** — the mounted app checkout's `HEAD` equals the resolved `app-ref` (a full + 40-hex SHA, §3.4), asserted against the **app** repo — independently of the deployer's own `HEAD`. + +Every writer of the deployer's **current-/default-branch** cache scope is trusted (deployer +authorization). Normative in the guide. ### 3.6 Container, runner, launcher @@ -193,21 +234,29 @@ identity, and every writer of the deployer's **current-/default-branch** cache s into the deploy instance** (as derived build state, not re-copied), so generated files (`dist/`, staged `pkg/`, produced manifests) reach the deploy step without a lossy re-clone. Freeze assertions (§3.7) run against the **read-only original**, never this mutated copy. -- **One launcher `run-app-cli-in-container`** with a **complete fixed mount table** (constant - in-container paths, so sccache's cwd/path hashing is stable regardless of the host checkout - location; never `RUNNER_TEMP` wholesale): - - | In-container path | Mode | Source | - | --- | --- | --- | - | `/work/repo` (repo root; compile cwd = `/work/repo/`) | **writable** | a **verified faithful copy** of the whole app checkout, layout preserved | - | `/work/target` | writable | fresh `CARGO_TARGET_DIR` | - | `/work/cargo-home` | writable | `CARGO_HOME` | - | `/work/sccache` | writable | `SCCACHE_DIR` (restored from the stable host path, §3.2) | - | `/work/home`, `/work/tmp` | writable (tmpfs) | provider/Fastly `HOME`, `TMPDIR` | - | the package/output dir | writable | staged CLI / Fastly `pkg/` | - | the validated CLI binary | read-only | consumer input | - | the specific inline-config temp file | read-only | config-push only, by exact path | - +- **One launcher `run-app-cli-in-container`** with a **maximum mount allowlist** and a **minimal + per-operation mount profile** (constant in-container paths, so sccache's cwd/path hashing is stable + regardless of the host checkout location; never `RUNNER_TEMP` wholesale). **No operation receives + more than its profile lists** — in particular the **archive-supplied validator is not authenticated, + so its `--help` smoke gets NONE of the writable repo/target/cargo-home/sccache mounts.** The table + is the ceiling; each row's "Ops" column is the closed set of operations that may mount it: + + | In-container path | Mode | Ops (only these mount it) | Source | + | --- | --- | --- | --- | + | `/work/repo` (repo root; compile cwd = `/work/repo/`) | **writable** | cached-compile, deploy-compile | a **verified faithful copy** of the whole app checkout, layout preserved | + | `/work/target` | writable | cached-compile, deploy-compile (each its own **fresh** dir) | fresh `CARGO_TARGET_DIR` | + | `/work/cargo-home` | writable | cached-compile, deploy-compile (fresh) | `CARGO_HOME` | + | `/work/sccache` | writable | **cached-compile only** | `SCCACHE_DIR` (restored from the stable host path, §3.2) | + | `/work/home`, `/work/tmp` | writable (tmpfs) | all | provider/Fastly `HOME`, `TMPDIR` | + | the package/output dir | writable | deploy-compile, config-push | staged CLI / Fastly `pkg/` | + | the validated CLI binary | read-only | **validation, provider-query, deploy** | consumer input | + | the specific inline-config temp file | read-only | **config-push only**, by exact path | config-push only | + + So: **validation** (`--help` smoke) mounts only the read-only binary + `/work/home,/work/tmp` — no + repo/target/cargo/sccache; **read-only provider query** mounts the read-only binary + tmpfs + + (host-side) token, nothing writable-source; **config-push** adds only the one read-only inline-config + file; **cached-compile** is the only operation that mounts `/work/sccache`; **deploy-compile** mounts + the (re-verified, §3.7) `/work/repo` + fresh target/cargo + output dir + token, **never sccache**. UID/GID mapping so the non-root container user owns the writable mounts. - **Writable working COPY (whole repo, layout preserved).** The CLI runs arbitrary manifest commands via `sh -c` in the manifest root and may create `dist/`, `node_modules/`, generated manifests — so @@ -232,12 +281,19 @@ identity, and every writer of the deployer's **current-/default-branch** cache s - **Source freezing:** the writable `/work/repo` copy is proven a **faithful copy** of the read-only original (§3.6) before compilation — **tracked files + initialized submodules only, git-ignored/ untracked detritus excluded**, so the copy is exactly what `source-revision` represents — and that - **same copy (now with build outputs) is reused for the deploy instance** (§3.6), so the frozen - source, the executed bytes, and the deployed artifacts are one lineage. On the **read-only + **same copy (now with build outputs) is reused for the deploy instance** (§3.6). On the **read-only original**, assert the initial `HEAD` SHA unchanged + tree clean (tracked + untracked + recursive - submodules) **before and after** all app-controlled commands; reject escaping symlinks. Consumers - additionally **verify their mounted checkout's repository id, `HEAD`, and workspace against the - artifact before and after commands**. + submodules) **before and after** all app-controlled commands; reject escaping symlinks. + - **Re-verify the shared copy before the token-bearing deploy-compile.** A `build.rs`/manifest + command in the credential-free build could have **mutated a tracked file inside `/work/repo`**; + the read-only-original assertions would still pass while the deploy step compiles the **changed + bytes** with the token present. So **before deploy-compile, re-verify every initially-tracked + path** in the copy against the frozen source — **content, mode, symlink target, and gitlink + (submodule commit)** — and **permit divergence only in explicitly declared output paths** + (`target/`, the staged package dir, and any manifest-declared build outputs). Any change to a + tracked source file outside those declared paths **fails closed** before the token is used. + Consumers additionally **verify their mounted checkout's repository id, `HEAD`, and workspace against + the artifact before and after commands**. - **`ExpectedIdentity`:** `app-repo-id` (decimal string), `source-revision` (full SHA, explicit), `app-cli-package`, `app-cli-bin`, `workspace-id` — **caller-supplied and checkout-verified**; `platform-id`/`container-ref` are **derived inside every action from same-SHA `image.json`, not @@ -268,7 +324,15 @@ identity, and every writer of the deployer's **current-/default-branch** cache s - **`validate-app-cli-provenance`** (fresh pinned container, minimal env, hardened): enforce the archive contract; JCS + JSON-Schema validate; re-verify binary digest/size; **ABI loadability proof** — recompute `PT_INTERP`, `DT_NEEDED`, and search paths from the binary, **resolve every - required library inside the immutable image**, then run a **credential-free `--help` smoke**. The + required library inside the immutable image**, then run a **credential-free `--help` smoke**. + - **Trusted validation runtime (baked, project-owned).** JCS canonicalization, duplicate-key + detection, JSON-Schema-2020-12 validation, strict `ustar` parsing, and ELF inspection are **not + expressible in `jq`/`tar`**, so the image **bakes a single pinned, project-owned validator binary** + (a small Rust tool built from the EdgeZero repo at the same SHA — **not** a network-fetched helper, + keeping the validation runtime credential-free and offline) that performs all of them. The + container plan **smoke-tests every required capability** (JCS, dup-key reject, schema reject, + non-ustar/pax reject, ELF read) **before the image digest is published**, so a missing capability + fails the publish, not a deploy. The smoke runs the archive-supplied binary under **`--network=none --read-only --user 1001 --cap-drop=ALL --security-opt=no-new-privileges`, a bounded `--memory`/`--pids-limit`, and a wall timeout** (Docker enforces these directly). Compare every caller `ExpectedIdentity` field. Output @@ -284,9 +348,10 @@ identity, and every writer of the deployer's **current-/default-branch** cache s `ExpectedIdentity`. - **Disclosure (enforceable):** because the action cannot compare reader sets, require **`disclosure-acknowledged: true` for every cross-repository build** (`app-repo-id` ≠ the deployer - repo id), **exempting only equal repository ids**. The sccache cache holds **compiled objects** - (not dependency source), so the exposure it acknowledges is compiled artifacts; `deploy-fastly.cache` - carries the same acknowledgement. + repo id), **exempting only equal repository ids**. The sccache cache holds **compiled results — not + only object files but the replayed compiler stdout/stderr** (warnings, absolute paths, source + excerpts, compile-time values, §3.1) — so the exposure it acknowledges is **compiled artifacts and + build diagnostics**, not merely objects; `deploy-fastly.cache` carries the same acknowledgement. ### 3.8 Reusable-workflow contract @@ -296,9 +361,16 @@ Inputs: `app-repository`, `app-ref`, **`app-repo-id`** (string, always required) reusable-workflow call in a run; the action **fails closed** on a collision it can detect, since two calls sharing `run_id`/`run_attempt` and a default artifact name would otherwise write the same key), `cache` (default `false`), `cache-key-suffix`, `disclosure-acknowledged` (required-true for cross-repo), -`timeout-minutes` (30). **No `rust-toolchain`/feature inputs.** Secret `app-checkout-token`. Job -`permissions: { contents: read }` (caller grants ≥ that); `persist-credentials: false`. **Runner floor -2.336.0** (self-repo `$/`). +`timeout-minutes` (30). **No `rust-toolchain`/feature inputs, and NO provider inputs** (the workflow is +**build-only**, §2 — it never deploys). Secret `app-checkout-token`. Job `permissions: { contents: +read }` (caller grants ≥ that); `persist-credentials: false`. **Runner floor 2.336.0** (self-repo `$/`). + +**Outputs (build-only):** the built **`artifact-name`** (the uploaded provenance tar, §3.7) plus +**every `ExpectedIdentity` field explicitly** — `app-repo-id`, `source-revision`, `app-cli-package`, +`app-cli-bin`, `workspace-id`, `platform-id`, `container-ref` — so a single-build caller can pass them +straight into its **own deployment job** (`validate-app-cli-provenance` → `active-version-fastly` → +the CLI's `fastly compute deploy`, §2). The shared writable copy / deploy-compile (§3.3/§3.6) lives in +that consumer job, never here. **Matrix:** v1's shared workflow outputs are **single-build** (GitHub returns only the last matrix leg's outputs). A **matrix caller uses unique per-leg `app-cli-artifact` names** (which also key each @@ -320,26 +392,42 @@ version misses); a corrupt/failed restore **resets cold** (one rebuild from empt (fixed `/work/repo/` cwd); a **nested working directory** (`working-directory: apps/api` under a parent workspace) builds with its enclosing Cargo config/sibling path-deps intact; **a public git dependency (the EdgeZero repo) builds and caches**; two writers with distinct -`app-cli-artifact` names save **distinct entries** (no key collision). Container/runner/launcher -(self-hosted fails closed; read-only rootfs; **separate build/deploy container instances sharing one -`/work/repo` copy** so build outputs reach deploy; the faithful `/work/repo` copy matches the original -in content/modes/symlinks/**initialized-submodule** state with hardlinks broken and **git-ignored -files excluded**; a manifest command creating `dist/` succeeds in the copy while the original stays -clean; enumerated fixed mount table only; host-side `mutation-attempted` before mutation; cancellation +`job.check_run_id` generations save **distinct entries** (no key collision); an sccache per-object IO +error **misses and continues** (`SCCACHE_IGNORE_SERVER_IO_ERROR=1`) while a bad restore **resets cold**; +a `rustc` error is **surfaced, not retried**. **Wall-time drop is telemetry, not a pass/fail assertion** +(only the sccache hit-rate rise is asserted). Topology (**build-only reusable workflow**: it exposes the +artifact + every `ExpectedIdentity` field as outputs, takes **no provider input**, and never deploys; +the **consumer's own job** validates then deploy-compiles; the cross-repo predicates — deployer ref, +called-workflow SHA, app-checkout SHA — are checked **separately** (deployer HEAD ≠ app SHA is fine); +a **path dep beneath `git-root` resolves**, only a `git-root` escape is rejected). Container/runner/ +launcher (self-hosted fails closed; read-only rootfs; **full recursive, non-sparse checkout** with LFS/ +smudge-filter content materialized (not pointer files); **separate build/deploy container instances +sharing one `/work/repo` copy** so build outputs reach deploy; the faithful `/work/repo` copy matches the +original in content/modes/symlinks/**initialized-submodule** state with hardlinks broken and **git-ignored +files excluded**; **before deploy-compile the copy's tracked files/modes/symlinks/gitlinks are +re-verified** and a `build.rs` that mutated a tracked file fails closed; a manifest command creating +`dist/` succeeds in the copy while the original stays clean; **per-operation mount profiles** — the +unauthenticated validator `--help` smoke gets **no** writable repo/target/cargo/sccache, and only +cached-compile mounts `/work/sccache`; host-side `mutation-attempted` before mutation; cancellation `docker stop -t`+reconcile). Env/config (constructed minimal env; **`PATH` includes `/usr/local/bin`** -so `fastly` resolves; `RUSTUP_HOME` set and an absolute `RUSTC_WRAPPER`; the deploy profile exposes -only the **enumerated** `EDGEZERO_*` allowlist + the single token; a caller `RUSTC_WRAPPER`/`PATH` is -absent, not merely rejected; non-allowlisted config anywhere fails). Identity (`app-repo-id` -API-verified **with `app-checkout-token` host-side, never forwarded into a container/copy/artifact/ -cache**; `app-ref` rejected unless a full 40-hex SHA; **length-framed `:` hash golden -vectors** with normalized paths; `platform-id` from `image.json`, not caller; consumer re-verifies -checkout id/HEAD/workspace before+after). Provenance (JCS canonical + dup-key rejection; ustar-only -exactly-two-members with **normalized headers** — zero `mtime`/`uid`/`gid`, fixed names — `pax` -rejected, binary size equality; **ABI loadability** — `abi` = recomputed `machine`/`interp`(`null` if -static)/direct-`DT_NEEDED`, transitive resolved in the image, `dlopen` out of scope — + a hardened -`--help` smoke (`--network=none --cap-drop=ALL --no-new-privileges`, memory/pids/timeout); a real -wrong-runtime rejected; provenance documented consistency-only). Disclosure required for every -cross-repo build (equal-id exempt). Recovery production-only. +so `fastly` resolves; the **deploy-compile profile** carries Rustup/Cargo + fresh target/cargo but +**no `RUSTC_WRAPPER`/`SCCACHE_DIR`/save**; `RUSTUP_HOME` set and an absolute `RUSTC_WRAPPER` in the +cached-compile profile; the deploy profile exposes only the **enumerated** `EDGEZERO_*` allowlist + the +single token; a caller `RUSTC_WRAPPER`/`PATH` is absent, not merely rejected; non-allowlisted config +anywhere fails). Identity (`app-repo-id` API-verified **with `app-checkout-token` host-side, never +forwarded into a container/copy/artifact/cache**; `app-ref` rejected unless a full 40-hex SHA; +**length-framed `:` hash golden vectors** with the `.` root and a **byte-exact NFC-vs-NFD +pair that hashes differently** (no Unicode folding); a **non-UTF-8 path rejected**; `platform-id` from +`image.json`, not caller; consumer re-verifies checkout id/HEAD/workspace before+after). Provenance +(the **baked project-owned validator** smoke-tests JCS/dup-key/schema/ustar/ELF capability at publish; +JCS canonical + dup-key rejection; ustar-only exactly-two-members with **normalized headers** — zero +`mtime`/`uid`/`gid`, fixed names — `pax` rejected, binary size equality; **ABI loadability** — `abi` = +recomputed `machine`/`interp`(`null` if static)/direct-`DT_NEEDED`, transitive resolved in the image, +`dlopen` out of scope — + a hardened `--help` smoke (`--network=none --cap-drop=ALL --no-new-privileges`, +memory/pids/timeout); a real wrong-runtime rejected; **a stale undeclared-input object can pass every +check** (documented, not caught); provenance documented consistency-only). Disclosure required for every +cross-repo build (equal-id exempt), acknowledging **compiled artifacts and build diagnostics**. Recovery +production-only. ## 5. Rollout, docs, migration @@ -407,7 +495,26 @@ containers/copies/artifacts/caches; **length-framed `:` hash encodin relative paths, **normalized ustar headers** (zero `mtime`/`uid`/`gid`, fixed names), and **`abi` as recomputed ELF metadata** (`machine`/`interp`=`null`-if-static/direct-`DT_NEEDED`; transitive resolved, `dlopen` out of scope). Container sub-plan: two-tier pin policy (major action tags, image digests) and a -**canonical-repository** check in `check-image-pin.sh`. +**canonical-repository** check in `check-image-pin.sh`. → **v6.17 (contract revision)**: the reusable +workflow is **build-only** — no provider inputs, emits the artifact + every `ExpectedIdentity` field as +outputs, and the shared-copy build→deploy lifecycle moves to the **consumer's deploy job** (resolving the +"one container builds+deploys" contradiction); a **deploy-compile env/mount profile** (Rustup/Cargo + +fresh target/cargo, **no wrapper/`SCCACHE_DIR`/save**) because `fastly compute deploy` compiles the wasm; +**per-operation mount profiles** (the unauthenticated validator smoke gets **no** writable repo/target/ +cargo/sccache; only cached-compile mounts sccache); the shared copy's tracked files/modes/symlinks/ +gitlinks **re-verified before the token-bearing deploy** (derived state only in declared output paths); +the cross-repo topology predicates **split** (deployer ref, called-workflow SHA, app-checkout SHA — not +one HEAD==app-SHA equality) and **path deps permitted anywhere beneath `git-root`**; the undeclared-input +staleness restated as **may pass every downstream check** (provenance/ABI is not a staleness detector); +the cache described as holding **compiled results incl. replayed compiler stdout/stderr**, widening the +**disclosure** to build diagnostics; **byte-exact path hashing** (drop NFC — Linux/Git paths are bytes; +NFC/NFD are distinct), a defined `.` root, non-UTF-8 rejected; **`job.check_run_id` generation** + +`SCCACHE_IGNORE_SERVER_IO_ERROR=1` (per-object IO error → miss, not cold) + name reserved before save + +compiler errors never retried; **full recursive non-sparse checkout** with LFS/filter content +materialized; wall-time as **telemetry**. Container sub-plan: a **baked project-owned validator** +(JCS/dup-key/schema/ustar/ELF, smoke-tested at publish); **SHA-pinned actions in the write-privileged +publish workflow**; the single-manifest check **rejects a one-entry OCI index** (leaf manifest required); +the anonymous-pull check reads the **merged** digest. ## 9. Deferred to the implementation plan (mechanics only) From 4cda7520993a39e05c94fedfe428b9e8eaa43c00 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:10:48 -0700 Subject: [PATCH 09/10] docs: finalize build caching design and plan --- .../plans/2026-08-20-build-cache-container.md | 803 ++++++------ ...20-edgezero-deploy-build-caching-design.md | 1104 +++++++++-------- 2 files changed, 1034 insertions(+), 873 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-build-cache-container.md b/docs/superpowers/plans/2026-08-20-build-cache-container.md index ca2cbe2a..d56366bf 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -1,429 +1,516 @@ -# Build-Cache Container Implementation Plan (sub-plan 1 of 4) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Publish a pinned, single-manifest `linux/amd64` build container that bakes the exact Rust toolchain + build tools, so `platform-id` for the cached-build feature is an immutable digest. - -**Architecture:** A versioned in-repo Dockerfile builds an image FROM a digest-pinned base with the workspace's pinned Rust toolchain and the tools `build-app-cli` needs (`git`, `jq`, `tar`, `curl`, `ca-certificates`, a C toolchain for `build.rs`). A publish workflow builds it single-arch, pushes it to GHCR, and records its **manifest digest** in a committed `image.json`. A fail-closed `check-image-pin.sh` (wired into the existing pin gate's test harness) proves the recorded reference is pinned by a 64-hex `sha256` digest, never a mutable tag. - -**Tech Stack:** Docker (BuildKit), GitHub Actions (`docker/build-push-action`), GHCR, Bash, `jq`. - -**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` (v6.17, sccache pivot) — §2 (build-only single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI + baked provenance validator, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). - -## Global Constraints - -- **Rust toolchain baked = `1.95.0`** (verbatim from `.tool-versions`); a build that resolves a different toolchain must fail closed downstream, so this image is the single source of truth. -- **Full build+deploy runtime baked** (spec §3.6): `1.95.0` + `wasm32-wasip1` + a pinned **`sccache`** (the cache mechanism, spec §3.1) + the pinned **Fastly CLI `15.1.0`** (`.tool-versions`) + `git jq tar curl cc` — the container is the deploy runtime, not only the CLI-compile runtime. -- **Baked provenance validator** (spec §3.7): the image also bakes a single pinned, **project-owned validator binary** (a small Rust tool built from this repo at the same SHA — not a network-fetched helper) that performs JCS canonicalization, duplicate-key detection, JSON-Schema-2020-12 validation, strict `ustar` parsing, and ELF inspection (`jq`/`tar` cannot). Its capabilities are smoke-tested **before the digest is published** (a downstream sub-plan wires the validator itself; this plan reserves its place in the image and the publish smoke). -- **Runtime posture:** consumed **read-only root filesystem, non-root user**, explicit writable mounts only (spec §3.7). -- **Single-manifest `linux/amd64` only** — no multi-arch index (an index digest can select another architecture). -- **No Python in CI tooling** — Bash + `jq` only. -- **Pin policy (risk-tiered, at or above the repo's `check-action-pins.sh` gate):** **images** are pinned by `sha256` digest (the base image's digest in the `FROM`, and the published image's digest recorded in `image.json`). **Actions in this write-privileged publish workflow are pinned to a full 40-hex commit SHA** — GitHub identifies a full commit SHA as the only immutable action reference, and this workflow holds `contents: write` + `packages: write` + `pull-requests: write`, a supply-chain-sensitive privilege class where a re-tagged major version is an unacceptable risk. (Elsewhere in the repo, low-privilege read-only actions follow the standing major-tag convention the gate accepts; **whether to migrate those existing references to SHAs is a separate, repo-wide decision** — see the review note — not made by this container plan.) -- **No AI bylines** in commits or PR bodies. -- **Bash 3.2-compatible** scripts (macOS dev parity); scripts are `shellcheck -S warning` clean. - -## File Structure - -- `.github/docker/build-app-cli/Dockerfile` — the image definition (one responsibility: the build environment). -- `.github/docker/build-app-cli/image.json` — the published image's canonical reference + digest (the pin record). -- `.github/docker/build-app-cli/check-image-pin.sh` — fail-closed validator of `image.json`. -- `.github/actions/deploy-core/tests/check-image-pin.test.sh` — unit tests for the validator (colocated with the existing action test harness). -- `.github/workflows/publish-build-container.yml` — build + push + digest capture (runs on a `build-container-v*` tag). -- `.github/actions/deploy-core/tests/run.sh` — modified to invoke the new validator suite. - ---- - -### Task 1: Fail-closed `image.json` validator (pure TDD) +# Build-Cache Container Implementation Plan (plan 1 of 4) + +> **Execution:** Use `superpowers:subagent-driven-development` or +> `superpowers:executing-plans`. Follow the tasks in order and stop at every release checkpoint. + +**Goal:** Publish and pin a public, leaf `linux/amd64` runtime image containing the exact EdgeZero +build/deploy toolchain and the trusted provenance validator required by build caching. + +**Architecture:** Source revision `S` builds the image from the repository root. The publish workflow +captures and verifies immutable digest `D`, proves anonymous access, and opens an idempotent PR adding +`image.json`. That pin plus its permanent gate forms baseline `B`. The remaining feature plans land on +top, and their final passing action revision `P` contains the unchanged `{D, S, protocol}` record. +Consumers pin all EdgeZero actions and reusable workflows to full SHA `P`. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.18. + +**Tooling:** Rust, Docker BuildKit/buildx, GHCR, GitHub Actions, Bash 3.2, `jq`, `gh`, `actionlint`, +`shellcheck`, and `zizmor`. + +## 1. Non-negotiable contracts + +- Rust is the exact version in `.tool-versions` (`1.95.0` at plan time). +- Fastly CLI is the exact version/checksum in `.github/actions/deploy-fastly/versions.json` + (`15.1.0` at plan time). +- sccache is exactly `0.10.0`, fetched from its release artifact and checksum-verified. +- The base image uses a real `sha256` digest. No placeholder digest or checksum is committed. +- The final image is a leaf `linux/amd64` image manifest, not an OCI index. +- The final image contains an installed `wasm32-wasip1` target, not merely a rustc target-list entry. +- The project-owned validator, schema, and capability fixtures are baked and tested before push. +- Runtime is non-root uid/gid 1001 and works with a read-only root filesystem plus explicit tmpfs. +- Every non-local external action and reusable workflow ref is a full lowercase 40-hex commit SHA. + Docker image refs use immutable `sha256` digests. Local `./...` actions remain local refs. +- Bash scripts are Bash 3.2-compatible and `shellcheck -S warning` clean. CI helper scripts do not use + Python. No AI bylines appear in commits or PRs. +- Publication never records a digest before the image passes authenticated verification and a clean, + anonymous pull by digest. + +## 2. Dependency order + +Although this is plan 1 of the feature set, its image task cannot run first. Execute these gates: + +1. Land the trusted validator contract and capability fixtures (Task 0). +2. Land the repository-wide full-SHA policy migration (Task 1). +3. Implement image pinning, the Dockerfile, publisher, local-image CI, and pin-change CI (Tasks 2-4). +4. Merge all pre-publication code and tests; record that exact full commit as source revision `S`. +5. Run the already-landed publisher at `S`, verify digest `D`, and merge its required-check pin PR to + create baseline `B` (Tasks 4-5). +6. Execute the cached-build, provenance integration, launcher, and consumer plans on `B`; their final + passing commit becomes action revision `P`. + +Do not publish a provisional image without the validator. Do not use a placeholder `image.json` to +break the dependency cycle. + +## 3. Planned file surface + +Create: + +- `crates/edgezero-provenance-validator/Cargo.toml` +- `crates/edgezero-provenance-validator/src/{main,json_contract,archive,elf,extract}.rs` +- `crates/edgezero-provenance-validator/tests/cli.rs` +- `.github/docker/build-app-cli/provenance.schema.json` +- `.github/docker/build-app-cli/fixtures/provenance/**` +- `.github/docker/build-app-cli/fixtures/wasm-smoke.rs` +- `.github/docker/build-app-cli/Dockerfile` +- `.dockerignore` +- `.github/docker/build-app-cli/verify-toolchain.sh` +- `.github/docker/build-app-cli/verify-published-image.sh` +- `.github/docker/build-app-cli/update-image-pin-pr.sh` +- `.github/actions/deploy-core/tests/verify-toolchain.test.sh` +- `.github/actions/deploy-core/tests/verify-published-image.test.sh` +- `.github/actions/deploy-core/tests/update-image-pin-pr.test.sh` +- `.github/actions/deploy-core/tests/check-doc-action-pins.sh` +- `.github/workflows/publish-build-container.yml` + +Created by the release PR, not source revision `S`: + +- `.github/docker/build-app-cli/image.json` + +Modify: + +- workspace `Cargo.toml` / `Cargo.lock` +- `.github/docker/build-app-cli/check-image-pin.sh` +- `.github/actions/deploy-core/tests/check-image-pin.test.sh` +- `.github/actions/deploy-core/tests/check-action-pins.sh` +- `.github/actions/deploy-core/tests/run.sh` +- `.github/workflows/deploy-action.yml` +- every existing `.github` workflow/composite containing a non-local external `uses:` ref +- the four deploy/adoption documents containing consumer `uses:` examples + +## 4. Task 0: Land the validator capability contract + +This task is implemented as part of this plan because no separate prerequisite plan exists. It is a +hard dependency of Task 3 and must merge into source revision `S`. **Files:** -- Create: `.github/docker/build-app-cli/check-image-pin.sh` -- Test: `.github/actions/deploy-core/tests/check-image-pin.test.sh` - -**Interfaces:** -- Consumes: nothing (leaf). -- Produces: `check-image-pin.sh ` — exit `0` iff the JSON has string-typed `repository`/`tag`/`digest`, `repository` **equals the canonical `ghcr.io/stackpop/edgezero-build-app-cli`** (a foreign repository can never become `platform-id`), and `digest` matches `^sha256:[0-9a-f]{64}$`; prints `::error::` and exits `1` otherwise. Reused by the pin gate and the publish workflow. (`image.json` is a committed, PR-reviewed 3-field pin record; its rigor is this type+repo+digest gate. The JCS/JSON-Schema/duplicate-key **provenance** machinery is for *produced* artifacts — `app-cli-meta.json`, spec §3.7 — and belongs to sub-plan 3, not this committed record.) - -- [ ] **Step 1: Write the failing test** -```bash -#!/usr/bin/env bash -# .github/actions/deploy-core/tests/check-image-pin.test.sh -set -euo pipefail -DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -CHECK="$DIR/../../../docker/build-app-cli/check-image-pin.sh" -WORK=$(mktemp -d) -trap 'rm -rf "$WORK"' EXIT -pass=0 fail=0 -ok(){ printf ' ok %s\n' "$1"; pass=$((pass+1)); } -no(){ printf ' FAIL %s\n' "$1"; fail=$((fail+1)); } -run(){ bash "$CHECK" "$1" >/dev/null 2>&1; } - -R="ghcr.io/stackpop/edgezero-build-app-cli" -printf '{"repository":"%s","tag":"v1","digest":"sha256:%064d"}\n' "$R" 0 >"$WORK/ok.json" -run "$WORK/ok.json" && ok "a digest-pinned reference passes" || no "a digest-pinned reference passes" - -printf '{"repository":"%s","tag":"v1","digest":"v1"}\n' "$R" >"$WORK/tag.json" -run "$WORK/tag.json" && no "a non-digest (tag) reference is rejected" || ok "a non-digest (tag) reference is rejected" - -printf '{"repository":"%s","tag":"v1"}\n' "$R" >"$WORK/nodigest.json" -run "$WORK/nodigest.json" && no "a missing digest is rejected" || ok "a missing digest is rejected" - -printf '{"repository":"ghcr.io/attacker/edgezero-build-app-cli","tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/foreign.json" -run "$WORK/foreign.json" && no "a foreign repository is rejected" || ok "a foreign repository is rejected" - -printf 'not json\n' >"$WORK/bad.json" -run "$WORK/bad.json" && no "malformed JSON fails closed" || ok "malformed JSON fails closed" - -printf 'Passed: %d Failed: %d\n' "$pass" "$fail" -[ "$fail" -eq 0 ] +- Create `crates/edgezero-provenance-validator/Cargo.toml` and + `src/{main,json_contract,archive,elf,extract}.rs`. +- Put module unit tests beside their implementation under `src/`; create only the true process-level + integration test `crates/edgezero-provenance-validator/tests/cli.rs`. +- Create `.github/docker/build-app-cli/provenance.schema.json`. +- Create `.github/docker/build-app-cli/fixtures/provenance/{valid,invalid}/**`. +- Modify workspace `Cargo.toml` and `Cargo.lock`. + +### 4.1 JSON/schema tranche + +- [ ] Add the exact JSON Schema and valid/invalid metadata fixtures. Write colocated failing tests for + RFC 8785 canonical bytes, duplicate-key rejection before object construction, exact field/type/ + bounds checks, unknown fields, caller/platform identity mismatch, and schema-version mismatch. +- [ ] Run `cargo test -p edgezero-provenance-validator json_contract::tests`; expected: non-zero with + the new assertions failing for unimplemented behavior. +- [ ] Implement only `json_contract.rs`; rerun the same command, then the full crate test; expected: + both pass. Commit the green JSON/schema tranche. + +### 4.2 Archive/extraction tranche + +- [ ] Add a byte-for-byte golden ustar archive plus malformed PAX/GNU, duplicate, extra, traversal, + link, special-file, bad-header, bad-order, bad-size, and trailing-data fixtures. +- [ ] Write colocated archive/extraction tests, then run + `cargo test -p edgezero-provenance-validator archive::tests`; expected: non-zero for unimplemented + strict parsing/extraction. +- [ ] Implement `archive.rs` and `extract.rs` without invoking system `tar`. Require exact normalized + headers and exactly one confined regular output file. Rerun focused and full crate tests; expected: + pass. Commit the green archive/extraction tranche. + +### 4.3 ELF/loadability tranche + +- [ ] Add controlled valid/wrong-architecture/unresolved-interpreter/unresolved-library ELF + fixtures. Write failing tests for machine, interpreter/null, sorted direct `DT_NEEDED`, digest, size, + and immutable-image dependency resolution. +- [ ] Run `cargo test -p edgezero-provenance-validator elf::tests`; expected: non-zero for + unimplemented inspection/loadability behavior. +- [ ] Implement `elf.rs`; rerun focused and full crate tests; expected: pass. Commit the green ELF + tranche. + +### 4.4 CLI/capability tranche + +- [ ] Write failing `tests/cli.rs` process tests that combine the three modules and verify clean failure + leaves the output directory empty. Run `cargo test -p edgezero-provenance-validator --test cli`; + expected: non-zero until the CLI is wired. Implement this stable credential-free interface: + +```text +edgezero-provenance-validator validate \ + --archive /work/input/artifact.tar \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --output /work/validated/app-cli + +edgezero-provenance-validator self-test \ + --fixtures /usr/local/share/edgezero/provenance-fixtures ``` -- [ ] **Step 2: Run it to verify it fails** - -Run: `bash .github/actions/deploy-core/tests/check-image-pin.test.sh` -Expected: FAIL (the `check-image-pin.sh` file does not exist yet). - -- [ ] **Step 3: Write the minimal implementation** +- [ ] Make `validate` create exactly one regular output file and fail if the output parent is not + empty, canonical, writable, and confined. The validator never executes the extracted binary. +- [ ] Implement `self-test` as a fixed manifest of expected valid and invalid fixture outcomes plus + fixture SHA-256 values; a missing, extra, or changed fixture fails. +- [ ] Use synchronous Rust; do not add Tokio, and do not change dependencies of core/adapter crates. +- [ ] Run `cargo test -p edgezero-provenance-validator --test cli`, then the full focused crate suite; + expected: pass. Commit the green CLI/capability tranche. +- [ ] Run the focused crate tests, then the repository-required Rust checks. ```bash -#!/usr/bin/env bash -# .github/docker/build-app-cli/check-image-pin.sh -# Fail-closed: the build container reference must be pinned by a sha256 digest, -# never a mutable tag (spec §3.7/§5). Requires mikefarah yq/jq-free: uses jq. -set -euo pipefail - -EXPECTED_REPO="ghcr.io/stackpop/edgezero-build-app-cli" -file="${1:?usage: check-image-pin.sh }" -if ! command -v jq >/dev/null 2>&1; then - echo "::error::check-image-pin.sh requires jq" >&2 - exit 2 -fi -if ! json=$(jq -e . "$file" 2>/dev/null); then - echo "::error::$file is not valid JSON — refusing to pass an unreadable image pin" >&2 - exit 1 -fi -# String TYPES (jq -r would coerce a numeric value to a string). -if [[ "$(jq -r '.repository|type' <<<"$json")" != string || - "$(jq -r '.tag|type' <<<"$json")" != string || - "$(jq -r '.digest|type' <<<"$json")" != string ]]; then - echo "::error::$file 'repository', 'tag', 'digest' must be JSON strings" >&2 - exit 1 -fi -repo=$(jq -r '.repository' <<<"$json"); tag=$(jq -r '.tag' <<<"$json"); digest=$(jq -r '.digest' <<<"$json") -if [[ -z "$repo" || -z "$tag" ]]; then - echo "::error::$file must set non-empty 'repository' and 'tag'" >&2 - exit 1 -fi -# The repository must be the canonical EdgeZero build container, not merely non-empty. -if [[ "$repo" != "$EXPECTED_REPO" ]]; then - echo "::error::$file 'repository' must be '$EXPECTED_REPO', not '$repo'" >&2 - exit 1 -fi -if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "::error::$file 'digest' must be a sha256 manifest digest (sha256:<64-hex>), not a tag: '$digest'" >&2 - exit 1 -fi -echo "build container reference is pinned: $repo@$digest" +cargo test -p edgezero-provenance-validator +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +npm --prefix docs ci +npm --prefix docs run format +npm --prefix docs run lint +npm --prefix docs run build ``` -- [ ] **Step 4: Run the test to verify it passes** +**Gate:** all capability tests and fixture hashes pass from a clean checkout. Task 3 must copy this +exact built binary, schema, and fixtures into the image. -Run: `chmod +x .github/docker/build-app-cli/check-image-pin.sh && bash .github/actions/deploy-core/tests/check-image-pin.test.sh` -Expected: `Passed: N Failed: 0` (the committed test carries the full case set — string-type, foreign-repo, tag, short/missing digest, missing repository, malformed JSON). +## 5. Task 1: Enforce full-SHA external references repository-wide -- [ ] **Step 5: Shellcheck** +The current pin gate accepts version tags. That contradicts v6.18 and must be migrated before adding +the write-privileged publisher. -Run: `shellcheck -S warning .github/docker/build-app-cli/check-image-pin.sh` -Expected: no output (clean). +**Files:** -- [ ] **Step 6: Commit** +- Modify `.github/actions/deploy-core/tests/check-action-pins.sh` and its tests in `run.sh`. +- Create `.github/actions/deploy-core/tests/check-doc-action-pins.sh`. +- Modify external refs in `.github/workflows/{codeql,deploy-action,deploy-docs,fastly-installer-check,format,test}.yml`. +- Modify external refs in `.github/actions/{build-app-cli,config-push-fastly,deploy-fastly,healthcheck-fastly,rollback-fastly}/action.yml`. +- Modify examples in `docs/specs/edgezero-deploy-github-action.md`, + `docs/specs/edgezero-deploy-action-implementation-plan.md`, + `docs/specs/edgezero-deploy-adoption-guide.md`, and `docs/guide/deploy-github-actions.md`. + +- [ ] Write failing pin-gate tests proving `@v1`, `@v1.2.3`, branches, abbreviated SHAs, malformed + SHAs, and empty refs fail; full lowercase 40-hex SHAs pass; local actions and digest-pinned Docker + actions remain valid. Generate invalid YAML fixtures under the test's temporary directory; do not + commit them into a surface scanned by the production gate. +- [ ] Resolve each existing version to a reviewed upstream commit SHA. Preserve the human-readable + release in an adjacent comment, for example `# v6.0.1`. +- [ ] Change the structural YAML scanner to require full 40-hex SHAs for every non-local external + action and reusable workflow. Its default scan is exactly workflow `*.yml`/`*.yaml` files directly + under `.github/workflows`, plus every repository-wide `action.yml`/`action.yaml`, pruning `.git`, + `target`, and `node_modules`. Shell source and arbitrary YAML test data are not inputs. Do not add a + low-privilege exception. +- [ ] Require Docker action refs to match an immutable lowercase + `docker://@sha256:<64-lowercase-hex>` form; tags, uppercase hex, short digests, and other + algorithms fail unless a separately reviewed digest algorithm is added to the policy. +- [ ] Update documentation examples to use a named `` placeholder where the + consumer must substitute release `P`; examples for third-party actions use real reviewed SHAs. +- [ ] Add `check-doc-action-pins.sh` to extract `uses:` lines from fenced YAML in the four named docs. + It allows the exact EdgeZero placeholder only in documentation, requires full SHAs for concrete + third-party refs, and rejects version/branch refs. Add positive/negative cases to `run.sh`. +- [ ] Scan that exact default surface, including reusable-workflow job-level `uses`, and require at + least one parsed external ref so a broken parser cannot pass vacuously. +- [ ] Run the pin suite, actionlint, and zizmor. ```bash -git add .github/docker/build-app-cli/check-image-pin.sh .github/actions/deploy-core/tests/check-image-pin.test.sh -git commit -m "build-cache container: fail-closed image.json digest-pin validator" +bash .github/actions/deploy-core/tests/run.sh +.github/actions/deploy-core/tests/check-action-pins.sh +.github/actions/deploy-core/tests/check-doc-action-pins.sh +actionlint +zizmor --offline .github/workflows .github/actions ``` ---- +**Gate:** both structural scanners pass their exact surfaces and report non-zero parsed-reference +counts; no broad `rg` gate scans intentional invalid test strings. -### Task 2: The pinned Dockerfile +## 6. Task 2: Implement the exact `image.json` validator + +`image.json` has five fields and is created only after publication succeeds. **Files:** -- Create: `.github/docker/build-app-cli/Dockerfile` -- Create: `.github/docker/build-app-cli/image.json` (placeholder digest until Task 3 publishes) - -**Interfaces:** -- Consumes: the Global Constraints (Rust `1.95.0`, single-arch amd64). -- Produces: an image whose `rustc --version` is `1.95.0` and which has `git jq tar curl cc` on `PATH`; consumed by Task 3's publish and by sub-plans 2–4 as `platform-id`. - -- [ ] **Step 1: Write the Dockerfile** - -```dockerfile -# .github/docker/build-app-cli/Dockerfile -# Single-manifest linux/amd64 FULL build+deploy runtime (spec §3.7): the pinned -# Rust toolchain, wasm32-wasip1, the pinned Fastly CLI, and build tools. This -# image IS the toolchain/ABI identity; it runs read-only/non-root at runtime. -# Base pinned by digest; replace the digest below with a current -# rust:1.95.0-bookworm linux/amd64 manifest digest (see README in this dir). -FROM rust:1.95.0-bookworm@sha256:0000000000000000000000000000000000000000000000000000000000000000 - -# Pinned downloads (spec §3.6): fastly 15.1.0 (versions.json) and a pinned sccache. -# Each ARG carries the exact release URL + sha256 (fill the sccache values from the -# chosen sccache release; the fastly values are versions.json's). -ARG FASTLY_URL="https://github.com/fastly/cli/releases/download/v15.1.0/fastly_v15.1.0_linux-amd64.tar.gz" -ARG FASTLY_SHA256="3ba3d8a739b7a88d0a612825a9755d735efb87a9b02ea67e53a11b96d178d500" -ARG SCCACHE_VERSION="0.10.0" -ARG SCCACHE_URL="https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz" -ARG SCCACHE_SHA256="REPLACE_WITH_RELEASE_SHA256" - -RUN set -eux; \ - apt-get update; \ - apt-get install -y --no-install-recommends \ - git jq tar curl ca-certificates build-essential; \ - rm -rf /var/lib/apt/lists/*; \ - rustup target add wasm32-wasip1; \ - curl -fsSL -o /tmp/fastly.tar.gz "$FASTLY_URL"; \ - echo "${FASTLY_SHA256} /tmp/fastly.tar.gz" | sha256sum -c -; \ - tar -xzf /tmp/fastly.tar.gz -C /usr/local/bin fastly; \ - curl -fsSL -o /tmp/sccache.tar.gz "$SCCACHE_URL"; \ - echo "${SCCACHE_SHA256} /tmp/sccache.tar.gz" | sha256sum -c -; \ - tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin "sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl/sccache"; \ - chmod +x /usr/local/bin/sccache; \ - rm /tmp/fastly.tar.gz /tmp/sccache.tar.gz; \ - fastly version; sccache --version - -# No ambient rustflags/wrapper env (spec §3.8 also scrubs at runtime); non-root. -ENV CARGO_TERM_COLOR=never RUSTFLAGS="" CARGO_ENCODED_RUSTFLAGS="" -RUN useradd -m -u 1001 build -USER build -WORKDIR /home/build -``` -> The Fastly CLI download is checksum-verified against `versions.json`'s pinned -> `sha256` (above). The publish workflow (Task 3) builds on a hosted runner and -> **makes the GHCR package public** (GHCR packages are private on first publish); the -> image is consumed **read-only/non-root** with explicit writable mounts (spec §3.7). +- Modify `.github/docker/build-app-cli/check-image-pin.sh`. +- Modify `.github/actions/deploy-core/tests/check-image-pin.test.sh`. -- [ ] **Step 2: Write the placeholder pin record** +- [ ] Write failing tests for the valid five-field record and rejection of malformed JSON, duplicate + or extra/missing fields, non-string string fields, foreign/empty repository, mutable/zero/uppercase + digest, malformed/zero/uppercase source revision, non-integer protocol, protocol other than `1`, + an empty/malformed release tag, and tag use as the runtime reference. +- [ ] Implement `check-image-pin.sh ` using Bash and `jq`. Detect duplicate top-level keys from + `jq --stream` events before normal object parsing; ordinary `jq` object parsing alone loses duplicate + keys. It accepts exactly: ```json { "repository": "ghcr.io/stackpop/edgezero-build-app-cli", "tag": "build-container-v1", - "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + "digest": "sha256:<64-lowercase-hex>", + "image-source-revision": "<40-lowercase-hex>", + "provenance-protocol": 1 } ``` -(The placeholder digest is intentional; Task 3's publish workflow overwrites it with the real one, and `check-image-pin.sh` still passes on shape. The pin-gate wiring in Task 4 additionally forbids the all-zero placeholder in a release.) + `tag` must match `^build-container-v[1-9][0-9]*$`; it remains informational. -- [ ] **Step 3: Verify the image builds and bakes the toolchain (local integration check)** +- [ ] Output only the canonical runtime ref, source revision, and protocol through explicit + subcommands or shell-safe output fields. Never use `tag` for a pull. +- [ ] Run unit tests and shellcheck. Do not create a placeholder `image.json`. -Run (requires Docker + a real base digest substituted into the `FROM`): ```bash -docker build --platform linux/amd64 -t edgezero-build-app-cli:local .github/docker/build-app-cli -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local rustc --version -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local rustc --print target-list | grep -x wasm32-wasip1 -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local fastly version -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local sccache --version -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local sh -c 'command -v git jq tar curl cc' -# read-only/non-root smoke (spec §3.7): a read-only rootfs run still works with a tmpfs. -docker run --rm --read-only --tmpfs /tmp --user 1001 --platform linux/amd64 edgezero-build-app-cli:local rustc --version +bash .github/actions/deploy-core/tests/check-image-pin.test.sh +shellcheck -S warning .github/docker/build-app-cli/check-image-pin.sh ``` -Expected: `rustc 1.95.0 (...)`, `wasm32-wasip1` present, `fastly` reports 15.1.0, all five tools resolve, and the read-only/non-root run succeeds. - -- [ ] **Step 4: Commit** -```bash -git add .github/docker/build-app-cli/Dockerfile .github/docker/build-app-cli/image.json -git commit -m "build-cache container: pinned single-arch Dockerfile + image pin record" -``` - ---- - -### Task 3: Publish workflow (build, push, record digest) +## 7. Task 3: Build the pinned image from repository root **Files:** -- Create: `.github/workflows/publish-build-container.yml` - -**Interfaces:** -- Consumes: `.github/docker/build-app-cli/Dockerfile`, `check-image-pin.sh`. -- Produces: a GHCR image `ghcr.io/stackpop/edgezero-build-app-cli` whose **manifest digest** is written back to `image.json` on the release tag; consumed by sub-plans 2–4. - -- [ ] **Step 1: Write the workflow** - -```yaml -# .github/workflows/publish-build-container.yml -name: Publish build container -on: - push: - tags: ["build-container-v*"] -permissions: - contents: write # push the pin branch - packages: write # push the image to GHCR - pull-requests: write # open the image.json PR -jobs: - publish: - runs-on: ubuntu-24.04 - steps: - # SHA-PINNED (not @v7): this job is write-privileged (contents/packages/PRs), - # so every action is pinned to a full 40-hex commit SHA — the only immutable - # action reference. Replace with the pinned actions/checkout - # release SHA (recorded in a comment as its version, e.g. # v4.3.0). - - uses: actions/checkout@ # vX.Y.Z - # Trusted publish job (no app code runs here); keep the token so the - # pin-record PR branch can be pushed. - with: - persist-credentials: true - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Build and push (single-arch amd64) - id: push - run: | - set -euo pipefail - REPO="ghcr.io/stackpop/edgezero-build-app-cli" - TAG="${GITHUB_REF_NAME}" - docker buildx build --platform linux/amd64 \ - --provenance=false --sbom=false \ - --tag "$REPO:$TAG" --push .github/docker/build-app-cli - DIGEST=$(docker buildx imagetools inspect "$REPO:$TAG" --format '{{json .Manifest.Digest}}' | tr -d '"') - echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" - - name: Verify the pushed image BY DIGEST before recording it - env: - REPO: ghcr.io/stackpop/edgezero-build-app-cli - DIGEST: ${{ steps.push.outputs.digest }} - run: | - set -euo pipefail - REF="$REPO@$DIGEST" - # Require a LEAF image manifest, not an index — reject ANY manifest list, - # including a one-entry OCI index (a count `<= 1` would wrongly accept it, - # and an index digest can be repointed to select a different image). The - # digest must resolve to an image manifest (has .config + .layers, no - # .manifests), whose platform is linux/amd64. - mt=$(docker buildx imagetools inspect "$REF" --raw | jq -r '.mediaType // ""') - case "$mt" in - *"image.index"*|*"manifest.list"*) - echo "::error::$REF is an index/manifest-list ($mt), not a leaf image manifest"; exit 1 ;; - esac - docker buildx imagetools inspect "$REF" --raw \ - | jq -e '(.config != null) and (.layers != null) and (.manifests == null)' >/dev/null \ - || { echo "::error::$REF is not a leaf image manifest (config+layers, no manifests)"; exit 1; } - plat=$(docker buildx imagetools inspect "$REF" --format '{{json .Image.Platform}}') - echo "$plat" | jq -e '.os=="linux" and .architecture=="amd64"' >/dev/null \ - || { echo "::error::$REF is not linux/amd64 ($plat)"; exit 1; } - # Runtime smoke, pulled with the AUTHENTICATED session (a GHCR package is - # PRIVATE on first publish, so an anonymous pull here would deadlock the very - # first release). The anonymous-pull check is the operator's post-make-public - # step below, once the package visibility is public. - docker run --rm --platform linux/amd64 "$REF" rustc --version | grep -F '1.95.0' - docker run --rm --platform linux/amd64 "$REF" sh -c 'rustc --print target-list | grep -qx wasm32-wasip1' - docker run --rm --platform linux/amd64 "$REF" fastly version - docker run --rm --platform linux/amd64 "$REF" sccache --version - docker run --rm --read-only --tmpfs /tmp --user 1001 --platform linux/amd64 "$REF" rustc --version - - name: Open a reviewable image.json PR (not an in-place commit) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DIGEST: ${{ steps.push.outputs.digest }} - run: | - set -euo pipefail - f=.github/docker/build-app-cli/image.json - jq --arg t "${GITHUB_REF_NAME}" --arg d "${DIGEST}" '.tag=$t | .digest=$d' "$f" > "$f.tmp" && mv "$f.tmp" "$f" - bash .github/docker/build-app-cli/check-image-pin.sh "$f" - br="build-container-pin-${GITHUB_REF_NAME}" - git switch -c "$br" - git add "$f" - git -c user.name=edgezero-ci -c user.email=ci@stackpop \ - commit -m "build container: pin ${GITHUB_REF_NAME} = ${DIGEST}" - git push -u origin "$br" - gh pr create --fill --base main --head "$br" \ - --title "Pin build container ${GITHUB_REF_NAME}" \ - --body "Digest verified by the publish workflow (single-manifest + authenticated runtime smoke). Anonymous-pull verification is the operator's post-make-public step." -``` -The publish thus **pushes → inspects by digest → verifies single-manifest + the runtime smoke (authenticated) → then opens a reviewable `image.json` PR** — the pin the rest of the feature keys on is never recorded until it has been proven against the actual pushed digest. The **anonymous** pull is verified separately, after the operator makes the package public (below), avoiding a first-publish deadlock. +- Create `.github/docker/build-app-cli/Dockerfile`. +- Create `.dockerignore`, `.github/docker/build-app-cli/verify-toolchain.sh`, and + `.github/docker/build-app-cli/fixtures/wasm-smoke.rs`. +- Extend validator/image tests under `.github/actions/deploy-core/tests/`. -- [ ] **Step 2: Actionlint the workflow** +- [ ] Before editing, resolve the amd64 digest for the exact Rust base image and the upstream sccache + v0.10.0 release checksum. Record provenance in comments. Never commit `000...` or `REPLACE_ME`. +- [ ] Use a multi-stage Dockerfile. The builder stage copies the repository and runs: -Run: `actionlint .github/workflows/publish-build-container.yml` (after substituting the real -`actions/checkout` release SHA for the `` placeholder, as with the -Dockerfile's base-image digest). -Expected: no output. +```bash +cargo build --locked --release -p edgezero-provenance-validator +``` -- [ ] **Step 3: Commit** +- [ ] Copy only the validator binary, schema, and capability fixtures from the builder into the final + runtime. BuildKit context is repository root; the Dockerfile remains under + `.github/docker/build-app-cli/`. +- [ ] Add a root `.dockerignore` excluding `.git`, `.claude`, every `target/`, `node_modules/`, local + editor/temp/env files, and other non-source detritus while retaining the workspace, `.github` + schema/fixtures, lockfile, and Dockerfile. CI also requires a clean checkout, so `.dockerignore` is + defense in depth rather than permission to build untracked source. +- [ ] Install the exact Rust toolchain, `wasm32-wasip1`, checksum-verified Fastly CLI and sccache, + `git`, `jq`, `tar`, `curl`, CA certificates, and a C toolchain. Remove package/download caches. +- [ ] Accept required build args `IMAGE_SOURCE_REVISION` and `PROVENANCE_PROTOCOL`. Fail the build + unless they are a lowercase full SHA and exactly `1`. +- [ ] Add OCI labels `org.opencontainers.image.revision=$IMAGE_SOURCE_REVISION` and + `org.edgezero.provenance-protocol=$PROVENANCE_PROTOCOL`. +- [ ] Create uid/gid 1001, set it as final `USER`, and avoid writable data under the image root. +- [ ] Build locally from root: ```bash -git add .github/workflows/publish-build-container.yml -git commit -m "build-cache container: GHCR publish workflow recording the manifest digest" +docker build --platform linux/amd64 \ + --build-arg IMAGE_SOURCE_REVISION="$(git rev-parse HEAD)" \ + --build-arg PROVENANCE_PROTOCOL=1 \ + -f .github/docker/build-app-cli/Dockerfile \ + -t edgezero-build-app-cli:local . ``` -- [ ] **Step 4: Publish (operator step, out of band)** - -Tag `build-container-v1` and push it. The workflow pushes the image, **verifies it by digest** (leaf image manifest, linux/amd64 + an **authenticated** runtime smoke — the package is private on first publish), and **opens a PR** updating `image.json` to the real `sha256` digest. Ordering matters: **review and merge the PR FIRST** — only then does the committed `image.json` carry the real digest — **then make the GHCR package public and verify the anonymous pull reading the merged `image.json`** (verifying before merge would read the still-placeholder digest). The digest is the pin the rest of the feature keys on, and it is only recorded after passing verification against the actual pushed image. +- [ ] Parse each tool's documented version line and compare the normalized semantic version for exact + equality; substring matching is forbidden. Assert target installation with + `rustup target list --installed`, then compile the committed `wasm-smoke.rs` as a library for + `wasm32-wasip1` into writable tmpfs and assert the output starts with wasm magic `00 61 73 6d`. +- [ ] Put those assertions in `verify-toolchain.sh` and unit-test its parsers with exact, prerelease, + extra-text, missing-line, and malformed output fixtures before copying it into the image. +- [ ] Run the baked validator `self-test`; then run one valid and each malformed fixture through the + baked `validate` command. +- [ ] Verify image config is linux/amd64, `User` is 1001, and OCI labels equal the build args. +- [ ] Verify a read-only/non-root smoke with `--network=none`, `--cap-drop=ALL`, + `--security-opt=no-new-privileges`, bounded memory/pids, and only `/tmp` as tmpfs. -**One-time GHCR visibility + retention (operator):** GHCR packages are **private on first publish** and there is no clean REST endpoint to flip a container package public, so set the package `edgezero-build-app-cli` to **public** in its GHCR package settings (or set the org's default package visibility) so consumers can **anonymously** pull by digest (spec §3.7), and enable a retention policy that never prunes a digest referenced by a committed `image.json`. Verify anonymous access: ```bash -docker logout ghcr.io -docker pull "ghcr.io/stackpop/edgezero-build-app-cli@$(jq -r .digest .github/docker/build-app-cli/image.json)" +docker run --rm --platform linux/amd64 --read-only --network=none --cap-drop=ALL \ + --security-opt=no-new-privileges --memory=512m --pids-limit=128 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec --user 1001:1001 \ + edgezero-build-app-cli:local verify-toolchain.sh \ + --rust 1.95.0 --fastly 15.1.0 --sccache 0.10.0 \ + --target wasm32-wasip1 \ + --fixture /usr/local/share/edgezero/wasm-smoke.rs +docker run --rm --read-only --network=none --cap-drop=ALL \ + --security-opt=no-new-privileges --memory=512m --pids-limit=128 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec --user 1001:1001 \ + edgezero-build-app-cli:local \ + edgezero-provenance-validator self-test \ + --fixtures /usr/local/share/edgezero/provenance-fixtures ``` -Expected: the pull succeeds without credentials. ---- +**Gate:** no image is pushed until every command above passes with the exact source SHA and protocol. -### Task 4: Wire the digest pin into the pin gate +## 8. Task 4: Publish, verify, and open an idempotent pin PR **Files:** -- Modify: `.github/actions/deploy-core/tests/run.sh` (add the validator suite) -- Modify: `.github/actions/deploy-core/tests/check-image-pin.test.sh` (add a reject-placeholder case) - -**Interfaces:** -- Consumes: `check-image-pin.sh`, `image.json`. -- Produces: a CI gate that fails if the build container is not digest-pinned (or is the all-zero placeholder), alongside the existing action-pin gate. - -- [ ] **Step 1: Add the failing placeholder-rejection test** -Append to `check-image-pin.test.sh` (before the summary), a case asserting the real repo `image.json` is not the all-zero placeholder: +- Create `.github/docker/build-app-cli/verify-published-image.sh`. +- Create `.github/docker/build-app-cli/update-image-pin-pr.sh`. +- Create `.github/actions/deploy-core/tests/verify-published-image.test.sh`. +- Create `.github/actions/deploy-core/tests/update-image-pin-pr.test.sh`. +- Create `.github/workflows/publish-build-container.yml`. +- Modify `.github/actions/deploy-core/tests/run.sh` and `.github/workflows/deploy-action.yml`. + +### 8.1 Testable verification helper + +- [ ] Write fixture-driven failing tests for leaf manifest media types, required config/layers, + rejection of one-entry and multi-entry indexes, `.Image` os/architecture, both image labels, exact + tool versions, installed target, validator self-test, and malformed BuildKit metadata. +- [ ] Implement a helper that takes `repository`, `digest`, `source SHA`, and protocol. It verifies the + immutable digest only and never rereads a mutable tag to discover identity. +- [ ] Use `docker buildx imagetools inspect "$REF" --raw` to require a leaf manifest. Use + `docker buildx imagetools inspect "$REF" --format '{{json .Image}}'` and inspect `.os` and + `.architecture` directly; do not use nonexistent `.Image.Platform`. +- [ ] Inspect image config labels and run the same exact-version, installed-target/minimal-compile, + validator-capability, and read-only/non-root tests as Task 3. + +### 8.2 Pre-`S` publisher and required CI + +- [ ] Implement the publisher before designating `S`. Trigger only protected `build-container-v*` + tags and configure the protected `build-container-release` environment and repository tag ruleset. +- [ ] Serialize the entire workflow under repository-global concurrency group + `edgezero-build-container-publication` with `cancel-in-progress: false`; different tags must not + race the one pin record. +- [ ] Use job permissions `contents: read` and `packages: write`. Mint a short-lived token from a + dedicated GitHub App, stored in the protected environment and scoped only to branch contents and + pull requests, for the pin branch/PR. `GITHUB_TOKEN` is forbidden for this operation because its + push does not trigger push workflows and its automation-created PR checks require manual approval; + it cannot guarantee the automatic required-check path. Pin the token-minting and checkout actions + to reviewed full SHAs. +- [ ] Mint the GitHub App token only after build, digest verification, and anonymous verification have + completed, so neither its private key nor installation token exists while repository-root context is + assembled or app-owned Rust code is built. +- [ ] Checkout with `persist-credentials: false` and full history. Resolve + `S=$(git rev-parse "${GITHUB_SHA}^{commit}")`, validate it as 40 lowercase hex, fetch the protected + default branch, and require `S` to be its ancestor. +- [ ] Immediately before BuildKit receives root context, require `HEAD == S`, no tracked/index + changes, no untracked files, and clean initialized submodules. Re-run the same assertions after + extracting metadata. No credential may exist in Git config or a file under the context. +- [ ] Build with repository-root context, explicit `-f`, `--platform linux/amd64`, exact source/protocol + args, `--provenance=false`, `--sbom=false`, and `--metadata-file`: ```bash -REAL="$DIR/../../../docker/build-app-cli/image.json" -zero="sha256:$(printf '%064d' 0)" -if [ "$(jq -r '.digest' "$REAL")" = "$zero" ]; then - no "committed image.json is still the all-zero placeholder" -else - ok "committed image.json carries a real digest" -fi +docker buildx build --platform linux/amd64 \ + --build-arg "IMAGE_SOURCE_REVISION=$S" \ + --build-arg PROVENANCE_PROTOCOL=1 \ + --provenance=false --sbom=false \ + --metadata-file "$RUNNER_TEMP/build-metadata.json" \ + -f .github/docker/build-app-cli/Dockerfile \ + --tag "$REPOSITORY:$GITHUB_REF_NAME" --push . +D=$(jq -er '."containerimage.digest"' "$RUNNER_TEMP/build-metadata.json") ``` -- [ ] **Step 2: Run it to verify it fails** +- [ ] Validate `D` immediately and pass it to `verify-published-image.sh`. Never derive `D` by + inspecting the mutable tag. +- [ ] After authenticated verification, remove the local image reference, use a fresh empty + `DOCKER_CONFIG`, and pull/run `REPOSITORY@D` without credentials. The anonymous check must make a + registry request and fail if the package is private. +- [ ] On first publication, a private GHCR package intentionally stops before pin PR creation. An + operator makes the package public and reruns the same workflow/tag. Do not merge a pin first. +- [ ] Generate the exact five-field `image.json`, run `check-image-pin.sh`, and use a branch derived + from both `S` and `D`. +- [ ] Implement and fixture-test the branch/PR state machine. Fetch an existing remote branch and + record its exact OID; update it only with + `--force-with-lease=refs/heads/:`. Create an absent branch without force. + Update one open matching PR. Reopen a closed-unmerged matching PR or fail for operator review. + Treat an already-merged exact `{S,D}` record as idempotent success. If the same `S` produces a new + `D`, close/supersede any older open pin PR before opening the new digest PR. Multiple or ambiguous + states fail closed. +- [ ] Put this state machine in `update-image-pin-pr.sh`. Its tests inject fake `git` and `gh` through + `PATH`, record every argv/stdin mutation, and cover absent branch, matching remote OID, lease race, + one open PR, closed-unmerged PR, already-merged exact record, same-`S`/new-`D` supersession, multiple + matches, API failure, and rerun idempotency. Run the focused test red before implementation and green + afterward, then run shellcheck. +- [ ] Include `S`, `D`, protocol, verified platform, and anonymous-pull result in the PR body. Never + include an AI byline. +- [ ] Before `S`, extend `.github/workflows/deploy-action.yml` with a required local-image job that + builds from root and runs all Task 3 smokes. Its PR/push trigger set is exactly `.tool-versions`, + root `Cargo.toml`/`Cargo.lock`, `crates/edgezero-provenance-validator/**`, + `.github/actions/deploy-fastly/versions.json`, `.dockerignore`, + `.github/docker/build-app-cli/**`, + `.github/actions/deploy-core/tests/check-image-pin.test.sh`, + `.github/actions/deploy-core/tests/verify-toolchain.test.sh`, + `.github/actions/deploy-core/tests/verify-published-image.test.sh`, + `.github/actions/deploy-core/tests/update-image-pin-pr.test.sh`, + `.github/actions/deploy-core/tests/run.sh`, + `.github/workflows/publish-build-container.yml`, and `.github/workflows/deploy-action.yml`. +- [ ] Before `S`, add a required pin-change job for every add/change/delete of `image.json`. It must + require the file to exist, run `check-image-pin.sh`, use a clean anonymous Docker config, and run the + complete `verify-published-image.sh` against the committed digest. This job is the pre-merge gate + for every future pin, not a one-time release checklist. +- [ ] Wire all helper unit suites into `run.sh`; assert the explicit trigger set above in contract + tests so existing-path omissions regress visibly; make actionlint, shellcheck, and + `zizmor --offline` cover the publisher and helpers. + +### 8.3 Land `S`, then execute publication + +- [ ] Run all Task 0-4 local and CI tests, merge validator, Dockerfile, `.dockerignore`, helpers, + publisher, and required CI jobs, then record the resulting full default-branch commit as `S`. +- [ ] Create the protected release tag at exactly `S`. The publisher must verify the tag resolves to + that commit and perform the build/verification logic already reviewed at `S`. +- [ ] On first publication, a private GHCR package intentionally stops before pin PR creation. An + operator makes the package public and reruns the same workflow/tag. Do not merge a pin first. +- [ ] Require the GitHub-App-created pin PR's local shape and remote anonymous image verification jobs + to pass before review or merge. + +**Gate:** the pin PR cannot exist unless the exact digest passed all checks including anonymous pull. + +## 9. Task 5: Merge and verify pin baseline `B` -Run: `bash .github/actions/deploy-core/tests/check-image-pin.test.sh` -Expected: FAIL on "committed image.json carries a real digest" until Task 3's publish lands a real digest. +**Files:** -- [ ] **Step 3: Invoke the suite from the contract runner** +- Add `.github/docker/build-app-cli/image.json` through the publisher PR. +- No post-merge gate wiring: all required checks were part of source `S`. -Add to `.github/actions/deploy-core/tests/run.sh` (near the other suite invocations): +- [ ] Review the generated record and confirm its source revision is the published `S`, digest is the + verified `D`, and protocol is `1`. +- [ ] Confirm the GitHub App push triggered all required pin-change workflows and that every check + passed. Merge the pin-only PR and record the merge/full commit SHA as baseline `B`, not final action + revision `P`. +- [ ] Confirm a deletion or syntactically valid but unverifiable replacement of `image.json` fails the + required pin-change job in a test PR. +- [ ] Run the full repository verification suite from a clean checkout at baseline `B`: ```bash -bash "$(dirname -- "${BASH_SOURCE[0]}")/check-image-pin.test.sh" +bash .github/actions/deploy-core/tests/run.sh +.github/actions/deploy-core/tests/check-action-pins.sh +.github/actions/deploy-core/tests/check-doc-action-pins.sh +actionlint +zizmor --offline .github/workflows .github/actions +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +npm --prefix docs ci +npm --prefix docs run format +npm --prefix docs run lint +npm --prefix docs run build ``` -- [ ] **Step 4: Run the full suite** - -Run: `bash .github/actions/deploy-core/tests/run.sh` -Expected: the image-pin cases run and (after Task 3) pass. +- [ ] Pull `repository@digest` anonymously again after merge and rerun image verification by the + committed record. -- [ ] **Step 5: Commit** +**Gate:** downstream plans build on baseline `B`; they do not reference source revision `S` as an +action ref or recompute a tag digest. Their final integration plan designates full SHA `P` only after +all feature contracts pass. -```bash -git add .github/actions/deploy-core/tests/run.sh .github/actions/deploy-core/tests/check-image-pin.test.sh -git commit -m "build-cache container: gate the build-container digest pin in the contract suite" -``` +## 10. Task 6: Release and retention runbook ---- +- [ ] Protect the publisher tag pattern and environment; require review for release execution. +- [ ] Confirm GHCR package visibility is public before the pin PR can be generated. +- [ ] Configure retention so no digest referenced by any supported `image.json` is deleted. +- [ ] Document rollback as reverting to an earlier reviewed `image.json` digest/protocol and pinning + consumers to the corresponding earlier action SHA. Never move a tag to simulate rollback. +- [ ] Document the release record: image source `S`, digest `D`, pin baseline `B`, final action pin + `P`, image tag (informational), checksums, and exact third-party action SHAs. +- [ ] Update the parent spec, implementation plan, adoption guide, and public guide in the downstream + integration plan. Consumer examples must use one full `P` for all EdgeZero references. -## Self-Review +## 11. Completion review -- **Spec coverage (container scope only):** §3.7 image contract → Tasks 2/3; digest = `platform-id` → Tasks 2/3; single-manifest amd64 → Task 3 (`--platform linux/amd64`, single-arch); baked toolchain `1.95.0` → Task 2 + verify; digest pinned/checked (§5) → Tasks 1/4. The *use* of the container (reusable workflow, launcher, provenance) is sub-plans 2–4, out of scope here. -- **Placeholder scan:** the only intentional placeholder is the all-zero digest, which Task 3 overwrites and Task 4 forbids in a release — flagged, not silent. -- **Type consistency:** `check-image-pin.sh ` contract is used identically in Tasks 1, 3, 4; the `image.json` keys (`repository`/`tag`/`digest`) match across Tasks 1–4. +Before declaring this plan complete, run two independent reviews: -## Downstream sub-plans (not written yet) +1. **Contract review:** compare every file and test with design v6.18 Sections 3, 5, 6.3, 8, 9, and + 10. Verify there is no same-SHA claim, no platform identity output, no tag runtime pull, no + placeholder, and no legacy `--stage` guidance. +2. **Release-adversary review:** test mutable tags, private package state, stale/idempotent PR branches, + malformed BuildKit metadata, index manifests, wrong platform/labels/versions/protocol, deleted + image pin, publication reruns, and concurrent release attempts. -2. Cached build path (reusable workflow + `prepare`/`compile` split + **an action-owned `sccache` disk cache**: fresh `CARGO_TARGET_DIR` + owned `actions/cache` restore/save over `SCCACHE_DIR` under a bounded rolling generation key + the constructed minimal env + config/source closure, spec §3.1–§3.4/§3.8). 3. Provenance (JCS canonical JSON + JSON Schema + procedural validation, `validate-app-cli-provenance`, `compute-app-cli-identity`, `ExpectedIdentity`). 4. Consumer integration (`active-version-fastly`, per-consumer `ExpectedIdentity` inputs, the Docker launcher, production-only recovery). Each is its own plan; sub-plan 2 consumes this container's digest as `platform-id`. +The container plan is complete only when source `S`, verified digest `D`, and pin baseline `B` are +recorded and all repository gates pass. The remaining plans may then implement cached compilation and +eventually designate final action revision `P`. diff --git a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md index 5763745e..b0537dc2 100644 --- a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md +++ b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md @@ -1,6 +1,6 @@ -# EdgeZero Deploy Actions — Build Caching Spec +# EdgeZero Deploy Actions - Build Caching Spec -**Status:** Design (proposed) — v6.17 (sccache pivot, hardened) +**Status:** Design (proposed) - v6.18 **Related:** `docs/specs/edgezero-deploy-github-action.md`, `docs/specs/edgezero-deploy-action-implementation-plan.md`, @@ -8,516 +8,590 @@ ## 1. Problem -`build-app-cli` compiles the application's CLI (native) with **no caching**, so every deploy -recompiles the whole dependency graph (~10 min for `stackpop/trusted-server-deployer`, which -checks out a **separate** application repo and builds its CLI). Caching must work for that -**cross-repository deployer** topology **and for real EdgeZero apps, whose crates are unpublished -git dependencies** (so a crates.io-only rule is unusable). - -## 2. Trust model and v1 shape - -- The build compiles **trusted code** (the deploy target); `build.rs` is trusted. -- Caching runs **only for authorized deployer events/refs (fail before compiling otherwise)**; - the runtime credential and the narrow app-checkout PAT are explicitly trusted. -- **The deployer owns and writes its repo-scoped cache**; every writer that can write the - deployer's **current-/default-branch** cache is trusted; the deployer's protected workflow - allowlists `app-repository`/`app-ref`. -- **The reusable workflow is the only SUPPORTED producer, and it is BUILD-ONLY.** It compiles the app - CLI in the **pinned container** (§3.6), caches via sccache, and **emits an artifact plus every - `ExpectedIdentity` field as workflow outputs** (§3.8) — it takes **no provider inputs and never - deploys**. Deployment is the **consumer's own job**: it validates the artifact - (`validate-app-cli-provenance`) then runs the CLI, whose `fastly compute deploy` compiles the wasm - target under a token-bearing **deploy-compile** profile (§3.3) and deploys. The shared writable - working copy / build→deploy lifecycle (§3.6) lives in that **consumer deployment job**, not the - reusable workflow. Provenance is a **consistency check, not producer authentication** (an other-job - archive can self-assert; attestation is §7). The direct composite is an internal `$/` step only. -- **GitHub-hosted `linux/amd64` runners only** (no reliable ephemeral self-hosted predicate). - -## 3. Design - -### 3.1 Cache mechanism: fresh target + action-owned sccache (no `target/` pruner) - -Rather than caching and pruning `target/` (whose unit graph and intermediate layout Cargo treats -as **internal and unstable**), v1 uses **`sccache`** — the compiler cache Cargo itself recommends -for shared dependency acceleration: - -- **`CARGO_TARGET_DIR` is FRESH every run** (an action-owned path under `RUNNER_TEMP`, never - cached, never inside the checkout) — so there is no stale-`target/`, no source-in-target, no - workspace-crate-output, and no unit-graph classification problem. -- **`RUSTC_WRAPPER` is set (action-owned) to the pinned `sccache`** (an **absolute path**, - `/usr/local/bin/sccache`, §3.3) baked into the container. sccache keys a rustc invocation on the - inputs it **observes** — **preprocessed source, `dep-info` inputs, compiler arguments, dependency - artifacts, a subset of the environment, and the working directory** (v0.10) — so a cached result is - reused only when all of *those* match. **Correctness is guaranteed only for observed inputs.** - **Undeclared-input caveat (accepted risk, may pass every downstream check):** sccache's own Rust - guidance warns it may **not** cache correctly when a **`build.rs` or a proc-macro reads files or - environment not among those observed inputs** (undeclared inputs). Rust has **no general mechanism - for a proc-macro to declare its filesystem inputs**, so this cannot be posed as a precondition an - application meets — it is simply the risk `cache: true` **accepts**. A stale result from an - undeclared input can be **internally consistent** and therefore **pass digest, ELF, and `--help` - validation** — the downstream provenance/ABI checks are consistency checks, **not** a staleness - detector, so they are **not** a safety net for this. v1 does not detect it; enabling `cache: true` - is an **explicit acceptance** of that staleness risk (documented on the input). No custom pruning; - `SCCACHE_CACHE_SIZE` bounds each snapshot (§3.2). -- **Cache contents = `SCCACHE_DIR` only** — sccache stores each cached compilation's **object output, - its index, AND the compiler's stdout/stderr** (which sccache **replays** on a hit). That replayed - diagnostic text can contain **warning messages, absolute paths, source excerpts, and compile-time - values**, so the cache holds **more than object files** (this widens the disclosure surface, §3.7). - **No `.crate` sources, no `registry/src`, no `git/*`, no `CARGO_HOME/bin`, no config, no - credentials** are cached — so a cold build's `registry/src` extraction is irrelevant to the audit, - and **no dependency source is ever cached** (only compiled results). Re-downloading crates each run - is the small remaining cost; caching `.crate` archives is §7. -- **Public, anonymously-fetchable sources only.** sccache caches the compilation of any source, but - the minimal build environment (§3.3) carries **no credentials**, so the dependency graph must be - **anonymously fetchable** — `crates.io` and **public git** (e.g. the public EdgeZero repo the - generator emits). Private git/registries, SSH auth, `.netrc`, and credential providers are **not - supported** (a credential design is §7); `cache: false` and `cache: true` resolve dependencies - identically (caching never changes resolution). - -### 3.2 Own restore + save, coarse rolling key - -`actions/cache/restore` + `save` over **one stable host path** (below): - -- **Stable host cache path (required).** `actions/cache` folds the **on-disk path** it archives into - the cache **version**, so a per-run `mktemp` path would make *every* restore miss regardless of a - matching key. The action therefore uses **one fixed host path — `${RUNNER_TEMP}/edgezero-sccache-v1`** - (constant across runs of a given runner-arch), **emptied before restore**, and bind-mounted at the - constant in-container `SCCACHE_DIR=/work/sccache` (§3.6). Only `SCCACHE_DIR` is archived. -- **Key** = `-`, `` = `edgezero-sccache-v1--`, - restore-keys prefix `-`. `` = `` — GitHub's **per-job unique - check-run id**, which differs for every job in a run (so two reusable-workflow calls in one run, and - every matrix leg, get distinct generations without relying on `run_id`/`run_attempt`/artifact-name - collision reasoning). The validated `app-cli-artifact` (unique per writer, §3.8) is **hashed into - ``** so distinct writers also occupy distinct families. Each writer saves a **distinct - immutable entry** and restores the **newest** in its ``; the immutable artifact/cache name - is **reserved (the save key computed and committed to) before save**, so a late collision fails - closed rather than clobbering. `platform-id` = the container digest; `suffix-hash` = the validated - `cache-key-suffix` (§3.8). No lockfile/manifest hashing — sccache content-addresses internally. -- **Concurrent lineages (accepted).** Concurrent matrix/sibling writers each restore the same newest - snapshot and **fork** it; entries are immutable and **not merged**, so only one lineage's warmth is - carried forward per family and the others' incremental warmth is **lost** (re-warmed next run). v1 - **accepts** this rather than partitioning per-leg families (which would multiply cold starts); - partitioned lineages are §7. -- **Bounded snapshot, repository-global eviction (accepted).** `SCCACHE_CACHE_SIZE` is a fixed - **2 GiB** (action-owned), bounding **each snapshot** well under GitHub's **10 GiB per-repository** - cache limit. **Aggregate storage is not family-local:** every successful run saves a **new immutable - entry**, and GitHub's eviction is **repository-wide LRU** — it can evict **unrelated** caches (other - workflows' entries) once the repo total is exceeded, and raising the repo cache quota may be - **billable**. v1 **explicitly accepts** repository-global LRU/thrashing under the rolling scheme (no - action-side cleanup; the actor lacks a cross-workflow cache-delete permission by default). Bump the - `-v1-` family namespace when the mechanism changes. -- **Restore → audit → build → stop-server → best-effort save, with executable failure contracts.** - Two distinct failure classes, handled differently: - - **Restore/audit failure → clear and build cold.** After restore, **audit** that the restored path - is exactly `SCCACHE_DIR` and contains only sccache's blob/index layout. A **restore download - failure or a failed audit** discards the restored dir and **builds once from empty** (the whole - cache is suspect). - - **An sccache per-object read/IO error → that object MISSES, the build continues** (not a cold - reset): `SCCACHE_IGNORE_SERVER_IO_ERROR=1` makes sccache treat a storage IO error as a cache miss - and compile directly, so one unreadable object does not fail the build. - - **Ordinary compiler failures are NEVER retried** — a `rustc` error is the app's, surfaced as-is; - the cache layer does not re-invoke it. - Run `sccache --show-stats` for observability. Before save, **`sccache --stop-server`** flushes and - shuts the server down so `SCCACHE_DIR` is consistent on disk; **if `--stop-server` fails, the save - is SKIPPED** (never archive a live/again-mutating cache). `actions/cache/save` under the run's - reserved `` key is otherwise **best-effort** (failures are warnings). - -### 3.3 Action-owned Cargo/sccache environment - -Every action runs under a **constructed minimal environment** (`env -i` + an explicit allowlist), -not scrub-then-reject, so there is nothing to miss: only the action-owned variables and an -enumerated allowlist exist. **`PATH` = `/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin`** — it -**must include `/usr/local/bin`**, where the container installs the **Fastly CLI** and **`sccache`** -(the deploy/validation profiles otherwise cannot find `fastly`). The rustup-image layout means -`PATH` and `RUSTUP_HOME` are **required** for rustc to start. - -**Enumerated env profiles** (each an exact, closed set — no inherited namespace): - -- **cached compile/build (credential-free):** `PATH` (above), `RUSTUP_HOME=/usr/local/rustup`, - `CARGO_HOME` (§below), `RUSTC_WRAPPER=/usr/local/bin/sccache` (absolute), - `SCCACHE_IGNORE_SERVER_IO_ERROR=1`, `RUSTUP_TOOLCHAIN`, `CARGO_TARGET_DIR` (fresh), `SCCACHE_DIR`, - `SCCACHE_CACHE_SIZE=2G`, `HOME`, `TMPDIR`, `CARGO_ENCODED_RUSTFLAGS=""`, `CARGO_INCREMENTAL=0`. - **No** provider token. -- **deploy-compile (`fastly compute deploy`, token-bearing):** `fastly compute deploy` **compiles the - wasm target**, so this profile carries the **pinned Rustup/Cargo state** — `PATH`, - `RUSTUP_HOME=/usr/local/rustup`, `RUSTUP_TOOLCHAIN`, a **fresh** `CARGO_TARGET_DIR` and a **fresh** - `CARGO_HOME` (no restored state), `CARGO_ENCODED_RUSTFLAGS=""`, `CARGO_INCREMENTAL=0`, `HOME`, - `TMPDIR` — **but NO `RUSTC_WRAPPER`, NO `SCCACHE_DIR`, and no cache save** (the deploy compile is not - cached; the token must never touch the sccache path), plus the **single** provider token - (`FASTLY_API_TOKEN`) and the enumerated `EDGEZERO_*` allowlist (below). -- **validation (`validate-app-cli-provenance`):** `PATH`, `HOME`, `TMPDIR` only (no cargo/sccache, no - token) — it recomputes ELF metadata and runs the hardened smoke (§3.7). -- **read-only provider query / config-push (`active-version-fastly`, config-push):** `PATH`, `HOME`, - `TMPDIR`, the **single** provider token (`FASTLY_API_TOKEN`), and an **enumerated** `EDGEZERO_*` - allowlist — the specific public variables the deploy CLI reads are **listed by name** (not the whole - `EDGEZERO_*` namespace); an unlisted `EDGEZERO_*` is not present. No cargo/sccache. - -A **caller-supplied** `RUSTC`/`RUSTC_WRAPPER`/`RUSTC_WORKSPACE_WRAPPER`/`RUSTDOC`/`RUSTFLAGS`/ -native-tool/`PATH` var simply is **not present** in any constructed profile (never inherited). - -**Cache-hit stability requires ALL sccache hash inputs to be fixed across runs** (v0.10 hashes the -**cwd** too, so a varying path turns every warm build cold). The container therefore fixes, at -**constant in-container paths regardless of the host checkout location**: the writable working copy -of the **whole repository** at **`/work/repo`** (preserving its layout, §3.6), the compile **cwd** at -**`/work/repo/`** (a **constant** path for a given app, so -enclosing Cargo config, parent workspaces, and sibling path-dependencies are all preserved — a -flattened single-directory mount would break `working-directory: apps/api`), `CARGO_TARGET_DIR=/work/target`, -`CARGO_HOME=/work/cargo-home`, `SCCACHE_DIR=/work/sccache`, `HOME=/work/home`, `TMPDIR=/work/tmp` -(writable tmpfs). Identical source built from different host paths must produce sccache hits (§4). - -The effective **Cargo config** over the full chain (cwd → `/`, incl. the working directory, plus -`CARGO_HOME`) must contain only benign allowlisted keys (registry index URLs, `net.retry`, -`http.timeout`/`check-revoke`); anything else fails closed. Default-features-only; `Cargo.lock` must -be a tracked, regular file. **Path dependencies are permitted anywhere beneath `git-root`** (so a -sibling crate such as `../shared` in the same repository resolves — the whole repo is mounted, §3.6); -only a path that **escapes `git-root`** (a repository escape) is rejected. - -### 3.4 Identity - -`git-root` (path, confinement); `app-repo` (`owner/repo`); **`app-repo-id`** (canonical decimal -**string**, always required, **verified via the GitHub REST API to belong to `app-repository`**). -**Credential for the repo-id lookup:** the API verification uses the **`app-checkout-token`** secret -(§3.8) — the only credential able to read a **private** app repo's metadata — and runs **host-side -only** in `compute-app-cli-identity`. It is **never forwarded into any container, working copy, -artifact, or cache**: the build/validate containers carry no GitHub token (§3.3 profiles), so the -token cannot leak into compiled output or the sccache archive. `app-ref` must be a **full 40-hex -commit SHA** (short refs/branches/tags rejected). `workspace-root` canonicalized, confined beneath -`git-root`, `working-directory` beneath it, asserted `== cargo metadata.workspace_root`. - -**All identity hashes are SHA-256 over a canonical, length-framed encoding.** Each field is encoded -as its UTF-8 bytes prefixed by a length frame: the byte length as **ASCII decimal with no leading -zeros** followed by a single `:` separator (`:`), fields concatenated in a fixed order — -so no field boundary is ambiguous and no fixed width can overflow. **Path fields are byte-exact, not -Unicode-folded.** A path is expressed **relative to `git-root`** with a **defined root -representation** — the root itself encodes as the single byte `.` (never the empty string) — is -`/`-separated with **no `.`/`..`/empty segments and no trailing slash**, and is hashed as its **exact -UTF-8 bytes with NO Unicode normalization** (no NFC/NFD): Linux and Git treat a path as a byte string, -so two byte-distinct paths (e.g. an NFC vs. NFD spelling of the same character) are **distinct files** -and must hash **distinctly** — folding them would collide two real workspaces onto one `workspace-id`. -A **non-UTF-8 path byte sequence is rejected** (fail closed). `workspace-id` = that hash over -(`app-repo-id`, workspace-root path relative to `git-root`); `suffix-hash` = that hash over the -validated `cache-key-suffix`. **Golden vectors** — including the `:` framing, the `.` -root, and an **NFC-vs-NFD pair that must produce different hashes** — are committed with the plan. -`platform-id` = the container digest, **read inside every action from `image.json` at the same -EdgeZero SHA — never caller-supplied**; `container-ref` = `@`. - -### 3.5 Writer fidelity vs. source authorization - -Cache runs only on `push`/`workflow_dispatch`/`schedule` on a **protected deployer ref**. Because the -**deployer and the app can be separate repositories**, the deployer's `HEAD` is **not** the app SHA — -the predicates are **checked separately**, never conflated into one equality: - -1. **Deployer workflow identity** — the calling workflow runs on a protected deployer ref - (`push`/`dispatch`/`schedule`), whose protected config allowlists the app identity. -2. **Called-workflow SHA** — the reusable workflow is called at a pinned EdgeZero SHA (the `$/` - self-repo floor, §3.8). -3. **App-checkout SHA** — the mounted app checkout's `HEAD` equals the resolved `app-ref` (a full - 40-hex SHA, §3.4), asserted against the **app** repo — independently of the deployer's own `HEAD`. - -Every writer of the deployer's **current-/default-branch** cache scope is trusted (deployer -authorization). Normative in the guide. - -### 3.6 Container, runner, launcher - -- **Image:** EdgeZero-published, **public** (anonymous pull) + retained, single-manifest - `linux/amd64`, pinned by **manifest digest**, from a versioned in-repo Dockerfile baking the - pinned Rust toolchain, `wasm32-wasip1`, the pinned **`sccache`**, the pinned **Fastly CLI** - (`versions.json`), and `git jq tar curl cc`. Run **`--read-only`, non-root**, explicit writable - mounts only. `platform-id` = its digest. -- **Runner: GitHub-hosted `linux/amd64` only** (fail closed on self-hosted). Host-level job, local - Docker daemon. -- **Separate container instances, one shared working copy.** The credential-free **build** and the - token-bearing **deploy** run in **distinct container instances** (never one long-lived container); - the build instance holds no provider token. They **share a single `/work/repo` working copy**: it is - made **once** as a faithful copy of the checkout, the build instance compiles into it (and into the - fresh `/work/target`), and the **same copy — now carrying the build's derived outputs — is remounted - into the deploy instance** (as derived build state, not re-copied), so generated files (`dist/`, - staged `pkg/`, produced manifests) reach the deploy step without a lossy re-clone. Freeze - assertions (§3.7) run against the **read-only original**, never this mutated copy. -- **One launcher `run-app-cli-in-container`** with a **maximum mount allowlist** and a **minimal - per-operation mount profile** (constant in-container paths, so sccache's cwd/path hashing is stable - regardless of the host checkout location; never `RUNNER_TEMP` wholesale). **No operation receives - more than its profile lists** — in particular the **archive-supplied validator is not authenticated, - so its `--help` smoke gets NONE of the writable repo/target/cargo-home/sccache mounts.** The table - is the ceiling; each row's "Ops" column is the closed set of operations that may mount it: - - | In-container path | Mode | Ops (only these mount it) | Source | - | --- | --- | --- | --- | - | `/work/repo` (repo root; compile cwd = `/work/repo/`) | **writable** | cached-compile, deploy-compile | a **verified faithful copy** of the whole app checkout, layout preserved | - | `/work/target` | writable | cached-compile, deploy-compile (each its own **fresh** dir) | fresh `CARGO_TARGET_DIR` | - | `/work/cargo-home` | writable | cached-compile, deploy-compile (fresh) | `CARGO_HOME` | - | `/work/sccache` | writable | **cached-compile only** | `SCCACHE_DIR` (restored from the stable host path, §3.2) | - | `/work/home`, `/work/tmp` | writable (tmpfs) | all | provider/Fastly `HOME`, `TMPDIR` | - | the package/output dir | writable | deploy-compile, config-push | staged CLI / Fastly `pkg/` | - | the validated CLI binary | read-only | **validation, provider-query, deploy** | consumer input | - | the specific inline-config temp file | read-only | **config-push only**, by exact path | config-push only | - - So: **validation** (`--help` smoke) mounts only the read-only binary + `/work/home,/work/tmp` — no - repo/target/cargo/sccache; **read-only provider query** mounts the read-only binary + tmpfs + - (host-side) token, nothing writable-source; **config-push** adds only the one read-only inline-config - file; **cached-compile** is the only operation that mounts `/work/sccache`; **deploy-compile** mounts - the (re-verified, §3.7) `/work/repo` + fresh target/cargo + output dir + token, **never sccache**. - UID/GID mapping so the non-root container user owns the writable mounts. - - **Writable working COPY (whole repo, layout preserved).** The CLI runs arbitrary manifest commands - via `sh -c` in the manifest root and may create `dist/`, `node_modules/`, generated manifests — so - `/work/repo` is a disposable writable copy of the **entire repository** (not the flattened working - directory), preserving parent Cargo config, enclosing workspaces, and sibling path-dependencies. - The copy is a **verified faithful copy of the read-only original** — equivalent in content, file - modes, symlink targets, and **initialized-submodule** state (submodules must be checked out at - their recorded commits; an uninitialized/dirty submodule fails closed), with **hardlinks broken** - (a real copy, e.g. `cp -a` + a content-hash comparison, not a bind of the original). **Ignored - files are excluded:** the copy carries only what `source-revision` represents — tracked files plus - initialized submodules; git-ignored/untracked build detritus is **absent** (excluded before the - copy), so the compiled bytes are exactly the frozen source (§3.7). - - **env:** only the required provider token + `EDGEZERO_*`; no GitHub file-command channels inside - the container. - - **signals/outputs:** host↔container readiness handshake; **`mutation-attempted` published - host-side to `$GITHUB_OUTPUT` before launching the mutating CLI**; named container + host-side - signal forwarding (`docker stop -t ` → `docker rm`) + - post-cancel reconciliation. - -### 3.7 Source freezing, provenance, disclosure, actions - -- **Source freezing:** the writable `/work/repo` copy is proven a **faithful copy** of the read-only - original (§3.6) before compilation — **tracked files + initialized submodules only, git-ignored/ - untracked detritus excluded**, so the copy is exactly what `source-revision` represents — and that - **same copy (now with build outputs) is reused for the deploy instance** (§3.6). On the **read-only - original**, assert the initial `HEAD` SHA unchanged + tree clean (tracked + untracked + recursive - submodules) **before and after** all app-controlled commands; reject escaping symlinks. - - **Re-verify the shared copy before the token-bearing deploy-compile.** A `build.rs`/manifest - command in the credential-free build could have **mutated a tracked file inside `/work/repo`**; - the read-only-original assertions would still pass while the deploy step compiles the **changed - bytes** with the token present. So **before deploy-compile, re-verify every initially-tracked - path** in the copy against the frozen source — **content, mode, symlink target, and gitlink - (submodule commit)** — and **permit divergence only in explicitly declared output paths** - (`target/`, the staged package dir, and any manifest-declared build outputs). Any change to a - tracked source file outside those declared paths **fails closed** before the token is used. - Consumers additionally **verify their mounted checkout's repository id, `HEAD`, and workspace against - the artifact before and after commands**. -- **`ExpectedIdentity`:** `app-repo-id` (decimal string), `source-revision` (full SHA, explicit), - `app-cli-package`, `app-cli-bin`, `workspace-id` — **caller-supplied and checkout-verified**; - `platform-id`/`container-ref` are **derived inside every action from same-SHA `image.json`, not - accepted from the caller**. -- **Schema/canonicalization (normative, with golden vectors):** `app-cli-meta.json` is **canonical - JSON per RFC 8785 (JCS)** — the exact escaping, number serialization, key ordering, and whitespace - rules are JCS's, not "minimal forms" — and duplicate keys are **rejected before parse** (JSON - Schema cannot). It is validated by a committed **JSON Schema 2020-12** file **plus** the JCS + - dup-key procedural pass. Meta ≤ **64 KiB**. Fields = `ExpectedIdentity` + `app-cli-version` - (informational) + `binary-sha256` + `binary-size` + `abi`. **`abi` is recomputed ELF metadata**, - each field an exact form: `machine` = the ELF `e_machine` **as its canonical string name** - (e.g. `"x86_64"`); `interp` = the `PT_INTERP` path **as a string, or JSON `null` for a static - binary** (no `PT_INTERP`); `needed` = the **direct** `DT_NEEDED` entries **as a sorted string array** - (`[]` for a static binary) — **transitive** libraries are not listed (they are resolved, not - recorded, by the loadability proof). `dlopen`-at-runtime libraries are **out of scope** (not in - `DT_NEEDED`, not asserted). `abi` is a **consistency/loadability** contract, not a full ABI model. -- **Archive contract (normative), with normalized headers:** a **deterministic `ustar` tar** (POSIX - ustar **only** — `pax` extended headers are **rejected**, so there is no ambiguous PAX extension - surface) with **exactly two** regular members in fixed order, `app-cli-meta.json` then the - `app-cli-bin` binary. **Header fields are normalized to fixed values** so byte-equality is - reproducible: `uid`/`gid` = `0`, `uname`/`gname` = empty, `mtime` = `0`, `mode` = `0644` (meta) / - `0755` (binary), `typeflag` = `0` (regular), `prefix` = empty and each `name` a fixed literal - (`app-cli-meta.json`, the `app-cli-bin` basename) — **not** the producer's path. Any extra/ - duplicate/renamed member, any symlink/hardlink/device/global-extended header, non-zero `mtime`/ - non-zero `uid`/`gid`, trailing bytes, or path-traversal name is **rejected**; total logical size ≤ - **512 MiB**, meta ≤ 64 KiB, and the binary member size **equals** `binary-size` exactly, with its - sha256 re-verified. -- **`validate-app-cli-provenance`** (fresh pinned container, minimal env, hardened): enforce the - archive contract; JCS + JSON-Schema validate; re-verify binary digest/size; **ABI loadability - proof** — recompute `PT_INTERP`, `DT_NEEDED`, and search paths from the binary, **resolve every - required library inside the immutable image**, then run a **credential-free `--help` smoke**. - - **Trusted validation runtime (baked, project-owned).** JCS canonicalization, duplicate-key - detection, JSON-Schema-2020-12 validation, strict `ustar` parsing, and ELF inspection are **not - expressible in `jq`/`tar`**, so the image **bakes a single pinned, project-owned validator binary** - (a small Rust tool built from the EdgeZero repo at the same SHA — **not** a network-fetched helper, - keeping the validation runtime credential-free and offline) that performs all of them. The - container plan **smoke-tests every required capability** (JCS, dup-key reject, schema reject, - non-ustar/pax reject, ELF read) **before the image digest is published**, so a missing capability - fails the publish, not a deploy. The - smoke runs the archive-supplied binary under **`--network=none --read-only --user 1001 - --cap-drop=ALL --security-opt=no-new-privileges`, a bounded `--memory`/`--pids-limit`, and a wall - timeout** (Docker enforces these directly). Compare every caller `ExpectedIdentity` field. Output - `app-cli-path`. -- **`active-version-fastly`** — inputs: `artifact-tar`, `ExpectedIdentity`, `fastly-service-id`, - `fastly-api-token`; validates, runs `active-version` via the launcher; output `version` (empty on - a first-ever **production** deploy = success). **Recovery is PRODUCTION-only.** -- **`compute-app-cli-identity`** — inputs: `app-repository`/`app-repo-id`, `source-revision`, - `workspace-root`, `app-cli-package`/`app-cli-bin`, and the **`app-checkout-token`** secret used - **host-side** to API-verify `app-repo-id` belongs to `app-repository` (the only credential that can - read a private repo's metadata); the token is **never** passed to a container, working copy, - artifact, or cache. Reads `platform-id`/`container-ref` from same-SHA `image.json`; outputs the full - `ExpectedIdentity`. -- **Disclosure (enforceable):** because the action cannot compare reader sets, require - **`disclosure-acknowledged: true` for every cross-repository build** (`app-repo-id` ≠ the deployer - repo id), **exempting only equal repository ids**. The sccache cache holds **compiled results — not - only object files but the replayed compiler stdout/stderr** (warnings, absolute paths, source - excerpts, compile-time values, §3.1) — so the exposure it acknowledges is **compiled artifacts and - build diagnostics**, not merely objects; `deploy-fastly.cache` carries the same acknowledgement. - -### 3.8 Reusable-workflow contract - -Inputs: `app-repository`, `app-ref`, **`app-repo-id`** (string, always required), `working-directory` -(`.`), `workspace-root` (required), `app-cli-package` (required), `app-cli-bin`, `app-cli-artifact` -(**required unique across every cache-writing invocation** — not only per matrix leg but per -reusable-workflow call in a run; the action **fails closed** on a collision it can detect, since two -calls sharing `run_id`/`run_attempt` and a default artifact name would otherwise write the same key), -`cache` (default `false`), `cache-key-suffix`, `disclosure-acknowledged` (required-true for cross-repo), -`timeout-minutes` (30). **No `rust-toolchain`/feature inputs, and NO provider inputs** (the workflow is -**build-only**, §2 — it never deploys). Secret `app-checkout-token`. Job `permissions: { contents: -read }` (caller grants ≥ that); `persist-credentials: false`. **Runner floor 2.336.0** (self-repo `$/`). - -**Outputs (build-only):** the built **`artifact-name`** (the uploaded provenance tar, §3.7) plus -**every `ExpectedIdentity` field explicitly** — `app-repo-id`, `source-revision`, `app-cli-package`, -`app-cli-bin`, `workspace-id`, `platform-id`, `container-ref` — so a single-build caller can pass them -straight into its **own deployment job** (`validate-app-cli-provenance` → `active-version-fastly` → -the CLI's `fastly compute deploy`, §2). The shared writable copy / deploy-compile (§3.3/§3.6) lives in -that consumer job, never here. - -**Matrix:** v1's shared workflow outputs are **single-build** (GitHub returns only the last matrix -leg's outputs). A **matrix caller uses unique per-leg `app-cli-artifact` names** (which also key each -leg's distinct cache lineage, §3.2) **and computes each leg's `ExpectedIdentity` via -`compute-app-cli-identity`** — it does not consume the shared outputs. Concurrent legs each restore -the newest snapshot and fork it without merging (§3.2, accepted). - -## 4. Testing - -sccache — **cross-run warm reuse is asserted via `sccache --show-stats`, not by disabling the -network** (only `SCCACHE_DIR` is cached, so Cargo still needs to fetch dependency **sources** before -invoking rustc): the warm run does `cargo fetch` **online**, then asserts the compile's sccache cache -**hit rate rose** and wall-time dropped versus cold. (If an offline compile is wanted, `cargo fetch` -**prefetches sources before** the network is disabled for the rustc phase only.) Also: a **stable host cache path** (`${RUNNER_TEMP}/edgezero-sccache-v1`, emptied before restore) — -a matching key **restores across runs** (proving the path is not a per-run `mktemp` that would force -version misses); a corrupt/failed restore **resets cold** (one rebuild from empty); a failed -`sccache --stop-server` **skips the save** (no live-cache archive); the audited cache path is exactly -`SCCACHE_DIR`; **identical source built from two different host checkout paths yields sccache hits** -(fixed `/work/repo/` cwd); a **nested working directory** (`working-directory: -apps/api` under a parent workspace) builds with its enclosing Cargo config/sibling path-deps intact; -**a public git dependency (the EdgeZero repo) builds and caches**; two writers with distinct -`job.check_run_id` generations save **distinct entries** (no key collision); an sccache per-object IO -error **misses and continues** (`SCCACHE_IGNORE_SERVER_IO_ERROR=1`) while a bad restore **resets cold**; -a `rustc` error is **surfaced, not retried**. **Wall-time drop is telemetry, not a pass/fail assertion** -(only the sccache hit-rate rise is asserted). Topology (**build-only reusable workflow**: it exposes the -artifact + every `ExpectedIdentity` field as outputs, takes **no provider input**, and never deploys; -the **consumer's own job** validates then deploy-compiles; the cross-repo predicates — deployer ref, -called-workflow SHA, app-checkout SHA — are checked **separately** (deployer HEAD ≠ app SHA is fine); -a **path dep beneath `git-root` resolves**, only a `git-root` escape is rejected). Container/runner/ -launcher (self-hosted fails closed; read-only rootfs; **full recursive, non-sparse checkout** with LFS/ -smudge-filter content materialized (not pointer files); **separate build/deploy container instances -sharing one `/work/repo` copy** so build outputs reach deploy; the faithful `/work/repo` copy matches the -original in content/modes/symlinks/**initialized-submodule** state with hardlinks broken and **git-ignored -files excluded**; **before deploy-compile the copy's tracked files/modes/symlinks/gitlinks are -re-verified** and a `build.rs` that mutated a tracked file fails closed; a manifest command creating -`dist/` succeeds in the copy while the original stays clean; **per-operation mount profiles** — the -unauthenticated validator `--help` smoke gets **no** writable repo/target/cargo/sccache, and only -cached-compile mounts `/work/sccache`; host-side `mutation-attempted` before mutation; cancellation -`docker stop -t`+reconcile). Env/config (constructed minimal env; **`PATH` includes `/usr/local/bin`** -so `fastly` resolves; the **deploy-compile profile** carries Rustup/Cargo + fresh target/cargo but -**no `RUSTC_WRAPPER`/`SCCACHE_DIR`/save**; `RUSTUP_HOME` set and an absolute `RUSTC_WRAPPER` in the -cached-compile profile; the deploy profile exposes only the **enumerated** `EDGEZERO_*` allowlist + the -single token; a caller `RUSTC_WRAPPER`/`PATH` is absent, not merely rejected; non-allowlisted config -anywhere fails). Identity (`app-repo-id` API-verified **with `app-checkout-token` host-side, never -forwarded into a container/copy/artifact/cache**; `app-ref` rejected unless a full 40-hex SHA; -**length-framed `:` hash golden vectors** with the `.` root and a **byte-exact NFC-vs-NFD -pair that hashes differently** (no Unicode folding); a **non-UTF-8 path rejected**; `platform-id` from -`image.json`, not caller; consumer re-verifies checkout id/HEAD/workspace before+after). Provenance -(the **baked project-owned validator** smoke-tests JCS/dup-key/schema/ustar/ELF capability at publish; -JCS canonical + dup-key rejection; ustar-only exactly-two-members with **normalized headers** — zero -`mtime`/`uid`/`gid`, fixed names — `pax` rejected, binary size equality; **ABI loadability** — `abi` = -recomputed `machine`/`interp`(`null` if static)/direct-`DT_NEEDED`, transitive resolved in the image, -`dlopen` out of scope — + a hardened `--help` smoke (`--network=none --cap-drop=ALL --no-new-privileges`, -memory/pids/timeout); a real wrong-runtime rejected; **a stale undeclared-input object can pass every -check** (documented, not caught); provenance documented consistency-only). Disclosure required for every -cross-repo build (equal-id exempt), acknowledging **compiled artifacts and build diagnostics**. Recovery -production-only. - -## 5. Rollout, docs, migration - -**Atomic same-SHA rollout** (container image w/ sccache, reusable workflow, all three actions, -consumers, recovery); direct-composite producer retired → adopters migrate to the **two-job** -topology; runner floor **2.336.0**. Scope the parent's exact-key/target-only caching language to -`deploy-fastly.cache`; document that `build-app-cli.cache` is an **sccache disk cache** (compiled -objects, no source); apply the cross-repo disclosure rule to both caches; add the container-runner, -sccache, provenance, single-producer, and 2.336.0 updates; correct the "consumers own -checkout/runner/timeout; actions never call `checkout`" claims. Pin gate/`zizmor`/actionlint: -container digest pin, `$/` carve-outs. Public-surface golden: the `ExpectedIdentity` table, the -committed JSON Schema + **golden meta/archive vectors**, all three actions. - -## 6. Default and effect - -**Off by default** (caching). Container execution + provenance unconditional. With `cache: true` on -an authorized deployer build, sccache reuses compiled dependency objects across runs (the bulk of -the ~10 min); changed local crates recompile. - -## 7. Out of scope / future - -Caching checksum-verified `.crate` archives (download savings); workflow-bound artifact -**attestation**; native-tool (`cc`) sccache wrapping; trusted **self-hosted** runner mode; -cross-image/directional ABI; alternate toolchains (a second container); non-default features; -`cli-profile`; non-Fastly adapters. - -## 8. History - -… v6.11 (container-only) → v6.12 (own restore+save, full-runtime container) → v6.13 (crates.io-only, -hosted-only, four-root prune) → **v6.14 (sccache pivot)**: replace the unbuildable `target/` unit-graph -pruner and the unusable crates.io-only rule with a **fresh `CARGO_TARGET_DIR` + an action-owned pinned -`sccache` disk cache** (content-addressed, no pruning, any source incl. git deps, no source cached); -coarse rolling `run_id` generation key; **constructed minimal build env**; **writable working copy** -for manifest commands (read-only original for the freeze checks); `app-repo-id` **API-verified**, -`platform-id` **from `image.json` not the caller**, consumer **re-verifies checkout before+after**; -**disclosure required for every cross-repo build** (equal-id exempt); **ABI loadability** via resolved -`DT_NEEDED` + a network-disabled `--help`; normative **canonical-JSON + tar** contracts with golden -vectors; **matrix caller computes per-leg identity**; container plan gains a **verify-by-digest-then-PR** -publish (§ container sub-plan). → **v6.15 (hardened)**: add `PATH`/`RUSTUP_HOME` + an absolute -`RUSTC_WRAPPER` so rustc starts under `env -i`; narrow the sccache correctness claim (dep-info/args/ -env/**cwd** hashing) and make the **undeclared-input (proc-macro/build.rs) risk** an explicit -cache opt-in; a **bounded, collision-free generation** (`run_id`-`run_attempt`-`artifact`, `SCCACHE_CACHE_SIZE=2G`, -`--stop-server` before save); a **complete fixed mount table** with a constant `/work/app` cwd (so -sccache's cwd hash is stable across host paths) and a **verified faithful working copy** (content/ -modes/symlinks/submodules, hardlinks broken); **separate build/deploy container instances**; a **full -40-hex `app-ref`** and **length-framed hash encodings** with golden vectors; **RFC 8785 (JCS)** JSON + -**ustar-only** archive with binary-size equality; a **hardened validator smoke** (`--cap-drop=ALL`, -`no-new-privileges`, memory/pids/timeout); and a **warm test via `sccache --show-stats`** (online, since -dependency sources are not cached). Public, anonymously-fetchable sources only. Validator string-type -fix + publish-visibility ordering land in the container sub-plan. → **v6.16 (contract revision)**: a -**stable host cache path** (`${RUNNER_TEMP}/edgezero-sccache-v1`, emptied before restore) so -`actions/cache`'s path-in-version rule cannot force permanent misses; **whole-repo `/work/repo`** -working copy with the compile cwd at the relative `working-directory` (preserving nested-workspace -parent config/sibling path-deps — the flattened `/work/app` is gone), **git-ignored files excluded** -and **initialized submodules validated**, and the **same copy reused across the separate build/deploy -container instances** so build outputs reach deploy; storage restated as **repository-global LRU** -(evicts unrelated caches, may be billable) — not family-local; **generation keyed on an -`app-cli-artifact` unique across every cache-writing invocation** (fail-closed on a detectable -collision) with concurrent lineages **forked, not merged** (accepted); the **sccache undeclared-input -risk stated as accepted** (no proc-macro input-declaration mechanism exists) with **fail-cold** restore/ -audit/read failures and a **skip-save on `--stop-server` failure**; **`PATH` includes `/usr/local/bin`** -(Fastly/sccache) with **enumerated compile/validation/deploy env profiles** (named `EDGEZERO_*`, not the -namespace); **`app-checkout-token` assigned to the host-side `app-repo-id` API check** and barred from -containers/copies/artifacts/caches; **length-framed `:` hash encoding** with normalized -relative paths, **normalized ustar headers** (zero `mtime`/`uid`/`gid`, fixed names), and **`abi` as -recomputed ELF metadata** (`machine`/`interp`=`null`-if-static/direct-`DT_NEEDED`; transitive resolved, -`dlopen` out of scope). Container sub-plan: two-tier pin policy (major action tags, image digests) and a -**canonical-repository** check in `check-image-pin.sh`. → **v6.17 (contract revision)**: the reusable -workflow is **build-only** — no provider inputs, emits the artifact + every `ExpectedIdentity` field as -outputs, and the shared-copy build→deploy lifecycle moves to the **consumer's deploy job** (resolving the -"one container builds+deploys" contradiction); a **deploy-compile env/mount profile** (Rustup/Cargo + -fresh target/cargo, **no wrapper/`SCCACHE_DIR`/save**) because `fastly compute deploy` compiles the wasm; -**per-operation mount profiles** (the unauthenticated validator smoke gets **no** writable repo/target/ -cargo/sccache; only cached-compile mounts sccache); the shared copy's tracked files/modes/symlinks/ -gitlinks **re-verified before the token-bearing deploy** (derived state only in declared output paths); -the cross-repo topology predicates **split** (deployer ref, called-workflow SHA, app-checkout SHA — not -one HEAD==app-SHA equality) and **path deps permitted anywhere beneath `git-root`**; the undeclared-input -staleness restated as **may pass every downstream check** (provenance/ABI is not a staleness detector); -the cache described as holding **compiled results incl. replayed compiler stdout/stderr**, widening the -**disclosure** to build diagnostics; **byte-exact path hashing** (drop NFC — Linux/Git paths are bytes; -NFC/NFD are distinct), a defined `.` root, non-UTF-8 rejected; **`job.check_run_id` generation** + -`SCCACHE_IGNORE_SERVER_IO_ERROR=1` (per-object IO error → miss, not cold) + name reserved before save + -compiler errors never retried; **full recursive non-sparse checkout** with LFS/filter content -materialized; wall-time as **telemetry**. Container sub-plan: a **baked project-owned validator** -(JCS/dup-key/schema/ustar/ELF, smoke-tested at publish); **SHA-pinned actions in the write-privileged -publish workflow**; the single-manifest check **rejects a one-entry OCI index** (leaf manifest required); -the anonymous-pull check reads the **merged** digest. - -## 9. Deferred to the implementation plan (mechanics only) - -Exact `prepare`/`compile`/launcher/helper signatures; the Dockerfile (checksum-verified Fastly CLI + -pinned sccache) + digest-pin + **verify-by-digest-then-PR** GHCR publish; the committed JSON Schema + -golden vectors; and the writer-fidelity / API-repo-id-binding / canonical-JSON predicate expressions. +`build-app-cli` compiles the application's native CLI without caching. A cross-repository deployer +therefore recompiles the full dependency graph on every run. The solution must also work for real +EdgeZero applications whose crates are public Git dependencies, not only crates.io packages. + +The design must preserve the existing deploy, staged deploy, healthcheck, rollback, and config-push +contracts. It must not expose provider credentials to app CLI compilation or to restored cache data. + +## 2. Scope and trust model + +- The application repository and the app code being compiled are trusted. This includes `build.rs`, + proc macros, manifest commands, and any native tools they invoke. +- Cache writes run only for authorized deployer events and protected refs. The deployer repository + owns the repository-scoped GitHub Actions cache and trusts every workflow allowed to write its + default-branch cache scope. +- The app checkout token and provider token are trusted credentials, but they have disjoint uses. + The checkout token is host-only. The provider token exists only in the minimum provider operation + that requires it. Neither credential enters the cached-compile container or `SCCACHE_DIR`. +- The reusable workflow is the only supported artifact producer. It is build-only: it accepts no + provider inputs and performs no provider mutation. +- Artifact provenance is a consistency and loadability check. It is not producer authentication or + an attestation. A malicious producer can create a self-consistent archive. Attestation remains out + of scope. +- Caching has an accepted correctness risk: sccache can miss undeclared filesystem or environment + inputs, including changed `app-env` values, read by `build.rs` or proc macros. A stale object can + pass digest, ELF, and smoke checks. + `cache: true` explicitly accepts this risk; v1 does not and cannot generally detect it. +- v1 supports GitHub-hosted `linux/amd64` runners only. It fails closed on self-hosted runners and + does not target GitHub Enterprise Server. + +## 3. Terminology and identity + +### 3.1 Caller and platform identity + +`CallerExpectedIdentity` is the caller-controlled identity that both producer and consumer verify: + +- `app-repo-id`: canonical decimal GitHub repository id, verified against `app-repository` through + the GitHub REST API. +- `source-revision`: the full lowercase 40-hex commit SHA checked out from the app repository. +- `app-cli-package`: Cargo package name. +- `app-cli-bin`: binary name. +- `workspace-id`: the canonical workspace identity described below. + +`PlatformIdentity` is action-controlled: + +- `platform-id`: the `sha256:<64-lowercase-hex>` image manifest digest from the local action + revision's `.github/docker/build-app-cli/image.json`. +- `container-ref`: `@` from that same file. +- `provenance-protocol`: the exact protocol integer from that same file. + +Every EdgeZero action derives `PlatformIdentity` locally. Callers cannot provide or override it, and +the reusable workflow does not expose it as an output. Artifact metadata contains both identity +groups so the consumer action can compare caller values and its locally derived platform values. + +The app checkout token is used host-side by `compute-app-cli-identity` to verify a private +repository's id. It is never copied into a container, working tree, artifact, or cache. + +### 3.2 Canonical hashes + +Identity hashes use SHA-256 over a fixed-order, length-framed byte encoding. Each UTF-8 field is +encoded as `:`, where the length is ASCII decimal with no leading zeroes. + +Paths are relative to `git-root`, `/`-separated, have no empty, `.`, or `..` segment, and have no +trailing slash. The root is represented by the single byte `.`. Paths are hashed byte-exactly with +no Unicode normalization; non-UTF-8 paths fail closed. + +`workspace-root` must canonicalize beneath `git-root`; `working-directory` must canonicalize beneath +`workspace-root`; and credential-free `cargo metadata --locked` from `working-directory` must report +that exact workspace root. Its `Cargo.lock` must be a tracked regular file. A caller-provided root is +never trusted without those checks. + +- `workspace-id` hashes, in order, `app-repo-id` and workspace root relative to `git-root`. +- `suffix-hash` hashes the validated `cache-key-suffix`. + +Committed golden vectors cover framing, the root representation, empty suffix, and byte-distinct NFC +and NFD paths that must produce different hashes. + +### 3.3 Workflow and action revisions + +`app-ref` must be a full lowercase 40-hex commit SHA. Branches, tags, abbreviated SHAs, and the +legacy `--stage` spelling are unsupported. Staged provider operations use only `--staging`. + +Every non-local external action and reusable workflow reference in this repository and in documented +consumer workflows must use a full 40-hex commit SHA. Version tags, including major and patch tags, +are not accepted. All EdgeZero references in one consumer workflow use one full action revision `P`. + +Inside the called workflow: + +- `job.workflow_repository` and `job.workflow_file_path` must identify the expected EdgeZero + reusable workflow. +- the suffix of `job.workflow_ref` must be a full 40-hex SHA, not a branch or tag; +- `job.workflow_sha` must equal that suffix. + +These hosted-runner context properties identify the workflow that defines the current job. They are +part of the hosted-only v1 floor. + +## 4. Cache design + +### 4.1 Cached data and fixed paths + +For the reusable workflow's native `build-app-cli` compile, `CARGO_TARGET_DIR` is fresh on every run +and is never cached; only `SCCACHE_DIR` is archived. The pinned image supplies +`/usr/local/bin/sccache` v0.10.0, and cached compilation sets its absolute path as `RUSTC_WRAPPER`. + +This section defines `build-app-cli.cache`, the reusable workflow's native CLI compilation cache. +It does not replace the parent's distinct `deploy-fastly.cache`: under `build-mode: always`, the +consumer may restore and save that exact-key Cargo target cache only around the credential-free +`app-build` profile below, before any provider token is introduced. `build-mode: never` receives no +target-cache restore or save. + +The host cache path is the fixed `${RUNNER_TEMP}/edgezero-sccache-v1`, emptied before restore. It is +mounted at the constant `/work/sccache`. The fixed host path is required because `actions/cache` +includes the archived path in its cache version. The fixed in-container path and `/work/repo` cwd +also avoid path-only misses in sccache keys. + +The cache contains compiled outputs, indexes, and replayable compiler stdout/stderr. Diagnostics can +contain paths, source excerpts, warnings, and compile-time values. Dependency sources, Cargo registry +or Git checkouts, `.crate` archives, credentials, and `CARGO_HOME/bin` are not intentionally cached. + +The compile environment has no dependency credentials. Both cached and uncached builds therefore +support only anonymously fetchable crates.io and public Git dependencies. + +### 4.2 Keys, restore, and save + +The cache family is exactly: + +```text +edgezero-sccache-v1-- +``` + +The primary key is `-`, where generation is `job.check_run_id`. The only restore +prefix is `-`. `app-cli-artifact` does not affect cache identity; it is unique only because +GitHub artifact names share a run-level namespace. + +Each successful writer creates a new immutable entry. Concurrent jobs in one family restore the +newest available entry and fork from it. Their results are not merged, so only one lineage may remain +the newest. This lost warmth is accepted. + +There is no cache reservation or fail-closed save protocol. Standard `actions/cache/save` is +best-effort and save failures are warnings. GitHub cache restore, cache absence, and cache save +availability never determine build success. A failure of the compiler-wrapper process itself can +still fail compilation as described below. + +GitHub cache storage and eviction are repository-global. The rolling generations can evict unrelated +workflow caches. Entries not accessed for seven days may be removed. This cost and eviction behavior +is accepted; v1 performs no cache deletion. + +### 4.3 Restore and runtime failure contracts + +The sequence is: + +1. Empty the stable host cache directory. +2. Restore the newest matching cache. +3. Audit restored data. On restore or audit failure, clear the directory and continue cold. +4. Start sccache, zero its statistics, and compile once. +5. Capture `sccache --show-stats` and stop the server. +6. Audit the stopped directory again. +7. Save only when the compile succeeded, stop succeeded, the final audit passed, and the captured + `cache_write_errors` count is zero. + +Storage lookup and decompression failures that sccache v0.10 treats as misses remain misses. +`SCCACHE_IGNORE_SERVER_IO_ERROR=1` is set because it covers selected client/server response failures; +it is not described as covering startup, connection, extraction, or every backend error. Any other +sccache error follows pinned v0.10 behavior. An ordinary compiler failure is surfaced once and is +never retried by the cache layer. + +If `sccache --stop-server` fails, the action skips save with a warning. If cache write errors are +non-zero, the build may still succeed but save is skipped with a warning. Restore, save, and cache +absence never trigger a second compilation. + +### 4.4 Cache audit and disclosure + +`SCCACHE_CACHE_SIZE=2G` is the managed sccache capacity. It is not the hard archive bound. The +post-stop audit computes a worst-case upper bound for the final cache archive using the pinned cache +client's tar and compression formats, including every entry header, file padding, end marker, and +compression framing/expansion, and requires that bound to be at most 2 GiB. It also applies a fixed +entry-count ceiling from committed v0.10 layout fixtures. + +Before use after restore and before save, the audit requires: + +- the canonical audited root is exactly `SCCACHE_DIR`; +- every entry is a regular file or directory beneath that root; +- no symlink, socket, FIFO, device, mount escape, or special file exists, and every regular file has + `nlink == 1` (directory link counts are not constrained); +- ownership is the expected container uid/gid; +- layout and record names match the pinned v0.10 format fixtures; +- the calculated archive upper bound and entry count satisfy the limits above. + +The application is trusted, but cached compilation shares a writable uid and `SCCACHE_DIR` with app +code. App code can therefore place arbitrary bytes in that directory. The audit constrains shape and +size, not authorship or semantic content. The cache is not content-authenticated and the disclosure +acknowledgement covers the entire archived directory, compiler diagnostics, and app-written bytes +that satisfy the audit. + +Every cross-repository build requires `disclosure-acknowledged: true`; equal repository ids are the +only exemption. + +## 5. Container execution + +### 5.1 Image and runner + +The EdgeZero image is public and anonymously pullable by digest, retained while referenced, and a +leaf `linux/amd64` image manifest rather than an OCI index. It is built from a digest-pinned base and +contains: + +- the exact Rust toolchain from `.tool-versions` and an installed `wasm32-wasip1` target; +- exact pinned Fastly CLI and sccache versions with checksum-verified downloads; +- `git`, `jq`, `tar`, `curl`, CA certificates, and a C toolchain; +- the project-owned provenance validator and its protocol/schema assets. + +Runtime containers use a read-only root filesystem, uid/gid 1001, dropped capabilities, +`no-new-privileges`, no GitHub file-command channels, explicit mounts, and operation-specific network, +memory, pid, and timeout limits. + +### 5.2 Working-copy topology + +There are two independent copies because GitHub jobs do not share filesystems: + +- **Copy A, producer build job:** a faithful writable copy used only for cached native CLI + compilation. The reusable workflow uploads its CLI artifact; Copy A is then discarded. +- **Copy B, consumer deployment job:** a fresh faithful writable copy made from the consumer's own + checkout. Provider actions in that job may reuse Copy B so generated files flow from app build to + `fastly compute deploy`. Copy A never crosses into this job. + +Each copy preserves the entire repository layout, enclosing workspaces, parent Cargo config, sibling +path dependencies, file modes, symlink targets, and initialized submodule state. It includes tracked +files and initialized submodules only; ignored and untracked detritus is absent. Hardlinks to the +original are broken. The read-only original checkout remains the freeze authority. + +### 5.3 Mount profiles + +`run-app-cli-in-container` has a maximum allowlist and a closed profile for each operation. It never +mounts all of `RUNNER_TEMP`. + +| In-container path | Mode | Allowed operations | Source | +| --- | --- | --- | --- | +| `/work/repo` | writable | cached-compile, app-build, provider-deploy | Copy A or Copy B | +| `/work/repo` | read-only | config-push | frozen original checkout | +| `/work/target` | writable | cached-compile, app-build, provider-deploy | fresh or parent target cache as specified below | +| `/work/cargo-home` | writable, fresh | cached-compile, app-build, provider-deploy | operation-specific directory | +| `/work/sccache` | writable | cached-compile only | stable host cache directory | +| `/work/input/artifact.tar` | read-only | provenance-validate only | downloaded artifact | +| `/work/input/expected.json` | read-only | provenance-validate only | host-generated expected identity | +| `/work/validated` | writable, fresh | provenance-validate only | empty host output directory | +| `/work/bin/app-cli` | read-only | binary-smoke and provider operations | validated binary | +| `/work/config/inline.toml` | read-only | config-push only | optional action-owned inline config file | +| `/work/package` | writable, fresh | app-build, provider-deploy | staged Fastly package/output | +| `/work/home`, `/work/tmp` | writable tmpfs | all operations | operation-local tmpfs | + +Profiles: + +- `cached-compile`: Copy A, fresh target and Cargo home, sccache, tmpfs; no token. +- `app-build`: validated CLI, Copy B, fresh Cargo home, package output, and the parent + `deploy-fastly.cache` target directory when enabled. It runs ` build` for + `build-mode: always`, has no provider token and no sccache mount, and saves the parent target cache + before any provider operation. +- `provider-deploy`: Copy B, fresh target/Cargo home/package, validated CLI, tmpfs, provider token; + never sccache and never a writable cache. Fastly deploy may compile application source with the + token for both `build-mode` values. A prior `app-build` is a credential-free validation/prebuild and + does not claim to suppress this recompile; its parent target cache was already saved before the + token appeared and is never saved again afterward. +- `provenance-validate`: trusted baked validator, read-only tar, fresh writable output directory, + read-only expected-identity JSON, and tmpfs; no repository, app-binary execution, token, Cargo, + target, package, or cache mount. +- `binary-smoke`: validated binary only plus tmpfs; no network, token, repository, Cargo, target, + package, cache, or validator output write access. +- `provider-read`: validated binary and tmpfs. `active-version` receives the provider token. + Production healthcheck receives no token; staging healthcheck receives the token needed for the + staged endpoint. +- `provider-rollback`: validated binary and tmpfs plus the provider token; no repository, Cargo, + target, package, or cache mount. +- `config-push`: validated binary, frozen repository read-only, tmpfs, provider token, and the + enumerated app-config overlay. A selected manifest and file-backed app config must canonicalize + beneath the frozen repository; inline config is one fresh host file mounted at the exact path + above. It receives no writable repository, package, Cargo, target, or sccache mount. + +The parent deploy spec remains normative for production/staging lifecycle semantics, rollback target +capture, mutation signaling, healthcheck ordering, and recovery. This addendum changes isolation and +mounting only. Every staged CLI invocation uses `--staging`, never `--stage`. + +### 5.4 Constructed environments + +Every operation starts with `env -i` and a closed allowlist. `PATH` is +`/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin`. + +- cached compile: `PATH`, `RUSTUP_HOME=/usr/local/rustup`, `RUSTUP_TOOLCHAIN`, fresh `CARGO_HOME`, + fresh `CARGO_TARGET_DIR`, `RUSTC_WRAPPER=/usr/local/bin/sccache`, `SCCACHE_DIR`, + `SCCACHE_CACHE_SIZE=2G`, `SCCACHE_IGNORE_SERVER_IO_ERROR=1`, `CARGO_INCREMENTAL=0`, empty + `CARGO_ENCODED_RUSTFLAGS`, `HOME`, `TMPDIR`, and validated `app-env`. +- app build: the Rustup/Cargo variables above except every sccache variable and wrapper, the + operation's action-owned target/package paths, `HOME`, `TMPDIR`, the validated `app-env` map, and + validated `EDGEZERO_MANIFEST` when selected; no provider token. +- provider deploy: the Rustup/Cargo variables above except every sccache variable and wrapper, plus + `FASTLY_API_TOKEN`, the operation's enumerated `EDGEZERO_*` variables, validated `app-env`, and + validated `EDGEZERO_MANIFEST` when the caller selected a manifest. +- provenance validation and binary smoke: `PATH`, `HOME`, `TMPDIR` only. +- provider operations: `PATH`, `HOME`, `TMPDIR`, only the token required by that operation, and only + explicitly named `EDGEZERO_*` variables plus validated `app-env`. Config push also receives its + selected validated overlay names unless `no-env` was selected. + +Non-credential application configuration is explicit rather than ambient. `app-env` is a JSON object +input (default `{}`) whose names and values are decoded host-side. Names must match the committed +portable environment-name grammar and must not be provider aliases, `GITHUB_*`, `RUNNER_*`, +`ACTIONS_*`, shell-startup variables, loader variables, compiler/toolchain controls, or action-owned +names. NUL values fail. The caller is responsible for passing no credentials; cross-repository cache +disclosure covers compile-time values. Only the exact validated names are added to operations that +execute app code or the app CLI. Config-push's separately derived typed-config overlay remains subject +to its own prefix and `no-env` rules. This explicit input replaces the parent's ambient workflow-`env` +behavior and is a documented adoption migration. + +Caller `PATH`, compiler wrappers, Rust flags, native-tool variables, ambient application variables, +and unlisted `EDGEZERO_*` variables are absent rather than scrubbed after inheritance. + +Cargo config across cwd, ancestors, and `CARGO_HOME` permits only the committed allowlist of benign +registry/network keys. `Cargo.lock` must be a tracked regular file. Path dependencies may resolve +anywhere beneath `git-root` and must not escape it. The parent toolchain resolver still runs, but v1 +requires its result (including an explicit `rust-toolchain` input) to equal the exact toolchain baked +in `image.json`'s image; a mismatch fails before container launch. Alternate toolchains require a +separate image/protocol and remain out of scope. + +## 6. Source freezing and provenance + +### 6.1 Freeze and pre-token verification + +The original checkout must be full, recursive, non-sparse, have LFS/filter content materialized, and +start clean: `HEAD` equals `source-revision`, no tracked/index or untracked modification exists, and +every initialized submodule is clean at its recorded gitlink. Before and after app-controlled +commands, the original's repository id, HEAD, clean state, and recursive submodule state must remain +unchanged. + +Immediately before any token-bearing operation that mounts Copy B, executes repository source, or +consumes its derived package, compare the complete Copy B inventory with the frozen source. This runs +whether or not `app-build` ran and, when it did, runs after that credential-free build: + +- every tracked file's bytes and executable mode, every symlink target, and every gitlink commit must + match, including deletion detection; +- no new path may exist except at or beneath a validated declared output root; +- each output root must canonicalize beneath the repository, must not be `.`, `.git`, or a symlink, + and must not equal or be an ancestor of any tracked path; +- output roots must not overlap each other, and each parent segment must remain confined beneath the + repository; +- entries under an output root must still pass the operation's type and confinement rules. + +The declaration authority is the protected caller's `generated-output-paths` JSON-array input to +`deploy-fastly` (default `[]`). Each value is a repository-relative canonical path validated before +app code runs. Action-owned target, Cargo-home, and package paths outside the repository are implicit +and cannot be overridden. Any application whose credential-free build writes inside the repository +must list every permitted root; the action never guesses from observed mutations. + +This permits declared generated output while preventing a credential-free build step from rewriting +source that a later token-bearing compile would execute. Source-free lifecycle actions such as +healthcheck and rollback do not receive Copy B and do not run this inventory comparison; they verify +artifact/caller/platform identity instead. Config-push verifies repository id, HEAD, cleanliness, and +confined selected files on its read-only checkout. The consumer repeats the checks applicable to each +mounted source profile before and after provider commands. + +### 6.2 Archive contract + +The producer emits deterministic POSIX ustar with exactly two regular members in order: +`app-cli-meta.json`, then the fixed `app-cli-bin` basename. PAX and GNU extensions are rejected. +Headers use uid/gid 0, empty uname/gname, mtime 0, empty prefix, typeflag regular, and mode 0644 for +metadata or 0755 for the binary. Extra, duplicate, renamed, linked, special, traversal, or trailing +content is rejected. Total logical size is at most 512 MiB and metadata is at most 64 KiB. + +Metadata is RFC 8785 JCS canonical JSON. Duplicate keys are rejected before parsing. A committed JSON +Schema 2020-12 and procedural validation define the exact fields: both identity groups, +`app-cli-version` (informational), `binary-sha256`, `binary-size`, and `abi`. + +`abi` is recomputed from ELF data: canonical machine name, `PT_INTERP` string or null, and sorted +direct `DT_NEEDED` strings. Transitive dependencies must resolve inside the pinned image. Runtime +`dlopen` dependencies are outside this contract. + +### 6.3 Split validation boundary + +Validation is deliberately two container invocations: + +1. **Trusted parse/extract:** `provenance-validate` runs the baked project-owned validator. It strictly + parses ustar and JSON, validates schema/JCS/duplicates, verifies identity/digest/size/ELF metadata, + proves required libraries resolve in the image, and extracts exactly one binary to + `/work/validated/app-cli`. The host then verifies the output directory contains only that regular, + non-linked file with the expected mode, size, and digest. +2. **Untrusted execution:** `binary-smoke` starts a new hardened container with only the verified + binary mounted read-only and tmpfs. It runs `--help` with no network or credentials and bounded + memory, pids, and wall time. + +The untrusted app binary never shares a writable mount with the parser/extractor. A successful action +outputs the host path, digest, size, and mode of the verified binary within the invoking action's +private workspace. + +The validator, schema, malformed fixtures, valid golden archive, and all required capabilities must +exist and pass before any image digest can be published. Golden tests cover JCS, duplicate keys, +schema rejection, ustar-only parsing, traversal/link/special-file rejection, normalized headers, size +limits, ELF inspection, dependency resolution, exact extraction, and output-directory confinement. + +## 7. Reusable workflow and action contract + +### 7.1 Reusable workflow + +Inputs: + +- `app-repository`, `app-ref`, `app-repo-id`, `working-directory` (default `.`), `workspace-root`, + `app-cli-package`, `app-cli-bin`, `app-cli-artifact`; +- `cache` (default `false`), `cache-key-suffix`, `disclosure-acknowledged`, and `timeout-minutes` + (default 30), plus `app-env` (default `{}`); +- secret `app-checkout-token`. + +`app-cli-artifact` must be unique among artifact uploads in the workflow run. It does not partition +the cache. The workflow has no provider inputs. Checkout persists no credentials. + +Outputs are `artifact-name` plus every `CallerExpectedIdentity` field: `app-repo-id`, +`source-revision`, `app-cli-package`, `app-cli-bin`, and `workspace-id`. It does not output +`platform-id`, `container-ref`, or protocol. + +The consumer job checks out the app itself and runs `compute-app-cli-identity` against that checkout +using `app-checkout-token`. It compares every computed caller identity field with the reusable +workflow output before validation. Each later action receives `CallerExpectedIdentity` and derives +`PlatformIdentity` from its local action revision. + +Matrix callers use unique artifact names and compare identity per leg. Shared workflow outputs are +not used to aggregate matrix results. + +### 7.2 Provider actions + +Every provider action accepts `app-cli-artifact` and `CallerExpectedIdentity`, derives local +`PlatformIdentity`, downloads exactly the named artifact into an action-private workspace, and runs +the full two-container validation sequence itself. Provider actions do not accept a caller-supplied +host binary path. Before each subsequent container launch, the action rechecks the validated path is +the same confined regular file with the recorded digest, size, mode, and single link. The private +workspace is removed with `if: always()`. + +Every provider action also accepts the validated `app-env` JSON object (default `{}`); no provider +action inherits ambient application variables. + +`deploy-fastly` additionally accepts `app-env` and `generated-output-paths`. It reuses one validated +binary for its `active-version`, optional credential-free `app-build`, and provider deploy operations +within that invocation. `active-version-fastly` is also a source-free action with inputs +`app-cli-artifact`, `CallerExpectedIdentity`, `fastly-service-id`, and `fastly-api-token`; it outputs +`version`, where an empty value is success only for a confirmed first production deploy. + +`validate-app-cli-provenance`, `deploy-fastly`, `active-version-fastly`, `healthcheck-fastly`, +`rollback-fastly`, and `config-push-fastly` all apply this handoff. An identity or path mismatch fails +before app code or provider mutation. + +`config-push-fastly` validates and confines the selected repository/manifest/config file and derives +the exact named app-config environment overlay before container launch. Inline config is written to +one fresh host file and mounted read-only. `no-env` exposes no app-config overlay. + +Mutation actions publish `mutation-attempted` host-side before launching the mutating CLI. Named +containers receive bounded signal forwarding and post-cancellation reconciliation as specified by the +parent deploy contract. + +## 8. Image publication and compatibility + +`image.json` is a reviewed record with exactly these typed fields: + +```json +{ + "repository": "ghcr.io/stackpop/edgezero-build-app-cli", + "tag": "build-container-v1", + "digest": "sha256:<64-lowercase-hex>", + "image-source-revision": "<40-lowercase-hex>", + "provenance-protocol": 1 +} +``` + +`tag` is informational. Runtime pulls use only `repository@digest`. + +The release has two revisions: + +- `S` is the full source commit used to build the image. The image has OCI label + `org.opencontainers.image.revision=S` and a protocol label matching the baked validator. +- `B` is the baseline revision created after the pin PR commits the verified digest and `S` to + `image.json` and permanent pin CI is enabled. +- `P` is the later, fully tested action revision that contains the unchanged reviewed pin plus the + cache, provenance, launcher, and consumer implementation. Consumers pin all EdgeZero + workflow/action references to full SHA `P`. + +There is no literal same-commit requirement between image source and pin record. Compatibility is +enforced by digest, image labels, and exact `provenance-protocol`. Changing the validator/archive +contract requires a protocol bump and a new image before the actions using that protocol are pinned. + +Publication order is: + +1. Land source revision `S`, including validator, schema, fixtures, `.dockerignore`, Dockerfile, + publisher, local-image CI, pin-change CI, and publication tests. +2. Build from repository root, push by protected release tag, and capture digest `D` from BuildKit's + metadata output. +3. Verify `D` is a leaf linux/amd64 image, labels identify `S` and protocol, exact tool versions and + target are installed, validator capability tests pass, and runtime works read-only/non-root. +4. Ensure the GHCR package is public, then prove an anonymous pull and smoke by `D`. The first release + stops here until an operator changes package visibility and reruns the same tag. +5. Open or update an idempotent PR committing `image.json = {D, S, protocol}`. Required pin CI + re-verifies the image before merge; merging the passing PR creates baseline `B`. +6. Implement the remaining plans on top of `B`, run the full pin, actionlint, zizmor, schema, + fixture, container, and contract suites, and designate the passing full commit SHA as `P`. + +Source `S` also contains a required CI job that, for every add/change/delete of `image.json`, requires +the file to exist, validates its structure, anonymously pulls its exact digest, and runs the complete +published-image verifier before merge. Thus no later syntactically valid pin can bypass image, +platform, label, protocol, public-access, target, validator, or exact-version checks. + +The release tag and environment are protected external prerequisites. The workflow also verifies `S` +is an ancestor of the protected default branch. All publication and pin-record mutation is serialized +under one repository-global concurrency group with `cancel-in-progress: false`; different release +tags cannot race the single `image.json`. Pin branches remain source/digest-derived and idempotent. +The publisher checks out without persisted credentials, proves `HEAD == S` and the recursive checkout +is clean immediately before the repository-root build, and excludes `.git`, build outputs, and local +detritus through the reviewed root `.dockerignore`. + +Pin branches and PRs use a short-lived, protected-environment GitHub App installation token scoped to +repository contents and pull requests. They do not use `GITHUB_TOKEN`: its push does not trigger push +workflows, and checks on its automation-created PR require manual approval, so it cannot guarantee the +automatic required-check path. The branch updater records the remote OID and uses an explicit +force-with-lease; ambiguous, closed, superseded, and already-merged PR states follow the fixture-tested +fail-closed state machine in the implementation plan. The App token is minted only after build and +anonymous image verification, so it cannot enter the repository-root build context. + +## 9. Testing + +Required automated coverage includes: + +- cold, warm, corrupt-restore, stop-failure, write-error, audit-failure, and save-warning cache paths; +- fixed host path restoration, cross-host-checkout-path hits, nested workspace and sibling path deps, + public Git dependencies, concurrent generations, seven-day expiry as documented behavior, and no + compiler retry; +- cache audit type/owner/path/layout/size checks and arbitrary app-written regular data disclosure; +- full source inventory, deleted/modified tracked paths, gitlinks, escaping symlinks, overlapping or + tracked-containing output roots, caller-declared generated output, undeclared output rejection, + source-free lifecycle bypass of Copy B checks, and unchanged original checkout; +- every environment and mount profile, including token absence, production healthcheck tokenlessness, + staging token presence, credential-free `app-build`, explicit `app-env` allow/deny behavior, + config-push repo/config confinement, and deploy-without-sccache; +- strict caller identity, full-SHA app/workflow/action refs, locally derived platform identity, matrix + artifacts, and consumer recomputation for private repositories; +- all provenance golden/malformed fixtures, provider actions independently validating named + artifacts and rechecking the binary handoff, and the split parse/extract versus binary-smoke boundary; +- exact Rust/Fastly/sccache versions, installed wasm target plus a minimal wasm compile, image labels, + leaf-manifest platform checks, anonymous pulls, and release rerun/idempotency; +- production/staging deploy, active-version, healthcheck, rollback, config push, mutation signaling, + cancellation, and the exclusive `--staging` spelling. + +Warm reuse is asserted by zeroing and comparing sccache statistics. Dependency fetching remains +online because source archives are not cached. Wall-clock improvement is telemetry, not a pass/fail +condition. + +## 10. Rollout and migration + +Before implementation is published: + +1. Migrate every existing non-local external action and reusable workflow reference in the repository + to a reviewed full 40-hex commit SHA and change the repository-wide pin gate accordingly. +2. Land the validator/schema/fixture capability set before the container publication tasks. +3. Publish and anonymously verify the image, then commit the pin and permanent gate as baseline `B`. +4. Land reusable workflow, cache, provenance, launcher, and consumer integration, then designate the + passing final action revision as `P`. +5. Update the parent spec, implementation plan, adoption guide, and public guide together. Remove + direct-composite producer guidance; document the two-job producer/consumer topology, explicit + `app-env` migration from ambient workflow environment, and `generated-output-paths` for + repository-writing credential-free app builds. + +Caching remains off by default. Container execution and provenance validation are unconditional. + +## 11. Out of scope + +- Detecting sccache staleness from undeclared proc-macro or `build.rs` inputs. +- Authenticating the artifact producer or proving workflow-bound attestation. +- Caching dependency source archives, private dependency credentials, native-tool sccache wrapping, + self-hosted runners, alternate toolchains, non-default feature sets, or non-Fastly adapters. +- Cache lineage merging, family-local eviction, or action-managed cache deletion. + +## 12. History + +- **v6.17:** introduced the build-only reusable workflow, consumer deployment job, deploy-compile + profile, full working-copy verification, `job.check_run_id`, and explicit undeclared-input risk. +- **v6.18:** split trusted provenance extraction from untrusted binary execution; completed provider + mount/environment profiles; strengthened full-inventory source verification; made cache family and + warning-only saves coherent; corrected sccache error/size/audit contracts; made platform identity + action-derived; replaced impossible same-SHA publication with image source `S`, pin baseline `B`, + and final action revision `P`; made full-SHA external references normative; and made validator + capability fixtures a hard publication prerequisite. + +## 13. Deferred implementation mechanics + +Implementation plans may choose helper names and internal module boundaries. They must commit exact +schema files, golden bytes, malformed fixtures, sccache v0.10 layout/stats fixtures, exact +tar/compression archive-bound and entry-count vectors, provider environment name allowlists, release +SHAs/checksums, and command-level tests before publication. +Those are mechanics, not permission to weaken the contracts above. From 2ea8c2dbc3519a59b027f3248f4e84431d6584f8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:49:05 -0700 Subject: [PATCH 10/10] docs: harden build caching release contracts --- .../plans/2026-08-20-build-cache-container.md | 701 ++++++++++++------ ...20-edgezero-deploy-build-caching-design.md | 544 ++++++++++++-- 2 files changed, 944 insertions(+), 301 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-build-cache-container.md b/docs/superpowers/plans/2026-08-20-build-cache-container.md index d56366bf..fddee4e2 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -12,7 +12,7 @@ captures and verifies immutable digest `D`, proves anonymous access, and opens a top, and their final passing action revision `P` contains the unchanged `{D, S, protocol}` record. Consumers pin all EdgeZero actions and reusable workflows to full SHA `P`. -**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.18. +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.19. **Tooling:** Rust, Docker BuildKit/buildx, GHCR, GitHub Actions, Bash 3.2, `jq`, `gh`, `actionlint`, `shellcheck`, and `zizmor`. @@ -22,8 +22,13 @@ Consumers pin all EdgeZero actions and reusable workflows to full SHA `P`. - Rust is the exact version in `.tool-versions` (`1.95.0` at plan time). - Fastly CLI is the exact version/checksum in `.github/actions/deploy-fastly/versions.json` (`15.1.0` at plan time). -- sccache is exactly `0.10.0`, fetched from its release artifact and checksum-verified. -- The base image uses a real `sha256` digest. No placeholder digest or checksum is committed. +- sccache is exactly `0.10.0`, fetched as the upstream + `sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz` client artifact and verified against upstream + checksum `1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b`. +- The base is the official `rust:1.95.0-slim-bookworm` `linux/amd64` leaf manifest, resolved on + 2026-08-31 as `sha256:6f9e63259f12e1e599296f5ecfed2bae46de4af0ee0525dd8b89c046e236d5c5` + and re-resolved immediately before the Dockerfile commit. No placeholder digest or checksum is + committed. - The final image is a leaf `linux/amd64` image manifest, not an OCI index. - The final image contains an installed `wasm32-wasip1` target, not merely a rustc target-list entry. - The project-owned validator, schema, and capability fixtures are baked and tested before push. @@ -39,9 +44,12 @@ Consumers pin all EdgeZero actions and reusable workflows to full SHA `P`. Although this is plan 1 of the feature set, its image task cannot run first. Execute these gates: -1. Land the trusted validator contract and capability fixtures (Task 0). -2. Land the repository-wide full-SHA policy migration (Task 1). -3. Implement image pinning, the Dockerfile, publisher, local-image CI, and pin-change CI (Tasks 2-4). +1. Complete and commit the repository-wide full-SHA and zizmor policy migration on the unmerged + source-candidate branch (Task 0). +2. Complete and commit the trusted protocol-owner validator and capability fixtures on that same + branch (Task 1). +3. Implement image pinning, the Dockerfile, publisher, local-image CI, and pin-change CI on that same + branch (Tasks 2-4). 4. Merge all pre-publication code and tests; record that exact full commit as source revision `S`. 5. Run the already-landed publisher at `S`, verify digest `D`, and merge its required-check pin PR to create baseline `B` (Tasks 4-5). @@ -56,7 +64,7 @@ break the dependency cycle. Create: - `crates/edgezero-provenance-validator/Cargo.toml` -- `crates/edgezero-provenance-validator/src/{main,json_contract,archive,elf,extract}.rs` +- `crates/edgezero-provenance-validator/src/{lib,main,json_contract,archive,elf,extract}.rs` - `crates/edgezero-provenance-validator/tests/cli.rs` - `.github/docker/build-app-cli/provenance.schema.json` - `.github/docker/build-app-cli/fixtures/provenance/**` @@ -65,11 +73,16 @@ Create: - `.dockerignore` - `.github/docker/build-app-cli/verify-toolchain.sh` - `.github/docker/build-app-cli/verify-published-image.sh` +- `.github/docker/build-app-cli/verify-release-prerequisites.sh` - `.github/docker/build-app-cli/update-image-pin-pr.sh` +- `.github/docker/build-app-cli/classify-build-container-change.sh` - `.github/actions/deploy-core/tests/verify-toolchain.test.sh` - `.github/actions/deploy-core/tests/verify-published-image.test.sh` +- `.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh` - `.github/actions/deploy-core/tests/update-image-pin-pr.test.sh` +- `.github/actions/deploy-core/tests/classify-build-container-change.test.sh` - `.github/actions/deploy-core/tests/check-doc-action-pins.sh` +- `.github/workflows/build-container-ci.yml` - `.github/workflows/publish-build-container.yml` Created by the release PR, not source revision `S`: @@ -83,64 +96,154 @@ Modify: - `.github/actions/deploy-core/tests/check-image-pin.test.sh` - `.github/actions/deploy-core/tests/check-action-pins.sh` - `.github/actions/deploy-core/tests/run.sh` +- `.github/zizmor.yml` - `.github/workflows/deploy-action.yml` - every existing `.github` workflow/composite containing a non-local external `uses:` ref - the four deploy/adoption documents containing consumer `uses:` examples -## 4. Task 0: Land the validator capability contract +## 4. Task 0: Enforce full-SHA external references repository-wide -This task is implemented as part of this plan because no separate prerequisite plan exists. It is a -hard dependency of Task 3 and must merge into source revision `S`. +The current pin gate and zizmor policy accept version tags. That contradicts v6.19 and must be +migrated before adding the write-privileged publisher. + +**Files:** + +- Modify `.github/actions/deploy-core/tests/check-action-pins.sh` and its tests in `run.sh`. +- Create `.github/actions/deploy-core/tests/check-doc-action-pins.sh`. +- Modify `.github/zizmor.yml`. +- Modify external refs in `.github/workflows/{codeql,deploy-action,deploy-docs,fastly-installer-check,format,test}.yml`. +- Modify external refs in `.github/actions/{build-app-cli,config-push-fastly,deploy-fastly,healthcheck-fastly,rollback-fastly}/action.yml`. +- Modify examples in `docs/specs/edgezero-deploy-github-action.md`, + `docs/specs/edgezero-deploy-action-implementation-plan.md`, + `docs/specs/edgezero-deploy-adoption-guide.md`, and `docs/guide/deploy-github-actions.md`. + +- [ ] Write failing pin-gate tests proving `@v1`, `@v1.2.3`, branches, abbreviated SHAs, malformed + SHAs, and empty refs fail; full lowercase 40-hex SHAs pass; local actions and digest-pinned Docker + actions remain valid. Generate invalid YAML fixtures under the test's temporary directory; do not + commit them into a surface scanned by the production gate. +- [ ] Resolve each existing version to a reviewed upstream commit SHA. Preserve the human-readable + release in an adjacent comment, for example `# v6.0.1`. +- [ ] Change the structural YAML scanner to require full 40-hex SHAs for every non-local external + action and reusable workflow. Its default scan is exactly workflow `*.yml`/`*.yaml` files directly + under `.github/workflows`, plus every repository-wide `action.yml`/`action.yaml`, pruning `.git`, + `target`, and `node_modules`. Shell source and arbitrary YAML test data are not inputs. Do not add a + low-privilege exception. +- [ ] Reject empty and null `uses` scalars and count only parsed non-local external refs for the + non-vacuity assertion. Encode each structurally extracted scalar so a multiline value cannot split + into multiple shell records. +- [ ] Require Docker action refs to match an immutable lowercase + `docker://@sha256:<64-lowercase-hex>` form; tags, uppercase hex, short digests, and other + algorithms fail unless a separately reviewed digest algorithm is added to the policy. +- [ ] Update documentation examples to use a named `` placeholder where the + consumer must substitute release `P`; examples for third-party actions use real reviewed SHAs. +- [ ] Add `check-doc-action-pins.sh` to extract `uses:` lines from fenced YAML in the four named docs. + It allows the exact EdgeZero placeholder only in documentation, requires full SHAs for concrete + third-party refs, and rejects version/branch refs. Add positive/negative cases to `run.sh`. +- [ ] Replace the global zizmor `ref-pin` relaxation with `hash-pin`. Update contradictory prose in + all four named documents, not only their fenced YAML examples. +- [ ] Scan that exact default surface, including reusable-workflow job-level `uses`, and require at + least one parsed external ref so a broken parser cannot pass vacuously. +- [ ] Run the pin suite, actionlint, and zizmor. + +```bash +bash .github/actions/deploy-core/tests/run.sh +.github/actions/deploy-core/tests/check-action-pins.sh +.github/actions/deploy-core/tests/check-doc-action-pins.sh +actionlint +zizmor --offline .github/workflows .github/actions +``` + +**Gate:** both structural scanners pass their exact surfaces and report non-zero parsed-reference +counts; no broad `rg` gate scans intentional invalid test strings. + +## 5. Task 1: Implement the protocol-owner validator on the source candidate + +This task owns protocol-1 encoding and validation. No shell, `jq`, system `tar`, or general-purpose +archive crate may become a second wire implementation. It is a hard dependency of Task 3 and must +merge into source revision `S`. **Files:** - Create `crates/edgezero-provenance-validator/Cargo.toml` and - `src/{main,json_contract,archive,elf,extract}.rs`. + `src/{lib,main,json_contract,archive,elf,extract}.rs`. - Put module unit tests beside their implementation under `src/`; create only the true process-level integration test `crates/edgezero-provenance-validator/tests/cli.rs`. - Create `.github/docker/build-app-cli/provenance.schema.json`. - Create `.github/docker/build-app-cli/fixtures/provenance/{valid,invalid}/**`. - Modify workspace `Cargo.toml` and `Cargo.lock`. -### 4.1 JSON/schema tranche - -- [ ] Add the exact JSON Schema and valid/invalid metadata fixtures. Write colocated failing tests for - RFC 8785 canonical bytes, duplicate-key rejection before object construction, exact field/type/ - bounds checks, unknown fields, caller/platform identity mismatch, and schema-version mismatch. -- [ ] Run `cargo test -p edgezero-provenance-validator json_contract::tests`; expected: non-zero with - the new assertions failing for unimplemented behavior. -- [ ] Implement only `json_contract.rs`; rerun the same command, then the full crate test; expected: - both pass. Commit the green JSON/schema tranche. - -### 4.2 Archive/extraction tranche - -- [ ] Add a byte-for-byte golden ustar archive plus malformed PAX/GNU, duplicate, extra, traversal, - link, special-file, bad-header, bad-order, bad-size, and trailing-data fixtures. -- [ ] Write colocated archive/extraction tests, then run - `cargo test -p edgezero-provenance-validator archive::tests`; expected: non-zero for unimplemented - strict parsing/extraction. -- [ ] Implement `archive.rs` and `extract.rs` without invoking system `tar`. Require exact normalized - headers and exactly one confined regular output file. Rerun focused and full crate tests; expected: - pass. Commit the green archive/extraction tranche. - -### 4.3 ELF/loadability tranche - -- [ ] Add controlled valid/wrong-architecture/unresolved-interpreter/unresolved-library ELF - fixtures. Write failing tests for machine, interpreter/null, sorted direct `DT_NEEDED`, digest, size, - and immutable-image dependency resolution. +### 5.1 JSON/schema tranche + +- [ ] Add one Draft 2020-12 schema and exact valid/invalid fixtures for both `expected.json` and + `app-cli-meta.json` from design Section 6.2. Write colocated failing tests for RFC 8785 bytes, + recursive duplicate-key rejection before object construction, every exact field/type/bound, + unknown and missing fields, noncanonical decimal/hash/name values, schema/protocol mismatch, + `container-ref` derivation, and complete caller/platform identity mismatch. +- [ ] Test a closed typed canonical encoder. Protocol 1 contains only bounded strings, positive + integers, null, fixed objects, and the `needed` array; no generic floating-point value is accepted. +- [ ] Run `cargo test -p edgezero-provenance-validator json_contract::tests`; expected: non-zero for + unimplemented behavior. +- [ ] Implement only `json_contract.rs`; rerun the focused and full crate tests; expected: pass. + Commit the green JSON/schema tranche. + +### 5.2 Archive/extraction tranche + +- [ ] Add a byte-for-byte golden archive from design Section 6.3 plus malformed base-256/octal, + checksum, embedded-NUL, PAX/GNU, sparse, duplicate, extra, traversal, link, special-file, header, + order, size, padding, end-block, overflow, and trailing-data fixtures. +- [ ] Write failing encoder, parser, and extraction tests. Assert two repeated encodes are identical, + all payload padding is zero, exactly two end blocks precede EOF, and failure leaves the fresh output + parent empty. +- [ ] Run `cargo test -p edgezero-provenance-validator archive::tests`; expected: non-zero for + unimplemented protocol behavior. +- [ ] Implement `archive.rs` and `extract.rs` directly over bounded `Read + Seek`/`Write`; do not + invoke system `tar`, add a tar crate, or load the allowed 512 MiB binary wholesale. Create outputs + atomically and require the final regular file to have mode 0755 and link count one. Rerun focused + and full crate tests; expected: pass. Commit the green archive/extraction tranche. + +### 5.3 ELF/loadability tranche + +- [ ] Add controlled static/dynamic valid, wrong class/endian/type/architecture/interpreter, + malformed/duplicate `PT_DYNAMIC`, missing/nonzero-after `DT_NULL`, conflicting string-table tags, + unmapped/overlapping string ranges, malformed string/interpreter termination, RPATH/RUNPATH, + AUDIT/DEPAUDIT/CONFIG/AUXILIARY/FILTER/POSFLAG rejection, valid bounded SONAME, empty/oversized/ + slash-containing/duplicate SONAME rejection, NODEFLIB/LOADFLTR and unknown-flag rejection, every + in-range and just-outside case for the closed numeric tag allowlist, exact + `DT_FLAGS=0x0000001e` and `DT_FLAGS_1=0x5eff976f` mask boundaries, unknown standard/GNU/OS/processor + tag rejection, duplicate rejection for every singleton tag, slash-containing dependency, missing + direct/transitive library, ambiguous resolution, dangling or escaping candidates, + mixed-architecture, duplicate-needed, interpreter dependency, and cycle fixtures for the + conservative loader profile in design Section 6.4. +- [ ] Write failing tests for machine, interpreter/null, byte-sorted duplicate-preserving direct + `DT_NEEDED`, digest, size, six-root candidate enumeration, same-device/inode symlink and hardlink + aliases, distinct-file ambiguity, interpreter parsing, and recursive dependency resolution against + a synthetic image root. - [ ] Run `cargo test -p edgezero-provenance-validator elf::tests`; expected: non-zero for - unimplemented inspection/loadability behavior. -- [ ] Implement `elf.rs`; rerun focused and full crate tests; expected: pass. Commit the green ELF - tranche. + unimplemented inspection/loadability behavior. +- [ ] Implement `elf.rs` with bounded ranged reads and checked offsets. Do not invoke `ldd`, the + loader, or the artifact. Rerun focused and full crate tests; expected: pass. Commit the green ELF + tranche. -### 4.4 CLI/capability tranche +### 5.4 CLI/capability tranche -- [ ] Write failing `tests/cli.rs` process tests that combine the three modules and verify clean failure - leaves the output directory empty. Run `cargo test -p edgezero-provenance-validator --test cli`; - expected: non-zero until the CLI is wired. Implement this stable credential-free interface: +- [ ] Write failing library integration tests using a private synthetic-root harness for deterministic + package/validate round trips, identity mismatch, atomic cleanup, and host-deletion recovery. This + harness calls library entry points and is not a CLI option or production bypass. Write host process + tests proving `package` and `validate` reject every `--work-root` that does not canonicalize to + literal `/work`, plus process tests for self-test fixture integrity. Run + `cargo test -p edgezero-provenance-validator --test cli`; expected: non-zero until wired. Implement: ```text +edgezero-provenance-validator package \ + --work-root /work \ + --binary /work/input/app-cli \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --app-cli-version \ + --archive /work/packaged/artifact.tar + edgezero-provenance-validator validate \ + --work-root /work \ --archive /work/input/artifact.tar \ --schema /usr/local/share/edgezero/provenance.schema.json \ --expected /work/input/expected.json \ @@ -150,14 +253,18 @@ edgezero-provenance-validator self-test \ --fixtures /usr/local/share/edgezero/provenance-fixtures ``` -- [ ] Make `validate` create exactly one regular output file and fail if the output parent is not - empty, canonical, writable, and confined. The validator never executes the extracted binary. -- [ ] Implement `self-test` as a fixed manifest of expected valid and invalid fixture outcomes plus - fixture SHA-256 values; a missing, extra, or changed fixture fails. -- [ ] Use synchronous Rust; do not add Tokio, and do not change dependencies of core/adapter crates. -- [ ] Run `cargo test -p edgezero-provenance-validator --test cli`, then the full focused crate suite; - expected: pass. Commit the green CLI/capability tranche. -- [ ] Run the focused crate tests, then the repository-required Rust checks. +- [ ] Make the production `package` and `validate` CLI require canonical `--work-root /work`, create + exactly one output through a create-new temporary sibling plus Linux no-replace rename, and fail if + the parent is not fresh, empty, canonical, writable, and confined. Handled failures remove the + sibling; synthetic-root library tests model host deletion of the whole parent after + SIGKILL/timeout. The validator never executes the app binary. Positive CLI round trips run only in + Task 3's container, where literal `/work` exists. +- [ ] Implement `self-test` as a compiled manifest of exact relative paths, fixture SHA-256 values, + and valid/invalid outcomes. A missing, extra, or changed fixture fails. +- [ ] Use synchronous Rust; do not add Tokio or change dependencies of core/adapter crates. +- [ ] Run process, focused, and full crate tests; expected: pass. Commit the green CLI/capability + tranche. +- [ ] Run the focused crate tests, then the repository-required Rust and documentation checks. ```bash cargo test -p edgezero-provenance-validator @@ -170,59 +277,25 @@ npm --prefix docs ci npm --prefix docs run format npm --prefix docs run lint npm --prefix docs run build +./scripts/check_no_placeholder_pins.sh +./scripts/check_no_legacy_typed_reads.sh +cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- \ + examples/app-demo crates/edgezero-cli/src/templates +cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config +cargo test -p edgezero-adapter-fastly --all-targets --features cli +cargo test -p edgezero-cli --test generated_project_builds -- --ignored +cargo clippy -p edgezero-adapter-fastly --features cli --all-targets -- -D warnings +cargo clippy -p edgezero-adapter-fastly --no-default-features --lib -- -D warnings +cargo fmt --manifest-path examples/app-demo/Cargo.toml --all -- --check +cargo clippy --manifest-path examples/app-demo/Cargo.toml \ + --workspace --all-targets --all-features -- -D warnings +cargo test --manifest-path examples/app-demo/Cargo.toml --locked --workspace --all-targets ``` -**Gate:** all capability tests and fixture hashes pass from a clean checkout. Task 3 must copy this -exact built binary, schema, and fixtures into the image. - -## 5. Task 1: Enforce full-SHA external references repository-wide - -The current pin gate accepts version tags. That contradicts v6.18 and must be migrated before adding -the write-privileged publisher. - -**Files:** - -- Modify `.github/actions/deploy-core/tests/check-action-pins.sh` and its tests in `run.sh`. -- Create `.github/actions/deploy-core/tests/check-doc-action-pins.sh`. -- Modify external refs in `.github/workflows/{codeql,deploy-action,deploy-docs,fastly-installer-check,format,test}.yml`. -- Modify external refs in `.github/actions/{build-app-cli,config-push-fastly,deploy-fastly,healthcheck-fastly,rollback-fastly}/action.yml`. -- Modify examples in `docs/specs/edgezero-deploy-github-action.md`, - `docs/specs/edgezero-deploy-action-implementation-plan.md`, - `docs/specs/edgezero-deploy-adoption-guide.md`, and `docs/guide/deploy-github-actions.md`. - -- [ ] Write failing pin-gate tests proving `@v1`, `@v1.2.3`, branches, abbreviated SHAs, malformed - SHAs, and empty refs fail; full lowercase 40-hex SHAs pass; local actions and digest-pinned Docker - actions remain valid. Generate invalid YAML fixtures under the test's temporary directory; do not - commit them into a surface scanned by the production gate. -- [ ] Resolve each existing version to a reviewed upstream commit SHA. Preserve the human-readable - release in an adjacent comment, for example `# v6.0.1`. -- [ ] Change the structural YAML scanner to require full 40-hex SHAs for every non-local external - action and reusable workflow. Its default scan is exactly workflow `*.yml`/`*.yaml` files directly - under `.github/workflows`, plus every repository-wide `action.yml`/`action.yaml`, pruning `.git`, - `target`, and `node_modules`. Shell source and arbitrary YAML test data are not inputs. Do not add a - low-privilege exception. -- [ ] Require Docker action refs to match an immutable lowercase - `docker://@sha256:<64-lowercase-hex>` form; tags, uppercase hex, short digests, and other - algorithms fail unless a separately reviewed digest algorithm is added to the policy. -- [ ] Update documentation examples to use a named `` placeholder where the - consumer must substitute release `P`; examples for third-party actions use real reviewed SHAs. -- [ ] Add `check-doc-action-pins.sh` to extract `uses:` lines from fenced YAML in the four named docs. - It allows the exact EdgeZero placeholder only in documentation, requires full SHAs for concrete - third-party refs, and rejects version/branch refs. Add positive/negative cases to `run.sh`. -- [ ] Scan that exact default surface, including reusable-workflow job-level `uses`, and require at - least one parsed external ref so a broken parser cannot pass vacuously. -- [ ] Run the pin suite, actionlint, and zizmor. - -```bash -bash .github/actions/deploy-core/tests/run.sh -.github/actions/deploy-core/tests/check-action-pins.sh -.github/actions/deploy-core/tests/check-doc-action-pins.sh -actionlint -zizmor --offline .github/workflows .github/actions -``` - -**Gate:** both structural scanners pass their exact surfaces and report non-zero parsed-reference -counts; no broad `rg` gate scans intentional invalid test strings. +**Gate:** deterministic package/validate golden tests and every capability fixture hash pass from a +clean checkout. The candidate PR must also pass every current format/test matrix job, including the +four wasm clippy legs and three wasm test runners; the local command list does not replace those +runner-backed gates. Task 3 copies this exact built binary, schema, and fixtures into the image. ## 6. Task 2: Implement the exact `image.json` validator @@ -234,12 +307,12 @@ counts; no broad `rg` gate scans intentional invalid test strings. - Modify `.github/actions/deploy-core/tests/check-image-pin.test.sh`. - [ ] Write failing tests for the valid five-field record and rejection of malformed JSON, duplicate - or extra/missing fields, non-string string fields, foreign/empty repository, mutable/zero/uppercase - digest, malformed/zero/uppercase source revision, non-integer protocol, protocol other than `1`, - an empty/malformed release tag, and tag use as the runtime reference. + or extra/missing fields, non-string string fields, foreign/empty repository, mutable/zero/uppercase + digest, malformed/zero/uppercase source revision, non-integer protocol, protocol other than `1`, + an empty/malformed release tag, and tag use as the runtime reference. - [ ] Implement `check-image-pin.sh ` using Bash and `jq`. Detect duplicate top-level keys from - `jq --stream` events before normal object parsing; ordinary `jq` object parsing alone loses duplicate - keys. It accepts exactly: + `jq --stream` events before normal object parsing; ordinary `jq` object parsing alone loses duplicate + keys. It accepts exactly: ```json { @@ -251,10 +324,10 @@ counts; no broad `rg` gate scans intentional invalid test strings. } ``` - `tag` must match `^build-container-v[1-9][0-9]*$`; it remains informational. +`tag` must match `^build-container-v[1-9][0-9]*$`; it remains informational. - [ ] Output only the canonical runtime ref, source revision, and protocol through explicit - subcommands or shell-safe output fields. Never use `tag` for a pull. + subcommands or shell-safe output fields. Never use `tag` for a pull. - [ ] Run unit tests and shellcheck. Do not create a placeholder `image.json`. ```bash @@ -271,8 +344,11 @@ shellcheck -S warning .github/docker/build-app-cli/check-image-pin.sh `.github/docker/build-app-cli/fixtures/wasm-smoke.rs`. - Extend validator/image tests under `.github/actions/deploy-core/tests/`. -- [ ] Before editing, resolve the amd64 digest for the exact Rust base image and the upstream sccache - v0.10.0 release checksum. Record provenance in comments. Never commit `000...` or `REPLACE_ME`. +- [ ] Before editing, re-resolve the official `rust:1.95.0-slim-bookworm` `linux/amd64` leaf manifest + and compare it with the reviewed digest in Section 1. Stop for review if the tag moved; never + silently replace the reviewed base. Download the selected sccache asset and its upstream checksum + companion independently, hash the payload, and require the reviewed checksum in Section 1. Record + provenance in comments. Never commit `000...` or `REPLACE_ME`. - [ ] Use a multi-stage Dockerfile. The builder stage copies the repository and runs: ```bash @@ -280,18 +356,21 @@ cargo build --locked --release -p edgezero-provenance-validator ``` - [ ] Copy only the validator binary, schema, and capability fixtures from the builder into the final - runtime. BuildKit context is repository root; the Dockerfile remains under - `.github/docker/build-app-cli/`. + runtime. BuildKit context is repository root; the Dockerfile remains under + `.github/docker/build-app-cli/`. - [ ] Add a root `.dockerignore` excluding `.git`, `.claude`, every `target/`, `node_modules/`, local - editor/temp/env files, and other non-source detritus while retaining the workspace, `.github` - schema/fixtures, lockfile, and Dockerfile. CI also requires a clean checkout, so `.dockerignore` is - defense in depth rather than permission to build untracked source. -- [ ] Install the exact Rust toolchain, `wasm32-wasip1`, checksum-verified Fastly CLI and sccache, - `git`, `jq`, `tar`, `curl`, CA certificates, and a C toolchain. Remove package/download caches. + editor/temp/env files, and other non-source detritus while retaining the workspace, `.github` + schema/fixtures, lockfile, and Dockerfile. CI also requires a clean checkout, so `.dockerignore` is + defense in depth rather than permission to build untracked source. +- [ ] Use the reviewed Rust leaf digest in every `FROM`. Install the exact Rust toolchain, + `wasm32-wasip1`, checksum-verified Fastly CLI, the selected static-musl sccache client, `git`, `jq`, + `tar`, `curl`, CA certificates, and a C toolchain. Remove package/download caches. - [ ] Accept required build args `IMAGE_SOURCE_REVISION` and `PROVENANCE_PROTOCOL`. Fail the build - unless they are a lowercase full SHA and exactly `1`. -- [ ] Add OCI labels `org.opencontainers.image.revision=$IMAGE_SOURCE_REVISION` and - `org.edgezero.provenance-protocol=$PROVENANCE_PROTOCOL`. + unless they are a lowercase full SHA and exactly `1`. +- [ ] Override inherited OCI metadata with exact labels + `org.opencontainers.image.source=https://github.com/stackpop/edgezero`, + `org.opencontainers.image.revision=$IMAGE_SOURCE_REVISION`, and + `org.edgezero.provenance-protocol=$PROVENANCE_PROTOCOL`. - [ ] Create uid/gid 1001, set it as final `USER`, and avoid writable data under the image root. - [ ] Build locally from root: @@ -304,16 +383,27 @@ docker build --platform linux/amd64 \ ``` - [ ] Parse each tool's documented version line and compare the normalized semantic version for exact - equality; substring matching is forbidden. Assert target installation with - `rustup target list --installed`, then compile the committed `wasm-smoke.rs` as a library for - `wasm32-wasip1` into writable tmpfs and assert the output starts with wasm magic `00 61 73 6d`. -- [ ] Put those assertions in `verify-toolchain.sh` and unit-test its parsers with exact, prerelease, - extra-text, missing-line, and malformed output fixtures before copying it into the image. -- [ ] Run the baked validator `self-test`; then run one valid and each malformed fixture through the - baked `validate` command. -- [ ] Verify image config is linux/amd64, `User` is 1001, and OCI labels equal the build args. + equality; substring matching is forbidden. Assert target installation with + `rustup target list --installed`, then compile the committed `wasm-smoke.rs` as a library for + `wasm32-wasip1` into writable tmpfs and assert the output starts with wasm magic `00 61 73 6d`. +- [ ] Write parser and command-fixture tests in `verify-toolchain.test.sh` for exact, prerelease, + extra-text, missing-line, malformed output, absent target, and invalid wasm magic. Run + `bash .github/actions/deploy-core/tests/verify-toolchain.test.sh`; expected: non-zero before the + helper exists. +- [ ] Put the assertions in `verify-toolchain.sh`, rerun the focused test, and require zero failures + before copying it into the image. +- [ ] Run the baked validator `self-test`; run deterministic `package` twice over the controlled ELF + fixture and compare bytes; then run the golden archive and every malformed fixture through the + baked `validate` command, with fresh mounts rooted at literal `/work`. Prove the production CLI + accepts `/work`, rejects an alternate root, and that the image's glibc layout satisfies the fixed + loader profile. +- [ ] Do not add a validator basename-mismatch case: the fixed `/work/input/app-cli` mount cannot + expose the original Cargo output basename. Record this as a mandatory host-action test in the + downstream provenance-integration plan, where the basename is checked before mounting. +- [ ] Verify image config is linux/amd64, `User` is 1001, and all three OCI labels equal the exact + EdgeZero source, source revision, and protocol values. - [ ] Verify a read-only/non-root smoke with `--network=none`, `--cap-drop=ALL`, - `--security-opt=no-new-privileges`, bounded memory/pids, and only `/tmp` as tmpfs. + `--security-opt=no-new-privileges`, bounded memory/pids, and only `/tmp` as tmpfs. ```bash docker run --rm --platform linux/amd64 --read-only --network=none --cap-drop=ALL \ @@ -338,49 +428,161 @@ docker run --rm --read-only --network=none --cap-drop=ALL \ **Files:** - Create `.github/docker/build-app-cli/verify-published-image.sh`. +- Create `.github/docker/build-app-cli/verify-release-prerequisites.sh`. - Create `.github/docker/build-app-cli/update-image-pin-pr.sh`. +- Create `.github/docker/build-app-cli/classify-build-container-change.sh`. - Create `.github/actions/deploy-core/tests/verify-published-image.test.sh`. +- Create `.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh`. - Create `.github/actions/deploy-core/tests/update-image-pin-pr.test.sh`. +- Create `.github/actions/deploy-core/tests/classify-build-container-change.test.sh`. +- Create `.github/workflows/build-container-ci.yml`. - Create `.github/workflows/publish-build-container.yml`. - Modify `.github/actions/deploy-core/tests/run.sh` and `.github/workflows/deploy-action.yml`. ### 8.1 Testable verification helper - [ ] Write fixture-driven failing tests for leaf manifest media types, required config/layers, - rejection of one-entry and multi-entry indexes, `.Image` os/architecture, both image labels, exact - tool versions, installed target, validator self-test, and malformed BuildKit metadata. + rejection of one-entry and multi-entry indexes, `.Image` os/architecture, all three image labels, exact + tool versions, installed target, validator self-test, and malformed BuildKit metadata. +- [ ] Run `bash .github/actions/deploy-core/tests/verify-published-image.test.sh`; expected: non-zero + before the helper exists. - [ ] Implement a helper that takes `repository`, `digest`, `source SHA`, and protocol. It verifies the - immutable digest only and never rereads a mutable tag to discover identity. + immutable digest only and never rereads a mutable tag to discover identity. - [ ] Use `docker buildx imagetools inspect "$REF" --raw` to require a leaf manifest. Use - `docker buildx imagetools inspect "$REF" --format '{{json .Image}}'` and inspect `.os` and - `.architecture` directly; do not use nonexistent `.Image.Platform`. + `docker buildx imagetools inspect "$REF" --format '{{json .Image}}'` and inspect `.os` and + `.architecture` directly; do not use nonexistent `.Image.Platform`. - [ ] Inspect image config labels and run the same exact-version, installed-target/minimal-compile, - validator-capability, and read-only/non-root tests as Task 3. + validator-capability, and read-only/non-root tests as Task 3. ### 8.2 Pre-`S` publisher and required CI +- [ ] Write failing fake-`gh`/`openssl` tests, then implement `verify-release-prerequisites.sh`. It takes + the repository and candidate PR, expected numeric App and installation IDs, App private-key path, + expected package state, and evidence output path. It reads a repository-administrator token and a + separate package-audit token from `EDGEZERO_RELEASE_REPOSITORY_ADMIN_TOKEN` and + `EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN`, respectively. The latter is a classic PAT belonging to an + active `stackpop` organization owner; require the normalized `X-OAuth-Scopes` set to equal exactly + `{read:org,read:packages}` and use that same verified token for all package requests. Reject + byte-equal token values before any API request without logging them. Neither token is stored in + GitHub Actions. The helper never accepts a PR-write token and never mutates settings, packages, or + comments. +- [ ] Require environment `build-container-release` to have at least one required reviewer, + `prevent_self_review=true`, administrator bypass disabled, custom deployment policies enabled, and + exactly one deployment policy: tag `build-container-v*`. The documented environment REST response + does not expose administrator bypass; do not invent an API assertion. Instead require a PNG settings + capture, independent reviewer login, and RFC 3339 review time as preflight inputs. Reject a non-PNG, + reviewer equal to the verifier, future review time, or recorded candidate head SHA unequal to the + current PR head. Record that SHA, `allowed:false`, `verification:"manual-ui"`, reviewer, review time, + literal basename, and `sha256:<64-lowercase-hex>` under `environment.administrator-bypass` in + canonical evidence. Require an active tag ruleset matching that pattern with creation, update, and + deletion restrictions. Require exactly one bypass actor: team + `edgezero-build-container-releasers`, whose ID equals environment variable + `EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID`, with bypass mode `always`; require the verifier actor + to be an active team member. Require an active default-branch ruleset requiring + `build-container-local` and `build-container-pin`. Enumerate the candidate's successful check runs, + require both names to come from one App with slug `github-actions`, and require each ruleset + status-check entry's non-null `integration_id` to equal that App ID. Record the ID and check-run URLs + in evidence; a same-name status from any other source fails. +- [ ] Require protected-environment variables `EDGEZERO_BUILD_CONTAINER_APP_ID`, + `EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID`, and + `EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID` to equal the reviewed App, installation, and sole + bypass-team IDs, and secret metadata to contain `EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY`. + Generate a short-lived App JWT with `openssl`; + verify the authenticated App and active installation identity; require account `stackpop`, selected + repositories, exactly `contents:write`, `pull_requests:write`, and implicit `metadata:read`, and an + installation repository list containing only `stackpop/edgezero`. Mint a test installation token + restricted to that repository ID with explicit contents/pull-request write permissions, verify its + returned scope and repository read, and revoke it in a trap before exit. Never print JWTs, tokens, or + private-key material. +- [ ] Before first push, allow an absent package only after the verified active organization owner's + package-audit token produces a successful fully paginated organization container-package listing + with no exact package-name match; a listing from another identity, GET 404, or authorization error + never establishes absence. Afterward require the package API record to be public and linked to + `stackpop/edgezero`. Emit canonical JSON containing repository/PR, package-audit login, owner role, + granted non-secret scopes, environment protection and policy IDs/URLs, ruleset and sole bypass-team + IDs/URLs, App and installation IDs, exact installation/token scopes, required checks and integration + ID, package identity/visibility/repository link, verifier actor/team membership, and timestamp, but + no credential values. Record its SHA-256. + A separately authenticated operator posts the evidence file, digest, and byte-identical + administrator-bypass PNG to the candidate PR; failure to post blocks `S`. API failure, incomplete + pagination, ambiguity, an extra bypass actor, + repository, or write permission, failed token revocation, or a missing control fails closed. +- [ ] In `build-container-ci.yml`, add non-required job `build-container-release-preflight`. It runs + only for a same-repository `pull_request` carrying maintainer-applied label + `build-container-release-candidate`, references environment + `{name: build-container-release, deployment: false}`, performs no checkout, and invokes only the + pinned token action plus fixed inline API assertions. Use the stored + App ID/private key, repository `edgezero`, explicit `permission-contents: write` and + `permission-pull-requests: write`, and default token revocation. Require the action's + `installation-id` output to equal stored variable `EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID`, + prove the token reads only `stackpop/edgezero`, and expose no credential-derived output. The + environment reviewer inspects the workflow diff before approval. +- [ ] Because the final environment is tag-only, document and fixture-test the bounded smoke sequence: + an administrator temporarily adds one custom branch deployment policy equal to literal + `refs/pull//merge`; applies the label; obtains environment approval and a green smoke; + then removes only that branch policy. The final preflight requires the sole `build-container-v*` tag + policy again. It resolves the successful workflow run and requires its PR number, head repository + `stackpop/edgezero`, and head SHA to equal the current candidate values, and requires all App-variable + and private-key-secret metadata `updated_at` timestamps to be no later than that run's completion. + A new commit or credential update invalidates the evidence and requires a new smoke. Any wildcard, + source-branch, or fork policy fails. +- [ ] Extend the preflight helper to require that job's latest candidate check run to be successful and + sourced from the same GitHub Actions integration ID as the two required jobs, and to resolve to that + exact workflow run. This is the pre-`S` proof that the actual protected-environment secret, not only + the operator's local key, mints the publisher's exact scoped token. +- [ ] Run `bash .github/actions/deploy-core/tests/verify-release-prerequisites.test.sh` before + implementation; expected: non-zero. Rerun after implementation and require zero failures plus + `shellcheck -S warning`. - [ ] Implement the publisher before designating `S`. Trigger only protected `build-container-v*` - tags and configure the protected `build-container-release` environment and repository tag ruleset. + tags. Before tagging, verify the protected `build-container-release` environment, tag ruleset, + dedicated GitHub App installation and credentials, package/repository permissions, and branch + ruleset entries for `build-container-local` and `build-container-pin`. Record operator evidence; + missing prerequisites stop release execution. - [ ] Serialize the entire workflow under repository-global concurrency group - `edgezero-build-container-publication` with `cancel-in-progress: false`; different tags must not - race the one pin record. -- [ ] Use job permissions `contents: read` and `packages: write`. Mint a short-lived token from a - dedicated GitHub App, stored in the protected environment and scoped only to branch contents and - pull requests, for the pin branch/PR. `GITHUB_TOKEN` is forbidden for this operation because its - push does not trigger push workflows and its automation-created PR checks require manual approval; - it cannot guarantee the automatic required-check path. Pin the token-minting and checkout actions - to reviewed full SHAs. -- [ ] Mint the GitHub App token only after build, digest verification, and anonymous verification have - completed, so neither its private key nor installation token exists while repository-root context is - assembled or app-owned Rust code is built. + `edgezero-build-container-publication` with `cancel-in-progress: false`; different tags must not + race the one pin record. +- [ ] Split the publisher into `build-and-verify` and `update-pin`. `build-and-verify` has no + `environment`, uses job permissions `contents: read` and `packages: write`, and exports only + non-secret `{S,D,protocol,tag}` outputs after every authenticated and anonymous check passes. + Authenticate to `ghcr.io` only by + piping `${{ secrets.GITHUB_TOKEN }}` to `docker login` in a fresh + `$RUNNER_TEMP/publish-docker-config`; never pass it as a build arg, secret mount, environment inside + the build, or context file. Remove that config before anonymous verification. +- [ ] Make `update-pin` depend on successful `build-and-verify`, set + `environment: build-container-release` on that job, grant its `GITHUB_TOKEN` only `contents: read`, + and perform no image build. It checks out with persisted credentials disabled, consumes only the + four non-secret outputs, verifies their syntax and relationship to the tag event, then mints and + uses the App token for pin branch/PR mutation. The environment private key is unavailable to the + build job. +- [ ] Before every `update-pin` environment approval, including same-tag reruns, wait for + `build-and-verify` to pass. The environment approver then captures a fresh PNG of the disabled + administrator-bypass control and records its digest, login, review time, workflow run ID, exact `S`, + and release tag. Require that same login to approve `update-pin` within 15 minutes. Attach the record + and byte-identical PNG to release evidence. A missed window, bypassed approval, run-ID mismatch, or + known policy change invalidates the run and requires a fresh workflow run, capture, and approval. +- [ ] Mint the branch/PR token only after anonymous verification with + `actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3`, using protected + environment variable `EDGEZERO_BUILD_CONTAINER_APP_ID` and secret + `EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY`, owner `stackpop`, repository `edgezero`, + `permission-contents: write`, and `permission-pull-requests: write`; do not inherit installation-wide + permissions. Require its `installation-id` output to equal protected-environment variable + `EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID` before use. The App installation itself is limited to + that repository and those two write permissions plus implicit metadata read. `GITHUB_TOKEN` is + forbidden for branch/PR mutation because its push does not trigger push workflows and its + automation-created PR checks require manual approval. Checkout uses + `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7` with persisted credentials + disabled. +- [ ] Mint the GitHub App token only in `update-pin`, after `build-and-verify` completed, so neither its + private key nor installation token is available while repository-root context is assembled or + app-owned Rust code is built. - [ ] Checkout with `persist-credentials: false` and full history. Resolve - `S=$(git rev-parse "${GITHUB_SHA}^{commit}")`, validate it as 40 lowercase hex, fetch the protected - default branch, and require `S` to be its ancestor. + `S=$(git rev-parse "${GITHUB_SHA}^{commit}")`, validate it as 40 lowercase hex, fetch the protected + default branch, and require `S` to be its ancestor. - [ ] Immediately before BuildKit receives root context, require `HEAD == S`, no tracked/index - changes, no untracked files, and clean initialized submodules. Re-run the same assertions after - extracting metadata. No credential may exist in Git config or a file under the context. + changes, no untracked files, and clean initialized submodules. Re-run the same assertions after + extracting metadata. No credential may exist in Git config or a file under the context. - [ ] Build with repository-root context, explicit `-f`, `--platform linux/amd64`, exact source/protocol - args, `--provenance=false`, `--sbom=false`, and `--metadata-file`: + args, `--provenance=false`, `--sbom=false`, and `--metadata-file`: ```bash docker buildx build --platform linux/amd64 \ @@ -394,57 +596,102 @@ D=$(jq -er '."containerimage.digest"' "$RUNNER_TEMP/build-metadata.json") ``` - [ ] Validate `D` immediately and pass it to `verify-published-image.sh`. Never derive `D` by - inspecting the mutable tag. + inspecting the mutable tag. - [ ] After authenticated verification, remove the local image reference, use a fresh empty - `DOCKER_CONFIG`, and pull/run `REPOSITORY@D` without credentials. The anonymous check must make a - registry request and fail if the package is private. + `DOCKER_CONFIG`, and pull/run `REPOSITORY@D` without credentials. The anonymous check must make a + registry request and fail if the package is private. - [ ] On first publication, a private GHCR package intentionally stops before pin PR creation. An - operator makes the package public and reruns the same workflow/tag. Do not merge a pin first. + operator makes the package public and reruns the same workflow/tag. Do not merge a pin first. +- [ ] Add a static release-workflow test rejecting package-deletion API endpoints, `delete:packages`, + package-admin tokens, or cleanup jobs. GHCR has no enforceable per-version retention lock; manual + administrator deletion remains an explicit operational risk rather than a fake automated gate. - [ ] Generate the exact five-field `image.json`, run `check-image-pin.sh`, and use a branch derived - from both `S` and `D`. + from both `S` and `D`. - [ ] Implement and fixture-test the branch/PR state machine. Fetch an existing remote branch and - record its exact OID; update it only with - `--force-with-lease=refs/heads/:`. Create an absent branch without force. - Update one open matching PR. Reopen a closed-unmerged matching PR or fail for operator review. - Treat an already-merged exact `{S,D}` record as idempotent success. If the same `S` produces a new - `D`, close/supersede any older open pin PR before opening the new digest PR. Multiple or ambiguous - states fail closed. + record its exact OID; update it only with + `--force-with-lease=refs/heads/:`. Create an absent branch without force. + Update one open matching PR. Reopen the sole closed-unmerged matching PR after recreating/updating + its exact source/digest branch; a missing head repository or failed reopen fails for operator review. + Treat an already-merged exact `{S,D}` record as idempotent success. If the same `S` produces a new + `D`, close/supersede any older open pin PR before opening the new digest PR. Multiple or ambiguous + states fail closed. - [ ] Put this state machine in `update-image-pin-pr.sh`. Its tests inject fake `git` and `gh` through - `PATH`, record every argv/stdin mutation, and cover absent branch, matching remote OID, lease race, - one open PR, closed-unmerged PR, already-merged exact record, same-`S`/new-`D` supersession, multiple - matches, API failure, and rerun idempotency. Run the focused test red before implementation and green - afterward, then run shellcheck. + `PATH`, record every argv/stdin mutation, and cover absent branch, matching remote OID, lease race, + one open PR, closed-unmerged PR, already-merged exact record, same-`S`/new-`D` supersession, multiple + matches, missing closed-PR head, reopen/API failure, and rerun idempotency. Run the focused test red + before implementation and green afterward, then run shellcheck. - [ ] Include `S`, `D`, protocol, verified platform, and anonymous-pull result in the PR body. Never - include an AI byline. -- [ ] Before `S`, extend `.github/workflows/deploy-action.yml` with a required local-image job that - builds from root and runs all Task 3 smokes. Its PR/push trigger set is exactly `.tool-versions`, - root `Cargo.toml`/`Cargo.lock`, `crates/edgezero-provenance-validator/**`, - `.github/actions/deploy-fastly/versions.json`, `.dockerignore`, - `.github/docker/build-app-cli/**`, - `.github/actions/deploy-core/tests/check-image-pin.test.sh`, - `.github/actions/deploy-core/tests/verify-toolchain.test.sh`, - `.github/actions/deploy-core/tests/verify-published-image.test.sh`, - `.github/actions/deploy-core/tests/update-image-pin-pr.test.sh`, - `.github/actions/deploy-core/tests/run.sh`, - `.github/workflows/publish-build-container.yml`, and `.github/workflows/deploy-action.yml`. -- [ ] Before `S`, add a required pin-change job for every add/change/delete of `image.json`. It must - require the file to exist, run `check-image-pin.sh`, use a clean anonymous Docker config, and run the - complete `verify-published-image.sh` against the committed digest. This job is the pre-merge gate - for every future pin, not a one-time release checklist. -- [ ] Wire all helper unit suites into `run.sh`; assert the explicit trigger set above in contract - tests so existing-path omissions regress visibly; make actionlint, shellcheck, and - `zizmor --offline` cover the publisher and helpers. - -### 8.3 Land `S`, then execute publication - -- [ ] Run all Task 0-4 local and CI tests, merge validator, Dockerfile, `.dockerignore`, helpers, - publisher, and required CI jobs, then record the resulting full default-branch commit as `S`. -- [ ] Create the protected release tag at exactly `S`. The publisher must verify the tag resolves to - that commit and perform the build/verification logic already reviewed at `S`. + include an AI byline. +- [ ] Write failing tests for `classify-build-container-change.sh`. Cover pull-request, merge-group, + and push base/head ranges, rename/add/change/delete, an all-zero first-push base, shallow/missing + commits, empty/duplicate/invalid output, and the exact local-image path set: `.tool-versions`, + root `rust-toolchain`/`rust-toolchain.toml`, `.cargo/**`, root `Cargo.toml`/`Cargo.lock`, + `crates/edgezero-provenance-validator/**`, `.github/actions/deploy-fastly/versions.json`, + `.dockerignore`, `.github/docker/build-app-cli/**`, the six focused helper test files, `run.sh`, + `.github/workflows/build-container-ci.yml`, and `.github/workflows/publish-build-container.yml`. + Pin classification is exact add/change/delete detection for + `.github/docker/build-app-cli/image.json`. +- [ ] Run `bash .github/actions/deploy-core/tests/classify-build-container-change.test.sh`; expected: + non-zero before the helper exists. +- [ ] Implement the classifier fail closed over a full checkout and explicit base/head SHAs. It emits + only a typed `relevant=true|false` output. Do not use a third-party path-filter action. +- [ ] Create `.github/workflows/build-container-ci.yml` with unfiltered `pull_request` types `opened`, + `synchronize`, `reopened`, and `labeled`, plus `merge_group` and `push` to `main` triggers. It always + materializes stable jobs `build-container-local` and `build-container-pin`; do not put workflow-level + `paths` or job-level skip conditions on them. +- [ ] Make each required job independently check out full history without persisted credentials and + run the classifier. `build-container-local` builds from root and runs all Task 3 smokes when + relevant, otherwise it runs an explicit successful not-applicable step. `build-container-pin` + requires `image.json`, runs `check-image-pin.sh`, creates a fresh anonymous Docker config, and runs + complete `verify-published-image.sh` for relevant add/change/delete events; otherwise it explicitly + succeeds as not applicable. Each job has an unconditional terminal assertion that classification + was exactly one valid line and exactly one execution branch wrote its completion marker. A + classifier/build/no-op failure fails that required job rather than skipping it. +- [ ] Keep the existing path-filtered `.github/workflows/deploy-action.yml` separate. The unfiltered + `build-container-local` job itself runs all focused helper suites, shellchecks + `.github/docker/build-app-cli/*.sh`, and applies actionlint plus `zizmor --offline` to both new + workflows whenever a helper/workflow input changes. Modify deploy-action static checks to run both + pin scanners and retain broad repository coverage, but do not rely on its path filter for the new + helper surface. +- [ ] Wire all focused helper suites into `run.sh`. Add workflow contract tests for the unfiltered + triggers, exact job names, independent classification, explicit no-op steps, local-image path set, + pin deletion failure, and the same-repository/label/environment/no-checkout/scoped-token contract of + `build-container-release-preflight` so topology drift is visible. + +### 8.3 Merge the source candidate as `S`, then execute publication + +- [ ] Run all Task 0-4 local and CI tests on the candidate PR, including both always-materialized + container jobs. Complete the external prerequisite check from Section 8.2 after those check names + exist, apply `build-container-release-candidate`, obtain the independent environment approval and + successful credential-smoke check, and complete the preflight evidence before merge. + +**Release checkpoint 1:** stop. A maintainer who is neither the preflight verifier nor the recorded +administrator-bypass reviewer reviews the canonical prerequisite evidence and both required jobs, +recomputes the attached PNG digest, and confirms it visibly shows administrator bypass disabled before +authorizing merge. Any candidate commit or environment-policy change invalidates the manual evidence. + +- [ ] Merge validator, Dockerfile, `.dockerignore`, helpers, publisher, and required CI jobs; record + the resulting full default-branch commit as `S`. + +**Release checkpoint 2:** stop. Confirm the recorded default-branch commit and protected tag target +are exactly `S` before creating the tag. + +- [ ] Using a credential for the preflight-verified active member of sole bypass team + `edgezero-build-container-releasers`, create the protected release tag at exactly `S`. The + publisher must verify the tag resolves to that commit and perform the build/verification logic + already reviewed at `S`. - [ ] On first publication, a private GHCR package intentionally stops before pin PR creation. An - operator makes the package public and reruns the same workflow/tag. Do not merge a pin first. + operator makes the package public, confirms its API record links `stackpop/edgezero`, and reruns + the same workflow/tag. Do not merge a pin first. + +**Release checkpoint 3:** stop after the first private-package failure. Resume the same tag only after +public visibility and repository linkage are independently reviewed. + - [ ] Require the GitHub-App-created pin PR's local shape and remote anonymous image verification jobs - to pass before review or merge. + to pass before review or merge. + +**Release checkpoint 4:** stop before merging the pin PR. Confirm its only content is the exact +five-field `image.json` for verified `{S,D,protocol}` and both required container checks passed. **Gate:** the pin PR cannot exist unless the exact digest passed all checks including anonymous pull. @@ -456,13 +703,14 @@ D=$(jq -er '."containerimage.digest"' "$RUNNER_TEMP/build-metadata.json") - No post-merge gate wiring: all required checks were part of source `S`. - [ ] Review the generated record and confirm its source revision is the published `S`, digest is the - verified `D`, and protocol is `1`. + verified `D`, and protocol is `1`. - [ ] Confirm the GitHub App push triggered all required pin-change workflows and that every check - passed. Merge the pin-only PR and record the merge/full commit SHA as baseline `B`, not final action - revision `P`. + passed. Merge the pin-only PR and record the merge/full commit SHA as baseline `B`, not final action + revision `P`. - [ ] Confirm a deletion or syntactically valid but unverifiable replacement of `image.json` fails the - required pin-change job in a test PR. -- [ ] Run the full repository verification suite from a clean checkout at baseline `B`: + required pin-change job in a test PR. +- [ ] From a clean checkout at baseline `B`, rerun every Task 0 gate and every command in the Task 1 + Section 5.4 matrix, then run the complete deploy-core/helper and workflow-static suites: ```bash bash .github/actions/deploy-core/tests/run.sh @@ -470,46 +718,51 @@ bash .github/actions/deploy-core/tests/run.sh .github/actions/deploy-core/tests/check-doc-action-pins.sh actionlint zizmor --offline .github/workflows .github/actions -cargo fmt --all -- --check -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace --all-targets -cargo check --workspace --all-targets --features "fastly cloudflare spin" -cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin -npm --prefix docs ci -npm --prefix docs run format -npm --prefix docs run lint -npm --prefix docs run build ``` +- [ ] Require the baseline `B` commit to pass every current format/test CI matrix job, including all + four wasm clippy legs and three wasm test runners. Local commands do not substitute for these + runner-backed checks. + - [ ] Pull `repository@digest` anonymously again after merge and rerun image verification by the - committed record. + committed record. **Gate:** downstream plans build on baseline `B`; they do not reference source revision `S` as an action ref or recompute a tag digest. Their final integration plan designates full SHA `P` only after all feature contracts pass. -## 10. Task 6: Release and retention runbook - -- [ ] Protect the publisher tag pattern and environment; require review for release execution. -- [ ] Confirm GHCR package visibility is public before the pin PR can be generated. -- [ ] Configure retention so no digest referenced by any supported `image.json` is deleted. +## 10. Task 6: Release and package-persistence runbook + +- [ ] Re-verify the publisher tag pattern, protected environment, GitHub App installation, required + container checks, and release-review requirement established before `S`; fail if they drifted. +- [ ] For every `update-pin` attempt, including reruns, repeat the administrator-bypass UI capture, + digest check, and same-reviewer environment approval from Task 4. Record the exact `S`, release tag, + workflow run ID, reviewer, review time, PNG basename, and SHA-256 in release evidence. Do not treat + either the pre-`S` capture or another workflow attempt's capture as current. +- [ ] Confirm GHCR package visibility is public and its API record links `stackpop/edgezero` before the + pin PR can be generated. +- [ ] Document that GHCR provides no enforceable per-version retention lock, repository automation has + no package-deletion path, and manual administrator deletion can break existing pinned consumers. + The recovery is an emergency rebuild, full verification, and new pin release; do not claim the old + digest remains available. - [ ] Document rollback as reverting to an earlier reviewed `image.json` digest/protocol and pinning - consumers to the corresponding earlier action SHA. Never move a tag to simulate rollback. + consumers to the corresponding earlier action SHA. Never move a tag to simulate rollback. - [ ] Document the release record: image source `S`, digest `D`, pin baseline `B`, final action pin - `P`, image tag (informational), checksums, and exact third-party action SHAs. + `P`, image tag (informational), checksums, and exact third-party action SHAs. - [ ] Update the parent spec, implementation plan, adoption guide, and public guide in the downstream - integration plan. Consumer examples must use one full `P` for all EdgeZero references. + integration plan. Consumer examples must use one full `P` for all EdgeZero references. ## 11. Completion review Before declaring this plan complete, run two independent reviews: -1. **Contract review:** compare every file and test with design v6.18 Sections 3, 5, 6.3, 8, 9, and - 10. Verify there is no same-SHA claim, no platform identity output, no tag runtime pull, no - placeholder, and no legacy `--stage` guidance. +1. **Contract review:** compare every file and test with design v6.19 Sections 3, 5, 6.2 through 6.6, + 8, 9, and 10. Verify there is one package/validate wire authority, no same-SHA claim, no platform + identity output, no tag runtime pull, no placeholder, and no legacy `--stage` guidance. 2. **Release-adversary review:** test mutable tags, private package state, stale/idempotent PR branches, malformed BuildKit metadata, index manifests, wrong platform/labels/versions/protocol, deleted - image pin, publication reruns, and concurrent release attempts. + image pin, unrelated PR no-op checks, classifier failure, publication reruns, and concurrent release + attempts. The container plan is complete only when source `S`, verified digest `D`, and pin baseline `B` are recorded and all repository gates pass. The remaining plans may then implement cached compilation and diff --git a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md index b0537dc2..38bd60ef 100644 --- a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md +++ b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md @@ -1,6 +1,6 @@ # EdgeZero Deploy Actions - Build Caching Spec -**Status:** Design (proposed) - v6.18 +**Status:** Design (proposed) - v6.19 **Related:** `docs/specs/edgezero-deploy-github-action.md`, `docs/specs/edgezero-deploy-action-implementation-plan.md`, @@ -208,9 +208,8 @@ only exemption. ### 5.1 Image and runner -The EdgeZero image is public and anonymously pullable by digest, retained while referenced, and a -leaf `linux/amd64` image manifest rather than an OCI index. It is built from a digest-pinned base and -contains: +The EdgeZero image is public and anonymously pullable by digest and is a leaf `linux/amd64` image +manifest rather than an OCI index. It is built from a digest-pinned base and contains: - the exact Rust toolchain from `.tool-versions` and an installed `wasm32-wasip1` target; - exact pinned Fastly CLI and sccache versions with checksum-verified downloads; @@ -241,20 +240,22 @@ original are broken. The read-only original checkout remains the freeze authorit `run-app-cli-in-container` has a maximum allowlist and a closed profile for each operation. It never mounts all of `RUNNER_TEMP`. -| In-container path | Mode | Allowed operations | Source | -| --- | --- | --- | --- | -| `/work/repo` | writable | cached-compile, app-build, provider-deploy | Copy A or Copy B | -| `/work/repo` | read-only | config-push | frozen original checkout | -| `/work/target` | writable | cached-compile, app-build, provider-deploy | fresh or parent target cache as specified below | -| `/work/cargo-home` | writable, fresh | cached-compile, app-build, provider-deploy | operation-specific directory | -| `/work/sccache` | writable | cached-compile only | stable host cache directory | -| `/work/input/artifact.tar` | read-only | provenance-validate only | downloaded artifact | -| `/work/input/expected.json` | read-only | provenance-validate only | host-generated expected identity | -| `/work/validated` | writable, fresh | provenance-validate only | empty host output directory | -| `/work/bin/app-cli` | read-only | binary-smoke and provider operations | validated binary | -| `/work/config/inline.toml` | read-only | config-push only | optional action-owned inline config file | -| `/work/package` | writable, fresh | app-build, provider-deploy | staged Fastly package/output | -| `/work/home`, `/work/tmp` | writable tmpfs | all operations | operation-local tmpfs | +| In-container path | Mode | Allowed operations | Source | +| --------------------------- | --------------- | ------------------------------------------ | ----------------------------------------------- | +| `/work/repo` | writable | cached-compile, app-build, provider-deploy | Copy A or Copy B | +| `/work/repo` | read-only | config-push | frozen original checkout | +| `/work/target` | writable | cached-compile, app-build, provider-deploy | fresh or parent target cache as specified below | +| `/work/cargo-home` | writable, fresh | cached-compile, app-build, provider-deploy | operation-specific directory | +| `/work/sccache` | writable | cached-compile only | stable host cache directory | +| `/work/input/app-cli` | read-only | provenance-package only | exact binary produced by cached-compile | +| `/work/input/artifact.tar` | read-only | provenance-validate only | downloaded artifact | +| `/work/input/expected.json` | read-only | provenance-package, provenance-validate | host-generated expected identity | +| `/work/packaged` | writable, fresh | provenance-package only | empty host archive-output directory | +| `/work/validated` | writable, fresh | provenance-validate only | empty host output directory | +| `/work/bin/app-cli` | read-only | binary-smoke and provider operations | validated binary | +| `/work/config/inline.toml` | read-only | config-push only | optional action-owned inline config file | +| `/work/package` | writable, fresh | app-build, provider-deploy | staged Fastly package/output | +| `/work/home`, `/work/tmp` | writable tmpfs | all operations | operation-local tmpfs | Profiles: @@ -268,6 +269,9 @@ Profiles: token for both `build-mode` values. A prior `app-build` is a credential-free validation/prebuild and does not claim to suppress this recompile; its parent target cache was already saved before the token appeared and is never saved again afterward. +- `provenance-package`: trusted baked validator, the exact compiled binary read-only at + `/work/input/app-cli`, read-only expected-identity JSON, fresh writable `/work/packaged`, and tmpfs; + no repository, target, Cargo, package, cache, app-binary execution, network, or token. - `provenance-validate`: trusted baked validator, read-only tar, fresh writable output directory, read-only expected-identity JSON, and tmpfs; no repository, app-binary execution, token, Cargo, target, package, or cache mount. @@ -284,8 +288,10 @@ Profiles: above. It receives no writable repository, package, Cargo, target, or sccache mount. The parent deploy spec remains normative for production/staging lifecycle semantics, rollback target -capture, mutation signaling, healthcheck ordering, and recovery. This addendum changes isolation and -mounting only. Every staged CLI invocation uses `--staging`, never `--stage`. +capture, mutation signaling, healthcheck ordering, and recovery. This addendum expressly supersedes +the parent's app-CLI metadata shape, caller override, archive member naming/ordering, system-tar +packaging/extraction, and artifact-validation rules, in addition to changing isolation and mounting. +Every staged CLI invocation uses `--staging`, never `--stage`. ### 5.4 Constructed environments @@ -302,7 +308,7 @@ Every operation starts with `env -i` and a closed allowlist. `PATH` is - provider deploy: the Rustup/Cargo variables above except every sccache variable and wrapper, plus `FASTLY_API_TOKEN`, the operation's enumerated `EDGEZERO_*` variables, validated `app-env`, and validated `EDGEZERO_MANIFEST` when the caller selected a manifest. -- provenance validation and binary smoke: `PATH`, `HOME`, `TMPDIR` only. +- provenance packaging, provenance validation, and binary smoke: `PATH`, `HOME`, `TMPDIR` only. - provider operations: `PATH`, `HOME`, `TMPDIR`, only the token required by that operation, and only explicitly named `EDGEZERO_*` variables plus validated `app-env`. Config push also receives its selected validated overlay names unless `no-env` was selected. @@ -363,23 +369,236 @@ artifact/caller/platform identity instead. Config-push verifies repository id, H confined selected files on its read-only checkout. The consumer repeats the checks applicable to each mounted source profile before and after provider commands. -### 6.2 Archive contract +### 6.2 Protocol-1 JSON contract -The producer emits deterministic POSIX ustar with exactly two regular members in order: -`app-cli-meta.json`, then the fixed `app-cli-bin` basename. PAX and GNU extensions are rejected. -Headers use uid/gid 0, empty uname/gname, mtime 0, empty prefix, typeflag regular, and mode 0644 for -metadata or 0755 for the binary. Extra, duplicate, renamed, linked, special, traversal, or trailing -content is rejected. Total logical size is at most 512 MiB and metadata is at most 64 KiB. +Protocol 1 has two closed JSON documents. Both are UTF-8 RFC 8785 JCS bytes with no BOM, surrounding +whitespace, or trailing newline. Duplicate object keys are rejected while parsing, before an object +or generic JSON value is constructed. Unknown and missing fields, wrong JSON types, noncanonical JCS +bytes, and values outside the bounds below fail closed. The committed JSON Schema 2020-12 file checks +the local shape; procedural validation enforces canonical bytes, duplicate rejection, cross-field +relationships, and exact expected-versus-observed identity. -Metadata is RFC 8785 JCS canonical JSON. Duplicate keys are rejected before parsing. A committed JSON -Schema 2020-12 and procedural validation define the exact fields: both identity groups, -`app-cli-version` (informational), `binary-sha256`, `binary-size`, and `abi`. +`expected.json` contains exactly the identity the protected caller and local action computed: -`abi` is recomputed from ELF data: canonical machine name, `PT_INTERP` string or null, and sorted -direct `DT_NEEDED` strings. Transitive dependencies must resolve inside the pinned image. Runtime -`dlopen` dependencies are outside this contract. +```json +{ + "caller": { + "app-cli-bin": "edgezero", + "app-cli-package": "edgezero-cli", + "app-repo-id": "123456", + "source-revision": "<40-lowercase-hex>", + "workspace-id": "sha256:<64-lowercase-hex>" + }, + "platform": { + "container-ref": "ghcr.io/stackpop/edgezero-build-app-cli@sha256:<64-lowercase-hex>", + "platform-id": "sha256:<64-lowercase-hex>", + "provenance-protocol": 1 + }, + "schema-version": 1 +} +``` + +`app-cli-meta.json` contains exactly the same identity plus observed binary data: + +```json +{ + "abi": { + "interpreter": "/lib64/ld-linux-x86-64.so.2", + "machine": "x86_64", + "needed": ["libc.so.6"] + }, + "app-cli-version": "0.1.0", + "binary-sha256": "sha256:<64-lowercase-hex>", + "binary-size": 123, + "caller": { + "app-cli-bin": "edgezero", + "app-cli-package": "edgezero-cli", + "app-repo-id": "123456", + "source-revision": "<40-lowercase-hex>", + "workspace-id": "sha256:<64-lowercase-hex>" + }, + "platform": { + "container-ref": "ghcr.io/stackpop/edgezero-build-app-cli@sha256:<64-lowercase-hex>", + "platform-id": "sha256:<64-lowercase-hex>", + "provenance-protocol": 1 + }, + "schema-version": 1 +} +``` + +The examples are line-wrapped for review; the wire fixtures contain compact JCS bytes. Field rules +are exact: + +- `schema-version` and `provenance-protocol` are JSON integers equal to `1`. Protocol 1 does not + evolve them independently; an incompatible JSON, archive, or ELF rule requires both to change. +- `app-repo-id` is a string containing the canonical nonzero decimal representation of a `u64`: no + sign and no leading zero. +- `source-revision` is a nonzero full lowercase 40-hex commit SHA. +- `app-cli-package`, `app-cli-bin`, and `app-cli-version` are 1 through 255 UTF-8 bytes, contain no + Unicode control character, and contain neither `/` nor `\\`. The package and binary values must + equal the validated Cargo package and target names. Before the host mounts the compiled file at the + fixed `/work/input/app-cli` path, it requires the source basename to equal `app-cli-bin`. +- `workspace-id`, `platform-id`, and `binary-sha256` use + `sha256:<64-lowercase-hex>` and reject the all-zero digest. +- `container-ref` is exactly + `ghcr.io/stackpop/edgezero-build-app-cli@`; no tag or alternate repository is valid. +- `binary-size` is a JSON integer from 1 through 536,870,912 and equals the exact + `app-cli-bin` member payload length. `binary-sha256` equals SHA-256 over those exact payload bytes, + with no header or padding bytes included. +- `abi.machine` is exactly `x86_64`; `abi.interpreter` is either the exact string defined in Section + 6.4 or JSON null; and `abi.needed` preserves every direct `DT_NEEDED` occurrence, including + duplicates, sorted by UTF-8 bytes. Each entry is 1 through 255 bytes and is a basename containing + no slash, backslash, NUL, or control character. + +`expected.json` is at most 16 KiB and `app-cli-meta.json` is at most 64 KiB. The validator compares +the complete `caller`, `platform`, and `schema-version` values for equality. The artifact is a +consistency record, not producer authentication. + +### 6.3 Protocol-1 ustar contract + +The protocol crate is the only archive encoder and decoder. The producer must not construct metadata +with `jq` or archives with system `tar` or a general-purpose tar library. It emits deterministic POSIX +ustar with exactly two regular members in order: literal `app-cli-meta.json`, then literal +`app-cli-bin`. The caller's binary name remains in metadata and is not used as an archive path. + +Every 512-byte header is byte-exact: + +- `name` is the member name followed by NUL bytes to width 100; `prefix`, `linkname`, `uname`, and + `gname` are all NUL bytes; +- `mode` is `0000644\0` for metadata and `0000755\0` for the binary; +- `uid`, `gid`, `devmajor`, and `devminor` are `0000000\0`; `mtime` is `00000000000\0`; +- `size` is eleven lowercase octal digits with leading zeroes followed by NUL; +- `chksum` is six lowercase octal digits with leading zeroes, NUL, and space; its unsigned-byte sum + is computed with all eight checksum bytes replaced by spaces; +- `typeflag` is ASCII `0`, `magic` is `ustar\0`, `version` is `00`, and bytes 500 through 511 are NUL. + +Base-256 numbers, alternate octal padding, embedded-NUL garbage, PAX/GNU extensions, sparse records, +links, special files, extra or duplicate members, renamed paths, and traversal are rejected. Payload +padding through the next 512-byte boundary is all zero. Exactly two all-zero end blocks follow the +binary payload, followed immediately by EOF; extra zero blocks or any trailing byte fail. The sum of +the two logical payload sizes is at most 512 MiB, and the metadata payload is nonempty and at most 64 +KiB. Overflow in any size, offset, padding, or checksum calculation fails before reading or writing. + +### 6.4 Protocol-1 ELF and loader profile + +Protocol 1 intentionally models one conservative immutable runtime rather than general Linux loader +behavior. The primary app binary and every parsed dependency must be ELF64, little-endian, and +`EM_X86_64`; metadata records the machine as `x86_64`. The primary is `ET_EXEC` or `ET_DYN` and may +contain at most one `PT_INTERP`. If it has an interpreter or any `DT_NEEDED`, it must use exactly +`/lib64/ld-linux-x86-64.so.2`; it is treated as static only when both are absent. A resolved library +must be `ET_DYN`, must not contain `PT_INTERP`, and may have its own `DT_NEEDED` entries. + +Program headers are the sole loader-visible authority. ELF and program-header sizes, counts, offsets, +virtual-address mappings, additions, and multiplications are checked before access. Section headers +may be absent and never affect validation; conflicting section data is ignored because the runtime +loader does not use it for this contract. A static primary has no `PT_DYNAMIC`. Every dynamic primary, +interpreter, and library has exactly one bounded `PT_DYNAMIC`; multiple segments fail. Its entry width +is the ELF64 width, it contains a terminating `DT_NULL`, and every remaining byte in that segment is +zero. Missing termination or a nonzero trailing entry fails. + +Protocol 1 defines loader-visible string tags as exactly `DT_NEEDED`, `DT_SONAME`, `DT_RPATH`, +`DT_RUNPATH`, `DT_AUDIT`, `DT_DEPAUDIT`, `DT_CONFIG`, `DT_AUXILIARY`, and `DT_FILTER`. If any of these +tags exists, the table has exactly one `DT_STRTAB` and one `DT_STRSZ`. Their complete nonempty range +must map into exactly one readable `PT_LOAD` file range. Duplicate or conflicting table tags, +unmapped/overlapping ranges, an out-of-range string offset, or a string without NUL before `DT_STRSZ` +fails. `PT_INTERP` follows the same bounded-range rules, contains exactly one trailing NUL, and +contains no interior NUL. Every accepted dynamic string is valid UTF-8 and has no NUL or control +character before its terminator. + +Only `DT_NEEDED` may induce a library lookup. `DT_SONAME` is accepted only as nonempty descriptive +metadata, is bounded to 255 UTF-8 bytes, and contains neither `/` nor `\\`; it never adds a dependency. +`DT_RPATH`, `DT_RUNPATH`, `DT_AUDIT`, `DT_DEPAUDIT`, `DT_CONFIG`, `DT_AUXILIARY`, `DT_FILTER`, and +`DT_POSFLAG_1` are always rejected. + +The accepted dynamic-tag vocabulary is numeric and closed; symbolic constants are labels only. It is +exactly core values `0..14`, `16..28`, `30`, and `32..37`; GNU values `0x6ffffef5` +(`DT_GNU_HASH`), `0x6ffffef6` (`DT_TLSDESC_PLT`), `0x6ffffef7` (`DT_TLSDESC_GOT`), `0x6ffffff0` +(`DT_VERSYM`), and `0x6ffffff9..0x6fffffff` (`DT_RELACOUNT` through `DT_VERNEEDNUM`); and x86-64 +values `0x70000000`, `0x70000001`, and `0x70000003` (`DT_X86_64_PLT`, `DT_X86_64_PLTSZ`, and +`DT_X86_64_PLTENT`). Rejected string/acquisition tags above remain rejected even though their values +fall outside this allowlist. Every other value, including future standard, OS-specific, GNU, or +processor-specific tags, fails until a protocol revision explicitly adds it. + +For accepted `DT_FLAGS` (value `30`), no bit outside mask `0x0000001e` may be set; this allows only +`DF_SYMBOLIC`, `DF_TEXTREL`, `DF_BIND_NOW`, and `DF_STATIC_TLS`. For accepted `DT_FLAGS_1` +(`0x6ffffffb`), no bit outside mask `0x5eff976f` may be set. This mask deliberately excludes +`DF_1_LOADFLTR`, `DF_1_ORIGIN`, `DF_1_NODEFLIB`, `DF_1_CONFALT`, `DF_1_ENDFILTEE`, +`DF_1_GLOBAUDIT`, and `DF_1_WEAKFILTER`; every undefined bit also fails. Multiple `DT_FLAGS` or +`DT_FLAGS_1` entries fail rather than combining masks. Apart from repeatable `DT_NEEDED` and the +all-zero bytes after the first `DT_NULL`, every accepted tag appears at most once; duplicate +`DT_SONAME`, table, size, relocation, version, flag, initialization, hash, or x86-64 tags fail. +Accepted non-string tags describe relocation, symbol, version, initialization, or hash tables but do +not participate in protocol identity or dependency discovery. + +Thus the only object-acquisition mechanisms in Protocol 1 are the primary's exact `PT_INTERP` and +recursively traversed `DT_NEEDED` entries; environment-driven preloads and runtime `dlopen` remain +outside the credential-free smoke contract. `DT_NEEDED` values containing `/` or `\\` fail. The +validator preserves duplicate direct `DT_NEEDED` values for metadata, sorts them bytewise, and +resolves dependencies recursively against this fixed directory list: + +1. `/lib/x86_64-linux-gnu` +2. `/usr/lib/x86_64-linux-gnu` +3. `/lib64` +4. `/usr/lib64` +5. `/lib` +6. `/usr/lib` + +For each `DT_NEEDED` basename, inspect `root + directory + basename` in the listed order but do not +silently choose a first match. A nonexistent path is skipped. A present path that is dangling, +escaping, or non-regular fails immediately. Every accepted candidate must canonicalize inside the +immutable image root and beneath one of the six roots. Zero candidates fails. Multiple candidates are +accepted only when `stat` reports the same device and inode; symlink or hardlink aliases to that same +file are one identity, while two different files are ambiguous and fail. The listed order controls +deterministic traversal and diagnostics, not precedence. + +The exact interpreter path is resolved with the same confinement and regular-file rules, parsed as an +`ET_DYN` runtime dependency with no `PT_INTERP`, and recursively validated; it is not added to the +primary's `abi.needed`. Recursive inspection uses a device/inode visited set so hardlink aliases and +dependency cycles terminate, and every transitive library satisfies this same profile. The validator +does not read `ld.so.cache`, invoke `ldd` or the loader, or emulate `$ORIGIN`. + +### 6.5 Protocol-owner CLI + +The synchronous `edgezero-provenance-validator` binary owns both encoding and validation. It has no +Tokio dependency and never executes an app binary. Its stable credential-free interface is: + +```text +edgezero-provenance-validator package \ + --work-root /work \ + --binary /work/input/app-cli \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --app-cli-version \ + --archive /work/packaged/artifact.tar + +edgezero-provenance-validator validate \ + --work-root /work \ + --archive /work/input/artifact.tar \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --output /work/validated/app-cli + +edgezero-provenance-validator self-test \ + --fixtures /usr/local/share/edgezero/provenance-fixtures +``` -### 6.3 Split validation boundary +`--work-root` is required for output-producing commands and must canonicalize to `/work` in the +container. Every input and output parent must canonicalize beneath it, except the trusted baked schema +path. `package` validates canonical expected identity, inspects and resolves the source ELF inside the +pinned image, generates canonical metadata, and atomically publishes the deterministic archive. +`validate` performs the inverse checks and atomically publishes exactly one mode-0755 regular output +file. Each output parent is a fresh canonical directory, must be writable and empty, and the final +file must have link count one. + +The implementation writes a create-new temporary sibling, flushes and validates it, then performs a +Linux no-replace atomic rename to the final basename. Handled errors remove the temporary file before +return. SIGKILL, OOM, runner cancellation, or a container timeout may prevent in-process cleanup; the +host therefore removes the entire action-owned output parent after every abnormal/nonzero exit and +verifies it is absent before reporting failure or retrying. On success the host requires exactly the +one final file and no temporary sibling. `self-test` verifies a compiled manifest of exact fixture +paths, SHA-256 values, and expected valid/invalid outcomes; missing, extra, or changed fixtures fail. + +### 6.6 Split validation boundary Validation is deliberately two container invocations: @@ -397,9 +616,11 @@ outputs the host path, digest, size, and mode of the verified binary within the private workspace. The validator, schema, malformed fixtures, valid golden archive, and all required capabilities must -exist and pass before any image digest can be published. Golden tests cover JCS, duplicate keys, -schema rejection, ustar-only parsing, traversal/link/special-file rejection, normalized headers, size -limits, ELF inspection, dependency resolution, exact extraction, and output-directory confinement. +exist and pass before any image digest can be published. Golden tests cover both JSON documents, +JCS, duplicate keys, schema rejection, byte-exact ustar encoding and parsing, traversal/link/special- +file rejection, header and padding normalization, size limits, ELF inspection, dependency resolution, +exact extraction, and output-directory confinement. Repeated `package` runs over the same inputs must +produce byte-identical archives, and `validate` must accept that golden output. ## 7. Reusable workflow and action contract @@ -460,6 +681,15 @@ parent deploy contract. ## 8. Image publication and compatibility +Protocol 1 selects the official `rust:1.95.0-slim-bookworm` image and pins its `linux/amd64` leaf +manifest, not its multi-platform index. The digest resolved from the official registry on 2026-08-31 +is `sha256:6f9e63259f12e1e599296f5ecfed2bae46de4af0ee0525dd8b89c046e236d5c5`; implementation must +re-resolve and compare it immediately before committing the Dockerfile. The exact sccache asset is +`sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz` from the upstream v0.10.0 release, with upstream +checksum `1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b`. The v0.10.0 release has no +GNU Linux client asset; the static musl client is the reviewed Linux x86-64 artifact. Changing either +base digest or tool asset requires a new source revision and image digest. + `image.json` is a reviewed record with exactly these typed fields: ```json @@ -474,10 +704,13 @@ parent deploy contract. `tag` is informational. Runtime pulls use only `repository@digest`. -The release has two revisions: +The rollout has three relevant revisions: - `S` is the full source commit used to build the image. The image has OCI label - `org.opencontainers.image.revision=S` and a protocol label matching the baked validator. + `org.opencontainers.image.revision=S`, + `org.opencontainers.image.source=https://github.com/stackpop/edgezero`, and a protocol label + matching the baked validator. The final image overrides inherited source/revision labels, and + verification requires all three exact values. - `B` is the baseline revision created after the pin PR commits the verified digest and `S` to `image.json` and permanent pin CI is enabled. - `P` is the later, fully tested action revision that contains the unchanged reviewed pin plus the @@ -490,39 +723,176 @@ contract requires a protocol bump and a new image before the actions using that Publication order is: -1. Land source revision `S`, including validator, schema, fixtures, `.dockerignore`, Dockerfile, - publisher, local-image CI, pin-change CI, and publication tests. -2. Build from repository root, push by protected release tag, and capture digest `D` from BuildKit's +1. Before merging the source candidate, configure and verify the protected release environment, + protected tag rule, dedicated GitHub App, repository permissions, and the two branch required + checks after their names have materialized on the candidate PR. The first package may not exist + yet; its public-visibility gate occurs after its first push and before a pin PR. +2. Land source revision `S`, including validator, schema, fixtures, `.dockerignore`, Dockerfile, + publisher, always-running container CI, pin-change CI, and publication tests. +3. Build from repository root, push by protected release tag, and capture digest `D` from BuildKit's metadata output. -3. Verify `D` is a leaf linux/amd64 image, labels identify `S` and protocol, exact tool versions and +4. Verify `D` is a leaf linux/amd64 image, labels identify `S` and protocol, exact tool versions and target are installed, validator capability tests pass, and runtime works read-only/non-root. -4. Ensure the GHCR package is public, then prove an anonymous pull and smoke by `D`. The first release - stops here until an operator changes package visibility and reruns the same tag. -5. Open or update an idempotent PR committing `image.json = {D, S, protocol}`. Required pin CI +5. Ensure the GHCR package is public and linked to `stackpop/edgezero`, then prove an anonymous pull + and smoke by `D`. The first release stops here until an operator changes package visibility and + reruns the same tag. +6. Open or update an idempotent PR committing `image.json = {D, S, protocol}`. Required pin CI re-verifies the image before merge; merging the passing PR creates baseline `B`. -6. Implement the remaining plans on top of `B`, run the full pin, actionlint, zizmor, schema, +7. Implement the remaining plans on top of `B`, run the full pin, actionlint, zizmor, schema, fixture, container, and contract suites, and designate the passing full commit SHA as `P`. -Source `S` also contains a required CI job that, for every add/change/delete of `image.json`, requires -the file to exist, validates its structure, anonymously pulls its exact digest, and runs the complete -published-image verifier before merge. Thus no later syntactically valid pin can bypass image, -platform, label, protocol, public-access, target, validator, or exact-version checks. - -The release tag and environment are protected external prerequisites. The workflow also verifies `S` -is an ancestor of the protected default branch. All publication and pin-record mutation is serialized +Source `S` contains a separate `.github/workflows/build-container-ci.yml` triggered for pull-request +types `opened`, `synchronize`, `reopened`, and `labeled`, every merge-queue `merge_group`, and every +push to the protected default branch, with no workflow-level path filter. It exposes two stable +required job names on every candidate: + +- `build-container-local` computes the documented image-input path set. It builds and smokes the local + image when relevant and otherwise runs an explicit successful not-applicable step. +- `build-container-pin` detects every add, change, or deletion of `image.json`. When relevant it + requires the file to exist, validates its structure, anonymously pulls the exact digest, and runs + the complete published-image verifier; otherwise it explicitly succeeds as not applicable. + +Each job performs its own fail-closed change classification from the checked-out base and head so a +failed shared classifier cannot skip a required job. The local-image set includes `.cargo/**`, both +possible root `rust-toolchain` filenames, and every other Docker build or verifier input listed in the +implementation plan. Classification output is exactly one line, `relevant=true` or `relevant=false`. +An unconditional terminal assertion rejects missing, duplicate, or malformed output and proves +exactly one of the relevant or not-applicable branches ran; an invalid classifier can never make both +conditional paths disappear behind a green job. Contract tests pin pull-request, merge-group, and +push ranges, event triggers, job names, path set, deletion handling, output validation, and explicit +no-op behavior. The existing path-filtered +`deploy-action.yml` remains separate. Thus required checks always materialize without running Docker +on unrelated changes, and no later syntactically valid pin can bypass image, platform, label, +protocol, public-access, target, validator, or exact-version checks. + +The same workflow also exposes non-required job `build-container-release-preflight` only for a +same-repository pull request carrying maintainer-applied label `build-container-release-candidate`. +That job uses environment `{name: build-container-release, deployment: false}`, performs no checkout, +and runs no repository script. After the environment reviewer approves it, the pinned token action +consumes the exact stored +App variable and private-key secret with repository `edgezero` and explicit `contents:write` and +`pull_requests:write`. The job requires its installation-ID output to equal the stored expected ID, +reads only `stackpop/edgezero` with the token, and lets the action's mandatory post step revoke the +token. The environment reviewer must inspect the candidate workflow diff before approval. A successful +check run from the GitHub Actions App proves the protected environment's stored credential, rather +than only an operator's local copy, can mint the publisher's exact token before `S`. + +The final environment policy is tag-only, so the smoke uses a bounded transition. An administrator +temporarily adds one custom branch deployment policy equal to literal +`refs/pull//merge`, runs the labeled job, then removes that branch policy without +changing the App variables or secret. The final preflight requires the environment to be back to its +sole `build-container-v*` tag policy and the successful workflow run to identify the exact candidate +PR, `stackpop/edgezero` head repository, and current PR head SHA. Every App variable/secret +`updated_at` value is no later than that run's completion time. Any new candidate commit or credential +update invalidates the smoke and requires the bounded transition again. The temporary branch policy +is a literal PR merge ref, never a wildcard or fork branch. + +Repository-administrator bypass of environment protection is disabled. GitHub's documented REST +environment representation does not expose that switch, so neither the helper nor its fake-API tests +claim to verify it automatically. Before the credential smoke, an independent maintainer who is not +the preflight verifier opens the repository's `build-container-release` environment settings and +captures a PNG showing the repository, environment name, and disabled administrator-bypass control. +The verifier supplies that file plus the reviewer's login and RFC 3339 review time to the preflight. +The helper rejects a non-PNG file, a reviewer equal to the verifier, a future review time, or evidence +whose recorded candidate head SHA differs from the current PR head; it records that SHA, the literal +basename, and `sha256:<64-lowercase-hex>` file digest under `environment.administrator-bypass` with +`allowed:false`, `verification:"manual-ui"`, `reviewer`, and `reviewed-at`. A separately +authenticated operator attaches the byte-identical PNG with the canonical evidence and digest to the +candidate PR. Release checkpoint 1 requires a maintainer other than the verifier and recorded reviewer +to recompute the attachment digest and confirm the screenshot visibly proves the disabled setting. +Any environment-policy change or new candidate commit invalidates this manual evidence. + +Before designating or tagging `S`, an operator runs the repository-owned preflight with a +read-administrative GitHub token, a separate package-audit token, the candidate PR number, the +expected App and installation IDs, and the App private key from a local file. The package-audit token +is a classic personal access token belonging to an active `stackpop` organization owner. Its granted +normalized OAuth-scope set is exactly `{read:org,read:packages}`; the helper verifies the +authenticated login, active owner membership, and returned `X-OAuth-Scopes` header before using that +same token for every package query. Neither local token is stored in GitHub Actions. They are supplied +only as `EDGEZERO_RELEASE_REPOSITORY_ADMIN_TOKEN` and +`EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN`, respectively. The helper rejects byte-equal token values +before making an API request and never logs either value. It never receives a PR-write token and never +mutates repository settings, packages, or comments. It requires all of the following and emits +canonical evidence for a separately authenticated operator to attach to the candidate PR: + +- environment `build-container-release` has administrator bypass disabled, a nonempty + `required_reviewers` rule with `prevent_self_review=true`, uses custom deployment policies, and has + exactly one deployment policy, type `tag`, with name `build-container-v*`; its separately supplied + administrator-bypass evidence satisfies the manual contract above; +- an active repository tag ruleset targets `build-container-v*` and restricts tag creation, update, + and deletion. Its only bypass actor is team `edgezero-build-container-releasers`, with the numeric ID + stored in `EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID` and bypass mode `always`; the verifier actor is + an active member of that team. An active default-branch ruleset requires the stable check names + `build-container-local` and `build-container-pin`. Each required-status-check entry has a non-null + `integration_id` equal to the single GitHub Actions App ID observed on the candidate's successful + check runs for those names; matching names from another integration do not satisfy the rule; +- protected-environment variables `EDGEZERO_BUILD_CONTAINER_APP_ID` and + `EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID` equal the reviewed numeric IDs, environment variable + `EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID` equals the ruleset's reviewed team ID, and secret metadata + includes `EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY` without exposing its value. Their `updated_at` + values are no later than the successful credential-smoke completion time; +- an App JWT made from that key identifies the expected dedicated App; the expected installation is + active on account `stackpop`, uses selected repositories, grants exactly `contents:write`, + `pull_requests:write`, and implicit `metadata:read`, and its repository list is exactly + `stackpop/edgezero`; +- an installation token can be minted for only the EdgeZero repository ID with explicit + `contents:write` and `pull_requests:write`, its response reports only those requested permissions + plus implicit metadata read, it can read `stackpop/edgezero`, and it is revoked before the helper + exits; and +- the candidate's `build-container-release-preflight` check run completed successfully, came from the + same GitHub Actions App integration, and belongs to a workflow run whose pull request, head + repository, and head SHA equal the current candidate values. It records the expected installation ID + without exposing a token; and +- repository/package identity and the absent-before-first-push or public-and-repository-linked package + state are the exact release state expected by the invocation. Absence is established only by a + successful, fully paginated organization-container-package listing made with the verified active + organization owner's package-audit token and containing no exact name match; a listing made by any + other identity, a GET 404, or an authorization failure is never absence. + +API failure, pagination truncation, ambiguity, extra bypass actor, extra repository or write +permission, credential failure, or evidence-post failure blocks `S`. After the first push creates the +package, publication stops until an operator makes it public and confirms it is linked to +`stackpop/edgezero`. GHCR exposes no enforceable per-version retention lock, so this contract does not +claim one. Repository workflows contain no package-deletion endpoint or delete-scoped credential; +manual deletion by a package or organization administrator is an accepted operational risk that can +break existing digest-pinned consumers and requires an emergency rebuild plus new reviewed pin. The +workflow also verifies `S` is an ancestor of the protected default branch. All publication and +pin-record mutation is serialized under one repository-global concurrency group with `cancel-in-progress: false`; different release tags cannot race the single `image.json`. Pin branches remain source/digest-derived and idempotent. -The publisher checks out without persisted credentials, proves `HEAD == S` and the recursive checkout -is clean immediately before the repository-root build, and excludes `.git`, build outputs, and local -detritus through the reviewed root `.dockerignore`. - -Pin branches and PRs use a short-lived, protected-environment GitHub App installation token scoped to -repository contents and pull requests. They do not use `GITHUB_TOKEN`: its push does not trigger push -workflows, and checks on its automation-created PR require manual approval, so it cannot guarantee the -automatic required-check path. The branch updater records the remote OID and uses an explicit -force-with-lease; ambiguous, closed, superseded, and already-merged PR states follow the fixture-tested -fail-closed state machine in the implementation plan. The App token is minted only after build and -anonymous image verification, so it cannot enter the repository-root build context. +The publisher has two jobs. `build-and-verify` does not reference the protected environment; it checks +out without persisted credentials, proves `HEAD == S` and the recursive checkout is clean immediately +before the repository-root build, pushes and anonymously verifies `D`, and exports only non-secret +`{S,D,protocol,tag}` job outputs. It excludes `.git`, build outputs, and local detritus through the +reviewed root `.dockerignore`. Only after that job succeeds does `update-pin` start with +`environment: build-container-release`. That job does no image build, receives the non-secret outputs, +checks out without persisted credentials, mints the scoped App token, and performs only the pin branch +and PR mutation. Thus the environment's private key is unavailable to the repository-root build job. + +Pin branches and PRs use a short-lived, protected-environment GitHub App installation token requested +for repository `edgezero` with explicit `contents:write` and `pull_requests:write`. The publisher +requires the token action's installation-ID output to equal +`EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID` before use. They do not use `GITHUB_TOKEN`: its push +does not trigger push workflows, and checks on its automation-created PR require manual approval, so +it cannot guarantee the automatic required-check path. The branch updater records the remote OID and +uses an explicit force-with-lease; ambiguous, closed, superseded, and already-merged PR states follow +the fixture-tested fail-closed state machine in the implementation plan. The App token is minted only +after build and anonymous image verification, so it cannot enter the repository-root build context. + +The administrator-bypass screenshot is repeated at the protected-secret boundary. For every workflow +run in which `update-pin` is eligible, including every same-tag rerun, its environment approver waits +for `build-and-verify` to succeed, opens the environment settings, and captures a fresh PNG before +approving `update-pin`. The record binds the screenshot digest, approver login, review time, workflow +run ID, exact source revision `S`, and release tag. The same login supplies the recorded environment +approval within 15 minutes of the review. The release operator attaches the record and byte-identical +PNG to the release evidence. A missed window, bypassed approval, run-ID mismatch, or known +environment-policy change invalidates the run and requires a new capture, approval, and workflow run. +The initial private-package stop does not carry evidence forward to its rerun. This per-attempt manual +check is required because the API-invisible setting cannot be proven current by the pre-`S` helper. + +The repository's zizmor policy uses `hash-pin` for every non-local action. The structural pin scanner +remains authoritative for lowercase 40-hex refs, strict Docker `sha256` digests, exact scanned +surfaces, and the documentation-only EdgeZero placeholder. ## 9. Testing @@ -541,10 +911,15 @@ Required automated coverage includes: config-push repo/config confinement, and deploy-without-sccache; - strict caller identity, full-SHA app/workflow/action refs, locally derived platform identity, matrix artifacts, and consumer recomputation for private repositories; -- all provenance golden/malformed fixtures, provider actions independently validating named - artifacts and rechecking the binary handoff, and the split parse/extract versus binary-smoke boundary; +- exact canonical metadata and expected JSON, schema versions, duplicate keys, byte-exact ustar + headers/padding/end blocks, deterministic package output, every accepted/rejected dynamic string and + object-acquisition tag, conservative ELF/loadability vectors, all provenance golden/malformed + fixtures, provider actions independently validating named artifacts and rechecking the binary + handoff, and the split parse/extract versus binary-smoke boundary; - exact Rust/Fastly/sccache versions, installed wasm target plus a minimal wasm compile, image labels, - leaf-manifest platform checks, anonymous pulls, and release rerun/idempotency; + leaf-manifest platform checks, anonymous pulls, always-materialized required container jobs, + image-pin deletion, environment reviewer/self-review/deployment-policy checks, App + installation/repository/permission/token-scope checks, and release rerun/idempotency; - production/staging deploy, active-version, healthcheck, rollback, config push, mutation signaling, cancellation, and the exclusive `--staging` spelling. @@ -557,12 +932,16 @@ condition. Before implementation is published: 1. Migrate every existing non-local external action and reusable workflow reference in the repository - to a reviewed full 40-hex commit SHA and change the repository-wide pin gate accordingly. -2. Land the validator/schema/fixture capability set before the container publication tasks. -3. Publish and anonymously verify the image, then commit the pin and permanent gate as baseline `B`. -4. Land reusable workflow, cache, provenance, launcher, and consumer integration, then designate the + to a reviewed full 40-hex commit SHA, change the repository-wide pin gate accordingly, and set + zizmor to `hash-pin`. +2. Implement the validator/schema/fixtures, container, publisher, and always-running container checks + on one source-candidate PR; no image is published from that branch. +3. After the stable check names materialize on the candidate PR, configure and verify every external + release prerequisite, require both checks, and merge the passing candidate as source `S`. +4. Publish and anonymously verify the image, then commit the pin and permanent gate as baseline `B`. +5. Land reusable workflow, cache, provenance, launcher, and consumer integration, then designate the passing final action revision as `P`. -5. Update the parent spec, implementation plan, adoption guide, and public guide together. Remove +6. Update the parent spec, implementation plan, adoption guide, and public guide together. Remove direct-composite producer guidance; document the two-job producer/consumer topology, explicit `app-env` migration from ambient workflow environment, and `generated-output-paths` for repository-writing credential-free app builds. @@ -576,6 +955,8 @@ Caching remains off by default. Container execution and provenance validation ar - Caching dependency source archives, private dependency credentials, native-tool sccache wrapping, self-hosted runners, alternate toolchains, non-default feature sets, or non-Fastly adapters. - Cache lineage merging, family-local eviction, or action-managed cache deletion. +- Preventing a GHCR package administrator from manually deleting a supported image digest; GitHub does + not expose a per-version retention lock for this contract. ## 12. History @@ -587,11 +968,20 @@ Caching remains off by default. Container execution and provenance validation ar action-derived; replaced impossible same-SHA publication with image source `S`, pin baseline `B`, and final action revision `P`; made full-SHA external references normative; and made validator capability fixtures a hard publication prerequisite. +- **v6.19:** froze the protocol-1 metadata and expected-identity JSON shapes, schema/version bounds, + byte-exact ustar encoding, conservative ELF/loader profile including exact dynamic-string and + object-acquisition semantics, and shared package/validate authority; selected the exact base and + sccache artifacts; moved release prerequisites before `S` with verifiable environment and + least-privilege App controls, including explicit manual evidence for the API-invisible administrator + bypass setting and an organization-owner package-audit identity; aligned zizmor with full-SHA policy; + removed unenforceable GHCR retention claims; and replaced path-filtered required image jobs with an + always-triggered workflow whose stable jobs explicitly succeed when not applicable. ## 13. Deferred implementation mechanics -Implementation plans may choose helper names and internal module boundaries. They must commit exact -schema files, golden bytes, malformed fixtures, sccache v0.10 layout/stats fixtures, exact -tar/compression archive-bound and entry-count vectors, provider environment name allowlists, release -SHAs/checksums, and command-level tests before publication. +Implementation plans may choose helper names and internal module boundaries. They must commit the +schema implementing Section 6.2, golden bytes and malformed fixtures for Sections 6.2 through 6.5, +sccache v0.10 layout/stats fixtures, exact cache tar/compression archive-bound and entry-count vectors, +provider environment name allowlists, release SHAs/checksums, and command-level tests before +publication. Those are mechanics, not permission to weaken the contracts above.