Skip to content

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

Draft
aram356 wants to merge 66 commits into
mainfrom
feature/edgezero-deploy-actions
Draft

Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303)#316
aram356 wants to merge 66 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 EdgeZero apps —
design + implementation — superseding the Fastly-only monolith in #303.
Based off main.

Actions:

  • 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; publishes a 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; installs the pinned Fastly CLI; optional
    stage: true; outputs fastly-version.
  • healthcheck-fastly / rollback-fastly — Fastly staging lifecycle (parity
    with stackpop/trusted-server-actions), driven by the app CLI over the Fastly
    API.

CLI scaffolding (edgezero-adapter-fastly + downstream template): --stage,
healthcheck/rollback subcommands, deployment-version output, --version.

Design docs

  • docs/specs/edgezero-deploy-github-action.md — 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)

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; provider-env scoped to the
    deploy step only.

Notes

Supersedes #303 (and the earlier stacked docs PR #315). #303's unrelated changes
(KV timing logs, dep bumps) are not carried here.

🚧 Implementation in progress — phases tracked in the plan doc.

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.
aram356 added 28 commits July 17, 2026 00:06
`tar -tvf … | grep -qE '^[lh]'` is unsound under `set -o pipefail`: grep -q exits
on the FIRST match, tar takes SIGPIPE, and the pipeline reports tar's failure —
so the `if` reads false exactly when a link WAS found, letting an unsafe archive
through. Capture the listing first, then match.

Not reproducible on macOS (bsdtar buffers a small archive and never sees
SIGPIPE), which is why the existing test passed; the hazard is real on GNU tar.
The fix removes the class rather than the symptom.
…nt, shadowing

Critical — production rollback could promote a staged version. previous_version
was literally current-1, but the version counter increments over every draft and
staged version. With prod v5, staged v6, prod v7, rolling back v7 activated v6 —
publishing staged code AND (since a staged draft is re-linked to the staging
config selector) staged config. Now resolved from the service's version list:
the highest version below current that is not staged and not an untouched draft.
Fails when nothing qualifies rather than guessing. Verified against the exact
reported case.

Staging edge cases the --staging design surfaced:
- stage:true was broken for apps with NO config stores (I introduced this): the
  relink required the staging store unconditionally. Now it is a no-op when the
  app has no runtime-override store at all — staging code with no config to
  isolate is valid — and still fails closed when the app HAS one but the staging
  twin is missing.
- Re-provision did not reconcile: once the [setup] marker existed, provision
  returned before writing selector entries, so a config store added later
  silently fell back to its production key. Selector entries are now upserted for
  every declared store on every provision (idempotent), and a remotely-deleted
  staging store is recreated — which is what the deploy-time error tells the
  operator to do.
- --key + --staging is now refused, not silently wrong. An explicit key would be
  written where the (logical-id-derived) staging selector never points, so the
  push would go nowhere. Rejected at clap (conflicts_with) and at the seam.
- provision guidance no longer tells the operator to point the PRODUCTION store
  at a _staging key (which would make the live service read staged config).

Also (independent of staging):
- PATH shadowing: an app CLI named `fastly` resolved to the provider CLI, since
  install-fastly prepends its own dir. download-app-cli now emits the app CLI's
  ABSOLUTE path; all four invocation sites invoke that, immune to PATH order.
- build-app-cli confines cargo metadata's workspace_root to the app repository —
  an enclosing deployer workspace could otherwise control the lockfile and deps.
- Default `edgezero.toml` discovery is confined (deploy + config-push): a
  committed symlink named edgezero.toml could point outside the app repo.
- Archive link check no longer pipes tar into grep -q under pipefail (a false
  negative that could let a symlink-bearing archive through on GNU tar).

84 -> 88 shell contracts; new Rust tests for the rollback target and the
config-store-less staging path. All five CI gates + docs green; the fastly suite
no longer touches a real account.
The deploy-fastly validate step did its input checks inline in action.yml `run:`
bash — which CI's shellcheck never sees (it globs scripts/*.sh) and which no
contract test can reach. All four wrappers had the same smell.

Each wrapper now has scripts/validate.sh:
- deploy-fastly: artifact + token presence, service-id format, then delegates to
  the engine's provider-neutral validate-inputs.sh.
- config-push-fastly: artifact + token presence, deploy-to fail-closed.
- healthcheck/rollback: artifact presence.

The Fastly-specific checks stay in the WRAPPER, never in the provider-neutral
engine validate-inputs.sh — putting fastly-service-id there would leak a provider
name into the layer that must not know any. New `require_present` helper asserts
a credential is set via the precomputed `…_PRESENT` bool without the secret ever
reaching the validate step.

12 new contract cases exercise every branch — the point of moving it out of YAML.
88 -> 100 shell contracts; shellcheck + actionlint clean.
The staged relink now no-ops when the app has no `edgezero_runtime_env` selector
store (an app with no config to isolate). The smoke's fake listed only the app
store and the staging twin, so the relink was correctly skipped — but the
assertion expects it. The smoke exists to prove the relink works, so its fake now
also lists `edgezero_runtime_env`, modelling an app that does select config.
The rollback Critical fix made production rollback GET the version list to
resolve its target, but the smoke's fake curl answered every GET with the
domain/staging-IP array — so resolution found no versions and (correctly)
refused, failing the smoke.

The fake now returns a version list for `/version`, and it is built to actually
test the fix: v42 and v41 are BOTH staged, v40 is active. `version - 1` (= 41)
would be a staged version — the exact Critical — so only resolution lands on 40.
The assertion now requires the GET, forbids activating staged-1, and expects
v40. The previous assertion used staged-1 arithmetic that coincidentally matched
and would not have caught a regression.
Five assert scripts still used the inline `Reads (env): A, B, C.` one-liner while
every other action script (and assert-config-push.sh) uses the aligned per-var
block with a required/optional column. Converted them for consistency —
comment-only, no behavior change.
… one

Rollback inferred the target as `version - 1` (earlier: a resolver that filtered
on a `staged` field). Fastly's version object has NO such field — the real field
is `staging`, documented "Unused", and `locked` only means "not editable", which
a staged version is too. So the inference activated the staged version in a
staged-then-rollback flow, publishing staged code and (via the staged config
relink) staged config. The passing test only passed because it fabricated a
`staged` field Fastly never emits.

There is no field to distinguish a previously-live version from a staged one, so
the target cannot be inferred — it is captured before the deploy that supersedes
it and passed in:

- New CLI verb `active-version` prints `version=<active>` (reads the real `active`
  field). deploy-fastly runs it BEFORE a production deploy and emits
  `previous-version`.
- rollback-fastly gains a `rollback-to` input; production activates it verbatim
  and fails closed when it is absent. Staging is unchanged (deactivate).
- The fabricated-field resolver and its test are deleted.

app-demo (the reference downstream CLI) now wires the full lifecycle surface:
healthcheck, rollback, and active-version.

Verified against the real fixture CLI: active-version resolves the active version,
production rollback activates the explicit target, and a targetless production
rollback errors. Smoke asserts the explicit-target activation and that a
targetless production rollback is refused. 100 shell contracts; fmt/clippy/tests
green.
The app-demo workspace (excluded from the main workspace, linted separately in
CI) enforces alphabetical item ordering. The new ActiveVersion/Rollback variants
were out of order.
…time

The staging config twin previously held only the config `__KEY` selectors,
written at provision time. Two defects followed:

  * A staged version relinked to the twin lost every OTHER production runtime
    override (adapter host, logging level, `__NAME` redirects), since the twin
    never carried them.
  * The relink guard treated any `edgezero_runtime_env` lookup error as "no
    config to isolate" and skipped — failing OPEN on a transient CLI/schema
    failure, and forcing a re-provision whenever a store was added.

A staged deploy now owns the twin end to end. It reads the production selector
store's CURRENT entries, copies the non-config overrides verbatim, redirects
every declared config store's selector to `<logical>_staging`, reconciles the
twin (deleting entries production no longer has), then relinks — so the twin
always mirrors production with only config isolated. The declared config stores
are threaded from the app manifest by the CLI as `--edgezero-staging-config`,
never a remote probe. Lookup FAILURE now fails closed; a genuine NotFound (or an
app that declares no config) is a no-op. The twin is created on demand, so
provision no longer touches it (a provision-time twin would drift the moment an
override changed).
…docs

The CLI derives a staging config key from the store's logical id
(`<logical>_staging`) and refuses an explicit `--key` alongside `--staging`, but
the config-push-fastly wrapper still exposed a `key` input usable with
`deploy-to: staging` and its docs described a `<key>_staging` variant that
composed with `key`. A caller who set both got a confusing CLI-level error deep
in the push.

The wrapper's validate.sh now rejects `key` + `deploy-to: staging` up front with
a clear message, mirroring the CLI's mutual exclusion. Input descriptions, the
push-script comments, and the guide/spec docs now state that the staging key is
derived from the logical store id and that `key` is production-only. The docs
also correct two now-stale claims: the staging twin is created and mirrored by a
staged deploy (not by provision), and a staged deploy fails closed on an
unreadable store listing (not on a missing twin).
The rollback docs still implied a production rollback re-activates an inferred
"previous version" (`<v> - 1`), which Fastly's version metadata cannot support —
a superseded production version is indistinguishable from a staged draft. The
guide and spec now describe the real contract: a production `deploy-fastly` emits
`previous-version` (captured via `active-version` before the deploy supersedes
it), which the caller threads into `rollback-fastly`'s `rollback-to`. A
production rollback without a target fails closed. Adds the missing `rollback-to`
input and `previous-version` output to the tables and a production-rollback
wiring example.
The lifecycle smoke test fakes the Fastly CLI, so install-fastly.sh's real
download + SHA-256 verification path is never exercised there. This dedicated
workflow runs it un-faked — fetching the pinned release, verifying its checksum,
and confirming the installed binary reports the pinned version. It is
path-filtered to the installer, its pinned versions.json, and the shared
common.sh (not scheduled), so a version/URL/checksum bump is validated against
the real release exactly when it changes.
`fastly version | head -n 1` under `set -o pipefail` made `fastly` exit 141
(SIGPIPE) when head closed the pipe, failing the step even though the install
itself succeeded. Capture the full output and take the first line with parameter
expansion instead of piping.
The verify step's inline shell was heavy enough to belong in its own file, like
the other action scripts. Extract it to
deploy-fastly/scripts/verify-installed-version.sh (sourcing common.sh, using the
standard Reads-env header), so it is shellcheck'd by CI and testable outside the
YAML. The workflow step now just invokes it.
Removes internal spec-section references (§5.4, 8.x, 9.x, 12.x), PR/round/finding
markers (PR #269, Post-F4/F6, F1/F2), plan-phase labels (Stage 5/6), and
test-doc severity prefixes from comments and user-facing strings in the files
this branch touches. The explanatory content is preserved verbatim — only the
process reference is dropped and the surrounding sentence re-flowed. Comments now
describe what the code does, not which spec section or review round produced it.

Two deliberate survivors: the raw-push error string points at the real design
doc's §3.2.1 (a user-facing pointer, and asserted by tests), so it and its test
assertions are left intact.
The workflow invokes the script directly (`run: .../verify-installed-version.sh`),
which needs the execute bit — it was committed 100644, so the installer-check
step failed with permission denied. Mark it 100755 like the sibling scripts.
The scaffold's `main.rs.hbs` shipped Deploy/Healthcheck/Rollback but not
ActiveVersion, so a freshly generated app CLI rejected the `active-version`
command that a production deploy's rollback-target capture invokes — leaving the
deploy to proceed with no rollback target. The lifecycle smoke fixture added the
command by hand, masking the omission. Add ActiveVersion (import, variant,
dispatch) to the template so generated apps expose it out of the box.
Four fixes to the Fastly staging/rollback lifecycle:

  * Per-service staging store. The staging selector twin was a single
    account-wide `edgezero_runtime_env_staging` store, destructively reconciled
    and relinked on every staged deploy — so two services staging on one account
    would clobber each other's selectors. The twin is now named per service
    (`edgezero_runtime_env_staging_<service_id>`), created on demand.

  * Isolate even without a production override store. When an app declares
    config but has no `edgezero_runtime_env` store, the relink returned early and
    the staged version silently read production's default key. It now still
    creates the per-service twin, writes the `<logical>_staging` selectors (there
    is simply nothing to mirror), and relinks — staging stays isolated.

  * Distinguish "no active version" from an operational failure. The active-
    version emitter returned Err for both a first-ever deploy AND API/auth/parse
    failures, and the capture step's `|| true` collapsed both to an empty
    rollback target. It now emits an empty `version=` and succeeds only when the
    API confirms no active version; a real failure returns Err, and the capture
    step fails closed rather than deploy with no way back.

  * Run lifecycle commands from the app directory. active-version / healthcheck /
    rollback load their manifest from the current directory, so in a monorepo a
    stray root `edgezero.toml` could shadow the app's. The wrappers now thread
    `working-directory` and the scripts cd into the app dir before invoking the
    CLI (a shared `enter_app_dir` helper).
An extensionless `rust-toolchain` may be a legacy single-line channel OR a TOML
document (`[toolchain]` + `channel = "..."`) — rustup accepts both. The channel-
file parser always took the first non-blank line, so a valid TOML document
resolved the toolchain to the literal `[toolchain]`. Detect the TOML form and
parse its channel instead, in both build-app-cli.sh and resolve-project.sh.
The adoption guide, implementation plan, and spec output table still showed a
production rollback without a target and omitted the previous-version output.
Document the real flow: deploy-fastly emits previous-version, threaded into
rollback-fastly's rollback-to (Fastly cannot infer it). Also correct the
installer-gate section: the check is path-filtered, not scheduled, per the
decision to avoid downloading the CLI on unrelated runs.
The deploy path threads resolve-project.sh's RESOLVED (absolute) working-
directory into the capture step, but enter_app_dir prepended github.workspace
again — breaking the production smoke. Accept both an absolute path (the resolve
output) and a workspace-relative path (raw wrapper inputs used by healthcheck /
rollback), with a contract test covering both plus escape/missing rejection.
Also run Prettier on the spec doc.
deploy-fastly now captures the rollback target before a production deploy via a
real `active-version` Fastly API call, and fails closed when it cannot resolve
one. The production composite-smoke drove a manifest-command deploy with no fake
`curl`, so that capture hit the real API and (correctly) failed closed — a case
the old fail-open capture had silently swallowed. Set up the fake Fastly env in
that job so active-version resolves the fake active version (40), and assert
previous-version=40 threads out, locking in the capture contract. The staged
lifecycle-smoke skips capture (stage=true) and already had the fake env.
Correctness:
  * resolve_active_version now rejects MALFORMED active entries (a non-boolean
    `active`, or an `active: true` entry whose `number` is missing or not an
    unsigned integer) as operational errors instead of reading them as "no active
    version" — closing the last fail-open path in rollback-target capture.
  * The production deploy's version fallback passes `--require-active`, so a
    post-activation API response with no active version is an ERROR, not a silent
    empty-`version=` success for direct CLI consumers.
  * active-version / healthcheck / rollback are now manifest-INDEPENDENT: they are
    pure Fastly-API operations keyed on explicit flags and can never be
    manifest-command overrides, so they no longer load the manifest. This fixes
    monorepo runs where a stray root edgezero.toml (or an explicit `manifest:`
    selection) shadowed the app's, and removes the now-unnecessary enter_app_dir /
    working-directory scaffolding.

Tooling:
  * The rust-toolchain TOML parser accepts a trailing `# comment` after the
    channel value (valid TOML).
  * The installer gate also triggers on .tool-versions — install-fastly.sh
    requires it to agree with versions.json, so a Fastly tool-version change must
    run the real installer.

Tests:
  * The production smoke now threads the deploy's `previous-version` into a real
    rollback's `rollback-to` (no hardcoded version) and asserts the activated
    target; the staged healthcheck asserts its `status-code`/`healthy` outputs;
    and the generator test asserts a generated CLI exposes active-version /
    healthcheck / rollback.

Docs: correct the credential-boundary contract (lifecycle steps take the token
directly under the adapter convention), the per-service staging store name, and
the path-filtered (not scheduled) installer gate.
Security / correctness:
  * install-fastly.sh no longer adopts a pre-seeded binary by its version text —
    which let an earlier step plant an executable that later ran with
    FASTLY_API_TOKEN. The `fastly` binary is now ALWAYS extracted from a
    SHA-256-verified archive (cached under downloads/, so still no refetch on a
    second run). The lifecycle smoke delivers its fake through that same verified
    path: it packages the fake as a tar.gz at the cache path and repoints its
    CHECKED-OUT versions.json at it with a matching checksum, so the real
    download+verify+extract is exercised rather than bypassed.
  * resolve_active_version scans the ENTIRE version list instead of returning at
    the first active entry: a non-boolean `active` anywhere, or more than one
    active version, is now an operational error rather than a silently-accepted
    or ignored result.
  * fastly_api_get uses curl's `fail` directive, so an HTTP 4xx/5xx (even an
    array-shaped error body) can never be mistaken for valid version data.
  * find_config_store_id treats ANY malformed entry as schema drift, so a
    malformed entry can't be masked by an unrelated well-formed one and read as
    NotFound (which would have failed staging isolation open).

Tests / robustness:
  * The production `deploy` version fallback's `--require-active` policy is now
    unit-tested (active_version_or_require). The unhealthy smoke asserts
    status-code=503 and an explicit healthy=false.
  * A credential-free `active-version --help` preflight fails a production deploy
    early (before the token-bearing call) when the app CLI lacks the command, and
    the guide documents the required command surface.

CI / docs:
  * The installer gate's path filter watches deploy-fastly/common.sh (which the
    installer actually sources) and the workflow is now covered by
    actionlint/zizmor and the pin checker.
  * Credential-boundary docs note the capture step also receives the token
    (adapter convention); removed the nonexistent adapter/effective-build-mode
    outputs from the spec/plan; the guide names the per-service staging store.
Correctness:
  * resolve_active_version now requires EVERY element to be a version object with
    an unsigned-integer `number` (an omitted `active` means "not active"). A
    null, a non-object, or a missing/non-numeric `number` is an operational error
    rather than being skipped and read as "no active version" (fail open).
  * find_config_store_id scans the WHOLE listing instead of returning on the
    first match, so a malformed entry after the match, or a duplicate store name,
    is caught (schema drift) rather than silently accepted.
  * fastly_api_get validates a 2xx status via write-out (as the PUT helper does),
    so a 3xx redirect body — which `--fail` alone would accept — can no longer be
    parsed as version data. The fake curl mirrors the new body+status shape.
  * The credential-free active-version preflight runs with FASTLY_API_TOKEN
    explicitly unset, so the token truly reaches only the real API call.

Tests:
  * The lifecycle smoke now PLANTS a decoy binary at the extract target that
    reports the pinned version but errors on every real command — a standing
    regression guard: if install-fastly ever re-adopts a pre-seeded binary by its
    version text, the staged deploy fails. Adjacent to the reworked verified-
    archive fake delivery.
  * A capture-previous contract test covers first-deploy (empty previous-version,
    still succeeds), an active version threading out, fail-closed on an
    operational error, and the missing-command preflight.

Tooling / docs:
  * The duplicated inline actionlint install is replaced by the reusable,
    checksum-verifying scripts/install-actionlint.sh, now shellcheck'd and
    path-triggered.
  * Credential docs (guide table + spec acceptance criterion) note the capture
    step; the adoption guide gains a required-command-surface checklist; the plan
    lists config-push-fastly and active-version.
Blockers:
  * Rollback-target capture now fails CLOSED when active-version exits 0 but emits
    no `version=` contract line (e.g. an app CLI that never called
    init_cli_logger). A silent success is no longer mistaken for a first deploy
    and allowed to deploy with no rollback target.
  * A production rollback verifies the version it is rolling back FROM
    (`--version`) is STILL the active version before activating the target. A
    delayed rollback that races a newer deploy is refused rather than silently
    clobbering the newer version. The lifecycle smoke's fake Fastly API now tracks
    the active version through a state file so this compare-and-swap is exercised
    end to end; the guard logic is also unit-tested.
  * The adoption guide documents the logger/output contract: hand-written CLIs
    must call edgezero_cli::init_cli_logger() (or emit equivalent unprefixed
    output) or the run_* handlers' machine-readable lines are swallowed.

Also:
  * resolve_active_version fails closed on an EMPTY version list (a real service
    always has at least an initial version).
  * Test harness hardened: the capture preflight fake asserts FASTLY_API_TOKEN is
    absent (guarding the scrub), and the fake fastly exits non-zero on an
    unhandled command (an unexpected provider call now breaks the smoke).
  * Docs: the spec's credential principle notes the lifecycle steps receive the
    token directly; healthcheck `retry` is documented as total attempts; and the
    guide names the app-owned CLI for `config push` (the bundled binary returns
    unsupported).
Blockers:
  * Rollback-target capture now rejects a MALFORMED `version=` value, not just an
    absent line. `version=12abc` previously passed the presence check, then the
    anchored numeric parse dropped it to empty and the deploy continued as a
    first deploy. Capture accepts only `version=<digits>` or an empty `version=`;
    anything else fails closed. Covered by a new contract test.
  * The production rollback's staleness check is no longer described as a
    compare-and-swap. It reads the active version and activates in two separate
    requests, and Fastly's activate endpoint takes no precondition, so a deploy
    landing in that window can still be clobbered. Code comments, the guard's
    docs, and the guide now say so plainly, and the recommended concurrency group
    is SERVICE-scoped (not per-ref) — that is what actually closes the window.

Coverage:
  * A stale-rollback smoke test: a newer version becomes active, the rollback is
    refused, and the assertion proves it activated NOTHING (refusal precedes
    mutation). The fake Fastly API now records activations so an activate
    genuinely changes the active version a later read sees.

Also:
  * `retry` must be >= 1 — it is a total attempt count, and 0 was accepted then
    silently coerced to 1.
  * Spec: drop the unimplemented `--output` file version contract (only the
    anchored `version=<N>` line is parsed); align the goal and test-strategy
    credential-scope wording with the principle (all provider-calling steps).
  * Adoption guide: staging config push writes the derived
    `<logical-store-id>_staging` key, and an explicit `key` is rejected with it.
…and rollback tests

Security:
  * build-app-cli's build step runs APP-CONTROLLED code (cargo build, and the
    built CLI's --help) — it now blanks every inherited provider alias, so a
    job-level FASTLY_API_TOKEN can never reach a compile or a --help of untrusted
    code. The upload step and the healthcheck/rollback validate steps blank them
    too (only the actual provider step receives the token).
  * A production healthcheck no longer requires or receives the token; the adapter
    reads it only for staging-IP resolution. `fastly-api-token` is optional and
    passed to the probe only for deploy-to: staging.

Fail-closed:
  * Rollback-target capture now requires EXACTLY ONE `version=` contract line, so
    a malformed line hidden behind a later well-formed one (`version=12abc`
    followed by `version=`) can no longer read as a first deploy. Covered by
    order-dependence and conflicting-line tests.

Tests / docs:
  * The stale-rollback smoke now snapshots the call log and asserts the DELTA:
    the active-version GET happened (the guard actually ran, not an earlier
    startup failure), no activate PUT was issued, and the active version is still
    99. The fake API records activations so a read reflects them.
  * Fixed a stray folded `echo` that turned into an argument to the stale-rollback
    assertion (dead line).
  * The hand-written-CLI contract in the guide now names init_cli_logger, the
    typed config-push path, and the pushed-store= output.
  * The rollback serialization guidance is honest: a repo-scoped concurrency group
    closes the race only when every mutation shares one deployment authority.
  * Spec: credential criterion covers all provider-calling steps; retries note the
    healthcheck probe; and the stale "no --version" claims are dropped (the
    template sets clap version).
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.

1 participant