Skip to content

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

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

Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303)#316
aram356 wants to merge 77 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 17 commits July 17, 2026 22:44
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).
aram356 added 11 commits July 21, 2026 17:35
…o-token boundary

build-app-cli is layer 1 and provider-neutral, so hard-coding FASTLY_* aliases in
it was wrong: a job-level CLOUDFLARE_API_TOKEN (or any other provider secret) was
still in scope for app-controlled `cargo metadata`, `cargo build`, and the built
CLI's `--help`. It now takes a `provider-env-clear` input — a JSON array of names
the script UNSETS before any of that runs — so the names come from the caller,
not this layer. The default covers the adapters EdgeZero ships; callers with
other providers extend it. Five contract tests cover it, including that an
arbitrary caller-named alias is cleared (proving nothing is hard-coded) and that
a malformed name is a configuration error rather than a silent no-op.

Coverage for the production no-token boundary (previously untested, since it is
enforced in YAML): the lifecycle smoke now carries an inherited FASTLY_API_TOKEN
at job level, runs a PRODUCTION healthcheck with no token input, and asserts from
the call-log delta that the probe ran with no token in scope and still reported
healthy. A staging healthcheck with no token is asserted to fail fast.

Test fidelity: the fake Fastly API now lists every version it may be asked to
activate and REJECTS activating one it does not have (404), so a rollback smoke
can no longer "succeed" against a version that never existed.

Docs: the spec, plan, and adoption guide no longer claim every healthcheck
receives the token (production receives none); the config-push contract states
the wrapper requires BOTH `pushed-key=` and `pushed-store=` and that the resolved
store is always emitted; and the retry note covers production probes too.
…override

My local verification run of make-fake-fastly-env.sh repointed the checked-out
versions.json at a machine-local file:// archive — that is how the smoke drives
the fake CLI through the real download+verify path — and `git add -A` swept the
mutation into the previous commit. The installer gate then tried to fetch the
Fastly CLI from a laptop temp path and failed.

Restores the official https release URL and its SHA-256 (re-verified against the
upstream artifact), and adds a contract test that versions.json pins an official
https release URL and a 64-hex digest. Confirmed the guard fails on exactly the
bad state that shipped, so this cannot silently recur.
… not unset)

The previous "provider-neutral" rework traded a strong guarantee for a weak one.
Three problems, all fixed:

  * `unset` inside the running shell is not isolation. On Linux
    `/proc/<pid>/environ` still exposes the environment the process was execve'd
    with, so an app-controlled cargo build script (or the built CLI's --help)
    could read a token we had "unset". The script now RE-EXECS itself via
    `env -u <name>... exec` before any app-controlled command, giving this
    process, its /proc entry, and every descendant a genuinely clean environment.
    A test asserts the re-exec'd process sees the credential gone from both its
    env and (on Linux) its /proc entry.

  * `provider-env-clear` failed OPEN for valid non-array JSON. With `jq '.[]?'`,
    the values `"FASTLY_API_TOKEN"`, `{}`, `null`, and `123` all exit 0 and yield
    no names — silently clearing nothing while inherited credentials stayed in
    scope. Parsing now requires a JSON array of non-empty identifier strings and
    fails closed otherwise, and the failure propagates to abort the build (the
    validation runs in a command substitution, not a process substitution, so a
    rejected input can no longer let the caller exec with an empty strip list).
    The tests now cover every fail-open shape the old single "not-json" case
    missed.

  * A script-level scrub cannot reach a LATER step. The build and upload steps
    again blank the shipped adapters' aliases statically in their own `env:` — so
    even the build step's /proc entry starts clean, and the non-provider upload
    step no longer inherits job-level provider secrets. `provider-env-clear`
    remains the neutral extension point for a caller's own provider aliases.

Docs: `provider-env-clear` is now in the spec input table, the plan, and the user
guide (with the re-exec rationale and a custom-provider example); and the
remaining spots that still said every healthcheck receives the token now state a
production probe needs none (spec lifecycle/mapping/criterion, plan).
…entinel, control chars)

Three real gaps in the previous scrub, all fixed:

  * The re-exec cleaned only the script's own process. GitHub runs a `run:` body
    in a generated WRAPPER shell that launched the script as a child, so that
    wrapper still held a caller's custom provider secret at its execve environment
    (readable by app code via /proc/<ppid>/environ). The `run:` body now `exec`s
    the script, replacing the wrapper, so no dirtier ancestor shell survives.

  * The "already scrubbed" marker was an ENV VAR, which a job could inherit
    (EDGEZERO__PROVIDER__ENV_CLEARED=1) to skip validation and the re-exec
    entirely. It is now a sentinel ARGUMENT: a caller controls the job env but not
    the composite step's argv, so the bypass is gone.

  * provider-env-clear validation normalized malformed values instead of failing
    closed. `["A\nB"]` (escaped newline) reached `jq -r`, split into two names,
    and left the real variable untouched; an escaped NUL truncated under `$(...)`.
    Validation now runs entirely in `jq` with `\A...\z` anchors on the DECODED
    strings, so any control character is rejected before bash transport.

Also: the static shipped-alias blanking is restored on the build AND upload steps
(a script scrub cannot reach a later `uses:` step), and the docs (input, guide,
spec, plan) now describe the actual two-layer design — static shipped aliases in
the step env plus the dynamic `provider-env-clear` re-exec — and state honestly
that a custom alias is guaranteed stripped from the build step but not the
upload step (which runs no application code).

New tests cover: escaped-newline/NUL rejection, an inherited env sentinel not
bypassing validation, a child not reading the credential from the scrubbed
ancestor, fail-closed propagation, and that the action `exec`s the script. Fixes
a bash-3.2 source failure from a stray NUL byte in a comment along the way.
Two credential-boundary holes remained in build-app-cli, both fixed:

  * BASH_ENV / ENV name a file the shell SOURCES AT STARTUP — before the first
    line of the step's script, and so before the re-exec scrub. A caller's job env
    could point either at checkout-controlled code that would then run with the
    provider token still present AND could forge the re-exec sentinel argument
    (`set -- --edgezero-provider-env-cleared`) to skip the scrub entirely. Neither
    is clearable from inside the script for that reason; both are now blanked
    statically in the build step's env, so no startup file is sourced.

  * App-controlled build code could reach FORWARD out of the build step. A Cargo
    build script (or the built CLI) can append to $GITHUB_ENV / $GITHUB_PATH,
    whose lines the runner applies to LATER steps; e.g. LD_PRELOAD would then
    execute attacker code inside the Node-based upload step, which still holds a
    caller's own provider token. Every app-controlled command (cargo metadata,
    cargo build, the built CLI's --help) now runs under a run_untrusted wrapper
    that points $GITHUB_ENV/$GITHUB_PATH/$GITHUB_OUTPUT/$GITHUB_STATE at
    /dev/null, so a build script can neither inject into a later step nor forge
    this action's outputs. The script's own outputs are written from the parent,
    which keeps the real $GITHUB_OUTPUT.

Also: the malformed-input diagnostic no longer echoes the raw provider-env-clear
value (it could be an accidentally supplied secret), matching the spec's
"name the input without printing its value" rule; and two integration tests that
asserted only a non-zero exit — which the script also returns later on an unset
required variable, so they would pass even if the scrub were bypassed — now
assert the specific scrub diagnostic via a new assert_fails_with helper. New
tests cover the run_untrusted channel neutralization and that the static
BASH_ENV/ENV blanking is present.
…push, mutation signal, hygiene

Gaps a thin deployer would otherwise hand-roll — moved into the actions.

healthcheck-fastly probes an arbitrary path (production and staging):
  The CLI healthcheck hardcoded https://<domain>/. Added a --path flag
  (HealthcheckArgs -> passthrough -> the Fastly probe URL builder), defaulting
  to '/'. Staging reuses the same URL with its --connect-to reroute, so one flag
  covers both targets and no staging-IP output is needed. The action gains a
  `path` input (validated to begin with '/', no whitespace) threaded to --path.

config-push-fastly accepts inline config and controls the env overlay:
  A deployer whose config lives in a GitHub variable (no file on disk) can now
  pass `app-config-inline` (written to an action-owned temp file, removed on
  exit, passed by absolute path — no checkout confinement needed since the step
  owns the content) instead of `app-config` (the two are mutually exclusive). A
  `no-env` input passes the CLI's existing --no-env to skip the
  <APP_NAME>__…__<KEY> overlay. Both validated; no CLI change required.

deploy/config-push/rollback report a mutation even when the canonical line is lost:
  All three parse a canonical line (fastly-version / pushed-key / rolled-back-to)
  AFTER mutating the provider; a missing/malformed line made the action fail
  while the mutation had already happened, so a caller could believe nothing
  occurred. Each now writes `mutation-attempted=true` BEFORE invoking the CLI —
  it survives the (still loud) failure and is readable via `if: always()`, so a
  caller reconciles instead of assuming a no-op. Exposed as an action output on
  each.

Hygiene:
  * deploy-fastly and config-push-fastly now surface `provider-cli-version` (the
    installer already emitted it at the step level; config-push's install step
    gained an id).
  * The versions.json contract test additionally asserts the release URL's
    filename embeds the pinned version, so a version bump that forgets the URL
    (or an URL swapped to a different build) fails CI — on top of the existing
    official-https-host and 64-hex-SHA checks.
  * Every third-party action nested inside a composite action is pinned to a full
    commit SHA with a version comment (upload/download-artifact, cache
    restore/save, setup-rust-toolchain) — a consumer's SHA-pin of our action
    cannot freeze these child tags, so we freeze them. Design principle 9 updated
    to distinguish workflow-level tag pins from nested-action SHA pins.

Tests: Rust unit tests for the probe path (production + staging) and path
validation; bash contract tests for --path threading/validation (Linux-gated),
inline vs path exclusivity, --no-env, and the mutation-attempted signal firing
when the canonical line is absent (config-push cross-platform, rollback
Linux-gated); the output-contract test now covers all 18 declared outputs.
…false-fail

Three real defects plus the doc gaps that trailed the new lifecycle contracts.

  * The credential boundary was still bypassable. run_untrusted overrode the
    child's GITHUB_ENV, but the re-exec'd build-app-cli.sh process — the parent of
    every app-controlled command — kept the REAL $GITHUB_ENV/$GITHUB_PATH paths in
    its environment, readable through /proc/<ppid>/environ. A build script could
    recover the real $GITHUB_ENV path that way and append LD_PRELOAD directly to
    it, which the runner applies to a LATER step (the artifact upload) that still
    holds a caller's own provider token. The re-exec now strips GITHUB_ENV,
    GITHUB_PATH, and GITHUB_STATE from the whole process image (env -u), so no
    reachable ancestor holds their real paths. GITHUB_OUTPUT is deliberately kept
    (build-app-cli.sh emits its own outputs through it, and a forged output is
    consumed as data — it cannot inject executable behavior into a later step).
    The ancestor test now also asserts GITHUB_ENV is gone from /proc even though
    it is never named in provider-env-clear.

  * The inline config-push temp file was never removed. new_private_log installs
    its own EXIT trap, which REPLACED the inline-file cleanup trap, so the raw
    TOML survived in RUNNER_TEMP after both success and CLI failure. The combined
    cleanup is now re-installed after new_private_log; a test asserts nothing is
    left on either path.

  * Health probe paths were interpreted as curl URL globs. A valid path such as
    /health?ids[0]=1 passes validation but, without --globoff, curl treats the
    brackets as a glob — failing with exit 3 or firing multiple probes, and so
    mis-reporting a healthy deployment as unhealthy and triggering rollback.
    build_curl_probe_args now passes --globoff; tested with a bracketed path.

Docs: the implementation plan, adoption guide, and the spec's error table now
carry the new lifecycle surface — healthcheck `path`, config-push
`app-config-inline`/`no-env`, and the `provider-cli-version`/`mutation-attempted`
outputs — including how a caller reads `mutation-attempted` via `if: always()` to
reconcile a mutation whose canonical line was lost, rather than assume a no-op.
… cleanup, and docs

The credential boundary was still bypassable, and being honest about why.

  * Keeping GITHUB_OUTPUT was wrong. The runner stores GITHUB_ENV/OUTPUT/PATH/
    STATE/step-summary as files in ONE directory, so a build script that recovers
    the retained GITHUB_OUTPUT path (via /proc on the compile process) can derive
    the sibling GITHUB_ENV path and append LD_PRELOAD to the real file for a
    later, secret-bearing step — or append a duplicate tarball-path to hijack the
    upload. The build is now a TWO-STEP split: the compile step blanks EVERY
    file-command channel (and the re-exec strips all five from the process image)
    and writes its outputs to an action-owned handoff file; a separate publish
    step, which runs no app code, re-emits them to the real GITHUB_OUTPUT,
    re-validating each (first-occurrence-wins defeats a tampered duplicate;
    tarball-path is confined to the action-owned temp area).

    Being explicit: this closes the demonstrated /proc-derivation and
    duplicate-append vectors but is NOT a hard boundary — a fully malicious build
    runs at the same uid and can still enumerate $RUNNER_TEMP/_runner_file_commands
    directly. The docs now state the real rule plainly: never expose a provider
    secret to a step or job that also builds application code; scope tokens to the
    deploy/lifecycle steps, or run the build in a separate job / sandbox.

  * mutation-attempted could be true when the deploy CLI never ran. deploy.sh
    emitted it before run-app-cli.sh resolved the binary, imported credentials,
    and changed directory. The engine now prints a stable `cli-invoked` marker
    immediately before invoking the CLI, and deploy.sh emits the signal only when
    that marker is present — so a setup failure no longer claims a mutation, while
    a mutation that fails afterward still signals (captured, not aborted).

  * Sensitive-temp cleanup failures were swallowed. The private-log and inline-
    config EXIT traps used bare `rm -f`, so a leftover (which may hold provider
    output or raw config) passed silently, against the spec's cleanup requirement.
    A shared cleanup_sensitive_temps now surfaces a removal failure (::error +
    non-zero exit) while preserving any original error code.

  * The lost-version recovery guidance is now actionable: recover the current
    version via `active-version`, compare to the captured `previous-version`, and
    roll back with that version if they differ (guide, spec example, and adoption
    guide). Reconciled the plan's config-push contract (inline/no-env/new outputs).

Tests: the ancestor test now also proves GITHUB_ENV/GITHUB_OUTPUT are gone from a
scrubbed ancestor; new coverage for publish-outputs (first-wins, tarball
confinement, fail-closed), the compile step blanking every channel,
cleanup-failure escalation (with original-code preservation), and deploy
signal-timing (invoked -> signal; setup failure -> no signal).
… race, and boundary docs

  * mutation-attempted could be LOST on cancellation. Last change moved the emit
    after the run (gated on a marker) to avoid false positives — but that meant a
    cancel/timeout while the CLI was mutating published no signal, contradicting
    the "emitted before invocation" contract. The launcher (run-app-cli.sh) now
    writes it itself, immediately before invoking the CLI for a mutating (deploy)
    mode: after all setup (so a setup failure never falsely signals) and before
    the mutation starts (so it is durable through a cancel/timeout). deploy.sh no
    longer post-emits. A new test proves the signal is present in GITHUB_OUTPUT
    before the CLI returns.

  * The static GITHUB_*: "" isolation layer was ineffective and misleading. The
    runner reinjects reserved GITHUB_* values after step-env evaluation, so
    blanking them in the compile step's env did nothing. Removed those lines; the
    re-exec's `env -u` of all five channels is the sole, authoritative mechanism
    (proved at runtime by the scrubbed-ancestor test, which now also checks
    GITHUB_OUTPUT). Comment and test updated to say so.

  * Inline config had a cleanup race. new_private_log's EXIT trap replaced the
    inline-file trap, leaving a window where a cancel would strand raw config.
    new_private_log now runs first and the combined trap is installed BEFORE the
    (deterministically-named) inline file is created, so no file ever exists
    without a trap covering it.

Docs — recovery and the trust boundary made correct:
  * The same-job quick-start/adoption examples no longer contradict the boundary
    note. Reframed it around trust: building and deploying your own app (incl.
    dependencies you trust with the credential) in one job is fine; an UNTRUSTED
    build must be isolated in a separate job — with a runnable two-job example
    ("Isolating an untrusted build"). Notes the same-uid reality: command-storage
    access and orphan processes surviving into a later step.
  * Lost-version recovery is now scoped and executable. active-version returns the
    PRODUCTION-active version, so the recovery applies to production only; the
    snippet is now runnable (download+extract the CLI artifact, credentials, parse
    version into a step output). Staging gets its own note: a possibly-created
    draft is inactive and cleaned up manually — no automated staged recovery.
  * The normal production rollback example now guards BOTH fastly-version and
    previous-version, and routes a missing current version to the recovery path
    instead of calling rollback with an empty version.
…-bracket span

The lost-version recovery prose used a long inline command
(`<app-cli> active-version --adapter fastly --service-id <id>`). Prettier wrapped
that inline-code span across a line, and the unindented continuation broke the
span so VitePress/Vue parsed `<app-cli>`/`<id>` as unclosed HTML tags — failing
`vitepress build` (the docs "Validate docs" job) though prettier and eslint
passed. Shortened both spots to reference the `active-version` subcommand without
the full inline command (the guide already carries the runnable snippet in a
fenced block, which is not affected).
…undary docs

Code — rollback.sh:
  * It emitted mutation-attempted without first verifying the CLI exists, so a
    missing binary falsely claimed a rollback was attempted. It now resolves and
    require_cmd's the CLI BEFORE the signal (after setup, before the mutation, so
    it is still durable through a cancel).
  * It wrote rolled-back-to before checking the captured CLI exit status, so an
    output-write failure could replace the real provider result. It now surfaces
    the exit status first and only writes rolled-back-to on success — matching
    deploy.sh. Tests: a missing CLI does not signal; a failing CLI signals but
    writes no rolled-back-to.

Docs — the deploy-trust boundary made honest:
  * "Isolating an untrusted build" overpromised. Deploying an app RUNS its code
    with the provider token (the deploy executes the built CLI and, for Fastly's
    default build-mode, recompiles the source), so separate jobs cannot make an
    untrusted app safe to deploy. Reframed as "Keeping the credential out of the
    build phase": you must trust the app you deploy; job separation only narrows
    when the token is present (out of the compile-heavy build), which is
    blast-radius reduction, not a way to deploy code you do not trust. Adoption
    guide + the build-app-cli security note aligned.

Docs — recovery corrected:
  * The recovery/rollback conditions used failure(), which does NOT fire on
    cancellation — the very case the durable signal was added for. They now use
    failure() || cancelled().
  * First-ever production deploy: the rollback condition did not require
    previous-version, so it would call rollback with an empty rollback-to (which
    the CLI rejects). It now guards previous-version and documents that a
    first-ever deploy has no rollback target — undo it manually.
  * Staging recovery was misclassified: the CLI runs `service-version stage`
    before emitting the version, so a lost-version staged failure may leave a
    version ALREADY STAGED (serving staging traffic), not an inactive draft.
    Corrected in the guide, spec, and adoption guide.
  * The recovery snippet now uses a literal app-cli-artifact name so it works in
    the separate-job layout (steps.cli.* does not cross job boundaries).

Docs — smaller corrections:
  * Spec and plan showed typed deploy-flags AFTER `--`; the engine puts typed
    flags BEFORE `--` and caller passthrough after. Fixed both.
  * The rollback error-table row no longer claims a failed rollback "was not
    rolled back" (the active version may be indeterminate); it now says to surface
    the exit status first and reconcile via mutation-attempted.
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