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..fac43bbc --- /dev/null +++ b/.github/actions/deploy-core/tests/check-image-pin.test.sh @@ -0,0 +1,51 @@ +#!/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/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/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/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 + +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..3aacbb1d --- /dev/null +++ b/.github/docker/build-app-cli/check-image-pin.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# 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, 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 + 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 + +# 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 'repository' and 'tag'" >&2 + 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 + exit 1 +fi + +echo "build container reference is pinned: $repo@$digest" diff --git a/docs/specs/edgezero-deploy-build-caching.md b/docs/specs/edgezero-deploy-build-caching.md deleted file mode 100644 index 6dee7a89..00000000 --- a/docs/specs/edgezero-deploy-build-caching.md +++ /dev/null @@ -1,244 +0,0 @@ -# EdgeZero Deploy Actions — Build Caching Spec - -**Status:** Design (proposed) — v6.14 (sccache pivot) - -**Related:** `docs/specs/edgezero-deploy-github-action.md`, -`docs/specs/edgezero-deploy-action-implementation-plan.md`, -`docs/specs/edgezero-deploy-adoption-guide.md`, `docs/guide/deploy-github-actions.md` - -## 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** (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. -- **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 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. -- **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 - 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. - -### 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`). - -### 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` = `@`. - -### 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. - -### 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. -- **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. - - **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:** 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**. -- **`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 - 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`. -- **`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`; 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** - (not dependency source), so the exposure it acknowledges is compiled artifacts; `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` -(**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 `$/`). - -**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. - -## 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 -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. - -## 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). - -## 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. 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..fddee4e2 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -1,393 +1,769 @@ -# 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/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 - -- **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. -- **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. -- **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.19. + +**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 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. +- 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. 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`. + +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/{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/**` +- `.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/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`: + +- `.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/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: Enforce full-SHA 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. **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 `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. - -- [ ] **Step 1: Write the failing test** +- 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 -#!/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; } - -printf '{"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":"v1","digest":"sha256:%064d"}\n' 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" -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" -run "$WORK/nodigest.json" && no "a missing digest is rejected" || ok "a missing digest 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 ] +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 ``` -- [ ] **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** - -```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 - -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 -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 string 'repository' and 'tag'" >&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" -``` +**Gate:** both structural scanners pass their exact surfaces and report non-zero parsed-reference +counts; no broad `rg` gate scans intentional invalid test strings. -- [ ] **Step 4: Run the test to verify it passes** +## 5. Task 1: Implement the protocol-owner validator on the source candidate -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`. +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`. -- [ ] **Step 5: Shellcheck** +**Files:** -Run: `shellcheck -S warning .github/docker/build-app-cli/check-image-pin.sh` -Expected: no output (clean). +- Create `crates/edgezero-provenance-validator/Cargo.toml` 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`. +- 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`. + +### 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` 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. + +### 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 + 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 \ + --output /work/validated/app-cli + +edgezero-provenance-validator self-test \ + --fixtures /usr/local/share/edgezero/provenance-fixtures +``` -- [ ] **Step 6: Commit** +- [ ] 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 -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" +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 +./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:** 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 -### Task 2: The pinned Dockerfile +`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: - - uses: actions/checkout@v7 - # 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" - # 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; } - # Anonymous pull (the package must be public) + the runtime smoke contract. - docker logout ghcr.io || true - 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, anonymous pull, runtime smoke)." -``` - -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. -- [ ] **Step 2: Actionlint the workflow** +- 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/`. -Run: `actionlint .github/workflows/publish-build-container.yml` -Expected: no output. - -- [ ] **Step 3: Commit** +- [ ] 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 -git add .github/workflows/publish-build-container.yml -git commit -m "build-cache container: GHCR publish workflow recording the manifest digest" +cargo build --locked --release -p edgezero-provenance-validator ``` -- [ ] **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. +- [ ] 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: -**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 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 . ``` -Expected: the pull succeeds without credentials. - ---- -### Task 4: Wire the digest pin into the pin gate - -**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: +- [ ] 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 -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 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 ``` -- [ ] **Step 2: Run it to verify it fails** +**Gate:** no image is pushed until every command above passes with the exact source SHA and protocol. -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. +## 8. Task 4: Publish, verify, and open an idempotent pin PR -- [ ] **Step 3: Invoke the suite from the contract runner** +**Files:** -Add to `.github/actions/deploy-core/tests/run.sh` (near the other suite invocations): +- 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 -bash "$(dirname -- "${BASH_SOURCE[0]}")/check-image-pin.test.sh" +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 4: Run the full suite** +- [ ] 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:** -Run: `bash .github/actions/deploy-core/tests/run.sh` -Expected: the image-pin cases run and (after Task 3) pass. +- 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`. -- [ ] **Step 5: Commit** +- [ ] 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: ```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" +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 ``` ---- - -## Self-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. - -## 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`. +- [ ] 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 + +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`. 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 new file mode 100644 index 00000000..38bd60ef --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md @@ -0,0 +1,987 @@ +# EdgeZero Deploy Actions - Build Caching Spec + +**Status:** Design (proposed) - v6.19 + +**Related:** `docs/specs/edgezero-deploy-github-action.md`, +`docs/specs/edgezero-deploy-action-implementation-plan.md`, +`docs/specs/edgezero-deploy-adoption-guide.md`, `docs/guide/deploy-github-actions.md` + +## 1. Problem + +`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 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; +- `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/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: + +- `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-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. +- `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 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 + +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 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. + +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 Protocol-1 JSON contract + +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. + +`expected.json` contains exactly the identity the protected caller and local action computed: + +```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 +``` + +`--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: + +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 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 + +### 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 + +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 +{ + "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 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`, + `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`. + +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. 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. +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: + +- `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 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 + +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; +- 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, 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. + +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, 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. + +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. +- 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 + +- **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. +- **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 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.