Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303) - #316
Draft
aram356 wants to merge 77 commits into
Draft
Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303)#316aram356 wants to merge 77 commits into
aram356 wants to merge 77 commits into
Conversation
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.
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).
- 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.)
… into feature/edgezero-deploy-actions
… 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.
… into feature/edgezero-deploy-actions
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.
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).
This was referenced Jul 20, 2026
Open
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 cratein 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 bywrappers; 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; optionalstage: true; outputsfastly-version.healthcheck-fastly/rollback-fastly— Fastly staging lifecycle (paritywith
stackpop/trusted-server-actions), driven by the app CLI over the FastlyAPI.
CLI scaffolding (
edgezero-adapter-fastly+ downstream template):--stage,healthcheck/rollbacksubcommands, deployment-version output,--version.Design docs
docs/specs/edgezero-deploy-github-action.md— specdocs/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
(
actionlint/zizmorpinned binaries); third-party actions on readable tags;explicit Fastly build-in-deploy credential caveat;
provider-envscoped to thedeploy 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.