Skip to content

Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303) - #316

Open
aram356 wants to merge 132 commits into
mainfrom
feature/edgezero-deploy-actions
Open

Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303)#316
aram356 wants to merge 132 commits into
mainfrom
feature/edgezero-deploy-actions

Conversation

@aram356

@aram356 aram356 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Layered, adapter-independent GitHub Actions for deploying an EdgeZero app to Fastly Compute — design + implementation, complete — superseding the Fastly-only monolith in #303. The EdgeZero CLI is the boundary: the actions compile the app's own CLI, scope credentials, and invoke it; they never reproduce provider build/deploy logic in YAML, so adding another provider later is a new thin wrapper, not an engine rewrite. Based off main.

Actions

  • build-app-cli — compiles the CLI package the application provides (a crate in the app's own workspace) from the app checkout, with an isolated CARGO_TARGET_DIR + --locked; publishes a self-describing tar (app-cli-meta.json) so downstream steps need no re-pass. Credential-free by design.
  • deploy-core — adapter-independent shared engine scripts sourced by the wrappers (not a standalone action). Provider credentials/flags flow only through provider-env (deploy-step-scoped), provider-env-clear, deploy-flags, and deploy-args.
  • deploy-fastly — minimal wrapper; installs the pinned, checksum-verified Fastly CLI; stage: true produces a staged draft; outputs fastly-version, previous-version (the rollback target captured pre-deploy), mutation-attempted, and the installed provider-CLI version.
  • healthcheck-fastly / rollback-fastly — Fastly staging lifecycle (parity with stackpop/trusted-server-actions), driven by the app CLI over the Fastly API.
  • config-push-fastly — pushes the app's typed config to a Fastly config store (the production key, or the isolated _staging twin).

CLI

edgezero-adapter-fastly and the scaffolded downstream template gain the lifecycle command surface the actions drive: build, deploy (--staging, --service-id), active-version, healthcheck, rollback, and typed config push / validate / diff. --staging is one consistent verb across the lifecycle; the legacy --stage is rejected (never aliased) and cannot slip through -- passthrough into a production deploy.

Security & isolation

  • Provider credentials reach only the deploy/lifecycle steps that need them; every other step blanks the shipped provider aliases and BASH_ENV/ENV, and the credential-free CLI build re-execs with the GitHub file-command channels stripped.
  • target/ caching is credential-free: the cache is seeded and saved from the build-mode: always build before the token-bearing deploy, never after — so a build script cannot persist a secret into the cache. With build-mode: never it is a documented no-op.
  • A repository-wide pin gate (check-action-pins.sh) parses every workflow and action structurally with a pinned, checksum-verified yq (installed under RUNNER_TEMP), rejecting any uses: on a mutable branch/floating ref while accepting released version tags or full SHAs. actionlint (all workflows, with its ShellCheck integration) and zizmor back it up.
  • A mutation-attempted reconcile signal on the mutating actions, fail-closed input validation, a dirty-source guard, and committed-source-only deploys.

End-to-end smoke coverage

The Deploy actions workflow drives the real wrappers against a fake fastly / curl served through the installer's genuine download + checksum + extract path:

  • static-checks — actionlint, the structural pin gate, zizmor, ShellCheck, the Bash contract suite, and the docs build.
  • composite-smoke — production deploy, the credential boundary, and rollback threading.
  • handoff-build / handoff-deploy — cross-job artifact handoff by literal name.
  • cache-smoke — cache populate + restore-hit, plus a negative (build-mode: never) no-op case.
  • recovery-smoke — an induced lost-version deploy failure, recovered via active-version + rollback-fastly threading previous-version.
  • config-push-smoke — typed staging and production config push.
  • lifecycle-smoke — staged deploy + healthcheck.

Docs

  • docs/specs/edgezero-deploy-github-action.md — normative spec.
  • docs/specs/edgezero-deploy-action-implementation-plan.md — plan (+ Add Fastly deploy action with config push #303 port map).
  • docs/specs/edgezero-deploy-adoption-guide.md — adoption guide (any app repo).
  • docs/guide/deploy-github-actions.md — practical how-to; docs/guide/cli-reference.md — CLI surface.

Notes

Supersedes #303 (and the earlier stacked docs PR #315); #303's unrelated changes (KV timing logs, dep bumps) are not carried here. All CI is green: Run Tests, Run Format, CodeQL, Fastly installer check, and the Deploy-actions smoke suite.

Design docs (spec + implementation plan + adoption guide) for GitHub Actions
that deploy EdgeZero apps, superseding the Fastly-only monolith from #303.

Architecture:
- build-cli compiles the CLI package the *application* provides (a crate in the
  app's own workspace), from the app checkout, isolated CARGO_TARGET_DIR +
  --locked, self-describing tar (cli-meta.json).
- deploy-core: adapter-independent shared engine scripts sourced by wrappers;
  provider creds/flags only via provider-env (deploy-step-scoped),
  provider-env-clear, deploy-flags, deploy-args.
- deploy-fastly: minimal wrapper; optional stage: true.
- Fastly staging lifecycle (parity with trusted-server-actions): deploy-fastly
  stage mode + healthcheck-fastly + rollback-fastly, scaffolded into the CLI's
  Fastly adapter and exposed via the app CLI; fastly-version output.

Cross-cutting: Git root vs Cargo workspace root for monorepo caching; no Python
(actionlint/zizmor pinned binaries); third-party actions on readable tags;
explicit Fastly build-in-deploy credential caveat. Plan includes a porting map
from the #303 reference scripts.

Based off main; supersedes #303.
aram356 added 3 commits July 9, 2026 09:59
provider-env is no longer listed among the engine's globally-passed parameters.
It is bound only to the deploy step's own env: and parsed only there; setup/build
steps receive only non-secret parameters plus provider-env-clear. Mirrors spec
§5.2/§10 so the plan no longer reintroduces the secret-blob leak.
…te target

- healthcheck-fastly / rollback-fastly now pass --service-id <id> (and step-
  scoped FASTLY_API_TOKEN) in their app-CLI invocations; without it the CLI
  can't resolve staging IPs or activate/deactivate versions.
- Make provider CLI install an explicit wrapper responsibility: deploy-fastly
  installs the pinned Fastly CLI onto PATH; the engine assumes it is present and
  never learns provider tools. healthcheck/rollback need no Fastly CLI (Fastly
  API only).
- target is wrapper-provided concrete (Fastly -> wasm32-wasip1); the engine no
  longer maps adapter -> target, keeping it provider-neutral.
- Qualify the follow-up list: additional staging/health/rollback lifecycles are
  'beyond Fastly' (Fastly's is in scope).
… guide creds

Gaps found in self-review + review:
- §13 error handling: add rows for staged-deploy failure, missing fastly-version,
  unhealthy-after-retries, rollback failure.
- Pin healthcheck-fastly exit semantics: exits non-zero on unhealthy so callers
  can gate rollback on if: failure() (the composing example relied on this
  implicitly).
- §5.4.3: deploy-fastly stage command now shows --service-id (matches §5.4.1).
- §15 testing + §17 acceptance: cover the staging lifecycle (were absent).
- §15.3 / plan smoke test: fake the app CLI + Fastly API/curl for
  healthcheck/rollback (they call the API, not the fastly CLI), not fake fastly
  binaries.
- Adoption guide §6.3: healthcheck/rollback steps now pass fastly-api-token +
  fastly-service-id (required by the CLI --service-id path).
@aram356 aram356 changed the title Design: layered EdgeZero deploy actions + Fastly staging lifecycle (supersedes #303) Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303) Jul 9, 2026
aram356 added 22 commits July 9, 2026 12:33
- build-cli: action.yml + build-cli.sh (resolve app cli-package via cargo
  metadata --locked, isolated CARGO_TARGET_DIR build, cli-meta.json, tar upload).
- deploy-core shared scripts: common, validate-inputs (provider-neutral allowlist
  + JSON→NUL parsing), install-rust (wrapper-provided target), download-cli
  (extract tar, read cli-meta.json, PATH-scope), resolve-project (Git root vs
  Cargo workspace root, cache key), cleanup, write-summary.

Wrappers (deploy-fastly, healthcheck/rollback), run-cli, CI, and tests follow.
All scripts shellcheck-clean; validate-inputs functionally tested.
Port install-fastly.sh (official release + SHA-256 checksum, action-owned PATH
dir) and versions.json (Fastly 15.1.0) into the deploy-fastly wrapper. The
wrapper action.yml and the shared run-cli.sh follow once the CLI staging
contract is finalized.
…back wrappers

- deploy-core/run-cli.sh: provider-neutral CLI runner; typed deploy-flags before
  --, caller passthrough after --; build-mode clears wrapper-named aliases.
- deploy-fastly/action.yml: full orchestration (validate -> download+extract CLI
  -> resolve -> cache -> install rust + Fastly CLI -> optional build -> deploy),
  credential scoping via step-level env:, stage input -> --stage, captures
  fastly-version from the CLI's version=<N> line.
- healthcheck-fastly / rollback-fastly: thin wrappers over <cli> healthcheck /
  rollback (Fastly API); healthcheck exits non-zero on unhealthy while still
  emitting healthy/status-code outputs.

All action.yml parse; deploy-core scripts shellcheck-clean.
Apply Bash best-practices structure: wrap logic in main() with explicit local
parameters and single-responsibility helpers; route the progress line to stderr;
portable NUL-array collection (no bash 4.3 namerefs); a small named assertion
harness (assert_succeeds/assert_fails/assert_equals) in the test runner. Kept
coreutils short flags for macOS/BSD portability. All shellcheck-clean; 10/10
contract tests pass.
…st-toolchain

- Apply the main()/helper structure and Bash best-practices across all engine
  scripts (validate-inputs, resolve-project, download-cli, install-fastly,
  cleanup, write-summary); route diagnostics to stderr; local scoping throughout.
- Replace the custom deploy-core install-rust.sh with the maintained
  actions-rust-lang/setup-rust-toolchain@v1 (readable tag) in deploy-fastly,
  feeding the resolved toolchain + wasm32-wasip1 target; cache: false so our
  exact-key target/ cache stays authoritative. build-cli keeps rustup for
  dynamic (app-resolved) toolchain install.
- Add .github/workflows/deploy-action.yml: no Python — actionlint from a pinned
  release binary, zizmor via cargo install (no pip), shellcheck, Bash contract
  tests, check-action-pins.sh (flags floating @main/@master refs), docs
  validation, and a build-cli -> deploy-fastly composite smoke test.
- Add check-action-pins.sh; all third-party actions pinned to readable tags.
…thcheck, rollback)

Add the CLI capability the deploy actions drive (spec §5.4):
- args.rs: --service-id / --stage on DeployArgs; new HealthcheckArgs, RollbackArgs;
  Healthcheck/Rollback Command variants (+ arg-parse tests).
- edgezero-adapter-fastly/cli.rs: deploy_staged (compute update --autoclone +
  service-version stage), emit_active_version, healthcheck (staging-IP resolution
  via Fastly API + curl), rollback (activate previous / deactivate staged); token
  piped via curl --config stdin so it never hits argv (+ 30 unit tests).
- adapter registry + edgezero-cli adapter/lib/main dispatch wiring; other adapters
  return a clear 'unsupported' error, keeping WASM builds unaffected.
- downstream CLI template: Healthcheck/Rollback arms + #[command(version)].
- Version output contract: a parseable 'version=<N>' line on stdout for deploy
  and staged deploy; 'rolled-back-to=<N>' / 'healthy=' / 'status-code=' for the
  lifecycle commands.

All gated behind fastly/cli features. (Implemented by subagent; tests/clippy/fmt
verified.)
… smoke fixture

- cli.rs tests: suffix numeric literals (default_numeric_fallback) and rename
  single-char closure params (min_ident_chars); bind+assert the ignored result
  (let_underscore_must_use). These fire under --all-targets, which the earlier
  clippy run omitted. Fastly tests: 100 pass; workspace clippy: 0 errors.
- deploy-action.yml: scope actionlint to this workflow (no-arg actionlint tripped
  on pre-existing SC2086 in other repo workflows).
- Extract the inline 'Create fixture app' block into
  deploy-core/tests/make-smoke-fixture.sh (shellcheck-linted) and add an empty
  [workspace] table so the fixture is standalone (fixes 'believes it's in a
  workspace').
- Set the git exec bit (100755) on run-cli.sh, deploy-fastly/common.sh, and
  install-fastly.sh (rewritten via editor, lost +x) so the composite actions can
  invoke them directly (fixes 'Permission denied' exit 126 in the smoke test).
- Keep the readable @v1 tag on setup-rust-toolchain (design principle #9) and
  add an inline 'zizmor: ignore[unpinned-uses]' with justification, instead of an
  opaque SHA pin.
- Give the smoke fixture a minimal fastly.toml so the CLI's Fastly deploy path
  reaches the fake fastly binary; assert the deploy reached 'fastly compute'.
- ShellCheck: exclude SC1091 (can't follow the dynamic $SCRIPT_DIR/common.sh
  source from repo root — an info finding, not a defect). zizmor now passes via
  the inline unpinned-uses ignore.
- Smoke fixture: the real Fastly CLI (installed by install-fastly) shadowed the
  fake and errored on a missing package. Replace it with an edgezero.toml Fastly
  deploy-command override (the proven #303 approach) that records the passthrough
  argv; assert the typed --service-id (dummy-service) threaded through.
… cmd sites

- cleanup.sh remove_if_present used '[[ -n && -d ]] && rm', which returns 1 when
  the dir is absent; called as a bare statement under set -e it exited non-zero,
  failing the deploy-fastly Cleanup step (with if: always()) even though the
  deploy succeeded. Use if/fi so it always returns 0.
- Same footgun fixed in resolve-project.sh (lockfile hash — a real correctness
  bug for lockfile-less apps with cache:false) and check-action-pins.sh.
- Relax the smoke assertion to marker-file existence (robust regardless of how
  the CLI threads passthrough args into an overridden manifest command).
…se 9)

User-facing VitePress guide for the layered deploy actions: three-layer model,
runner support, same-repo/separate-repo/monorepo checkout examples, build-cli and
deploy-fastly input/output tables, typed-credential and trusted-ref guidance, the
Fastly staging lifecycle (stage -> healthcheck -> rollback), build-mode/cache
behavior, and job hardening. Wired into the VitePress sidebar under Reference.
prettier + eslint + vitepress build pass locally.
HIGH
1. Fail closed on invalid lifecycle values. 'stage' must be exactly true|false
   (validate-inputs) and 'deploy-to' exactly production|staging (healthcheck /
   rollback wrappers). A typo previously fell through to PRODUCTION, so it could
   activate a previous production version.
2. Rollback used wrong Fastly API semantics: POST -> PUT, and staging rollback
   now uses PUT /version/<v>/deactivate/staging (was a plain /deactivate).
   Verified against Fastly's version API reference + the 2024-08 staging change.
3. curl-config injection: tokens/service-ids were interpolated into a
   'curl --config -' document unescaped, so a quote/newline could terminate a
   value and inject options (another URL/proxy). Added curl_quote escaping plus
   validate_service_id / validate_version / validate_domain. The token still
   travels via the config file (never argv).
4. Implement the specified provider-env boundary. The wrapper no longer exports
   FASTLY_* directly; it passes typed values as data and run-cli.sh CLEARS every
   provider alias (FASTLY_TOKEN/ENDPOINT/API_URL/...) before exporting only the
   declared, typed credentials. Inherited aliases can no longer reach a deploy.
5. Staged deploy selected the wrong manifest: it bypassed manifest commands and
   searched fastly.toml from the cwd, ignoring EDGEZERO_MANIFEST — unsafe in
   monorepos. It now resolves and threads the configured manifest path.

MEDIUM
6. A successful deploy could emit an empty fastly-version (errors were demoted
   to warnings), breaking deploy->healthcheck->rollback threading. Version is now
   parsed from the deploy output (canonical version=<N>, then Fastly's native
   phrasing), API only as fallback, Err if both fail; the action also fails if no
   version is emitted.
7. Lifecycle inputs are now required in the CLI: --service-id/--version for
   healthcheck and rollback, --domain for healthcheck; the token is required
   where it is actually used.
8. Test coverage: Bash contract tests 10 -> 19 (stage validation, artifact-name
   traversal, provider-env boundary), and the composite smoke now asserts version
   threading AND that an inherited FASTLY_ENDPOINT is cleared before deploy.
9. artifact-name is validated (no separators/traversal/leading dot) and the
   tarball name is fixed, so caller input is never a path component.

Verified: cargo fmt/clippy(-D warnings)/test --workspace --all-targets, feature +
spin-wasm checks, shellcheck, actionlint, 19/19 bash tests, prettier + docs build.
The composite smoke test only covered a production deploy. The staging
lifecycle — stage, healthcheck, rollback — had no end-to-end coverage, which
is exactly where the review found real defects (--comment forwarded to a
command that doesn't support it, a plural staging_ips misread, POST instead of
PUT). Those are argv/verb bugs, so the test has to assert argv and verbs.

- lifecycle-smoke job: builds the app-owned fixture CLI, installs fake
  `fastly`/`curl` that mirror the real contracts (singular `staging_ip`,
  `--config -` on stdin), then drives stage -> healthcheck -> rollback through
  the real wrappers and asserts:
    * `compute update` carries --autoclone/--version=active/--non-interactive
      and never --comment;
    * the comment is applied via `service-version update` BEFORE staging;
    * the probe is rerouted to the staging IP via --connect-to;
    * an unhealthy probe FAILS healthcheck-fastly (the rollback gate);
    * staging rollback PUTs /deactivate/staging, production PUTs
      /version/41/activate, and rolled-back-to threads out.
- test.yml: run `cargo test -p edgezero-adapter-fastly --all-targets --features
  cli`. The workspace gate never enabled the `cli` feature, so 115 adapter
  dispatch tests were compiled by nothing but the clippy job.
- spec §9.1: document that compute-deploy-only flags are no-ops under --stage.
The lifecycle job's inline run blocks had grown into the largest logic in the
workflow, unreadable and unlinted. Each assertion is now a named script under
deploy-core/tests/ that documents the defect it regression-tests, and the YAML
is a list of steps again.
…_<NAME>

Security / correctness (High):
- cleanup.sh removed $EDGEZERO_FASTLY_HOME, a variable nothing in the action ever
  set — so its value could only ever be inherited, making an `rm -rf` of the
  checkout (or anything on a self-hosted runner) reachable from job env. Dropped
  it, and confined every removal to real paths beneath RUNNER_TEMP, resolving
  symlinks before comparing.
- run-cli.sh now scrubs its private env before exec'ing the app CLI. The typed
  token arrived twice — as a step variable and inside the provider-env JSON — and
  both stayed exported, so the CLI and every subprocess it spawned (including a
  manifest command) inherited the raw token under names we never promised.
- Production deploy never threaded --manifest-path, so in a monorepo it fell back
  to "closest fastly.toml" and could deploy the wrong app. It is now threaded on
  both paths and stripped from the Fastly argv (compute deploy has no such flag).
- A manifest-command deploy (`deploy = "fastly compute deploy"`) never received
  --non-interactive and could block on a TTY prompt in CI. The wrapper now
  supplies it as an action-owned passthrough arg; the built-in path dedupes it.

Correctness (Medium):
- Version parsing is anchored end-to-end. `version=15.2.0` used to parse as 15 and
  thread a version that was never deployed into healthcheck and rollback.
- healthcheck/rollback validate their required inputs. GitHub does not enforce
  `required: true`, so an empty service-id or version silently reached the probe.
- The toolchain search stops at the app's Git root, not github.workspace — in the
  separate-repo layout the deployer's .tool-versions was choosing the app's Rust.
  All paths canonicalized: a symlinked TMPDIR made the boundary never match.
- Wrapper logs are mktemp/0600 and removed by an EXIT trap; the three aliases that
  were declared but never blanked (FASTLY_DEBUG_MODE/CONFIG_FILE/HOME) are blanked
  on every step, including third-party `uses:` steps.

Env-var convention:
Every action-owned variable is now EDGEZERO__<SECTION>__<NAME> — `__` between
sections, `_` within. This is what makes the credential boundary a SINGLE rule
(unset EDGEZERO__*) instead of a hand-maintained list that a later variable could
silently escape. EDGEZERO_MANIFEST (single underscore) stays outside: it is the
CLI's public contract, and the one variable we deliberately pass through.

Also: build-cli -> build-app-cli (it builds the APP's CLI, never EdgeZero's own).

Tests: lifecycle-smoke now drives stage -> healthcheck -> rollback through the
REAL wrappers with the version threaded from the deploy output (no hard-coded 42),
which required install-fastly.sh to become idempotent. Contract suite 29 -> 49,
covering cleanup confinement, the env scrub, the action-owned passthrough, anchored
parsing, private logs, and the toolchain boundary — the last of which caught the
canonicalization bug above.
…app-cli.sh

The actions compile and run the CLI package the APPLICATION provides — never
EdgeZero's own. Half the names didn't say so, and "cli-artifact" / "cli-bin" /
"EDGEZERO__CLI__BIN" read as if they might be EdgeZero's CLI. That ambiguity is
exactly the thing this design exists to rule out, so it is swept from every layer:

- env vars:  EDGEZERO__APP__CLI__{BIN,VERSION,ARTIFACT_DIR},
             EDGEZERO__INPUT__APP_CLI_{PACKAGE,BIN,ARTIFACT}
- inputs:    app-cli-package, app-cli-bin, app-cli-artifact
- outputs:   app-cli-version, app-cli-package, app-cli-bin, app-cli-artifact
- scripts:   download-app-cli.sh, run-app-cli.sh (build-app-cli.sh already renamed)
- artifact:  app-cli-meta.json, with app-cli-{bin,version,package} keys
- docs:      guide, spec, adoption guide, and plan all updated

The contract test for the artifact metadata caught the one place the rename would
have broken the wiring (the download step's outputs), which is what it is for.

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Summary

15.5k lines across 73 files, and the layering thesis holds up: the app CLI really is the boundary, the wrappers really are thin, and adding a second provider really would be a new wrapper rather than an engine rewrite. Read every changed file; ran all five CI gates locally (all pass). The Bash contract suite, the golden public-surface test, and the credential-free cache ordering are better than most repos ship, and the code is unusually honest about its own residual risks.

The blocking findings share one root cause: build-app-cli establishes a real untrusted-build boundary, and deploy-fastly's seed build does not inherit it. Everything the build-app-cli docstrings warn about — a build.rs writing $GITHUB_PATH, persisting state a later privileged step consumes — applies to deploy-fastly's build-mode: always step, which runs in the same job that then receives the Fastly token. Two more findings (the .curlrc channel, the mutable major-tag pins) are the same shape: something an earlier step can leave behind that a token-bearing step later trusts.

😃 Praise

  • assert_safe_tarball (deploy-core/scripts/common.sh:158) identifies and fixes a genuine pipefail + SIGPIPE fail-open where the symlink check would have been false precisely when a symlink was found. Documented in place. That class of bug is normally found in production, not review.
  • The fake is delivered through the real trust path. make-fake-fastly-env.sh:247-267 packages the fake fastly as a tar.gz and repoints the checked-out versions.json with a matching SHA-256, so install-fastly.sh verifies and extracts it through its genuine download → checksum → extract path instead of adopting a planted binary — and run.sh:757-788 guards against that override ever being committed. The part that can't be faked gets its own path-filtered job against the real release. That split is rare and right.
  • The golden public-surface test (run.sh:1631-1755) pins the exact input/output names, required flags, and defaults per action, which is why the docs findings below are docs bugs rather than implementation drift.
  • EDGEZERO__ as a one-rule credential boundary (run-app-cli.sh:144-176): a single compgen -e prefix scrub instead of a list that rots, with EDGEZERO_MANIFEST deliberately outside it — and a docstring that states plainly what the boundary is not ("NOT a process-image boundary — on Linux this shell's original environment stays readable via /proc/<ppid>/environ"). Documented residual risk beats an overclaimed guarantee, here and at cli.rs:1999-2002.
  • Fail-closed version parsing throughout. parse_canonical_version_line rejecting version=15.2.0 rather than reading 15, last_version_after requiring a terminator, and resolve_active_version refusing to read a garbled or multi-active response as "no active version." Each of those is a wrong-version-into-rollback bug that isn't there.

Findings

Blocking

  • 🔧 Untrusted seed build shares a job with the tokendeploy-core/scripts/run-app-cli.sh:194. build mode runs the app's and every dependency's build.rs with GITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUT live; build-app-cli strips exactly these via env -u … exec and run_untrusted, and this path does not. Reaches the Deploy and Capture rollback target steps' jq/tee/grep calls. Gated on build-mode: always — which is also the only mode where the advertised target/ caching works.
  • 🔧 Pin gate accepts the mutable refs it claims to rejectdeploy-core/tests/check-action-pins.sh:48. (\.[0-9]+)* admits bare major tags; the branch head then moved every third-party ref onto them, including actions-rust-lang/setup-rust-toolchain@v1 inside the token-bearing job. Three separate comments (this file, zizmor.yml:11-13, run.sh:1758) assert immutability that isn't there.
  • 🔧 curl reads ~/.curlrc into the token-bearing config documentcrates/edgezero-adapter-fastly/src/cli.rs:2200. No -q, so a build.rs in the same job can plant a proxy = directive and receive the Fastly-Key header. The env-var variant of this attack is closed by the re-exec; the filesystem variant is not.
  • 🔧 BASH_ENV/ENV blanked on 4 of 10 bash stepsdeploy-fastly/action.yml:109 and the same gap in all three other wrappers. The unprotected steps are the ones producing deploy-flags-file and provider-env-clear-file, and the ones installing the two binaries the token steps execute. build-app-cli gets this right on all four of its steps, so it's an evenness problem, not a missing idea. run.sh:443-479 tests the invariant against a hardcoded step whitelist, which hides the gap.
  • 🔧 deploy.sh takes "last wins" where its sibling requires "exactly one"deploy-fastly/scripts/deploy.sh:51. Not reachable through EdgeZero's own CLI (its canonical line is emitted last); reachable with a non-conforming app CLI, which is the exact producer capture-previous.sh:78-90 was hardened against.
  • 🔧 Tool checksums fetched from the asset's own originscripts/install-yq.sh:60, scripts/install-actionlint.sh:47-52; cargo install zizmor has no pin at all. yq is the pin gate, and check-action-pins.sh:81-83 reports success on an empty ref list — a substituted yq makes the gate green while parsing nothing. versions.json:6 already demonstrates the in-repo-digest pattern.
  • 🔧 Production config push has no committed-source guardconfig-push-fastly/scripts/config-push.sh:65. No Resolve project, so no assert_committed_source and GITHUB_WORKSPACE as the confinement root — the boundary resolve-project.sh:137-141 explicitly rejects for the separate-repo layout. An uncommitted config can be pushed to the store the live service reads, with no source-revision to reconcile.

Non-blocking

  • 🤔 Staging IP unvalidated; IPv6 misparsescli.rs:2088. Fails closed, but the outcome is an automatic rollback of a healthy staged deploy, reported as "unhealthy." One parse::<IpAddr>() fixes both this and the recursive-descent looseness in find_staging_ip.
  • 🤔 zizmor's hardcoded file listdeploy-action.yml:106. Skips codeql.yml (security-events: write) and deploy-docs.yml (pages: write + id-token: write), and no tool audits composite action.yml for template injection. A new wrapper with ${{ github.event.* }} in a run: block passes every gate here.
  • 🤔 docker:// is wholly exempt from the pin gatecheck-action-pins.sh:63-65. docker://ghcr.io/x/y:latest passes a gate whose docstring promises to reject floating refs. Also, only .github/workflows (maxdepth 1) and .github/actions/**/action.y*ml are scanned, so a valid local action at e.g. tools/deploy/action.yml is never parsed.
  • 🤔 The checksum comparison is never negatively testedinstall-fastly.sh:74. The smoke computes the expected SHA from the archive it just built (make-fake-fastly-env.sh:255), so inverting or deleting the comparison leaves every job green. A run.sh case that corrupts the archive and asserts the mismatch message would close it. (Relatedly, fastly-installer-check.yml:3-4 now contradicts make-fake-fastly-env.sh:17-23 about whether the real checksum path is exercised elsewhere.)
  • 🤔 Skipped tests are counted as passesrun.sh:289, :1952, :2019 call pass "…(skipped: non-Linux runner)", and :958/:1268/:1333/:1499 do git init -q 2>/dev/null || return 0, so a git init failure silently deletes four whole suites with no diagnostic. On a macOS dev box the run is green with several suites never executed. A separate skip() counter reported apart from Passed: would make that visible.
  • 🤔 test_recovery_version_parse covers no production coderun.sh:2211-2262 tests a string literal defined inside the test, and the shipped helper recovery-active-version.sh:22-25 implements a third, incompatible parse that rejects the empty version= the test asserts must succeed. Extracting one parse and having both the doc snippet and the helper use it would make the test mean something.
  • 🤔 Partial download poisons the tool rootinstall-fastly.sh:71-74. curl --output truncates before the transfer completes, so a reset mid-download leaves a short archive; the documented idempotency then skips the refetch on retry and fails the checksum forever, reading as a supply-chain alarm rather than a network blip. Download to a scratch name and mv after verification.
  • 🤔 Lifecycle log lives outside the workspace cleanup deletesdeploy-core/scripts/common.sh:218-222 puts it in RUNNER_TEMP, so the in-process EXIT trap is the only thing that removes it — and no trap survives the SIGKILL after a cancellation grace period. Mode 600 doesn't help; every step in a job is the same uid. EDGEZERO__ACTION__WORKSPACE is already exported to those steps.
  • 🤔 Inline config written to a predictable path, with a trap that breaks on quotesconfig-push-fastly/scripts/config-push.sh:127-134. > follows symlinks and doesn't create exclusively, and $$ is small and reusable; on a self-hosted runner RUNNER_TEMP persists across jobs. Separately, the immediate-expansion trap (trap "… '$inline_file'") fails to parse if the path contains a quote, silently losing the cleanup. Single-quote the trap and mktemp the file — cleanup_sensitive_temps already tolerates an empty path.
  • 🤔 Resolved Rust toolchain is never shape-checkedresolve-project.sh:45-52 / build-app-cli.sh:60-68. It reaches rustup toolchain install, cargo +…, a third-party action input, and the cache key. It's the only free-form repo-sourced string in the PR with no regex gate; a checked-in rust-toolchain of --profile complete is parsed as an option. Worst case is a confusing failure, and the guard is one line.
  • 🤔 Allowlist admits one unvalidated token per permitted flagvalidate-inputs.sh:76 assumes every allowlisted flag takes a value. Not exploitable with today's single-entry allowlist (--comment), but the moment a boolean flag is added, --flag <anything> becomes an unchecked path into the provider argv, silently.
  • 🤔 local argv=("$(resolve_app_cli)" …) masks the :? guardhealthcheck-fastly/scripts/healthcheck.sh:74-84. local x=$(false) exits 0, so the diagnostic prints and execution continues with argv[0]="". Fails closed at 127, but it's the errexit-masking pattern the rest of the PR is careful to avoid, and this is the only lifecycle script omitting require_cmd "$cli_bin".
  • 🤔 cache: true is a silent no-op under the documented defaultresolve-project.sh:98-104 maps auto → never for Fastly, and both cache steps require always. validate-inputs.sh:103-106 accepts cache: true regardless with no warning, so a user who sets only cache: true gets nothing and no signal.
  • 🤔 cache_key omits build-argsresolve-project.sh:218. Two invocations at the same revision with different --features share one entry, and the first writer wins for the key's life. Cargo's fingerprinting degrades this to extra rebuilds rather than a wrong artifact, but it defeats the exact-key contract the surrounding comment establishes for workspace identity.
  • 🤔 provider-env-clear: '' degrades to [] instead of failing closedbuild-app-cli/action.yml:24-37 documents "the build fails closed"; an explicitly-empty input doesn't take the default, and provider_env_clear_names '[]' validates cleanly with zero names. The static layer still covers all 25 shipped aliases, so only a caller's own alias leaks.
  • 🤔 Two smoke assertions are outcome-onlydeploy-action.yml:619-621, :670-672 assert outcome == "failure" under whole-composite continue-on-error, so they'd pass if prepare-workspace or the installer checksum failed instead. assert-stale-rollback-refused.sh:46 already demonstrates the log-delta pattern that fixes it.
  • 🤔 Fake fastly never records the credential in scope, and always exits 0. The fake curl does record PROBE-TOKEN= and gets a real assertion; the fake fastly doesn't, so the staged-deploy and config-push jobs never verify which token arrived, and curl_config_capture's non-zero-exit path (cli.rs:2223-2229) is unreachable in test. Also config-store-entry describe always returns the "absent" shape, so an update-an-existing-key push is untested.
  • 🤔 Assertions on source text rather than behaviorrun.sh:494-495 greps an env key name in YAML under the description "cleanup removes the workspace root"; :2122-2126 pins the spelling of a loop, so rewriting it as an array fails with correct behavior; :917-927 is a bare grep … || fail with no matching pass, so success is invisible in the count.
  • 🔧 Docs: --yes is mandatory without a TTY and isn't documentedconfig.rs:854 errors, cli-reference.md:257 only mentions prompting, and both the adoption guide (:232) and deploy guide (:742) invite users to run config push directly. A copy-pasted CI invocation hard-fails. The action compensates silently (config-push.sh:148 appends --yes --no-diff) and no input table says so.
  • 🔧 Docs: cli-reference.md has no section for healthcheck, rollback, or active-version — the exact three commands deploy-github-actions.md:38-40 requires a hand-written app CLI to expose. Their required flags and defaults appear nowhere; the env-var table (:418-424) also omits FASTLY_API_TOKEN and FASTLY_SERVICE_ID, without which every lifecycle command hard-fails.
  • app-cli-bin's default is documented wrong in four tablesdeploy-github-actions.md:306, :464, :484, :509 say "artifact's name"; the real default is the app-cli-bin field of app-cli-meta.json (download-app-cli.sh:69), i.e. the built binary name. The spec has it right.
  • The separate-repo example won't run as pasteddeploy-github-actions.md:96-126 uses token: ${{ steps.app-token.outputs.token }} with no id: app-token step in the snippet, and ref: ${{ inputs.ref }} with no on: block. The adoption guide states the assumption in prose; the snippet doesn't. Worth one line noting @<ref> must be replaced too, since no example in either guide is runnable verbatim.
  • cli-reference.md:236/:298 attribute the full config push/config diff flag surfaces to the bundled edgezero binary, whose subcommands are hidden trailing-var sinks that exit 2. The typed split is explained, but only after the flags are attributed. Also :315/:327 claim exit 0 without --exit-code, while config.rs:408 returns 2 for Unsupported regardless.
  • ♻️ Three spellings of one verb across the action surfacestage: vs deploy-to: vs --staging. See the inline note on deploy-fastly/action.yml:42; these are the names downstream repos pin, so the window to change them closes at merge.
  • ♻️ deploy-fastly/scripts/common.sh is a third diverged copy of the helper set and is never sourced by run.sh; 5 of its 13 functions have no consumer anywhere, including the path-confinement helper is_under:81. No test would catch further drift.
  • 🌱 Zero-coverage production code with real logic: write-summary.sh (runs if: always() in five jobs; its "never emits credentials" contract is unenforced and nothing reads GITHUB_STEP_SUMMARY), verify-installed-version.sh:18, install-fastly.sh:33 provider_bin_dir (its entire rationale — an app CLI legitimately named fastly — can't fire because the fixture CLI is fixture-app-cli), and resolve-project.sh:164-169's symlink-escape guard (whose twin in config-push.sh is tested).
  • 📝 is_healthy_status counts 3xx as healthy (cli.rs:1851). Defensible and documented, but for a gate that triggers an automatic rollback, a staged version answering 301 to an error page passes. Worth a thought about 2xx-only or making it configurable.
  • 📝 tee_stream swallows read errors (adapter.rs:191): Ok(0) | Err(_) => break means one non-UTF-8 byte in a child's output silently truncates both the captured text and the operator-visible echo. Logging the error before breaking would make that diagnosable.
  • 📝 actions/checkout is pinned at both @v6 and @v7 in the same repo. $GITHUB_ACTION_PATH/... is unquoted in every run: (harmless on hosted runners; word-splits under a workspace path with spaces). build-app-cli.sh:226 re-declares local workspace_real, changing the variable's meaning mid-function.

📌 Out of Scope

  • Service-scoped serialization for deploy/rollback. ensure_rollback_from_is_active narrows the clobber window and cli.rs:1999-2002 says plainly that it can't close it. The fix is a concurrency group per service in the calling workflow, not in this PR — but it belongs in the guide's reconcile section, since a rollback that lands after a newer deploy is the failure this PR can't prevent alone.
  • post: cleanup. Composite actions have none, so cancellation/SIGKILL can leave the chmod-600 lifecycle log behind. Harmless on hosted runners, a cross-job leak on self-hosted — and validate-inputs.sh:37-42 doesn't exclude self-hosted. Tracking a documented "hosted runners only" constraint (or a reaper) is probably its own issue.
  • The production deploy path in composite-smoke/handoff-deploy runs a fake shell deploy command (make-smoke-fixture.sh:184-186), so no real fastly compute deploy/activate is ever exercised. The staged path does bypass manifest commands and test the real adapter, so this is a known and reasonable limit — worth naming in the spec's testing section rather than fixing here.

CI Status

Run locally against 93a9b90:

  • cargo fmt --all -- --checkPASS
  • cargo clippy --workspace --all-targets --all-features -- -D warningsPASS
  • cargo test --workspace --all-targetsPASS (1238 passed, 0 failed)
  • cargo check --workspace --all-targets --features "fastly cloudflare spin"PASS
  • cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spinPASS

GitHub checks are all green (23/23), including the full Deploy-actions smoke suite. No CI failures — every finding above is a design/hardening issue the gates don't cover.

Comment thread .github/actions/deploy-core/scripts/run-app-cli.sh
Comment thread .github/actions/deploy-core/tests/check-action-pins.sh
Comment thread crates/edgezero-adapter-fastly/src/cli.rs Outdated
Comment thread .github/actions/deploy-fastly/action.yml
Comment thread .github/actions/deploy-fastly/scripts/deploy.sh Outdated
Comment thread crates/edgezero-adapter-fastly/src/cli.rs
Comment thread .github/actions/deploy-fastly/action.yml Outdated
Comment thread .github/workflows/deploy-action.yml
Comment thread .github/actions/deploy-core/scripts/common.sh
Comment thread .github/actions/deploy-core/scripts/resolve-project.sh Outdated

@ChristianPavilonis ChristianPavilonis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Reviewed the Trusted Server/EdgeZero deployment, staging, healthcheck, rollback, security, and recovery paths. I found four actionable issues; details are inline. Current CI is green. Approving under the review rubric, while recommending that the P1 findings be addressed before merge.

Comment thread crates/edgezero-cli/src/lib.rs Outdated
Comment thread crates/edgezero-adapter-fastly/src/cli.rs Outdated
Comment thread crates/edgezero-adapter-fastly/src/cli.rs
Comment thread crates/edgezero-adapter-fastly/src/cli.rs
Pin gate (keep major-version tags, correct the immutability claims):
- Reword check-action-pins.sh header, zizmor.yml, and the run.sh suite so
  none of them claim a version tag is immutable — a major tag is
  publisher-repointable; the gate enforces a concrete, reviewable ref, not
  cryptographic immutability. Pin to a full SHA where that matters.
- Reject floating docker:// refs (bare image / :latest); accept an
  @<algo>:<digest> or a version tag. Add contract cases.
- Widen the default scan to action.yml anywhere in the repo (local actions),
  not just under .github/actions.

Fastly adapter / CLI (Rust):
- curl_config_capture and the health probe now lead with -q so curl never
  merges ~/.curlrc into a token-bearing --config document (a same-job build
  step could otherwise plant a proxy= directive and exfiltrate Fastly-Key).
- Bound every Fastly API call with --connect-timeout/--max-time and surface
  curl's exit 28 as an explicit timeout error (rollback is time-sensitive).
- Validate the resolved staging IP as an IpAddr before it reaches
  --connect-to, and bracket IPv6 literals so curl does not misparse them.
- is_healthy_status is 2xx-only: a 3xx to an error page must not suppress an
  automatic rollback (the probe does not follow redirects).
- Confine the adapter platform-manifest path under the manifest root:
  canonicalize and reject absolute/traversal/symlink escapes so a
  credential-bearing fastly build/deploy cannot run against out-of-repo
  source. Adds absolute/traversal/symlink regression tests.
- Staging selector twin: upsert the full desired set BEFORE deleting stale
  entries, so a concurrently-linked staged version never sees a required
  selector transiently absent (fall-through to production) and a partial
  failure leaves a superset. Document the residual per-service concurrency
  limit (serialize with a per-service concurrency group).
- Production healthcheck: when a token is available, require the requested
  version to be ACTIVE before and after the probe, so a concurrently
  activated newer version is not reported as a healthy `version`; without a
  token the check is documented service-level.
- tee_stream logs a read error before breaking instead of silently
  truncating captured + echoed child output.
Untrusted-build isolation:
- run-app-cli.sh build mode re-execs with GITHUB_ENV/PATH/OUTPUT/STATE/
  STEP_SUMMARY (and BASH_ENV/ENV) stripped, so a build.rs in the seed build
  cannot append a shim to a channel the later token-bearing steps trust. Deploy
  mode is unchanged (trusted app CLI). Adds a build-isolation contract test.
- BASH_ENV/ENV are now blanked on EVERY run: step across all five actions (they
  were even on only 4/10 deploy-fastly steps); the scrub test enumerates run
  steps from the YAML instead of a hardcoded whitelist, so a new unguarded step
  fails CI. Every run: invocation also quotes $GITHUB_ACTION_PATH.

Version threading:
- deploy.sh requires EXACTLY ONE canonical version= line (mirroring the
  rollback-target capture) rather than last-wins, so a non-conforming app CLI
  cannot thread a version that was never deployed into healthcheck/rollback.
- recovery-active-version.sh captures the CLI's exit status (no errexit-masked
  abort) and requires one well-formed version= line.

Tool installers:
- install-yq.sh / install-actionlint.sh verify against SHA-256 digests PINNED IN
  THE REPO, not the release's own checksum file — a compromised origin can serve
  a matching bad checksum, and yq IS the pin gate. install-fastly.sh downloads to
  a scratch path and mv's into the cache only after the checksum verifies, so a
  partial download never poisons the idempotent cache.
- The pin gate fails closed if a whole-repo scan parses ZERO refs (a broken/
  swapped yq), rejects floating docker:// refs, and scans local action.yml
  anywhere in the repo. Immutability claims corrected (major tags are mutable).

config-push committed-source guard:
- config pushed from the checked-out tree now requires committed source (shared
  assert_committed_source, moved to common.sh); inline content is exempt. The
  inline temp file is mktemp'd (exclusive, unpredictable) instead of a
  predictable $$ path.

Consolidation: delete the diverged deploy-fastly/scripts/common.sh (a stale
subset) and point install-fastly.sh at the shared deploy-core copy, dropping its
duplicated require_linux_x86_64.

Docs: document that config push requires --yes without a TTY (the action adds it);
add cli-reference sections for healthcheck/rollback/active-version + the
FASTLY_API_TOKEN/FASTLY_SERVICE_ID env vars; correct the app-cli-bin default in
four tables; make the separate-repo example runnable; fix config push/diff
attribution and exit-code docs.
resolve-project.sh:
- Shape-check the resolved Rust toolchain (channel/version token only) before it
  reaches rustup, cargo +<tc>, a third-party action input, and the cache key — a
  checked-in rust-toolchain of '--profile complete' no longer parses as an option.
- Fold build-args into the cache key so two invocations at one revision with
  different --features do not share (and clobber) a target/ cache entry.
- Drop the redeclared source_revision local; document RUNNER_OS/RUNNER_ARCH and
  build-args in the Reads table.

validate-inputs.sh:
- The deploy-arg allowlist now distinguishes value-taking flags (marked with a
  trailing '=', e.g. --comment=) from boolean flags, so adding a boolean to the
  allowlist can no longer let '--boolflag <anything>' smuggle an unchecked token
  into the provider argv. A boolean given a value is rejected.
- Warn when cache: true is set without build-mode: always (a silent no-op today).

Credential-boundary evenness:
- build-app-cli.sh fails closed on an explicitly-blank provider-env-clear instead
  of degrading to [] (scrub nothing); renames the redeclared workspace_real to
  cargo_ws_real; documents three previously-undocumented env vars.
- healthcheck.sh resolves the app CLI on its own line + require_cmd, so a failed
  ':?'-guarded resolve stops the step instead of running with an empty argv[0].
- The lifecycle log is minted inside the per-invocation action workspace (which
  cleanup removes wholesale) rather than RUNNER_TEMP, so it dies even when the
  EXIT trap cannot fire (SIGKILL after a cancellation grace period).

Test fidelity:
- A skip() counter reports non-Linux / missing-yq / failed-git-init cases apart
  from Passed, so a green run that silently skipped whole suites is visible.
- A negative install-fastly checksum test (corrupt archive -> mismatch) closes a
  gap where inverting the comparison left every job green.
- assert-config-push.sh sources common.sh instead of redefining fail() to log to
  stdout without >&2.
The deploy surface spelled the staging verb three ways: deploy-fastly's boolean
'stage', the lifecycle actions' 'deploy-to: production|staging', and the CLI's
--staging. Downstream repos pin these input names, so the window to make them
consistent closes at merge.

Rename deploy-fastly's input 'stage: true|false' to 'deploy-to:
production|staging' (default production), matching config-push/healthcheck/
rollback and the CLI. The wrapper derives --staging only for exactly 'staging',
and validate-inputs.sh now rejects any deploy-to that is neither production nor
staging (a typo can never silently reach production — the same fail-closed
guarantee the boolean had). Updates the plumbing (EDGEZERO__DEPLOY__STAGE ->
EDGEZERO__DEPLOY__TO), the smoke workflow, the golden public-surface test, the
validate-inputs tests, and the guide + specs.
Reconcile the staging-lifecycle feature with main's config-gc / config-store
refactor in the Fastly adapter. Took main's lead on the shared config-store
plumbing (stricter fail-closed store-list scan, resolve_remote_config_store_id
returning Option, redacted describe/stderr diagnostics, strict_stdout,
FUTURE_FORMAT_READ_ERROR) and the whole config gc feature; kept the staging
lifecycle and the review-fix hardening (curl -q + timeouts, 2xx-only health,
staging-IP IpAddr validation + IPv6 bracketing, production version verification,
write-before-delete selector mirror). Renamed the staging delete helper to
delete_staging_config_store_entry to coexist with gc's delete_config_store_entry,
and unioned both test suites. CLI template + app-demo gain the config gc command
alongside the lifecycle commands; cli-reference merges the richer --dry-run text
with the new --yes/no-TTY guidance.
curl_config_capture closed the child stdin with an explicit drop(stdin), which
trips clippy::drop_non_drop on wasm32-wasip1 where std::process::ChildStdin is
not Drop (the fastly cli wasm-clippy job builds this code). Hand the handle to a
by-value write_config_to_curl_stdin helper so it drops at scope end instead —
the same pattern main already uses for write_value_to_fastly_stdin. Verified with
cargo clippy -p edgezero-adapter-fastly --target wasm32-wasip1 --features 'fastly cli' --all-targets -- -D warnings.
The strict exactly-one-version= parse broke the production smokes (composite,
cache, handoff-deploy): a conforming deploy legitimately prints the version
TWICE, because the app CLI tees the provider output (which carries a version=
line) before emitting its own canonical version=<N>. Key on the DISTINCT set
instead of the raw count: benign duplicates of the same version collapse to one
value, while two DIFFERENT versions still fail closed rather than guessing which
was deployed (a missing/malformed line also fails closed). Adds run.sh coverage
for both the duplicate-accepted and conflicting-rejected cases.
…og cleanup, docs

P1 — config-store list errors no longer leak values. read_config_store_entries's
parse/schema-drift/malformed-entry errors embedded the raw stdout, which carries
every item_value (possibly production secrets) into retained CI logs. Split the
parse into a pure parse_config_store_entries and route every error through
redact_describe_response (size + top-level shape only). Adds sentinel-secret
regression tests for malformed JSON, schema drift, and a malformed entry.

P1 — adoption guide no longer offers the unsupported stage: input. The migration
table said deploy-fastly (stage: input); an unknown input is only a warning, so
the production default stood. Now deploy-to: staging; fixed the plan's wording too.

P2 — deploy.sh rejects a malformed version line even beside a valid one. It
grepped only well-formed lines, so version=42 + version=43x passed. Now every
^version= line must be well-formed before the valid values are deduplicated; a
malformed line fails closed. Adds run.sh coverage.

P2 — documented the self-hosted runner floor: Actions Runner 2.327.1+ for the
Node 24 actions (download-artifact@v8, cache@v6, upload-artifact@v7, checkout@v7),
in the guide and the spec.

P3 — the sensitive lifecycle log now lands in the per-invocation workspace.
common.sh prefers EDGEZERO__ACTION__WORKSPACE, but the capture/deploy/healthcheck/
rollback/config-push steps never passed it, so logs stayed under RUNNER_TEMP where
the workspace cleanup cannot reach them. Wired it into all five token steps.

P3 — corrected the pin-policy spec prose: it claimed immutable/exact while both
using and discouraging @v4. Now states the accepted policy — full SHA or a
version-shaped tag INCLUDING a movable major tag; branches/floating refs rejected;
not an immutability guarantee.
@aram356
aram356 requested a review from prk-Jr August 16, 2026 19:22
Proposes configurable caching for build-app-cli (which today compiles the app CLI
--release with no caching at all — the dominant deploy cost). Approach: extend the
in-house exact-key cache deploy-fastly already uses, exposed as cache (auto|true|
false, default auto), cache-key-suffix, and an optional cli-profile knob; share
one cache-key derivation between build-app-cli and resolve-project; keep the
credential-free-cache invariant. Design only — no implementation.
aram356 added 15 commits August 17, 2026 14:33
Address the review findings: (1) split the single stable-key cache into a
lockfile-keyed dependency cache and a source-revision-keyed target cache with
restore-keys prefixes (GitHub cache entries are immutable, so a stable key freezes
the first snapshot); (2) replace the 'credential-free by construction' invariant
with an explicit trust boundary + required preconditions (same-UID build.rs can
reach registry tokens/git creds/job secrets; private-dep source exposure) and
default cache off to match the parent's opt-in posture; (3) expand the key with
workspace identity, app-cli-bin, cli-profile, host target, and a cache-schema
version, and hash+length-bound cache-key-suffix; (4) drop the unreachable no-lock
auto branch (the lockfile is mandatory); (5) fully specify cli-profile (allowed
values, effective flags, target/<dir> artifact discovery, keying); (6) define
CARGO_HOME ownership, private-registry config, and key-scoped target-dir cleanup/
concurrency. Testing becomes an A/B/C generation test proving which generation a
third run restores. Design only.
Fix the structural errors: stable fixed CARGO_HOME/CARGO_TARGET_DIR paths (actions/
cache folds path into its version, and Cargo dep-info holds absolute paths, so
per-run/per-generation paths break restores) with the generation entirely in the
key; dependency cache uses Cargo's recommended layout (registry/index+cache, git/db;
excludes extracted src/checkouts that are executable input); restore semantics are
'latest accessible compatible generation', matched-key recorded, no ancestry
asserted; separate exact key grammars with source-revision as the final target
component; add app-repo/workspace/package/bin/cache-kind/schema identity + hashed
bounded suffix; add a committed-source guard (build-app-cli lacks one today);
specify the full step graph (toolchain before keys; no target reset on restore);
phased credentialed fetch -> scrub -> offline build for private deps
(registry-credentials input); force --target host + JSON artifact discovery;
cross-step ownership file for self-hosted concurrency with always-remove cleanup;
trusted writers AND readers (fork-PR cache reads; suffix is not an ACL; private-
source prohibition in untrusted-PR repos); reword the reuse claim (dep-artifact
reuse, not incremental app compile); note the parent spec forbids prefixes and must
be amended, plus impl-plan/adoption-guide updates and cache-churn tradeoff; record
the pinned rust-cache build-vs-buy alternative. Design only.
…f by default

Per the maintainer's build-vs-buy and default decisions: adopt Swatinem/rust-cache
pinned by full SHA (reversing the 'no third-party cache' non-goal) instead of the
bespoke in-house cache, which the prior reviews showed must re-derive rust-cache's
invariants (path stability, safe Cargo-home layout, key/prefix restore, cleaning).
The spec now specifies only what rust-cache does not cover: a stable action-owned
CARGO_TARGET_DIR it caches (no more mktemp; no target reset on a restored path); a
committed-source guard before the cached build (closing a pre-existing gap); a
phased credentialed cargo-fetch -> scrub -> offline build for private deps via a
registry-credentials input; forced --target host + JSON compiler-artifact discovery;
cli-profile; and the trusted-writers-AND-readers boundary (fork-PR cache reads;
suffix is not an ACL; private-source prohibition). cache defaults to false (opt-in).
Notes the parent spec's exact/no-prefix/no-third-party language must be amended, plus
impl-plan and adoption-guide updates. Design only.
…dential-free cache

Drop rust-cache (its job-end post-hook save runs after the token deploy and it
caches private source + executable bin + breaks the trusted-Cargo boundary) and the
rolling/prefix in-house design (ancestry GitHub does not guarantee, churn). Instead
apply deploy-fastly's already-proven pattern to build-app-cli: an EXACT lock key
from a shared resolve-project helper (no restore-prefix, aligning with the parent
spec's exact-cache mandate), explicit actions/cache restore+save with the save AFTER
the credential-free build and BEFORE any token step, and a Cargo-home layout we
control (registry index/cache + git/db + the CLI target/; excludes registry/src,
git/checkouts, and CARGO_HOME/bin, so no dependency source or executable is cached).
Adds a committed-source provenance gate (before build + re-check before save;
closes a pre-existing gap), stable fixed CARGO_HOME/CARGO_TARGET_DIR paths with a
cross-step ownership file for self-hosted concurrency, and app-repo/workspace/
package/bin identity in the key. cache defaults to false. cli-profile and private-
registry/git auth are explicitly out of scope for v1. Restricted to build-app-cli;
deploy-fastly unchanged. Design only.
…urity boundary

Pivot from in-place caching (five rounds, all blocked by the token being in the job)
to running build-app-cli in a dedicated credential-free job. Job isolation dissolves
the hardest blockers: no save-before-token ordering, no post-hook-timing problem
(so rust-cache's job-end save is safe), and no trusted-Cargo-boundary regression
(nothing to exfiltrate in a tokenless job). Within that job, delegate the cache
correctness machinery (keys, path stability, cleaning, restore) to pinned
Swatinem/rust-cache rather than re-deriving it in-house. Make the boundary
ENFORCEABLE: build-app-cli fails closed if a provider credential is present when
cache: true. Own the earlier factual error: a dependency-reuse cache necessarily
stores dependency source (.crate archives, git/db), readable across refs including
fork PRs -- so reader trust is the one irreducible precondition, stated plainly, not
papered over. Small action changes: stable target dir when caching, forced --target
host + JSON compiler-artifact discovery. cache off by default; cli-profile and
private-registry/git auth out of scope for v1; deploy-fastly unchanged. Design only.
…otes

Carry forward the two v5 review findings that still apply after the job-isolation
pivot: rust-cache keys omit the native build environment (runner image/libc/linker/
CC) so caching assumes homogeneous runner images (heterogeneous fleets namespace via
cache-key-suffix); and clarify that the resolver values feeding rust-cache are
derived inside the tokenless build job, so the handoff is not a credential regression
(still validated: canonical owned paths, bounded/hashed prefix-key). Design only.
…free job

Address the central blocker: a composite action cannot enforce a tokenless job
(callers add later steps; rust-cache's post-hook saves at job cleanup after them).
Deliver caching through a reusable workflow (on: workflow_call) that owns the build
job end-to-end -- minimal permissions, no id-token/OIDC, requests no provider
secrets, persist-credentials:false, and callers cannot inject steps -- so
tokenlessness is structural. Fold in the rest: toolchain-bound lifecycle (export
RUSTUP_TOOLCHAIN so rust-cache uses the app toolchain, not runner-default); concrete
pin Swatinem/rust-cache@6323deb1 # v2.9.2 with a gate rejecting any non-40-hex ref;
cache-bin:false + cache-workspace-crates:false + a versioned prefix-key including
hosted-image identity; stable target outside the per-invocation workspace with reset
only before restore and a relative workspaces mapping; Cargo>=1.91 gate for
build.build-dir with strict JSON compiler-artifact selection; artifact provenance
(app-repo + source-revision, validated before the CLI gets credentials); scope the
parent's exact-key language to deploy-fastly.cache and define build-app-cli.cache as
a separate rolling cache; correct the cache-key env model (rust-cache keys CC/CFLAGS/
RUST* but not image/libc/linker; hosted images are not homogeneous over time);
downgrade the alias check to defense-in-depth. Design only.
…ementation-ready)

Fold in the v6.1 review's ten concrete items: full reusable-workflow contract
(inputs/outputs/secrets incl. app-repository/app-ref + a scoped app-checkout-token
for private cross-repo, since a called workflow's checkout defaults to the caller
repo); reference the self composite via $/.github/actions/build-app-cli with a narrow
pin-gate exemption + actionlint suppression; define the internal resolve -> reset ->
rust-cache -> compile/stage/upload boundary (public composite unchanged, no cache
input; the workflow consumes cache/suffix); reset the stable target before EVERY
restore and scope its path + prefix-key by app-repo + workspace identity; drop forced
--target host (explicit-target mode changes build-script/proc-macro/RUSTFLAGS
semantics and would break cache:false parity) in favor of native semantics + strict
JSON compiler-artifact discovery, failing closed on an incompatible configured target;
provenance (app-repo + source-revision + schema version from the checkout) validated
by EVERY consumer before any CLI execution; writer-trust save-if; correct rust-cache
semantics (hashes all installed toolchains, whole-workspace metadata, conditional
save, path-containment); fix runs-on to hosted x64 / ephemeral one-job (persistent
self-hosted unsupported for cache); precise credential wording (GITHUB_TOKEN exists;
no PROVIDER credential/OIDC exposed); and correct the guide claims that consumers own
checkout/runner/timeout. Design only.
…etails to the plan

Fold in the v6.2 review's design-level findings: fix the runner (drop the caller-
controlled runs-on; hard-code one hosted x64 image; persistent self-hosted unsupported
for cache; build/deploy OS baseline compatibility); bind writer-trust save-if to the
actual checkout, not the event, and make cross-repository builds RESTORE-ONLY; restore
the RUSTUP_TOOLCHAIN export across restore/compile/post-save; preclude the cache save
when the post-hook cargo metadata cannot succeed (no empty-cache publish under an
immutable key); make matrix handoff go through unique artifact names, not the shared
single-CLI workflow outputs; make persist-credentials:false normative and require a
stored fine-grained PAT for private cross-repo (a calling job cannot mint an App token);
extend provenance (repo+revision+package+bin+workspace, one schema+validator) to EVERY
consumer including the lost-version recovery flow; and name the CALLER/deployer repo as
the cache owner in the reader-trust precondition. The remaining contract-level precision
(exact prepare/compile signatures, provenance schema literal, save-if predicate, id
canonicalization) is sequenced to the implementation plan in a new deferred section.
Design only.
…d trust model

Ground the design in the required use case (a deployer repo builds a SEPARATE app repo,
per trusted-server-deployer#24). This reframes the hardest v6.3 findings: the build
compiles the exact app the deploy will run, so build.rs is already trusted under 'trust
the code you deploy' -- caching does not widen the trust boundary (findings 2/3/5); and
the deployer OWNS and WRITES its own repo-scoped cache, so cross-repo warms normally
(the earlier restore-only rule was the actual bug behind finding 1 -- removed). Writer
authorization now binds a trusted deployer event/ref allowlist AND HEAD == the resolved
app SHA; fork-PR never writes. Complete the provenance identity (adds workspace-id) and
route it -- via one shared validator -- through EVERY consumer including checkout-less
healthcheck/rollback (new expected-identity inputs) and lost-version recovery, before any
credentialed CLI call. Pin one Cargo cwd shared by compile and rust-cache so config
chains match; bind-or-reject ancestor/extensionless .cargo/config, virtual roots, and
path deps. Fix a literal hosted image + a glibc/ABI baseline recorded in provenance and
enforced before the binary reaches a credentialed step. The empty-save-on-metadata-
failure residual is documented (rotate suffix) with an owned/forked save phase as future
work. Restore the dropped lifecycle tests. Design only.
… decisions

Make the eight v6.4-review items concrete rather than deferring them: accept the empty-
save residual and DROP the impossible no-empty-save guarantee/test (recover via suffix
rotation; owned save is future); run all cargo from the canonical workspace ROOT with
-p <package> and point rust-cache workspaces at that root (one config chain, correct
member classification) instead of cd-ing into a nested working-directory; replace
save-if restore-only with authorize-writer-BEFORE-compile so the runtime cache token is
present only for an authorized SHA (trusted deployer allowlist AND HEAD==resolved SHA;
unauthorized refs build with NO cache step), since app build.rs can call the cache API
directly; require explicit per-consumer expected-identity inputs (no self-default from
the artifact) plus a typed active-version-fastly + shared validation action so recovery
never hand-runs the CLI; add app-cli-package/app-cli-bin (and abi-id) to the key so
matrix legs never share an exact immutable entry; require implicit host-target (reject a
forced build.target/CARGO_BUILD_TARGET), confine both Cargo dirs, and REJECT under-
hashable config layouts (ancestor/extensionless/included config, source replacement,
local wrappers, external path deps); fix a literal hosted image, verify it is
GitHub-hosted, record abi-id (ImageOS/version + glibc + x86-64-v2) and require
same-family consumption. $9 now holds only string/interface mechanics. Design only.
…dings

Drop the false 'no cache token when unauthorized' boundary: the runner injects
ACTIONS_RUNTIME_TOKEN into the job's Node actions regardless, so the real rule is
fail-before-compile for non-allowlisted deployer events/refs and explicit trust of the
runtime credential for the trusted deploy-target build. Make workspace-id and abi-id
DETERMINISTIC pure functions of static inputs so matrix consumers compute their own
expected values (no artifact self-read, no last-leg-output transport). Key by the SHA-256
of a canonical length-prefixed tuple (fixes foo-bar/baz vs foo/bar-baz collisions) and
fold in the workspace-root Cargo.toml hash so virtual-root profile/patch/workspace changes
bust the key. Preserve the working-directory cwd (workspace-root cwd would drop member-
local config) and instead REJECT member-local .cargo/config, out-of-root members, forced
build.target/CARGO_BUILD_TARGET, and raised target-cpu. Force the x86-64 baseline; fix a
literal ubuntu-24.04, verify GitHub-hosted, and define a directional ABI predicate
(consumer >= producer) with abi-id = image+glibc+cpu. Upgrade the public composite to emit
workspace-id/abi-id too so provenance is producer-agnostic. Specify the
validate-app-cli-provenance and active-version-fastly action contracts (required expected
identity, empty-version=success, no self-validation). Concrete writer event list incl.
schedule; document the rust-cache bare-metadata --locked exception. Design only.
…findings

Split the conflated abi-id into a STATIC platform-id (image label + forced x86-64 baseline;
in the key and outputs) and RUNTIME ABI provenance (image version, glibc, ELF DT_NEEDED +
glibc symbol versions; provenance only), and add a static workspace-root input so
workspace-id needs no runtime Cargo discovery -- making matrix identity truly static.
Replace the member-local-only config rule with a COMPLETE fail-closed closure: isolated
action-owned CARGO_HOME plus rejection of any effective in-tree config (extensionless,
ancestor, recursive includes) setting a rustc/workspace wrapper, runner/linker override,
source replacement/mirror, or out-of-root [patch]. Reject external path dependencies whose
source is outside the workspace root (not just out-of-root members). Add a cargo metadata
--locked preflight (fail closed) and a post-restore Cargo.lock byte-identity check. Declare
normative job permissions: { contents: read } (forces id-token/all to none; tested against a
caller granting id-token: write). Narrow the ABI guarantee to the SAME literal ubuntu-24.04
image with recorded DT_NEEDED/glibc-symver as defense in depth (cross-image directional
analysis -> future). Give the validator a hardened extraction contract (one tar, owned root,
unique members, no traversal/links/special, confined regular executable). Restore
app-cli-version to the schema/validator. Qualify workflow_dispatch by a protected ref. Fix
the reset-after test to 'reset before; no reset after; deps survive post'. Design only.
…findings

Put ImageVersion in the cache key (weekly rollout = fresh cache, accepted) with exact
producer/consumer image-version equality and ELF DT_NEEDED/glibc-symver RECOMPUTED from the
extracted binary rather than trusting the JSON. Demote app-cli-version to informational (not
validated identity) and require a unique app-cli-artifact per matrix leg. Make workspace-root
required/canonical/confined and assert it equals cargo metadata.workspace_root; derive the Git
root canonically for both producers. Replace the incomplete config denylist with a full-chain
(cwd -> / plus CARGO_HOME) scan gated by a SAFE-KEY ALLOWLIST (rejecting env/build.rustc/
rustflags/profile/target-links/paths/source-replacement/includes, incl. deployer config above
the app workspace). Assert clean source -- tracked, untracked, recursive submodules -- BEFORE
and AFTER app-controlled commands. Add the private-app/public-deployer artifact-disclosure
precondition (fail closed; independent of caching). Make CARGO_HOME a deterministic
identity-scoped stable path exported unchanged through post-save. Reject every rust-flag
channel (RUSTFLAGS/CARGO_ENCODED_RUSTFLAGS/CARGO_BUILD_RUSTFLAGS/target-qualified) and inject
one baseline; state native/assembly portability is an app responsibility. Keep the direct
composite's existing runner support under a separate exact-environment ABI policy (cached path
stays hosted ubuntu-24.04). Add concrete contract tables for the workflow and both actions.
Clarify caller-must-grant-contents:read and the three checkout cases. Expand tests + parent
runner/provenance migration. Design only.
…e v6.8 review

Adopt a pinned CONTAINER (by digest) for the cached build so platform-id = the immutable
container digest -- static, known ahead, and identical across a two-job handoff -- resolving
the mutable-hosted-ImageVersion problem (finding 1) and the self-hosted ABI problem (6); the
consumer runs the binary against the same digest. Split identity into git-root (path) /
app-repo (owner/repo) / immutable app-repo-id (GitHub numeric id, in key+provenance) so both
producers agree (3). Make the config closure a MINIMAL explicit allowlist covering CARGO_* env
overrides and rejecting net.git-fetch-with-cli and credential providers (2). Add a
disclosure-acknowledged consent input; require it for any private cross-repo build; stop
inferring visibility from PAT presence (4). Require workspace-root for ALL handoffs and give
every consumer expected-* inputs (5). Separate action-enforced checkout FIDELITY (HEAD==SHA)
from deployer-enforced source AUTHORIZATION (the protected workflow allowlists app identity;
all cache-branch writers are trusted) (7). Make the preflight mirror rust-cache's exact
invocation (--all-features --locked, workspace root, CARGO_ENCODED_RUSTFLAGS='') (8). Freeze
the revision by asserting HEAD UNCHANGED (not just clean) before/after + reject escaping
symlinks (9). Hash only codegen-critical root-manifest sections so rolling restores survive
dependency edits (10). timeout-minutes default 30; PAT scoped to private cross-repo; rename
service-id -> fastly-service-id (11). Define the complete versioned JSON schema incl.
toolchain-id in the validated identity (12). Design only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow action commands to use a predefined EdgeZero app binary As developer I want to deploy edgezero app using reusable GitHub actions

3 participants