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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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. From 97b2eec30ded38d0cef69c9981fdbe82c13fd0d3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:42:06 -0700 Subject: [PATCH 11/11] docs: finalize build cache design and plans --- .../plans/2026-08-20-build-cache-actions.md | 176 ++ ...026-08-20-build-cache-consumer-adoption.md | 268 +++ .../plans/2026-08-20-build-cache-container.md | 1307 ++++++++----- ...26-08-20-build-cache-launcher-providers.md | 207 +++ .../2026-08-20-build-cache-provenance.md | 179 ++ ...20-edgezero-deploy-build-caching-design.md | 1636 ++++++++++++++--- 6 files changed, 2984 insertions(+), 789 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-20-build-cache-actions.md create mode 100644 docs/superpowers/plans/2026-08-20-build-cache-consumer-adoption.md create mode 100644 docs/superpowers/plans/2026-08-20-build-cache-launcher-providers.md create mode 100644 docs/superpowers/plans/2026-08-20-build-cache-provenance.md diff --git a/docs/superpowers/plans/2026-08-20-build-cache-actions.md b/docs/superpowers/plans/2026-08-20-build-cache-actions.md new file mode 100644 index 00000000..e9410183 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-build-cache-actions.md @@ -0,0 +1,176 @@ +# Build Cache Actions Implementation Plan (plan 2 of 5) + +> **Execution:** Start only after `2026-08-20-build-cache-container.md` records passing +> `{G,S,D,B}`. Use test-driven development and the gate-rotation procedure for every gate-owned test +> or helper change. + +**Goal:** Implement the shared app-source/identity/environment preflight and the optional sccache +restore/compile/save primitive that later plans consume, without making cache availability part of +build correctness or exposing credentials to compilation. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.26 Sections +2 through 5 and 9. + +## 1. Fixed decisions + +- Pin `actions/cache/restore@v6.1.0` and `actions/cache/save@v6.1.0` exactly. +- Pin host Git LFS to exact `3.7.1` and verify + `git-lfs-linux-amd64-v3.7.1.tar.gz` against SHA-256 + `1c0b6ee5200ca708c5cebebb18fdeb0e1c98f1af5c1a9cba205a4c0ab5a5ec08` and exact size + 5,524,590 bytes before installation. +- Cache only `${RUNNER_TEMP}/edgezero-sccache-v1`, mounted as `/work/sccache`; never cache target, + Cargo home, sources, registry data, credentials, or artifacts. +- Cache family is exactly `edgezero-sccache-v1--`, generation is exact + `job.check_run_id`, and the only restore prefix is `-`. +- `cache: false` selects `uncached-compile` and performs no restore, cache audit, sccache mount/start, + stats, stop, or save. `cache: true` accepts the documented stale-object and disclosure risks but + does not authorize save by itself. +- After the required metadata preflight, Cargo has exactly one compile/build invocation. The action + has no compile retry. Pinned sccache's documented post-`CompileStarted` local fallback is accepted + internal client behavior. + +## 2. Gate update before implementation + +- [ ] Add gate-owned failing fixtures and structural tests for the exact cache action versions, + authority/export boundaries, shared identity/environment/toolchain policy, stable host/path + literals, key grammar, separate lookup/save predicates, no credential mount/environment, exact + ordering, warning-only restore/save behavior, and one Cargo compile/build invocation after the + metadata preflight. +- [ ] Add shell fixtures for sccache 0.10.0 layout/stats, including corrupt records, unexpected paths, + write errors, stop failure, sparse files, hardlinks, special files, owner mismatch, path limits, + entry-count limit, and checked-byte overflow. +- [ ] Add closed canonical `.github/actions/deploy-core/host-tools.json`, the trusted Git LFS + installer, object-first authority materializer, exporter implementation, and focused tests to + the gate-owned path manifest. These exact runtime helper bytes are part of the gate update; no + public action metadata or workflow calls them yet. The manifest has exactly the design's literal + JCS bytes; trusted code constructs the fixed official release URL. + Permit at most three HTTPS redirects from exact `github.com` to a final exact + `release-assets.githubusercontent.com` host without credentials. Require final HTTP 200, + identity encoding, one exact `Content-Length: 5524590`, a streaming cap of 5,524,590 bytes, and + exact received size. Reject unknown/missing fields, unsupported platforms, a different asset/ + version/size, downgrade/host drift, absent/duplicate/malformed/mismatched length, chunked or + oversized/partial/trailing transfer, checksum or archive-layout mismatch, and replacement or + wrong-version execution of the installed binary. +- [ ] Land those tests as a gate-update PR under old `G`, activate the resulting gate revision, and + complete the full rotation/recovery checklist. Record it as this plan's active gate. Do not mix + public action wiring/metadata into the gate PR; the reviewed materializer/exporter helper itself + is gate code and lands here. Do not publish a new image because image-context bytes are unchanged. + +## 3. Shared build preflight, identity, and keys + +- [ ] Write failing tests for the length-framed `workspace-id` and suffix hash. Commit vectors for root + `.`, nested workspace, repository IDs of different decimal lengths, empty/255-byte suffixes, + overlong/control suffixes, malformed UTF-8 paths, and byte-distinct NFC/NFD names. +- [ ] Implement identity calculation in one shared host helper, which remains the sole owner in later + plans. Validate the authority checkout, canonical `git-root`, workspace and working-directory + containment, tracked regular `Cargo.lock`, and credential-free `cargo metadata --locked` + agreement before constructing either hash. +- [ ] Consume, without modifying, the active gate's shared authority materializer and trusted + exporter. Wire validated repository/ref/token inputs to those immutable helper bytes. They + install the exact verified Git LFS binary; fetch exact commits with system/global config, + includes, templates, hooks, credential helpers, worktree creation, filter execution, and + submodule initialization disabled; recursively validate committed attributes/config/gitlinks; + then create worktrees with filters disabled and materialize through the absolute LFS binary. + Require their existing tests for no forbidden command/origin contact, pointer residue, + configuration races, credential cleanup, and `.git`-free non-hardlinked Copy A/B to pass. Any + required helper change stops action work and returns to a separate gate rotation. +- [ ] Move the duplicate-rejecting bounded `app-env` decoder, empty Cargo-config/credentials policy, + path-dependency confinement, and exact `rust-toolchain` comparison into shared helpers here. + Deny exact `RUSTC`/`RUSTDOC`, every `RUSTC_`/`RUSTDOC_` name, and native-tool/flag prefix and + suffix forms including `CC_`, `_CC`, `HOST_CC`, `TARGET_CC`, and corresponding + `ARFLAGS`/`CFLAGS`/`CXXFLAGS`/`CPPFLAGS`/`LDFLAGS` variants. Also deny `CXXSTDLIB`, + `CXXSTDLIB_STATIC`, `CRATE_CC_NO_DEFAULTS`, all `CRATE_CC_*`, and every other exact design + control. Commit the full valid/invalid name/value/filter/config/toolchain fixtures and prove both + cached and uncached profiles reject compiler/wrapper/native-build replacement. Plans 3 and 4 + call these helpers and must not reimplement them. +- [ ] Emit shell-safe typed outputs and reject duplicate, missing, multiline, or malformed output. + Tests independently recompute SHA-256 bytes; they do not call the implementation as oracle. +- [ ] Require hosted `linux/amd64`, exact workflow repository/path/ref/SHA identity, full lowercase + app ref, and canonical positive `job.check_run_id` before any cache action runs. + +## 4. Restore and pre-use audit + +- [ ] Compute lookup eligibility before creating the host root or invoking `actions/cache/restore`. + First authenticate `app-repository`, verify its actual repository id and authority checkout, + and reject a mismatched caller `app-repo-id`. Same-repository builds are eligible only when that + verified id equals the event repository id; cross-repository `cache:true` requires boolean + `disclosure-acknowledged:true`. Missing, false, stringified, or malformed acknowledgement fails + the action before restore rather than degrading to restore-only. Commit a complete lookup truth + table, including a forged same-repository id, independent of save authorization. +- [ ] Create the stable host directory fresh and prove its canonical path equals the fixed runner-temp + child. Require it absent before create, mode 0700, uid/gid 1001, non-mount status, and stable + device/inode. Reject symlinked runner temp, any preexisting path, root replacement, nested mount, + wrong owner, or cleanup failure. +- [ ] Restore with exact primary key and sole family prefix. Restore failure, absence, download error, + or audit failure emits a warning, removes the complete host directory without following links, + recreates it empty, and continues cold. +- [ ] Implement the audit over descriptor-relative traversal. Accept only expected sccache 0.10.0 + regular files/directories beneath root with container uid/gid and `nlink==1` for files. Reject + links, sockets, FIFOs, devices, nested mounts, path escape, unknown layout, and arithmetic error. +- [ ] Enforce at most 2,147,483,648 summed regular-file `st_size` bytes, no sparse file + (`st_blocks*512 < st_size`), at most 100,000 descendants, path length at most 4,096 bytes, and + component length at most 255 bytes. Do not inspect or predict the cache action's tar/zstd wire + representation. + +## 5. Compile lifecycle + +- [ ] Implement a closed `uncached-compile` branch with Copy A, fresh target/Cargo home, and the shared + validated environment, but no sccache mount, process, socket, wrapper, or `SCCACHE_*` variable. + Invoke Cargo compile/build exactly once after metadata and test the complete mount/environment/ + process snapshot. +- [ ] Launch the pinned image by digest with read-only root, uid/gid 1001, dropped capabilities, + `no-new-privileges`, 6 GiB memory/no extra swap, 512 pids, bridge network, numeric integer + `timeout-minutes` in 1..120, and only the cached-compile mounts from the design. + Checkout tokens, GitHub file-command paths, provider inputs, and provider tokens must be absent. +- [ ] Construct the exact closed environment, including absolute `RUSTC_WRAPPER`, `SCCACHE_DIR`, 2G + managed size, `SCCACHE_IGNORE_SERVER_IO_ERROR=1`, zero incremental mode, empty encoded rustflags, + and validated `app-env`. Enforce the design's empty Cargo-config policy before launch. +- [ ] Start sccache, zero stats, invoke Cargo compile/build exactly once after metadata with locked inputs, capture exact + `sccache --show-stats --stats-format=json`, and stop the server. Validate the complete closed + v0.10.0 `ServerInfo`/`ServerStats` schema before reading `stats.cache_write_errors`: the six + top-level fields and all 22 exact `stats` fields listed by the design, exact language/count-map + and duration shapes, `cache_location` equal to `Local disk: "/work/sccache"`, bounded nonnull + `cache_size`, `max_cache_size:2147483648`, false preprocessor mode, and version `0.10.0`. Reject + duplicate/unknown/missing fields, wrong types, invalid duration nanoseconds, negative/overflow + counters, wrong version/cache path, and trailing data. Surface Cargo failure once. A wrapper startup/connection or + nonaccepted sccache failure is a build failure, not an action retry. +- [ ] On successful compile, require stop success, parse exact 0.10.0 stats, and rerun the complete + stopped-directory audit. Stop failure, malformed stats, nonzero `cache_write_errors`, or final + audit failure skips save with a warning but preserves successful build output. + +## 6. Save authorization + +- [ ] Commit a complete save truth table distinct from lookup eligibility. Save is true only for exact `push` or `workflow_dispatch`, boolean + `github.ref_protected==true`, boolean `github.event.repository.fork==false`, equal event/current + repository IDs, passed workflow identity, successful compile/stop/audit, zero write errors, and + satisfied cross-repository disclosure. Missing, stringified, malformed, or caller-supplied + substitutes are false. +- [ ] Explicitly cover `pull_request`, `pull_request_target`, `merge_group`, fork events, deleted or + unprotected refs, repository mismatch, same-repository disclosure exemption, and cross-repository + acknowledgement. A save-denied row remains restore-only only when lookup eligibility passed. +- [ ] Save with the immutable generation key. Save failure is warning-only. Verify no cache deletion, + reservation protocol, fallback key, mutable exact-key overwrite, or post-token save exists. +- [ ] After the sole save attempt or every earlier terminal path, remove only the recorded root by + descriptor-relative no-follow traversal and verify it is absent. Cleanup failure is fatal even + when restore/save failure was warning-only; test consecutive invocations in one job cannot + inherit a dirty fixed directory. + +## 7. Integration and completion + +- [ ] Integrate the helper only into a non-public test harness under the protected contract suite. The + build-only reusable workflow does not exist before plan 3, and the current direct-composite + producer must not expose an intermediate cache/provenance contract. Do not change provider or + public producer behavior in this plan. +- [ ] Run cold, warm, corrupt-restore, concurrent-generation, stop-failure, write-error, audit-failure, + save-denied, save-warning, and sccache response-loss fixtures. Assert the design's exact cold + Rust miss/write counters, a new-job warm restore with at least one post-zero Rust cache hit and + equal binary digest, and complete default-off absence; elapsed time is never evidence. +- [ ] Run shellcheck, the protected contract/container harness, `scripts/run-actionlint.sh`, zizmor, + and all Rust checks. Defer real reusable-workflow cold/warm evidence to plan 3 after the workflow + exists. Confirm all non-local actions remain exact-version pinned. +- [ ] Merge through the one-entry queue. Record the resulting commit and active gate revision for plan + 3; do not designate final `P` yet. + +**Gate:** the isolated primitive compiles correctly with an empty or unavailable cache; no restored +byte or application process can observe a credential; and only an action-derived authorized event can +attempt a save. It is not a supported producer until plan 3 integrates it. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-consumer-adoption.md b/docs/superpowers/plans/2026-08-20-build-cache-consumer-adoption.md new file mode 100644 index 00000000..b7560e2d --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-build-cache-consumer-adoption.md @@ -0,0 +1,268 @@ +# Build Cache Consumer and Adoption Implementation Plan (plan 5 of 5) + +> **Execution:** Start after plan 4 is merged and its gate revision is active. Select candidate exact +> version `C` before opening the final executable candidate. Select and publish stable `V` only after +> that merged commit passes detached local and immutable-candidate hosted verification; release the +> runnable documentation afterward as protected revision `R`. + +**Goal:** Ship the two-job producer/consumer workflow, prove real app repositories can adopt it with +full-SHA app identity and explicit inputs, publish exact stable action version `V`, then activate +synchronized runnable documentation at revision `R` without ever merging an unpublished version ref. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.26 Sections +3.3, 5.2, 7, 9, and 10, plus the parent deploy lifecycle contract. + +## 1. Release structure + +- `C` is an unused canonical exact patch version with no prerelease suffix. After the final candidate + reaches protected main as commit `H`, `C` is published as an immutable GitHub Release at `H` with + `prerelease:true` so hosted cross-repository workflows can test the exact merged bytes while every + `uses:` ref remains a full version. +- `H` becomes final action revision `P` only after the complete clean-detached local suite at `H` and + complete hosted suite through literal `C` pass. Only then is distinct unused canonical stable + version `V` selected and published as an immutable release at exact `P`. +- `R` is a later documentation-only protected-main commit. After the literal-`V` hosted smoke passes, + it adds canonical `docs/.edgezero-action-release.json` bound to `{V,P}` and replaces the bootstrap + placeholder in tracked Markdown with literal `V`. It changes no action/workflow implementation or + gate-owned path, and it is not a new action revision. +- Repository immutable releases must report enabled, and exact no-bypass `refs/tags/v*` rules prevent + action-version tag update/deletion. Failed candidate versions remain immutable; a correction uses a + new commit and newly selected unused `C`. A failed published stable version is never + retargeted; it is superseded by a new patch. Deletion of a GitHub Release object or mutation of its + title, notes, prerelease, or latest metadata by a sufficiently privileged actor remains an accepted + availability/discovery risk, so final evidence preserves release attestations and audits release + existence/state; tag deletion and retargeting remain blocked by the no-bypass ruleset. +- The gate built in plan 1 owns the entire bootstrap-to-released documentation transition. Before + `R`, only the four named prepublication documents may use the exact placeholder and are not claimed + runnable. At and after `R`, all tracked Markdown is placeholder-free and every EdgeZero `uses:` ref + names literal stable `V`. + +## 2. Gate update and contract fixtures + +- [ ] Add parsed-workflow tests for exact two-job separation, canonical exact patch-version + reusable-workflow/action refs, unique artifact names, caller and action identity forwarding, + public identity-action use, action-local source materialization with no authority-path handoff, + no aggregate matrix identity, minimal permissions, secret confinement, and absence of + direct-composite producer use. +- [ ] Add public/private app repository fixtures, root/nested workspace fixtures, public Git and + sibling path dependencies, submodules, no-filter and pinned-LFS cases, app-env migration, + generated outputs, production/staging provider paths, and cache enabled/disabled cases. +- [ ] Add negative fixtures for major/minor/prerelease/SHA/branch refs in published workflows, mixed + EdgeZero versions, producer provider inputs, missing authority/Copy B identity check, authority + path/descriptor/handle passed between actions, checkout token passed to a source-free action, + artifact-name reuse, caller-supplied platform/expected identity, ambient env reliance, custom + Git filters, and legacy `--stage`. +- [ ] Land and activate this gate update before changing reusable workflow/action interfaces. The gate + validates version grammar and equality, not a not-yet-created value for `V`. + +## 3. Final reusable producer workflow + +- [ ] Freeze `.github/workflows/build-app-cli.yml` as a build-only `workflow_call` interface with the + exact design inputs, required `rust-toolchain`, `app-checkout-token` secret, hosted-only workflow + version/resolved-SHA identity checks, bounded timeout, cache default `false`, and no provider + credential or mutation surface. +- [ ] Materialize EdgeZero source separately at exact `job.workflow_sha`. Checkout the app into a + recursive non-sparse authority root at full lowercase `app-ref`, verify authenticated repository + id and CallerExpectedIdentity, remove credentials, enforce the no-filter-or-pinned-LFS contract, + and invoke the shared exporter to create non-hardlinked `.git`-free Copy A. Prove no local action + or helper resolves from app data. +- [ ] Compile/package through the digest-pinned image and upload one deterministic named artifact. + Reverify the authority after use and remove Copy A plus every operation root on all exits. +- [ ] Emit only `artifact-name`, trusted `action-version`, resolved `action-revision`, and the five + CallerExpectedIdentity fields. Do not expose host paths, image ref/digest, protocol, cache path, + token, provider state, or an aggregate matrix value. +- [ ] Preserve separate cache lookup and save decisions: cross-repository cache use without disclosure + acknowledgement fails before restore; save additionally requires the protected-event predicate. + `cache:false` uses the no-sccache `uncached-compile` profile and Cargo has exactly one compile/ + build invocation after metadata preflight. + +## 4. Consumer job topology + +- [ ] Provide tested workflows in which job 1 calls the reusable producer and job 2 invokes public + `compute-app-cli-identity` at the same action version with the exact + repository/ref/id/workspace/cwd/package/bin/toolchain inputs and `app-checkout-token`. Compare + all five typed outputs with the producer and only then invoke provider actions with the named + artifact. The identity action destroys its action-private authority before returning and exposes + no host path or handle. +- [ ] Pin every EdgeZero reusable workflow and action within a published consumer workflow to one + identical literal stable `V`. Generated candidate-release tests substitute one identical literal + `C`. Reject mixed versions, major/minor tags, prereleases in published examples, SHAs, and + branches even when each ref resolves. +- [ ] Pin every third-party action to a separately reviewed canonical stable patch version. Record its + release URL and resolved commit in release evidence and prove no same-named branch exists at + review time. Later version-tag movement/deletion or same-name branch ambiguity introduced by a + trusted third-party publisher is accepted risk; branches, major/minor tags, prereleases, commit + SHAs, and mutable Docker tags remain prohibited in repository text. +- [ ] Pass producer `action-version` and `action-revision` to the comparison step. Every EdgeZero + composite requires its runner-provided action repository/ref to equal + `stackpop/edgezero@` before downloading anything. EdgeZero immutable release and + tag protections bind `V` or `C` to the producer revision. +- [ ] Give each matrix leg a deterministic unique artifact name and keep its identity comparison in + that leg. Reject aggregation, `merge-multiple`, wildcard downloads, and outputs inferred from a + matrix-wide reusable-workflow call. +- [ ] Keep checkout tokens host-side and provider tokens only in consumer steps that require them. + Pass `app-checkout-token` only to the identity action and the exact source-bearing actions + `deploy-fastly` and `config-push-fastly`; each independently materializes its authority and + removes its checkout credential channel before app code or provider-token creation/injection. + Source-free actions receive neither source-materialization inputs nor the token. Define explicit + job permissions and prove artifacts, caches, summaries, logs, and outputs contain neither token. +- [ ] Exercise `validate-app-cli-provenance`, `active-version-fastly`, `deploy-fastly`, + `healthcheck-fastly`, `rollback-fastly`, and `config-push-fastly` as independent consumers. Each + action downloads its own artifact, derives PlatformIdentity locally, writes a fresh expected file + from verified caller plus local platform fields, validates/smokes, and cleans its private state. + `deploy-fastly` and `config-push-fastly` additionally rematerialize and verify independent + action-local authorities; they never reuse the identity action's destroyed authority. + +## 5. App-repository migration behavior + +- [ ] Replace ambient workflow `env` examples with the explicit duplicate-safe `app-env` JSON object. + Document the exact deny rules and state that otherwise allowed values are caller-classified as + non-secret and may affect cross-repository compilation cache contents. +- [ ] Add `generated-output-paths` only for absent repository-relative roots genuinely written by the + credential-free app build. Explain that selected Fastly project `bin` and `pkg` roots are + implicit, callers must not list them, and preexisting/overlapping/tracked-containing roots fail. +- [ ] Require full app commit SHA, canonical repository id, explicit workspace root/package/bin and + `rust-toolchain`, and a tracked lockfile. Cover nested workspaces and private authority checkouts + without suggesting app branch or tag refs. +- [ ] Document the exact consumer authority interface: `compute-app-cli-identity` returns only five + typed identity fields; source-bearing actions receive the same explicit source inputs plus the + checkout token and rematerialize independently; source-free actions receive neither. Do not + document or expose an authority path, checkout step output, or reusable handle. +- [ ] State that the caller repository's effective Actions policy must permit version-tag action and + reusable-workflow refs; an organization/enterprise full-SHA mandate is incompatible with this + release policy and must fail adoption preflight rather than trigger an undocumented SHA fallback. +- [ ] Explain that protocol 1 rejects custom Git filters, supports only no filter or the action's + pinned Git LFS materialization path, rejects repository/enclosing Cargo config and credentials, + requires the image toolchain, permits only public dependency fetching, and preserves the + accepted undeclared proc-macro/build-script cache risk. +- [ ] Keep caching opt-in and distinguish `build-app-cli.cache` from `deploy-fastly.cache`. Both require + disclosure acknowledgement before cross-repository restore and use the same protected-event save + predicate. The latter applies only to credential-free app-build, saves before token introduction, + and does not prevent Fastly's token-bearing deploy compile. +- [ ] Preserve production/staging, first-deploy, healthcheck, rollback, cancellation, config-push, and + mutation-attempt semantics from the parent guide. Every staged command uses `--staging`; no + compatibility alias or legacy `--stage` instruction remains. + +## 6. Candidate-independent integration preparation + +- [ ] Create disposable public and private app repositories or equivalent GitHub-owned fixtures with + immutable source SHAs. Validate their repository ids, authority/export state, required LFS cases, + explicit app inputs, and provider test credentials before selecting `V`; do not replace hosted + evidence with local `act` or Docker-only tests. +- [ ] Build a release harness that writes one exact EdgeZero version into every producer/provider ref, + verifies all refs match, triggers hosted linux/amd64 runs, polls exact run/job attempts, and + records artifact/image/action/app identities without logging credentials or app-env values. +- [ ] Locally prove cold/warm/default-off cache behavior, source relocation, nested workspace identity, + artifact transfer, identity-action/source-bearing authority separation, Copy A/Copy B + independence, expected-file freshness, and exact caller/platform/action validation using + generated fixtures. +- [ ] Prepare production/staging provider fixtures for successful deploy, unhealthy rollback, first + deploy, active-version, healthcheck, config push, and cancellation reconciliation. Assert no + mutation on identity, source-freeze, output-root, loader, or token-order failure. + +## 7. Build final candidate `H` + +- [ ] Query current releases and remote refs, select unused canonical patch version `C`, and validate + its grammar. Require no current Git ref or release with that `tag_name`; release display `name` + is not identity and supplies no historical proof. Record the queries and results, and require + later creation to succeed without tag reuse. Do not create the tag yet, and do not select `V`. +- [ ] Reconcile the parent spec, original implementation plan, adoption guide, and public guide + against shipped metadata: two-job topology, authority materialization, action/caller identity, + exact app inputs, `app-env`, generated outputs, cache defaults, and provider lifecycle. Keep the + exact `` bootstrap placeholder in these four prepublication documents; + do not introduce a guessed or unpublished stable version. +- [ ] Run plan 1's permanent documentation scanner in bootstrap mode over every tracked Markdown + file. Prove `docs/.edgezero-action-release.json` is absent, the placeholder appears only in the + four named surfaces, no other action-ref placeholder exists, and every third-party ref is an + exact stable patch version. Do not modify the gate-owned scanner in this plan. +- [ ] Run every protocol, cache, image, launcher, source-freeze, provider, workflow, fixture, docs/pin, + actionlint, zizmor, shellcheck, Rust, and local integration suite at one clean candidate descended + from `B`. Confirm `image.json` remains reviewed `{D,S,protocol}`. +- [ ] Run independent contract and release-adversary reviews against design v6.26, including exact-tag + policy, third-party tag movement risk, EdgeZero immutable releases, action-version mixing, + substitution, identity replay, malformed artifacts, host/container races, source mutation, + generated-output escape, credential flow, cache disclosure, rollback, and cancellation. +- [ ] Merge through the one-entry queue and require exact protected-main push checks. Record the + resulting lowercase full commit SHA as `H`; do not designate it `P` or publish stable `V` yet. + +## 8. Qualify `H` and publish `V` + +- [ ] From a clean detached checkout at exact `H`, rerun the complete local suite from Section 7, + including the bootstrap-state docs/pin scanner, exact staged container build, Docker-backed + provider tests, and repository CI commands. Preserve logs and digests bound to `H`. +- [ ] Select the release operator before `C` exists. Use a short-lived fine-grained PAT selected only + for `stackpop/edgezero`, expiring within 24 hours, with exactly repository `Contents:write` and + `Workflows:write`, implicit metadata read, organization `Members:read`, and no other grant. Keep + it outside Actions/argv/environment, reject classic or installation tokens, and preserve its + settings as review evidence. The local helper permits only no-redirect versioned `GET /user`, + exact organization-id/team-id membership GET, release POST, release-id PATCH, and release-id GET + routes from the design. Require active team membership and no unallowlisted method/path/query/ + body field; destroy the token after release work. +- [ ] For each action release, create a draft with exact `tag_name`, full target commit, no assets, + and required `prerelease` boolean; publish only by a PATCH that changes `draft` to false. Record + requests/responses, authenticated login, release `author.login`, team membership, release id/ + URL, remote peeled ref, exact state, and generated attestation. A different actor, broader token, + or direct unrecorded tag creation fails release. +- [ ] Reconfirm the immutable-releases endpoint returns HTTP 200 with `enabled:true` and boolean + `enforced_by_owner`, and both action-version tag rulesets are exact. Publish candidate `C` as an + immutable release targeting exact `H` with API field `prerelease:true`; require its exact + patch-version `tag_name`, `draft:false`, `immutable:true`, and the remote peeled tag to resolve + to `H`, and record the generated release attestation. Never move or delete a failed candidate + tag. +- [ ] Run the complete hosted cross-repository suite with every EdgeZero workflow/action ref equal to + literal `C`: cold/warm/default-off caches; public/private and LFS source; two matrix identities; + artifact/identity/expected handoff; all provider lifecycles; all negative pre-mutation cases; and + cancellation reconciliation. Verify exact run attempts and `action-revision==H`. +- [ ] If either exact-`H` suite fails, fix on a new commit, choose a new unused `C`, and repeat Sections + 7-8; no stable `V` has been selected. After both pass, designate `H` as final action revision `P`. +- [ ] Query current releases and remote refs, then select a distinct unused canonical stable patch + version `V`. Require no current ref or release with that `tag_name`. Publish `V` through the same + exact fine-grained-PAT actor/draft/publish procedure targeting `P`; require API and anonymous + peeled-ref resolution to equal `P`, `draft:false`, `prerelease:false`, and `immutable:true`. + Preserve the attestation/operator evidence and verify no major/minor alias was created or moved. +- [ ] Run a final hosted producer/consumer identity smoke with every EdgeZero ref literal `V` and + require `action-revision==P`. A post-publication failure does not permit changing `V`; publish a + corrected new patch through the normal protected process. Do not create documentation revision + `R` until this smoke passes. + +## 9. Publish documentation revision `R` + +- [ ] Create canonical `docs/.edgezero-action-release.json` with exact JCS bytes + `{"action-revision":"

","action-version":"","schema-version":1}` and no trailing newline. + Replace every `` ref in tracked Markdown with literal `V`. Change only + tracked Markdown plus that record; do not touch action/workflow code, metadata, gate-owned paths, + or implementation fixtures. +- [ ] Run the plan-1 dual-state scanner on the documentation PR and its final merge-group candidate + using the closed event-to-range table. The selected base is the then-current protected-main + commit, which may be newer than `P`; the synthetic candidate is not named `R`. Require the + one-way bootstrap-to-released transition, exact record schema/JCS, no placeholder in any fenced + YAML, one identical EdgeZero `V` per workflow, and exact third-party patch versions. The hosted + transition verifier must prove public release `V` is `draft:false`, `prerelease:false`, + `immutable:true` and its API target and anonymously peeled ref both equal record `P`. +- [ ] Parse every fenced YAML example, validate it against action/workflow metadata, and prove examples + are runnable after only repository/application-value substitution. Run the complete docs build, + pin scanner, actionlint, and required repository checks; merge through the one-entry queue and + record the resulting protected-main commit as `R`. Require the protected-main push scanner to + compare `event.before` with `event.after==R`, then rerun released-state checks. Do not move or + recreate `V`. + +## 10. Final review + +- [ ] Compare every documented input, output, default, secret, permission, mount, environment, + artifact, cache, source-freeze, provider, and failure behavior at documentation revision `R` to + actual action metadata/tests at `P`. +- [ ] Search for forbidden external major/minor/prerelease/branch/SHA `uses:` refs, EdgeZero version + mismatches, direct `build-app-cli` composite producer guidance, caller-provided platform or + expected fields, ambient app env, writable source mounts outside declared roots, and legacy + `--stage`. Every hit must be a clearly marked rejected example or fail documentation release. +- [ ] Verify public anonymous image pull by digest and an end-to-end fresh app adoption from the + published guide using literal `V`. Record image-release gate `G`, the ordered gate-rotation + lineage and final active gate, `{S,D,B,P,C,V,R,protocol}`, action-version resolved commits, + release attestations, hosted run ids/attempts, and documentation-check results. Do not collapse + the post-`B` gate revisions into the image-release `G` label. + +**Gate:** exact merged revision `P` passed the complete local and candidate-version hosted suites; +immutable stable release `V` resolves to `P`; protected documentation revision `R` activated the +one-way released scanner state and all concrete consumer examples use `V`; no consumer relies on a +floating ref, same-job producer shortcut, ambient application environment, or provider mutation +before independent validation. 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 fddee4e2..5a668991 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -1,4 +1,4 @@ -# Build-Cache Container Implementation Plan (plan 1 of 4) +# Build-Cache Container Implementation Plan (plan 1 of 5) > **Execution:** Use `superpowers:subagent-driven-development` or > `superpowers:executing-plans`. Follow the tasks in order and stop at every release checkpoint. @@ -6,13 +6,18 @@ **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`. +**Architecture:** A separately landed, immutable gate baseline `G` owns the validator, fixtures, +classifier, image verifier, publisher checker, exact Dockerfile, complete image-context manifest, and +organization-required workflow. Source revision `S` is an isolated canonical release request whose +repository image-context bytes remain identical to `G`. The publisher stages a fresh context solely from the +verified `G` checkout, captures and verifies immutable digest `D`, proves anonymous access, and opens +an idempotent, forward-only App-authored PR adding the image and release-evidence records. That pin +forms baseline `B`. Four remaining feature +plans land on top. Their final passing action revision `P` contains the unchanged `{D, S, protocol}` +record and the adoption documents for exact stable version `V`; consumers pin immutable release +version `V`, which resolves to `P`. -**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.19. +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.26. **Tooling:** Rust, Docker BuildKit/buildx, GHCR, GitHub Actions, Bash 3.2, `jq`, `gh`, `actionlint`, `shellcheck`, and `zizmor`. @@ -33,28 +38,44 @@ Consumers pin all EdgeZero actions and reusable workflows to full SHA `P`. - 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. +- Every committed non-local external action and reusable workflow ref is a canonical exact stable + `v..` release tag. Major/minor tags, prereleases, branches, commit SHAs, and + floating refs fail. Docker image refs use immutable `sha256` digests. Local `./...` actions remain + local refs. Third-party tag movement/deletion and future same-name branch ambiguity are explicitly + accepted risks; EdgeZero `V` is an immutable release protected by no-bypass tag rules. +- In this plan, an action/workflow "pin" means that exact stable version tag. Full Git commit SHAs + identify app source, resolved workflow execution, protected gate/release commits, and the + organization required-workflow descriptor only; they are never written as non-local `uses:` 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. +- Candidate source never supplies the workflow, classifier, verifier, completion marker, or publisher + policy used to approve itself. Gate code runs from full SHA `G` with no secret or mutation token. +- The exact actionlint version is `1.7.12`; the installer carries reviewed SHA-256 values for its + linux/darwin amd64/arm64 archives. Earlier actionlint releases are not release evidence because they + reject the required `environment.deployment: false` syntax. Because 1.7.12 predates GitHub's + `concurrency.queue`, the gate uses the design's exact yq-backed compatibility wrapper; no other + actionlint diagnostic is ignored. ## 2. Dependency order -Although this is plan 1 of the feature set, its image task cannot run first. Execute these gates: - -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). -6. Execute the cached-build, provenance integration, launcher, and consumer plans on `B`; their final - passing commit becomes action revision `P`. +Although this is plan 1 of five, image publication cannot run first. Execute these gates: + +1. Complete the repository-wide exact-version pin-gate migration and actionlint upgrade (Task 0). +2. Complete the protocol-owner validator, schema, fixtures, pin validator, image verifier, exact image + source/context, fail-closed classifier, publisher contract checker, and protected workflow (Tasks + 1-2). Merge these as the separately reviewed protected gate baseline `G`. +3. Configure the organization required-workflow descriptor at exact SHA `G`, mandatory merge queue, + protected environment, split tag rulesets, audit credentials, and dedicated GitHub App. Prove the rules + and credential smoke before any candidate becomes `S` (Task 2). +4. Add only the canonical `release-request.json` for `G`. Run this isolated candidate through `G`, + merge only through the queue, and require the API-visible exact post-merge push assertion. Record + that default-branch commit as `S` (Task 3). +5. Run the already-landed publisher at `S`, verify digest `D`, and merge its ancestry-checked pin PR to + create baseline `B` (Tasks 3-4). +6. Execute the four remaining 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. @@ -63,67 +84,97 @@ break the dependency cycle. Create: -- `crates/edgezero-provenance-validator/Cargo.toml` -- `crates/edgezero-provenance-validator/src/{lib,main,json_contract,archive,elf,extract}.rs` -- `crates/edgezero-provenance-validator/tests/cli.rs` +- `.github/tools/edgezero-provenance-validator/{Cargo.toml,Cargo.lock}` +- `.github/tools/edgezero-provenance-validator/src/{lib,main,json_contract,archive,elf,extract}.rs` +- `.github/tools/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/gate-paths.txt` +- `.github/docker/build-app-cli/image-context-paths.txt` - `.github/docker/build-app-cli/verify-toolchain.sh` - `.github/docker/build-app-cli/verify-published-image.sh` +- `.github/docker/build-app-cli/stage-build-context.sh` +- `.github/docker/build-app-cli/assert-build-container-context.sh` - `.github/docker/build-app-cli/verify-release-prerequisites.sh` +- `.github/docker/build-app-cli/release-approval-gate.sh` +- `.github/docker/build-app-cli/write-image-release-record.sh` - `.github/docker/build-app-cli/update-image-pin-pr.sh` - `.github/docker/build-app-cli/classify-build-container-change.sh` +- `.github/docker/build-app-cli/run-build-container-gate.sh` +- `.github/docker/build-app-cli/check-build-container-publisher.sh` +- `.github/docker/build-app-cli/verify-gate-rotation-lock.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/stage-build-context.test.sh` +- `.github/actions/deploy-core/tests/assert-build-container-context.test.sh` - `.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh` +- `.github/actions/deploy-core/tests/release-approval-gate.test.sh` +- `.github/actions/deploy-core/tests/write-image-release-record.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/run-build-container-gate.test.sh` +- `.github/actions/deploy-core/tests/check-build-container-publisher.test.sh` +- `.github/actions/deploy-core/tests/verify-gate-rotation-lock.test.sh` +- `.github/actions/deploy-core/tests/build-container-workflows.test.sh` - `.github/actions/deploy-core/tests/check-doc-action-pins.sh` +- `.github/actions/deploy-core/tests/run-actionlint.test.sh` - `.github/workflows/build-container-ci.yml` - `.github/workflows/publish-build-container.yml` +- `.github/workflows/rotate-build-container-gate.yml` +- `.github/CODEOWNERS` +- `scripts/run-actionlint.sh` + +Created by the isolated source/release PR, not gate `G`: + +- `.github/docker/build-app-cli/release-request.json` -Created by the release PR, not source revision `S`: +Created by the pin PR, not source revision `S`: - `.github/docker/build-app-cli/image.json` +- `.github/docker/build-app-cli/image-release-evidence.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/zizmor.yml` - `.github/workflows/deploy-action.yml` +- `scripts/install-actionlint.sh` - every existing `.github` workflow/composite containing a non-local external `uses:` ref - the four deploy/adoption documents containing consumer `uses:` examples -## 4. Task 0: Enforce full-SHA external references repository-wide +## 4. Task 0: Enforce exact-version external references repository-wide -The current pin gate and zizmor policy accept version tags. That contradicts v6.19 and must be -migrated before adding the write-privileged publisher. +The current pin gate permits major/minor tags, prereleases, and commit SHAs. That is broader than +v6.26 and must be narrowed 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 `scripts/install-actionlint.sh` and the workflow environment that selects its version. +- Create `scripts/run-actionlint.sh` and its focused compatibility test. - 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 +- [ ] Write failing pin-gate tests proving `@v1`, `@v1.2`, branches, prereleases, build metadata, + full/abbreviated SHAs, malformed/leading-zero versions, and empty refs fail; canonical stable + `@v1.2.3` passes; 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 external ref to a reviewed upstream exact stable patch release. Record + the release URL and resolved commit in review evidence, prove the release tag exists and no + same-named branch exists at review time, but write the version tag in YAML. +- [ ] Change the structural YAML scanner to require canonical exact stable patch versions 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 @@ -134,13 +185,51 @@ migrated before adding the write-privileged publisher. - [ ] 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. +- [ ] Update the four prepublication adoption documents to use literal + `` where the future consumer will substitute stable release `V`; + examples for third-party actions use real reviewed exact patch versions. +- [ ] Add `check-doc-action-pins.sh` to parse fenced YAML in every tracked Markdown file and implement + both documentation states from the design. In bootstrap state, absent + `docs/.edgezero-action-release.json` permits the exact EdgeZero placeholder only in the four + named prepublication documents. In transition state, a candidate adds the exact JCS `{V,P}` + record, changes only tracked Markdown plus that record, removes every placeholder, and uses one + literal `V` in all EdgeZero refs. In released state, the base record cannot disappear; it is + either byte-identical or replaced atomically with all documentation refs by a strictly greater + stable version under the same hosted release/ref proof. Concrete third-party refs always use + exact stable patch versions; major/minor/prerelease/branch/SHA refs fail. Add positive/negative + state-transition, base/candidate, partial-update, downgrade/deletion, hidden-placeholder, and + mixed-version cases to `run.sh`. +- [ ] Add the hosted transition verifier to gate `G`. With only read permissions and fixed + no-redirect versioned requests, it proves record `V` is a published `draft:false`, + `prerelease:false`, `immutable:true` release whose API target and anonymous peeled remote ref + both equal record `P`. Bootstrap and unchanged released-state checks remain offline. Candidate + code cannot replace the verifier or release record parser. +- [ ] Retain global zizmor `ref-pin` as defense in depth and document that the structural scanner is + stricter. Rewrite `.github/zizmor.yml`'s existing comment so it no longer claims full commit + SHAs pass repository policy: `ref-pin` accepts symbolic refs, while the structural gate permits + only exact stable patch tags. Update contradictory prose in all four named documents, not only + their fenced YAML examples. +- [ ] Upgrade actionlint to exactly `1.7.12`. Pin these reviewed release archives in + `scripts/install-actionlint.sh`: linux/amd64 + `8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8`, linux/arm64 + `325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6`, darwin/amd64 + `5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644`, and darwin/arm64 + `aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f`. Test exact version, + supported tuples, unknown tuple rejection, and checksum mismatch. Add an actionlint regression + fixture containing `environment: {name: build-container-release, deployment: false}`. +- [ ] Before the publisher exists, add failing tests for pinned actionlint's two known syntax gaps. + `run-actionlint.sh` requires mikefarah yq 4.53.3 and structurally permits workflow-level + `concurrency.queue: max` only in the publisher and gate-rotation workflows, each with exact group + `edgezero-build-container-publication` and literal `cancel-in-progress:false`. It permits exactly + the four `job.workflow_*` properties only in approved expressions/checkout ref locations of + `.github/workflows/build-app-cli.yml`. Reject duplicate/aliased/misplaced/wrong values, + misspellings, extra job properties, other workflows, and dynamic expressions. +- [ ] After structural validation, make line-count-preserving temporary copies that blank only the two + approved queue lines and substitute same-type constants for only the approved job-context + expressions. Run unfiltered actionlint 1.7.12 on those files and remap paths/lines; do not use + `-ignore` or filter diagnostics. Raw canonical fixtures must emit exactly the reviewed unsupported + queue/job-context diagnostic set, sanitized fixtures must pass, and every unrelated actionlint + error must remain fatal. - [ ] 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. @@ -149,28 +238,30 @@ migrated before adding the write-privileged publisher. 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 +scripts/run-actionlint.sh 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 +## 5. Task 1: Implement the protocol-owner validator for gate baseline `G` 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`. +archive crate may become a second wire implementation. It is a hard dependency of Task 2 and lands in +protected gate baseline `G` before source revision `S` is proposed. **Files:** -- Create `crates/edgezero-provenance-validator/Cargo.toml` and +- Create standalone workspace `.github/tools/edgezero-provenance-validator` with its own + `Cargo.toml`, `[workspace]`, `Cargo.lock`, and `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`. + integration test `.github/tools/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`. +- Do not modify or include the root workspace manifests; the validator has no external local path + dependency. ### 5.1 JSON/schema tranche @@ -178,10 +269,12 @@ merge into source revision `S`. `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. + `container-ref` derivation, `workspace-id` rendering, and complete caller/platform identity + mismatch. Workspace/suffix hash computation vectors belong to the cache-actions follow-on plan, + not this protocol crate. - [ ] 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 +- [ ] Run `cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml 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. @@ -194,7 +287,7 @@ merge into source revision `S`. - [ ] 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 +- [ ] Run `cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml 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 @@ -204,21 +297,25 @@ merge into source revision `S`. ### 5.3 ELF/loadability tranche - [ ] Add controlled static/dynamic valid, wrong class/endian/type/architecture/interpreter, + wrong `EI_VERSION`/`e_version`/`EI_OSABI`/`EI_ABIVERSION`/`EI_PAD`/`e_flags`/`e_ehsize`/ + `e_phentsize`, zero `e_phnum`, `PN_XNUM`, 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. + tag rejection, duplicate rejection for every singleton tag, slash/backslash/dollar-containing + dependency including every `$ORIGIN`, `$LIB`, and `$PLATFORM` spelling, missing + direct/transitive flat-closure library, duplicate basename, dangling or escaping candidate, + mixed architecture, duplicate-needed, interpreter dependency, and cycle fixtures for the + controlled 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 + `DT_NEEDED`, digest, size, exact `/opt/edgezero/runtime-lib` lookup, symlink/hardlink/subdirectory + rejection, duplicate basename, interpreter parsing, and recursive dependency resolution against + a synthetic image root. Add preload presence, cache-only/default-directory/hardware-capability + substitution, direct-loader argv, and explicit `dlopen` non-claim fixtures. +- [ ] Run `cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml elf::tests`; expected: non-zero for 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 @@ -227,13 +324,33 @@ merge into source revision `S`. ### 5.4 CLI/capability tranche - [ ] 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 + expected-write/package/validate round trips, identity mismatch, atomic cleanup, no-replace + collision, 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: + tests proving all output-producing commands reject every `--work-root` that does not canonicalize + to literal `/work`, plus process tests for self-test fixture integrity. Run + `cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml --test cli`; + expected: non-zero until wired. Implement: ```text +edgezero-provenance-validator write-expected \ + --work-root /work \ + --app-repo-id \ + --source-revision <40-lowercase-hex> \ + --app-cli-package \ + --app-cli-bin \ + --workspace-id sha256:<64-lowercase-hex> \ + --platform-id sha256:<64-lowercase-hex> \ + --provenance-protocol 1 \ + --output /work/expected/expected.json + +edgezero-provenance-validator write-release-request \ + --work-root /work \ + --gate-sha <40-lowercase-hex> \ + --provenance-protocol 1 \ + --release-tag build-container-v \ + --output /work/release/release-request.json + edgezero-provenance-validator package \ --work-root /work \ --binary /work/input/app-cli \ @@ -253,12 +370,20 @@ edgezero-provenance-validator self-test \ --fixtures /usr/local/share/edgezero/provenance-fixtures ``` -- [ ] 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 +- [ ] Make production `write-expected`, `write-release-request`, `package`, and `validate` require + canonical `--work-root /work`. + `write-expected` accepts only the typed bounded scalars above, fixes schema version `1`, derives + `container-ref`, and is the sole expected-identity encoder. Every command creates exactly one + output through a create-new temporary sibling plus Linux no-replace rename and fails if the parent + is not fresh, empty, canonical, writable, and confined. Test lexical and canonical confinement for + every binary, expected, archive, schema, fixture, and output path; require schema and fixture paths + to equal their baked image-owned literals. 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. +- [ ] Make `write-release-request` the sole release-request producer. Accept only typed gate SHA, + protocol `1`, canonical release tag, and the literal fresh output path; test exact three-key JCS + bytes, duplicate/missing/unknown flags, no-replace publication, and output cleanup. - [ ] 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. @@ -267,7 +392,10 @@ edgezero-provenance-validator self-test \ - [ ] Run the focused crate tests, then the repository-required Rust and documentation checks. ```bash -cargo test -p edgezero-provenance-validator +cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml +cargo fmt --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml --all -- --check +cargo clippy --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml \ + --workspace --all-targets --all-features -- -D warnings cargo fmt --all -- --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-targets @@ -292,27 +420,47 @@ cargo clippy --manifest-path examples/app-demo/Cargo.toml \ cargo test --manifest-path examples/app-demo/Cargo.toml --locked --workspace --all-targets ``` -**Gate:** deterministic package/validate golden tests and every capability fixture hash pass from a +**Gate:** deterministic expected-write/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. +runner-backed gates. Task 2 copies the exact reviewed validator source, schema, and fixtures into the +gate-owned image source; the Dockerfile rebuilds the binary from that closed source rather than +copying this host build. -## 6. Task 2: Implement the exact `image.json` validator +## 6. Task 2: Establish protected gate baseline `G` -`image.json` has five fields and is created only after publication succeeds. +This task creates the trust root that evaluates the later release request, owns every repository +image-context input and fixed external-source verification rule, and owns every script that can see a +release credential. Candidate code is always subject data. No image is published in this task. **Files:** +- Create `.github/docker/build-app-cli/{gate-paths,image-context-paths}.txt`, the Dockerfile, and root + `.dockerignore`. +- Create `.github/CODEOWNERS`. - Modify `.github/docker/build-app-cli/check-image-pin.sh`. -- 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. -- [ ] 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: +- Create `.github/docker/build-app-cli/{verify-toolchain,verify-published-image}.sh`. +- Create `.github/docker/build-app-cli/{stage-build-context,assert-build-container-context}.sh`. +- Create `.github/docker/build-app-cli/{classify-build-container-change,run-build-container-gate}.sh`. +- Create `.github/docker/build-app-cli/{verify-release-prerequisites,release-approval-gate}.sh`. +- Create `.github/docker/build-app-cli/{update-image-pin-pr,check-build-container-publisher}.sh`. +- Create the matching focused tests under `.github/actions/deploy-core/tests/`. +- Create `.github/workflows/{build-container-ci,publish-build-container}.yml`. +- Modify `.github/actions/deploy-core/tests/run.sh` and + `.github/workflows/deploy-action.yml`. + +### 6.1 Pin-record validator + +- [ ] Extend `check-image-pin.test.sh` first. Cover the valid five-field record; malformed, + duplicate, extra, and missing fields; wrong JSON types; foreign or empty repository; mutable, + zero, uppercase, or malformed digest/source; non-integer or non-`1` protocol; malformed tag; + and any use of the tag as a runtime pull reference. +- [ ] Add independent canonical bytes and malformed cases for the exact ten-field + `image-release-evidence.json`: duplicate/extra/missing/reordered keys, non-JCS bytes, wrong + strings/integer, run id/attempt precision, stale/invalid UTC time, login/challenge/digest/tag/S + grammar, and every cross-file mismatch. Pair add/change/delete must be atomic. +- [ ] Implement `check-image-pin.sh ` with Bash and `jq`. Detect duplicate top-level + keys from streaming parse events before ordinary object construction. Accept exactly: ```json { @@ -324,446 +472,587 @@ runner-backed gates. Task 3 copies this exact built binary, schema, and fixtures } ``` -`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. -- [ ] Run unit tests and shellcheck. Do not create a placeholder `image.json`. - -```bash -bash .github/actions/deploy-core/tests/check-image-pin.test.sh -shellcheck -S warning .github/docker/build-app-cli/check-image-pin.sh -``` - -## 7. Task 3: Build the pinned image from repository root - -**Files:** - -- 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/`. - -- [ ] 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: + The tag matches `^build-container-v[1-9][0-9]*$` and is informational. Expose only the + digest-qualified runtime ref, source SHA, and protocol through typed subcommands or shell-safe + output fields. + +- [ ] Run the focused test and `shellcheck -S warning`. Do not create placeholder + `image.json` or evidence record. +- [ ] Implement the gate-owned typed release-record writer and paired validator. The writer consumes + only trusted current publisher/approval scalars, emits exact JCS create-new bytes, and never + accepts raw JSON. Runtime actions continue to parse only `image.json`. + +### 6.2 Image and runtime verification + +- [ ] Immediately before the gate-image commit, re-resolve the official + `rust:1.95.0-slim-bookworm` linux/amd64 leaf and require the reviewed digest in Section 1. + Independently download the exact sccache asset and checksum companion and require the reviewed + checksum. Stop for review on movement or disagreement; never commit a placeholder. +- [ ] Commit the exact multi-stage Dockerfile, root `.dockerignore`, and sorted + `image-context-paths.txt` as gate-owned inputs. The context manifest excludes the root workspace + manifests and contains only the complete standalone validator directory, schema/fixtures, + `.tool-versions`, Fastly versions, Dockerfile, and `.dockerignore`; every entry is also in + `gate-paths.txt`. Run `cargo metadata --locked --manifest-path +.github/tools/edgezero-provenance-validator/Cargo.toml` inside the staged context and reject any + workspace member or path dependency outside that validator directory. Any new effective input + requires gate rotation. +- [ ] Write `stage-build-context.test.sh` before its helper. Cover missing/extra/duplicate/unsorted + manifest entries, symlink/hardlink/FIFO/device inputs, path escape, dirty gate checkout, + candidate Dockerfile substitution, changed `S` copy of a manifested byte, unmanifested source, + remote `ADD`, bind-mounted build context, broad `COPY`, and post-install replacement. The helper + creates a fresh directory outside both checkouts and copies only regular manifested files from + exact clean `G`, preserving paths and executable modes; the publisher passes that directory as + the sole Docker context. +- [ ] Build the validator in the Dockerfile with one + `cargo build --locked --release --manifest-path +.github/tools/edgezero-provenance-validator/Cargo.toml` invocation. Test both the exact staged- + context metadata command and exact Docker build. Use the reviewed Rust base digest and + checksum-verified Fastly/sccache assets; install `wasm32-wasip1`; copy only + the exact final binaries/assets and the complete startup-library closure. No later Dockerfile + instruction may replace an installed tool, validator, schema, fixture, interpreter, or closure + member. Static contract tests bind the instruction sequence and destinations. +- [ ] Populate flat `/opt/edgezero/runtime-lib` with the complete reviewed x86-64 startup closure, + require exact dynamic interpreter `/lib64/ld-linux-x86-64.so.2`, and remove + `/etc/ld.so.preload`. Dynamic app-binary launches use the container runtime's argv API with exact + interpreter options `--inhibit-cache --glibc-hwcaps-mask '' --library-path +/opt/edgezero/runtime-lib`; static binaries run directly. Test that no shell, cache, default + directory, hardware-capability directory, or preload can substitute a startup object. +- [ ] Write failing command-fixture tests for `verify-toolchain.sh`. Cover exact, prerelease, + extra-text, missing, and malformed Rust/Fastly/sccache version output; absent + `wasm32-wasip1`; failed minimal compile; invalid wasm magic; validator self-test failure; + wrong uid/gid; and read-only-root failure. +- [ ] Snapshot the `self-test` container's complete create/run contract: digest-pinned linux/amd64 + image, uid/gid 1001, read-only root, all capabilities dropped, no-new-privileges, no network, + 2 GiB memory/swap, 64 pids, 10-minute wall limit, no host bind mounts, only `/work/home` and + `/work/tmp` tmpfs, exact three-variable environment, and exact baked-fixture argv. Reject every + extra mount, environment value, flag, or path. +- [ ] Implement exact semantic-version parsing, installed-target inspection, and compilation of the + committed `wasm-smoke.rs` library into tmpfs. Substring version matching is forbidden. +- [ ] Write failing fixture tests for `verify-published-image.sh`. Cover accepted leaf Docker and + OCI manifests; rejected one-entry/multi-entry indexes; missing config/layers; malformed BuildKit + metadata; wrong OS/architecture; wrong source/revision/protocol labels; mutable-tag lookup; private + registry response; and every toolchain/validator failure. +- [ ] Implement the verifier over a supplied `repository@digest`, source SHA, and protocol. Use + `docker buildx imagetools inspect "$REF" --raw` for media type and + `--format '{{json .Image}}'` for image OS/architecture. Never inspect a tag to discover + identity. +- [ ] Exercise the baked validator's `write-expected` profile with only a fresh writable + `/work/expected`, tmpfs home/temp, no repository/binary/target/Cargo/cache/token mount, and + `--network=none`. Then run deterministic package twice and validate every golden/malformed + archive with each operation's exact mount profile. Assert no `/work/package` convention + exists. +- [ ] Run both focused tests and shellcheck before implementing, then rerun them green. + +### 6.3 Protected classifier and required workflow + +- [ ] Commit `gate-paths.txt` as the canonical, sorted list of every gate-owned path: + the standalone validator manifest/lockfile, validator crate, schema and fixtures, pin scanner, + all classifier/verifier/policy/approval/updater/publisher-checker helpers, + context manifest/Dockerfile/`.dockerignore`, their focused tests and `run.sh` wiring, + `scripts/{install-actionlint,run-actionlint}.sh`, `.github/CODEOWNERS`, `.github/zizmor.yml`, and + both container workflows. The manifest contains itself. `image-context-paths.txt` is a strict + subset, is also canonical/sorted, and closes over every Docker build input and local Cargo + dependency. +- [ ] Write failing classifier tests for the design's closed pull-request, merge-group, and protected- + push event-to-range table, including exact payload/context SHA and ref agreement; + add/change/rename/delete; all-zero first-push base; shallow/missing commits; malformed or duplicate + output; ordinary, isolated-release-request, and gate-update modes; mixed gate/non-gate changes; + old/candidate manifest union handling; exact failed-`G'` gate-rollback restoration; every + interrupted pointer/policy state; and every context-manifest entry. Pin classification is + exact paired add/change/delete detection for `image.json` and `image-release-evidence.json`; a release request is relevant only when it is + the sole changed path and has exact canonical shape and active `G`. +- [ ] Implement the classifier over explicit base/head SHAs from a full subject checkout. Its only + stdout is exactly two fixed-order lines: `mode=ordinary|gate-update|gate-rollback` and + `relevant=true|false`; both gate modes require true. Any invalid range, missing object, gitlink, + path ambiguity, mixed + gate/non-gate change, or unmanifested repository image-context input fails. Gate-update mode requires the + base tree to equal old `G`, validates candidate manifest and CODEOWNERS as inert data, and permits + changed paths only in the union of old and candidate manifests. Gate-rollback requires disabled + release, both pointers at old `G`, a base valid as failed `G'`, a head tree byte-equal to old + `G`, the two-manifest union, and no non-gate/release/pin change. +- [ ] Derive those classifier SHAs only through the trusted workflow's closed event selector. For + `pull_request`, require exact base repository/ref and synthetic merge-parent agreement. For + `merge_group:checks_requested`, require payload base/head SHAs, exact base/head refs, + `head_sha==github.sha`, the `gh-readonly-queue/main/` head prefix, and base ancestry. For + protected-main `push`, require nonzero `before`, `after==github.sha==github.workflow_sha`, exact + main ref, and base ancestry. Reject every other event or inconsistent/missing field before the + classifier runs. `workflow_dispatch` is a separate credential-smoke path and invokes neither + the classifier nor documentation scanner. +- [ ] Write `run-build-container-gate.test.sh` before its driver. Test separate gate/subject + roots, full-SHA checkout assertions, candidate helper substitution, symlink escape, dirty checkout, + missing/duplicate completion markers, ordinary relevant, gate-update, gate-rollback, and + explicit-not-applicable branches, pin deletion, mixed-change rejection, and propagation of every verifier + failure. +- [ ] Implement the driver so all authority comes from its own canonical gate root. The subject root is + read/build input only. It never sources, executes, or resolves a helper from the subject. +- [ ] Create workflow contract tests before YAML. They require: - organization-rule events `pull_request` and `merge_group` with + `merge_group` limited to `checks_requested`, local + protected-main `push`, and manual `workflow_dispatch`; - no workflow-level path filter and stable jobs `build-container-local` and + `build-container-pin` on every PR/merge-group candidate; - workflow permissions exactly `contents: read`, `actions: read`, and `pull-requests: read`, with + no secret/environment/mutation token in either required job; - repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA` validated as a full SHA; - separate gate and subject checkouts with persisted credentials disabled; - all classifier, driver, completion, and verifier commands resolved under the gate checkout; - exact protected workflow repository/path parsing from `github.workflow_ref` and exact + `github.workflow_sha` assertions for required runs; - explicit not-applicable execution and an unconditional terminal completion assertion; and - fixed steps `assert-exact-g-dispatch-context` and `assert-exact-main-push-context`; generic + protected-main push assertions for event, ref, `event.after`, current head `Q`, workflow SHA, + active gate SHA, and both latest-attempt job conclusions, plus separate release-request + identity when `Q=S`. +- [ ] Implement `build-container-ci.yml`. For organization-required runs, + `github.workflow_ref` must identify `stackpop/edgezero` and the exact path, and + `github.workflow_sha` must equal repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA`, whose + value is `G`. Push runs use local workflow SHA equal to current protected head `Q` while still + executing gate code from that variable. Each stable push job invokes the trusted context helper + in exactly one named `assert-exact-main-push-context` step. Keep the existing path-filtered + `deploy-action.yml` separate. +- [ ] Wire all focused suites into `run.sh`. The protected gate runs its own tests; it never + executes a candidate test script. + +### 6.4 Release-policy verifier and publisher + +- [ ] Write fake-API tests before `verify-release-prerequisites.sh`. Implement the exact + credential-specific method/path/query allowlists from design Section 8. Reject redirects, + unvalidated placeholders, unknown pagination, wrong credential use, any persistent mutation + route, hidden/missing `bypass_actors`, and partial/truncated pagination before network or state + interpretation. Every request must use exact `Accept: application/vnd.github+json`, + `X-GitHub-Api-Version: 2026-03-10`, and `User-Agent: edgezero-build-container-gate/1`; reject a + missing/different header. Require response + `X-GitHub-Api-Version-Selected: 2026-03-10`, parsed media type exactly `application/json` with + absent/UTF-8 charset for a body, exact GET/POST/DELETE statuses 200/201/204, and an empty 204 + revocation body. Require a clean detached checkout at exact `G` before reading any credential. +- [ ] Require local `EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN` to be a short-lived fine-grained PAT + owned by the verified active `stackpop` organization-owner login and selected only for + `stackpop/edgezero` with repository Actions/read, Checks/read, + Environments/read, Pull requests/read, Variables/read, Metadata/read, Administration/write and organization + Members/read, Administration/write. A second operator records token id, resource owner, repository + selection, expiry, exact displayed grants, screenshot digest, and every absent write surface. + The helper does not claim GitHub can report the complete PAT grant set. +- [ ] Require separate `EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN` to authenticate an active + `stackpop` owner and report exactly normalized scopes + `{read:org,read:packages}`. Reject byte-equal audit tokens before the first request. +- [ ] Verify the environment's nonempty reviewer rule, `prevent_self_review=true`, final sole + tag deployment policy `build-container-v*`, empty custom deployment-protection-rules endpoint, + and supplied manual administrator-bypass + evidence. Verify repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA=G`; the exact + organization required-workflow descriptor bound directly to gate commit `G`, with no ref or + bypass actor, exact repository-id/main-ref conditions, and `do_not_enforce_on_create=false`; + and repository ruleset + `edgezero-build-container-main` with + target `branch`, active enforcement, no bypass, exact `main` include/no excludes, exact + pull-request review fields, and merge queue `{timeout:60, ALLGREEN, build:1, merge:1, SQUASH, +min:1, wait:0}` from the design. Every missing, extra, defaulted, or changed semantic field + fails. Require the immutable-releases endpoint to return HTTP 200, parse `enabled` as exact + boolean `true`, require `enforced_by_owner` to be present as a boolean, and record its value; + tolerate additional response fields. Require exact + image tag rulesets `edgezero-build-container-tag-creation` and + `edgezero-build-container-tag-immutability` for `refs/tags/build-container-v*`, plus action tag + rulesets `edgezero-action-version-tag-creation` and + `edgezero-action-version-tag-immutability` for `refs/tags/v*`; all have exact repository source, + tag target, active enforcement, include, and no excludes. Each creation ruleset has only the + creation rule and sole reviewed-team `always` bypass. Each immutability ruleset has no bypass + and only deletion plus update with `update_allows_fetch_and_merge:false`. + Require Repository ruleset `edgezero-build-container-pin-branches` with exact repository source, + branch target, active enforcement, `refs/heads/edgezero-build-container-pin/*` include/no + excludes, only the dedicated App Integration `always` bypass, and exact creation/update/deletion + rule array. +- [ ] Verify organization and repository Actions permissions both report + `sha_pinning_required:false`; add their exact GET routes to the policy-token allowlist. Run a + hosted exact-patch-version action fixture so any stricter enterprise override fails before + publication. +- [ ] Verify the candidate PR identity, final queue `merge_group` required-workflow run from + `G`, and exact post-merge run/job API records for `S`. The run must have event `push`, exact + workflow path, and `head_sha=S`; each stable job must have `head_sha=S`, succeed, and contain + exactly one successful `assert-exact-main-push-context` step. The trusted step checks ref, + `event.after`, current main head `Q`, `github.sha`, `github.workflow_sha`, and active gate internally because those + values are not exposed by the run REST response. Missing/duplicate/wrong-attempt assertion steps + fail. Matching check names from candidate workflow code are not evidence. +- [ ] Verify protected-environment App/installation/team variables and private-key secret metadata. + Use the local App key to authenticate the exact dedicated App and selected-repository installation + with only contents/write, pull-requests/write, and implicit metadata/read. The only non-GET calls + are creation of a repository-id-bounded test token and its guaranteed revocation. Reject extra + repository or permission scope. +- [ ] Resolve `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN` through the exact public user endpoint + and require the response login, numeric id, and `type:"Bot"` to equal the independently reviewed + repository variables. Reject a user/login collision before trusting pin-PR authorship. +- [ ] Prove package absence only through the fully paginated verified-owner listing before first push. + After first push, require public visibility and linkage to `stackpop/edgezero`. A 404 or + authorization failure is never absence. +- [ ] Emit canonical evidence with every identity, rule id/URL, workflow/run/attempt/job URL, permission, + package state, manual-evidence digest, and timestamp, but no credential. A separately authenticated + operator posts it and the byte-identical PNG to the candidate PR. Test evidence-post failure. +- [ ] Add `build-container-release-preflight` to the gate-owned CI workflow only for + a `workflow_dispatch` body with `ref:"main"` while protected `main==G`, with a required + candidate PR-number, head-repository, and full head-SHA input. Its exact candidate-bound + `run-name` is API-visible. It uses + `environment: {name: build-container-release, deployment: false}`, checks out only exact `G`, + runs no candidate code, contains exactly one fixed `assert-exact-g-dispatch-context` step that + fetches the PR with the read-only token and compares all three inputs, and + uses the pinned App-token action only to prove the stored key can mint the exact repository- + scoped token. The run API must later show event `workflow_dispatch`, exact path, `head_sha=G`, + and that successful named assertion step. +- [ ] Fixture-test the bounded credential smoke: temporarily add literal deployment branch policy + `main`, dispatch the workflow with body `ref:"main"` while `main==G`, approve and complete the smoke, remove + only `main`, and restore the sole tag policy. Any wildcard, caller branch, candidate + commit, credential update, or workflow SHA other than `G` invalidates the smoke. Capture + the pre-`S` administrator-bypass PNG only after final tag-only policy is restored. +- [ ] Write `release-approval-gate.test.sh`. Cover canonical valid comment; missing, duplicate, + rejected, and bypassed reviews; wrong environment/reviewer/run id/run attempt/source/tag/PNG digest; + wrong/missing/duplicate challenge and image digest; attempted future-attempt predeclaration; + malformed/extra/reordered JSON; invalid calendar, fractional, offset, stale, or future time; + API/non-200/malformed response; reused earlier-attempt + evidence; and proof that token creation and every mutation have not run on failure. +- [ ] Implement the gate with only `actions:read` and `contents:read` available. It + performs only the exact two no-redirect GETs for the current run and its non-paginated approval + history, requires complete valid 200 responses, and requires exactly: -```bash -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/`. -- [ ] 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. -- [ ] 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`. -- [ ] 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: - -```bash -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 . -``` - -- [ ] 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`. -- [ ] 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. - -```bash -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 +```text +edgezero-release-evidence-v1 {"challenge":"<64-lowercase-hex>","image-digest":"","png-sha256":"sha256:<64-lowercase-hex>","release-tag":"","reviewed-at":"","run-attempt":"","run-id":"","source-revision":""} ``` -**Gate:** no image is pushed until every command above passes with the exact source SHA and protocol. + Run it from the verified `G` checkout; it must pass before App-token creation. Require exactly one + protocol-prefixed record claiming the current run id/attempt, exact current challenge and `D`, and + approved state; any second or mismatched current-attempt record fails. The API reviewer login is + the evidence approver. Earlier attempts never satisfy the current `github.run_attempt`. + +- [ ] Implement and fixture-test `update-image-pin-pr.sh` in the gate. Let `I` be the + current protected-base source pin. Permit first pin, `I==S`, or `I` ancestor + of `S`; reject older or incomparable `S`. Fully enumerate open pin PRs and require the exact + authenticated App author id/login plus head repository `stackpop/edgezero`; an actor/repository + collision fails. Close older proposals when superseding them, make an older run a mutation-free superseded + success, and fail malformed, incomparable, multiple-same-source, or ambiguous state. +- [ ] Test absent branch, exact remote OID, force-with-lease race, one open PR, closed-unmerged exact PR, + already-merged exact record, same-`S`/new-`D`, forward source, older source, + incomparable source, newer existing PR, stale PR after newer merge, missing head repository, + wrong author/repository collision, pagination/API/reopen failure, and idempotent rerun. No test + may depend on the real network. +- [ ] Write `check-build-container-publisher.test.sh` and then its structural checker. It rejects + changes to the gate-owned publisher topology, permissions, concurrency group, action versions, + gate checkout, helper paths, output set, token ordering, package deletion/admin scope, build secret + exposure, missing/late release-state and rotation checks, predictable/hard-coded/pre-verification + challenge generation, or pin mutation outside the trusted updater. Apply the same exact + concurrency/group/queue checks to the gate-rotation workflow and reject any third workflow using + that group or either approved workflow using a different one. +- [ ] Create gate-owned `publish-build-container.yml`. It triggers only protected + `build-container-v*` tags and uses exact concurrency group + `edgezero-build-container-publication` with `cancel-in-progress: false` and `queue: max`. + Document and test one running plus at most 100 pending runs; a run rejected at capacity publishes + no pin and must be rerun. Run it through the exact structurally validated actionlint + compatibility wrapper. +- [ ] Create gate-owned `rotate-build-container-gate.yml`, dispatched only from protected main at old + `G`, with the same exact workflow-level concurrency contract. Its unprivileged acquire job runs + after older publishers; its second job waits on secret-free + `build-container-gate-rotation-lock` with `deployment:false`, parses the exact current-run + rotation approval/evidence, and releases only after activated or rolled-back final state. Add a + live prerequisite fixture proving a publisher queued behind the waiting lock starts no build or + push. A canceled/expired lock may release concurrency only after the release-state variable and + absent tag policy keep later publishers fail-closed. +- [ ] Split the publisher into `build-and-verify` and `update-pin`. + `build-and-verify` has no environment, permissions only contents/read and packages/write, + no App key, and after anonymous verification generates 32 OS-CSPRNG bytes, exposes the 64-lowercase- + hex `approval-challenge`, and writes exact `{challenge,S,D,tag,run-id,run-attempt}` to its job + summary. Its outputs are only `{S,D,protocol,tag,approval-challenge}`. `update-pin` has the protected environment, no image build, initial + actions/read and contents/read, and executes approval/updater helpers only from a separate checkout + of repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA`, whose value is `G`. + `build-and-verify` separately checks out `S` only to validate the isolated release request and + manifested-byte equality, then uses `stage-build-context.sh` to build solely from copied `G` + inputs. After acquiring shared concurrency and before registry login/build, require release state + exact `enabled`, active gate consistency, and no active rotation-lock run. A candidate Dockerfile + or unmanifested path is never in the Docker context. Each job invokes exactly one fixed + `assert-exact-publisher-context` step from `G` before sensitive work and verifies tag event/ref, + `github.sha==github.workflow_sha==S`, run id/attempt/tag, active gate, and enabled release state. +- [ ] Pin checkout to `actions/checkout@v7.0.1` and App-token creation to + `actions/create-github-app-token@v3.2.0`. + The App token is requested only after approval-gate success and only for repository `edgezero` with explicit contents/write + and pull-requests/write. Require returned installation id to equal the protected variable and rely + on mandatory post-step revocation. +- [ ] Add a static workflow test rejecting package-delete endpoints, `delete:packages`, + package-admin tokens, cleanup jobs, candidate helper execution, and any token before anonymous + image verification. + +### 6.5 Land and configure `G` + +- [ ] Run all Task 0-2 focused tests, Rust checks, the pinned actionlint `1.7.12` compatibility + wrapper, shellcheck, and + `zizmor --offline` from a clean checkout. Run every current format/test CI matrix job. +- [ ] Obtain an independent security review of the exact gate-owned path manifest, workflow source, + API allowlists, App-token ordering, and fail-closed/no-op markers. +- [ ] Merge the gate-only PR through the repository's existing protected process. Record the exact + default-branch commit as `G`. This bootstrap is a human-reviewed trust-root operation; + candidate-controlled checks are not evidence for it. + +**Gate checkpoint:** stop before opening the source candidate. + +- [ ] Set repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA` to full SHA `G`. + Set `EDGEZERO_BUILD_CONTAINER_RELEASE_STATE` to exact `enabled`. Configure secret-free + environment `build-container-gate-rotation-lock` for protected main only, with required + reviewers, self-review and administrator bypass disabled, no custom protection App, and no + workflow reference to any environment secret or variable. + Set repository variables `EDGEZERO_BUILD_CONTAINER_PUBLISHER_APP_ID`, + `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID`, and + `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN` to the independently verified dedicated App and + bot identity; require the App id to equal the protected-environment App id and pin-branch + ruleset Integration actor. + Configure active organization ruleset `edgezero-build-container-required-workflow` with target + `branch`, no bypass actors, repository-id condition exactly `[]`, ref-name include + exactly `["refs/heads/main"]`, no ref excludes, and exactly one `workflows` rule containing + `do_not_enforce_on_create=false` and exactly one required-workflow descriptor + `{repository_id:,path:".github/workflows/build-container-ci.yml",sha:G}`, + with no `ref`. Enable repository immutable releases, then require the versioned REST endpoint + to return HTTP 200 with `enabled` exactly boolean `true` and `enforced_by_owner` present as a + boolean; record both fields without requiring a byte-exact or one-field JSON object. +- [ ] Configure repository ruleset `edgezero-build-container-main` at target `branch`, enforcement + `active`, no bypass actors, include exactly `refs/heads/main`, exclude none, and exactly two + rules. Its pull-request rule allows only squash, dismisses stale reviews, requires code owners, + requires a distinct last-push approval, two approvals, and resolved threads. Its merge queue is + exactly `check_response_timeout_minutes:60`, `grouping_strategy:ALLGREEN`, + `max_entries_to_build:1`, `max_entries_to_merge:1`, `merge_method:SQUASH`, + `min_entries_to_merge:1`, and `min_entries_to_merge_wait_minutes:0`. Add fake payloads for every + missing/wrong parameter and prove each fails. Configure both image-tag and action-version-tag + creation-only/team-bypass and update/delete/no-bypass ruleset pairs with exact names, repository + source, tag target, ref include/no-exclude conditions, rule arrays, and actors; protected environment; dedicated GitHub + App; App-only canonical pin-branch ruleset; publisher App/bot repository variables; audit + identities; and final package policy from design Section 8. +- [ ] From a clean detached checkout at exact `G`, run the prerequisite verifier in configuration-only mode and preserve independently reviewed + evidence for the exact ruleset payload, repository variable, merge queue, environment, App, + audit-token screens, and team. Reopen or synchronize the later source PR after activation so its + authoritative required-workflow run is not stale. + +### 6.6 Prove gate rotation and recovery before release + +- [ ] Add fixture/integration tests for a gate-update PR. Old `G` must require the protected base tree + to equal old `G`, classify exactly `mode=gate-update`, accept changes only in the union of old and + candidate manifests, validate canonical candidate manifest and CODEOWNERS coverage as inert data, + reject mixed release-request/pin/non-gate changes, and never execute a candidate helper. +- [ ] Exercise the activation state machine with fake configuration APIs: quiesce publication, remove + by dispatching the old-`G` rotation workflow and waiting until it holds publication concurrency; + set release state `disabled::`, remove the sole tag deployment policy, merge + through the one-entry queue, record `G'`, run the clean + detached `G'` suite and exact post-merge assertion, update the repository variable and + organization descriptor after updating the marker with `G'`, verify both plus the base manifested + tree, restore the tag policy, produce fresh evidence, set state enabled, and approve the lock with + the exact evidence-bound comment. Every intermediate pointer mismatch, publisher that starts + behind the lock, or malformed state/comment fails closed. +- [ ] Exercise rollback at every activation step. Release stays disabled; both pointers restore to old + `G`; ordinary work remains blocked while base contains `G'`; and a separately reviewed old-`G` + `mode=gate-rollback` must restore the manifested tree before release policy returns. Require + current base to be a valid failed `G'` tree, proposed manifested bytes to equal old `G`, diff to + stay within the two-manifest union, no release/pin/non-gate change, both pointers already old + `G`, release disabled, queue merge, and generic exact-head push evidence. If old `G` cannot + validate either side, require a new manual trust-root review with release disabled. Add fixtures + for every interrupted pointer/policy state. No bypass or mixed-pointer operating mode is + permitted. + +**Gate:** do not tag or publish from `G`. The next task cannot alter a gate-owned path. + +## 7. Task 3: Build, merge, and publish source revision `S` + +**Source-candidate files:** + +- Create only `.github/docker/build-app-cli/release-request.json`. +- No gate-owned, image-context, pin, or unrelated file changes. The Dockerfile, complete context, + publisher, and all credential-bearing helpers already exist at `G`. + +### 7.1 Qualify the isolated release request + +- [ ] Create byte-canonical RFC 8785 JCS with no trailing newline. The file contains this one data + line; the Markdown fence line break is not file content: -## 8. Task 4: Publish, verify, and open an idempotent pin PR - -**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, 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. -- [ ] 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 - -- [ ] 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. 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. -- [ ] 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. -- [ ] 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 -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") +```text +{"gate-sha":"","provenance-protocol":1,"release-tag":"build-container-v"} ``` -- [ ] 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. -- [ ] 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`. -- [ ] 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 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, 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. -- [ ] 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, 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. - -**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. - -## 9. Task 5: Merge and verify pin baseline `B` - -**Files:** - -- 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`. - -- [ ] 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. -- [ ] 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: +- [ ] From a clean detached `G`, use the gate-owned staging helper to create the canonical fresh + context, build the local image with the exact Dockerfile/arguments and revision label `G`, and + capture local image/config identity `L` through BuildKit `--iidfile`. Run the full gate-owned + leaf-platform, label, protocol, validator, and toolchain verifier against `L`. This bootstrap + image is local only, is not published, and is not the future digest `D`; no preexisting pinned + `G` image is assumed. +- [ ] Invoke only `write-release-request` by immutable local identity `L`, under the exact + networkless/credential-free/read-only `release-request-write` profile, with full active `G` and + the next never-moved tag. Copy its sole output into the candidate branch and remove its fresh + output parent. Test duplicate/extra/reordered keys, whitespace/newline, wrong G/protocol/tag, + leading-zero release number, existing output, cleanup failure, wrong/tagged local image, and any + second changed path. No shell or `jq` is a second canonical encoder. + +- [ ] Require the organization workflow at exact `G` to run both stable jobs. Verify its workflow + repository/path/SHA and candidate head SHA. The local job must verify the candidate's complete + gate/context tree equals `G`, stage context only from clean `G`, build with the candidate head as + revision label, and run the trusted verifier suite. Candidate Dockerfile, helper, and context + paths are never read. +- [ ] Require linux/amd64 leaf config, exact labels, checksum-pinned Fastly/sccache bytes, exact + installed command versions/paths, actual wasm compile, validator self-test, deterministic + archive bytes, every malformed fixture, controlled startup-loader behavior, and read-only/non- + root runtime with no network/capabilities and bounded memory/pids. +- [ ] Run the exact `write-expected`, package, validate, and binary-smoke mount profiles from + design Section 5. No shell or `jq` may produce expected identity. No + `/work/package` mount may appear. +- [ ] Execute the bounded credential smoke while protected main is still `G`: temporarily add literal + `main` as the sole extra environment deployment policy, dispatch with body `ref:"main"` and the + candidate PR number, exact head repository, and current full head SHA, approve and complete the + smoke, then remove `main`. Require the exact candidate-bound `run-name`, run event + `workflow_dispatch`, path, `head_sha=G`, and exactly one successful + `assert-exact-g-dispatch-context` step that resolves the PR API and compares all three inputs; + then require final tag-only policy and unchanged credential metadata. A new candidate commit + makes this evidence stale. + +### 7.2 Qualify and merge `S` + +- [ ] After policy restoration, an independent maintainer captures the administrator-bypass/final-policy + PNG. Enter the candidate in the mandatory merge queue. Require the final `merge_group` + execution of the organization workflow from `G` to pass. The queue payload must still have + single-entry build/merge limits and every exact review parameter. +- [ ] Merge only through that queue. Record the resulting default-branch full SHA as `S`. + Wait for the repository-local `build-container-ci.yml` latest-attempt push run. Its run API + record must have event `push`, exact path, and `head_sha=S`; both stable jobs must have + `head_sha=S`, success, and exactly one successful `assert-exact-main-push-context` step. The + immutable step internally proves `refs/heads/main`, `event.after==github.sha==S`, + `github.workflow_sha==S`, and active gate `G`. +- [ ] From a clean detached checkout at `G`, run the full release-prerequisite verifier after merge using the candidate PR number and exact + `G`/`S`. Attach canonical evidence and the byte-identical PNG to the merged PR. + A maintainer other than the verifier and screenshot reviewer recomputes the digest, reviews all + API evidence, and authorizes tag creation. + +**Release checkpoint 1:** no tag exists until exact-`S` push evidence and the three-person +preflight review pass. + +### 7.3 Publish digest `D` and propose the pin + +- [ ] A preflight-verified active member of the creation ruleset's sole bypass team + `edgezero-build-container-releasers` creates protected tag + `build-container-vN` at exactly `S`. +- [ ] `build-and-verify` checks out full history without persisted credentials; proves + `HEAD==S`, clean tracked/index/untracked/submodule state immediately before and after + release-request validation. It separately checks out clean exact `G`, proves every manifested + byte at `S` equals `G`, and stages a fresh context containing only canonical files copied from + `G`. Build with that context and gate-owned Dockerfile, `--platform linux/amd64`, exact + source/protocol args, + `--provenance=false`, `--sbom=false`, and BuildKit metadata output. +- [ ] Authenticate GHCR only through a fresh `DOCKER_CONFIG` outside build context. Pipe + `GITHUB_TOKEN` to login; never pass it as build arg, secret mount, image environment, or + context file. Parse `D` only from + `containerimage.digest` in the metadata file. +- [ ] Run trusted `G` verification while authenticated, remove local credentials/reference, + then pull and run `repository@D` with a new empty Docker config. The anonymous check must + issue a registry request. A private package fails before `update-pin`. +- [ ] On first publication, stop at the expected private-package failure. An operator makes the GHCR + package public, then reruns the exact `G` prerequisite verifier in package-present mode and + attaches its public-visibility/repository-link evidence before rerunning the same tag. Evidence + from the failed run attempt does not carry forward. + +**Release checkpoint 2:** after the image is public and anonymously verified, the environment approver +captures a fresh PNG and enters the exact design comment for the current +`{challenge,D,run-id,run-attempt,S,tag,png-sha256,reviewed-at}` before approving `update-pin`. + +- [ ] Check out exact `G` without persisted credentials, then require `release-approval-gate.sh` to + pass before App-token minting. + Then mint the exact repository-scoped App token, verify installation id, and invoke only + `G/update-image-pin-pr.sh`. +- [ ] Generate the exact five-field image record plus canonical JCS release-evidence record through + gate-owned typed writers. Use branch `edgezero-build-container-pin/` and exact title from the + design. Enforce protected-base and every-open-PR source ancestry. Use recorded remote OID and + exact force-with-lease; never blind force or accept caller-provided evidence bytes. +- [ ] Include `S`, `D`, protocol, platform, anonymous result, approval run attempt, + and evidence link in the PR body. Never include credential material or an AI byline. +- [ ] Require pin CI from protected `G` to recompute current-base ancestry; verify both files are the + only changed paths and agree; verify exact App bot author/id, head repository, protected branch, + PR title, and pin-branch ruleset; then poll and verify the bound publisher run, attempt, both + exact context steps, and current approval comment against the evidence file. Only afterward + anonymously verify the exact digest against labels, platform, tools, validator, target, + protocol, and package visibility. Candidate scripts, PR-body claims, and mutable tags are + forbidden. + +**Release checkpoint 3:** confirm the pin PR changes only the image/evidence pair, was authored and +pushed through the dedicated App's protected source branch, binds the successful publisher run and +approval, proposes a source not older or incomparable with the current base or another proposal, and +passed both protected jobs on its final merge-queue candidate. + +## 8. Task 4: Merge and verify pin baseline `B` + +- [ ] Merge the pin-only PR through the mandatory queue and record the resulting full commit as + baseline `B`, not final action revision `P`. +- [ ] Confirm the App push caused the required organization workflow to materialize and every latest + attempt passed. Confirm deletion or a syntactically valid but unverifiable/older/incomparable pin + fails in dedicated test PRs. +- [ ] From a clean checkout at `B`, rerun all Task 0-3 suites: ```bash +cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml +cargo fmt --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml --all -- --check +cargo clippy --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml \ + --workspace --all-targets --all-features -- -D warnings +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" 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 +ACTIONLINT_VERSION=1.7.12 scripts/install-actionlint.sh 1.7.12 +scripts/run-actionlint.sh zizmor --offline .github/workflows .github/actions ``` -- [ ] 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. - -**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 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. -- [ ] 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. - -## 11. Completion review +- [ ] Require every current hosted format/test matrix job, including all wasm clippy/test legs. Local + commands do not replace runner-backed checks. +- [ ] Pull `repository@digest` anonymously after merge and rerun the complete image verifier + from the committed record. +- [ ] Record `{G,S,D,B,protocol,tag}`, workflow/ruleset ids, action versions plus their reviewed + resolved commits, tool checksums, and + evidence digests in the release record. + +**Gate:** downstream plans build on `B` and never use `S` as an action ref or derive +a digest from the mutable tag. + +## 9. Task 5: Runbook and downstream-plan handoff + +- [ ] Document publication concurrency accurately: one running and at most 100 pending runs under + `queue: max`; a run rejected at capacity publishes no pin and must be rerun. +- [ ] For every `update-pin` attempt, record run id, run attempt, exact approval comment, API + reviewer login, challenge, image digest, source, tag, review time, PNG basename/digest, and final evidence attachment. + Earlier-attempt or pre-`S` evidence is invalid. +- [ ] Document that GHCR has no enforceable per-version retention lock and repository automation has no + package-deletion credential or endpoint. Manual administrator deletion is accepted operational + risk; recovery is a new source/image verification/pin release. +- [ ] Document rollback as selecting an earlier reviewed exact action version containing its + corresponding pin. + Never regress protected-main `image.json` and never move a release tag. +- [ ] Review and approve these four sibling plans before implementing post-`B` behavior: + `2026-08-20-build-cache-actions.md` (cache key, restore/save authorization truth table, + sccache lifecycle/audit), `2026-08-20-build-cache-provenance.md` (typed expected producer, + package/validate handoff and two-job topology), + `2026-08-20-build-cache-launcher-providers.md` (container profiles, source freeze, + nested-project Fastly `bin`/`pkg` output and cleanup, provider lifecycle), and + `2026-08-20-build-cache-consumer-adoption.md` (consumer workflow, docs, migration, and + final `P`/`V` qualification plus documentation revision `R`). +- [ ] Each follow-on plan must assign the design's deferred fixtures and tests before final action + revision `P`: workspace/suffix vectors, format-independent cache tree bounds and entry count, + exact cache action versions, cache lookup/save truth tables, sccache response-loss semantics, + mount/environment matrices, empty Cargo-config policy, path confinement, implicit nested + `bin`/`pkg` ownership and cleanup, exact app-env allow/deny boundaries, controlled-loader argv, + artifact identity, and consumer recomputation. +- [ ] In the consumer plan, keep the four prepublication documents at the gated placeholder while + candidate `H` is tested through exact version `C`. Designate `P` only after the exact-main local + suite and immutable candidate-version hosted suite pass, then select and publish immutable `V` + at `P`. Only after the literal-`V` smoke passes, merge documentation-only `R` adding the exact + `{V,P}` record and replacing every placeholder with `V`; the preinstalled dual-state scanner + then remains permanently in released mode. Every concrete third-party and final EdgeZero + `uses:` ref is a reviewed exact patch version; no major/minor tag, branch, or commit SHA appears. + +## 10. Completion review Before declaring this plan complete, run two independent reviews: -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, 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 -eventually designate final action revision `P`. +1. **Contract review:** compare every file and test with design v6.26 Sections 2 through 10. Verify one + expected/package/validate wire authority, protected gate and image source `G`, isolated release + request `S`, staged gate-only Docker context, API-visible exact post-merge `S` proof, forward-only + pin ancestry, no tag runtime pull, no placeholder image digest/checksum, no `/work/package` + convention, and no legacy `--stage` guidance. +2. **Release-adversary review:** test candidate workflow/helper substitution, forbidden + major/minor/branch/SHA action refs, third-party exact-tag movement as recorded risk, immutable + EdgeZero release enforcement, private + package state, stale/older/incomparable pin PRs, malformed BuildKit metadata, indexes, wrong + platform/labels/versions/protocol, deleted pin, classifier/completion failure, workflow-source + mismatch, missing bypass fields, API redirect/path/header/version confusion, approval reruns, token + ordering, gate rotation/recovery, actionlint queue compatibility, publication queue overflow, and + concurrent release attempts. + +The container plan is complete only when protected gate `G`, source `S`, verified +digest `D`, and pin baseline `B` are recorded and all repository gates pass. The +four remaining plans may then implement caching and designate final action revision `P` only +after their complete contract suites pass. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-launcher-providers.md b/docs/superpowers/plans/2026-08-20-build-cache-launcher-providers.md new file mode 100644 index 00000000..a7e6acb1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-build-cache-launcher-providers.md @@ -0,0 +1,207 @@ +# Build Cache Launcher and Provider Implementation Plan (plan 4 of 5) + +> **Execution:** Start after plan 3 is merged and its provenance gate revision is active. Add the +> launcher/provider adversarial tests to a new gate revision before changing action behavior. + +**Goal:** Run the validated app CLI through closed container profiles, freeze every source byte that +can reach a credentialed provider command, and preserve the parent deploy lifecycle without ambient +host state or the legacy `--stage` spelling. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.26 Sections +2, 5, 6.1, 6.6, 7.2, and 9. The parent deploy spec remains normative where the addendum does not +expressly replace it. + +## 1. File and ownership boundaries + +- Modify the provider composites under `.github/actions/{deploy-fastly,healthcheck-fastly, +rollback-fastly,config-push-fastly}` and their shared `.github/actions/deploy-core` helpers. +- Create `.github/actions/active-version-fastly/action.yml`; preserve plan 3's no-output + `.github/actions/validate-app-cli-provenance/action.yml` interface while extending its shared runner + with provider profiles. +- Keep JSON/environment validation, source inventory, output-root ownership, container argv, binary + recheck, token-ordering, and cleanup in shared typed or narrowly scoped helpers. Provider composites + may select profiles; they may not reconstruct mount lists or environments ad hoc. +- Do not modify the protocol JSON/archive encoder in this plan. A protocol change returns to plan 3 + and requires a new image/gate/pin sequence. + +## 2. Gate update first + +- [ ] Add structural fixtures for every operation/profile pair, including exact image digest, network, + uid/gid, read-only root, capabilities, security options, tmpfs, mounts, environment names, + resource limits, timeout, command, and token presence. +- [ ] Add hostile source/output fixtures for deleted or modified tracked paths, submodule drift, + escaping symlinks, mount substitution, overlapping roots, tracked descendants, hardlinks, + sparse files, special files, nested mounts, inode replacement, cleanup races, and undeclared + generated output. +- [ ] Add provider lifecycle fixtures for production, staging, first deploy, unhealthy deploy, + rollback, stale rollback refusal, lost version, cancellation, config push, and mutation output. +- [ ] Prove candidate changes cannot weaken the profile tables, source-freeze checks, binary recheck, + token boundary, or exclusive `--staging` spelling, then land and activate the new gate revision + using plan 1's rotation procedure. + +## 3. Closed input and environment construction + +- [ ] Consume plan 2's sole duplicate-rejecting `app-env` decoder with the exact v1 limits; do not + introduce a provider-local parser. Require JSON object only, + valid UTF-8 and at most 65,536 raw bytes before parsing, at most 64 entries, at most 32,768 + aggregate UTF-8 bytes, 1..127-byte ASCII names matching + `[A-Za-z_][A-Za-z0-9_]*`, values at most 8,192 UTF-8 bytes with no NUL/C0/DEL, and + ASCII-case-insensitive exact/prefix/target-tool deny rules from the design. +- [ ] Start every operation with `env -i`; emit only the fixed `PATH`, action-owned `HOME`/`TMPDIR`, + the operation's exact Rust/Cargo variables, validated `app-env`, selected typed `EDGEZERO_*` + values, and the one provider token required by that profile. Reject duplicate final names and + prove caller `PATH`, shell startup variables, wrappers, flags, and ambient workflow variables do + not survive. +- [ ] Consume plan 2's shared empty Cargo-config and toolchain checks over the cwd, every ancestor through git root, + all copied enclosing workspace directories, and fresh `CARGO_HOME`. Reject either Cargo config + filename and legacy credentials before app code starts. +- [ ] Require the explicit `rust-toolchain` input to equal the pinned image toolchain and confine all + path dependencies beneath git root. Test exact, missing, inferred-only, nested-workspace, and + mismatch cases. + +## 4. Runner and mount profiles + +- [ ] Refactor `run-app-cli-in-container` to accept a closed operation enum, not caller-provided + Docker flags, mounts, environment names, network settings, entrypoint, or command prefix. Build + argv as an array and reject newline/NUL/control-bearing scalar inputs before logging. +- [ ] Serialize each validated operation environment to one mode-0600 single-link private env file; + pass only its path to `docker create`, remove and verify the file before `docker start --attach`, + and remove the uniquely named container on every exit. Never place a token or app-env value in + Docker argv. Test create/start/attach failure, env-file replacement, newline/NUL values, + container-name collision, inspectable lifetime, and mandatory removal/reconciliation. +- [ ] Implement the exact mount table from design Section 5.3. Never mount all of `RUNNER_TEMP`, the + original checkout writable, the Docker socket, host credential directories, GitHub file-command + files, or a cache in a token-bearing operation. +- [ ] Enforce the fixed hardened runtime: pinned digest, linux/amd64, uid/gid 1001, read-only root, + dropped capabilities, `no-new-privileges`, operation-local tmpfs, and the exact design table's + bridge/none network, memory/no-extra-swap, pid, and wall-time values. Reject Docker host, + container-sharing, caller-selected network, resource, and timeout flags. +- [ ] Before every app-binary launch, reopen and verify the confined regular path, device/inode, + SHA-256, size, mode 0755, and link count one. Dynamic binaries run only by direct argv through + `/lib64/ld-linux-x86-64.so.2 --inhibit-cache --glibc-hwcaps-mask '' --library-path +/opt/edgezero/runtime-lib `; static binaries execute directly. Never use a shell, + `PATH` lookup, implicit kernel interpreter launch, `ld.so.cache`, default library directory, + preload file, or hardware-capability substitution. +- [ ] Cover static/dynamic success, wrong interpreter, dependency replacement, hwcaps/default/cache/ + preload substitution, inode swap, symlink/hardlink replacement, malformed ELF, and the explicit + non-claim for post-startup `dlopen` and child-process behavior. + +## 5. Source freeze and generated outputs + +- [ ] Define `deploy-fastly` and `config-push-fastly` as the only source-bearing public actions. Give + each the design's exact repository/ref/id/workspace/cwd/package/bin/toolchain inputs plus required + sensitive string `app-checkout-token`, supplied from a GitHub secret and masked before use. At + action start, independently materialize one action-private authority with plan 2's trusted + object-first helper, verify all five supplied + `CallerExpectedIdentity` fields, and remove the checkout credential channel before application + code, provider-token creation, or provider-token injection. Never accept or output an authority + path, descriptor, or opaque handle from another action. Source-free actions accept none of these + materialization inputs and no checkout token. +- [ ] For `deploy-fastly`, build Copy B with plan 2's trusted exporter from that action-local, + credential-free authority. It is a faithful, private, `.git`-free, non-hardlinked, recursive, + non-sparse copy containing only tracked files and initialized submodules. Keep the authority + read-only and verify repository id, exact HEAD, index/worktree cleanliness, gitlinks, permitted + no-filter or pinned-LFS materialization, modes, symlink targets, and full inventory before and + after app-controlled work. Create one Copy B per invocation; reuse it only among that + `deploy-fastly` invocation's internal operations, and never share it or its output roots with + another action. +- [ ] Make `config-push-fastly` the explicit no-Copy-B exception. It executes no app code and mounts + only its independently materialized credential-free frozen authority read-only. Record and + verify authority HEAD, index/worktree state, full inventory, and selected tracked + manifest/file-config identity before token creation, immediately before container start, and + after the command; apply equivalent identity checks to an action-owned inline config. Add + substitution/race fixtures at every boundary and fail on any authority, manifest, or config + change. +- [ ] Parse `generated-output-paths` as the exact bounded canonical JSON array. Reject raw input over + 65,536 bytes, duplicates, overlap, root/dot/git paths, tracked path ancestors, existing roots, + non-UTF-8 or noncanonical + segments, components over 255 bytes, joined host paths over 4,096 bytes, escaping parents, and + any parent reached through a symlink. +- [ ] Resolve the selected `fastly.toml` before app code runs and add exactly the implicit + `/bin` and `/pkg` roots. Apply the same collision, + absence, parent-confinement, and ownership rules; do not substitute workspace-root paths. +- [ ] Create every root empty with mode 0700, record its device/inode, and expose repository write + access only through the corresponding nested writable bind mount beneath read-only + `/work/repo`. Audit after every app-controlled command and immediately before each token-bearing + command. +- [ ] Accept beneath roots only real directories and non-sparse regular uid/gid-1001 single-link + files. Reject symlinks, hardlinks, sparse files, devices, sockets, FIFOs, mounts, ownership + changes, root replacement, and new paths elsewhere. Across all roots enforce checked totals of + at most 2,147,483,648 logical bytes and 100,000 descendants plus the component/path bounds. + Compare all other Copy B bytes/modes/gitlinks to the frozen authority. +- [ ] Implement descriptor-relative, no-follow cleanup on success and failure. Remove only recorded + action-owned trees, then verify roots are absent and both original and Copy B satisfy their final + inventories. Cleanup or post-cleanup failure is fatal; a preexisting root is never adopted. + +## 6. Credential-free app build and parent target cache + +- [ ] Under `build-mode: always`, run exactly one credential-free app-build profile before provider + deploy. It receives the validated binary, Copy B, fresh Cargo home, action-owned target path, + validated app environment, and declared/implicit output roots, but no provider token or sccache. +- [ ] If the parent `deploy-fastly.cache` option is enabled, restore its exact-key Cargo target cache + only after plan 2's lookup-eligibility predicate passes, audit it under the parent's contract, + and save it after successful credential-free app-build and source/output audit only when plan + 2's full protected-event save predicate passes. Cross-repository use without disclosure + acknowledgement fails before restore. Save must finish before token minting or injection and is + never retried or attempted after any token-bearing command. Commit independent lookup/save + truth-table integration tests for this cache family. +- [ ] Under `build-mode: never`, perform no target-cache restore/save and no credential-free build. + Provider deploy may still compile with the token for either mode; never claim app-build prevents + that compile and never expose its token-bearing outputs to a cache. +- [ ] Test cache hit/miss/save warning, build failure, audit failure, cancellation, and token-order + traces. Every path either saves before token introduction or performs no save. + +## 7. Provider lifecycle actions + +- [ ] Make every public provider action accept the named artifact, trusted producer + `action-version`, and complete `CallerExpectedIdentity`; require its own action repository/ref to + equal that version, derive PlatformIdentity locally, independently download/validate/smoke the + artifact, and remove its private workspace with `if: always()`. The two source-bearing actions + also accept exactly the action-local materialization inputs from Section 5 and independently + verify caller identity before use. No action accepts a host binary or authority path, exposes + one as a public output, or trusts another action's platform or materialization state. The + validation-only action has no outputs and removes its extracted binary before success returns. +- [ ] `active-version-fastly` is source-free and token-bearing. Return an empty version only when the + provider response confirms a first production deploy; reject malformed, ambiguous, or absent + state otherwise. +- [ ] `deploy-fastly` validates one binary and reuses it only within that action invocation. Preserve + the parent's production/staging sequence, rollback target capture, healthcheck order, + reconciliation, and output semantics. Production probes are tokenless; staging probes receive + only the token required for the staged endpoint. +- [ ] `healthcheck-fastly` remains source-free. Split production and staging token profiles and reject + an unexpected token in production rather than silently ignoring it. Enforce retry 1..20, delay + 0..300 seconds, per-attempt timeout 1..300 seconds, checked total-budget arithmetic, and the + 3,600-second maximum before launch. +- [ ] `rollback-fastly` remains source-free, validates the rollback target against current provider + state, publishes `mutation-attempted` immediately before launch, and reconciles cancellation or + lost output according to the parent contract. +- [ ] `config-push-fastly` mounts its action-local frozen repository read-only, confines the selected + manifest and file-backed config directly from that credential-free authority without creating + Copy B, or creates exactly one action-owned read-only inline file. Derive only the typed named + config overlay; `no-env` exposes none. Assign the pre-token, pre-start, and post-command + authority/manifest/config identity checks from Section 5 to this action and prove replacement + races fail. +- [ ] Every mutating action sets `mutation-attempted` host-side before starting the mutating CLI, + names the container, forwards termination within bounded time, and runs post-cancellation + reconciliation. No mutation can precede provenance, binary, source, output-root, and token-order + checks. +- [ ] Remove every implementation, fixture, and document path that accepts or emits legacy `--stage`. + Add a repository-wide negative test and use only `--staging` for staged CLI invocations. + +## 8. Verification and merge + +- [ ] Run deploy-core and every provider action suite; protocol and image tests; source/output hostile + fixtures; shellcheck; `scripts/run-actionlint.sh`; zizmor; repository Rust tests; and docs/pin + checks. +- [ ] Run Docker-backed integration tests for every profile and provider lifecycle on linux/amd64, + including read-only root/non-root behavior, exact mount/environment snapshots, controlled + loader argv, network denial, signal handling, cleanup, and mutation reconciliation. +- [ ] Review logs, summaries, outputs, cache contents, and artifacts for token/app-env/path leakage. + Redaction is not proof: tests assert the sensitive bytes were never supplied to disallowed + processes or persistence surfaces. +- [ ] Merge through the one-entry queue, record the resulting commit and active gate revision, and + hand both to plan 5. Do not designate final action revision `P` yet. + +**Gate:** provider actions execute only an independently validated artifact under a closed profile; +no credentialed command can consume source or generated output that escaped the frozen inventory, +and no post-token byte can enter either cache family. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-provenance.md b/docs/superpowers/plans/2026-08-20-build-cache-provenance.md new file mode 100644 index 00000000..070982be --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-build-cache-provenance.md @@ -0,0 +1,179 @@ +# Build Cache Provenance Implementation Plan (plan 3 of 5) + +> **Execution:** Start after plan 2 is merged and its gate revision is active. Rotate the gate before +> landing any new protected test/helper, then keep implementation changes separate. + +**Goal:** Make the reusable workflow the sole build-only producer of a deterministic protocol-1 app +CLI artifact and make every consumer independently validate exact caller and platform identity before +the binary can execute. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.26 Sections +3, 5.3, 6, 7, and 9. + +## 1. Fixed actions and boundaries + +- Pin upload to exact `actions/upload-artifact@v7.0.1` and download to exact + `actions/download-artifact@v8.0.1`. +- The artifact payload is the validator-produced deterministic ustar file. Artifact service wrapping + is transport only and is never parsed as provenance. +- The producer accepts no provider token/input and performs no provider mutation. The app checkout + token is host-only and absent before any container starts. +- `expected.json` has one typed producer: baked `edgezero-provenance-validator write-expected`. + Shell, jq, workflow expressions, and generic JSON writers do not encode either protocol document. +- Validation remains two invocations: trusted parse/extract, host output check, then hardened + credential-free binary smoke. +- Plan 1 already implemented and baked the protocol owner, schema, canonical golden/malformed + fixtures, and compiled fixture manifest into pinned image `D`. This plan integrates those bytes and + may add only host-side workflow/action fixtures outside the canonical image context. It must not + change the protocol crate, schema, baked fixtures, Dockerfile, image-context manifest, or `image.json`. + A defect in any of those stops this plan and requires a new gate/source/image/pin cycle before work + resumes. + +## 2. Gate update and integration fixtures + +- [ ] Hash and consume the already reviewed independent golden bytes for exact `expected.json`, + `app-cli-meta.json`, deterministic ustar, extracted binary, and every schema/duplicate/JCS/path/ + size/ELF failure. Add host-side integration assertions that do not call the production encoder as + their oracle and do not edit or duplicate the baked fixture authority. +- [ ] Add workflow/action structural tests for build-only permissions, exact-version action pins, checkout + credential removal, artifact-name uniqueness, no platform output, typed identity output, exact + EdgeZero/app checkout separation, no app-relative local action resolution, exact mount split, + cleanup on every exit, and no provider input or secret. +- [ ] Add substitution fixtures: caller-supplied expected JSON, alternate schema/fixtures, candidate + validator, shell-generated JSON, tar implementation, binary execution in parser container, + writable parser input, and a second downloaded artifact must all fail. +- [ ] Land and activate this gate update using the plan-1 rotation and rollback procedure before + modifying producer or consumer actions. Prove its changed paths are outside + `image-context-paths.txt`; otherwise stop for a new image release. + +## 3. Caller identity and authority export + +- [ ] Consume plan 2's sole identity-calculation helper over a validated authority + checkout; do not add another identity implementation. Require exact + repository ID from authenticated GitHub API, full lowercase source SHA, workspace/cwd + containment, tracked regular lockfile, and credential-free locked Cargo metadata agreement. + Derive the package version from that same metadata result; no caller input may override it. +- [ ] Produce exact bounded package/bin names and the design's length-framed workspace hash. Reject + non-UTF-8/escaping paths, symlink roots, malformed repository ID/SHA, duplicate outputs, and + submodule mismatch. +- [ ] Invoke plan 2's trusted authority materializer: fetch exact app/submodule commits without a + worktree, inspect all committed filter/config/submodule policy before it can execute, then + checkout and materialize only through the absolute verified Git LFS binary. Remove credentials + and their host channel before Copy A or any container exists. Produce tracked/submodule-only, + `.git`-free, non-hardlinked Copy A. Prove forbidden filters/hooks/origins are never reached and + the token never appears in Copy A, argv, environment, mounted data, logs, artifacts, or cache; + reverify the read-only authority after compilation. + +## 4. Expected identity and package + +- [ ] Derive `PlatformIdentity` only from the invoking action revision's validated `image.json`. + Reject tags, indexes, malformed records, mismatched protocol, and caller overrides. +- [ ] Invoke `write-expected` in the exact expected-write profile with only fresh `/work/expected` and + tmpfs. Require canonical `/work`, the baked schema, create-new/no-replace publication, exactly one + regular output, and complete host cleanup after abnormal exit. +- [ ] Select `cached-compile` only when validated boolean `cache:true`; select `uncached-compile` for + the default `cache:false`. Compile the named app CLI in that selected profile and retain its + exact host digest, size, mode, path, and single-link identity. Invoke `package` with either + profile's output plus read-only expected input and a fresh `/work/packaged`; require exactly one + deterministic `artifact.tar`. Test cache-on and cache-off producer/package paths independently. +- [ ] Run `package` twice over identical inputs and compare bytes. Validate archive member order, + headers, modes, owner fields, checksums, padding, end blocks, size caps, metadata digest/ELF + closure, and no extra filesystem entry. +- [ ] Validate `app-cli-artifact` as 1..128 ASCII bytes matching the design regex and unique in the + run. Upload exactly the literal file path with `archive:true`, `compression-level:0`, + `include-hidden-files:false`, `if-no-files-found:error`, `overwrite:false`, and + `retention-days:1`; require nonempty artifact id/digest outputs and no wildcard/multiple path. + +## 5. Reusable workflow interface + +- [ ] Implement the exact design input set and defaults. Validate booleans and numeric + `timeout-minutes` as an integer in 1..120 before checkout. Require the hosted runner/workflow identity contract + with exact stable `action-version` plus resolved action SHA, and one action version across all + EdgeZero calls. +- [ ] Declare required string inputs for repository/ref/id/workspace/package/bin/artifact, optional + string defaults for working directory/suffix/app env, boolean defaults for cache/disclosure, the + required `rust-toolchain`, numeric timeout default, and the required checkout secret exactly as specified. Parse the + received numeric timeout as an integer in 1..120 and reject every undeclared compatibility, + platform, provider, ambient-environment, Cargo, and arbitrary-argument surface. +- [ ] Require `job.workflow_ref` to name canonical exact stable version `V` in published use or the + distinct exact patch version `C` only in the disposable release fixture; `C` has no prerelease + suffix and its GitHub Release must report `prerelease:true`. Require `job.workflow_sha` to be + the ref's resolved full SHA. Use exact `actions/checkout@v7.0.1` with persisted credentials + disabled to check out only `stackpop/edgezero` at ref `job.workflow_sha` into a fixed private + action-source root. Use plan 2's prevalidated object-first materializer for the app at full + `app-ref` in a distinct authority root. Verify EdgeZero repository identity/HEAD/clean tree, + authority and Copy A export contracts, root separation, and that every local composite/helper + path resolves beneath the EdgeZero root rather than app data. +- [ ] Wire plan 2's already gated cache primitive and exact restore/save action versions into this + workflow. Add the first public cache-off, cold, warm, corrupt-restore, save-denied, and + warning-only save hosted runs; preserve evidence for exactly one Cargo compile/build invocation + after metadata preflight and for token absence. +- [ ] Expose only `artifact-name`, trusted `action-version`, resolved `action-revision`, and + CallerExpectedIdentity fields. Never expose the host artifact + path, container ref, platform digest/protocol, checkout token, cache path, or provider state. +- [ ] Test matrix legs with unique artifact names and independent identity comparison. Reject shared + aggregate outputs, duplicate names in one run, empty names, and cross-leg identity reuse. + +## 6. Consumer identity and action validation primitives + +- [ ] Create `.github/actions/compute-app-cli-identity/action.yml` as the public identity action that + wraps plan 2's sole identity-calculation helper. Create + `.github/actions/validate-app-cli-provenance/action.yml` as the no-output public validation + action. Implement the shared private artifact/expected/parse/extract/recheck/smoke helpers that + later provider actions call. Implement only the expected-write, provenance-package, + provenance-validate, and binary-smoke runner profiles here; plan 4 adds provider profiles. +- [ ] Give `compute-app-cli-identity` exactly the required string inputs `action-version`, + `app-repository`, `app-ref`, `app-repo-id`, `workspace-root`, `app-cli-package`, `app-cli-bin`, + and `rust-toolchain`, optional string `working-directory` default `.`, and required sensitive + string input `app-checkout-token`, supplied by the caller from a GitHub secret and masked before + use. Require its runner action repository/ref to equal the supplied exact version. Materialize + one action-private authority with plan 2's gated object-first helper, remove the credential + channel, recompute identity, and clean the authority on every exit. Output exactly + `app-repo-id`, `source-revision`, `app-cli-package`, `app-cli-bin`, and `workspace-id`; never + output a path, descriptor, opaque authority handle, token, or platform field. +- [ ] In each consuming action, create a private action workspace, derive local PlatformIdentity, + download exactly one named artifact with current repository/run id, `merge-multiple:false`, + `skip-decompress:false`, and `digest-mismatch:error`, and reject an invalid name, token, pattern, + artifact id, foreign run/repository selection, or second payload. Require the destination to + contain exactly one regular single-link `artifact.tar` before validation. +- [ ] In each consuming action, invoke baked `write-expected` into a fresh expected directory from + only the consumer-verified caller fields and locally derived platform fields before invoking + `validate`. Reject caller-supplied JSON/platform values, stale or preexisting output, duplicate + output, substitution, and cross-action expected-file reuse; clean the directory on every exit. +- [ ] In the consumer job, invoke `compute-app-cli-identity` once with the exact source inputs and + checkout-token secret, then compare every typed output with the reusable-workflow outputs before + invoking any provider action. Pass those verified caller fields to later actions; do not retain + or pass an authority path because the identity action destroys its authority before returning. + Source-free actions do not materialize source or mount Copy B; each compares artifact metadata + with the supplied verified caller fields and recomputes PlatformIdentity locally, which no + workflow output can substitute. Pass producer `action-version`; every action requires its own + `github.action_repository/ref` to equal `stackpop/edgezero@` before work. +- [ ] Run `validate` with read-only tar/expected/schema, fresh `/work/validated`, no network/credential/ + repository/Cargo/cache/binary execution, and the exact design memory/pid/10-minute limits. + Host-check exactly one + mode-0755 regular single-link output with recorded digest and size. +- [ ] Start a new container for binary smoke with no network or credential and only the validated + binary plus tmpfs. Use direct controlled-loader argv for dynamic binaries and direct execution + for static binaries. Recheck path/device/inode/digest/size/mode/link count before every later use. +- [ ] Ensure `if: always()` cleanup removes the private workspace and operation output parents without + following links. Any cleanup or post-cleanup verification failure is fatal. +- [ ] Keep the extracted path, digest, size, mode, device, and inode in action-private step state only; + expose no host binary path as a reusable-workflow or composite-action output. Make + `validate-app-cli-provenance` validation-only with no outputs and prove its path is absent after + successful cleanup. + +## 7. Verification and merge + +- [ ] Run all protocol crate tests, malformed/golden fixtures, producer/consumer contract tests, + artifact upload/download tests, shellcheck, `scripts/run-actionlint.sh`, zizmor, and repository + Rust/docs checks. +- [ ] Exercise corrupted transport, missing artifact, wrong artifact, replayed CallerExpectedIdentity, + locally changed image pin, parser crash/timeout/SIGKILL, output replacement race, and smoke + timeout. Every failure occurs before provider mutation. +- [ ] Merge through the one-entry queue and record the commit plus active gate revision for plan 4. + Do not designate `P` until launcher/provider and adoption plans pass. + +**Gate:** one deterministic archive and one typed expected identity cross the job boundary; the +consumer job recomputes caller identity without exporting authority state, and every consuming action +independently derives platform identity, checks both groups, and extracts without executing untrusted +bytes. 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 38bd60ef..adfaa0b8 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.19 +**Status:** Design (proposed) - v6.26 **Related:** `docs/specs/edgezero-deploy-github-action.md`, `docs/specs/edgezero-deploy-action-implementation-plan.md`, @@ -36,6 +36,14 @@ contracts. It must not expose provider credentials to app CLI compilation or to `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. +- Image publication trusts the digest-pinned official Rust base, Rustup's manifest/checksum chain, + Debian's signed package archive, and checksum-pinned Fastly/sccache release assets. Candidate source + cannot change their coordinates or verification steps. The build is not claimed byte-reproducible + across time: captured leaf digest `D` plus post-build tool/capability verification is the release + identity. +- Host source materialization trusts the exact checksum-pinned official Git LFS release asset and the + bounded GitHub release redirect path defined in Section 5.2. It never trusts a runner-preinstalled + Git LFS binary or a repository-selected download coordinate. ## 3. Terminology and identity @@ -61,8 +69,10 @@ Every EdgeZero action derives `PlatformIdentity` locally. Callers cannot provide 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. +The app checkout token is used host-side by the reusable producer, the public +`compute-app-cli-identity` action, and each source-bearing provider action to verify and materialize a +private repository independently. It is never copied into a container, exported working copy, +artifact, or cache, and no materialized authority path or handle crosses a public action boundary. ### 3.2 Canonical hashes @@ -78,11 +88,16 @@ no Unicode normalization; non-UTF-8 paths fail closed. 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`. +- `workspace-id` is SHA-256 over the concatenation + `::` with no separator or + newline, and is rendered as `sha256:<64-lowercase-hex>`. +- `cache-key-suffix` is valid UTF-8 from 0 through 255 bytes with no Unicode control character. + `suffix-hash` is SHA-256 over `:` with no newline and is rendered as + 64 lowercase hexadecimal characters without a `sha256:` prefix. -Committed golden vectors cover framing, the root representation, empty suffix, and byte-distinct NFC -and NFD paths that must produce different hashes. +Committed golden vectors cover framing, both digest renderings, the root representation, empty and +255-byte suffixes, rejected oversized/control-character suffixes, and byte-distinct NFC and NFD paths +that must produce different hashes. ### 3.3 Workflow and action revisions @@ -90,19 +105,71 @@ and NFD paths that must produce different hashes. 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`. +consumer workflows must use an exact patch-version tag of the form +`v..`, with canonical decimal components and no leading zero except the value +zero itself. Major-only tags, minor-only tags, prereleases, build metadata, branches, commit SHAs, +abbreviated SHAs, and floating names such as `main` or `latest` are not accepted. Docker action refs +remain digest-only. All EdgeZero references in one consumer workflow use one exact action version. +Third-party refs and released EdgeZero repository/documentation surfaces must name stable releases. +Before the first stable EdgeZero release exists, the four prepublication adoption documents may use +literal `` under the dual-state documentation gate defined below; those +examples are intentionally non-runnable until documentation revision `R`. The plan-5 disposable +release fixture alone may use distinct candidate `C`, which has the same exact patch-version grammar +but belongs to a GitHub Release whose `prerelease` field is `true`; `C` never contains a SemVer +prerelease suffix. That exception qualifies candidate commit `H` before final `V` is published. + +In this document, pinning a non-local GitHub action or reusable workflow always means the exact +patch-version tag above, never a commit SHA. Commit SHAs remain mandatory only where they identify +source or execution provenance: `app-ref`, `job.workflow_sha`, `action-revision`, protected-head +commit `Q`, rollout commits `G`/`S`/`B`/`H`/`P`/`R`, and the organization required-workflow descriptor +bound to gate commit `G`. +SHA-256 values remain content identities for images, archives, binaries, fixtures, and hashes; they +are not GitHub `uses:` refs. + +This is an explicit usability tradeoff, not a claim that ordinary Git tags are immutable. A +third-party publisher can move or delete a version tag or create a same-named branch that introduces +symbolic-ref ambiguity; those supply-chain risks are accepted for the separately reviewed, trusted +actions listed by the implementation plans. Initial review must still prove a published stable +release tag exists, no same-named branch exists, and the recorded resolved commit is the reviewed +release commit. EdgeZero's own `V` is +stronger: repository immutable releases must be enabled, `V` must be a published immutable release +whose target is executable commit `P`, and the no-bypass action-version tag ruleset must prohibit +update and deletion. A release version is never silently retargeted; a correction receives a new +patch version. 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. +- `job.workflow_ref` must be exactly + `stackpop/edgezero/.github/workflows/build-app-cli.yml@refs/tags/` where the action + version is stable `V` or, only in the release fixture, candidate `C`; +- `job.workflow_sha` must be a full lowercase 40-hex commit SHA and is the resolved executable + revision for that invocation (`H` under `C`, final `P` under `V`). It is not compared textually with the tag-bearing + `job.workflow_ref`. These hosted-runner context properties identify the workflow that defines the current job. They are part of the hosted-only v1 floor. +`Q` denotes the exact protected-default-branch commit whose post-merge `push` run is being examined. +It is generic: during this rollout it may be a gate candidate `G'`, release source `S`, pin baseline +`B`, an implementation commit, or final action revision `P`. The generic main-push assertion proves +workflow/context identity at `Q`; a release check separately proves that `Q=S` when qualifying the +image source. `H` is reserved for the final action candidate defined in Section 8. + +After those checks, the reusable job uses `actions/checkout@v7.0.1` only to place repository +`stackpop/edgezero` at `ref: job.workflow_sha` in a fixed private action-source directory with +`persist-credentials:false`. The trusted Section 5.2 materializer fetches the application at +`app-ref` into a distinct fixed private authority root without initially creating a worktree, proves +the committed filter/submodule policy, and only then checks out materialized bytes. The EdgeZero +checkout must have exact HEAD `job.workflow_sha`, repository id/name, clean state, and no submodule, +LFS, sparse, or untracked content. Every local composite/helper invocation resolves beneath that +verified EdgeZero root. No `./...` action or helper path may resolve against the application +authority, and neither root may overlap, contain, or symlink into the other. The app token is removed +from Git configuration and the credential channel before the workflow exports tracked files and +supported submodules into non-hardlinked Copy A. The authority remains read-only and is verified +before and after use; Copy A contains no `.git`, checkout credential, ignored file, or untracked file. + ## 4. Cache design ### 4.1 Cached data and fixed paths @@ -127,7 +194,11 @@ contain paths, source excerpts, warnings, and compile-time values. Dependency so 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. +support only anonymously fetchable crates.io and public Git dependencies. `cache:false` selects a +separate `uncached-compile` profile: after the required metadata preflight, Cargo has exactly one +compile/build invocation with a fresh target/Cargo home and +no sccache mount, process, wrapper, socket, or `SCCACHE_*` variable. It is not the cached profile with +cache steps merely skipped. ### 4.2 Keys, restore, and save @@ -156,16 +227,78 @@ is accepted; v1 performs no cache deletion. ### 4.3 Restore and runtime failure contracts -The sequence is: +For `cache:true`, the sequence is: + +1. Authenticate `app-repository`, verify its actual repository id and the authority checkout, then + validate cache eligibility from that verified id, including the cross-repository disclosure + acknowledgement, before creating the cache root or invoking a cache action. The unverified + `app-repo-id` input can never obtain the same-repository exemption. +2. Empty the stable host cache directory. +3. Restore the newest matching cache. +4. Audit restored data. On restore or audit failure, clear the directory and continue cold. +5. Start sccache, zero its statistics, and compile once. +6. Capture `sccache --show-stats --stats-format=json` and stop the server. +7. Audit the stopped directory again. +8. Save only when the compile succeeded, stop succeeded, the final audit passed, the captured + `cache_write_errors` count is zero, and the save-authorization predicate below is true. + +`cache: true` authorizes lookup and restore, not publication. Saving is permitted only when all of the +following action-derived conditions hold: the event is exactly `push` or `workflow_dispatch`, +`github.ref_protected` is the boolean `true`, `github.event.repository.fork` is the boolean `false`, +the event repository id equals `github.repository_id`, the workflow identity checks in Section 3.3 +passed, and the disclosure requirement in Section 4.4 passed. Pull-request events +(including `pull_request_target`), `merge_group`, forks, +unprotected refs, missing context, malformed values, and caller-supplied substitutes are restore-only +only after lookup eligibility passed. A cross-repository request without +`disclosure-acknowledged:true` is an input error before restore, not a restore-only row. The cache plan +commits separate lookup-eligibility and save-authorization truth tables; no input can override either +computed decision. + +The captured sccache statistics wire is the single JSON value emitted by exact v0.10.0 command +`sccache --show-stats --stats-format=json`. The parser rejects duplicate keys, trailing data, +non-UTF-8, non-object roots, unknown/missing top-level or `stats` fields, wrong types, negative or +non-integer counters, and any integer outside `u64`. Its closed v0.10.0 schema is independently +derived from the pinned `ServerInfo`/`ServerStats` source. The top level has exactly `stats`, +`cache_location`, `cache_size`, `max_cache_size`, `use_preprocessor_cache_mode`, and `version`. +`stats` has exactly these 22 keys: + +```text +compile_requests +requests_unsupported_compiler +requests_not_compile +requests_not_cacheable +requests_executed +cache_errors +cache_hits +cache_misses +cache_timeouts +cache_read_errors +non_cacheable_compilations +forced_recaches +cache_write_errors +cache_writes +cache_write_duration +cache_read_hit_duration +compilations +compiler_write_duration +compile_fails +not_cached +dist_compiles +dist_errors +``` + +`cache_errors`, `cache_hits`, and `cache_misses` are objects with exactly `counts` and `adv_counts`; +each is a map from a 1..255-byte UTF-8 key without Unicode controls to a `u64`. `not_cached` and +`dist_compiles` are maps with the same key and value bounds. The three duration fields are objects +with exactly canonical nonnegative integer `secs` and `nanos`, where both fit `u64` and +`nanos<1_000_000_000`. Every other `stats` field is a `u64`. -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. +For this managed local-disk profile, `cache_location` is exactly +`Local disk: "/work/sccache"`, `cache_size` is a `u64` no greater than 2,147,483,648, +`max_cache_size` is integer `2147483648`, `use_preprocessor_cache_mode` is false, and `version` is +exactly `0.10.0`; null optional-size values are rejected after a successful compile. A committed +schema and golden output enumerate the same fields and bounds. The implementation reads +`stats.cache_write_errors` only after the entire closed document validates. 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; @@ -175,25 +308,34 @@ 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. +absence never cause an action-level retry. After metadata preflight, the action invokes exactly one +Cargo compile/build command. Pinned sccache v0.10 may itself fall back to a local compiler invocation +after `CompileStarted` when the server response is lost; this internal fallback is accepted +pinned-client behavior and is not described or tested as single compiler-process execution. ### 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. +`SCCACHE_CACHE_SIZE=2G` is the managed sccache capacity. The independent hard tree bound is +2,147,483,648 bytes: the audit sums `st_size` for every regular file with checked integer arithmetic +and rejects a greater total. It also rejects sparse files (`st_blocks * 512 < st_size`), more than +100,000 descendants, a path longer than 4,096 bytes, or a single path component longer than 255 +bytes. Directory `st_size` values do not contribute. These are filesystem-tree limits, not a claim +about `actions/cache`'s host-selected tar, zstd, framing, or wire size. The selected cache action and +host archiver are outside the trusted data parser; an upload-size or archiver failure remains the +warning-only save failure defined above. The cache implementation plan pins +`actions/cache/restore@v6.1.0` and `actions/cache/save@v6.1.0` and tests these format-independent tree +bounds. Before use after restore and before save, the audit requires: -- the canonical audited root is exactly `SCCACHE_DIR`; +- the canonical audited root is exactly `SCCACHE_DIR`; it is the same recorded device/inode as the + action-created mode-0700 real directory, is owned by uid/gid 1001, and is not itself a mount; - 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 logical-byte, non-sparse-file, path-length, and entry-count bounds above all pass. 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 @@ -201,8 +343,19 @@ size, not authorship or semantic content. The cache is not content-authenticated 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. +Every cross-repository cached build requires `disclosure-acknowledged: true` before either cache +family is restored or saved; equality between the authenticated application repository id and event +repository id is the only exemption. The +parent `deploy-fastly.cache` target cache uses the same lookup-eligibility rule and the same +action-derived protected-event save predicate as the sccache family. A denied parent save remains +restore-only only when lookup was eligible. Its existing key/content/audit contract remains owned by +the parent deploy spec. + +At invocation start, the fixed host cache root must be absent beneath a real, non-symlinked +`${RUNNER_TEMP}`; the action creates it, records it, and never adopts a preexisting path. After the +single save attempt or any earlier terminal path, descriptor-relative no-follow cleanup removes that +recorded tree and verifies absence. Cleanup failure fails the action even when restore/save failure +itself was warning-only, because a dirty fixed root could contaminate another invocation in the job. ## 5. Container execution @@ -222,53 +375,133 @@ 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. +There are two independent exported-copy roles because GitHub jobs do not share filesystems. The +producer workflow and every consumer-side public action that needs repository source independently +create a private **authority checkout** at the exact app SHA through the object-first trusted +materializer defined below. The public identity action also creates its own short-lived authority for +identity calculation. The helper may use the app token only through its bounded host credential +channel; no application-controlled command, filter, hook, or worktree operation runs before policy +validation. No authority directory, descriptor, path, or opaque handle is accepted from or returned +to another public action. After credential removal and source validation, a trusted exporter creates +the applicable action-local copy from that authority checkout: + +- **Copy A, producer build job:** a private faithful copy used only for cached native CLI + compilation. It is mounted read-only; Cargo target, Cargo home, sccache, home, and temporary output + live in separate action-owned paths. The reusable workflow uploads its CLI artifact; Copy A is then + discarded. +- **Copy B, source-bearing consumer action:** a fresh private faithful copy exported from that + action's independently materialized authority. Its repository view is mounted read-only except for + the action-created nested output-root mounts, so generated files can flow from app build to + `fastly compute deploy` without granting general source write access. `deploy-fastly` creates one + Copy B at invocation start and may reuse it only between its internal app-build and deploy + operations; its active-version operation does not mount the copy. No authority, Copy B, or generated + root is shared across public action invocations. Source-free actions create no authority or Copy B. + Copy A never crosses into the consumer job. + +`config-push-fastly` is the sole source-bearing Copy-B exception: it executes no application code and +creates no Copy B. It independently materializes its own credential-free frozen authority and mounts +that authority read-only so its selected tracked manifest and file-backed config retain +repository-relative semantics. Before token creation and again immediately before container start, +the host records and verifies authority HEAD, index/worktree state, full tracked/submodule inventory, +and the selected manifest/config device, inode, digest, size, mode, and link count. It repeats those +checks after the command and before cleanup. Inline config is action-owned and receives the same +identity checks. Any authority or selected-file change, replacement, or race fails; fixtures exercise +replacement between every check and launch boundary. 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. +files and initialized submodules only; ignored and untracked detritus, `.git` directories/files, LFS +object stores, checkout credentials, and action metadata are absent. Every regular file has a newly +created inode, so hardlinks to the authority are forbidden. The authority is made read-only +after export and remains the freeze authority. + +Protocol 1 permits only paths with no Git filter or `filter=lfs`; any custom clean/smudge/process +filter, required filter other than LFS, or submodule using one fails. Before authority checkout, a +trusted host helper installs Git LFS 3.7.1 from exact asset +`git-lfs-linux-amd64-v3.7.1.tar.gz`, whose SHA-256 is +`1c0b6ee5200ca708c5cebebb18fdeb0e1c98f1af5c1a9cba205a4c0ab5a5ec08`. The closed canonical +gate-owned `.github/actions/deploy-core/host-tools.json` contains the following single data line with +no terminating newline; the Markdown fence line break is not file content: + +```text +{"git-lfs":{"asset":"git-lfs-linux-amd64-v3.7.1.tar.gz","sha256":"1c0b6ee5200ca708c5cebebb18fdeb0e1c98f1af5c1a9cba205a4c0ab5a5ec08","size":5524590,"version":"3.7.1"},"schema-version":1} +``` + +The download URL is constructed in trusted code as +`https://github.com/git-lfs/git-lfs/releases/download/v3.7.1/git-lfs-linux-amd64-v3.7.1.tar.gz`, not +read from data. Every hop is HTTPS, the initial host is exactly `github.com`, redirects are bounded +to three and may terminate only at `release-assets.githubusercontent.com`, and no credential is sent +on the public download. The final response must be HTTP 200 with identity content encoding and exactly +one decimal `Content-Length: 5524590`; the streaming receiver rejects an absent, duplicate, malformed, +or different length, more than 5,524,590 received bytes, early EOF, or trailing data. An unexpected +host, downgrade, redirect count, checksum, archive layout, or installed `git-lfs version` also fails +before authority materialization. + +The trusted materializer creates fresh Git object repositories with system/global configuration, +configuration includes, credential helpers, template hooks, and hook execution disabled. While no +application-controlled process is running, it uses the app token only through a noninteractive, +non-logging, host-only credential channel scoped to each canonical GitHub repository origin and +fetches exact app/submodule commits into object databases without creating a worktree or initializing +a submodule. Before any checkout, it recursively inspects the committed trees, `.gitattributes`, +`.lfsconfig`, `.gitmodules`, gitlinks, and submodule target trees using trusted Git plumbing. It +rejects `.lfsconfig`; any custom clean/smudge/process filter; any required filter other than LFS; any +repository/local/global/system `lfs.*` URL or transfer override; non-GitHub origin/submodule URLs; and +inconsistent, missing, or unlisted submodule commits. This validation may fetch a verified canonical +submodule origin but never creates its worktree or runs a filter, hook, or repository command. + +Only after the complete recursive object graph passes does the helper create the authority worktrees +with every filter disabled, including automatic LFS smudging. It invokes the absolute verified Git +LFS 3.7.1 binary directly to fetch and materialize the exact permitted LFS objects, checks out exact +submodule commits, runs `git lfs fsck --objects` in each repository, and rejects any worktree file +that remains a valid LFS pointer. It then removes the credential channel and every credential before +export. The exporter copies materialized worktree bytes, not pointer blobs. Tests prove forbidden +filter commands, hooks, and non-GitHub origins are never contacted or executed and cover absent/ +corrupt objects, pointer residue, nested submodules, credential cleanup, and configuration races. ### 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/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 | +| In-container path | Mode | Allowed operations | Source | +| --------------------------- | --------------- | ------------------------------------------------------------ | ----------------------------------------------- | +| `/work/repo` | read-only | cached-compile, uncached-compile, app-build, provider-deploy | Copy A or Copy B | +| `/work/repo` | read-only | config-push | credential-free frozen authority; no Copy B | +| `/work/repo/` | writable | app-build, provider-deploy | action-created declared/implicit directory | +| `/work/target` | writable | cached-compile, uncached-compile, app-build, provider-deploy | fresh or parent target cache as specified below | +| `/work/cargo-home` | writable, fresh | cached-compile, uncached-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/expected` | writable, fresh | expected-write | empty host expected-identity output directory | +| `/work/release` | writable, fresh | release-request-write | empty host release-request output directory | +| `/work/input/expected.json` | read-only | provenance-package, provenance-validate | validator-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/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 +- `uncached-compile`: Copy A, fresh target and Cargo home, and tmpfs; no token, sccache mount, + wrapper, socket, or sccache process. +- `app-build`: validated CLI, Copy B, fresh Cargo home, 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; +- `provider-deploy`: Copy B, fresh target/Cargo home, 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. +- `expected-write`: trusted baked validator, fresh writable `/work/expected`, and tmpfs; no + repository, app binary, target, Cargo, cache, network, or token. It converts typed identity scalars + into the only supported `expected.json` encoding. +- `release-request-write`: trusted baked validator, fresh writable `/work/release`, and tmpfs; no + repository, app binary, target, Cargo, cache, network, or token. It converts typed release scalars + into the only supported `release-request.json` encoding. - `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. @@ -277,6 +510,9 @@ Profiles: 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. +- `self-test`: no host bind mount. It reads only the image-owned validator, schema, and exact fixture + directory and writes only to `/work/home` and `/work/tmp` tmpfs; no network, repository, app + binary, token, Cargo, target, package, cache, or output bind mount. - `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. @@ -293,6 +529,28 @@ the parent's app-CLI metadata shape, caller override, archive member naming/orde packaging/extraction, and artifact-validation rules, in addition to changing isolation and mounting. Every staged CLI invocation uses `--staging`, never `--stage`. +Network and resource limits are closed by operation. An enabled network is Docker's ordinary isolated +bridge, never host or another container's namespace. Memory and memory-plus-swap limits are equal, so +no operation receives additional swap: + +| Operations | Network | Memory | Pids | Hard wall timeout | +| -------------------------------------------------- | ------- | ------- | ---- | ----------------- | +| cached-compile, uncached-compile | bridge | 6 GiB | 512 | `timeout-minutes` | +| app-build, provider-deploy | bridge | 6 GiB | 512 | 60 minutes | +| expected-write, release-request-write | none | 256 MiB | 32 | 60 seconds | +| provenance-package, provenance-validate, self-test | none | 2 GiB | 64 | 10 minutes | +| binary-smoke | none | 512 MiB | 64 | 60 seconds | +| active-version, provider-rollback, config-push | bridge | 1 GiB | 128 | 10 minutes | +| production/staging healthcheck | bridge | 1 GiB | 128 | computed below | + +`timeout-minutes` is a canonical decimal integer from 1 through 120 and defaults to 30. Healthcheck +`retry` is 1..20, `retry-delay` is 0..300 seconds, and per-attempt `timeout` is 1..300 seconds. Checked +arithmetic computes `retry * timeout + (retry - 1) * retry-delay + 30` seconds; the value must be at +most 3,600 and becomes the container hard wall timeout. The supervisor sends TERM on expiry or runner +cancellation, waits at most 10 seconds, then sends KILL and enters the parent reconciliation path. +Timeout, OOM, resource-limit, and forced-kill outcomes are failures; they never relax cleanup, +mutation, cache-save, or reconciliation rules. + ### 5.4 Constructed environments Every operation starts with `env -i` and a closed allowlist. `PATH` is @@ -302,46 +560,88 @@ Every operation starts with `env -i` and a closed allowlist. `PATH` is 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`. +- uncached compile: the same Rustup/Cargo, `HOME`, `TMPDIR`, and validated `app-env` values, but no + `RUSTC_WRAPPER`, `SCCACHE_*`, or sccache socket variable. - 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 + operation's action-owned target 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 packaging, provenance validation, and binary smoke: `PATH`, `HOME`, `TMPDIR` only. +- expected writing, release-request writing, provenance packaging, provenance validation, binary smoke, and self-test: + `PATH`, `HOME`, `TMPDIR` only. `self-test` argv is exactly + `edgezero-provenance-validator self-test --fixtures /usr/local/share/edgezero/provenance-fixtures`. - 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 +input (default `{}`). Its raw representation is valid UTF-8 and at most 65,536 bytes before parsing, +and duplicate keys are rejected before object construction. It has at most 64 entries and at most +32,768 UTF-8 bytes across names and values. Every value is a JSON string; numbers, booleans, null, +arrays, and objects fail. A name is 1..127 ASCII bytes matching +`[A-Za-z_][A-Za-z0-9_]*`; a value is at most 8,192 UTF-8 bytes and contains no NUL, C0 control, or DEL. +Reserved-name comparison is ASCII-case-insensitive. The exact deny set is: + +- exact names `PATH`, `HOME`, `TMPDIR`, `TMP`, `TEMP`, `PWD`, `OLDPWD`, `SHELL`, `BASH_ENV`, `ENV`, + `CDPATH`, `IFS`, `GLOBIGNORE`, `SHELLOPTS`, `BASHOPTS`, `CC`, `CXX`, `AR`, `AS`, `LD`, `NM`, + `OBJCOPY`, `OBJDUMP`, `RANLIB`, `STRIP`, `CFLAGS`, `CXXFLAGS`, `CPPFLAGS`, `LDFLAGS`, `RUSTFLAGS`, + `RUSTDOCFLAGS`, `RUSTC`, `RUSTDOC`, `ARFLAGS`, `CXXSTDLIB`, `CXXSTDLIB_STATIC`, + `CRATE_CC_NO_DEFAULTS`, `CC_KNOWN_WRAPPER_CUSTOM`, `CC_SHELL_ESCAPED_FLAGS`, + `CC_ENABLE_DEBUG_OUTPUT`, `NUM_JOBS`, `MAKEFLAGS`, `MFLAGS`, `LIBRARY_PATH`, `CPATH`, + `C_INCLUDE_PATH`, `CPLUS_INCLUDE_PATH`, `GCC_EXEC_PREFIX`, `COMPILER_PATH`, `PKG_CONFIG`, + `PKG_CONFIG_PATH`, `PKG_CONFIG_LIBDIR`, and `PKG_CONFIG_SYSROOT_DIR`; +- prefixes `GITHUB_`, `RUNNER_`, `ACTIONS_`, `EDGEZERO_`, `FASTLY_`, `CARGO_`, `RUST_`, `RUSTC_`, + `RUSTDOC_`, `RUSTUP_`, `SCCACHE_`, `CRATE_CC_`, `CXXSTDLIB_`, `LD_`, `DYLD_`, and `BASH_FUNC_`; and +- target- or build-kind-qualified native-tool names matching either + `(CC|CXX|AR|AS|LD|NM|OBJCOPY|OBJDUMP|RANLIB|STRIP|ARFLAGS|CFLAGS|CXXFLAGS|CPPFLAGS|LDFLAGS)_.+` or + `.+_(CC|CXX|AR|AS|LD|NM|OBJCOPY|OBJDUMP|RANLIB|STRIP|ARFLAGS|CFLAGS|CXXFLAGS|CPPFLAGS|LDFLAGS)`. + This includes cc-rs forms such as `CC_`, `_CC`, `HOST_CC`, `TARGET_CC`, and their + target/build-kind flag equivalents. + +The caller is responsible for passing no credential under an otherwise allowed application name; +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`, `RUSTC`, `RUSTDOC`, compiler wrappers, Rust flags, native-tool variables, ambient +application variables, and unlisted `EDGEZERO_*` variables are absent rather than scrubbed after +inheritance. Cached and uncached profiles both prove those variables cannot be reintroduced through +`app-env`. + +No environment value is placed literally in Docker CLI argv. The action writes the already validated +operation environment to one fresh mode-0600, single-link, action-owned env file outside every +checkout/cache/output root, invokes `docker create --env-file ` for a uniquely named container, +and deletes and verifies absence of the env file before `docker start --attach`. File serialization +rejects newline and NUL in every name/value; provider inputs whose token format permits either are +therefore invalid. For token-bearing profiles, the final source/output/binary checks occur immediately +before this env file is created. Create/start/attach failure still triggers named-container removal, +env-file removal, private-workspace cleanup, and any required mutation reconciliation. The token may +exist in the isolated runner's Docker container metadata while that container exists; no other +container receives Docker-socket access, and removal is mandatory before the action completes. + +Protocol 1's Cargo-config allowlist is empty. Before compilation, the action rejects `.cargo/config`, +`.cargo/config.toml`, and legacy `.cargo/credentials*` at the cwd, every ancestor through `git-root`, +and every enclosing workspace directory copied into `/work/repo`; the fresh action-owned +`CARGO_HOME` must contain none of those files. Cargo environment controls are absent under the closed +environment above. `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. +in `image.json`'s image; a mismatch fails before container launch. Alternate toolchains or Cargo +configuration require a separate protocol revision 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. +The authority checkout must be full, recursive, non-sparse, satisfy the no-filter-or-pinned-LFS +contract in Section 5.2, and start clean: `HEAD` equals `source-revision`, no tracked/index or +untracked modification exists, and every initialized submodule is clean at its recorded gitlink. +Credentials are removed before export, and Copy A or Copy B must pass the trusted export-inventory +comparison before any application command. Before and after app-controlled commands, the authority's +repository id, HEAD, clean state, filter policy, LFS object/materialization 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 @@ -354,13 +654,38 @@ whether or not `app-build` ran and, when it did, runs after that credential-free 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. +- every output-root entry must be a directory or regular file owned by the container uid/gid; regular + files must have `nlink == 1` and must not be sparse (`st_blocks * 512 < st_size` fails), and + symlinks, sockets, FIFOs, devices, mounts, and hardlinks fail; +- across all output roots, checked arithmetic permits at most 2,147,483,648 summed regular-file + `st_size` bytes and 100,000 descendants. Existing component and joined-path bounds remain in force. 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. +`deploy-fastly` (default `[]`). The raw input is valid UTF-8 and at most 65,536 bytes before parsing; +the array has at most 32 unique entries. Each entry is 1..1,024 UTF-8 +bytes, uses `/` separators, is already in repository-relative lexical normal form, and has no empty, +`.`, `..`, NUL, control-character, or backslash component; every component is at most 255 UTF-8 bytes. +The joined absolute host path is at most 4,096 bytes. Each nearest existing parent is resolved before +app code runs, must be a real directory reached without a symlink, and must canonicalize beneath the +repository. Declared roots must be pairwise nonoverlapping, contain no tracked path, and be absent +initially. The action creates each as an empty mode-0700 directory, records its device/inode as action- +owned, and grants Copy B its only repository write access through those directory mounts. It audits +the closed file-type, ownership, hardlink, and confinement rules above after every app-controlled +command and immediately before every token-bearing operation. + +Action-owned target and Cargo-home paths outside the repository are implicit and cannot be +overridden. The current Fastly CLI changes cwd to the resolved project directory containing the +selected `fastly.toml` and writes both `bin/main.wasm` and `pkg/.tar.gz` there. Therefore +`/bin` and `/pkg`, not workspace-root paths, are two +additional implicit output roots. The manifest is resolved before any app process; both roots obey +the same absent-before-create, mount, audit, and ownership contract and callers do not repeat them. +The action keeps all output roots only until the last operation that consumes them, then a trusted +descriptor-relative cleanup routine removes each recorded action-owned tree without following links +and verifies the original checkout and Copy B again. Cleanup runs on success and ordinary failure; +cleanup or post-cleanup verification failure fails the action. A preexisting root fails before any +provider mutation, so cleanup never adopts or deletes caller-owned content. Any application whose +credential-free build writes elsewhere must declare every additional root; the action never infers +permission from an observed mutation. 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 @@ -436,8 +761,10 @@ are exact: - `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`. + equal the validated Cargo package and target names; `app-cli-version` must equal that package's + version from the same credential-free locked Cargo metadata result. 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 @@ -448,7 +775,7 @@ are exact: - `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. + no slash, backslash, dollar sign, 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 @@ -488,10 +815,15 @@ contain at most one `PT_INTERP`. If it has an interpreter or any `DT_NEEDED`, it `/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, +Program headers are the sole loader-visible authority. In addition to the ELF magic, `EI_CLASS` is +`ELFCLASS64`, `EI_DATA` is `ELFDATA2LSB`, `EI_VERSION` and `e_version` are `EV_CURRENT`, `e_ehsize` +is exactly 64, `EI_OSABI` is `ELFOSABI_SYSV`, `EI_ABIVERSION` is zero, every `EI_PAD` byte is zero, +`e_flags` is zero, and `e_phentsize` is exactly 56. `e_phnum` is nonzero and is not `PN_XNUM`; extended +program-header numbering is rejected rather than consulting section header zero. 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. @@ -530,32 +862,36 @@ all-zero bytes after the first `DT_NULL`, every accepted tag appears at most onc 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`. +Protocol 1 metadata describes the primary's load-time ELF closure: its exact `PT_INTERP` and the +recursively traversed `DT_NEEDED` entries. It does not certify later application-directed `dlopen` or +child-process behavior, and the security model does not represent an application binary as trusted +merely because this structural profile passes. `DT_NEEDED` values containing `/`, `\\`, or `$` fail. +Rejecting `$` prevents glibc's `$ORIGIN`, `${ORIGIN}`, `$LIB`, `${LIB}`, `$PLATFORM`, and +`${PLATFORM}` expansion from making startup resolution differ from the validator's literal lookup. + +The image build copies the complete reviewed x86-64 startup-library closure into the flat directory +`/opt/edgezero/runtime-lib`; that directory contains regular files only and no subdirectory, symlink, +or duplicate basename. A dynamic primary's `PT_INTERP` is exactly +`/lib64/ld-linux-x86-64.so.2`. The validator preserves duplicate direct `DT_NEEDED` values for +metadata, sorts them bytewise, and resolves every dependency basename only as +`/opt/edgezero/runtime-lib/`. A missing, escaping, non-regular, multiply linked, or duplicate +candidate fails. Recursive inspection uses a device/inode visited set so dependency cycles terminate, +and every transitive library satisfies this same profile. The interpreter resolves inside the +immutable image root, is a regular `ET_DYN` file with no `PT_INTERP`, and is parsed and recorded as a +member of the validator's visited runtime closure but is not added to the primary's +`abi.needed` metadata array. + +The final image has no `/etc/ld.so.preload`. Every dynamic `binary-smoke` and provider-action launch +uses the container runtime's argv/entrypoint API directly, without a shell, to invoke that validated +interpreter with exact arguments `--inhibit-cache`, `--glibc-hwcaps-mask`, the empty-string mask, +`--library-path`, `/opt/edgezero/runtime-lib`, then `/work/bin/app-cli` and the validated operation +arguments. Thus `/etc/ld.so.cache`, default-directory precedence, and hardware-capability +subdirectories cannot select a different object for the validated startup closure. A static primary +is launched directly and has no `PT_INTERP` or `PT_DYNAMIC`. The verifier never invokes `ldd` and +never infers trust from loader output. Tests include preload presence, cache-only libraries, +hardware-capability alternates, default-directory duplicates, wrong interpreter, missing flat-closure +members, and an application-directed `dlopen` fixture demonstrating that such runtime behavior is +outside the metadata claim rather than silently certified. ### 6.5 Protocol-owner CLI @@ -563,6 +899,24 @@ The synchronous `edgezero-provenance-validator` binary owns both encoding and va Tokio dependency and never executes an app binary. Its stable credential-free interface is: ```text +edgezero-provenance-validator write-expected \ + --work-root /work \ + --app-repo-id \ + --source-revision <40-lowercase-hex> \ + --app-cli-package \ + --app-cli-bin \ + --workspace-id sha256:<64-lowercase-hex> \ + --platform-id sha256:<64-lowercase-hex> \ + --provenance-protocol 1 \ + --output /work/expected/expected.json + +edgezero-provenance-validator write-release-request \ + --work-root /work \ + --gate-sha <40-lowercase-hex> \ + --provenance-protocol 1 \ + --release-tag build-container-v \ + --output /work/release/release-request.json + edgezero-provenance-validator package \ --work-root /work \ --binary /work/input/app-cli \ @@ -583,8 +937,17 @@ edgezero-provenance-validator self-test \ ``` `--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 +container. Every caller-selected input and output parent for those commands must canonicalize beneath +it, except the trusted baked schema path, which must be the exact literal path shown above and must +resolve to the image-owned regular file. `self-test` accepts only the exact baked fixture path shown +above, which must resolve to the image-owned fixture directory. `write-expected` accepts only the typed bounded scalars shown above, fixes `schema-version` to +`1`, derives `container-ref` from the fixed repository plus `platform-id`, and atomically publishes +canonical `expected.json`; shell, `jq`, and generic JSON encoders are not supported producers. +`write-release-request` accepts only the exact typed gate SHA, protocol, and release-tag scalars, +fixes the three-key JCS shape from Section 8, and create-new publishes only the literal basename +`release-request.json` in a fresh `/work/release` output directory. It is the sole supported producer +of that file. +`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 @@ -611,9 +974,11 @@ Validation is deliberately two container invocations: 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 untrusted app binary never shares a writable mount with the parser/extractor. Within one composite +action invocation, the trusted validation step records the host path, digest, size, mode, device, and +inode only in action-private state for its later steps. No public action or reusable-workflow output +exposes a host binary path; the path becomes invalid when the mandatory end-of-action cleanup removes +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 both JSON documents, @@ -629,39 +994,100 @@ produce byte-identical archives, and `validate` must accept that golden output. Inputs: - `app-repository`, `app-ref`, `app-repo-id`, `working-directory` (default `.`), `workspace-root`, - `app-cli-package`, `app-cli-bin`, `app-cli-artifact`; + `app-cli-package`, `app-cli-bin`, `app-cli-artifact`, `rust-toolchain`; - `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. +The reusable-workflow input schema is exact. `app-repository`, `app-ref`, `app-repo-id`, +`workspace-root`, `app-cli-package`, `app-cli-bin`, `app-cli-artifact`, and `rust-toolchain` are +required strings. +`working-directory`, `cache-key-suffix`, and `app-env` are optional strings with defaults `.`, the +empty string, and `{}`. `cache` and `disclosure-acknowledged` are boolean inputs defaulting to false. +`timeout-minutes` is a number input defaulting to 30 and must be an integer in the range already +defined. `app-checkout-token` is a required secret. No compatibility alias, platform/container input, +provider input, generic environment map, or arbitrary Cargo/action argument is accepted. + +The workflow verifies its hosted identity and materializes its resolved action source before it +processes application input. The EdgeZero source checkout, app authority checkout, and Copy A use +three distinct fixed children of a fresh workspace. After token removal, the trusted exporter creates +Copy A from the validated authority checkout under Section 5.2; checkout never writes directly into +Copy A. Every local action/helper is invoked only from the verified EdgeZero checkout at +`job.workflow_sha`; application paths cannot shadow action code. + +`app-cli-artifact` is 1..128 ASCII bytes matching +`[A-Za-z0-9][A-Za-z0-9._-]{0,127}` and 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. + +The producer pins `actions/upload-artifact@v7.0.1`, uploads the one literal `artifact.tar` path with +`archive:true`, `compression-level:0`, `include-hidden-files:false`, `if-no-files-found:error`, +`overwrite:false`, and `retention-days:1`, and requires nonempty artifact id/digest outputs. The ZIP +wrapper and service digest are transport checks, not protocol provenance. Each consumer pins +`actions/download-artifact@v8.0.1`, supplies exact `name`, private destination `path`, current repository and +run id, `merge-multiple:false`, `skip-decompress:false`, and `digest-mismatch:error`, and supplies no +cross-repository token, pattern, or artifact id. It then requires that the destination contains only +one regular single-link `artifact.tar`; archive parsing remains the protocol validator's job. + +Outputs are `artifact-name`, `action-version`, `action-revision`, plus every +`CallerExpectedIdentity` field: `app-repo-id`, `source-revision`, `app-cli-package`, `app-cli-bin`, +and `workspace-id`. `action-version` is exact `V` or release-fixture `C` parsed from +`job.workflow_ref`, and +`action-revision` is exact `job.workflow_sha`. It does not output `platform-id`, `container-ref`, or +protocol. + +The consumer job invokes public action `stackpop/edgezero/.github/actions/compute-app-cli-identity` +at the same exact `action-version` as the producer and every provider action. Its exact required +string inputs are `action-version`, `app-repository`, `app-ref`, `app-repo-id`, `workspace-root`, +`app-cli-package`, `app-cli-bin`, and `rust-toolchain`; `working-directory` is an optional string +defaulting to `.`, and `app-checkout-token` is a required sensitive string input supplied by the +caller from a GitHub secret and masked before use. The action requires +`github.action_repository==stackpop/edgezero` and `github.action_ref==action-version`, independently +materializes and validates a short-lived authority through Section 5.2, removes the credential +channel, computes identity, and cleans the authority on every exit. It outputs exactly the five +`CallerExpectedIdentity` fields and no authority path, descriptor, opaque handle, credential, +platform field, or other host state. The job compares every output with the reusable-workflow output +before invoking another EdgeZero action. + +Every later EdgeZero action receives `action-version`, requires the same runner-provided action +repository/ref equality, receives the consumer-verified `CallerExpectedIdentity`, and derives +`PlatformIdentity` from its local action files. Immutable release enforcement binds that version to +the producer's recorded `action-revision`; the consumer does not accept either value as a +caller-authored replacement. A source-bearing action does not trust the identity action's destroyed +authority: it independently repeats materialization and caller-identity verification under its own +action lifetime as defined below. 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 +Every provider action accepts `app-cli-artifact`, producer `action-version`, and +`CallerExpectedIdentity`, verifies its own repository/version context, 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()`. +the full validation sequence itself. Before `validate`, it invokes the baked validator's +`write-expected` command with the supplied, already consumer-verified caller fields plus its locally +derived platform fields into a fresh action-private expected directory. It never accepts expected +JSON, a platform field, or a host binary path from the caller, and never reuses an expected file from +another action invocation. 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 +The source-bearing actions are exactly `deploy-fastly` and `config-push-fastly`. In addition to the +common inputs, both require string inputs `app-repository`, `app-ref`, `app-repo-id`, +`workspace-root`, `app-cli-package`, `app-cli-bin`, and `rust-toolchain`, accept optional string +`working-directory` defaulting to `.`, and require sensitive string input `app-checkout-token`, which +the caller supplies from a GitHub secret and the action masks before use. Each uses those inputs to +independently materialize an action-local authority, removes the checkout credential channel before +any application code, provider-token creation, or provider-token injection, and recomputes all five +caller-identity fields. A mismatch with supplied `CallerExpectedIdentity` fails before artifact +execution or provider mutation. Cleanup removes and verifies the action-local authority and any Copy +B on every exit. No source-free action accepts repository-materialization inputs or +`app-checkout-token`. + +`deploy-fastly` additionally accepts `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 @@ -669,7 +1095,9 @@ within that invocation. `active-version-fastly` is also a source-free action wit `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. +before app code or provider mutation. `validate-app-cli-provenance` is validation-only and has no +outputs; success means that its independent download, parse/extract, host check, and binary smoke all +completed before its private workspace was removed. `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 @@ -690,6 +1118,16 @@ checksum `1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b`. The 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. +The validator lives at `.github/tools/edgezero-provenance-validator` as a self-contained Cargo +workspace: its package manifest contains its own `[workspace]`, it has its own committed lockfile, +and it has no path dependency outside that directory. The staged Docker context excludes the root +workspace `Cargo.toml` and `Cargo.lock`; it includes the complete validator directory plus the other +explicit image assets only. The Dockerfile invokes one exact +`cargo build --locked --release --manifest-path +.github/tools/edgezero-provenance-validator/Cargo.toml`. Gate tests run +`cargo metadata --locked --manifest-path` inside the staged context and then the exact Docker build, +so a manifest that names an absent workspace member or path dependency cannot pass. + `image.json` is a reviewed record with exactly these typed fields: ```json @@ -704,94 +1142,406 @@ base digest or tool asset requires a new source revision and image digest. `tag` is informational. Runtime pulls use only `repository@digest`. -The rollout has three relevant revisions: +The pin PR changes exactly `image.json` and canonical `image-release-evidence.json` together. The +latter is UTF-8 RFC 8785 JCS with no BOM, surrounding whitespace, or trailing newline. It contains +the following single data line; the Markdown fence line break is not file content: -- `S` is the full source commit used to build the image. The image has OCI label +```text +{"approval-challenge":"<64-lowercase-hex>","approver-login":"","image-digest":"sha256:<64-lowercase-hex>","release-tag":"build-container-v","reviewed-at":"","run-attempt":"","run-id":"","schema-version":1,"screenshot-sha256":"sha256:<64-lowercase-hex>","source-revision":"<40-lowercase-hex>"} +``` + +Its values equal the current approval record and `image.json`; run identifiers are strings to avoid +JSON number precision loss. A gate-owned typed writer is the sole producer. Unknown/duplicate/missing +keys, wrong types/order/JCS bytes, invalid bounds, or cross-file mismatch fail. Runtime actions do not +read this evidence file. + +The rollout has five relevant commits and two action-release tags: + +- `G` is the protected gate and image-source baseline. It contains the protocol validator, schema, + golden/malformed fixtures, image verifier, fail-closed classifier, release-policy verifier, + approval gate, pin-PR updater, publisher contract checker, exact Dockerfile, root `.dockerignore`, + canonical image-context manifest, and both `.github/workflows/build-container-ci.yml` and + `.github/workflows/publish-build-container.yml`, plus the gate-rotation lock workflow and verifier. + Repository variable + `EDGEZERO_BUILD_CONTAINER_GATE_SHA` equals `G`. Organization ruleset + `edgezero-build-container-required-workflow` has target `branch`, active enforcement, no bypass + actors, conditions containing repository id `[]` and ref-name include + `["refs/heads/main"]` with no excludes, and exactly one `workflows` rule. That rule has + `do_not_enforce_on_create=false` and exactly one descriptor with the EdgeZero repository id, the + literal workflow path, full SHA `G`, and no `ref`. The workflow checks out `G` separately from + candidate source and executes only gate code from `G`; candidate files are data under test. A + change to a gate-owned or image-context path must land as a separately reviewed new `G`, and the + ruleset must be updated to that SHA, before any dependent release request. +- `S` is the full release-source commit. It descends from `G`, changes exactly the canonical + `release-request.json` described below, and leaves every gate-owned and image-context byte equal to + `G`. The publisher uses a trusted `G` helper to construct a fresh Docker context containing only the + manifested paths copied from the verified `G` checkout; it never builds a candidate Dockerfile or + admits an unmanifested repository path. The image has OCI label `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 - cache, provenance, launcher, and consumer implementation. Consumers pin all EdgeZero - workflow/action references to full SHA `P`. + `image.json` plus its bound `image-release-evidence.json`, and permanent pin CI is enabled. +- `H` is the protected-main commit proposed as the final action revision. It contains the unchanged + reviewed pin plus the cache, provenance, launcher, and consumer implementation. The prepublication + adoption documents still contain only the explicit action-version placeholder allowed by the + bootstrap documentation gate. +- `C` is a unique canonical exact patch-version tag published as an immutable + GitHub Release at `H` with `prerelease:true`. Its tag has no prerelease suffix. The complete local + suite runs from a clean detached `H`; the complete hosted cross-repository/provider suite invokes + `C`. A failure leaves `C` immutable and requires a new commit plus a newly selected unused `C`. +- `P` is defined as `H` only after the exact-`H` local suite and complete hosted suite through `C` + pass. Before that point, no text may call `H` final revision `P`. +- `V` is a canonical exact stable action version `v..`, distinct from `C`, + selected only after `H` qualifies as `P`. An immutable stable release `V` is published at `P`, then + a final hosted identity and consumer smoke uses literal `V`. The release API and remote refs must + both resolve `C` and `V` to `P`. +- `R` is the later protected-main documentation revision. It changes only tracked Markdown adoption + surfaces and `docs/.edgezero-action-release.json`, replacing the bootstrap placeholder with literal + `V` and activating the documentation gate's released state. It does not alter action/workflow code + and is not a new action revision; published consumers pin `V`, never `P`, `C`, a major/minor tag, or + a branch. + +`docs/.edgezero-action-release.json` is absent through `P`. Revision `R` adds it as UTF-8 RFC 8785 +JCS with no BOM, surrounding whitespace, or trailing newline. It contains the following single data +line; the Markdown fence line break is not file content: + +```text +{"action-revision":"

","action-version":"","schema-version":1} +``` + +The action revision and version obey their exact grammars and must match the already published stable +immutable release and peeled remote tag. Unknown, duplicate, missing, reordered, or mistyped fields +fail. + +`release-request.json` is UTF-8 RFC 8785 JCS with no BOM, surrounding whitespace, or trailing +newline. It contains the following single data line; the Markdown fence line break is not file +content: + +```text +{"gate-sha":"","provenance-protocol":1,"release-tag":"build-container-v"} +``` + +`gate-sha` is lowercase full hex and must equal the active gate variable and required-workflow SHA; +the decimal has no sign or leading zero. The source PR changes this file and no other path. Its tag is +unused until the reviewed operator creates that exact protected tag at `S`. A changed Dockerfile, +`.dockerignore`, image manifest, validator input, workspace lockfile, or other repository image-context +byte is a gate update, never an ordinary `S` candidate. + +The reviewed operator creates this file using a local image built solely from the canonical context +staged from clean `G`. The build records local image/config identity `L` from BuildKit's `--iidfile`, +and the gate-owned verifier requires `L` to be a leaf `linux/amd64` image with revision label `G`, the +expected protocol, and the complete validator/toolchain contract. The operator then invokes +`write-release-request` by immutable local identity `L`, with no network, credentials, repository +mount, or tag lookup and with only the closed `release-request-write` profile. This local bootstrap +image is not published and is not digest `D`; no already-published or pinned `G` image is assumed. 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. +The gate owns a canonical path manifest and `.github/CODEOWNERS`; the latter assigns every manifested +path and itself to `@stackpop/edgezero-build-container-gate-reviewers`. The no-bypass default-branch +ruleset requires code-owner review, at least two approving reviews, dismissal of stale approvals, and +the merge queue. A gate-update PR changes only paths in the union of the old and candidate canonical +manifests. Old `G` validates the candidate manifest's canonical sorted form, validates candidate +`CODEOWNERS` coverage, classifies the change as `mode=gate-update`, executes only old-`G` static and +subject-data checks, and never runs candidate gate code. A mixed gate/non-gate change fails. If that PR +passes the old gate and required human reviews, merging it creates candidate `G'`, not `S`. Until +activation, the protected base's manifested bytes differ from active `G`, so every ordinary candidate +and release preflight fails. + +To activate `G'`, dispatch the gate-owned rotation-lock workflow from protected main while main and +both gate pointers still equal old `G`. It uses the exact repository-global +`edgezero-build-container-publication` concurrency group with `cancel-in-progress:false` and +`queue:max`; after all older publishers finish, its unprivileged acquire job records its run id and its +second job waits for independent approval on environment `build-container-gate-rotation-lock`. The +waiting workflow holds the concurrency group. Before first use, a live fixture must prove that a +publisher dispatched behind this waiting job remains pending and starts no build/push step. + +While the lock is held, the operator verifies no older publication is active or pending ahead of it, +sets repository variable `EDGEZERO_BUILD_CONTAINER_RELEASE_STATE` from `enabled` to +`disabled::`, removes the release environment's tag policy, and verifies release is +disabled. The operator then merges the gate update through the one-entry queue. From clean detached +`G'`, run the full gate suite and require the generic post-merge main-push assertion at `Q=G'`; update +the disabled marker to `disabled:::`; then update both +`EDGEZERO_BUILD_CONTAINER_GATE_SHA` and the organization required-workflow descriptor SHA to `G'`. +Either intermediate mismatch fails all required runs. Verify the base's complete manifested tree +equals `G'`, restore the sole tag policy, produce new independently reviewed prerequisite evidence, +and set release state back to exact `enabled`. Only then may a reviewer enter the canonical rotation +evidence comment and approve the waiting lock job, whose old-`G` helper verifies its own run/context, +the main head, public repository variables, and evidence digest before releasing concurrency. No +ruleset bypass is used for a gate update. + +If activation fails before both pointers and all post-activation checks agree, keep release disabled +and restore both pointers to old `G` while the lock remains held. Because the base then still contains +`G'`, ordinary work remains blocked. Old `G` has a distinct `mode=gate-rollback`: both active pointers +must equal old `G` and release must remain disabled; the current base must be exactly the failed `G'` +tree that old `G` validates as a canonical gate update; the proposed head's complete manifested tree +must be byte-identical to old `G`; changed paths must be confined to the union of the `G'` and old-`G` +manifests; and no release request, pin record, or non-gate path may change. Merge that separately +reviewed rollback through the one-entry queue, require generic main-push evidence at the resulting +head `Q`, verify the base tree and both pointers equal old `G`, and only then restore release policy. +If old `G` cannot validate either the current `G'` tree or the exact restoration, recovery is a manual +trust-root operation requiring the same independent review as bootstrap, and release remains disabled +throughout. No mixed `{variable, descriptor, base manifest}` state is a degraded operating mode. +If manual recovery cannot finish before the lock run expires, leave release state disabled and the tag +policy absent before canceling it; every later publisher must acquire concurrency and fail closed on +the disabled state before image build or push. + +`build-container-gate-rotation-lock` has no secret or variable and no enabled GitHub App custom +protection rule. It permits only protected `main`, disables administrator bypass, requires a nonempty +reviewer set with self-review prevention, and is referenced with `deployment:false`. Its one approval +comment is exactly: + +```text +edgezero-gate-rotation-v1 {"evidence-sha256":"sha256:<64-lowercase-hex>","head-sha":"","lock-run-id":"","new-gate-sha":"","old-gate-sha":"","result":"activated|rolled-back","reviewed-at":""} +``` + +The compact JSON uses the shown key order/types and no extra whitespace. The reviewer differs from +the operator, `reviewed-at` uses the release approval's exact time grammar and 15-minute freshness +window, run id equals the lock run, and `head-sha` is the current protected head. The attached +canonical evidence proves the exact policy/pointer/base checks; the helper recomputes its digest and +requires exactly one current-run protocol comment. An activated result requires head tree and both +pointers at `G'`, restored tag policy, and release state enabled. A rolled-back result requires an +exact old-`G` tree, both pointers at old `G`, restored tag policy, and release state enabled. + +The dedicated repository ruleset `edgezero-build-container-main` has target `branch`, enforcement +`active`, no bypass actors, and ref-name conditions including exactly `refs/heads/main` with an empty +exclude list. Its rules are exactly: + +- `pull_request` with `allowed_merge_methods:["squash"]`, + `dismiss_stale_reviews_on_push:true`, `require_code_owner_review:true`, + `require_last_push_approval:true`, `required_approving_review_count:2`, and + `required_review_thread_resolution:true`; and +- `merge_queue` with `check_response_timeout_minutes:60`, `grouping_strategy:"ALLGREEN"`, + `max_entries_to_build:1`, `max_entries_to_merge:1`, `merge_method:"SQUASH"`, + `min_entries_to_merge:1`, and `min_entries_to_merge_wait_minutes:0`. + +The one-entry build and merge limits prevent a passing merge-group result from authorizing a +different batched tree. Missing, extra, or changed semantic rule fields fail the prerequisite audit. + +The two release-tag repository rulesets both have source type `Repository`, source +`stackpop/edgezero`, target `tag`, active enforcement, and ref-name conditions including exactly +`refs/tags/build-container-v*` with an empty exclude list. Ruleset +`edgezero-build-container-tag-immutability` has no bypass actors and exactly `update` with +`update_allows_fetch_and_merge:false` plus `deletion` rules. Ruleset +`edgezero-build-container-tag-creation` has exactly one bypass actor, the numeric team id for +`edgezero-build-container-releasers` with type `Team` and mode `always`, and exactly one `creation` +rule. It has no update or deletion rule. Missing, extra, defaulted, or changed source, target, +condition, actor, mode, parameter, or rule fails the prerequisite audit. + +Two additional repository rulesets apply the same creation/immutability split to action releases. +Both have source type `Repository`, source `stackpop/edgezero`, target `tag`, active enforcement, and +ref-name conditions including exactly `refs/tags/v*` with no excludes. +`edgezero-action-version-tag-immutability` has no bypass actors and exactly `update` with +`update_allows_fetch_and_merge:false` plus `deletion`; `edgezero-action-version-tag-creation` has only +the same reviewed releaser Team actor in `always` mode and exactly `creation`. Repository immutable +releases are enabled. The broader `v*` ruleset protects distinct exact patch versions `C` and `V`, +while the release procedure separately enforces their canonical grammars, absence, release states, +and commit targets. + +The action-release operator uses a short-lived fine-grained personal access token selected only for +repository `stackpop/edgezero`, expiring within 24 hours, with exactly repository `Contents:write` +and `Workflows:write`, implicit metadata read, and organization `Members:read`; every other repository +or organization grant is disabled. A classic PAT, installation token, `GITHUB_TOKEN`, broader +repository selection, extra grant, or token shared with Actions is invalid. The operator preserves +the fine-grained token settings as review evidence, supplies the token to a local non-logging helper +through a private descriptor rather than argv or environment, and destroys it after release. + +That helper permits no redirect and only requests with +`Accept: application/vnd.github+json`, `X-GitHub-Api-Version: 2026-03-10`, +`User-Agent: edgezero-action-release/1`, and its authorization header: +`GET /user`, `GET /organizations//team//memberships/`, +`POST /repos/stackpop/edgezero/releases`, `PATCH /repos/stackpop/edgezero/releases/`, and +`GET /repos/stackpop/edgezero/releases/`. The membership response must be active. POST +creates a draft with exact tag, full target commit, no assets, and required prerelease boolean; PATCH +changes only `draft` to false. GET verifies author, tag, target, draft/prerelease/immutable state, and +release id. Any other method, host, path, query, body field, redirect, credential type, or response +shape fails. Anonymous remote-ref resolution and release-attestation verification are separate +read-only checks. + +Repository ruleset `edgezero-build-container-pin-branches` has source type `Repository`, source +`stackpop/edgezero`, target `branch`, active enforcement, conditions including exactly +`refs/heads/edgezero-build-container-pin/*` with no excludes, and exactly one bypass actor: the +dedicated publisher App's numeric integration id, type `Integration`, mode `always`. Its rules are +exactly `creation`, `update` with `update_allows_fetch_and_merge:false`, and `deletion`. Thus only that +App can create, move, or delete a canonical pin branch. A pin branch is exactly +`edgezero-build-container-pin/` and its PR title is exactly +`chore(actions): pin build container for `; any different head repository, branch, author, +integration, title, or changed-path set fails required pin CI. + Publication order is: -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. +1. Land `G`, then configure and verify the exact active organization ruleset and its sole + required-workflow descriptor + `{repository_id:,path:".github/workflows/build-container-ci.yml",sha:G}` + and an active default-branch ruleset that requires the merge queue with no bypass actor. Configure + the protected release and rotation-lock environments, split tag-creation/immutability rulesets, + dedicated GitHub App, exact release-state `enabled` and publisher App/bot identity repository + variables, and repository permissions. Prove the + rotation workflow holds publication concurrency before the first gate update. The one-time + bootstrap of `G` is explicitly a human-reviewed trust-root operation; + candidate-controlled checks are not represented as independent proof of `G`. +2. Open the isolated `release-request.json` candidate, run it through the organization-required + workflow from `G`, complete the credential smoke, and merge it only through the verified merge + queue. The resulting default-branch commit is `S`. Before tagging, require the repository-local + workflow's latest-attempt run API record to have event `push`, path + `.github/workflows/build-container-ci.yml`, and `head_sha=S`. Require both stable jobs to report + `head_sha=S`, success, and exactly one successful step named `assert-exact-main-push-context`. That + immutable step invokes only the `G` helper and internally asserts `github.ref==refs/heads/main`, + `github.event.after==github.sha==github.workflow_sha==S`, and active gate SHA `G`; the REST API + does not expose those context fields, so the step result is the external evidence. A PR or + `merge_group` result cannot substitute for this exact post-merge run. The first package may not + exist yet; its public-visibility gate occurs after its first push and before a pin PR. +3. The publisher verifies `S`'s manifested bytes equal `G`, constructs a fresh context solely from + the clean `G` checkout and canonical context manifest, builds with the gate-owned Dockerfile, pushes + by protected release tag, and captures digest `D` from BuildKit's metadata output. 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. 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`. -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` 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: + and smoke by `D`. The first release stops here until an operator changes package visibility, reruns + the `G` preflight in package-present mode, attaches its evidence, and reruns the same tag. +6. Open or update an idempotent App-authored PR on the exact protected pin branch, committing + `image.json = {D, S, protocol}` plus its canonical run/approval evidence record. Required pin CI + verifies App/branch/run/approval origin and re-verifies the image before merge; merging the passing + PR creates baseline `B`. +7. Select currently absent canonical patch version `C` for candidate qualification. Implement the + remaining executable plans on top of `B`; keep the four prepublication adoption documents at the + exact bootstrap placeholder. Merge the executable candidate through the queue and record the + resulting protected-main commit as candidate `H`. +8. From a clean detached checkout of exact `H`, rerun the complete pin, actionlint, zizmor, schema, + fixture, container, Rust, documentation, and contract suites. A locally authenticated active member + of the exact releaser team authorized by the creation ruleset creates a draft for `C` with exact + target `H`, no assets, and `prerelease:true`, then publishes it. Require release API fields + `draft:false`, `prerelease:true`, and `immutable:true`, remote peeled ref `C==H`, authenticated + operator identity equal to release author, and recorded team membership plus release attestation. + Run the complete hosted cross-repository/provider suite with every EdgeZero workflow/action ref + equal to literal `C`. Only after both suites pass, designate `H` as `P`, select a distinct currently + absent stable version `V`, and repeat the same actor/draft/publish/evidence procedure for `V` at + `P`, with `prerelease:false`. Verify API and remote-ref resolution and run a final hosted identity/ + consumer smoke with literal `V`. A candidate failure requires a new commit and unused `C`; no `V` + has yet been selected. A post-publication verification failure cannot retarget `V` and is corrected by a new patch + release. Deletion of the GitHub Release object, or mutation of its title, notes, prerelease, or + latest metadata by an actor with sufficient repository privilege, remains an accepted availability/ + discovery risk; the no-bypass tag rules still prevent tag deletion or retargeting, and GitHub's + immutable-release tombstone prevents tag-name reuse. Preserve both generated release attestations + in the release evidence and verify release existence and required state during the final audit. +9. Only after the literal-`V` smoke passes, open documentation-only revision `R`. It adds the exact + action-release record bound to `{V,P}`, replaces every bootstrap placeholder in tracked Markdown + with literal `V`, and changes no executable, workflow, action metadata, gate-owned, or non-document + path. The already active dual-state gate verifies the stable immutable release and remote ref, + applies final-mode documentation checks, and rejects deletion/downgrade of the release record. + Merge `R` through the queue, run the final documentation build/pin scan on protected main, and + record `R`; no action release or action revision changes at this step. + +Gate baseline `G` contains `.github/workflows/build-container-ci.yml`. The active organization ruleset +uses its exact repository id, path, and SHA `G`; it uses neither a branch nor a candidate-controlled +ref. A repository PR cannot substitute its own workflow or helper implementation. The workflow +supports `pull_request`, `merge_group`, protected-default-branch `push`, and a manual +`workflow_dispatch` credential-smoke mode, with no workflow-level path filter. It exposes two stable +required job names on every candidate and grants only workflow-level `contents:read`, `actions:read`, +and `pull-requests:read`; neither job references an environment or mutation credential: - `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 +- `build-container-pin` detects every add, change, or deletion of either pin-record file. When + relevant, both must exist and be the only changed paths; it validates both structures and their + equality, verifies the exact pin branch/PR title/head repository and dedicated App bot id/login, + validates the bound publisher run/attempt and approval evidence described below, anonymously pulls + the exact digest, and runs the complete published-image verifier. Otherwise it explicitly succeeds + as not applicable. + +Each organization-required job parses `github.workflow_ref` and requires its repository and path to be +exactly `stackpop/edgezero/.github/workflows/build-container-ci.yml`; it also requires +`github.workflow_sha` to equal repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA`. The variable +is a full SHA and equals `G` and the ruleset descriptor SHA. On a protected-default-branch push, each +stable job instead requires exactly one successful `assert-exact-main-push-context` step whose trusted +helper proves `github.ref==refs/heads/main` and `github.event.after==github.sha==github.workflow_sha==Q`; +the job still checks out gate code at the variable's active `G`. Each job checks out +`G` and the candidate revision into distinct roots and invokes only the protected gate's classifier and +verification driver. Candidate scripts are never sourced or +executed as gate authority. Each job performs its own fail-closed change classification from the +candidate base and head so a failed shared classifier cannot skip a required job. The local-image set +is not a hand-maintained approximation of a root context: it is the release request plus the complete +canonical image-context manifest, and every manifested image path is also gate-owned. Classification +output is exactly two lines in fixed order: `mode=ordinary`, `mode=gate-update`, or +`mode=gate-rollback`, followed by `relevant=true` or `relevant=false`. Gate-update and gate-rollback +always report relevant true. An unconditional terminal assertion rejects missing, duplicate, or +malformed output and proves exactly one of the relevant ordinary, gate-update, gate-rollback, or not- +applicable branches ran; an invalid classifier can never make all conditional paths disappear behind +a green job. +Contract tests pin pull-request, merge-group, and push ranges, protected workflow/helper provenance, +event triggers, job names, context-manifest closure, 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 +For a pin candidate, the trusted `G` helper reads `image-release-evidence.json` and uses only the +required job's read-only `GITHUB_TOKEN`. It polls the identified same-repository workflow run at most +30 times with a fixed 10-second delay, then requires completion/success, event `push`, workflow path +`.github/workflows/publish-build-container.yml`, `head_sha=S`, `head_branch=`, and exact +run attempt. The run has successful `build-and-verify` and `update-pin` jobs, each with exactly one +successful `assert-exact-publisher-context` step; that trusted `G` step internally proves the protected +tag ref, `github.sha==github.workflow_sha==S`, active gate, release state enabled, run id/attempt, and +tag. The helper also reads the run's non-paginated approvals, requires exactly one current-attempt +release protocol record, and compares every comment field and API reviewer login with the evidence +file. Missing, pending, duplicate, stale, foreign, or contradictory evidence fails before image pull. +The pin check never accepts a candidate-provided check name or PR body as publisher evidence. + +For ordinary image or pin candidates, each job also compares every path in the active gate manifest +between the protected base and its `G` checkout and fails on any mismatch. Gate-update and the exact +recovery-only gate-rollback are the only exceptions. For gate update, old `G` must classify the +candidate's manifested-path change explicitly, verify the base +still equals old `G`, require every changed path to be in the union of old and candidate manifests, +require no changed release request or pin, run the protected static/subject-data gate-update checks, +and record that mode in its terminal marker. Candidate gate scripts are not executed in either gate +mode. Gate rollback obeys the exact failed-`G'`-to-old-`G` restoration contract above. An +ordinary release request must change exactly `release-request.json`; its `gate-sha` is `G`, its +protocol is 1, and every repository image-context input in `S` is byte-identical to `G`. + +The protected workflow also exposes non-required job `build-container-release-preflight` only for a +`workflow_dispatch` request whose body uses `ref:"main"` while protected `main` is exactly `G`. A +workflow dispatch ref is a branch or tag name, not a raw commit SHA. Its required typed inputs are a +same-repository candidate PR number, exact head repository, and full lowercase head SHA. Its +`run-name` is exactly +`build-container-release-preflight pr= repo= sha=<40-lowercase-hex>`, making the +claimed binding API-visible. The job uses environment +`{name: build-container-release, deployment: false}`, checks out only exact `G` into a private gate +root, and runs no candidate repository script. Its fixed step `assert-exact-g-dispatch-context` +invokes only the protected `G` helper, uses the read-only `GITHUB_TOKEN` to fetch the named PR, and +requires the three inputs to equal the current PR number/head repository/head SHA as well as event `workflow_dispatch`, `github.ref==refs/heads/main`, +`github.sha==github.workflow_sha==G`, and repository variable +`EDGEZERO_BUILD_CONTAINER_GATE_SHA==G`. After the environment reviewer approves it, +`actions/create-github-app-token@v3.2.0` 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 +token. 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 +temporarily adds one custom branch deployment policy equal to the literal protected default-branch +name `main`, dispatches the workflow with body `ref:"main"` while `main==G`, 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 API record to have +event `workflow_dispatch`, path `.github/workflows/build-container-ci.yml`, `head_sha=G`, and exact +display title/run-name for the current candidate. Its job +must contain exactly one successful `assert-exact-g-dispatch-context` step and identify the exact +candidate PR input, `stackpop/edgezero` head repository, and current PR head SHA. The step's success is +the API-visible evidence for the internal workflow-SHA assertion. 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. +is literal `main`, never a wildcard or caller-supplied 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. +claim to verify it automatically. After the bounded credential smoke is complete and the final +tag-only policy is restored, 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, disabled administrator-bypass control, and final deployment-policy list. 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 @@ -800,37 +1550,134 @@ basename, and `sha256:<64-lowercase-hex>` file digest under `environment.adminis 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. +Any subsequent 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 dedicated +policy-audit 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 helper is executed only from a clean, +detached checkout whose `HEAD` is exact gate SHA `G`; it verifies that condition before reading a +credential. The policy-audit token is a short-lived fine-grained personal access token owned by the +verified active `stackpop` organization-owner login, selected for only `stackpop/edgezero`, with +repository permissions `Actions:read`, `Checks:read`, `Environments:read`, `Pull requests:read`, +`Variables:read`, implicit `Metadata:read`, and `Administration:write`, plus organization permissions `Members:read` +and `Administration:write`. The two administration grants are unavoidable for the reviewed GitHub API: +repository ruleset bypass actors are hidden without ruleset write access, and organization required- +workflow ruleset inspection requires organization administration access. This is an administrative +credential even though the helper is read-only; the contract does not claim that GitHub exposes a +machine-verifiable complete grant set for a supplied fine-grained PAT. + +Before use, a second operator records the token's selected repository, exact displayed grants, +expiration, and a screenshot digest in the prerequisite evidence. The helper authenticates the +expected verifier login and uses the policy token only through one wrapper whose literal allowlist is +`GET` on these routes, with only documented pagination and filter query keys: + +```text +/user +/orgs/stackpop/memberships/{verifier-login} +/orgs/stackpop/teams/edgezero-build-container-releasers/memberships/{verifier-login} +/orgs/stackpop/actions/permissions +/orgs/stackpop/rulesets +/orgs/stackpop/rulesets/{ruleset-id} +/repos/stackpop/edgezero +/repos/stackpop/edgezero/actions/permissions +/repos/stackpop/edgezero/immutable-releases +/users/{publisher-bot-login} +/repos/stackpop/edgezero/pulls/{candidate-pr} +/repos/stackpop/edgezero/rulesets +/repos/stackpop/edgezero/rulesets/{ruleset-id} +/repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_GATE_SHA +/repos/stackpop/edgezero/actions/variables/{approved-repository-variable-name} +/repos/stackpop/edgezero/environments/build-container-release +/repos/stackpop/edgezero/environments/build-container-release/deployment-branch-policies +/repos/stackpop/edgezero/environments/build-container-release/deployment_protection_rules +/repos/stackpop/edgezero/environments/build-container-release/variables/{approved-variable-name} +/repos/stackpop/edgezero/environments/build-container-release/secrets/EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY +/repos/stackpop/edgezero/commits/{candidate-sha}/check-runs +/repos/stackpop/edgezero/actions/runs/{run-id} +/repos/stackpop/edgezero/actions/runs/{run-id}/jobs +/repos/stackpop/edgezero/actions/runs/{run-id}/approvals +``` -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 +Every GitHub REST request made by a gate helper, including the approval gate and bounded App-token +test, sets exact non-authorization headers `Accept: application/vnd.github+json`, +`X-GitHub-Api-Version: 2026-03-10`, and `User-Agent: edgezero-build-container-gate/1`. A missing or +different value fails before network access. Authorization is added only by the credential-specific +wrapper. Responses must report the selected API version, use the expected JSON media type, and obey +the endpoint's exact success status; a redirect or silent fallback is failure. Specifically, every +response must contain `X-GitHub-Api-Version-Selected: 2026-03-10`. A response with a body must parse +its `Content-Type` to media type exactly `application/json`, with no charset or charset `utf-8`, and +must contain exactly one complete JSON value. GET succeeds only with 200, bounded test-token creation +only with 201, and token revocation only with 204 and an empty body; the 204 response has no JSON +media-type requirement. + +`{approved-variable-name}` is exactly one of `EDGEZERO_BUILD_CONTAINER_APP_ID`, +`EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID`, or +`EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID`. +`{approved-repository-variable-name}` is exactly one of +`EDGEZERO_BUILD_CONTAINER_RELEASE_STATE`, `EDGEZERO_BUILD_CONTAINER_PUBLISHER_APP_ID`, +`EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID`, or `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN`. +Release state obeys the exact state grammar above, App/Bot IDs are canonical positive decimals, and +the bot login equals the independently verified dedicated App bot account. The user lookup must +return that exact login, numeric id equal to `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID`, and +`type:"Bot"`. All other numeric +placeholders are canonical positive decimals; SHA and login placeholders must equal values already +validated from the candidate or authenticated API response. Paginated list calls require +`per_page=100` and a canonical positive `page`; check-run +calls also +fix the documented app/latest filters used by the verifier. No redirect is followed. A request with +any other credential, method, path, placeholder value, query key/value, or fixed header fails before +network access; fake-API and static tests cover the complete allowlist. 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 +only as `EDGEZERO_RELEASE_POLICY_AUDIT_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: +before making an API request and never logs either value. The policy- and package-token wrappers never +issue a mutation request. The helper's only non-GET requests are the bounded test-token creation and +revocation calls described below; it never mutates persistent repository settings, packages, pull +requests, rulesets, 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 + no GitHub App custom deployment-protection rule and exactly one deployment policy, type `tag`, with + name `build-container-v*`. The protection-rules endpoint must return HTTP 200, `total_count:0`, and + an empty `custom_deployment_protection_rules` array; 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; +- organization and repository Actions permission responses both have + `sha_pinning_required:false`; if an enterprise override still rejects exact version tags, the + hosted exact-version prerequisite fails and release is blocked. Consumer repositories must likewise + permit version-tag action refs; +- an active organization ruleset with no bypass actor has exactly one required-workflow descriptor: + the EdgeZero repository id, `.github/workflows/build-container-ci.yml`, full SHA `G`, and no ref; + `do_not_enforce_on_create` is false. Repository ruleset `edgezero-build-container-main` has the exact + target, enforcement, ref conditions, no-bypass state, pull-request parameters, and seven merge-queue + parameters defined above; no default or omitted field may weaken them. `.github/CODEOWNERS` assigns + the canonical gate-owned path manifest and every listed path to the exact gate-reviewer team. The + protected workflow's source repository, path, workflow SHA, and candidate SHA are recorded. The + exact App-only pin-branch ruleset is active and its Integration actor id equals the dedicated + publisher App id. The immutable-releases endpoint returns HTTP 200; parsed field `enabled` is + exactly boolean `true`, and `enforced_by_owner` is present as a boolean and recorded. Additional + response fields do not change this decision. Four active repository + tag rulesets have the exact source/target/ref/rule objects defined above: the image and action + creation/immutability pairs. Each immutability ruleset prohibits update and deletion and has no + bypass actor. Each creation ruleset prohibits creation and has exactly one bypass actor: team + `edgezero-build-container-releasers`, with the numeric ID stored in + `EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID` and bypass mode `always`; it contains no update or deletion + rule. The verifier actor is an active member of that team. The candidate's successful + required-workflow run is a `merge_group` + run for its final queue merge candidate, uses that exact protected workflow source, and contains + successful `build-container-local` and `build-container-pin` jobs from the GitHub Actions App; - 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; + values are no later than the successful credential-smoke completion time. Repository variable + `EDGEZERO_BUILD_CONTAINER_GATE_SHA` equals `G`, the ruleset descriptor SHA, and the successful + credential-smoke workflow SHA. Repository release state equals `enabled`; publisher App id equals + the environment App id and pin-branch bypass actor; and publisher bot id/login identify the verified + App bot account used for pin PRs; - 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 @@ -840,111 +1687,294 @@ canonical evidence for a separately authenticated operator to attach to the cand 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 + same protected workflow and GitHub Actions App integration, and belongs to a `workflow_dispatch` run + whose run record has exact path, `head_sha=G`, and exact candidate-bound display title; whose + separately supplied input PR, head repository, and head SHA are independently resolved through the + PR API by the trusted step and equal the current candidate values; and whose sole + `assert-exact-g-dispatch-context` step succeeded. It records the expected installation ID without + exposing a token. After queue merge, the repository-local workflow's latest-attempt `push` run for + exact `S` has the required event/path/head fields, both stable jobs succeeded for `head_sha=S`, and + each has exactly one successful `assert-exact-main-push-context` step before the release tag is created; + 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. +The package-audit token has its own GET-only wrapper limited to `/user`, +`/orgs/stackpop/memberships/{package-login}`, the fully paginated +`/orgs/stackpop/packages?package_type=container&per_page=100&page={page}` listing, and +`/orgs/stackpop/packages/container/edgezero-build-app-cli`. The App JWT wrapper permits only +`GET /app`, `GET /app/installations/{expected-installation-id}`, and +`POST /app/installations/{expected-installation-id}/access_tokens` with the exact repository id and +requested permission body. The resulting installation-token wrapper permits only +`GET /installation/repositories?per_page=100&page={page}`, `GET /repos/stackpop/edgezero`, and +`DELETE /installation/token`. The POST and DELETE create and revoke only the bounded test token; no credential +may call a persistent repository, organization, package, pull-request, comment, or ruleset mutation +endpoint. + 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 +permission, credential failure, or evidence-post failure blocks release designation or publication. +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 +`stackpop/edgezero` through a fresh package-present preflight from `G`. 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 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 +workflow also verifies `S` is an ancestor of the protected default branch. All publication, +pin-record mutation, and gate rotation uses the exact repository-global concurrency group +`edgezero-build-container-publication` with `cancel-in-progress: false` and `queue: max`; therefore at +most one run executes the mutation path and up to 100 wait. A run rejected because that queue is full +publishes no pin and must be rerun after capacity is available. Pin branches remain exactly source- +derived as `edgezero-build-container-pin/` and updates are idempotent under explicit force-with- +lease. + +GitHub accepts `queue: max` and the four `job.workflow_*` reusable-workflow identity properties, but +pinned actionlint 1.7.12 predates both additions. The gate does not pretend the raw linter accepts +them. A gate-owned compatibility wrapper first uses pinned mikefarah yq 4.53.3 to require `queue` only +at workflow-level in exactly the publisher and rotation-lock workflows, with scalar `max`, exact +shared group, and literal `cancel-in-progress:false`. It also permits only +`job.workflow_repository`, `job.workflow_file_path`, `job.workflow_ref`, and `job.workflow_sha`, only +in the exact checked expressions/checkout ref locations of `.github/workflows/build-app-cli.yml`; a +misspelling, extra property, other workflow/location, alias, duplicate, or dynamic expression fails. + +After structural validation, the wrapper creates line-count-preserving temporary copies: it replaces +only those exact approved `job.workflow_*` scalar expressions with same-type constants and replaces +only the two approved `queue` lines with blank lines. It runs unfiltered actionlint 1.7.12 over those +copies and remaps any diagnostic to the real path/line; no `-ignore` rule or diagnostic filter is used. +Self-tests require raw 1.7.12 to emit exactly the reviewed unsupported-diagnostic set for canonical +queue and job-context fixtures, require the sanitized copies to pass, and require every malformed or +additional use to fail before sanitization. Remove each compatibility rewrite independently once a +reviewed actionlint release natively supports that syntax. + +After acquiring that group, every publisher requires +`EDGEZERO_BUILD_CONTAINER_RELEASE_STATE==enabled`, verifies the active gate variable and organization +descriptor agree through the already reviewed prerequisite evidence, and proves no rotation lock is +active before image build or registry authentication. 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 gate-context build, pushes and anonymously verifies `D`, and exports only non-secret +`{S,D,protocol,tag,approval-challenge}` job outputs. After anonymous verification it obtains 32 bytes +from the runner OS CSPRNG and renders `approval-challenge` as 64 lowercase hexadecimal characters. It +writes the exact challenge, `S`, `D`, tag, run id, and run attempt to the job summary so the approver +can inspect them; the challenge is public but unpredictable before this attempt reaches that point. It +builds only the freshly staged `G` context, which contains the reviewed `.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. +and PR mutation. Thus the environment's private key is unavailable to the gate-context build job. +Each publisher job has exactly one fixed `assert-exact-publisher-context` step, executed from the +active `G` checkout before sensitive work. It verifies tag event/ref, `github.sha==github.workflow_sha` +and equals release source `S`, exact run id/attempt/tag, active gate, and enabled release state; a +missing, duplicate, failed, candidate-resolved, or differently named assertion is fatal. +The publisher workflow itself is a gate-owned file unchanged from `G`; it checks out trusted helper +code at repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA` into a separate root. Before image +build, the helper validates `S`'s isolated release request, verifies every gate/context path at `S` is +byte-identical to `G`, and copies only canonical manifested paths from the clean `G` root into a fresh +context outside both checkouts. The Dockerfile comes from `G`; candidate Dockerfiles, tools, +validators, context files, and post-install replacement steps are unreachable. The workflow executes +the approval gate and pin updater only from the same `G` root. The protected gate's structural +publisher checker rejects any candidate that changes this topology, permissions, ordering, action +pin, checkout source, context-construction source, or helper invocation. 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 +does not create a new workflow run, so it cannot guarantee the automatic protected pin-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 fixture-tested fail-closed state machine in the implementation plan. It writes both pin records +through gate-owned typed encoders, uses the exact protected branch/title, and records its own run and +current approval values; it cannot accept a caller-supplied evidence file. + +Let `I` be `image-source-revision` in the protected default branch's current `image.json`; absence is +the first-pin state. Normal publication may propose `S` only when `I` is absent, `I==S`, or `I` is an +ancestor of `S`. An older or incomparable `S` fails before mutation. The publisher fully paginates all +open pin PRs whose author id/login equals the authenticated dedicated App and whose head repository is +exactly `stackpop/edgezero`, and fails if a matching pin branch or title is owned by another actor or +repository. It compares every proposed source with `S`: it closes older-source PRs when +superseding them; updates one exact `{S,D}` PR idempotently; closes and replaces one same-`S`, different-`D` +PR; treats a run older than an existing proposal as superseded success without mutation; and fails on +incomparable, malformed, multiple-same-source, or otherwise ambiguous state. Required pin +CI recomputes the `I`-to-`S` ancestry relation against the PR's current merge-queue base, so a stale +older PR cannot merge after a newer pin. Operational rollback never regresses default-branch +`image.json`; consumers select an earlier reviewed exact action version containing its corresponding +pin. 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. +run attempt 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 that +visibly includes the repository, environment, disabled administrator-bypass control, and sole final +tag policy. The +approver computes its digest and enters exactly one line as the environment review comment before +approving the job: + +```text +edgezero-release-evidence-v1 {"challenge":"<64-lowercase-hex>","image-digest":"","png-sha256":"sha256:<64-lowercase-hex>","release-tag":"","reviewed-at":"","run-attempt":"","run-id":"","source-revision":""} +``` + +The JSON is compact, uses the shown key order and string types with no extra key or whitespace, and every +placeholder obeys its already defined syntax. `RFC3339-UTC` here is exactly a valid calendar instant +in `YYYY-MM-DDTHH:MM:SSZ` form, with no fractional seconds or offset spelling. The exact `reviewed-at` instant is neither future nor +more than 15 minutes before the machine check. `update-pin` initially has only `actions:read` and +`contents:read`. After checking out exact gate SHA `G` without persisted credentials and before +App-token minting, its trusted gate helper uses the current `GITHUB_TOKEN` only for exact no-redirect +`GET /repos/stackpop/edgezero/actions/runs/{github.run_id}` and +`GET /repos/stackpop/edgezero/actions/runs/{github.run_id}/approvals`, with the fixed REST headers +defined above. The approval endpoint is +non-paginated; the helper requires one complete, valid HTTP 200 JSON array. It requires the API run id and +`run_attempt` to equal `github.run_id` and `github.run_attempt`; exactly one approved review for +`build-container-release` must have the exact current challenge, `D`, and remaining fields above, and +the API reviewer's login becomes the recorded approver. Every protocol-prefixed review claiming the +current run id and attempt is parsed: there must be exactly one, it must be approved and exact, and no +second current-attempt protocol record may exist. A rejected or mismatched current-attempt record, +missing or malformed history, environment bypass without the approval, stale or future time, or a +different challenge fails before any mutation credential exists. Records for earlier attempts remain +historical data but can never satisfy the current attempt; a prior reviewer cannot predeclare a useful +future-attempt approval because that attempt's CSPRNG challenge does not yet exist. + +Only after that gate passes may `actions/create-github-app-token@v3.2.0` mint the App token. The release operator then +attaches a canonical record containing the API reviewer login and exact comment plus the byte-identical +PNG to the release evidence. The comment cryptographically binds the reviewer attestation to the PNG +bytes and run attempt; the screenshot's visible meaning remains a required human review, not a claim +of machine image interpretation. The initial private-package stop and every canceled or rerun attempt +require a new capture and approval. This check is required because the API-invisible setting cannot be +proven current by the pre-`S` helper. + +The repository's zizmor policy uses `ref-pin` for every non-local action. The structural pin scanner +is the stronger authority: it accepts only canonical exact stable `v..` refs, +strict Docker `sha256` digests, and the exact scanned workflow/action/documentation surfaces. It +rejects commit SHAs as well as floating major/minor tags and branches. + +Gate `G` includes the permanent dual-state documentation scanner from the start. It parses fenced +YAML in every tracked Markdown file. Its trusted workflow selects the comparison range from this +closed event table; every SHA is a full lowercase 40-hex object present in the full subject checkout, +and any other event or malformed/inconsistent field fails closed: + +- **`pull_request`:** base is `github.event.pull_request.base.sha` and candidate is `github.sha`. + Require `github.event.pull_request.base.repo.full_name==stackpop/edgezero`, + `github.event.pull_request.base.ref==main`, + `github.ref==refs/pull//merge`, candidate first parent equal to the base SHA, and candidate + second parent equal to `github.event.pull_request.head.sha`; +- **`merge_group` `checks_requested`:** base is `github.event.merge_group.base_sha` and candidate is + `github.event.merge_group.head_sha`. Require base ref `refs/heads/main`, head ref equal to + `github.ref`, head ref beneath exact prefix `refs/heads/gh-readonly-queue/main/`, candidate equal to + `github.sha`, and base an ancestor of candidate; +- **protected-main `push`:** base is `github.event.before` and candidate is `github.event.after`. + Require `github.ref==refs/heads/main`, nonzero base, candidate equal to both `github.sha` and + `github.workflow_sha`, and base an ancestor of candidate. + +`workflow_dispatch` is a separate credential-smoke mode and invokes neither the change classifier nor +the documentation scanner. The “other event” rejection above applies whenever either range consumer +is invoked. + +The scanner compares the selected base and candidate states as follows: + +- **bootstrap:** `docs/.edgezero-action-release.json` is absent from both base and candidate. Only the + four named prepublication adoption documents may use literal `` for an + EdgeZero ref; every other external ref still obeys the exact stable-version rule; +- **transition:** the record is absent from base and added by the candidate. The candidate changes + only tracked Markdown plus that record, every EdgeZero ref equals its literal `V`, no placeholder + remains, and a fixed no-redirect versioned API/ref verifier proves `V` is a published + `draft:false`, `prerelease:false`, `immutable:true` release whose peeled tag equals recorded `P`; +- **released:** the record exists on the base and cannot be deleted. It is either byte-identical or a + documentation-only candidate atomically replaces it and all EdgeZero documentation refs with a + strictly greater canonical stable version whose immutable release/ref binding to its new `P` passes + the same hosted proof. Every candidate remains placeholder-free and every EdgeZero ref in each + fenced workflow equals the candidate record's `V`; downgrade, partial update, or non-document + change fails closed. + +The first transition therefore happens only in `R`, after `V` exists. Candidate `H` never puts an +unpublished or retired version into protected-main documentation, no later PR can return to +bootstrap mode, and later action releases repeat the same post-release atomic record/documentation +update. During the queue, “candidate” means the synthetic pull-request or merge-group commit selected +above; `R` names only the resulting protected-main commit after the push range passes. ## 9. Testing Required automated coverage includes: -- cold, warm, corrupt-restore, stop-failure, write-error, audit-failure, and save-warning cache paths; +- cold, warm, uncached, corrupt-restore, stop-failure, write-error, audit-failure, and save-warning + cache paths, plus separate complete lookup-eligibility and protected-event save-authorization truth + tables for both cache families; - 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; + public Git dependencies, concurrent generations, seven-day expiry as documented behavior, exactly + one Cargo compile/build invocation after metadata preflight, no action-level retry, and pinned + sccache response-loss fallback behavior; +- cache audit type/owner/path/layout/logical-byte/non-sparse/path-length/entry-count 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; + tracked-containing output roots, nested-project implicit Fastly `bin` and `pkg` roots, absent-root + precreation, special-file/hardlink rejection, descriptor-relative cleanup, 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; -- exact canonical metadata and expected JSON, schema versions, duplicate keys, byte-exact ustar + staging token presence, credential-free `app-build`, every exact `app-env` name/value/count/size + boundary, empty Cargo-config policy, config-push repo/config confinement, and deploy-without-sccache; +- strict caller identity, full-SHA app refs, exact-version workflow/action refs, resolved workflow + SHA, immutable EdgeZero release enforcement, locally derived platform identity, matrix artifacts, + and consumer recomputation for private repositories; +- exact canonical metadata and expected JSON, typed `write-expected`, 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 + fixtures, exact ELF header sizes/versions and extended-numbering rejection, dynamic-token rejection, + controlled direct-loader invocation, absent system preload, inhibited cache, flat runtime-library + closure, hardware-capability/default-path non-substitution, explicit `dlopen` non-claim, + every consumer independently writing fresh expected identity, 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, 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; + protected gate/workflow identity, gate-owned staged build context and post-install replacement + resistance, release-request isolation, required-workflow descriptor and bypass checks, exact + merge-queue payload and single-entry behavior, API-visible exact-`S` push assertion-step evidence, + image-pin deletion and source-ancestry ordering, environment + reviewer/self-review/deployment-policy checks, per-attempt approval comment and token-ordering checks, + policy-API method/path/header/version allowlisting, App installation/repository/permission/token- + scope checks, actionlint queue-compatibility isolation, publication concurrency and queue-overflow + cancellation, gate-rotation failure recovery, 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. +Cold evidence starts from an empty audited cache root and, after zeroing statistics, requires +`cache_misses.counts["Rust"]>=1`, `cache_writes>=1`, and zero write errors. Warm evidence runs in a +new job with fresh target/Cargo-home directories, restores the recorded cold generation through the +sole family prefix, zeros statistics, and requires `cache_hits.counts["Rust"]>=1`; the rebuilt binary +digest must equal the cold binary digest. Default-off evidence proves no cache action or sccache +process ran. 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: +To publish final action revision `P`, exact version `V`, and its adoption documentation: 1. Migrate every existing non-local external action and reusable workflow reference in the repository - 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`. -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. + to a reviewed exact stable patch-version tag, change the repository-wide pin gate accordingly, and + retain zizmor `ref-pin` as defense in depth. Record the accepted third-party tag-movement risk. +2. Implement and separately land the validator, schema, fixtures, protected classifier/verifier + helpers, publisher contract checker, and required workflow as gate baseline `G`. Configure the + organization required-workflow rule directly to gate commit `G` (this is not a consumer `uses:` + ref) and the mandatory default-branch merge queue. +3. Include the exact Dockerfile and complete image-context closure in `G`. Open an isolated canonical + `release-request.json` candidate, run it through the protected gate, complete the credential smoke, + merge it only through the merge queue as `S`, and require the API-visible exact post-merge `S` push + assertion-step evidence before tagging. Build only from the freshly staged `G` context. +4. Publish and anonymously verify the image, then merge the ancestry-checked pin PR as baseline `B`. +5. Select unused canonical patch version `C`. Land the reusable workflow, cache, provenance, + launcher, and consumer integration while leaving prepublication adoption examples at the gated + placeholder. Record resulting main commit `H`. +6. Rerun the complete local suite from detached `H`; have a verified active releaser-team member use + a local credential to draft and publish immutable `C` at `H` with `prerelease:true`; and run the + complete hosted cross-repository/provider suite through literal `C`. On success designate `H=P`, + select unused stable `V`, use the same auditable actor procedure to draft and publish it at `P`, + verify both release/ref resolutions and attestations, and run the final literal-`V` hosted smoke. + A candidate failure follows the new-commit/new-`C` rules in Section 8. +7. After the literal-`V` smoke passes, merge documentation-only `R` through the protected queue. Add + the `{V,P}` action-release record and replace every gated placeholder with literal `V`; the + preinstalled dual-state gate proves the release/ref binding and permanently enters released mode. + Run the docs build and exact-version scan on protected main and record `R`. Caching remains off by default. Container execution and provenance validation are unconditional. @@ -976,12 +2006,58 @@ Caching remains off by default. Container execution and provenance validation ar 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. +- **v6.20:** made cache writes an action-derived protected-event decision; added the typed canonical + expected-identity producer; froze suffix and ELF edge cases; replaced candidate-controlled image + checks with an immutable-SHA organization required workflow and mandatory merge queue; required an + exact post-merge source check; split tag creation authority from no-bypass immutability; specified the policy-audit credential and request allowlist; bound + each protected-secret approval to a per-attempt CSPRNG challenge, image and screenshot digest before token minting; defined + forward-only pin ancestry and bounded publication queue semantics; and assigned current Fastly + `pkg` output to the source-freeze contract. +- **v6.21:** replaced archiver-dependent cache sizing with exact filesystem-tree bounds; froze the + app-environment, Cargo-config, generated-output, nested Fastly `bin`/`pkg`, cleanup, and controlled + loader contracts; moved every repository image-context input into gate `G` and made `S` an isolated release + request built from a staged trusted context; defined gate rotation and recovery, exact one-entry + merge-queue policy, API-visible dispatch/push assertion evidence, REST API headers/version, and the + narrowly scoped actionlint 1.7.12 `queue: max` compatibility check. +- **v6.22:** separated the self-referential action release from its documentation by defining + documentation-only revision `R`, whose concrete examples pin already-known full SHA `P`; froze the + reusable-workflow input, self-checkout, artifact transport, network/resource, and private binary- + state contracts; froze the organization required-workflow ruleset's repository/ref target; and made + the protected release environment's machine-verified absence of GitHub App custom deployment- + protection rules an explicit prerequisite for the non-deployment credential-smoke job. +- **v6.23:** replaced full-SHA action/workflow references with exact stable patch-version tags and an + immutable EdgeZero release process; removed the documentation-only self-reference workaround; + defined authority-checkout export and pinned-LFS rules, uncached compilation, pre-restore + disclosure, parent-cache authorization, closed sccache JSON statistics, consumer expected-file + production, candidate-bound credential-smoke evidence, standalone validator workspace/build + closure, exact release-request production, self-test isolation, and generated-output sparse/size + limits. +- **v6.24:** made exact version tags unambiguous for every non-local `uses:` reference while retaining + commit SHAs only for source/provenance identity; removed the last cache-action SHA-pinning + contradiction; assigned distinct notation to the current image source pin; froze the complete + sccache statistics schema and warm-evidence counters; and specified the exact checksummed host Git + LFS installation, authenticated materialization, and credential-removal sequence. +- **v6.25:** made release-candidate `uses:` refs exact patch versions without prerelease suffixes; + removed circular `P` qualification; matched the immutable-release settings response; distinguished + immutable tag identity from accepted privileged release-object deletion risk; and replaced + unprovable historical release-name absence with current ref/release absence plus creation success; + reserved `H` for the final action candidate and renamed the generic protected head `Q`; made every + normative JCS example a literal single line; closed Rust/native compiler overrides; moved filter/ + submodule validation before worktree creation; fixed Git LFS asset size/transfer bounds; defined + local bootstrap image `L`; assigned config-push authority checks, permanent documentation pin + enforcement, and the auditable action-release actor/procedure. +- **v6.26:** moved literal stable-version documentation into post-release revision `R` under a + one-way dual-state gate so failed candidates cannot strand unpublished refs on main; selected `V` + only after `H` qualifies as `P`; froze the local fine-grained release PAT permissions, API + allowlist, actor proof, and credential handling; and defined the public consumer identity action + plus per-invocation source materialization so no authority path crosses a public action boundary; + and closed the documentation scanner's pull-request, merge-group, and protected-push ranges. ## 13. Deferred implementation mechanics 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. +sccache v0.10 layout/stats fixtures, exact cache tree-bound and entry-count vectors, release +versions/checksums, and command-level tests before exact action version `V` at final action revision +`P` is published. Those are mechanics, not permission to weaken the contracts above.