From b946250c858352c009605647aa09ed4bdc37f144 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:05:03 -0700 Subject: [PATCH 01/26] Design: layered EdgeZero deploy actions with Fastly staging lifecycle 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. --- ...ezero-deploy-action-implementation-plan.md | 201 +++++ docs/specs/edgezero-deploy-adoption-guide.md | 249 ++++++ docs/specs/edgezero-deploy-github-action.md | 787 ++++++++++++++++++ 3 files changed, 1237 insertions(+) create mode 100644 docs/specs/edgezero-deploy-action-implementation-plan.md create mode 100644 docs/specs/edgezero-deploy-adoption-guide.md create mode 100644 docs/specs/edgezero-deploy-github-action.md diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md new file mode 100644 index 00000000..d3b7c0e8 --- /dev/null +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -0,0 +1,201 @@ +# EdgeZero Deploy GitHub Actions Implementation Plan + +**Status:** Revised plan (layered, adapter-independent) + +**Spec:** `docs/specs/edgezero-deploy-github-action.md` + +## Scope + +Implement the layered deploy actions in the EdgeZero monorepo: + +```text +.github/actions/build-cli +.github/actions/deploy-core +.github/actions/deploy-fastly +.github/actions/healthcheck-fastly +.github/actions/rollback-fastly +``` + +`build-cli` compiles the app-provided CLI package once and publishes it as an +artifact. `deploy-core` is the adapter-independent deploy engine that consumes +the prebuilt CLI. `deploy-fastly` is a minimal wrapper that types Fastly +credentials and calls the engine, with an optional `stage` mode. `healthcheck-fastly` +and `rollback-fastly` are thin Fastly-specific wrappers for the staging lifecycle +(§5.4). Provider orchestration (build, deploy, config push, provision, stage, +healthcheck, rollback) stays in the CLI. + +The actions deploy already checked-out application source. They do not perform +checkout or runtime config mutation as a deploy side effect. Fastly staging, +health checks, and rollback are provided as provider-specific actions (§5.4) +driven by the app CLI; the generic engine stays neutral. + +## Porting from the existing #303 action (reference) + +This design **supersedes** the monolithic Fastly action from #303 +(`.github/actions/deploy/`). That branch is not the base; its scripts are a +reference to port from. Most transfer with light changes: + +| Existing `.github/actions/deploy/` | New home | Disposition | +| -------------------------------------------- | ------------------------------- | --------------------------------------------------------------- | +| `scripts/common.sh` | `deploy-core/scripts/` | Reuse ~as-is (annotation escaping, helpers). | +| `scripts/cleanup.sh` | `deploy-core/scripts/` | Reuse. | +| `scripts/write-summary.sh` | `deploy-core/scripts/` | Reuse; update summary field names. | +| `scripts/validate-inputs.sh` | `deploy-core/scripts/` | Reuse; move Fastly-specific allowlist to the wrapper. | +| `scripts/resolve-project.sh` | `deploy-core/scripts/` | Reuse + split Git root vs Cargo workspace root. | +| `scripts/install-rust.sh` | shared | Reuse; parameterize (build-cli host-only; engine adds target). | +| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | +| `tests/run.sh` | `deploy-core/tests/` | Reuse the harness; add new cases. | +| `scripts/install-fastly.sh`, `versions.json` | `deploy-fastly/` | Move (provider-specific install + checksum). | +| `scripts/install-edgezero.sh` | → `build-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | +| `action.yml` (one composite) | `build-cli/` + `deploy-fastly/` | Split into build + wrapper; engine is sourced scripts. | +| `.github/workflows/deploy-action.yml` | same path | Rewrite: de-Python, repin actions to tags. | +| cache `uses: actions/cache@` | `actions/cache@v4` | Repin to readable tag. | + +## Implementation phases + +1. **`build-cli` action** + - Required `cli-package` input: the Cargo package name of the CLI defined in + the application's own workspace. Fail if missing or not found in the app + workspace under `working-directory`. + - `working-directory`, `rust-toolchain`, `artifact-name` inputs (no `adapters` + input — the app's `Cargo.toml` pins adapters). + - Optional `cli-bin` input (default = `cli-package`; the generated CLI names + its bin after the package). + - Require a `Cargo.lock` at the app's Cargo workspace root; run all Cargo + commands with `--locked` (never mutate the lockfile). Validate via + `cargo metadata --locked` that `cli-package` exists and declares a + `` binary target. + - Install the host toolchain (no WASM target — the CLI is a native tool); + build into an **action-owned `CARGO_TARGET_DIR` under `RUNNER_TEMP`** (never + the checkout) so the CLI build leaves the app tree clean for the later + dirty-source guard, via + `cargo build --locked --release -p --bin `. No + `--features` injection: the app's own `Cargo.toml` pins its adapters. + - Read `cli-version` from `cargo metadata`; smoke-check with ` --help` + (today's CLI has no `--version`); write `cli-meta.json` (`cli-bin`, + `cli-version`, `cli-package`) next to the binary and upload both as one + **tar** so the executable bit survives `actions/upload-artifact` and the + artifact is self-describing. + - Outputs: `cli-version`, `cli-package`, `cli-bin`, `artifact-name`. + - No provider credentials in scope. Never builds the EdgeZero monorepo CLI. + +2. **`deploy-core` shared engine scripts (provider-neutral)** + - A directory of scripts under `.github/actions/deploy-core/`, **not** a + standalone composite action. Wrappers source them via + `$GITHUB_ACTION_PATH/../deploy-core/…`. + - Parameters (via env from the wrapper): `adapter`, `cli-artifact`, `cli-bin`, + `working-directory`, `manifest`, `rust-toolchain`, `target`, `build-mode`, + `build-args`, `deploy-args`, `deploy-arg-allow`, `provider-env`, + `provider-env-clear`, `deploy-flags`, `cache`. + - Download the CLI artifact (tar) under `RUNNER_TEMP`, extract preserving the + executable bit (or `chmod +x `), read `cli-meta.json` for + `cli-bin`/`cli-version` (wrapper `cli-bin` overrides), and PATH-scope it. + - Validate `adapter` well-formedness (no compiled-adapter enumeration — the + CLI rejects unknown adapters itself), booleans, JSON arrays/object, NUL + bytes. + - Confine `working-directory` and `manifest` inside `github.workspace` + (canonical paths, symlink resolution). + - Resolve **Git root** for `source-revision` and the dirty-source guard + (`build-cli`'s isolated target dir keeps this clean). + - Resolve the **Cargo workspace root** (`cargo locate-project --workspace`) + for lockfile hashing and `target/` caching — in a monorepo this may be under + `working-directory`, not the Git root (spec §11.1). + - Resolve Rust toolchain (explicit → Rustup files → `.tool-versions` → repo + fallback) and application `target` (from `adapter` when `auto`). + - Optional exact-key cache of the **Cargo workspace root** `target/` + restore/save. + - Resolve `build-mode`; optional credential-free build. + - Non-deploy steps: unset the `provider-env-clear` names. + - Deploy step: clear the `provider-env-clear` aliases, export only + `provider-env`, then run + ` deploy --adapter -- ` via + Bash arrays. Note the build-in-deploy caveat: Fastly's default `never` + compiles the app during deploy with the token in scope, so require trusted + immutable refs (spec §10.1). + - Surface results to the wrapper: `adapter`, `source-revision`, `cli-version`, + `effective-build-mode`. + - Contains no provider-specific credential names, service concepts, endpoints, + or CLI flags; invokes ``, never a hard-coded `edgezero`. + +3. **`deploy-fastly` wrapper (minimal composite action)** + - Typed inputs: `cli-artifact`, `cli-bin`, `fastly-api-token`, + `fastly-service-id`, plus forwarded `working-directory`, `manifest`, + `build-mode`, `build-args`, `deploy-args`, `cache`, and `stage` (§5.4). + - Map `fastly-api-token` → `provider-env: {FASTLY_API_TOKEN: …}` and + `fastly-service-id` → action-owned + `deploy-flags: ["--service-id", …, "--non-interactive"]`; when + `stage: true`, add `--stage`. + - Set `adapter: fastly`, `target: wasm32-wasip1`, `deploy-arg-allow` = + `--comment` only, `provider-env-clear` = Fastly auth/endpoint aliases + (`FASTLY_API_TOKEN`, `FASTLY_SERVICE_ID`, `FASTLY_ENDPOINT`, …), Fastly + `auto` build-mode → `never`. + - Output `fastly-version` (parsed from the app CLI). Source the shared + `deploy-core` scripts; no build, toolchain, or path logic of its own. + +4. **Fastly staging lifecycle actions (§5.4)** + - `healthcheck-fastly`: thin wrapper — inputs `cli-artifact`, `cli-bin`, + `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, + `deploy-to` (`production`/`staging`), retry/timeout; runs + ` healthcheck --adapter fastly …`; outputs `healthy`, `status-code`. + - `rollback-fastly`: thin wrapper — inputs `cli-artifact`, `cli-bin`, + `fastly-api-token`, `fastly-service-id`, `fastly-version`, `deploy-to`; + runs ` rollback --adapter fastly …`; outputs `rolled-back-to`. + - Both reuse only the CLI-artifact download + credential-scoping helpers from + `deploy-core`; no source resolution, toolchain, build, or cache. Carry no + orchestration policy — the caller wires stage → healthcheck → rollback. + +5. **Scripts layout** + - Provider-neutral scripts under `deploy-core/`; the Fastly install + checksum + step lives with `deploy-fastly/` (or a shared script keyed by adapter). + - No CLI-build script here — CLI build lives entirely in `build-cli`. + +6. **CI workflow (`.github/workflows/deploy-action.yml`) — no Python** + - Pin third-party actions to readable released tags (`actions/checkout@v4`, + `actions/cache@v4`, artifact upload/download at released tags). + - Run `actionlint` from a pinned release binary (no `go run @`). + - Run `zizmor` from a pinned release binary or `cargo install zizmor --locked` + (no `pip`). + - Port the metadata-validation heredocs into `tests/run.sh`. + - Composite smoke test: `build-cli` → `deploy-fastly` (both production and + `stage: true`) → `healthcheck-fastly` → `rollback-fastly` with fake `fastly` + binaries writing marker files; assert CLI-artifact reuse, credential + scoping, and version threading. + +7. **Bash contract tests (`tests/run.sh`)** + - Cover engine + wrappers: adapter/boolean/JSON validation, path confinement, + symlink escape, dirty source, toolchain precedence, cache keys, credential + scoping, deploy-arg allowlist, build/deploy argv, cleanup, log redaction, + metadata contract checks, and the staging lifecycle (stage flag, version + output parsing, healthcheck/rollback argv, staging vs production paths). + - No Python; no live provider credentials. + +8. **Companion CLI scaffolding (`crates/edgezero-cli`, `edgezero-adapter-fastly`)** + - Add `#[command(version)]` to the downstream CLI template + (`crates/edgezero-cli/src/templates/cli/src/main.rs.hbs`) so generated app + CLIs expose `--version`. Until adopted, `build-cli` reads the version from + `cargo metadata` and smoke-checks with `--help`. + - Fastly staging deploy: extend the Fastly adapter `deploy` path with + `--stage` → `fastly compute update --autoclone --version=active` + + `fastly service-version stage`; emit the service version in a parseable form. + - Add `healthcheck` and `rollback` CLI subcommands with a Fastly adapter + implementation (staging-IP resolution via the Fastly API + `curl`; activate + previous / deactivate staged), and wire `Healthcheck` / `Rollback` arms into + the downstream CLI template so app CLIs expose them. + +9. **Docs** + - Rewrite `docs/guide/deploy-github-actions.md` around the three-layer model, + general EdgeZero-app-repo adoption, and the Fastly staging lifecycle. + - Document the app-provided CLI package build, artifact reuse, credential + scoping, adapter layering, staging trio, and non-goals. + +10. **Validation** + - Bash contract tests, `actionlint`, `shellcheck`, `zizmor`, checksum + metadata, docs validation, composite smoke test. + - Workspace Rust tests, format, clippy, and feature checks. + +## Known follow-up candidates + +- Add `deploy-cloudflare` / `deploy-spin` wrappers via the same engine. +- Add provider-specific staging/health-check/rollback as separate actions. +- Optionally consume a prebuilt/attested CLI binary matching the app's pinned + version instead of compiling from source. diff --git a/docs/specs/edgezero-deploy-adoption-guide.md b/docs/specs/edgezero-deploy-adoption-guide.md new file mode 100644 index 00000000..fcff0f15 --- /dev/null +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -0,0 +1,249 @@ +# EdgeZero Deploy Actions — Adoption Guide + +**Status:** Adoption guide for any EdgeZero application repository + +**Spec:** `docs/specs/edgezero-deploy-github-action.md` + +The layered deploy actions are for **any** EdgeZero application repository, not a +single deployer. This guide describes the general adoption shape and then walks +through the Trusted Server deployer as one concrete migration example. + +## 1. What a consumer gets + +Composable actions: + +- `build-cli` — compile the CLI package the application provides (a crate in the + app's own workspace) once, publish it as an artifact; +- `deploy-fastly` — deploy a checked-out Fastly application using the prebuilt + CLI artifact, to production or (with `stage: true`) a staged draft version; +- `healthcheck-fastly` / `rollback-fastly` — the Fastly staging lifecycle (§4); +- future `deploy-cloudflare` / `deploy-spin` wrappers over the same engine. + +The actions own repeatable deploy setup and the Fastly staging mechanisms. The +consumer owns checkout, ref selection, permissions, environments, concurrency, +timeouts, and **orchestrating** the health-check / rollback flow. + +## 2. Checkout layouts + +The adapters your CLI supports come from your app's own `Cargo.toml`, so +`build-cli` takes no `adapters` input — it builds your CLI package as declared. + +### 2.1 Same-repository application + +The app and its deploy workflow live in one repo. + +```yaml +jobs: + deploy: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - id: cli + uses: stackpop/edgezero/.github/actions/build-cli@ + with: + cli-package: my-app-cli # the CLI crate in your app's workspace + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +### 2.2 Separate deployer and application repositories + +A deployment repo drives a separate application repo. Check the app into a path +and point both actions at it. + +> **Private app repos need their own token.** The deployer job's default +> `GITHUB_TOKEN` cannot read a _different_ private repository. Mint an app-scoped +> token first — e.g. with `actions/create-github-app-token` for a GitHub App +> installed on the app repo, or a fine-grained PAT with `contents: read` — and +> pass it to the application checkout's `token:`. The step below assumes an +> earlier `id: app-token` step produced `steps.app-token.outputs.token`. + +```yaml +steps: + - name: Checkout deployer + uses: actions/checkout@v4 + with: + path: deployer + persist-credentials: false + + - name: Checkout application + uses: actions/checkout@v4 + with: + repository: stackpop/my-edgezero-app + ref: ${{ inputs.ref }} + path: app + persist-credentials: false + # A private app repo is NOT readable with the deployer's default + # GITHUB_TOKEN. Supply a token scoped to the app repo — a GitHub App + # installation token (preferred) or a fine-grained PAT with + # `contents: read` on the app repo: + token: ${{ steps.app-token.outputs.token }} + + - id: cli + uses: stackpop/edgezero/.github/actions/build-cli@ + with: + cli-package: my-app-cli + working-directory: app + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + working-directory: app + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +### 2.3 Monorepo application + +Select the app subdirectory and, when needed, an explicit manifest. Caching +resolves `target/` and `Cargo.lock` at the **Cargo workspace root** for that +subdirectory (which in a nested workspace may be the subdirectory itself, not the +repo root), so a monorepo caches the right artifacts. + +```yaml +steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - id: cli + uses: stackpop/edgezero/.github/actions/build-cli@ + with: + cli-package: api-cli + working-directory: apps/api + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + working-directory: apps/api + manifest: edgezero.toml + cache: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## 3. Consumer requirements + +- Check out application source yourself; the actions never call + `actions/checkout`. +- Provide a CLI package in your own workspace and name it via `cli-package`; + `build-cli` compiles that package from your checkout, so the CLI and your app + never disagree on schema. `build-cli` does not use the EdgeZero monorepo CLI. +- Provide typed provider credentials through wrapper inputs, not caller `env:`. +- Ensure the deployed ref has committed source (no dirty working tree) and a + `Cargo.lock` at your app's **Cargo workspace root** (the workspace that owns + `cli-package` — in a nested-workspace monorepo this may be your app + subdirectory, not the repo root). `build-cli` requires it, and caching keys on + it. +- Pin action references to readable released tags, or full SHAs for production + reproducibility. +- Use least-privilege permissions (`contents: read`), protected environments, + `timeout-minutes`, and appropriate concurrency. + +## 4. Fastly staging lifecycle + +For Fastly, staging deploy, health checks, and rollback are supported as a +provider-specific trio, scaffolded into the CLI and exposed through your app CLI: + +- `deploy-fastly` with `stage: true` — deploy to a **staged** draft version + (Fastly `service-version stage`) instead of activating production; outputs + `fastly-version`. +- `healthcheck-fastly` — verify a version; for staging it resolves the Fastly + staging IP and probes the staged version specifically. +- `rollback-fastly` — production: activate the previous version; staging: + deactivate the staged version. + +You wire the trio; the actions carry no orchestration policy (see the spec §5.4 +for a worked staging workflow). + +## 5. What the actions intentionally do not do + +The deploy actions do not perform: internal application checkout; config +expansion or JSON→provider config conversion. Config push and provisioning are +explicit CLI subcommands (`edgezero config push`, `edgezero provision`) a +consumer may run as separate steps, not deploy side effects. The generic engine +stays provider-neutral — staging/health/rollback exist only as Fastly-specific +actions (§4), not engine behavior. + +## 6. Worked example — Trusted Server deployer migration + +### 6.1 Current behavior + +The Trusted Server deployer orchestrates Fastly deploys with: + +- `.github/workflows/deploy.yml` (manual) and `daily-deploy.yml` (scheduled); +- `stackpop/trusted-server-actions/fastly/deploy@v2`; +- `stackpop/trusted-server-actions/fastly/healthcheck@v2`; and +- `stackpop/trusted-server-actions/fastly/rollback@v2`. + +The old deploy action checks out Trusted Server internally, accepts +`trusted-server-ref`, expands `trusted-server-config`, supports Fastly staging, +and returns `fastly-version`. + +### 6.2 Compatibility with the EdgeZero actions + +The EdgeZero actions now cover Fastly **staging, health checks, rollback, and the +`fastly-version` output**, so the Trusted Server deployer can move off the legacy +`fastly/deploy|healthcheck|rollback@v2` actions. The remaining differences the +deployer must handle itself are: internal checkout, `trusted-server-ref`, +`trusted-server-config` expansion, and legacy JSON→Config Store TOML conversion. + +### 6.3 Recommended migration + +Map the legacy trio onto the EdgeZero staging trio: + +| Legacy action | EdgeZero replacement | +| ------------------------------------------ | ---------------------------------------------- | +| `fastly/deploy@v2` (with `fastly-staging`) | `build-cli` + `deploy-fastly` (`stage:` input) | +| `fastly/healthcheck@v2` | `healthcheck-fastly` | +| `fastly/rollback@v2` | `rollback-fastly` | + +Workflow shape: + +1. check out `trusted-server-deployer` with `persist-credentials: false`; +2. check out Trusted Server source separately at the selected ref into + `trusted-server`; +3. run `build-cli` with `cli-package: ` and + `working-directory: trusted-server` (Trusted Server's own CLI package, whose + `Cargo.toml` already pins the Fastly adapter); +4. run `deploy-fastly` (set `stage: true` for staging) with the CLI artifact, + `working-directory: trusted-server`, typed Fastly credentials, and optional + `deploy-args: ["--comment", …]`; capture `fastly-version`; +5. run `healthcheck-fastly` with `deploy-to`, `domain`, and the captured + `fastly-version`; +6. on failure, run `rollback-fastly` with the same `deploy-to`/`fastly-version`; + and +7. write a summary from the action outputs. + +### 6.4 Required deployer changes + +- Add explicit Trusted Server checkout; the EdgeZero actions do not call + `actions/checkout`. +- Replace the legacy `fastly/*@v2` trio with `build-cli` + `deploy-fastly` + + `healthcheck-fastly` + `rollback-fastly`. +- Pin action references to readable released tags, or full SHAs for production. +- Read the version from `steps..outputs.fastly-version` (same concept as + the legacy `fastly-version`). +- Audit `TRUSTED_SERVER_CONFIG`; if still needed, keep config expansion in the + deployer workflow or run `edgezero config push` as an explicit step before or + after deploy. +- Confirm the canonical Trusted Server repository/ref has a `Cargo.lock` at the + CLI package's Cargo workspace root, plus `Cargo.toml`, `fastly.toml`, and + preferably `edgezero.toml`. + +### 6.5 Gotchas + +- `daily-deploy.yml` appears to stage but health-check/rollback production by + default. Decide whether the scheduled workflow is production or staging and set + `deploy-to` / `stage` consistently before migration. +- The old action targets `IABTechLab/trusted-server`; verify the actual + deployment refs before switching. diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md new file mode 100644 index 00000000..4781003f --- /dev/null +++ b/docs/specs/edgezero-deploy-github-action.md @@ -0,0 +1,787 @@ +# EdgeZero Deploy GitHub Actions — Layered, Adapter-Independent Spec + +**Status:** Revised design (supersedes the Fastly-only v0 spec) + +**Date:** 2026-07-08 + +**Delivery target:** implementation in the `stackpop/edgezero` monorepo + +**Action paths:** + +```text +.github/actions/build-cli +.github/actions/deploy-core +.github/actions/deploy-fastly +.github/actions/healthcheck-fastly +.github/actions/rollback-fastly +``` + +## 1. Executive summary + +Ship reusable GitHub composite actions that deploy a checked-out EdgeZero +application to a supported provider by driving the EdgeZero CLI. + +The design is **layered** so a new adapter is added without rewriting the deploy +engine, and the **CLI is the boundary** — the actions never reproduce provider +build, deploy, config-push, or provision logic in YAML or shell. Everything an +adapter needs (`edgezero build`, `edgezero deploy`, `edgezero config push`, +`edgezero provision`) already lives in the CLI; the actions only compile the +CLI, scope credentials, and invoke the right subcommand. + +The CLI is **the app's own CLI package.** The application tells `build-cli` which +CLI package to compile (a crate in the application's own workspace), and +`build-cli` builds it from the application checkout. It is **not** the EdgeZero +monorepo CLI and **not** the action's own repository revision. Because the CLI is +the app's own package, built from the app's source and lockfile, the CLI and the +application always agree on the manifest, adapter, and config schema — and an app +may ship a CLI extended with its own commands. + +Three layers: + +| Layer | Action | Responsibility | +| --------------- | ------------------- | ------------------------------------------------------------------------------------- | +| Build | `build-cli` | Compile the app-provided CLI package **once** and publish it. | +| Engine (shared) | `deploy-core` | Adapter-independent engine **scripts** sourced by wrappers; consume the prebuilt CLI. | +| Adapter wrapper | `deploy-fastly` (…) | Minimal per-adapter shim: type provider credentials, call the engine. | + +The actions do not check out application source. The caller owns checkout, +repository permissions, ref selection, GitHub Environment policy, concurrency, +timeouts, and **orchestrating** the health-check / rollback process (the Fastly +mechanisms themselves are provided as actions — §5.4). + +The core boundary is EdgeZero itself: + +```text + build --adapter + deploy --adapter +``` + +where `` is the application's own CLI binary built by `build-cli`. + +The generic engine stays provider-neutral. Provider-specific staging, health +checks, and rollback are supported for Fastly as a separate lifecycle (§5.4) — +scaffolded into the EdgeZero CLI's Fastly adapter and exposed through the app's +own CLI, with thin action wrappers — so the engine never grows provider logic. + +## 2. Design principles + +1. **EdgeZero CLI is the deployment boundary.** The actions invoke CLI + subcommands instead of reproducing provider logic. Provider orchestration + belongs in the CLI, not in action shell scripts. +2. **Layered and adapter-independent.** A generic `deploy-core` engine holds all + provider-neutral behavior. Adapter wrappers are minimal and only supply typed + credentials and the adapter name. Adding an adapter adds a wrapper; it does + not fork the engine. +3. **The caller owns source.** The actions never call `actions/checkout`. +4. **The application provides the CLI package.** The app tells `build-cli` which + CLI package to compile via a required `cli-package` input, and `build-cli` + builds that package from the application's own checkout. It never builds the + EdgeZero monorepo CLI or the action's own repository revision. The + application owns which CLI deploys it. +5. **Compile once, reuse everywhere.** `build-cli` compiles the CLI a single + time per workflow and publishes it as an artifact. Deploy actions consume the + prebuilt binary and never recompile it. +6. **Typed provider credentials.** Credentials are passed through wrapper action + inputs, not caller `env:`, so setup and build steps never inherit provider + tokens. Only the provider mutation step receives them. +7. **No shell string APIs.** Passthrough arguments are JSON arrays invoked + through Bash arrays without `eval`. +8. **No Python in tooling or CI.** Validation, metadata checks, and security + scans run through Bash, `jq`, and pinned release binaries (`actionlint`, + `zizmor`). No `python3` heredocs and no `pip install`. +9. **Pin third-party actions to readable released tags.** Reusable third-party + actions are referenced by their published major/minor tag (for example + `actions/checkout@v4`), not opaque commit SHAs chosen ad hoc. +10. **Safe by default.** Caching is opt-in, deploys require committed source, and + provider credentials never reach outputs, summaries, caches, or + action-global environment files. + +## 3. Goals + +1. Deploy any checked-out EdgeZero application from GitHub Actions through the + EdgeZero CLI. +2. Keep the deploy engine adapter-independent so Cloudflare, Spin, and future + adapters reuse it. +3. Compile the CLI package the application provides, from the application's own + source, so the CLI and the deployed application never disagree on schema. +4. Compile the CLI once and share the artifact across deploy steps and jobs. +5. Support same-repository, separate-repository, private-repository, and + monorepo checkout layouts. +6. Respect the application's `edgezero.toml` when present and support explicit + `working-directory` and `manifest` selection. +7. Accept typed provider credentials and expose them only to the provider + mutation step. +8. Support JSON-array build and deploy passthrough arguments. +9. Support opt-in, exact-key application `target/` caching. +10. Produce actionable validation failures before deployment begins. +11. Keep all tooling and CI free of Python; use pinned release binaries. + +## 4. Non-goals + +The **generic** engine (`deploy-core`) will not: + +1. check out application source; +2. choose an application ref; +3. deploy more than one adapter per `deploy-*` invocation; +4. provision provider resources or push runtime config as a side effect of + deploy (these remain explicit CLI subcommands the caller may run separately); +5. implement provider staging, health checks, rollback, or deployment-version + parsing **in the provider-neutral engine** — these are provider-specific and + live in the Fastly staging lifecycle actions (§5.4), driven by the app CLI; +6. configure GitHub job permissions, environments, approvals, concurrency, or + timeouts; +7. support Windows or macOS runners; +8. publish a stable version alias; or +9. provide a general `setup` action for running arbitrary EdgeZero commands + (the CLI is available via the `build-cli` artifact for callers who need it). + +Staging deploy, health checks, and rollback **are** supported for Fastly, as a +provider-specific lifecycle (§5.4). The engine stays neutral; the capability is +scaffolded into the EdgeZero CLI's Fastly adapter and exposed through the app's +own CLI, with thin action wrappers. + +## 5. Architecture + +### 5.1 Layer 1 — `build-cli` + +Compiles the **CLI package the application provides** — a crate in the +application's own workspace, named by the required `cli-package` input — once, +and publishes it as a workflow artifact so every downstream deploy step consumes +the same binary. The CLI is built from the application checkout and its lockfile, +so it matches the application and may include app-specific commands. `build-cli` +never builds the EdgeZero monorepo CLI. + +**Inputs** + +| Input | Required | Default | Contract | +| ------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `cli-package` | Yes | none | Cargo package name of the CLI to build, defined in the application's own workspace. `build-cli` builds this package from the application checkout. | +| `cli-bin` | No | `` | Binary name produced by `cli-package`. Defaults to the package name (the generated downstream CLI names its bin after the package). | +| `working-directory` | No | `.` | Application directory (relative to `github.workspace`) containing the workspace/lockfile that defines `cli-package`. Must resolve inside `github.workspace`. | +| `rust-toolchain` | No | `auto` | Explicit toolchain, or `auto` to follow the application toolchain resolution precedence (§7). | +| `artifact-name` | No | `edgezero-cli` | Name of the uploaded artifact. | + +There is intentionally no `adapters` / features input. The application's own +`Cargo.toml` already pins which adapters compile into its CLI (through the +`edgezero-cli` dependency it declares); `build-cli` builds the package exactly as +the application declares it, so the app owns adapter selection. + +**Outputs** + +| Output | Meaning | +| --------------- | -------------------------------------------------------------- | +| `cli-version` | CLI package version, read from `cargo metadata` at build time. | +| `cli-package` | The application CLI package that was built. | +| `cli-bin` | The binary name inside the artifact. | +| `artifact-name` | Name of the uploaded CLI artifact for downstream `download`. | + +**Behavior** + +1. Require a `Cargo.lock` at the app's Cargo workspace root (see §11.1); fail + with a remediation message if it is missing. All Cargo commands run with + `--locked` so the build never creates or updates the lockfile. +2. Confirm via `cargo metadata --locked` that `cli-package` exists in the + application workspace under `working-directory` and that it declares a binary + target named `` (default ``). Fail if either is absent. +3. Install the resolved host Rust toolchain (§7). The CLI is a native host tool; + the WASM target needed to build the _application_ is installed later by the + deploy engine, not here. +4. Build **into an action-owned `CARGO_TARGET_DIR` below `RUNNER_TEMP`**, never + the app checkout, so the CLI build does not write `target/` into the + application working tree (which the deploy engine later dirty-checks): + + ```text + CARGO_TARGET_DIR=/edgezero-cli-build \ + cargo build --locked --release -p --bin + ``` + +5. Read `cli-version` from `cargo metadata` for `cli-package`, and smoke-check + the binary with ` --help` (today's CLI has no `--version`; see the + note below). +6. Write a small metadata file (`cli-meta.json`) next to the binary containing + `cli-bin`, `cli-version`, and `cli-package`. +7. Upload the binary **and `cli-meta.json`** as a single **tar archive** so the + executable bit survives the round trip (`actions/upload-artifact` zips and + drops POSIX permissions). + +The artifact is self-describing: the engine reads `cli-meta.json` to learn the +binary name and version, so callers do not have to re-pass `cli-bin`/`cli-version` +(a wrapper `cli-bin` input, if given, overrides the metadata). + +`build-cli` never receives provider credentials and leaves the app checkout +clean (no `target/`, no lockfile mutation), so a later dirty-source guard passes. + +> **Companion CLI improvement (tracked separately):** the generated downstream +> CLI template currently sets no clap `version`, so ` --version` fails. Add +> `#[command(version)]` to the downstream CLI template so future apps expose a +> version surface. Until then, `cli-version` comes from `cargo metadata` and the +> runnability check uses `--help`. + +### 5.2 Layer 2 — `deploy-core` (shared engine scripts) + +Adapter-independent. Holds every provider-neutral concern so wrappers stay +minimal. `deploy-core` is **not a standalone composite action**; it is a +directory of shared scripts under `.github/actions/deploy-core/`. Each adapter +wrapper is the real composite action and sources these scripts through +`$GITHUB_ACTION_PATH/../deploy-core/…`, which resolves inside the same fetched +EdgeZero action repository. This avoids referencing a "private" sibling action by +ref and keeps one engine for every adapter. + +The engine is parameterized by the values the wrapper passes to those scripts +(as environment variables), conceptually: + +| Parameter | Meaning | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `adapter` | Passed to ` deploy --adapter `. The engine does **not** enumerate compiled adapters; the CLI rejects an unknown adapter with its own error. | +| `cli-artifact` | Name of the `build-cli` artifact to download. The engine reads `cli-bin` and `cli-version` from the artifact's `cli-meta.json`. | +| `cli-bin` | Optional override for the binary name; if empty, taken from `cli-meta.json`. | +| `working-directory` | Application directory relative to `github.workspace`. Must resolve inside `github.workspace`. | +| `manifest` | Optional `edgezero.toml` path relative to `working-directory`. If set, must exist; exported as `EDGEZERO_MANIFEST`. | +| `rust-toolchain` | Application Rust toolchain for the deploy build. `auto` follows §7. | +| `target` | Application build target. `auto` derives it from `adapter` (for example `fastly` → `wasm32-wasip1`). | +| `build-mode` | One of `auto`, `always`, `never` (§8). | +| `build-args` | JSON array of strings passed after ` build --adapter --`. Must not contain secrets. | +| `deploy-args` | JSON array of caller-supplied deploy args appended after action-owned deploy flags. Must not contain secrets. | +| `deploy-arg-allow` | Adapter allowlist pattern for caller `deploy-args` (wrapper-provided; §9). | +| `provider-env` | JSON object of provider credential names → values. Present **only in the deploy step's own environment** (step-scoped `env:`); the variable never reaches setup/build steps, and the engine parses it only inside the deploy step (§10). | +| `provider-env-clear` | JSON array of env var names (wrapper-provided) the engine unsets in non-deploy steps and clears + re-exports from `provider-env` inside the deploy step (§10). Defense-in-depth against inherited caller `env:`; keeps clearing provider-agnostic. | +| `deploy-flags` | JSON array of action-owned deploy flags the wrapper injects before caller `deploy-args` (`--service-id …`, `--non-interactive`). | +| `cache` | Enable exact-key application `target/` caching (`true`/`false`). | + +The wrapper surfaces engine results as its own outputs: `adapter`, +`source-revision`, `cli-version`, `effective-build-mode`. + +The engine contains no provider-specific credential names, service concepts, +endpoints, or CLI flags — those, and the list of aliases to clear +(`provider-env-clear`), all arrive from the wrapper. It invokes the application's +CLI binary (``), not a hard-coded `edgezero`. + +### 5.3 Layer 3 — adapter wrappers (`deploy-fastly`, …) + +Minimal composite actions. A wrapper only: + +1. declares the provider's typed credential inputs; +2. maps them into `provider-env` and action-owned `deploy-flags`; +3. sets `adapter`, `target`, the adapter `deploy-arg` allowlist, and the + `provider-env-clear` alias list; and +4. sources the shared `deploy-core` scripts via `$GITHUB_ACTION_PATH/../deploy-core`. + +A wrapper contains no build logic, no toolchain resolution, no path +confinement — those are engine concerns. + +**`deploy-fastly` inputs** + +| Input | Required | Default | Contract | +| ------------------- | -------- | ------------- | ---------------------------------------------------------------------------------------------- | +| `cli-artifact` | Yes | none | `build-cli` artifact name. Forwarded to the engine. | +| `cli-bin` | No | from artifact | Binary name inside the artifact. Forwarded to the engine. | +| `fastly-api-token` | Yes | none | Mapped into `provider-env` as `FASTLY_API_TOKEN`, deploy step only. | +| `fastly-service-id` | Yes | none | Mapped into action-owned `deploy-flags` as `--service-id ` to prevent accidental creation. | +| `working-directory` | No | `.` | Forwarded to the engine. | +| `manifest` | No | empty | Forwarded to the engine. | +| `build-mode` | No | `auto` | Forwarded. Fastly `auto` resolves to `never`. | +| `build-args` | No | `[]` | Forwarded to the engine. | +| `deploy-args` | No | `[]` | Forwarded. Allowlisted to `--comment` for Fastly (§9). | +| `stage` | No | `false` | When `true`, deploy to a **staged** draft version instead of activating production (§5.4). | +| `cache` | No | `false` | Forwarded to the engine. | + +**`deploy-fastly` outputs** + +| Output | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------ | +| `fastly-version` | The Fastly service version deployed (production) or staged. Emitted by the app CLI (§5.4). | +| `source-revision` | Passthrough from the engine. | +| `cli-version` | Passthrough from the engine. | + +The wrapper sets `adapter: fastly`, `target: wasm32-wasip1`, the action-owned +`deploy-flags` (`--service-id …`, `--non-interactive`) so deployments cannot +prompt in CI or select an unintended service, and +`provider-env-clear: ["FASTLY_API_TOKEN", "FASTLY_SERVICE_ID", "FASTLY_ENDPOINT", +"FASTLY_CARGO_PROFILE", …]` so the engine clears Fastly auth/endpoint aliases +without the engine itself knowing Fastly's names. When `stage: true` it adds +`--stage` to the deploy flags (§5.4). + +### 5.4 Fastly staging lifecycle (`deploy-fastly` stage mode, `healthcheck-fastly`, `rollback-fastly`) + +Staging parity with `stackpop/trusted-server-actions` is supported for Fastly as +a **provider-specific lifecycle**. The generic engine is untouched; all Fastly +staging semantics live in the **EdgeZero CLI's Fastly adapter** and are invoked +through the app's own CLI. The three actions are thin wrappers over app-CLI +subcommands. + +#### 5.4.1 CLI scaffolding (companion work, in `edgezero-adapter-fastly`) + +The capability is scaffolded into the CLI, not reproduced in action shell: + +| App-CLI invocation | Fastly operations the adapter performs | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ` deploy --adapter fastly` (production, existing) | `fastly compute deploy` → builds, uploads, **activates**; emits the activated version. | +| ` deploy --adapter fastly --stage` | `fastly compute update --autoclone --version=active` (upload to a new **draft** version, no activation) → `fastly service-version stage`; emits the staged version. | +| ` healthcheck --adapter fastly --version --domain [--staging]` | Production: `curl` the domain. Staging: resolve `staging_ips` for `` via the Fastly API, then `curl --connect-to` that IP; emits healthy/status. | +| ` rollback --adapter fastly --version [--staging]` | Production: activate ` - 1`. Staging: deactivate the staged ``. | + +The app CLI (built by `build-cli`) exposes these subcommands the same way it +exposes `deploy`/`config`. The downstream CLI template gains `Healthcheck` and +`Rollback` arms and a deployment-version surface, tracked with the other +companion CLI changes. + +#### 5.4.2 Version output + +Because staging must thread a version from deploy → healthcheck → rollback, the +Fastly path emits the service version. The app CLI prints it in a parseable form +(e.g. a `version=` line or `--output` file); `deploy-fastly` surfaces it as +the `fastly-version` output. This is the one **provider-specific** output; the +generic engine still exposes no deployment version. + +#### 5.4.3 The three actions + +- **`deploy-fastly` (`stage: true`)** — runs ` deploy --adapter fastly +--stage`; outputs `fastly-version` (the staged draft). Reuses the engine for + build/source/credential scoping; only the `--stage` flag differs. +- **`healthcheck-fastly`** — thin wrapper: downloads the CLI artifact, takes + `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, + `deploy-to` (`production`/`staging`), retry/timeout inputs; runs + ` healthcheck --adapter fastly …`; outputs `healthy` and `status-code`. + Needs no application source or build. +- **`rollback-fastly`** — thin wrapper: takes `fastly-api-token`, + `fastly-service-id`, `fastly-version`, `deploy-to`; runs + ` rollback --adapter fastly …`; on production emits `rolled-back-to`. + Needs no application source or build. + +`healthcheck-fastly` and `rollback-fastly` reuse only the CLI-artifact download +and credential-scoping helpers from `deploy-core`; they skip source resolution, +toolchain install, build, and cache, since they operate on Fastly service +versions via the API, not on application source. + +#### 5.4.4 Composing the lifecycle + +A caller wires the trio; the actions carry no orchestration policy of their own: + +```yaml +- id: cli + uses: stackpop/edgezero/.github/actions/build-cli@ + with: { cli-package: my-app-cli } + +- id: stage + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + stage: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + +- id: check + uses: stackpop/edgezero/.github/actions/healthcheck-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + domain: staging.example.com + fastly-version: ${{ steps.stage.outputs.fastly-version }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + +- if: failure() && steps.stage.outputs.fastly-version != '' + uses: stackpop/edgezero/.github/actions/rollback-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + fastly-version: ${{ steps.stage.outputs.fastly-version }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +Because these are Fastly-specific, future adapters do not inherit them; a new +adapter adds its own lifecycle actions if its provider supports staging. + +## 6. Execution flow (engine) + +1. Verify the runner is Linux x86-64 (`ubuntu-24.04` is the tested environment). +2. Validate that `adapter` is a well-formed, non-empty token. The engine does + **not** enumerate the CLI's compiled adapters (there is no introspection + command); an unsupported adapter surfaces as the CLI's own error at build or + deploy time. +3. Validate exact boolean inputs. +4. Download the `cli-artifact` (a tar) into an action-owned directory below + `RUNNER_TEMP`, extract it preserving permissions (or `chmod +x `), + read `cli-meta.json` for `cli-bin`/`cli-version` (a wrapper `cli-bin` input + overrides), and prepend the directory to `PATH` for action steps only. +5. Parse `build-args`, `deploy-args`, `deploy-flags`, `provider-env-clear` as + JSON string arrays. **`provider-env` is not present in these steps** — it is + scoped to the deploy step's own `env:` and parsed only there (step 17), so + setup/build never hold the secret-bearing blob. +6. In every non-deploy step (setup, build), unset each name in + `provider-env-clear` (defense-in-depth against inherited caller `env:`). +7. Reject NUL-containing argument or value entries. +8. Apply the adapter's deploy-arg allowlist (wrapper-provided) to `deploy-args`. +9. Resolve `working-directory` beneath `github.workspace` using canonical paths + and symlink resolution; fail if missing or not a directory. +10. If `manifest` is non-empty: resolve it under `working-directory`, fail if it + escapes `github.workspace` or is not a regular file, and export + `EDGEZERO_MANIFEST`. +11. Resolve the application **Git root**; record `source-revision`; fail on a + dirty working tree (`build-cli` used an isolated `CARGO_TARGET_DIR`, so its + CLI build did not dirty this tree). +12. Resolve the **Cargo workspace root** for `working-directory` (§11.1) for all + Cargo-scoped operations that follow. +13. Resolve the application Rust toolchain (§7) and install it plus the resolved + application `target` (for example `wasm32-wasip1` for Fastly). +14. If `cache: true`, restore the exact-key **Cargo workspace root** `target/` + cache. +15. Print non-sensitive diagnostics. +16. Resolve `build-mode` (§8). If `always`, run + ` build --adapter -- ` with **no** provider + credentials in scope (the `provider-env-clear` names stay unset here). +17. In a separate deploy step whose step-scoped `env:` is the **only** place + `provider-env` is exposed: clear the `provider-env-clear` aliases, parse + `provider-env` and export only its values, and run: + + ```text + deploy --adapter -- + ``` + + For adapters whose deploy also compiles the application (Fastly's default), + this step builds application code with credentials in scope — see §10.1. + +18. Clean action-owned temporary tool, auth, log, and cache state with + `if: always()`. +19. Save the application cache when enabled and safe. +20. Set outputs and write a non-sensitive GitHub step summary with + `if: always()`. + +When an argument array is empty, the trailing `--` may be omitted. + +## 7. Toolchain resolution + +Application Rust toolchain resolution precedence: + +1. explicit `rust-toolchain` input when not `auto`; +2. nearest `rust-toolchain.toml` or `rust-toolchain`, walking from + `working-directory` to the application Git root; +3. nearest `rust` entry in `.tool-versions` over the same path; and +4. the EdgeZero repository root `.tool-versions` `rust` entry. + +At each directory, Rustup-native files take precedence over `.tool-versions`. +Malformed toolchain files fail instead of silently selecting a different +compiler. `build-cli` uses the same precedence for the CLI build. + +## 8. Build behavior + +| Value | Behavior | +| -------- | ---------------------------------------------------------------- | +| `auto` | Apply the adapter's default policy. | +| `always` | Run ` build --adapter ` before deploy. | +| `never` | Skip the separate build; deploy builds or consumes the artifact. | + +Fastly `auto` resolves to `never` because ` deploy --adapter fastly` +builds unless a prebuilt package is provided. Other adapters define their own +default policy in their wrapper. + +## 9. Passthrough arguments + +`build-args`, `deploy-args`, and `deploy-flags` are JSON arrays so argument +boundaries are explicit: + +```yaml +with: + build-args: '["--verbose"]' + deploy-args: '["--comment", "deployed by GitHub Actions"]' +``` + +`deploy-core` must: + +- parse arrays with `jq` (no Python); +- reject non-arrays, non-string entries, and NUL bytes; +- construct commands as Bash arrays; +- never use `eval`; and +- avoid printing raw JSON inputs during validation. + +Each adapter wrapper supplies an allowlist for caller `deploy-args`. For Fastly, +`deploy-args` are allowlisted to `--comment VALUE` / `--comment=VALUE`; all other +deploy args are rejected so caller input cannot override typed service selection, +authentication, non-interactive mode, endpoint, profile, or debug behavior. The +allowlist ships with accept/reject tests. + +## 10. Provider credential contract + +Credentials flow through the `provider-env` JSON object, which `deploy-core` +injects **only** into the deploy step: + +```text +wrapper inputs (typed) → provider-env {NAME: value} → deploy step env → CLI +``` + +Rules: + +- **`provider-env` is bound only to the deploy step's own `env:`** — a + step-scoped environment, not a job/engine-global variable. Setup, `build-cli`, + and separate build steps never receive the `provider-env` variable at all, so + the secret-bearing blob is absent from their environments (not merely unset + after the fact). The engine parses `provider-env` only inside the deploy step. +- Alias clearing is **wrapper-driven and provider-agnostic**, and is + defense-in-depth for a _different_ threat — a caller who sets provider aliases + through their own workflow `env:`. The engine unsets the wrapper-supplied + `provider-env-clear` names in non-deploy steps, and clears them in the deploy + step before exporting only the `provider-env` values. The engine hard-codes no + provider names, so caller `env:` cannot override the typed contract. +- `provider-env` values never reach `GITHUB_ENV`, `GITHUB_OUTPUT`, caches, or + summaries. + +Application (non-credential) configuration may still pass through normal +workflow `env:`. + +### 10.1 Build-in-deploy caveat (trusted source requirement) + +Some adapters compile the application **inside** the deploy step. Fastly's +default `build-mode: never` relies on ` deploy`, which runs +`fastly compute deploy` — and that command builds the application unless a +prebuilt package already exists. Consequently, with the Fastly default, the +application is compiled while `FASTLY_API_TOKEN` is in scope. + +This is an explicit, accepted boundary, not an oversight: + +- The action still guarantees credentials are absent from setup, `build-cli`, + and any separate `build-mode: always` build step. +- Because deploy may still recompile, a credential-free `always` prebuild does + not remove the exposure; it only front-loads a validation build. +- Therefore callers **must** deploy only trusted, immutable source refs (full + SHAs or protected tags) and use GitHub Environment approvals, so untrusted + code never runs with the token in scope. + +Adapters that support a genuinely credential-free prebuild followed by a +credential-only publish may set a different default in their wrapper; Fastly does +not today. + +## 11. Caching + +`cache` enables opt-in application build caching, `false` by default. + +### 11.1 Git root vs Cargo workspace root + +Two distinct roots are resolved from `working-directory`, and they are **not** +interchangeable: + +- **Git root** — the enclosing repository. Used only for `source-revision` and + the dirty-source guard. +- **Cargo workspace root** — resolved with `cargo locate-project --workspace` + (or `cargo metadata`) from `working-directory`. Owns the real `Cargo.lock` and + the real `target/` directory. In a monorepo or nested workspace this is often + under `working-directory` (for example `apps/api/`), not the Git root. + +Cargo-scoped operations — lockfile hashing, lockfile presence checks, and +`target/` caching — use the **Cargo workspace root**. Git-scoped operations use +the **Git root**. + +### 11.2 Cache contents and key + +When enabled, cache only the resolved **Cargo workspace root** `target/`. Never +cache provider auth files, action-owned tool installs, logs, temporary deploy +state, or paths outside that `target/`. + +The cache key is exact and includes at least: runner OS, runner architecture, +resolved Rust toolchain, resolved `target`, CLI version, application source +revision, and the **Cargo workspace root** `Cargo.lock` hash. No broad restore +prefixes. If `cache: true` and that lockfile is missing, fail before deployment +with a remediation message. + +## 12. Logging and summary + +Log and summarize non-sensitive facts only: adapter, workspace-relative +application directory, source revision, manifest path or default discovery, Rust +toolchain and target, CLI version, requested/effective build mode, cache +enabled/disabled and key fingerprint, and final result. + +Never log provider credentials, full process environments, application secret +values, or provider auth state. + +## 13. Error handling + +All validation and setup failures stop before invoking provider deployment. + +| Failure | Required diagnostic | +| --------------------------------------- | ----------------------------------------------------------------------------- | +| Missing/unknown `cli-package` | State that the app must name a CLI package present in its own workspace. | +| Missing `cli-artifact` | State that a compiled CLI artifact from `build-cli` is required. | +| Malformed `adapter` token | Name the input and its allowed shape (the CLI validates support at run time). | +| Invalid boolean | Name the input and allowed values. | +| Missing working directory | Print the workspace-relative requested path. | +| Path escapes workspace | Name the input; require paths under `github.workspace`. | +| Missing explicit manifest | Print the workspace-relative requested path. | +| Invalid JSON arguments/env | Name the invalid input without printing its value. | +| Non-string entry | State that every array/object value must be a string. | +| Disallowed deploy arg | State the allowlist and rejected position without printing the array. | +| Rust toolchain cannot be resolved | List files checked and suggest explicit `rust-toolchain`. | +| Dirty working tree | State that deployments require committed source. | +| Missing `Cargo.lock` when cache enabled | Explain the exact-key cache requirement. | +| Missing provider credential input | Name the missing input, never its value. | +| Build command fails | Preserve exit status; state that deploy was not attempted. | +| Deploy command fails | Preserve exit status; state that rollback is caller-owned. | +| Cleanup fails | Mark the action failed; identify the area without printing secrets. | + +Provider CLI stderr passes through so provider API errors stay actionable. The +actions never construct error messages containing credentials. + +## 14. Security requirements + +1. Recommend readable released tags for third-party actions and, for production, + full commit SHAs of the EdgeZero action ref where reproducibility matters. +2. Compile the CLI package the application provides, from the application + checkout and its lockfile; do not build the EdgeZero monorepo CLI or the + action's own revision. +3. Compile the CLI once in `build-cli`; deploy steps consume the artifact and + never recompile. +4. Download provider tools and validation binaries only from official release + locations and verify SHA-256 checksums. +5. Install action-owned binaries below `RUNNER_TEMP`. +6. Use Bash arrays; never use `eval`; never use Python. +7. Allow-list `adapter` before using it in file selection or command arguments. +8. Treat the checked-out application and `edgezero.toml` as executable code. +9. Inject provider credentials only into the deploy step via `provider-env`. +10. Never write provider credentials to `GITHUB_ENV`, `GITHUB_OUTPUT`, caches, or + summaries. +11. Clear the wrapper-supplied `provider-env-clear` aliases from non-provider + steps; the engine hard-codes no provider names. +12. Reject caller paths outside `github.workspace`, including symlink escapes. +13. Escape percent, carriage return, and newline characters before emitting + user-influenced GitHub annotations or masking commands; reject CR/LF in + single-line output values. +14. Disable caching by default; use exact keys only when enabled. +15. Do not auto-retry provider deployment; retries are limited to idempotent + downloads. +16. Do not use `github.token` for provider authentication. +17. Document least-privilege workflow permissions (`contents: read` unless the + caller needs more) and caller-owned environment protection, concurrency, and + timeouts. + +## 15. Testing strategy + +### 15.1 Static validation (no Python) + +CI for the actions runs: + +- `actionlint` from a **pinned release binary** over workflow and action files; +- `shellcheck` over shell scripts; +- YAML parsing for each `action.yml`; +- metadata contract tests for public inputs/outputs, ported into the Bash + `tests/run.sh` harness (replacing the previous `python3` heredocs); +- a check that action tool versions agree with `.tool-versions`; +- `zizmor` from a **pinned release binary** (Rust; installed as a release + artifact or via `cargo install zizmor --locked`, never `pip`); and +- Markdown/example validation. + +Third-party actions used by CI (`actions/checkout`, `actions/cache`, artifact +upload/download) are pinned to readable released tags. + +### 15.2 Script contract tests (Bash) + +Use temporary directories and fake binaries to test, across the engine and the +Fastly wrapper: + +- `adapter` well-formedness validation (unknown adapters surface as the CLI's + own error, not an engine allowlist); +- app-provided `cli-package` build (fail on missing/unknown package), tar + round-trip preserving the executable bit, and artifact consumption; +- exact boolean parsing; +- toolchain precedence and malformed-file failure; +- working-directory confinement and symlink-escape rejection; +- dirty-source rejection and source-revision output; +- explicit and default manifest behavior; +- JSON argument/env parsing and boundary preservation; +- rejected non-string and NUL-containing entries; +- adapter deploy-arg allowlist (accept `--comment`, reject service/auth/endpoint/ + profile/interactive/short-flag/debug overrides); +- build-mode resolution and build-failure-prevents-deploy; +- deploy exit-code propagation; +- credential presence validation and scoping (absent from build-cli/setup/build, + present only in deploy); +- cache key construction and missing-lockfile failure; +- cleanup on success and failure; and +- redaction of credentials from action-owned logs. + +Tests must not need live provider credentials. + +### 15.3 Composite smoke test + +A workflow exercises the layered actions end to end with a minimal fixture +EdgeZero app: run `build-cli`, then `deploy-fastly`, using fake provider binaries +that write marker files instead of contacting Fastly; assert CLI-artifact reuse, +invocation order, working directory, argument boundaries, cache behavior, +credential scope, and public outputs. + +### 15.4 Installer / live gates + +- Scheduled CI verifies the pinned Fastly CLI installer still produces a runnable + binary matching the expected version, without deploying. +- A protected manual workflow may eventually deploy a disposable Fastly fixture + before any stable version alias is created; it runs only from protected + branches or approved dispatch, never from fork PRs, uses isolated resources, + and treats rollback/cleanup as caller-owned. + +## 16. Documentation requirements + +User-facing docs must cover: the three-layer model and when to use each action; +how `build-cli` compiles the app-provided CLI package; supported adapters and how new adapters +layer on; runner support; same-repo, separate-repo, and monorepo checkout +examples; complete input/output tables per action; typed provider credential +guidance and why credentials must not pass through caller `env:`; build-mode and +cache behavior with security caveats; least-privilege permissions and +environment/concurrency/timeout recommendations; explicit non-goals; and future +adapter notes. + +## 17. Acceptance criteria + +The design is implemented when: + +1. A caller can compile the CLI once with `build-cli` and deploy a checked-out + EdgeZero application with `deploy-fastly`, reusing the same CLI artifact. +2. `build-cli` compiles the app-provided `cli-package` from the application + checkout and never builds the EdgeZero monorepo CLI or the action's own + revision. +3. `deploy-core` contains no provider-specific credential names, service + concepts, endpoints, or CLI flags — only `provider-env`, `provider-env-clear`, + `deploy-flags`, and `deploy-args` carry them. +4. Adding a second adapter is a new minimal wrapper plus target/allowlist data, + with no engine fork. +5. Deploy steps consume the prebuilt CLI artifact and never recompile it. +6. Typed provider credentials reach only the deploy step and never appear in + outputs, caches, action-owned logs, or summaries. +7. Passthrough argument boundaries are preserved; no `eval`. +8. `cache: true` uses exact keys and caches only the **Cargo workspace root** + `target/` (§11.1), so nested-workspace monorepos cache the right artifacts. +9. All CI, tooling, and tests run without Python; `actionlint` and `zizmor` run + from pinned release binaries. +10. Third-party actions are pinned to readable released tags. +11. Static checks, Bash contract tests, and the composite smoke test pass. +12. Docs include same-repo, separate-repo, and monorepo examples across the + three-layer model. + +## 18. Risks and mitigations + +| Risk | Mitigation | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| CLI and application manifest schema incompatible | CLI is the app's own package, built from the app checkout, so they cannot diverge. | +| Provider deploy builds while credentials are in scope | Keep the separate build credential-free; document caching caveats; require trusted immutable refs. | +| Mutable refs execute unexpected manifest commands | Caller owns checkout; document tag/SHA protection and GitHub Environment approvals. | +| Caching stores sensitive generated output | Disable by default; exact keys only; cache only `target/`. | +| Provider CLI installer changes or disappears | Pin versions and checksums; run scheduled installer tests. | +| Monorepo has multiple provider manifests | Require deterministic `working-directory` or explicit `edgezero.toml`; the actions do not guess. | +| Engine grows provider-specific behavior | Keep provider concepts in wrappers and the CLI; keep `deploy-core` provider-neutral. | + +## 19. Future work + +1. Cloudflare Workers deployment (`deploy-cloudflare` wrapper). +2. Spin/Fermyon Cloud preview deployment (`deploy-spin` wrapper). +3. Staging / health-check / rollback lifecycle for adapters **beyond Fastly** + (Fastly's is delivered in §5.4). +4. Optionally consume a prebuilt/attested CLI binary matching the application's + pinned version instead of compiling from source. +5. Release artifact reuse between build and deploy jobs beyond the CLI. +6. Stable version aliases such as `v1`. +7. Linux arm64, macOS, or other runner support. + +## 20. References + +- EdgeZero CLI reference: `docs/guide/cli-reference.md` +- EdgeZero Fastly adapter: `crates/edgezero-adapter-fastly/src/cli.rs` +- EdgeZero CLI dispatch: `crates/edgezero-cli/src/main.rs` +- Fastly Compute deploy reference: +- GitHub Actions secure use reference: From 49f5668fe07a26a88f31ead7cc5b52befdc59cd9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:59:44 -0700 Subject: [PATCH 02/26] Plan: align deploy-core credential scoping with the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...ezero-deploy-action-implementation-plan.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index d3b7c0e8..29be6608 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -83,10 +83,15 @@ reference to port from. Most transfer with light changes: - A directory of scripts under `.github/actions/deploy-core/`, **not** a standalone composite action. Wrappers source them via `$GITHUB_ACTION_PATH/../deploy-core/…`. - - Parameters (via env from the wrapper): `adapter`, `cli-artifact`, `cli-bin`, - `working-directory`, `manifest`, `rust-toolchain`, `target`, `build-mode`, - `build-args`, `deploy-args`, `deploy-arg-allow`, `provider-env`, + - Non-secret parameters (available to all steps): `adapter`, `cli-artifact`, + `cli-bin`, `working-directory`, `manifest`, `rust-toolchain`, `target`, + `build-mode`, `build-args`, `deploy-args`, `deploy-arg-allow`, `provider-env-clear`, `deploy-flags`, `cache`. + - **`provider-env` is NOT one of these.** It is bound only to the deploy + step's own `env:` (step-scoped) and parsed only there — never present in the + setup/build step environments, so the secret-bearing blob cannot leak (spec + §5.2, §10). Setup/build see only the non-secret parameters plus + `provider-env-clear`. - Download the CLI artifact (tar) under `RUNNER_TEMP`, extract preserving the executable bit (or `chmod +x `), read `cli-meta.json` for `cli-bin`/`cli-version` (wrapper `cli-bin` overrides), and PATH-scope it. @@ -105,9 +110,11 @@ reference to port from. Most transfer with light changes: - Optional exact-key cache of the **Cargo workspace root** `target/` restore/save. - Resolve `build-mode`; optional credential-free build. - - Non-deploy steps: unset the `provider-env-clear` names. - - Deploy step: clear the `provider-env-clear` aliases, export only - `provider-env`, then run + - Non-deploy steps: unset the `provider-env-clear` names (defense-in-depth; + `provider-env` itself is absent here). + - Deploy step only (its `env:` carries `provider-env`): clear the + `provider-env-clear` aliases, parse `provider-env` and export only its + values, then run ` deploy --adapter -- ` via Bash arrays. Note the build-in-deploy caveat: Fastly's default `never` compiles the app during deploy with the token in scope, so require trusted From 7446fbb58155a0aa794a38fdd040b46191234d07 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:34:55 -0700 Subject: [PATCH 03/26] Spec/plan review: lifecycle service-id, provider-tool install, concrete target - healthcheck-fastly / rollback-fastly now pass --service-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). --- ...ezero-deploy-action-implementation-plan.md | 25 +++++--- docs/specs/edgezero-deploy-github-action.md | 60 ++++++++++++------- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index 29be6608..2fe47274 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -106,7 +106,8 @@ reference to port from. Most transfer with light changes: for lockfile hashing and `target/` caching — in a monorepo this may be under `working-directory`, not the Git root (spec §11.1). - Resolve Rust toolchain (explicit → Rustup files → `.tool-versions` → repo - fallback) and application `target` (from `adapter` when `auto`). + fallback) and install the **wrapper-provided** concrete `target` (the engine + never maps `adapter` → target). - Optional exact-key cache of the **Cargo workspace root** `target/` restore/save. - Resolve `build-mode`; optional credential-free build. @@ -136,6 +137,10 @@ reference to port from. Most transfer with light changes: `--comment` only, `provider-env-clear` = Fastly auth/endpoint aliases (`FASTLY_API_TOKEN`, `FASTLY_SERVICE_ID`, `FASTLY_ENDPOINT`, …), Fastly `auto` build-mode → `never`. + - **Install the pinned Fastly CLI** (official release + checksum, action-owned + dir on `PATH`) before sourcing the engine, so ` deploy --adapter fastly` + finds `fastly`. This is the wrapper's provider-tool responsibility; the + engine assumes `fastly` is already on `PATH`. - Output `fastly-version` (parsed from the app CLI). Source the shared `deploy-core` scripts; no build, toolchain, or path logic of its own. @@ -143,13 +148,18 @@ reference to port from. Most transfer with light changes: - `healthcheck-fastly`: thin wrapper — inputs `cli-artifact`, `cli-bin`, `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, `deploy-to` (`production`/`staging`), retry/timeout; runs - ` healthcheck --adapter fastly …`; outputs `healthy`, `status-code`. + ` healthcheck --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; outputs `healthy`, `status-code`. - `rollback-fastly`: thin wrapper — inputs `cli-artifact`, `cli-bin`, `fastly-api-token`, `fastly-service-id`, `fastly-version`, `deploy-to`; - runs ` rollback --adapter fastly …`; outputs `rolled-back-to`. - - Both reuse only the CLI-artifact download + credential-scoping helpers from - `deploy-core`; no source resolution, toolchain, build, or cache. Carry no - orchestration policy — the caller wires stage → healthcheck → rollback. + runs ` rollback --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; outputs `rolled-back-to`. + - Both map `fastly-service-id` → `--service-id` and `fastly-api-token` → + step-scoped `FASTLY_API_TOKEN`. They reuse only the CLI-artifact download + + credential-scoping helpers from `deploy-core`; no source resolution, + toolchain, build, cache, or Fastly CLI install (they call the Fastly API). + Carry no orchestration policy — the caller wires stage → healthcheck → + rollback. 5. **Scripts layout** - Provider-neutral scripts under `deploy-core/`; the Fastly install + checksum @@ -203,6 +213,7 @@ reference to port from. Most transfer with light changes: ## Known follow-up candidates - Add `deploy-cloudflare` / `deploy-spin` wrappers via the same engine. -- Add provider-specific staging/health-check/rollback as separate actions. +- Add staging/health-check/rollback lifecycle actions for adapters **beyond + Fastly** (Fastly's trio is in current scope, phases 3–4 / 8). - Optionally consume a prebuilt/attested CLI binary matching the app's pinned version instead of compiling from source. diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md index 4781003f..d20e956a 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -238,7 +238,7 @@ The engine is parameterized by the values the wrapper passes to those scripts | `working-directory` | Application directory relative to `github.workspace`. Must resolve inside `github.workspace`. | | `manifest` | Optional `edgezero.toml` path relative to `working-directory`. If set, must exist; exported as `EDGEZERO_MANIFEST`. | | `rust-toolchain` | Application Rust toolchain for the deploy build. `auto` follows §7. | -| `target` | Application build target. `auto` derives it from `adapter` (for example `fastly` → `wasm32-wasip1`). | +| `target` | Concrete application build target the **wrapper** supplies (Fastly → `wasm32-wasip1`). The engine installs exactly this target and never maps `adapter` → target, so adding an adapter does not touch the engine. | | `build-mode` | One of `auto`, `always`, `never` (§8). | | `build-args` | JSON array of strings passed after ` build --adapter --`. Must not contain secrets. | | `deploy-args` | JSON array of caller-supplied deploy args appended after action-owned deploy flags. Must not contain secrets. | @@ -262,12 +262,19 @@ Minimal composite actions. A wrapper only: 1. declares the provider's typed credential inputs; 2. maps them into `provider-env` and action-owned `deploy-flags`; -3. sets `adapter`, `target`, the adapter `deploy-arg` allowlist, and the - `provider-env-clear` alias list; and -4. sources the shared `deploy-core` scripts via `$GITHUB_ACTION_PATH/../deploy-core`. +3. sets `adapter`, a **concrete `target`** (for Fastly, `wasm32-wasip1`), the + adapter `deploy-arg` allowlist, and the `provider-env-clear` alias list; +4. **installs the pinned provider CLI it needs** — for Fastly, the Fastly CLI + (official release, checksum-verified, into an action-owned dir on `PATH`), so + the app CLI's ` deploy --adapter fastly` (which shells out to `fastly`) + finds it. This is the one provider-specific install, and it lives in the + wrapper precisely so the provider-neutral engine never learns provider tools; + and +5. sources the shared `deploy-core` scripts via `$GITHUB_ACTION_PATH/../deploy-core`. A wrapper contains no build logic, no toolchain resolution, no path -confinement — those are engine concerns. +confinement — those are engine concerns. Provider **tooling** (the Fastly CLI) +is a wrapper concern; the engine assumes the provider CLI is already on `PATH`. **`deploy-fastly` inputs** @@ -313,17 +320,18 @@ subcommands. The capability is scaffolded into the CLI, not reproduced in action shell: -| App-CLI invocation | Fastly operations the adapter performs | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ` deploy --adapter fastly` (production, existing) | `fastly compute deploy` → builds, uploads, **activates**; emits the activated version. | -| ` deploy --adapter fastly --stage` | `fastly compute update --autoclone --version=active` (upload to a new **draft** version, no activation) → `fastly service-version stage`; emits the staged version. | -| ` healthcheck --adapter fastly --version --domain [--staging]` | Production: `curl` the domain. Staging: resolve `staging_ips` for `` via the Fastly API, then `curl --connect-to` that IP; emits healthy/status. | -| ` rollback --adapter fastly --version [--staging]` | Production: activate ` - 1`. Staging: deactivate the staged ``. | +| App-CLI invocation | Fastly operations the adapter performs | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ` deploy --adapter fastly --service-id ` (production, existing) | `fastly compute deploy` → builds, uploads, **activates**; emits the activated version. | +| ` deploy --adapter fastly --service-id --stage` | `fastly compute update --autoclone --version=active` (upload to a new **draft** version, no activation) → `fastly service-version stage`; emits the staged version. | +| ` healthcheck --adapter fastly --service-id --version --domain [--staging]` | Production: `curl` the domain. Staging: resolve `staging_ips` for `` on `` via the Fastly API, then `curl --connect-to` that IP; emits healthy/status. | +| ` rollback --adapter fastly --service-id --version [--staging]` | Production: activate ` - 1` on ``. Staging: deactivate the staged `` on ``. | -The app CLI (built by `build-cli`) exposes these subcommands the same way it -exposes `deploy`/`config`. The downstream CLI template gains `Healthcheck` and -`Rollback` arms and a deployment-version surface, tracked with the other -companion CLI changes. +Every Fastly subcommand takes `--service-id ` (the service the operation +targets) and reads `FASTLY_API_TOKEN` from the environment. The app CLI (built by +`build-cli`) exposes these subcommands the same way it exposes `deploy`/`config`. +The downstream CLI template gains `Healthcheck` and `Rollback` arms and a +deployment-version surface, tracked with the other companion CLI changes. #### 5.4.2 Version output @@ -341,17 +349,22 @@ generic engine still exposes no deployment version. - **`healthcheck-fastly`** — thin wrapper: downloads the CLI artifact, takes `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, `deploy-to` (`production`/`staging`), retry/timeout inputs; runs - ` healthcheck --adapter fastly …`; outputs `healthy` and `status-code`. - Needs no application source or build. + ` healthcheck --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; outputs `healthy` and `status-code`. Needs + no application source or build. - **`rollback-fastly`** — thin wrapper: takes `fastly-api-token`, `fastly-service-id`, `fastly-version`, `deploy-to`; runs - ` rollback --adapter fastly …`; on production emits `rolled-back-to`. - Needs no application source or build. + ` rollback --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; on production emits `rolled-back-to`. Needs + no application source or build. -`healthcheck-fastly` and `rollback-fastly` reuse only the CLI-artifact download +`healthcheck-fastly` and `rollback-fastly` map `fastly-service-id` → the +`--service-id` flag and `fastly-api-token` → step-scoped `FASTLY_API_TOKEN` +(same credential discipline as deploy). They reuse only the CLI-artifact download and credential-scoping helpers from `deploy-core`; they skip source resolution, toolchain install, build, and cache, since they operate on Fastly service -versions via the API, not on application source. +versions via the API, not on application source. They need no Fastly CLI install +(they call the Fastly API, not `fastly compute …`). #### 5.4.4 Composing the lifecycle @@ -423,8 +436,9 @@ adapter adds its own lifecycle actions if its provider supports staging. CLI build did not dirty this tree). 12. Resolve the **Cargo workspace root** for `working-directory` (§11.1) for all Cargo-scoped operations that follow. -13. Resolve the application Rust toolchain (§7) and install it plus the resolved - application `target` (for example `wasm32-wasip1` for Fastly). +13. Resolve the application Rust toolchain (§7) and install it plus the + **wrapper-provided** application `target` (Fastly → `wasm32-wasip1`). The + engine does not map `adapter` → target. 14. If `cache: true`, restore the exact-key **Cargo workspace root** `target/` cache. 15. Print non-sensitive diagnostics. From 6f18573b1353f1ddb1beda94e1cfbd1a6c5f75e6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:03:35 -0700 Subject: [PATCH 04/26] Self-review: wire staging lifecycle through error/testing/acceptance; guide creds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ...ezero-deploy-action-implementation-plan.md | 9 +- docs/specs/edgezero-deploy-adoption-guide.md | 10 ++- docs/specs/edgezero-deploy-github-action.md | 89 ++++++++++++------- 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index 2fe47274..1dc69028 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -174,9 +174,12 @@ reference to port from. Most transfer with light changes: (no `pip`). - Port the metadata-validation heredocs into `tests/run.sh`. - Composite smoke test: `build-cli` → `deploy-fastly` (both production and - `stage: true`) → `healthcheck-fastly` → `rollback-fastly` with fake `fastly` - binaries writing marker files; assert CLI-artifact reuse, credential - scoping, and version threading. + `stage: true`) → `healthcheck-fastly` → `rollback-fastly`. Fake each action's + real dependency: a fake `fastly` binary (marker files + printed version) for + `deploy-fastly`; a fake app CLI or stubbed Fastly API/`curl` responses for + `healthcheck-fastly`/`rollback-fastly` (they call the API, not `fastly`). + Assert CLI-artifact reuse, credential scoping, and `fastly-version` + threading. 7. **Bash contract tests (`tests/run.sh`)** - Cover engine + wrappers: adapter/boolean/JSON validation, path confinement, diff --git a/docs/specs/edgezero-deploy-adoption-guide.md b/docs/specs/edgezero-deploy-adoption-guide.md index fcff0f15..88f9ce22 100644 --- a/docs/specs/edgezero-deploy-adoption-guide.md +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -218,10 +218,12 @@ Workflow shape: 4. run `deploy-fastly` (set `stage: true` for staging) with the CLI artifact, `working-directory: trusted-server`, typed Fastly credentials, and optional `deploy-args: ["--comment", …]`; capture `fastly-version`; -5. run `healthcheck-fastly` with `deploy-to`, `domain`, and the captured - `fastly-version`; -6. on failure, run `rollback-fastly` with the same `deploy-to`/`fastly-version`; - and +5. run `healthcheck-fastly` with the CLI artifact, typed Fastly credentials + (`fastly-api-token`, `fastly-service-id`), `deploy-to`, `domain`, and the + captured `fastly-version`; +6. on failure, run `rollback-fastly` with the CLI artifact, typed Fastly + credentials (`fastly-api-token`, `fastly-service-id`), and the same + `deploy-to` / `fastly-version`; and 7. write a summary from the action outputs. ### 6.4 Required deployer changes diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md index d20e956a..4c92a5bb 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -343,15 +343,19 @@ generic engine still exposes no deployment version. #### 5.4.3 The three actions -- **`deploy-fastly` (`stage: true`)** — runs ` deploy --adapter fastly ---stage`; outputs `fastly-version` (the staged draft). Reuses the engine for - build/source/credential scoping; only the `--stage` flag differs. +- **`deploy-fastly` (`stage: true`)** — runs + ` deploy --adapter fastly --service-id --stage` (the wrapper injects + `--service-id` via `deploy-flags`); outputs `fastly-version` (the staged + draft). Reuses the engine for build/source/credential scoping; only the + `--stage` flag differs. - **`healthcheck-fastly`** — thin wrapper: downloads the CLI artifact, takes `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, `deploy-to` (`production`/`staging`), retry/timeout inputs; runs ` healthcheck --adapter fastly --service-id --version …` with - `FASTLY_API_TOKEN` in the step env; outputs `healthy` and `status-code`. Needs - no application source or build. + `FASTLY_API_TOKEN` in the step env; outputs `healthy` and `status-code`. It + **exits non-zero after retries when the probe is unhealthy** (so a caller can + gate rollback on `if: failure()`), while still emitting the outputs. Needs no + application source or build. - **`rollback-fastly`** — thin wrapper: takes `fastly-api-token`, `fastly-service-id`, `fastly-version`, `deploy-to`; runs ` rollback --adapter fastly --service-id --version …` with @@ -611,25 +615,29 @@ values, or provider auth state. All validation and setup failures stop before invoking provider deployment. -| Failure | Required diagnostic | -| --------------------------------------- | ----------------------------------------------------------------------------- | -| Missing/unknown `cli-package` | State that the app must name a CLI package present in its own workspace. | -| Missing `cli-artifact` | State that a compiled CLI artifact from `build-cli` is required. | -| Malformed `adapter` token | Name the input and its allowed shape (the CLI validates support at run time). | -| Invalid boolean | Name the input and allowed values. | -| Missing working directory | Print the workspace-relative requested path. | -| Path escapes workspace | Name the input; require paths under `github.workspace`. | -| Missing explicit manifest | Print the workspace-relative requested path. | -| Invalid JSON arguments/env | Name the invalid input without printing its value. | -| Non-string entry | State that every array/object value must be a string. | -| Disallowed deploy arg | State the allowlist and rejected position without printing the array. | -| Rust toolchain cannot be resolved | List files checked and suggest explicit `rust-toolchain`. | -| Dirty working tree | State that deployments require committed source. | -| Missing `Cargo.lock` when cache enabled | Explain the exact-key cache requirement. | -| Missing provider credential input | Name the missing input, never its value. | -| Build command fails | Preserve exit status; state that deploy was not attempted. | -| Deploy command fails | Preserve exit status; state that rollback is caller-owned. | -| Cleanup fails | Mark the action failed; identify the area without printing secrets. | +| Failure | Required diagnostic | +| ----------------------------------------------- | ------------------------------------------------------------------------------- | +| Missing/unknown `cli-package` | State that the app must name a CLI package present in its own workspace. | +| Missing `cli-artifact` | State that a compiled CLI artifact from `build-cli` is required. | +| Malformed `adapter` token | Name the input and its allowed shape (the CLI validates support at run time). | +| Invalid boolean | Name the input and allowed values. | +| Missing working directory | Print the workspace-relative requested path. | +| Path escapes workspace | Name the input; require paths under `github.workspace`. | +| Missing explicit manifest | Print the workspace-relative requested path. | +| Invalid JSON arguments/env | Name the invalid input without printing its value. | +| Non-string entry | State that every array/object value must be a string. | +| Disallowed deploy arg | State the allowlist and rejected position without printing the array. | +| Rust toolchain cannot be resolved | List files checked and suggest explicit `rust-toolchain`. | +| Dirty working tree | State that deployments require committed source. | +| Missing `Cargo.lock` when cache enabled | Explain the exact-key cache requirement. | +| Missing provider credential input | Name the missing input, never its value. | +| Build command fails | Preserve exit status; state that deploy was not attempted. | +| Deploy command fails | Preserve exit status; state that rollback is caller-owned. | +| Staged deploy fails | Preserve exit status; emit no `fastly-version` so the caller skips rollback. | +| Missing `fastly-version` (healthcheck/rollback) | State it is required, sourced from the deploy/stage output. | +| Health check unhealthy after retries | Exit non-zero and set `healthy=false`/`status-code` so the caller can rollback. | +| Rollback command fails | Preserve exit status; state the version was not rolled back. | +| Cleanup fails | Mark the action failed; identify the area without printing secrets. | Provider CLI stderr passes through so provider API errors stay actionable. The actions never construct error messages containing credentials. @@ -708,6 +716,10 @@ Fastly wrapper: - credential presence validation and scoping (absent from build-cli/setup/build, present only in deploy); - cache key construction and missing-lockfile failure; +- staging lifecycle: `stage` flag adds `--stage`; `fastly-version` parsed from CLI + output; `healthcheck-fastly` / `rollback-fastly` pass `--service-id` + version + and scope `FASTLY_API_TOKEN`; healthcheck exits non-zero on unhealthy; staging + vs production argv; - cleanup on success and failure; and - redaction of credentials from action-owned logs. @@ -716,10 +728,19 @@ Tests must not need live provider credentials. ### 15.3 Composite smoke test A workflow exercises the layered actions end to end with a minimal fixture -EdgeZero app: run `build-cli`, then `deploy-fastly`, using fake provider binaries -that write marker files instead of contacting Fastly; assert CLI-artifact reuse, -invocation order, working directory, argument boundaries, cache behavior, -credential scope, and public outputs. +EdgeZero app: run `build-cli`, then `deploy-fastly` (both production and +`stage: true`), then `healthcheck-fastly` and `rollback-fastly`. Fake the +dependencies each action actually uses: + +- for `deploy-fastly`, a fake `fastly` binary that writes marker files and prints + a version instead of contacting Fastly; +- for `healthcheck-fastly` / `rollback-fastly`, a fake **app CLI** (or stubbed + Fastly API / `curl` responses) — these actions call the Fastly API, not the + `fastly` CLI, so no fake `fastly` binary is involved. + +Assert CLI-artifact reuse, invocation order, working directory, argument +boundaries (`--service-id`, `--stage`), `fastly-version` threading stage → +healthcheck → rollback, cache behavior, credential scope, and public outputs. ### 15.4 Installer / live gates @@ -764,9 +785,15 @@ The design is implemented when: 9. All CI, tooling, and tests run without Python; `actionlint` and `zizmor` run from pinned release binaries. 10. Third-party actions are pinned to readable released tags. -11. Static checks, Bash contract tests, and the composite smoke test pass. -12. Docs include same-repo, separate-repo, and monorepo examples across the - three-layer model. +11. Fastly staging lifecycle works end to end: `deploy-fastly` `stage: true` + stages a draft and outputs `fastly-version`; `healthcheck-fastly` probes the + staged version (via its staging IP) and exits non-zero when unhealthy; + `rollback-fastly` deactivates the staged version (or activates the previous + production version). All three thread `--service-id` and `fastly-version` and + scope `FASTLY_API_TOKEN`; the generic engine is unchanged. +12. Static checks, Bash contract tests, and the composite smoke test pass. +13. Docs include same-repo, separate-repo, and monorepo examples across the + three-layer model, plus a Fastly staging-lifecycle example. ## 18. Risks and mitigations From 193c045c8a64a5feb3275eefd2f7ec7cc683e67b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:02:28 -0700 Subject: [PATCH 05/26] impl(actions): build-cli + deploy-core engine foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .github/actions/build-cli/action.yml | 60 ++++++++ .../actions/build-cli/scripts/build-cli.sh | 139 ++++++++++++++++++ .github/actions/build-cli/scripts/common.sh | 105 +++++++++++++ .../actions/deploy-core/scripts/cleanup.sh | 17 +++ .github/actions/deploy-core/scripts/common.sh | 105 +++++++++++++ .../deploy-core/scripts/download-cli.sh | 42 ++++++ .../deploy-core/scripts/install-rust.sh | 16 ++ .../deploy-core/scripts/resolve-project.sh | 138 +++++++++++++++++ .../deploy-core/scripts/validate-inputs.sh | 108 ++++++++++++++ .../deploy-core/scripts/write-summary.sh | 24 +++ 10 files changed, 754 insertions(+) create mode 100644 .github/actions/build-cli/action.yml create mode 100755 .github/actions/build-cli/scripts/build-cli.sh create mode 100755 .github/actions/build-cli/scripts/common.sh create mode 100755 .github/actions/deploy-core/scripts/cleanup.sh create mode 100755 .github/actions/deploy-core/scripts/common.sh create mode 100755 .github/actions/deploy-core/scripts/download-cli.sh create mode 100755 .github/actions/deploy-core/scripts/install-rust.sh create mode 100755 .github/actions/deploy-core/scripts/resolve-project.sh create mode 100755 .github/actions/deploy-core/scripts/validate-inputs.sh create mode 100755 .github/actions/deploy-core/scripts/write-summary.sh diff --git a/.github/actions/build-cli/action.yml b/.github/actions/build-cli/action.yml new file mode 100644 index 00000000..14b51d67 --- /dev/null +++ b/.github/actions/build-cli/action.yml @@ -0,0 +1,60 @@ +name: EdgeZero build-cli +description: Compile the CLI package the application provides and publish it as a self-describing artifact. + +inputs: + cli-package: + description: Cargo package name of the CLI to build, defined in the application's own workspace. + required: true + cli-bin: + description: Binary name produced by cli-package. Defaults to the package name. + required: false + default: "" + working-directory: + description: Application directory (relative to github.workspace) whose workspace defines cli-package. + required: false + default: . + rust-toolchain: + description: Explicit host Rust toolchain, or 'auto' to follow application discovery. + required: false + default: auto + artifact-name: + description: Name of the uploaded CLI artifact. + required: false + default: edgezero-cli + +outputs: + cli-version: + description: CLI package version read from cargo metadata. + value: ${{ steps.build.outputs['cli-version'] }} + cli-package: + description: The application CLI package that was built. + value: ${{ steps.build.outputs['cli-package'] }} + cli-bin: + description: The binary name inside the artifact. + value: ${{ steps.build.outputs['cli-bin'] }} + artifact-name: + description: Name of the uploaded CLI artifact for downstream download. + value: ${{ steps.build.outputs['artifact-name'] }} + +runs: + using: composite + steps: + - name: Build application CLI package + id: build + shell: bash + env: + EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. + INPUT_CLI_PACKAGE: ${{ inputs['cli-package'] }} + INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + INPUT_WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + INPUT_RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} + INPUT_ARTIFACT_NAME: ${{ inputs['artifact-name'] }} + run: $GITHUB_ACTION_PATH/scripts/build-cli.sh + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.build.outputs['artifact-name'] }} + path: ${{ steps.build.outputs['tarball-path'] }} + if-no-files-found: error + retention-days: 1 diff --git a/.github/actions/build-cli/scripts/build-cli.sh b/.github/actions/build-cli/scripts/build-cli.sh new file mode 100755 index 00000000..2c930179 --- /dev/null +++ b/.github/actions/build-cli/scripts/build-cli.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Compiles the CLI package the *application* provides (a crate in the app's own +# workspace, named by INPUT_CLI_PACKAGE) into an action-owned CARGO_TARGET_DIR, +# then packages the binary plus a self-describing cli-meta.json into a tar so the +# executable bit survives actions/upload-artifact. Never builds the EdgeZero +# monorepo CLI. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +WORKSPACE=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} +ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} +CLI_PACKAGE=${INPUT_CLI_PACKAGE:?input 'cli-package' is required} +CLI_BIN=${INPUT_CLI_BIN:-} +WORKING_DIRECTORY=${INPUT_WORKING_DIRECTORY:-.} +RUST_TOOLCHAIN_INPUT=${INPUT_RUST_TOOLCHAIN:-auto} +ARTIFACT_NAME=${INPUT_ARTIFACT_NAME:-edgezero-cli} +STAGE_ROOT=${EDGEZERO_CLI_STAGE_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-cli-artifact} +BUILD_TARGET_DIR=${CARGO_TARGET_DIR_OVERRIDE:-${RUNNER_TEMP:-/tmp}/edgezero-cli-build} + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) fail "build-cli supports only Linux x86-64 runners" ;; +esac + +require_cmd cargo +require_cmd rustup +require_cmd jq +require_cmd tar + +# --- Resolve the application directory beneath github.workspace --------------- +WORKSPACE_REAL=$(canonical_path "$WORKSPACE") +APP_INPUT="$WORKSPACE/$WORKING_DIRECTORY" +[[ -d "$APP_INPUT" ]] || fail "working-directory '$WORKING_DIRECTORY' does not exist or is not a directory" +APP_DIR=$(canonical_path "$APP_INPUT") +is_under "$WORKSPACE_REAL" "$APP_DIR" || fail "input 'working-directory' must resolve inside github.workspace" + +# --- Resolve the application Rust toolchain (input > rustup files > .tool-versions) -- +parse_rust_toolchain_file() { + local value + value=$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$1" | awk 'NF { print; exit }') + [[ -n "$value" ]] || fail "malformed Rust toolchain file: $1" + printf '%s\n' "$value" +} +parse_rust_toolchain_toml() { + local value + value=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*["'\''`]([^"'\''`]+)["'\''`][[:space:]]*$/\1/p' "$1" | head -n 1) + [[ -n "$value" ]] || fail "malformed Rust toolchain TOML file: $1" + printf '%s\n' "$value" +} +resolve_rust_toolchain() { + if [[ "$RUST_TOOLCHAIN_INPUT" != "auto" ]]; then + [[ -n "$RUST_TOOLCHAIN_INPUT" ]] || fail "input 'rust-toolchain' cannot be empty" + printf '%s\n' "$RUST_TOOLCHAIN_INPUT" + return + fi + local directory="$APP_DIR" value + while true; do + if [[ -f "$directory/rust-toolchain.toml" ]]; then + parse_rust_toolchain_toml "$directory/rust-toolchain.toml" + return + fi + if [[ -f "$directory/rust-toolchain" ]]; then + parse_rust_toolchain_file "$directory/rust-toolchain" + return + fi + if [[ -f "$directory/.tool-versions" ]] && value=$(read_tool_version "$directory/.tool-versions" rust) && [[ -n "$value" ]]; then + printf '%s\n' "$value" + return + fi + [[ "$directory" == "$WORKSPACE_REAL" ]] && break + local next + next=$(dirname "$directory") + [[ "$next" == "$directory" ]] && break + directory="$next" + done + if [[ -f "$ACTION_ROOT/.tool-versions" ]] && value=$(read_tool_version "$ACTION_ROOT/.tool-versions" rust) && [[ -n "$value" ]]; then + printf '%s\n' "$value" + return + fi + fail "could not resolve Rust toolchain; checked rust-toolchain.toml, rust-toolchain, .tool-versions; set input 'rust-toolchain' explicitly" +} +RUST_TOOLCHAIN=$(resolve_rust_toolchain) + +# Install the host toolchain only. The CLI is a native tool; the WASM target the +# *application* needs is installed later by the deploy engine, not here. +rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal + +# --- Validate the package + resolve the binary/version via cargo metadata ----- +cd "$APP_DIR" +[[ -f Cargo.lock || -f "$(cargo locate-project --workspace --message-format plain 2>/dev/null | xargs -r dirname)/Cargo.lock" ]] || + fail "no Cargo.lock at the app's Cargo workspace root; build-cli requires a committed lockfile" + +METADATA=$(cargo +"$RUST_TOOLCHAIN" metadata --locked --no-deps --format-version 1) || + fail "cargo metadata --locked failed; ensure Cargo.lock is present and up to date" + +PKG_JSON=$(jq -c --arg p "$CLI_PACKAGE" '.packages[] | select(.name == $p)' <<<"$METADATA") +[[ -n "$PKG_JSON" ]] || fail "cli-package '$CLI_PACKAGE' was not found in the application workspace" + +# Default the binary name to the package name; verify the bin target exists. +[[ -n "$CLI_BIN" ]] || CLI_BIN="$CLI_PACKAGE" +HAS_BIN=$(jq -r --arg b "$CLI_BIN" '[.targets[] | select(.kind | index("bin")) | .name] | index($b) != null' <<<"$PKG_JSON") +[[ "$HAS_BIN" == "true" ]] || fail "cli-package '$CLI_PACKAGE' declares no binary target named '$CLI_BIN'" +CLI_VERSION=$(jq -r '.version' <<<"$PKG_JSON") + +# --- Build into an action-owned target dir (never dirties the checkout) ------- +rm -rf "$BUILD_TARGET_DIR" +mkdir -p "$BUILD_TARGET_DIR" +CARGO_TARGET_DIR="$BUILD_TARGET_DIR" cargo +"$RUST_TOOLCHAIN" build \ + --locked --release -p "$CLI_PACKAGE" --bin "$CLI_BIN" + +BIN_PATH="$BUILD_TARGET_DIR/release/$CLI_BIN" +[[ -x "$BIN_PATH" ]] || fail "build did not produce an executable at $BIN_PATH" + +# Smoke-check runnability (today's generated CLI may have no --version). +"$BIN_PATH" --help >/dev/null 2>&1 || fail "built CLI '$CLI_BIN' did not run '$CLI_BIN --help'" + +# --- Package binary + self-describing metadata into a tar --------------------- +rm -rf "$STAGE_ROOT" +mkdir -p "$STAGE_ROOT" +cp "$BIN_PATH" "$STAGE_ROOT/$CLI_BIN" +chmod +x "$STAGE_ROOT/$CLI_BIN" +jq -n --arg bin "$CLI_BIN" --arg version "$CLI_VERSION" --arg package "$CLI_PACKAGE" \ + '{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \ + >"$STAGE_ROOT/cli-meta.json" + +TARBALL="$STAGE_ROOT/../${ARTIFACT_NAME}.tar" +tar -C "$STAGE_ROOT" -cf "$TARBALL" "$CLI_BIN" cli-meta.json +TARBALL=$(canonical_path "$TARBALL") + +notice "built app CLI '$CLI_BIN' v$CLI_VERSION from package '$CLI_PACKAGE'" +append_output cli-version "$CLI_VERSION" +append_output cli-package "$CLI_PACKAGE" +append_output cli-bin "$CLI_BIN" +append_output artifact-name "$ARTIFACT_NAME" +append_output tarball-path "$TARBALL" diff --git a/.github/actions/build-cli/scripts/common.sh b/.github/actions/build-cli/scripts/common.sh new file mode 100755 index 00000000..38f96051 --- /dev/null +++ b/.github/actions/build-cli/scripts/common.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +escape_annotation() { + local value="$*" + value=${value//%/%25} + value=${value//$'\r'/%0D} + value=${value//$'\n'/%0A} + printf '%s' "$value" +} + +fail() { + local message + message=$(escape_annotation "$*") + echo "::error::$message" >&2 + exit 1 +} + +notice() { + local message + message=$(escape_annotation "$*") + echo "::notice::$message" >&2 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command '$1' was not found" +} + +append_output() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail "invalid output name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "output '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_OUTPUT" + else + printf '%s=%s\n' "$name" "$value" + fi +} + +append_env() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "invalid environment name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "environment value '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_ENV:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_ENV" + else + export "$name=$value" + fi +} + +canonical_path() { + require_cmd realpath + local path + path=$(realpath "$1" 2>/dev/null) || fail "could not resolve path '$1'" + printf '%s\n' "$path" +} + +relative_to() { + local root="${1%/}" + local path="${2%/}" + if [[ "$path" == "$root" ]]; then + printf '.\n' + elif [[ "$path" == "$root"/* ]]; then + printf '%s\n' "${path#"$root"/}" + else + printf '%s\n' "$path" + fi +} + +is_under() { + local root="${1%/}" + local path="${2%/}" + [[ "$path" == "$root" || "$path" == "$root"/* ]] +} + +json_get() { + require_cmd jq + jq -er ".$2" "$1" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{ print $1 }' + else + fail "required command 'sha256sum' or 'shasum' was not found" + fi +} + +read_tool_version() { + local file="$1" + local tool="$2" + awk -v tool="$tool" '$1 == tool { print $2; found=1; exit } END { if (!found) exit 1 }' "$file" +} + +sanitize_ref() { + printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '-' +} diff --git a/.github/actions/deploy-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh new file mode 100755 index 00000000..d7e21eec --- /dev/null +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=${EDGEZERO_ACTION_STATE_DIR:-} +if [[ -n "$ROOT" && -d "$ROOT" ]]; then + rm -rf "$ROOT" +fi + +TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-} +if [[ -n "$TOOL_ROOT" && -d "$TOOL_ROOT" ]]; then + rm -rf "$TOOL_ROOT" +fi + +# Remove action-owned Fastly auth state if future installers create it. +if [[ -n "${EDGEZERO_FASTLY_HOME:-}" && -d "$EDGEZERO_FASTLY_HOME" ]]; then + rm -rf "$EDGEZERO_FASTLY_HOME" +fi diff --git a/.github/actions/deploy-core/scripts/common.sh b/.github/actions/deploy-core/scripts/common.sh new file mode 100755 index 00000000..38f96051 --- /dev/null +++ b/.github/actions/deploy-core/scripts/common.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +escape_annotation() { + local value="$*" + value=${value//%/%25} + value=${value//$'\r'/%0D} + value=${value//$'\n'/%0A} + printf '%s' "$value" +} + +fail() { + local message + message=$(escape_annotation "$*") + echo "::error::$message" >&2 + exit 1 +} + +notice() { + local message + message=$(escape_annotation "$*") + echo "::notice::$message" >&2 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command '$1' was not found" +} + +append_output() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail "invalid output name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "output '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_OUTPUT" + else + printf '%s=%s\n' "$name" "$value" + fi +} + +append_env() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "invalid environment name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "environment value '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_ENV:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_ENV" + else + export "$name=$value" + fi +} + +canonical_path() { + require_cmd realpath + local path + path=$(realpath "$1" 2>/dev/null) || fail "could not resolve path '$1'" + printf '%s\n' "$path" +} + +relative_to() { + local root="${1%/}" + local path="${2%/}" + if [[ "$path" == "$root" ]]; then + printf '.\n' + elif [[ "$path" == "$root"/* ]]; then + printf '%s\n' "${path#"$root"/}" + else + printf '%s\n' "$path" + fi +} + +is_under() { + local root="${1%/}" + local path="${2%/}" + [[ "$path" == "$root" || "$path" == "$root"/* ]] +} + +json_get() { + require_cmd jq + jq -er ".$2" "$1" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{ print $1 }' + else + fail "required command 'sha256sum' or 'shasum' was not found" + fi +} + +read_tool_version() { + local file="$1" + local tool="$2" + awk -v tool="$tool" '$1 == tool { print $2; found=1; exit } END { if (!found) exit 1 }' "$file" +} + +sanitize_ref() { + printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '-' +} diff --git a/.github/actions/deploy-core/scripts/download-cli.sh b/.github/actions/deploy-core/scripts/download-cli.sh new file mode 100755 index 00000000..edff8a76 --- /dev/null +++ b/.github/actions/deploy-core/scripts/download-cli.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Extracts the build-cli artifact tar (downloaded by actions/download-artifact) +# into an action-owned tool dir, preserving the executable bit, reads the +# self-describing cli-meta.json, and prepends the dir to PATH for action steps. +# A wrapper-supplied CLI_BIN overrides the metadata's binary name. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ARTIFACT_DIR=${EDGEZERO_CLI_ARTIFACT_DIR:?EDGEZERO_CLI_ARTIFACT_DIR is required} +CLI_BIN_OVERRIDE=${INPUT_CLI_BIN:-} +TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} +require_cmd jq +require_cmd tar + +mkdir -p "$TOOL_ROOT/bin" + +# The artifact contains a single tar (built by build-cli). Locate it. +TARBALL=$(find "$ARTIFACT_DIR" -maxdepth 2 -type f -name '*.tar' | head -n 1) +[[ -n "$TARBALL" ]] || fail "no CLI tar found under the downloaded artifact at '$ARTIFACT_DIR'" + +tar -xf "$TARBALL" -C "$TOOL_ROOT/bin" +[[ -f "$TOOL_ROOT/bin/cli-meta.json" ]] || fail "CLI artifact is missing cli-meta.json" + +META_BIN=$(jq -er '."cli-bin"' "$TOOL_ROOT/bin/cli-meta.json") || fail "cli-meta.json has no cli-bin" +CLI_VERSION=$(jq -er '."cli-version"' "$TOOL_ROOT/bin/cli-meta.json") || fail "cli-meta.json has no cli-version" +CLI_BIN=${CLI_BIN_OVERRIDE:-$META_BIN} + +[[ -f "$TOOL_ROOT/bin/$CLI_BIN" ]] || fail "CLI binary '$CLI_BIN' not present in the artifact" +chmod +x "$TOOL_ROOT/bin/$CLI_BIN" +"$TOOL_ROOT/bin/$CLI_BIN" --help >/dev/null 2>&1 || fail "downloaded CLI '$CLI_BIN' did not run '--help'" + +printf '%s\n' "$TOOL_ROOT/bin" >>"${GITHUB_PATH:-/dev/null}" +export PATH="$TOOL_ROOT/bin:$PATH" + +notice "using app CLI '$CLI_BIN' v$CLI_VERSION from artifact" +append_output cli-bin "$CLI_BIN" +append_output cli-version "$CLI_VERSION" +append_env EDGEZERO_TOOL_ROOT "$TOOL_ROOT" diff --git a/.github/actions/deploy-core/scripts/install-rust.sh b/.github/actions/deploy-core/scripts/install-rust.sh new file mode 100755 index 00000000..31d3ba3a --- /dev/null +++ b/.github/actions/deploy-core/scripts/install-rust.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs the application Rust toolchain plus the wrapper-provided concrete +# target. The engine never maps adapter -> target; the wrapper supplies it. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +RUST_TOOLCHAIN=${RUST_TOOLCHAIN:?RUST_TOOLCHAIN is required} +TARGET=${RUST_TARGET:?RUST_TARGET is required (wrapper-provided concrete target)} +require_cmd rustup +rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal +rustup target add "$TARGET" --toolchain "$RUST_TOOLCHAIN" +append_env RUSTUP_TOOLCHAIN "$RUST_TOOLCHAIN" diff --git a/.github/actions/deploy-core/scripts/resolve-project.sh b/.github/actions/deploy-core/scripts/resolve-project.sh new file mode 100755 index 00000000..0de2ae37 --- /dev/null +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Resolves the application context for the deploy engine. Distinguishes the Git +# root (source revision + dirty-source guard) from the Cargo workspace root +# (Cargo.lock hash + target/ cache), so nested-workspace monorepos cache the +# right artifacts. Provider-neutral: no provider names appear here. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +WORKSPACE=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} +ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} +WORKING_DIRECTORY=${INPUT_WORKING_DIRECTORY:-.} +MANIFEST=${INPUT_MANIFEST:-} +RUST_TOOLCHAIN_INPUT=${INPUT_RUST_TOOLCHAIN:-auto} +RUST_TARGET=${INPUT_TARGET:?INPUT_TARGET is required (wrapper-provided concrete target)} +CACHE=${INPUT_CACHE:-false} +CLI_VERSION=${EDGEZERO_CLI_VERSION:-unknown} + +require_cmd git +require_cmd cargo + +WORKSPACE_REAL=$(canonical_path "$WORKSPACE") +APP_INPUT="$WORKSPACE/$WORKING_DIRECTORY" +[[ -d "$APP_INPUT" ]] || fail "working-directory '$WORKING_DIRECTORY' does not exist or is not a directory" +APP_DIR=$(canonical_path "$APP_INPUT") +is_under "$WORKSPACE_REAL" "$APP_DIR" || fail "input 'working-directory' must resolve inside github.workspace" +APP_REL=$(relative_to "$WORKSPACE_REAL" "$APP_DIR") + +if [[ -n "$MANIFEST" ]]; then + MANIFEST_INPUT="$APP_DIR/$MANIFEST" + [[ -f "$MANIFEST_INPUT" ]] || fail "manifest '$APP_REL/$MANIFEST' does not exist or is not a regular file" + MANIFEST_PATH=$(canonical_path "$MANIFEST_INPUT") + is_under "$WORKSPACE_REAL" "$MANIFEST_PATH" || fail "input 'manifest' must resolve inside github.workspace" + MANIFEST_REL=$(relative_to "$WORKSPACE_REAL" "$MANIFEST_PATH") +else + MANIFEST_PATH="" + MANIFEST_REL="EdgeZero default discovery" +fi + +# --- Git root: source revision + dirty-source guard --------------------------- +APP_GIT_ROOT=$(git -C "$APP_DIR" rev-parse --show-toplevel 2>/dev/null || true) +[[ -n "$APP_GIT_ROOT" ]] || fail "working-directory '$APP_REL' is not inside a Git repository" +APP_GIT_ROOT=$(canonical_path "$APP_GIT_ROOT") +is_under "$WORKSPACE_REAL" "$APP_GIT_ROOT" || fail "application Git root must resolve inside github.workspace" +SOURCE_REVISION=$(git -C "$APP_GIT_ROOT" rev-parse HEAD) +if ! git -C "$APP_GIT_ROOT" diff --quiet --ignore-submodules -- || + ! git -C "$APP_GIT_ROOT" diff --cached --quiet --ignore-submodules -- || + [[ -n "$(git -C "$APP_GIT_ROOT" ls-files --others --exclude-standard)" ]]; then + fail "deployments require committed source; working tree for '$APP_REL' is dirty" +fi + +# --- Cargo workspace root: lockfile + target/ + cache ------------------------- +if ! WORKSPACE_MANIFEST=$(cd "$APP_DIR" && cargo locate-project --workspace --message-format plain 2>/dev/null); then + WORKSPACE_MANIFEST="" +fi +[[ -n "$WORKSPACE_MANIFEST" ]] || fail "could not locate the Cargo workspace root from '$APP_REL'" +CARGO_WS_ROOT=$(canonical_path "$(dirname "$WORKSPACE_MANIFEST")") +LOCKFILE="$CARGO_WS_ROOT/Cargo.lock" +if [[ "$CACHE" == "true" && ! -f "$LOCKFILE" ]]; then + fail "cache is enabled but Cargo.lock was not found at the Cargo workspace root ($CARGO_WS_ROOT); exact-key caching requires Cargo.lock" +fi +LOCK_HASH="none" +[[ -f "$LOCKFILE" ]] && LOCK_HASH=$(sha256_file "$LOCKFILE") +TARGET_DIR="$CARGO_WS_ROOT/target" + +# --- Rust toolchain resolution (input > rustup files > .tool-versions) -------- +parse_rust_toolchain_file() { + local value + value=$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$1" | awk 'NF { print; exit }') + [[ -n "$value" ]] || fail "malformed Rust toolchain file: $1" + printf '%s\n' "$value" +} +parse_rust_toolchain_toml() { + local value + value=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*["'\''`]([^"'\''`]+)["'\''`][[:space:]]*$/\1/p' "$1" | head -n 1) + [[ -n "$value" ]] || fail "malformed Rust toolchain TOML file: $1" + printf '%s\n' "$value" +} +resolve_rust_toolchain() { + if [[ "$RUST_TOOLCHAIN_INPUT" != "auto" ]]; then + [[ -n "$RUST_TOOLCHAIN_INPUT" ]] || fail "input 'rust-toolchain' cannot be empty" + printf '%s\n' "$RUST_TOOLCHAIN_INPUT" + return + fi + local directory="$APP_DIR" value + while true; do + if [[ -f "$directory/rust-toolchain.toml" ]]; then + parse_rust_toolchain_toml "$directory/rust-toolchain.toml" + return + fi + if [[ -f "$directory/rust-toolchain" ]]; then + parse_rust_toolchain_file "$directory/rust-toolchain" + return + fi + if [[ -f "$directory/.tool-versions" ]] && value=$(read_tool_version "$directory/.tool-versions" rust) && [[ -n "$value" ]]; then + printf '%s\n' "$value" + return + fi + [[ "$directory" == "$APP_GIT_ROOT" ]] && break + local next + next=$(dirname "$directory") + [[ "$next" == "$directory" ]] && break + directory="$next" + done + if [[ -f "$ACTION_ROOT/.tool-versions" ]] && value=$(read_tool_version "$ACTION_ROOT/.tool-versions" rust) && [[ -n "$value" ]]; then + printf '%s\n' "$value" + return + fi + fail "could not resolve Rust toolchain; checked rust-toolchain.toml, rust-toolchain, .tool-versions; set input 'rust-toolchain' explicitly" +} +RUST_TOOLCHAIN=$(resolve_rust_toolchain) + +CACHE_KEY="edgezero-deploy-${RUNNER_OS:-Linux}-${RUNNER_ARCH:-X64}-$(sanitize_ref "$RUST_TOOLCHAIN")-$(sanitize_ref "$RUST_TARGET")-$(sanitize_ref "$CLI_VERSION")-${SOURCE_REVISION}-${LOCK_HASH}" + +effective_build_mode() { + case "${INPUT_BUILD_MODE:-auto}" in + auto) printf 'never\n' ;; + always) printf 'always\n' ;; + never) printf 'never\n' ;; + *) fail "input 'build-mode' must be one of: auto, always, never" ;; + esac +} +EFFECTIVE_BUILD_MODE=$(effective_build_mode) + +append_output working-directory "$APP_DIR" +append_output working-directory-relative "$APP_REL" +append_output manifest "$MANIFEST_PATH" +append_output manifest-summary "$MANIFEST_REL" +append_output app-git-root "$APP_GIT_ROOT" +append_output cargo-workspace-root "$CARGO_WS_ROOT" +append_output source-revision "$SOURCE_REVISION" +append_output rust-toolchain "$RUST_TOOLCHAIN" +append_output effective-build-mode "$EFFECTIVE_BUILD_MODE" +append_output cache-key "$CACHE_KEY" +append_output cache-path "$TARGET_DIR" diff --git a/.github/actions/deploy-core/scripts/validate-inputs.sh b/.github/actions/deploy-core/scripts/validate-inputs.sh new file mode 100755 index 00000000..5b98f1fb --- /dev/null +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Provider-neutral input validation for the deploy engine. Parses the JSON-array +# parameters into NUL-delimited files, applies the wrapper-supplied deploy-arg +# allowlist, and validates booleans. It never learns provider credential names +# or provider CLI flags — those arrive from the wrapper as opaque data. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ADAPTER=${INPUT_ADAPTER:-} +BUILD_MODE=${INPUT_BUILD_MODE:-auto} +CACHE=${INPUT_CACHE:-false} +BUILD_ARGS=${INPUT_BUILD_ARGS:-[]} +DEPLOY_ARGS=${INPUT_DEPLOY_ARGS:-[]} +DEPLOY_FLAGS=${INPUT_DEPLOY_FLAGS:-[]} +PROVIDER_ENV_CLEAR=${INPUT_PROVIDER_ENV_CLEAR:-[]} +# Space-separated list of value-taking flags the caller's deploy-args may use +# (wrapper-supplied). Empty means no caller deploy-args are permitted. +DEPLOY_ARG_ALLOW=${INPUT_DEPLOY_ARG_ALLOW:-} +RUNNER_OS_VALUE=${EDGEZERO_RUNNER_OS:-} +RUNNER_ARCH_VALUE=${EDGEZERO_RUNNER_ARCH:-} + +if [[ -n "$RUNNER_OS_VALUE" || -n "$RUNNER_ARCH_VALUE" ]]; then + [[ "$RUNNER_OS_VALUE" == "Linux" && "$RUNNER_ARCH_VALUE" == "X64" ]] || + fail "the EdgeZero deploy engine supports only Linux x86-64 runners; received ${RUNNER_OS_VALUE:-unknown}/${RUNNER_ARCH_VALUE:-unknown}" +fi + +# Well-formedness only: the CLI validates whether the adapter is actually +# supported (there is no engine allowlist). +[[ -n "$ADAPTER" ]] || fail "internal parameter 'adapter' is required" +[[ "$ADAPTER" =~ ^[a-z][a-z0-9-]*$ ]] || fail "adapter '$ADAPTER' is malformed; expected a lowercase token like 'fastly'" + +case "$BUILD_MODE" in + auto | always | never) ;; + *) fail "input 'build-mode' must be one of: auto, always, never" ;; +esac + +case "$CACHE" in + true | false) ;; + *) fail "input 'cache' must be exactly 'true' or 'false'" ;; +esac + +require_cmd jq + +parse_args() { + local name="$1" value="$2" out="$3" + if ! printf '%s' "$value" | jq -e 'type == "array"' >/dev/null 2>&1; then + fail "parameter '$name' must be a JSON array of strings" + fi + if ! printf '%s' "$value" | jq -e 'all(.[]; type == "string")' >/dev/null; then + fail "every element of parameter '$name' must be a string" + fi + if printf '%s' "$value" | jq -e 'any(.[]; contains("\u0000"))' >/dev/null; then + fail "parameter '$name' contains a NUL byte, which cannot be passed as an OS argument" + fi + printf '%s' "$value" | jq -jr '.[] | ., "\u0000"' >"$out" +} + +STATE_DIR=${EDGEZERO_ACTION_STATE_DIR:-${RUNNER_TEMP:-/tmp}/edgezero-action-state} +mkdir -p "$STATE_DIR" +BUILD_ARGS_FILE="$STATE_DIR/build-args.nul" +DEPLOY_ARGS_FILE="$STATE_DIR/deploy-args.nul" +DEPLOY_FLAGS_FILE="$STATE_DIR/deploy-flags.nul" +PROVIDER_ENV_CLEAR_FILE="$STATE_DIR/provider-env-clear.nul" +parse_args "build-args" "$BUILD_ARGS" "$BUILD_ARGS_FILE" +parse_args "deploy-args" "$DEPLOY_ARGS" "$DEPLOY_ARGS_FILE" +parse_args "deploy-flags" "$DEPLOY_FLAGS" "$DEPLOY_FLAGS_FILE" +parse_args "provider-env-clear" "$PROVIDER_ENV_CLEAR" "$PROVIDER_ENV_CLEAR_FILE" + +# Apply the wrapper-supplied deploy-arg allowlist. Each permitted flag accepts +# either `--flag=value` (one token) or `--flag value` (two tokens). +validate_deploy_args_allowlist() { + local file="$1" + local -a allowed=() + read -r -a allowed <<<"$DEPLOY_ARG_ALLOW" + local item position=0 expect_value=false + while IFS= read -r -d '' item; do + position=$((position + 1)) + if [[ "$expect_value" == "true" ]]; then + expect_value=false + continue + fi + local flag="${item%%=*}" + local matched=false permitted + for permitted in "${allowed[@]}"; do + if [[ "$flag" == "$permitted" ]]; then + matched=true + [[ "$item" == *=* ]] || expect_value=true + break + fi + done + [[ "$matched" == "true" ]] || + fail "deploy-args allows only: ${DEPLOY_ARG_ALLOW:-} (as '--flag value' or '--flag=value'); rejected argument $position" + done <"$file" + [[ "$expect_value" == "false" ]] || fail "a value-taking deploy-arg flag is missing its value" +} +validate_deploy_args_allowlist "$DEPLOY_ARGS_FILE" + +append_output adapter "$ADAPTER" +append_output build-args-file "$BUILD_ARGS_FILE" +append_output deploy-args-file "$DEPLOY_ARGS_FILE" +append_output deploy-flags-file "$DEPLOY_FLAGS_FILE" +append_output provider-env-clear-file "$PROVIDER_ENV_CLEAR_FILE" +append_output requested-build-mode "$BUILD_MODE" +append_output cache "$CACHE" diff --git a/.github/actions/deploy-core/scripts/write-summary.sh b/.github/actions/deploy-core/scripts/write-summary.sh new file mode 100755 index 00000000..bae59256 --- /dev/null +++ b/.github/actions/deploy-core/scripts/write-summary.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Writes a non-sensitive GitHub step summary. Never emits credentials, full +# environments, or raw argument arrays. + +SUMMARY=${GITHUB_STEP_SUMMARY:-} +[[ -n "$SUMMARY" ]] || exit 0 +{ + echo "## EdgeZero deploy" + echo + echo "| Field | Value |" + echo "| ----- | ----- |" + echo "| Adapter | ${SUMMARY_ADAPTER:-unknown} |" + echo "| Application directory | ${SUMMARY_WORKING_DIRECTORY:-unknown} |" + echo "| Source revision | ${SUMMARY_SOURCE_REVISION:-unknown} |" + echo "| Manifest | ${SUMMARY_MANIFEST:-EdgeZero default discovery} |" + echo "| Rust toolchain | ${SUMMARY_RUST_TOOLCHAIN:-unknown} |" + echo "| Target | ${SUMMARY_TARGET:-unknown} |" + echo "| CLI version | ${SUMMARY_CLI_VERSION:-unknown} |" + echo "| Effective build mode | ${SUMMARY_EFFECTIVE_BUILD_MODE:-unknown} |" + echo "| Cache | ${SUMMARY_CACHE:-false} |" + echo "| Result | ${SUMMARY_RESULT:-unknown} |" +} >>"$SUMMARY" From a668a8f652442721e6feb497d0dbdd0b396a5e3d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:50:46 -0700 Subject: [PATCH 06/26] impl(actions): deploy-fastly Fastly CLI installer + versions.json 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. --- .../actions/deploy-fastly/scripts/common.sh | 105 ++++++++++++++++++ .../deploy-fastly/scripts/install-fastly.sh | 44 ++++++++ .github/actions/deploy-fastly/versions.json | 10 ++ 3 files changed, 159 insertions(+) create mode 100644 .github/actions/deploy-fastly/scripts/common.sh create mode 100644 .github/actions/deploy-fastly/scripts/install-fastly.sh create mode 100644 .github/actions/deploy-fastly/versions.json diff --git a/.github/actions/deploy-fastly/scripts/common.sh b/.github/actions/deploy-fastly/scripts/common.sh new file mode 100644 index 00000000..38f96051 --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/common.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +escape_annotation() { + local value="$*" + value=${value//%/%25} + value=${value//$'\r'/%0D} + value=${value//$'\n'/%0A} + printf '%s' "$value" +} + +fail() { + local message + message=$(escape_annotation "$*") + echo "::error::$message" >&2 + exit 1 +} + +notice() { + local message + message=$(escape_annotation "$*") + echo "::notice::$message" >&2 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command '$1' was not found" +} + +append_output() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail "invalid output name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "output '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_OUTPUT" + else + printf '%s=%s\n' "$name" "$value" + fi +} + +append_env() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "invalid environment name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "environment value '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_ENV:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_ENV" + else + export "$name=$value" + fi +} + +canonical_path() { + require_cmd realpath + local path + path=$(realpath "$1" 2>/dev/null) || fail "could not resolve path '$1'" + printf '%s\n' "$path" +} + +relative_to() { + local root="${1%/}" + local path="${2%/}" + if [[ "$path" == "$root" ]]; then + printf '.\n' + elif [[ "$path" == "$root"/* ]]; then + printf '%s\n' "${path#"$root"/}" + else + printf '%s\n' "$path" + fi +} + +is_under() { + local root="${1%/}" + local path="${2%/}" + [[ "$path" == "$root" || "$path" == "$root"/* ]] +} + +json_get() { + require_cmd jq + jq -er ".$2" "$1" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{ print $1 }' + else + fail "required command 'sha256sum' or 'shasum' was not found" + fi +} + +read_tool_version() { + local file="$1" + local tool="$2" + awk -v tool="$tool" '$1 == tool { print $2; found=1; exit } END { if (!found) exit 1 }' "$file" +} + +sanitize_ref() { + printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '-' +} diff --git a/.github/actions/deploy-fastly/scripts/install-fastly.sh b/.github/actions/deploy-fastly/scripts/install-fastly.sh new file mode 100644 index 00000000..5debc582 --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/install-fastly.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ACTION_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +ACTION_ROOT=${EDGEZERO_ACTION_ROOT:-$(cd -- "$ACTION_DIR/../../.." && pwd)} +VERSIONS_JSON=${VERSIONS_JSON:-$ACTION_DIR/versions.json} +TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} +mkdir -p "$TOOL_ROOT/bin" "$TOOL_ROOT/downloads" + +require_cmd jq +VERSION=$(json_get "$VERSIONS_JSON" fastly.version) +TOOL_VERSION=$(read_tool_version "$ACTION_ROOT/.tool-versions" fastly || true) +[[ -n "$TOOL_VERSION" ]] || fail "EdgeZero repository .tool-versions must contain a fastly entry" +[[ "$VERSION" == "$TOOL_VERSION" ]] || fail "Fastly version mismatch: versions.json has $VERSION but .tool-versions has $TOOL_VERSION" +URL=$(json_get "$VERSIONS_JSON" fastly.linux_amd64.url) +SHA256=$(json_get "$VERSIONS_JSON" fastly.linux_amd64.sha256) +ARCHIVE="$TOOL_ROOT/downloads/fastly-$VERSION-linux-amd64.tar.gz" + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64|Linux-amd64) ;; + *) fail "Fastly v0 action supports only Linux x86-64 runners" ;; +esac + +if [[ ! -f "$ARCHIVE" ]]; then + require_cmd curl + curl --fail --location --silent --show-error "$URL" --output "$ARCHIVE" +fi + +ACTUAL=$(sha256_file "$ARCHIVE") +[[ "$ACTUAL" == "$SHA256" ]] || fail "Fastly CLI checksum mismatch for version $VERSION" + +tar -xzf "$ARCHIVE" -C "$TOOL_ROOT/bin" fastly +chmod +x "$TOOL_ROOT/bin/fastly" +printf '%s\n' "$TOOL_ROOT/bin" >>"${GITHUB_PATH:-/dev/null}" +export PATH="$TOOL_ROOT/bin:$PATH" +PROVIDER_CLI_VERSION=$(fastly version 2>/dev/null || fastly --version 2>/dev/null || true) +PROVIDER_CLI_VERSION=${PROVIDER_CLI_VERSION%%$'\n'*} +[[ -n "$PROVIDER_CLI_VERSION" ]] || fail "installed Fastly CLI did not report a version" +printf '%s\n' "$PROVIDER_CLI_VERSION" +append_output provider-cli-version "$PROVIDER_CLI_VERSION" diff --git a/.github/actions/deploy-fastly/versions.json b/.github/actions/deploy-fastly/versions.json new file mode 100644 index 00000000..1304e487 --- /dev/null +++ b/.github/actions/deploy-fastly/versions.json @@ -0,0 +1,10 @@ +{ + "fastly": { + "version": "15.1.0", + "linux_amd64": { + "url": "https://github.com/fastly/cli/releases/download/v15.1.0/fastly_v15.1.0_linux-amd64.tar.gz", + "sha256": "3ba3d8a739b7a88d0a612825a9755d735efb87a9b02ea67e53a11b96d178d500" + } + }, + "rust_target": "wasm32-wasip1" +} From 658e6dae9cedcef67707259480395ceec6fb1c9c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:23:04 -0700 Subject: [PATCH 07/26] impl(actions): run-cli engine script + deploy-fastly/healthcheck/rollback 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= line. - healthcheck-fastly / rollback-fastly: thin wrappers over 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. --- .../actions/deploy-core/scripts/run-cli.sh | 82 +++++++ .github/actions/deploy-fastly/action.yml | 217 ++++++++++++++++++ .github/actions/healthcheck-fastly/action.yml | 91 ++++++++ .github/actions/rollback-fastly/action.yml | 67 ++++++ 4 files changed, 457 insertions(+) create mode 100644 .github/actions/deploy-core/scripts/run-cli.sh create mode 100644 .github/actions/deploy-fastly/action.yml create mode 100644 .github/actions/healthcheck-fastly/action.yml create mode 100644 .github/actions/rollback-fastly/action.yml diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh new file mode 100644 index 00000000..593141a8 --- /dev/null +++ b/.github/actions/deploy-core/scripts/run-cli.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the application CLI (build or deploy) through Bash arrays — never eval. +# Provider-neutral: it invokes `` with the adapter, the wrapper's typed +# deploy-flags (before `--`), and caller passthrough deploy-args (after `--`). +# Credential scoping is done by the wrapper via step-level env: — this script +# only clears the wrapper-named aliases during a credential-free build. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +MODE=${1:?usage: run-cli.sh build|deploy} +case "$MODE" in build | deploy) ;; *) fail "mode must be build or deploy" ;; esac + +CLI_BIN=${EDGEZERO_CLI_BIN:?EDGEZERO_CLI_BIN is required} +ADAPTER=${EDGEZERO_ADAPTER:?EDGEZERO_ADAPTER is required} +WORKING_DIRECTORY=${EDGEZERO_WORKING_DIRECTORY:?EDGEZERO_WORKING_DIRECTORY is required} +MANIFEST=${EDGEZERO_MANIFEST_PATH:-} +require_cmd "$CLI_BIN" + +read_nul_into() { + # read_nul_into + local -n _dest="$1" + local file="$2" + [[ -s "$file" ]] || return 0 + local item + while IFS= read -r -d '' item; do + _dest+=("$item") + done <"$file" +} + +clear_named_aliases() { + local file="$1" name + [[ -s "$file" ]] || return 0 + while IFS= read -r -d '' name; do + if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + unset "$name" || true + fi + done <"$file" +} + +args=("$CLI_BIN" "$MODE" --adapter "$ADAPTER") + +case "$MODE" in + build) + # Credential-free: clear the wrapper-named provider aliases defensively. + clear_named_aliases "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" + passthrough=() + read_nul_into passthrough "${DEPLOY_BUILD_ARGS_FILE:-/dev/null}" + if ((${#passthrough[@]})); then + args+=(--) + args+=("${passthrough[@]}") + fi + ;; + deploy) + # Typed adapter flags (before `--`): --service-id , optional --stage. + flags=() + read_nul_into flags "${DEPLOY_FLAGS_FILE:-/dev/null}" + if ((${#flags[@]})); then + args+=("${flags[@]}") + fi + # Caller passthrough (after `--`): allowlisted deploy-args (e.g. --comment). + passthrough=() + read_nul_into passthrough "${DEPLOY_ARGS_FILE:-/dev/null}" + if ((${#passthrough[@]})); then + args+=(--) + args+=("${passthrough[@]}") + fi + ;; +esac + +if [[ -n "$MANIFEST" ]]; then + export EDGEZERO_MANIFEST="$MANIFEST" +else + unset EDGEZERO_MANIFEST || true +fi + +cd "$WORKING_DIRECTORY" +echo "[edgezero-action] running $CLI_BIN $MODE for adapter $ADAPTER" +"${args[@]}" diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml new file mode 100644 index 00000000..06eac528 --- /dev/null +++ b/.github/actions/deploy-fastly/action.yml @@ -0,0 +1,217 @@ +name: EdgeZero deploy-fastly +description: Deploy a checked-out EdgeZero application to Fastly Compute using a prebuilt app CLI artifact. + +inputs: + cli-artifact: + description: Name of the build-cli artifact to download and run. + required: true + cli-bin: + description: Binary name inside the artifact. Defaults to the artifact metadata. + required: false + default: "" + fastly-api-token: + description: Fastly API token. Injected only into the deploy step. + required: true + fastly-service-id: + description: Fastly service ID. Passed as the typed --service-id CLI flag. + required: true + working-directory: + description: Application directory relative to github.workspace. + required: false + default: . + manifest: + description: Optional edgezero.toml path relative to working-directory. + required: false + default: "" + build-mode: + description: One of auto, always, or never. Fastly auto resolves to never. + required: false + default: auto + build-args: + description: JSON array of strings passed to the CLI build after --. + required: false + default: "[]" + deploy-args: + description: JSON array of Fastly --comment passthrough args (allowlisted). + required: false + default: "[]" + stage: + description: When true, deploy to a staged draft version instead of activating production. + required: false + default: "false" + cache: + description: Enable exact-key Cargo workspace target/ caching. + required: false + default: "false" + +outputs: + fastly-version: + description: Fastly service version deployed (production) or staged. + value: ${{ steps.deploy.outputs['fastly-version'] }} + source-revision: + description: Git revision deployed from working-directory. + value: ${{ steps.resolve.outputs['source-revision'] }} + cli-version: + description: Version of the app CLI consumed from the artifact. + value: ${{ steps.cli.outputs['cli-version'] }} + +runs: + using: composite + steps: + - name: Validate inputs + id: validate + shell: bash + env: + INPUT_ADAPTER: fastly + INPUT_BUILD_MODE: ${{ inputs['build-mode'] }} + INPUT_CACHE: ${{ inputs.cache }} + INPUT_BUILD_ARGS: ${{ inputs['build-args'] }} + INPUT_DEPLOY_ARGS: ${{ inputs['deploy-args'] }} + INPUT_DEPLOY_ARG_ALLOW: "--comment" + INPUT_DEPLOY_FLAGS: ${{ inputs.stage == 'true' && format('["--service-id","{0}","--stage"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} + INPUT_PROVIDER_ENV_CLEAR: '["FASTLY_API_TOKEN","FASTLY_SERVICE_ID","FASTLY_TOKEN","FASTLY_API_ENDPOINT","FASTLY_ENDPOINT"]' + INPUT_FASTLY_API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} + INPUT_FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} + EDGEZERO_RUNNER_OS: ${{ runner.os }} + EDGEZERO_RUNNER_ARCH: ${{ runner.arch }} + EDGEZERO_ACTION_STATE_DIR: ${{ runner.temp }}/edgezero-deploy-state + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + run: | + [[ "${INPUT_FASTLY_API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } + [[ -n "${INPUT_FASTLY_SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } + "$GITHUB_ACTION_PATH/../deploy-core/scripts/validate-inputs.sh" + + - name: Download CLI artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs['cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-cli-download + + - name: Extract CLI + id: cli + shell: bash + env: + EDGEZERO_CLI_ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh + + - name: Resolve project + id: resolve + shell: bash + env: + EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. + EDGEZERO_CLI_VERSION: ${{ steps.cli.outputs['cli-version'] }} + INPUT_WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + INPUT_MANIFEST: ${{ inputs.manifest }} + INPUT_RUST_TOOLCHAIN: auto + INPUT_TARGET: wasm32-wasip1 + INPUT_BUILD_MODE: ${{ inputs['build-mode'] }} + INPUT_CACHE: ${{ inputs.cache }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/resolve-project.sh + + - name: Restore application target cache + if: ${{ inputs.cache == 'true' }} + id: cache-restore + uses: actions/cache/restore@v4 + with: + key: ${{ steps.resolve.outputs['cache-key'] }} + path: ${{ steps.resolve.outputs['cache-path'] }} + + - name: Install Rust + shell: bash + env: + RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} + RUST_TARGET: wasm32-wasip1 + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/install-rust.sh + + - name: Install Fastly CLI + id: install-fastly + shell: bash + env: + EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. + VERSIONS_JSON: ${{ github.action_path }}/versions.json + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/scripts/install-fastly.sh + + - name: Build (validation) + if: ${{ steps.resolve.outputs['effective-build-mode'] == 'always' }} + shell: bash + env: + EDGEZERO_CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO_ADAPTER: fastly + EDGEZERO_WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} + EDGEZERO_MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} + DEPLOY_BUILD_ARGS_FILE: ${{ steps.validate.outputs['build-args-file'] }} + DEPLOY_PROVIDER_ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh build + + - name: Deploy + id: deploy + shell: bash + env: + EDGEZERO_CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO_ADAPTER: fastly + EDGEZERO_WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} + EDGEZERO_MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} + DEPLOY_FLAGS_FILE: ${{ steps.validate.outputs['deploy-flags-file'] }} + DEPLOY_ARGS_FILE: ${{ steps.validate.outputs['deploy-args-file'] }} + FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} + run: | + set -o pipefail + log="${RUNNER_TEMP:-/tmp}/edgezero-deploy.log" + "$GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh" deploy 2>&1 | tee "$log" + version=$(grep -oE '^version=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) + echo "fastly-version=${version}" >> "$GITHUB_OUTPUT" + + - name: Save application target cache + if: ${{ inputs.cache == 'true' && steps.cache-restore.outputs['cache-hit'] != 'true' }} + uses: actions/cache/save@v4 + with: + key: ${{ steps.resolve.outputs['cache-key'] }} + path: ${{ steps.resolve.outputs['cache-path'] }} + + - name: Write summary + if: ${{ always() }} + shell: bash + env: + SUMMARY_ADAPTER: fastly + SUMMARY_WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory-relative'] }} + SUMMARY_SOURCE_REVISION: ${{ steps.resolve.outputs['source-revision'] }} + SUMMARY_MANIFEST: ${{ steps.resolve.outputs['manifest-summary'] }} + SUMMARY_RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} + SUMMARY_TARGET: wasm32-wasip1 + SUMMARY_CLI_VERSION: ${{ steps.cli.outputs['cli-version'] }} + SUMMARY_EFFECTIVE_BUILD_MODE: ${{ steps.resolve.outputs['effective-build-mode'] }} + SUMMARY_CACHE: ${{ inputs.cache }} + SUMMARY_RESULT: ${{ steps.deploy.outcome }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/write-summary.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO_ACTION_STATE_DIR: ${{ runner.temp }}/edgezero-deploy-state + EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml new file mode 100644 index 00000000..b5946b32 --- /dev/null +++ b/.github/actions/healthcheck-fastly/action.yml @@ -0,0 +1,91 @@ +name: EdgeZero healthcheck-fastly +description: Probe a deployed Fastly version's health via the app CLI. Exits non-zero when unhealthy after retries. + +inputs: + cli-artifact: + description: Name of the build-cli artifact to download and run. + required: true + cli-bin: + description: Binary name inside the artifact. Defaults to the artifact metadata. + required: false + default: "" + fastly-api-token: + description: Fastly API token (required for staging IP resolution). + required: true + fastly-service-id: + description: Fastly service ID. + required: true + fastly-version: + description: Fastly service version to check. + required: true + domain: + description: Domain to probe (e.g. www.example.com). + required: true + deploy-to: + description: Deployment target, 'production' or 'staging'. + required: false + default: production + retry: + description: Number of retries. + required: false + default: "3" + retry-delay: + description: Seconds between retries. + required: false + default: "5" + timeout: + description: Request timeout in seconds. + required: false + default: "10" + +outputs: + healthy: + description: Whether the deployment is healthy (true/false). + value: ${{ steps.check.outputs.healthy }} + status-code: + description: HTTP status code returned. + value: ${{ steps.check.outputs['status-code'] }} + +runs: + using: composite + steps: + - name: Download CLI artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs['cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-cli-download + + - name: Extract CLI + id: cli + shell: bash + env: + EDGEZERO_CLI_ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh + + - name: Health check + id: check + shell: bash + env: + CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} + SERVICE_ID: ${{ inputs['fastly-service-id'] }} + VERSION: ${{ inputs['fastly-version'] }} + DOMAIN: ${{ inputs.domain }} + DEPLOY_TO: ${{ inputs['deploy-to'] }} + RETRY: ${{ inputs.retry }} + RETRY_DELAY: ${{ inputs['retry-delay'] }} + TIMEOUT: ${{ inputs.timeout }} + FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + run: | + log="${RUNNER_TEMP:-/tmp}/edgezero-healthcheck.log" + args=("$CLI_BIN" healthcheck --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION" --domain "$DOMAIN" --retry "$RETRY" --retry-delay "$RETRY_DELAY" --timeout "$TIMEOUT") + [[ "$DEPLOY_TO" == "staging" ]] && args+=(--staging) + rc=0 + "${args[@]}" 2>&1 | tee "$log" || rc=$? + healthy=$(grep -oE '^healthy=(true|false)' "$log" | tail -n 1 | cut -d= -f2 || true) + status=$(grep -oE '^status-code=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) + echo "healthy=${healthy:-false}" >> "$GITHUB_OUTPUT" + echo "status-code=${status}" >> "$GITHUB_OUTPUT" + exit "$rc" diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml new file mode 100644 index 00000000..c0769ef8 --- /dev/null +++ b/.github/actions/rollback-fastly/action.yml @@ -0,0 +1,67 @@ +name: EdgeZero rollback-fastly +description: Roll back a Fastly deployment via the app CLI. Production activates the previous version; staging deactivates the staged version. + +inputs: + cli-artifact: + description: Name of the build-cli artifact to download and run. + required: true + cli-bin: + description: Binary name inside the artifact. Defaults to the artifact metadata. + required: false + default: "" + fastly-api-token: + description: Fastly API token. + required: true + fastly-service-id: + description: Fastly service ID. + required: true + fastly-version: + description: The current (bad) Fastly version to roll back from. + required: true + deploy-to: + description: Deployment target, 'production' or 'staging'. + required: false + default: production + +outputs: + rolled-back-to: + description: The Fastly version that was activated (production only). + value: ${{ steps.rollback.outputs['rolled-back-to'] }} + +runs: + using: composite + steps: + - name: Download CLI artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs['cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-cli-download + + - name: Extract CLI + id: cli + shell: bash + env: + EDGEZERO_CLI_ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh + + - name: Rollback + id: rollback + shell: bash + env: + CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} + SERVICE_ID: ${{ inputs['fastly-service-id'] }} + VERSION: ${{ inputs['fastly-version'] }} + DEPLOY_TO: ${{ inputs['deploy-to'] }} + FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + run: | + log="${RUNNER_TEMP:-/tmp}/edgezero-rollback.log" + args=("$CLI_BIN" rollback --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION") + [[ "$DEPLOY_TO" == "staging" ]] && args+=(--staging) + rc=0 + "${args[@]}" 2>&1 | tee "$log" || rc=$? + rolled=$(grep -oE '^rolled-back-to=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) + echo "rolled-back-to=${rolled}" >> "$GITHUB_OUTPUT" + exit "$rc" From 1007c37c244409edf6aea11339f606ecf541ae1c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:34:10 -0700 Subject: [PATCH 08/26] impl(actions): readability pass on run-cli, build-cli, tests 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. --- .../actions/build-cli/scripts/build-cli.sh | 219 ++++++++++-------- .../actions/deploy-core/scripts/run-cli.sh | 141 ++++++----- .github/actions/deploy-core/tests/run.sh | 202 ++++++++++++++++ 3 files changed, 405 insertions(+), 157 deletions(-) create mode 100755 .github/actions/deploy-core/tests/run.sh diff --git a/.github/actions/build-cli/scripts/build-cli.sh b/.github/actions/build-cli/scripts/build-cli.sh index 2c930179..9832971d 100755 --- a/.github/actions/build-cli/scripts/build-cli.sh +++ b/.github/actions/build-cli/scripts/build-cli.sh @@ -6,134 +6,153 @@ set -euo pipefail # then packages the binary plus a self-describing cli-meta.json into a tar so the # executable bit survives actions/upload-artifact. Never builds the EdgeZero # monorepo CLI. +# +# Inputs (environment): +# INPUT_CLI_PACKAGE required Cargo package name to build +# INPUT_CLI_BIN optional binary name (defaults to the package name) +# INPUT_WORKING_DIRECTORY optional app dir relative to github.workspace (".") +# INPUT_RUST_TOOLCHAIN optional explicit toolchain or "auto" +# INPUT_ARTIFACT_NAME optional uploaded artifact name ("edgezero-cli") SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -WORKSPACE=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} -ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} -CLI_PACKAGE=${INPUT_CLI_PACKAGE:?input 'cli-package' is required} -CLI_BIN=${INPUT_CLI_BIN:-} -WORKING_DIRECTORY=${INPUT_WORKING_DIRECTORY:-.} -RUST_TOOLCHAIN_INPUT=${INPUT_RUST_TOOLCHAIN:-auto} -ARTIFACT_NAME=${INPUT_ARTIFACT_NAME:-edgezero-cli} -STAGE_ROOT=${EDGEZERO_CLI_STAGE_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-cli-artifact} -BUILD_TARGET_DIR=${CARGO_TARGET_DIR_OVERRIDE:-${RUNNER_TEMP:-/tmp}/edgezero-cli-build} - -case "$(uname -s)-$(uname -m)" in - Linux-x86_64 | Linux-amd64) ;; - *) fail "build-cli supports only Linux x86-64 runners" ;; -esac - -require_cmd cargo -require_cmd rustup -require_cmd jq -require_cmd tar - -# --- Resolve the application directory beneath github.workspace --------------- -WORKSPACE_REAL=$(canonical_path "$WORKSPACE") -APP_INPUT="$WORKSPACE/$WORKING_DIRECTORY" -[[ -d "$APP_INPUT" ]] || fail "working-directory '$WORKING_DIRECTORY' does not exist or is not a directory" -APP_DIR=$(canonical_path "$APP_INPUT") -is_under "$WORKSPACE_REAL" "$APP_DIR" || fail "input 'working-directory' must resolve inside github.workspace" - -# --- Resolve the application Rust toolchain (input > rustup files > .tool-versions) -- -parse_rust_toolchain_file() { +# --- Rust toolchain resolution helpers --------------------------------------- +parse_toolchain_from_channel_file() { + local file="$1" local value - value=$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$1" | awk 'NF { print; exit }') - [[ -n "$value" ]] || fail "malformed Rust toolchain file: $1" + value=$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$file" | awk 'NF { print; exit }') + [[ -n "$value" ]] || fail "malformed Rust toolchain file: $file" printf '%s\n' "$value" } -parse_rust_toolchain_toml() { + +parse_toolchain_from_toml() { + local file="$1" local value - value=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*["'\''`]([^"'\''`]+)["'\''`][[:space:]]*$/\1/p' "$1" | head -n 1) - [[ -n "$value" ]] || fail "malformed Rust toolchain TOML file: $1" + value=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*["'\''`]([^"'\''`]+)["'\''`][[:space:]]*$/\1/p' "$file" | head -n 1) + [[ -n "$value" ]] || fail "malformed Rust toolchain TOML file: $file" printf '%s\n' "$value" } + +# Resolve the application toolchain: explicit input > rustup files (walking up to +# github.workspace) > .tool-versions > the EdgeZero action repo fallback. resolve_rust_toolchain() { - if [[ "$RUST_TOOLCHAIN_INPUT" != "auto" ]]; then - [[ -n "$RUST_TOOLCHAIN_INPUT" ]] || fail "input 'rust-toolchain' cannot be empty" - printf '%s\n' "$RUST_TOOLCHAIN_INPUT" + local input="$1" app_dir="$2" workspace_root="$3" action_root="$4" + if [[ "$input" != "auto" ]]; then + [[ -n "$input" ]] || fail "input 'rust-toolchain' cannot be empty" + printf '%s\n' "$input" return fi - local directory="$APP_DIR" value + + local directory="$app_dir" value while true; do if [[ -f "$directory/rust-toolchain.toml" ]]; then - parse_rust_toolchain_toml "$directory/rust-toolchain.toml" + parse_toolchain_from_toml "$directory/rust-toolchain.toml" return fi if [[ -f "$directory/rust-toolchain" ]]; then - parse_rust_toolchain_file "$directory/rust-toolchain" + parse_toolchain_from_channel_file "$directory/rust-toolchain" return fi if [[ -f "$directory/.tool-versions" ]] && value=$(read_tool_version "$directory/.tool-versions" rust) && [[ -n "$value" ]]; then printf '%s\n' "$value" return fi - [[ "$directory" == "$WORKSPACE_REAL" ]] && break - local next - next=$(dirname "$directory") - [[ "$next" == "$directory" ]] && break - directory="$next" + [[ "$directory" == "$workspace_root" ]] && break + local parent + parent=$(dirname "$directory") + [[ "$parent" == "$directory" ]] && break + directory="$parent" done - if [[ -f "$ACTION_ROOT/.tool-versions" ]] && value=$(read_tool_version "$ACTION_ROOT/.tool-versions" rust) && [[ -n "$value" ]]; then + + if [[ -f "$action_root/.tool-versions" ]] && value=$(read_tool_version "$action_root/.tool-versions" rust) && [[ -n "$value" ]]; then printf '%s\n' "$value" return fi fail "could not resolve Rust toolchain; checked rust-toolchain.toml, rust-toolchain, .tool-versions; set input 'rust-toolchain' explicitly" } -RUST_TOOLCHAIN=$(resolve_rust_toolchain) - -# Install the host toolchain only. The CLI is a native tool; the WASM target the -# *application* needs is installed later by the deploy engine, not here. -rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal - -# --- Validate the package + resolve the binary/version via cargo metadata ----- -cd "$APP_DIR" -[[ -f Cargo.lock || -f "$(cargo locate-project --workspace --message-format plain 2>/dev/null | xargs -r dirname)/Cargo.lock" ]] || - fail "no Cargo.lock at the app's Cargo workspace root; build-cli requires a committed lockfile" - -METADATA=$(cargo +"$RUST_TOOLCHAIN" metadata --locked --no-deps --format-version 1) || - fail "cargo metadata --locked failed; ensure Cargo.lock is present and up to date" - -PKG_JSON=$(jq -c --arg p "$CLI_PACKAGE" '.packages[] | select(.name == $p)' <<<"$METADATA") -[[ -n "$PKG_JSON" ]] || fail "cli-package '$CLI_PACKAGE' was not found in the application workspace" - -# Default the binary name to the package name; verify the bin target exists. -[[ -n "$CLI_BIN" ]] || CLI_BIN="$CLI_PACKAGE" -HAS_BIN=$(jq -r --arg b "$CLI_BIN" '[.targets[] | select(.kind | index("bin")) | .name] | index($b) != null' <<<"$PKG_JSON") -[[ "$HAS_BIN" == "true" ]] || fail "cli-package '$CLI_PACKAGE' declares no binary target named '$CLI_BIN'" -CLI_VERSION=$(jq -r '.version' <<<"$PKG_JSON") - -# --- Build into an action-owned target dir (never dirties the checkout) ------- -rm -rf "$BUILD_TARGET_DIR" -mkdir -p "$BUILD_TARGET_DIR" -CARGO_TARGET_DIR="$BUILD_TARGET_DIR" cargo +"$RUST_TOOLCHAIN" build \ - --locked --release -p "$CLI_PACKAGE" --bin "$CLI_BIN" - -BIN_PATH="$BUILD_TARGET_DIR/release/$CLI_BIN" -[[ -x "$BIN_PATH" ]] || fail "build did not produce an executable at $BIN_PATH" - -# Smoke-check runnability (today's generated CLI may have no --version). -"$BIN_PATH" --help >/dev/null 2>&1 || fail "built CLI '$CLI_BIN' did not run '$CLI_BIN --help'" - -# --- Package binary + self-describing metadata into a tar --------------------- -rm -rf "$STAGE_ROOT" -mkdir -p "$STAGE_ROOT" -cp "$BIN_PATH" "$STAGE_ROOT/$CLI_BIN" -chmod +x "$STAGE_ROOT/$CLI_BIN" -jq -n --arg bin "$CLI_BIN" --arg version "$CLI_VERSION" --arg package "$CLI_PACKAGE" \ - '{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \ - >"$STAGE_ROOT/cli-meta.json" - -TARBALL="$STAGE_ROOT/../${ARTIFACT_NAME}.tar" -tar -C "$STAGE_ROOT" -cf "$TARBALL" "$CLI_BIN" cli-meta.json -TARBALL=$(canonical_path "$TARBALL") - -notice "built app CLI '$CLI_BIN' v$CLI_VERSION from package '$CLI_PACKAGE'" -append_output cli-version "$CLI_VERSION" -append_output cli-package "$CLI_PACKAGE" -append_output cli-bin "$CLI_BIN" -append_output artifact-name "$ARTIFACT_NAME" -append_output tarball-path "$TARBALL" + +require_linux_x86_64() { + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) fail "build-cli supports only Linux x86-64 runners" ;; + esac +} + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local action_root="${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required}" + local cli_package="${INPUT_CLI_PACKAGE:?input 'cli-package' is required}" + local cli_bin="${INPUT_CLI_BIN:-}" + local working_directory="${INPUT_WORKING_DIRECTORY:-.}" + local rust_toolchain_input="${INPUT_RUST_TOOLCHAIN:-auto}" + local artifact_name="${INPUT_ARTIFACT_NAME:-edgezero-cli}" + local stage_root="${EDGEZERO_CLI_STAGE_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-cli-artifact}" + local build_target_dir="${CARGO_TARGET_DIR_OVERRIDE:-${RUNNER_TEMP:-/tmp}/edgezero-cli-build}" + + require_linux_x86_64 + require_cmd cargo + require_cmd rustup + require_cmd jq + require_cmd tar + + # Resolve the application directory beneath github.workspace. + local workspace_real app_dir + workspace_real=$(canonical_path "$workspace") + [[ -d "$workspace/$working_directory" ]] || fail "working-directory '$working_directory' does not exist or is not a directory" + app_dir=$(canonical_path "$workspace/$working_directory") + is_under "$workspace_real" "$app_dir" || fail "input 'working-directory' must resolve inside github.workspace" + + # Install the host toolchain only. The CLI is a native tool; the WASM target + # the *application* needs is installed later by the deploy engine. + local rust_toolchain + rust_toolchain=$(resolve_rust_toolchain "$rust_toolchain_input" "$app_dir" "$workspace_real" "$action_root") + rustup toolchain install "$rust_toolchain" --profile minimal + + # Require a committed lockfile and validate the package + binary target. + cd "$app_dir" + local metadata package_json + metadata=$(cargo +"$rust_toolchain" metadata --locked --no-deps --format-version 1) || + fail "cargo metadata --locked failed; ensure Cargo.lock is present and up to date" + package_json=$(jq -c --arg p "$cli_package" '.packages[] | select(.name == $p)' <<<"$metadata") + [[ -n "$package_json" ]] || fail "cli-package '$cli_package' was not found in the application workspace" + + [[ -n "$cli_bin" ]] || cli_bin="$cli_package" + local has_bin cli_version + has_bin=$(jq -r --arg b "$cli_bin" '[.targets[] | select(.kind | index("bin")) | .name] | index($b) != null' <<<"$package_json") + [[ "$has_bin" == "true" ]] || fail "cli-package '$cli_package' declares no binary target named '$cli_bin'" + cli_version=$(jq -r '.version' <<<"$package_json") + + # Build into an action-owned target dir so the checkout stays clean. + rm -rf "$build_target_dir" + mkdir -p "$build_target_dir" + CARGO_TARGET_DIR="$build_target_dir" cargo +"$rust_toolchain" build \ + --locked --release -p "$cli_package" --bin "$cli_bin" + + local bin_path="$build_target_dir/release/$cli_bin" + [[ -x "$bin_path" ]] || fail "build did not produce an executable at $bin_path" + "$bin_path" --help >/dev/null 2>&1 || fail "built CLI '$cli_bin' did not run '$cli_bin --help'" + + # Package the binary and self-describing metadata into a tar. + rm -rf "$stage_root" + mkdir -p "$stage_root" + cp "$bin_path" "$stage_root/$cli_bin" + chmod +x "$stage_root/$cli_bin" + jq -n --arg bin "$cli_bin" --arg version "$cli_version" --arg package "$cli_package" \ + '{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \ + >"$stage_root/cli-meta.json" + + local tarball="$stage_root/../${artifact_name}.tar" + tar -C "$stage_root" -cf "$tarball" "$cli_bin" cli-meta.json + tarball=$(canonical_path "$tarball") + + notice "built app CLI '$cli_bin' v$cli_version from package '$cli_package'" + append_output cli-version "$cli_version" + append_output cli-package "$cli_package" + append_output cli-bin "$cli_bin" + append_output artifact-name "$artifact_name" + append_output tarball-path "$tarball" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh index 593141a8..946a9b78 100644 --- a/.github/actions/deploy-core/scripts/run-cli.sh +++ b/.github/actions/deploy-core/scripts/run-cli.sh @@ -2,38 +2,45 @@ set -euo pipefail # Runs the application CLI (build or deploy) through Bash arrays — never eval. -# Provider-neutral: it invokes `` with the adapter, the wrapper's typed -# deploy-flags (before `--`), and caller passthrough deploy-args (after `--`). -# Credential scoping is done by the wrapper via step-level env: — this script -# only clears the wrapper-named aliases during a credential-free build. +# +# Provider-neutral: it invokes ` --adapter ` with the +# wrapper's typed deploy-flags (before `--`) and the caller's passthrough +# deploy-args (after `--`). Credential scoping is the wrapper's job, done with +# step-level `env:`; this script only clears the wrapper-named aliases during a +# credential-free build. +# +# Inputs (environment): +# EDGEZERO_CLI_BIN required binary name to invoke (on PATH) +# EDGEZERO_ADAPTER required adapter passed as --adapter +# EDGEZERO_WORKING_DIRECTORY required directory to run the CLI from +# EDGEZERO_MANIFEST_PATH optional exported as EDGEZERO_MANIFEST when set +# DEPLOY_BUILD_ARGS_FILE optional NUL-delimited build passthrough (build) +# DEPLOY_FLAGS_FILE optional NUL-delimited typed flags (deploy) +# DEPLOY_ARGS_FILE optional NUL-delimited passthrough (deploy) +# DEPLOY_PROVIDER_ENV_CLEAR_FILE optional NUL-delimited env names to clear (build) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -MODE=${1:?usage: run-cli.sh build|deploy} -case "$MODE" in build | deploy) ;; *) fail "mode must be build or deploy" ;; esac - -CLI_BIN=${EDGEZERO_CLI_BIN:?EDGEZERO_CLI_BIN is required} -ADAPTER=${EDGEZERO_ADAPTER:?EDGEZERO_ADAPTER is required} -WORKING_DIRECTORY=${EDGEZERO_WORKING_DIRECTORY:?EDGEZERO_WORKING_DIRECTORY is required} -MANIFEST=${EDGEZERO_MANIFEST_PATH:-} -require_cmd "$CLI_BIN" - -read_nul_into() { - # read_nul_into - local -n _dest="$1" - local file="$2" +# Collect a NUL-delimited file into the global COLLECTED array (portable; avoids +# Bash 4.3 namerefs, which some runners/macOS Bash 3.2 lack). +COLLECTED=() +collect_nul() { + local file="$1" + COLLECTED=() [[ -s "$file" ]] || return 0 - local item - while IFS= read -r -d '' item; do - _dest+=("$item") + local entry + while IFS= read -r -d '' entry; do + COLLECTED+=("$entry") done <"$file" } +# Unset each wrapper-named provider alias listed (NUL-delimited) in a file. clear_named_aliases() { - local file="$1" name + local file="$1" [[ -s "$file" ]] || return 0 + local name while IFS= read -r -d '' name; do if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then unset "$name" || true @@ -41,42 +48,62 @@ clear_named_aliases() { done <"$file" } -args=("$CLI_BIN" "$MODE" --adapter "$ADAPTER") +# Build the CLI argv for `build` mode into the global ARGV array. +build_build_argv() { + local cli_bin="$1" + local adapter="$2" + ARGV=("$cli_bin" build --adapter "$adapter") + # Credential-free build: defensively drop the wrapper-named aliases. + clear_named_aliases "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" + collect_nul "${DEPLOY_BUILD_ARGS_FILE:-/dev/null}" + if ((${#COLLECTED[@]})); then + ARGV+=(-- "${COLLECTED[@]}") + fi +} -case "$MODE" in - build) - # Credential-free: clear the wrapper-named provider aliases defensively. - clear_named_aliases "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" - passthrough=() - read_nul_into passthrough "${DEPLOY_BUILD_ARGS_FILE:-/dev/null}" - if ((${#passthrough[@]})); then - args+=(--) - args+=("${passthrough[@]}") - fi - ;; - deploy) - # Typed adapter flags (before `--`): --service-id , optional --stage. - flags=() - read_nul_into flags "${DEPLOY_FLAGS_FILE:-/dev/null}" - if ((${#flags[@]})); then - args+=("${flags[@]}") - fi - # Caller passthrough (after `--`): allowlisted deploy-args (e.g. --comment). - passthrough=() - read_nul_into passthrough "${DEPLOY_ARGS_FILE:-/dev/null}" - if ((${#passthrough[@]})); then - args+=(--) - args+=("${passthrough[@]}") - fi - ;; -esac +# Build the CLI argv for `deploy` mode into the global ARGV array. +build_deploy_argv() { + local cli_bin="$1" + local adapter="$2" + ARGV=("$cli_bin" deploy --adapter "$adapter") + # Typed adapter flags (before `--`): e.g. --service-id , --stage. + collect_nul "${DEPLOY_FLAGS_FILE:-/dev/null}" + ((${#COLLECTED[@]})) && ARGV+=("${COLLECTED[@]}") + # Caller passthrough (after `--`): allowlisted deploy-args, e.g. --comment. + collect_nul "${DEPLOY_ARGS_FILE:-/dev/null}" + if ((${#COLLECTED[@]})); then + ARGV+=(-- "${COLLECTED[@]}") + fi +} + +ARGV=() +main() { + local mode="${1:-}" + case "$mode" in + build | deploy) ;; + *) fail "usage: run-cli.sh build|deploy" ;; + esac + + local cli_bin="${EDGEZERO_CLI_BIN:?EDGEZERO_CLI_BIN is required}" + local adapter="${EDGEZERO_ADAPTER:?EDGEZERO_ADAPTER is required}" + local working_directory="${EDGEZERO_WORKING_DIRECTORY:?EDGEZERO_WORKING_DIRECTORY is required}" + local manifest="${EDGEZERO_MANIFEST_PATH:-}" + require_cmd "$cli_bin" -if [[ -n "$MANIFEST" ]]; then - export EDGEZERO_MANIFEST="$MANIFEST" -else - unset EDGEZERO_MANIFEST || true -fi + case "$mode" in + build) build_build_argv "$cli_bin" "$adapter" ;; + deploy) build_deploy_argv "$cli_bin" "$adapter" ;; + esac + + if [[ -n "$manifest" ]]; then + export EDGEZERO_MANIFEST="$manifest" + else + unset EDGEZERO_MANIFEST || true + fi + + cd "$working_directory" + echo "[edgezero-action] running $cli_bin $mode for adapter $adapter" >&2 + "${ARGV[@]}" +} -cd "$WORKING_DIRECTORY" -echo "[edgezero-action] running $CLI_BIN $MODE for adapter $ADAPTER" -"${args[@]}" +main "$@" diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh new file mode 100755 index 00000000..0f8190ae --- /dev/null +++ b/.github/actions/deploy-core/tests/run.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Contract tests for the EdgeZero deploy actions. +# +# Pure Bash: no Python, no network, no live provider credentials. Every test +# runs against temp dirs and fake binaries, so it is safe in CI and locally. + +REPO_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) +WORK_DIR=$(mktemp -d) +readonly REPO_ROOT WORK_DIR +readonly ACTIONS_DIR="$REPO_ROOT/.github/actions" +readonly CORE_SCRIPTS="$ACTIONS_DIR/deploy-core/scripts" +trap 'rm -rf "$WORK_DIR"' EXIT + +# --------------------------------------------------------------------------- +# Tiny assertion harness +# --------------------------------------------------------------------------- +tests_passed=0 +tests_failed=0 + +pass() { + tests_passed=$((tests_passed + 1)) + printf ' \033[32mok\033[0m %s\n' "$1" +} + +fail() { + tests_failed=$((tests_failed + 1)) + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 +} + +# assert_succeeds "" +assert_succeeds() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then pass "$description"; else fail "$description"; fi +} + +# assert_fails "" +assert_fails() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then fail "$description (expected non-zero exit)"; else pass "$description"; fi +} + +# assert_equals "" "" "" +assert_equals() { + local description="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + pass "$description" + else + fail "$description" + diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") >&2 || true + fi +} + +section() { printf '\n== %s ==\n' "$1"; } + +# --------------------------------------------------------------------------- +# validate-inputs.sh — provider-neutral input validation +# --------------------------------------------------------------------------- +# Runs validate-inputs in a clean environment. Inputs are supplied by the +# caller through the VALIDATE_* variables (all optional; sane defaults below). +run_validate_inputs() { + local state_dir + state_dir=$(mktemp -d "$WORK_DIR/validate.XXXXXX") + env -i PATH="$PATH" \ + INPUT_ADAPTER="${VALIDATE_ADAPTER:-fastly}" \ + INPUT_CACHE="${VALIDATE_CACHE:-false}" \ + INPUT_BUILD_MODE="${VALIDATE_BUILD_MODE:-auto}" \ + INPUT_BUILD_ARGS="${VALIDATE_BUILD_ARGS:-[]}" \ + INPUT_DEPLOY_ARGS="${VALIDATE_DEPLOY_ARGS:-[]}" \ + INPUT_DEPLOY_FLAGS="${VALIDATE_DEPLOY_FLAGS:-[]}" \ + INPUT_PROVIDER_ENV_CLEAR="${VALIDATE_PROVIDER_ENV_CLEAR:-[]}" \ + INPUT_DEPLOY_ARG_ALLOW="${VALIDATE_ALLOW:-}" \ + EDGEZERO_ACTION_STATE_DIR="$state_dir" \ + GITHUB_OUTPUT="$state_dir/output.txt" \ + bash "$CORE_SCRIPTS/validate-inputs.sh" +} + +test_validate_inputs() { + section "validate-inputs" + VALIDATE_ADAPTER=fastly assert_succeeds "accepts a well-formed adapter" run_validate_inputs + VALIDATE_ADAPTER=FASTLY assert_fails "rejects a malformed adapter" run_validate_inputs + VALIDATE_CACHE=maybe assert_fails "rejects a non-boolean cache" run_validate_inputs + VALIDATE_DEPLOY_ARGS='["--comment","hi"]' VALIDATE_ALLOW='--comment' \ + assert_succeeds "allows an allowlisted deploy-arg (--comment)" run_validate_inputs + VALIDATE_DEPLOY_ARGS='["--service-id","x"]' VALIDATE_ALLOW='--comment' \ + assert_fails "rejects a non-allowlisted deploy-arg (--service-id)" run_validate_inputs + VALIDATE_DEPLOY_ARGS='"not-an-array"' assert_fails "rejects non-array deploy-args" run_validate_inputs + VALIDATE_BUILD_ARGS='[1,2]' assert_fails "rejects non-string build-args" run_validate_inputs +} + +# --------------------------------------------------------------------------- +# run-cli.sh — CLI argv construction +# --------------------------------------------------------------------------- +# Installs a fake CLI that records its argv, then asserts run-cli places typed +# deploy-flags before `--` and caller passthrough after `--`. +test_run_cli_argv() { + section "run-cli argv" + + local bin_dir="$WORK_DIR/bin" + local argv_file="$WORK_DIR/recorded-argv.txt" + local app_dir="$WORK_DIR/app" + mkdir -p "$bin_dir" "$app_dir" + + cat >"$bin_dir/fakecli" <"$argv_file" +EOF + chmod +x "$bin_dir/fakecli" + + # NUL-delimited argument files, exactly as validate-inputs would emit them. + printf -- '--service-id\0abc\0--stage\0' >"$WORK_DIR/deploy-flags.nul" + printf -- '--comment\0hello\0' >"$WORK_DIR/deploy-args.nul" + + if env -i PATH="$bin_dir:$PATH" \ + EDGEZERO_CLI_BIN=fakecli \ + EDGEZERO_ADAPTER=fastly \ + EDGEZERO_WORKING_DIRECTORY="$app_dir" \ + DEPLOY_FLAGS_FILE="$WORK_DIR/deploy-flags.nul" \ + DEPLOY_ARGS_FILE="$WORK_DIR/deploy-args.nul" \ + bash "$CORE_SCRIPTS/run-cli.sh" deploy >/dev/null 2>&1; then + local expected + expected=$'deploy\n--adapter\nfastly\n--service-id\nabc\n--stage\n--\n--comment\nhello' + assert_equals "flags precede --, passthrough follows --" "$expected" "$(cat "$argv_file")" + else + fail "run-cli deploy failed to execute" + fi +} + +# --------------------------------------------------------------------------- +# download-cli.sh — self-describing artifact +# --------------------------------------------------------------------------- +# Builds a fake artifact tar (binary + cli-meta.json) and asserts download-cli +# extracts it and surfaces the metadata. +test_download_cli_metadata() { + section "download-cli metadata" + + local artifact_dir="$WORK_DIR/artifact" + local stage_dir="$artifact_dir/stage" + mkdir -p "$stage_dir" + + cat >"$stage_dir/myapp-cli" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$stage_dir/myapp-cli" + printf '{"cli-bin":"myapp-cli","cli-version":"1.2.3","cli-package":"myapp-cli"}\n' \ + >"$stage_dir/cli-meta.json" + tar -C "$stage_dir" -cf "$artifact_dir/edgezero-cli.tar" myapp-cli cli-meta.json + + local output_file="$WORK_DIR/download-output.txt" + if env -i PATH="$PATH" \ + EDGEZERO_CLI_ARTIFACT_DIR="$artifact_dir" \ + EDGEZERO_TOOL_ROOT="$WORK_DIR/tools" \ + GITHUB_OUTPUT="$output_file" \ + GITHUB_PATH="$WORK_DIR/download-path.txt" \ + bash "$CORE_SCRIPTS/download-cli.sh" >/dev/null 2>&1; then + if grep -qx 'cli-bin=myapp-cli' "$output_file" && grep -qx 'cli-version=1.2.3' "$output_file"; then + pass "extracts the tar and reads cli-meta.json" + else + fail "download-cli did not surface the expected metadata" + fi + else + fail "download-cli failed to execute" + fi +} + +# --------------------------------------------------------------------------- +# versions.json — pinned Fastly CLI metadata +# --------------------------------------------------------------------------- +# The pinned Fastly version must agree with .tool-versions and the checksum +# must be a well-formed SHA-256 (replaces the old Python metadata check). +check_fastly_versions() { + command -v jq >/dev/null 2>&1 || return 0 + local versions_json="$ACTIONS_DIR/deploy-fastly/versions.json" + local pinned tool_versions_entry checksum + pinned=$(jq -er '.fastly.version' "$versions_json") + tool_versions_entry=$(awk '$1 == "fastly" { print $2 }' "$REPO_ROOT/.tool-versions") + [[ "$pinned" == "$tool_versions_entry" ]] || return 1 + checksum=$(jq -er '.fastly.linux_amd64.sha256' "$versions_json") + [[ ${#checksum} -eq 64 && "$checksum" =~ ^[0-9a-f]+$ ]] +} + +test_fastly_versions() { + section "Fastly versions.json" + assert_succeeds "pinned version matches .tool-versions and sha256 is well-formed" check_fastly_versions +} + +# --------------------------------------------------------------------------- +main() { + test_validate_inputs + test_run_cli_argv + test_download_cli_metadata + test_fastly_versions + + printf '\nPassed: %d Failed: %d\n' "$tests_passed" "$tests_failed" + [[ "$tests_failed" -eq 0 ]] +} + +main "$@" From 344d244e0284c43609f0aa2c994ebb5f137115e5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:56:21 -0700 Subject: [PATCH 09/26] impl(actions): consistent main() structure, CI workflow, use setup-rust-toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../actions/deploy-core/scripts/cleanup.sh | 25 ++- .../deploy-core/scripts/download-cli.sh | 64 ++++-- .../deploy-core/scripts/install-rust.sh | 16 -- .../deploy-core/scripts/resolve-project.sh | 205 ++++++++++-------- .../deploy-core/scripts/validate-inputs.sh | 165 +++++++------- .../deploy-core/scripts/write-summary.sh | 42 ++-- .../deploy-core/tests/check-action-pins.sh | 43 ++++ .github/actions/deploy-fastly/action.yml | 16 +- .../deploy-fastly/scripts/install-fastly.sh | 93 +++++--- .github/workflows/deploy-action.yml | 155 +++++++++++++ 10 files changed, 538 insertions(+), 286 deletions(-) delete mode 100755 .github/actions/deploy-core/scripts/install-rust.sh create mode 100755 .github/actions/deploy-core/tests/check-action-pins.sh create mode 100644 .github/workflows/deploy-action.yml diff --git a/.github/actions/deploy-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh index d7e21eec..cf82f376 100755 --- a/.github/actions/deploy-core/scripts/cleanup.sh +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -1,17 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -ROOT=${EDGEZERO_ACTION_STATE_DIR:-} -if [[ -n "$ROOT" && -d "$ROOT" ]]; then - rm -rf "$ROOT" -fi +# Removes action-owned temporary state, tool installs, and any provider auth +# state. Runs with `if: always()`, so it must tolerate partially-created dirs. -TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-} -if [[ -n "$TOOL_ROOT" && -d "$TOOL_ROOT" ]]; then - rm -rf "$TOOL_ROOT" -fi +remove_if_present() { + local dir="$1" + [[ -n "$dir" && -d "$dir" ]] && rm -rf "$dir" +} -# Remove action-owned Fastly auth state if future installers create it. -if [[ -n "${EDGEZERO_FASTLY_HOME:-}" && -d "$EDGEZERO_FASTLY_HOME" ]]; then - rm -rf "$EDGEZERO_FASTLY_HOME" -fi +main() { + remove_if_present "${EDGEZERO_ACTION_STATE_DIR:-}" + remove_if_present "${EDGEZERO_TOOL_ROOT:-}" + remove_if_present "${EDGEZERO_FASTLY_HOME:-}" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/download-cli.sh b/.github/actions/deploy-core/scripts/download-cli.sh index edff8a76..fc737bcb 100755 --- a/.github/actions/deploy-core/scripts/download-cli.sh +++ b/.github/actions/deploy-core/scripts/download-cli.sh @@ -4,39 +4,55 @@ set -euo pipefail # Extracts the build-cli artifact tar (downloaded by actions/download-artifact) # into an action-owned tool dir, preserving the executable bit, reads the # self-describing cli-meta.json, and prepends the dir to PATH for action steps. -# A wrapper-supplied CLI_BIN overrides the metadata's binary name. +# A wrapper-supplied INPUT_CLI_BIN overrides the metadata's binary name. +# +# Inputs (environment): +# EDGEZERO_CLI_ARTIFACT_DIR required dir containing the downloaded tar +# INPUT_CLI_BIN optional override for the binary name +# EDGEZERO_TOOL_ROOT optional install dir (defaults under RUNNER_TEMP) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -ARTIFACT_DIR=${EDGEZERO_CLI_ARTIFACT_DIR:?EDGEZERO_CLI_ARTIFACT_DIR is required} -CLI_BIN_OVERRIDE=${INPUT_CLI_BIN:-} -TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} -require_cmd jq -require_cmd tar +# Locate the single CLI tar produced by build-cli within the downloaded artifact. +find_cli_tarball() { + local artifact_dir="$1" + find "$artifact_dir" -maxdepth 2 -type f -name '*.tar' | head -n 1 +} -mkdir -p "$TOOL_ROOT/bin" +main() { + local artifact_dir="${EDGEZERO_CLI_ARTIFACT_DIR:?EDGEZERO_CLI_ARTIFACT_DIR is required}" + local cli_bin_override="${INPUT_CLI_BIN:-}" + local tool_root="${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" -# The artifact contains a single tar (built by build-cli). Locate it. -TARBALL=$(find "$ARTIFACT_DIR" -maxdepth 2 -type f -name '*.tar' | head -n 1) -[[ -n "$TARBALL" ]] || fail "no CLI tar found under the downloaded artifact at '$ARTIFACT_DIR'" + require_cmd jq + require_cmd tar + mkdir -p "$tool_root/bin" -tar -xf "$TARBALL" -C "$TOOL_ROOT/bin" -[[ -f "$TOOL_ROOT/bin/cli-meta.json" ]] || fail "CLI artifact is missing cli-meta.json" + local tarball + tarball=$(find_cli_tarball "$artifact_dir") + [[ -n "$tarball" ]] || fail "no CLI tar found under the downloaded artifact at '$artifact_dir'" -META_BIN=$(jq -er '."cli-bin"' "$TOOL_ROOT/bin/cli-meta.json") || fail "cli-meta.json has no cli-bin" -CLI_VERSION=$(jq -er '."cli-version"' "$TOOL_ROOT/bin/cli-meta.json") || fail "cli-meta.json has no cli-version" -CLI_BIN=${CLI_BIN_OVERRIDE:-$META_BIN} + tar -xf "$tarball" -C "$tool_root/bin" + [[ -f "$tool_root/bin/cli-meta.json" ]] || fail "CLI artifact is missing cli-meta.json" -[[ -f "$TOOL_ROOT/bin/$CLI_BIN" ]] || fail "CLI binary '$CLI_BIN' not present in the artifact" -chmod +x "$TOOL_ROOT/bin/$CLI_BIN" -"$TOOL_ROOT/bin/$CLI_BIN" --help >/dev/null 2>&1 || fail "downloaded CLI '$CLI_BIN' did not run '--help'" + local meta_bin cli_version cli_bin + meta_bin=$(jq -er '."cli-bin"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-bin" + cli_version=$(jq -er '."cli-version"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-version" + cli_bin="${cli_bin_override:-$meta_bin}" -printf '%s\n' "$TOOL_ROOT/bin" >>"${GITHUB_PATH:-/dev/null}" -export PATH="$TOOL_ROOT/bin:$PATH" + [[ -f "$tool_root/bin/$cli_bin" ]] || fail "CLI binary '$cli_bin' not present in the artifact" + chmod +x "$tool_root/bin/$cli_bin" + "$tool_root/bin/$cli_bin" --help >/dev/null 2>&1 || fail "downloaded CLI '$cli_bin' did not run '--help'" -notice "using app CLI '$CLI_BIN' v$CLI_VERSION from artifact" -append_output cli-bin "$CLI_BIN" -append_output cli-version "$CLI_VERSION" -append_env EDGEZERO_TOOL_ROOT "$TOOL_ROOT" + printf '%s\n' "$tool_root/bin" >>"${GITHUB_PATH:-/dev/null}" + export PATH="$tool_root/bin:$PATH" + + notice "using app CLI '$cli_bin' v$cli_version from artifact" + append_output cli-bin "$cli_bin" + append_output cli-version "$cli_version" + append_env EDGEZERO_TOOL_ROOT "$tool_root" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/install-rust.sh b/.github/actions/deploy-core/scripts/install-rust.sh deleted file mode 100755 index 31d3ba3a..00000000 --- a/.github/actions/deploy-core/scripts/install-rust.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Installs the application Rust toolchain plus the wrapper-provided concrete -# target. The engine never maps adapter -> target; the wrapper supplies it. - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=common.sh -source "$SCRIPT_DIR/common.sh" - -RUST_TOOLCHAIN=${RUST_TOOLCHAIN:?RUST_TOOLCHAIN is required} -TARGET=${RUST_TARGET:?RUST_TARGET is required (wrapper-provided concrete target)} -require_cmd rustup -rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal -rustup target add "$TARGET" --toolchain "$RUST_TOOLCHAIN" -append_env RUSTUP_TOOLCHAIN "$RUST_TOOLCHAIN" diff --git a/.github/actions/deploy-core/scripts/resolve-project.sh b/.github/actions/deploy-core/scripts/resolve-project.sh index 0de2ae37..5bc9544b 100755 --- a/.github/actions/deploy-core/scripts/resolve-project.sh +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -5,134 +5,157 @@ set -euo pipefail # root (source revision + dirty-source guard) from the Cargo workspace root # (Cargo.lock hash + target/ cache), so nested-workspace monorepos cache the # right artifacts. Provider-neutral: no provider names appear here. +# +# Inputs (environment): INPUT_WORKING_DIRECTORY, INPUT_MANIFEST, +# INPUT_RUST_TOOLCHAIN, INPUT_TARGET (required), INPUT_BUILD_MODE, INPUT_CACHE, +# EDGEZERO_ACTION_ROOT (required), EDGEZERO_CLI_VERSION. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -WORKSPACE=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} -ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} -WORKING_DIRECTORY=${INPUT_WORKING_DIRECTORY:-.} -MANIFEST=${INPUT_MANIFEST:-} -RUST_TOOLCHAIN_INPUT=${INPUT_RUST_TOOLCHAIN:-auto} -RUST_TARGET=${INPUT_TARGET:?INPUT_TARGET is required (wrapper-provided concrete target)} -CACHE=${INPUT_CACHE:-false} -CLI_VERSION=${EDGEZERO_CLI_VERSION:-unknown} - -require_cmd git -require_cmd cargo - -WORKSPACE_REAL=$(canonical_path "$WORKSPACE") -APP_INPUT="$WORKSPACE/$WORKING_DIRECTORY" -[[ -d "$APP_INPUT" ]] || fail "working-directory '$WORKING_DIRECTORY' does not exist or is not a directory" -APP_DIR=$(canonical_path "$APP_INPUT") -is_under "$WORKSPACE_REAL" "$APP_DIR" || fail "input 'working-directory' must resolve inside github.workspace" -APP_REL=$(relative_to "$WORKSPACE_REAL" "$APP_DIR") - -if [[ -n "$MANIFEST" ]]; then - MANIFEST_INPUT="$APP_DIR/$MANIFEST" - [[ -f "$MANIFEST_INPUT" ]] || fail "manifest '$APP_REL/$MANIFEST' does not exist or is not a regular file" - MANIFEST_PATH=$(canonical_path "$MANIFEST_INPUT") - is_under "$WORKSPACE_REAL" "$MANIFEST_PATH" || fail "input 'manifest' must resolve inside github.workspace" - MANIFEST_REL=$(relative_to "$WORKSPACE_REAL" "$MANIFEST_PATH") -else - MANIFEST_PATH="" - MANIFEST_REL="EdgeZero default discovery" -fi - -# --- Git root: source revision + dirty-source guard --------------------------- -APP_GIT_ROOT=$(git -C "$APP_DIR" rev-parse --show-toplevel 2>/dev/null || true) -[[ -n "$APP_GIT_ROOT" ]] || fail "working-directory '$APP_REL' is not inside a Git repository" -APP_GIT_ROOT=$(canonical_path "$APP_GIT_ROOT") -is_under "$WORKSPACE_REAL" "$APP_GIT_ROOT" || fail "application Git root must resolve inside github.workspace" -SOURCE_REVISION=$(git -C "$APP_GIT_ROOT" rev-parse HEAD) -if ! git -C "$APP_GIT_ROOT" diff --quiet --ignore-submodules -- || - ! git -C "$APP_GIT_ROOT" diff --cached --quiet --ignore-submodules -- || - [[ -n "$(git -C "$APP_GIT_ROOT" ls-files --others --exclude-standard)" ]]; then - fail "deployments require committed source; working tree for '$APP_REL' is dirty" -fi - -# --- Cargo workspace root: lockfile + target/ + cache ------------------------- -if ! WORKSPACE_MANIFEST=$(cd "$APP_DIR" && cargo locate-project --workspace --message-format plain 2>/dev/null); then - WORKSPACE_MANIFEST="" -fi -[[ -n "$WORKSPACE_MANIFEST" ]] || fail "could not locate the Cargo workspace root from '$APP_REL'" -CARGO_WS_ROOT=$(canonical_path "$(dirname "$WORKSPACE_MANIFEST")") -LOCKFILE="$CARGO_WS_ROOT/Cargo.lock" -if [[ "$CACHE" == "true" && ! -f "$LOCKFILE" ]]; then - fail "cache is enabled but Cargo.lock was not found at the Cargo workspace root ($CARGO_WS_ROOT); exact-key caching requires Cargo.lock" -fi -LOCK_HASH="none" -[[ -f "$LOCKFILE" ]] && LOCK_HASH=$(sha256_file "$LOCKFILE") -TARGET_DIR="$CARGO_WS_ROOT/target" - -# --- Rust toolchain resolution (input > rustup files > .tool-versions) -------- -parse_rust_toolchain_file() { +# --- Rust toolchain resolution helpers --------------------------------------- +parse_toolchain_from_channel_file() { local value value=$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$1" | awk 'NF { print; exit }') [[ -n "$value" ]] || fail "malformed Rust toolchain file: $1" printf '%s\n' "$value" } -parse_rust_toolchain_toml() { + +parse_toolchain_from_toml() { local value value=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*["'\''`]([^"'\''`]+)["'\''`][[:space:]]*$/\1/p' "$1" | head -n 1) [[ -n "$value" ]] || fail "malformed Rust toolchain TOML file: $1" printf '%s\n' "$value" } + +# Resolve the toolchain: explicit input > rustup files (walking to the Git root) +# > .tool-versions > the EdgeZero action repo fallback. resolve_rust_toolchain() { - if [[ "$RUST_TOOLCHAIN_INPUT" != "auto" ]]; then - [[ -n "$RUST_TOOLCHAIN_INPUT" ]] || fail "input 'rust-toolchain' cannot be empty" - printf '%s\n' "$RUST_TOOLCHAIN_INPUT" + local input="$1" app_dir="$2" git_root="$3" action_root="$4" + if [[ "$input" != "auto" ]]; then + [[ -n "$input" ]] || fail "input 'rust-toolchain' cannot be empty" + printf '%s\n' "$input" return fi - local directory="$APP_DIR" value + local directory="$app_dir" value while true; do if [[ -f "$directory/rust-toolchain.toml" ]]; then - parse_rust_toolchain_toml "$directory/rust-toolchain.toml" + parse_toolchain_from_toml "$directory/rust-toolchain.toml" return fi if [[ -f "$directory/rust-toolchain" ]]; then - parse_rust_toolchain_file "$directory/rust-toolchain" + parse_toolchain_from_channel_file "$directory/rust-toolchain" return fi if [[ -f "$directory/.tool-versions" ]] && value=$(read_tool_version "$directory/.tool-versions" rust) && [[ -n "$value" ]]; then printf '%s\n' "$value" return fi - [[ "$directory" == "$APP_GIT_ROOT" ]] && break - local next - next=$(dirname "$directory") - [[ "$next" == "$directory" ]] && break - directory="$next" + [[ "$directory" == "$git_root" ]] && break + local parent + parent=$(dirname "$directory") + [[ "$parent" == "$directory" ]] && break + directory="$parent" done - if [[ -f "$ACTION_ROOT/.tool-versions" ]] && value=$(read_tool_version "$ACTION_ROOT/.tool-versions" rust) && [[ -n "$value" ]]; then + if [[ -f "$action_root/.tool-versions" ]] && value=$(read_tool_version "$action_root/.tool-versions" rust) && [[ -n "$value" ]]; then printf '%s\n' "$value" return fi fail "could not resolve Rust toolchain; checked rust-toolchain.toml, rust-toolchain, .tool-versions; set input 'rust-toolchain' explicitly" } -RUST_TOOLCHAIN=$(resolve_rust_toolchain) - -CACHE_KEY="edgezero-deploy-${RUNNER_OS:-Linux}-${RUNNER_ARCH:-X64}-$(sanitize_ref "$RUST_TOOLCHAIN")-$(sanitize_ref "$RUST_TARGET")-$(sanitize_ref "$CLI_VERSION")-${SOURCE_REVISION}-${LOCK_HASH}" -effective_build_mode() { - case "${INPUT_BUILD_MODE:-auto}" in - auto) printf 'never\n' ;; +resolve_effective_build_mode() { + case "${1:-auto}" in + auto | never) printf 'never\n' ;; always) printf 'always\n' ;; - never) printf 'never\n' ;; *) fail "input 'build-mode' must be one of: auto, always, never" ;; esac } -EFFECTIVE_BUILD_MODE=$(effective_build_mode) - -append_output working-directory "$APP_DIR" -append_output working-directory-relative "$APP_REL" -append_output manifest "$MANIFEST_PATH" -append_output manifest-summary "$MANIFEST_REL" -append_output app-git-root "$APP_GIT_ROOT" -append_output cargo-workspace-root "$CARGO_WS_ROOT" -append_output source-revision "$SOURCE_REVISION" -append_output rust-toolchain "$RUST_TOOLCHAIN" -append_output effective-build-mode "$EFFECTIVE_BUILD_MODE" -append_output cache-key "$CACHE_KEY" -append_output cache-path "$TARGET_DIR" + +# Record the source revision and fail on a dirty working tree. +assert_committed_source() { + local git_root="$1" app_rel="$2" + if ! git -C "$git_root" diff --quiet --ignore-submodules -- || + ! git -C "$git_root" diff --cached --quiet --ignore-submodules -- || + [[ -n "$(git -C "$git_root" ls-files --others --exclude-standard)" ]]; then + fail "deployments require committed source; working tree for '$app_rel' is dirty" + fi +} + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local action_root="${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required}" + local working_directory="${INPUT_WORKING_DIRECTORY:-.}" + local manifest="${INPUT_MANIFEST:-}" + local rust_toolchain_input="${INPUT_RUST_TOOLCHAIN:-auto}" + local target="${INPUT_TARGET:?INPUT_TARGET is required (wrapper-provided concrete target)}" + local cache="${INPUT_CACHE:-false}" + local cli_version="${EDGEZERO_CLI_VERSION:-unknown}" + + require_cmd git + require_cmd cargo + + # Application directory, confined to github.workspace. + local workspace_real app_dir app_rel + workspace_real=$(canonical_path "$workspace") + [[ -d "$workspace/$working_directory" ]] || fail "working-directory '$working_directory' does not exist or is not a directory" + app_dir=$(canonical_path "$workspace/$working_directory") + is_under "$workspace_real" "$app_dir" || fail "input 'working-directory' must resolve inside github.workspace" + app_rel=$(relative_to "$workspace_real" "$app_dir") + + # Optional explicit manifest. + local manifest_path manifest_summary + if [[ -n "$manifest" ]]; then + [[ -f "$app_dir/$manifest" ]] || fail "manifest '$app_rel/$manifest' does not exist or is not a regular file" + manifest_path=$(canonical_path "$app_dir/$manifest") + is_under "$workspace_real" "$manifest_path" || fail "input 'manifest' must resolve inside github.workspace" + manifest_summary=$(relative_to "$workspace_real" "$manifest_path") + else + manifest_path="" + manifest_summary="EdgeZero default discovery" + fi + + # Git root: source revision + dirty-source guard. + local git_root source_revision + git_root=$(git -C "$app_dir" rev-parse --show-toplevel 2>/dev/null || true) + [[ -n "$git_root" ]] || fail "working-directory '$app_rel' is not inside a Git repository" + git_root=$(canonical_path "$git_root") + is_under "$workspace_real" "$git_root" || fail "application Git root must resolve inside github.workspace" + source_revision=$(git -C "$git_root" rev-parse HEAD) + assert_committed_source "$git_root" "$app_rel" + + # Cargo workspace root: lockfile + target/ + cache. + local cargo_ws_manifest cargo_ws_root lockfile lock_hash target_dir + if ! cargo_ws_manifest=$(cd "$app_dir" && cargo locate-project --workspace --message-format plain 2>/dev/null); then + cargo_ws_manifest="" + fi + [[ -n "$cargo_ws_manifest" ]] || fail "could not locate the Cargo workspace root from '$app_rel'" + cargo_ws_root=$(canonical_path "$(dirname "$cargo_ws_manifest")") + lockfile="$cargo_ws_root/Cargo.lock" + if [[ "$cache" == "true" && ! -f "$lockfile" ]]; then + fail "cache is enabled but Cargo.lock was not found at the Cargo workspace root ($cargo_ws_root); exact-key caching requires Cargo.lock" + fi + lock_hash="none" + [[ -f "$lockfile" ]] && lock_hash=$(sha256_file "$lockfile") + target_dir="$cargo_ws_root/target" + + local rust_toolchain effective_build_mode cache_key + rust_toolchain=$(resolve_rust_toolchain "$rust_toolchain_input" "$app_dir" "$git_root" "$action_root") + effective_build_mode=$(resolve_effective_build_mode "${INPUT_BUILD_MODE:-auto}") + cache_key="edgezero-deploy-${RUNNER_OS:-Linux}-${RUNNER_ARCH:-X64}-$(sanitize_ref "$rust_toolchain")-$(sanitize_ref "$target")-$(sanitize_ref "$cli_version")-${source_revision}-${lock_hash}" + + append_output working-directory "$app_dir" + append_output working-directory-relative "$app_rel" + append_output manifest "$manifest_path" + append_output manifest-summary "$manifest_summary" + append_output app-git-root "$git_root" + append_output cargo-workspace-root "$cargo_ws_root" + append_output source-revision "$source_revision" + append_output rust-toolchain "$rust_toolchain" + append_output effective-build-mode "$effective_build_mode" + append_output cache-key "$cache_key" + append_output cache-path "$target_dir" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/validate-inputs.sh b/.github/actions/deploy-core/scripts/validate-inputs.sh index 5b98f1fb..2eab4f84 100755 --- a/.github/actions/deploy-core/scripts/validate-inputs.sh +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -5,104 +5,109 @@ set -euo pipefail # parameters into NUL-delimited files, applies the wrapper-supplied deploy-arg # allowlist, and validates booleans. It never learns provider credential names # or provider CLI flags — those arrive from the wrapper as opaque data. +# +# Inputs (environment): INPUT_ADAPTER, INPUT_BUILD_MODE, INPUT_CACHE, +# INPUT_BUILD_ARGS, INPUT_DEPLOY_ARGS, INPUT_DEPLOY_FLAGS, +# INPUT_PROVIDER_ENV_CLEAR, INPUT_DEPLOY_ARG_ALLOW, EDGEZERO_RUNNER_OS/ARCH. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -ADAPTER=${INPUT_ADAPTER:-} -BUILD_MODE=${INPUT_BUILD_MODE:-auto} -CACHE=${INPUT_CACHE:-false} -BUILD_ARGS=${INPUT_BUILD_ARGS:-[]} -DEPLOY_ARGS=${INPUT_DEPLOY_ARGS:-[]} -DEPLOY_FLAGS=${INPUT_DEPLOY_FLAGS:-[]} -PROVIDER_ENV_CLEAR=${INPUT_PROVIDER_ENV_CLEAR:-[]} -# Space-separated list of value-taking flags the caller's deploy-args may use -# (wrapper-supplied). Empty means no caller deploy-args are permitted. -DEPLOY_ARG_ALLOW=${INPUT_DEPLOY_ARG_ALLOW:-} -RUNNER_OS_VALUE=${EDGEZERO_RUNNER_OS:-} -RUNNER_ARCH_VALUE=${EDGEZERO_RUNNER_ARCH:-} - -if [[ -n "$RUNNER_OS_VALUE" || -n "$RUNNER_ARCH_VALUE" ]]; then - [[ "$RUNNER_OS_VALUE" == "Linux" && "$RUNNER_ARCH_VALUE" == "X64" ]] || - fail "the EdgeZero deploy engine supports only Linux x86-64 runners; received ${RUNNER_OS_VALUE:-unknown}/${RUNNER_ARCH_VALUE:-unknown}" -fi - -# Well-formedness only: the CLI validates whether the adapter is actually -# supported (there is no engine allowlist). -[[ -n "$ADAPTER" ]] || fail "internal parameter 'adapter' is required" -[[ "$ADAPTER" =~ ^[a-z][a-z0-9-]*$ ]] || fail "adapter '$ADAPTER' is malformed; expected a lowercase token like 'fastly'" - -case "$BUILD_MODE" in - auto | always | never) ;; - *) fail "input 'build-mode' must be one of: auto, always, never" ;; -esac - -case "$CACHE" in - true | false) ;; - *) fail "input 'cache' must be exactly 'true' or 'false'" ;; -esac - -require_cmd jq - -parse_args() { - local name="$1" value="$2" out="$3" - if ! printf '%s' "$value" | jq -e 'type == "array"' >/dev/null 2>&1; then +# Fail unless the runner is the tested Linux x86-64 environment. +require_supported_runner() { + local os="$1" arch="$2" + [[ -z "$os" && -z "$arch" ]] && return 0 + [[ "$os" == "Linux" && "$arch" == "X64" ]] || + fail "the EdgeZero deploy engine supports only Linux x86-64 runners; received ${os:-unknown}/${arch:-unknown}" +} + +# Parse a JSON string array into a NUL-delimited file, rejecting non-arrays, +# non-string entries, and embedded NUL bytes. +parse_json_string_array() { + local name="$1" value="$2" out_file="$3" + printf '%s' "$value" | jq -e 'type == "array"' >/dev/null 2>&1 || fail "parameter '$name' must be a JSON array of strings" - fi - if ! printf '%s' "$value" | jq -e 'all(.[]; type == "string")' >/dev/null; then + printf '%s' "$value" | jq -e 'all(.[]; type == "string")' >/dev/null || fail "every element of parameter '$name' must be a string" - fi if printf '%s' "$value" | jq -e 'any(.[]; contains("\u0000"))' >/dev/null; then fail "parameter '$name' contains a NUL byte, which cannot be passed as an OS argument" fi - printf '%s' "$value" | jq -jr '.[] | ., "\u0000"' >"$out" + printf '%s' "$value" | jq -jr '.[] | ., "\u0000"' >"$out_file" } -STATE_DIR=${EDGEZERO_ACTION_STATE_DIR:-${RUNNER_TEMP:-/tmp}/edgezero-action-state} -mkdir -p "$STATE_DIR" -BUILD_ARGS_FILE="$STATE_DIR/build-args.nul" -DEPLOY_ARGS_FILE="$STATE_DIR/deploy-args.nul" -DEPLOY_FLAGS_FILE="$STATE_DIR/deploy-flags.nul" -PROVIDER_ENV_CLEAR_FILE="$STATE_DIR/provider-env-clear.nul" -parse_args "build-args" "$BUILD_ARGS" "$BUILD_ARGS_FILE" -parse_args "deploy-args" "$DEPLOY_ARGS" "$DEPLOY_ARGS_FILE" -parse_args "deploy-flags" "$DEPLOY_FLAGS" "$DEPLOY_FLAGS_FILE" -parse_args "provider-env-clear" "$PROVIDER_ENV_CLEAR" "$PROVIDER_ENV_CLEAR_FILE" - -# Apply the wrapper-supplied deploy-arg allowlist. Each permitted flag accepts -# either `--flag=value` (one token) or `--flag value` (two tokens). -validate_deploy_args_allowlist() { - local file="$1" - local -a allowed=() - read -r -a allowed <<<"$DEPLOY_ARG_ALLOW" - local item position=0 expect_value=false - while IFS= read -r -d '' item; do +# Enforce the wrapper's deploy-arg allowlist. Each permitted flag accepts either +# `--flag=value` (one token) or `--flag value` (two tokens). +enforce_deploy_arg_allowlist() { + local args_file="$1" allow_list="$2" + local -a permitted=() + read -r -a permitted <<<"$allow_list" + + local arg position=0 expecting_value=false + while IFS= read -r -d '' arg; do position=$((position + 1)) - if [[ "$expect_value" == "true" ]]; then - expect_value=false + if [[ "$expecting_value" == "true" ]]; then + expecting_value=false continue fi - local flag="${item%%=*}" - local matched=false permitted - for permitted in "${allowed[@]}"; do - if [[ "$flag" == "$permitted" ]]; then + local flag="${arg%%=*}" matched=false candidate + for candidate in "${permitted[@]}"; do + if [[ "$flag" == "$candidate" ]]; then matched=true - [[ "$item" == *=* ]] || expect_value=true + [[ "$arg" == *=* ]] || expecting_value=true break fi done [[ "$matched" == "true" ]] || - fail "deploy-args allows only: ${DEPLOY_ARG_ALLOW:-} (as '--flag value' or '--flag=value'); rejected argument $position" - done <"$file" - [[ "$expect_value" == "false" ]] || fail "a value-taking deploy-arg flag is missing its value" + fail "deploy-args allows only: ${allow_list:-} (as '--flag value' or '--flag=value'); rejected argument $position" + done <"$args_file" + [[ "$expecting_value" == "false" ]] || fail "a value-taking deploy-arg flag is missing its value" +} + +main() { + local adapter="${INPUT_ADAPTER:-}" + local build_mode="${INPUT_BUILD_MODE:-auto}" + local cache="${INPUT_CACHE:-false}" + local deploy_arg_allow="${INPUT_DEPLOY_ARG_ALLOW:-}" + + require_supported_runner "${EDGEZERO_RUNNER_OS:-}" "${EDGEZERO_RUNNER_ARCH:-}" + + # Well-formedness only: the CLI decides whether the adapter is supported. + [[ -n "$adapter" ]] || fail "internal parameter 'adapter' is required" + [[ "$adapter" =~ ^[a-z][a-z0-9-]*$ ]] || fail "adapter '$adapter' is malformed; expected a lowercase token like 'fastly'" + + case "$build_mode" in + auto | always | never) ;; + *) fail "input 'build-mode' must be one of: auto, always, never" ;; + esac + case "$cache" in + true | false) ;; + *) fail "input 'cache' must be exactly 'true' or 'false'" ;; + esac + + require_cmd jq + + local state_dir="${EDGEZERO_ACTION_STATE_DIR:-${RUNNER_TEMP:-/tmp}/edgezero-action-state}" + mkdir -p "$state_dir" + local build_args_file="$state_dir/build-args.nul" + local deploy_args_file="$state_dir/deploy-args.nul" + local deploy_flags_file="$state_dir/deploy-flags.nul" + local provider_env_clear_file="$state_dir/provider-env-clear.nul" + + parse_json_string_array "build-args" "${INPUT_BUILD_ARGS:-[]}" "$build_args_file" + parse_json_string_array "deploy-args" "${INPUT_DEPLOY_ARGS:-[]}" "$deploy_args_file" + parse_json_string_array "deploy-flags" "${INPUT_DEPLOY_FLAGS:-[]}" "$deploy_flags_file" + parse_json_string_array "provider-env-clear" "${INPUT_PROVIDER_ENV_CLEAR:-[]}" "$provider_env_clear_file" + + enforce_deploy_arg_allowlist "$deploy_args_file" "$deploy_arg_allow" + + append_output adapter "$adapter" + append_output build-args-file "$build_args_file" + append_output deploy-args-file "$deploy_args_file" + append_output deploy-flags-file "$deploy_flags_file" + append_output provider-env-clear-file "$provider_env_clear_file" + append_output requested-build-mode "$build_mode" + append_output cache "$cache" } -validate_deploy_args_allowlist "$DEPLOY_ARGS_FILE" - -append_output adapter "$ADAPTER" -append_output build-args-file "$BUILD_ARGS_FILE" -append_output deploy-args-file "$DEPLOY_ARGS_FILE" -append_output deploy-flags-file "$DEPLOY_FLAGS_FILE" -append_output provider-env-clear-file "$PROVIDER_ENV_CLEAR_FILE" -append_output requested-build-mode "$BUILD_MODE" -append_output cache "$CACHE" + +main "$@" diff --git a/.github/actions/deploy-core/scripts/write-summary.sh b/.github/actions/deploy-core/scripts/write-summary.sh index bae59256..15625a0d 100755 --- a/.github/actions/deploy-core/scripts/write-summary.sh +++ b/.github/actions/deploy-core/scripts/write-summary.sh @@ -2,23 +2,27 @@ set -euo pipefail # Writes a non-sensitive GitHub step summary. Never emits credentials, full -# environments, or raw argument arrays. +# environments, or raw argument arrays. All values arrive via SUMMARY_* env. -SUMMARY=${GITHUB_STEP_SUMMARY:-} -[[ -n "$SUMMARY" ]] || exit 0 -{ - echo "## EdgeZero deploy" - echo - echo "| Field | Value |" - echo "| ----- | ----- |" - echo "| Adapter | ${SUMMARY_ADAPTER:-unknown} |" - echo "| Application directory | ${SUMMARY_WORKING_DIRECTORY:-unknown} |" - echo "| Source revision | ${SUMMARY_SOURCE_REVISION:-unknown} |" - echo "| Manifest | ${SUMMARY_MANIFEST:-EdgeZero default discovery} |" - echo "| Rust toolchain | ${SUMMARY_RUST_TOOLCHAIN:-unknown} |" - echo "| Target | ${SUMMARY_TARGET:-unknown} |" - echo "| CLI version | ${SUMMARY_CLI_VERSION:-unknown} |" - echo "| Effective build mode | ${SUMMARY_EFFECTIVE_BUILD_MODE:-unknown} |" - echo "| Cache | ${SUMMARY_CACHE:-false} |" - echo "| Result | ${SUMMARY_RESULT:-unknown} |" -} >>"$SUMMARY" +main() { + local summary_file="${GITHUB_STEP_SUMMARY:-}" + [[ -n "$summary_file" ]] || return 0 + { + echo "## EdgeZero deploy" + echo + echo "| Field | Value |" + echo "| ----- | ----- |" + echo "| Adapter | ${SUMMARY_ADAPTER:-unknown} |" + echo "| Application directory | ${SUMMARY_WORKING_DIRECTORY:-unknown} |" + echo "| Source revision | ${SUMMARY_SOURCE_REVISION:-unknown} |" + echo "| Manifest | ${SUMMARY_MANIFEST:-EdgeZero default discovery} |" + echo "| Rust toolchain | ${SUMMARY_RUST_TOOLCHAIN:-unknown} |" + echo "| Target | ${SUMMARY_TARGET:-unknown} |" + echo "| CLI version | ${SUMMARY_CLI_VERSION:-unknown} |" + echo "| Effective build mode | ${SUMMARY_EFFECTIVE_BUILD_MODE:-unknown} |" + echo "| Cache | ${SUMMARY_CACHE:-false} |" + echo "| Result | ${SUMMARY_RESULT:-unknown} |" + } >>"$summary_file" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/check-action-pins.sh b/.github/actions/deploy-core/tests/check-action-pins.sh new file mode 100755 index 00000000..afa195a7 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verifies every third-party `uses:` reference across the deploy actions and the +# deploy-action workflow is pinned to a concrete ref (a readable released tag or +# a full commit SHA) rather than a floating branch like @main or an unpinned +# reference. Local (`./...`) and docker refs are exempt. + +REPO_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) + +files=( + "$REPO_ROOT/.github/workflows/deploy-action.yml" + "$REPO_ROOT/.github/actions/build-cli/action.yml" + "$REPO_ROOT/.github/actions/deploy-core" + "$REPO_ROOT/.github/actions/deploy-fastly/action.yml" + "$REPO_ROOT/.github/actions/healthcheck-fastly/action.yml" + "$REPO_ROOT/.github/actions/rollback-fastly/action.yml" +) + +status=0 +while IFS= read -r line; do + # line format: :: + ref=$(printf '%s' "$line" | sed -nE 's/.*uses:[[:space:]]*//p' | tr -d '"'"'"'') + [[ -z "$ref" ]] && continue + case "$ref" in + ./* | docker://*) continue ;; + esac + if [[ ! "$ref" == *@* ]]; then + echo "::error::unpinned action reference (no @ref): $line" >&2 + status=1 + continue + fi + suffix="${ref##*@}" + case "$suffix" in + main | master | HEAD) + echo "::error::action pinned to a floating ref '@$suffix': $line" >&2 + status=1 + ;; + esac +done < <(grep -rEn '^[[:space:]]*(-[[:space:]]+)?uses:' "${files[@]}" 2>/dev/null || true) + +[[ "$status" -eq 0 ]] && echo "all third-party action references are pinned to a concrete ref" +exit "$status" diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 06eac528..2a1cacf0 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -129,14 +129,14 @@ runs: key: ${{ steps.resolve.outputs['cache-key'] }} path: ${{ steps.resolve.outputs['cache-path'] }} - - name: Install Rust - shell: bash - env: - RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} - RUST_TARGET: wasm32-wasip1 - FASTLY_API_TOKEN: "" - FASTLY_SERVICE_ID: "" - run: $GITHUB_ACTION_PATH/../deploy-core/scripts/install-rust.sh + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.resolve.outputs['rust-toolchain'] }} + target: wasm32-wasip1 + # Our resolve-project step owns exact-key target/ caching; disable the + # action's own cache to avoid double-caching and key drift. + cache: false - name: Install Fastly CLI id: install-fastly diff --git a/.github/actions/deploy-fastly/scripts/install-fastly.sh b/.github/actions/deploy-fastly/scripts/install-fastly.sh index 5debc582..9e6e9ac8 100644 --- a/.github/actions/deploy-fastly/scripts/install-fastly.sh +++ b/.github/actions/deploy-fastly/scripts/install-fastly.sh @@ -1,44 +1,65 @@ #!/usr/bin/env bash set -euo pipefail +# Installs the pinned Fastly CLI from an official release, verifying its SHA-256 +# checksum, into an action-owned dir on PATH. This is the Fastly wrapper's +# provider-tool responsibility; the provider-neutral engine never installs it. +# +# Inputs (environment): +# EDGEZERO_ACTION_ROOT optional repo root holding .tool-versions (defaults up) +# VERSIONS_JSON optional pinned metadata (defaults alongside this dir) + SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -ACTION_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) -ACTION_ROOT=${EDGEZERO_ACTION_ROOT:-$(cd -- "$ACTION_DIR/../../.." && pwd)} -VERSIONS_JSON=${VERSIONS_JSON:-$ACTION_DIR/versions.json} -TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} -mkdir -p "$TOOL_ROOT/bin" "$TOOL_ROOT/downloads" - -require_cmd jq -VERSION=$(json_get "$VERSIONS_JSON" fastly.version) -TOOL_VERSION=$(read_tool_version "$ACTION_ROOT/.tool-versions" fastly || true) -[[ -n "$TOOL_VERSION" ]] || fail "EdgeZero repository .tool-versions must contain a fastly entry" -[[ "$VERSION" == "$TOOL_VERSION" ]] || fail "Fastly version mismatch: versions.json has $VERSION but .tool-versions has $TOOL_VERSION" -URL=$(json_get "$VERSIONS_JSON" fastly.linux_amd64.url) -SHA256=$(json_get "$VERSIONS_JSON" fastly.linux_amd64.sha256) -ARCHIVE="$TOOL_ROOT/downloads/fastly-$VERSION-linux-amd64.tar.gz" - -case "$(uname -s)-$(uname -m)" in - Linux-x86_64|Linux-amd64) ;; - *) fail "Fastly v0 action supports only Linux x86-64 runners" ;; -esac - -if [[ ! -f "$ARCHIVE" ]]; then +require_linux_x86_64() { + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) fail "the Fastly wrapper supports only Linux x86-64 runners" ;; + esac +} + +main() { + local action_dir + action_dir=$(cd -- "$SCRIPT_DIR/.." && pwd) + local action_root="${EDGEZERO_ACTION_ROOT:-$(cd -- "$action_dir/../../.." && pwd)}" + local versions_json="${VERSIONS_JSON:-$action_dir/versions.json}" + local tool_root="${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" + + require_linux_x86_64 + require_cmd jq require_cmd curl - curl --fail --location --silent --show-error "$URL" --output "$ARCHIVE" -fi - -ACTUAL=$(sha256_file "$ARCHIVE") -[[ "$ACTUAL" == "$SHA256" ]] || fail "Fastly CLI checksum mismatch for version $VERSION" - -tar -xzf "$ARCHIVE" -C "$TOOL_ROOT/bin" fastly -chmod +x "$TOOL_ROOT/bin/fastly" -printf '%s\n' "$TOOL_ROOT/bin" >>"${GITHUB_PATH:-/dev/null}" -export PATH="$TOOL_ROOT/bin:$PATH" -PROVIDER_CLI_VERSION=$(fastly version 2>/dev/null || fastly --version 2>/dev/null || true) -PROVIDER_CLI_VERSION=${PROVIDER_CLI_VERSION%%$'\n'*} -[[ -n "$PROVIDER_CLI_VERSION" ]] || fail "installed Fastly CLI did not report a version" -printf '%s\n' "$PROVIDER_CLI_VERSION" -append_output provider-cli-version "$PROVIDER_CLI_VERSION" + mkdir -p "$tool_root/bin" "$tool_root/downloads" + + # The pinned version must agree with the repository .tool-versions policy. + local version tool_version url sha256 archive + version=$(json_get "$versions_json" fastly.version) + tool_version=$(read_tool_version "$action_root/.tool-versions" fastly || true) + [[ -n "$tool_version" ]] || fail "EdgeZero repository .tool-versions must contain a fastly entry" + [[ "$version" == "$tool_version" ]] || fail "Fastly version mismatch: versions.json has $version but .tool-versions has $tool_version" + + url=$(json_get "$versions_json" fastly.linux_amd64.url) + sha256=$(json_get "$versions_json" fastly.linux_amd64.sha256) + archive="$tool_root/downloads/fastly-$version-linux-amd64.tar.gz" + + [[ -f "$archive" ]] || curl --fail --location --silent --show-error "$url" --output "$archive" + + local actual + actual=$(sha256_file "$archive") + [[ "$actual" == "$sha256" ]] || fail "Fastly CLI checksum mismatch for version $version" + + tar -xzf "$archive" -C "$tool_root/bin" fastly + chmod +x "$tool_root/bin/fastly" + printf '%s\n' "$tool_root/bin" >>"${GITHUB_PATH:-/dev/null}" + export PATH="$tool_root/bin:$PATH" + + local provider_cli_version + provider_cli_version=$(fastly version 2>/dev/null || fastly --version 2>/dev/null || true) + provider_cli_version=${provider_cli_version%%$'\n'*} + [[ -n "$provider_cli_version" ]] || fail "installed Fastly CLI did not report a version" + notice "installed Fastly CLI: $provider_cli_version" + append_output provider-cli-version "$provider_cli_version" +} + +main "$@" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml new file mode 100644 index 00000000..c646217f --- /dev/null +++ b/.github/workflows/deploy-action.yml @@ -0,0 +1,155 @@ +name: Deploy actions + +on: + pull_request: + paths: + - .github/actions/build-cli/** + - .github/actions/deploy-core/** + - .github/actions/deploy-fastly/** + - .github/actions/healthcheck-fastly/** + - .github/actions/rollback-fastly/** + - .github/workflows/deploy-action.yml + - crates/edgezero-adapter/** + - crates/edgezero-adapter-fastly/** + - crates/edgezero-cli/** + - docs/guide/deploy-github-actions.md + - docs/specs/** + push: + branches: [main] + paths: + - .github/actions/build-cli/** + - .github/actions/deploy-core/** + - .github/actions/deploy-fastly/** + - .github/actions/healthcheck-fastly/** + - .github/actions/rollback-fastly/** + - .github/workflows/deploy-action.yml + - crates/edgezero-adapter/** + - crates/edgezero-adapter-fastly/** + - crates/edgezero-cli/** + - docs/guide/deploy-github-actions.md + - docs/specs/** + +permissions: + contents: read + +env: + ACTIONLINT_VERSION: 1.7.7 + ZIZMOR_VERSION: 1.16.3 + +jobs: + static-checks: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install actionlint (pinned release binary) + run: | + set -euo pipefail + url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl --fail --location --silent --show-error "$url" --output /tmp/actionlint.tar.gz + tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint + sudo install -m 0755 /tmp/actionlint /usr/local/bin/actionlint + + - name: Actionlint (workflows) + run: actionlint + + - name: Third-party actions pinned to a ref + run: .github/actions/deploy-core/tests/check-action-pins.sh + + - name: Install zizmor (cargo, no pip) + run: cargo install zizmor --version "${ZIZMOR_VERSION}" --locked + + - name: Zizmor security scan + run: | + zizmor --offline \ + .github/workflows/deploy-action.yml \ + .github/actions/build-cli/action.yml \ + .github/actions/deploy-fastly/action.yml \ + .github/actions/healthcheck-fastly/action.yml \ + .github/actions/rollback-fastly/action.yml + + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: ShellCheck action scripts + run: | + shellcheck -x \ + .github/actions/build-cli/scripts/*.sh \ + .github/actions/deploy-core/scripts/*.sh \ + .github/actions/deploy-core/tests/*.sh \ + .github/actions/deploy-fastly/scripts/*.sh + + - name: Bash contract tests + run: .github/actions/deploy-core/tests/run.sh + + - name: Validate docs + run: | + cd docs + npm ci + npm run format + npm run lint + npm run build + + composite-smoke: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Build CLI from the monorepo package + id: cli + uses: ./.github/actions/build-cli + with: + cli-package: edgezero-cli + cli-bin: edgezero + working-directory: . + + - name: Create fixture app + run: | + set -euo pipefail + mkdir -p fixture-app + cd fixture-app + git init -q + git config user.email test@example.com + git config user.name Test + cat >Cargo.toml <<'TOML' + [package] + name = "fixture-app" + version = "0.0.0" + edition = "2021" + TOML + mkdir -p src + echo 'fn main() {}' >src/main.rs + cargo generate-lockfile + # Fake fastly binary on PATH so `fastly compute deploy` writes a marker. + mkdir -p bin + cat >bin/fastly <<'FASTLY' + #!/usr/bin/env bash + printf '%s\n' "$@" >>"$GITHUB_WORKSPACE/fixture-app/fastly-argv.txt" + echo "SUCCESS: Deployed package (version 7)" + FASTLY + chmod +x bin/fastly + echo "$GITHUB_WORKSPACE/fixture-app/bin" >>"$GITHUB_PATH" + git add -A + git commit -q -m fixture + + - name: Deploy fixture (production) with local action + uses: ./.github/actions/deploy-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + working-directory: fixture-app + fastly-api-token: dummy-token + fastly-service-id: dummy-service + deploy-args: '["--comment","smoke"]' + + - name: Assert Fastly was invoked with the service id + run: | + set -euo pipefail + test -f fixture-app/fastly-argv.txt + grep -q -- 'dummy-service' fixture-app/fastly-argv.txt + grep -q -- 'compute' fixture-app/fastly-argv.txt From 06ac765db7a8071e6c48fea222dc8cc7890d9ddf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:05:39 -0700 Subject: [PATCH 10/26] impl(cli): Fastly staging lifecycle scaffolding (deploy --stage, healthcheck, rollback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=' line on stdout for deploy and staged deploy; 'rolled-back-to=' / 'healthy=' / 'status-code=' for the lifecycle commands. All gated behind fastly/cli features. (Implemented by subagent; tests/clippy/fmt verified.) --- crates/edgezero-adapter-axum/src/cli.rs | 7 + crates/edgezero-adapter-cloudflare/src/cli.rs | 7 + crates/edgezero-adapter-fastly/src/cli.rs | 765 ++++++++++++++++++ crates/edgezero-adapter-spin/src/cli.rs | 7 + crates/edgezero-adapter/src/registry.rs | 21 + crates/edgezero-cli/src/adapter.rs | 23 + crates/edgezero-cli/src/args.rs | 242 ++++++ crates/edgezero-cli/src/lib.rs | 132 ++- crates/edgezero-cli/src/main.rs | 2 + .../src/templates/cli/src/main.rs.hbs | 14 +- 10 files changed, 1215 insertions(+), 5 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 75caf585..4a3a218e 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -148,6 +148,13 @@ impl Adapter for AxumCliAdapter { AdapterAction::Build => build(args), AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // The Fastly staging lifecycle (spec §5.4) is Fastly-only. + AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "axum adapter does not support the Fastly staging lifecycle action {action:?} (spec §5.4)" + )), other => Err(format!("axum adapter does not support {other:?}")), } } diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index f858021c..9931c4f8 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -158,6 +158,13 @@ impl Adapter for CloudflareCliAdapter { }), AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // The Fastly staging lifecycle (spec §5.4) is Fastly-only. + AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "cloudflare adapter does not support the Fastly staging lifecycle action {action:?} (spec §5.4)" + )), other => Err(format!("cloudflare adapter does not support {other:?}")), } } diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index a3de1cba..4ec90c4e 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -4,6 +4,8 @@ use std::io::{ErrorKind, Write as _}; use std::path::{Path, PathBuf}; use std::process::Command; use std::process::Stdio; +use std::thread; +use std::time::Duration; use crate::chunked_config::{prepare_fastly_config_entries, resolve_fastly_config_value}; use ctor::ctor; @@ -120,6 +122,14 @@ static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ const FASTLY_INSTALL_HINT: &str = "install the Fastly CLI (https://www.fastly.com/documentation/reference/tools/cli/) and try again"; +/// Env var carrying the Fastly API token (read by the Fastly CLI and +/// forwarded to the Fastly API via the `Fastly-Key` header). Part of +/// the Fastly staging lifecycle (deploy-github-action spec §5.4). +const FASTLY_API_TOKEN_ENV: &str = "FASTLY_API_TOKEN"; +/// Env var carrying the default Fastly service id, used when +/// `--service-id` is not passed explicitly (spec §5.4). +const FASTLY_SERVICE_ID_ENV: &str = "FASTLY_SERVICE_ID"; + struct FastlyCliAdapter; /// Outcome of scanning `fastly config-store list --json` for a @@ -190,6 +200,11 @@ impl Adapter for FastlyCliAdapter { } AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // Fastly staging lifecycle (deploy-github-action spec §5.4). + AdapterAction::DeployStaged => deploy_staged(args), + AdapterAction::EmitVersion => emit_active_version(args), + AdapterAction::Healthcheck => healthcheck(args), + AdapterAction::Rollback => rollback(args), other => Err(format!("fastly adapter does not support {other:?}")), } } @@ -1386,6 +1401,541 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { Ok(()) } +// =================================================================== +// Fastly staging lifecycle (deploy-github-action spec §5.4) +// =================================================================== +// +// These entry points back the `deploy --stage`, `healthcheck`, and +// `rollback` app-CLI subcommands. They mirror the Fastly semantics of +// `stackpop/trusted-server-actions`: +// +// * staged deploy → build + `compute update --autoclone` (no +// activation) + `service-version stage`; emits the staged version. +// * production → `fastly compute deploy` runs via the manifest +// command; `emit_active_version` resolves the activated version. +// * healthcheck → curl the domain (production) or the version's +// resolved staging IP (`--staging`); non-zero exit when unhealthy. +// * rollback → activate `-1` (production) or deactivate +// `` (staging) via the Fastly API. +// +// **Version-output contract (spec §5.4.2):** deploy/stage print a +// single `version=` line to stdout (via `log::info!`, which the CLI +// logger emits verbatim). The `deploy-fastly` action greps that line +// to surface `fastly-version`. Rollback prints `rolled-back-to=`. +// +// Provider HTTP calls shell out to `curl` (matching +// trusted-server-actions and avoiding a WASM-incompatible HTTP client +// in the adapter). The `FASTLY_API_TOKEN` is passed to `curl` via a +// `--config -` stdin file rather than on argv, so it never appears in +// `ps` / `/proc//cmdline` (same discipline as +// `create_config_store_entry`'s `--stdin`). + +/// Value that follows `flag` in a `--flag value` arg slice, if present. +fn arg_value<'args>(args: &'args [String], flag: &str) -> Option<&'args str> { + args.iter() + .position(|arg| arg == flag) + .and_then(|idx| idx.checked_add(1)) + .and_then(|idx| args.get(idx)) + .map(String::as_str) +} + +/// Whether a boolean `flag` (e.g. `--staging`) is present in `args`. +fn arg_flag(args: &[String], flag: &str) -> bool { + args.iter().any(|arg| arg == flag) +} + +/// Copy of `args` with `--flag value` removed (both tokens). Used to +/// forward operator passthrough (e.g. `--comment`) to `fastly compute +/// update` without re-passing `--service-id`, which is threaded +/// explicitly. +fn args_without_flag_value(args: &[String], flag: &str) -> Vec { + let mut out = Vec::with_capacity(args.len()); + let mut skip = false; + for arg in args { + if skip { + skip = false; + continue; + } + if arg == flag { + skip = true; + continue; + } + out.push(arg.clone()); + } + out +} + +/// Resolve the target service id from `--service-id` or, failing that, +/// `FASTLY_SERVICE_ID`. +fn resolve_service_id(args: &[String]) -> Result { + if let Some(value) = arg_value(args, "--service-id") { + return Ok(value.to_owned()); + } + env::var(FASTLY_SERVICE_ID_ENV).map_err(|_err| { + format!("no service id: pass `--service-id ` or set {FASTLY_SERVICE_ID_ENV}") + }) +} + +/// Read the required Fastly API token from the environment. +fn require_token() -> Result { + env::var(FASTLY_API_TOKEN_ENV) + .map_err(|_err| format!("{FASTLY_API_TOKEN_ENV} must be set in the environment")) +} + +/// Whether an HTTP status counts as healthy (2xx/3xx). +fn is_healthy_status(code: u16) -> bool { + (200..400).contains(&code) +} + +/// The version to activate when rolling a production service back from +/// `version`. `None` when there is no earlier version (`version <= 1`). +fn previous_version(version: u64) -> Option { + (version > 1).then(|| version.saturating_sub(1)) +} + +/// Best-effort scan of Fastly CLI output for a trailing version number +/// (`... version 42`, `version=42`, etc.). Returns the LAST match, +/// which is the freshly-created draft in `compute update` output. +fn parse_fastly_version(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + let mut result = None; + for (idx, _) in lower.match_indices("version") { + let after = idx.saturating_add("version".len()); + let Some(rest) = lower.get(after..) else { + continue; + }; + let digits: String = rest + .chars() + .skip_while(|ch| !ch.is_ascii_digit()) + .take_while(char::is_ascii_digit) + .collect(); + if let Ok(parsed) = digits.parse::() { + result = Some(parsed); + } + } + result +} + +/// Parse `fastly service-version list --json` (or the Fastly API +/// `/service//version` array) for the `number` of the `active` +/// version. +fn parse_active_version(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + value.as_array()?.iter().find_map(|entry| { + (entry.get("active").and_then(serde_json::Value::as_bool) == Some(true)) + .then(|| entry.get("number").and_then(serde_json::Value::as_u64)) + .flatten() + }) +} + +/// Highest `number` in a version-list JSON array — the fallback for +/// resolving the just-created draft when output parsing fails. +fn parse_latest_version(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + value + .as_array()? + .iter() + .filter_map(|entry| entry.get("number").and_then(serde_json::Value::as_u64)) + .max() +} + +/// First staging IP found anywhere under a `staging_ips` array in the +/// Fastly API `domain?include=staging_ips` response. +fn parse_staging_ip(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + find_staging_ip(&value) +} + +fn find_staging_ip(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Object(map) => { + if let Some(ip) = map + .get("staging_ips") + .and_then(serde_json::Value::as_array) + .and_then(|arr| arr.iter().find_map(serde_json::Value::as_str)) + { + return Some(ip.to_owned()); + } + map.values().find_map(find_staging_ip) + } + serde_json::Value::Array(arr) => arr.iter().find_map(find_staging_ip), + serde_json::Value::Null + | serde_json::Value::Bool(_) + | serde_json::Value::Number(_) + | serde_json::Value::String(_) => None, + } +} + +/// Build the `curl` argv for a health probe. Production probes the +/// domain directly; staging reroutes the TLS connection to the +/// resolved staging IP via `--connect-to :::443`. +fn build_curl_probe_args(domain: &str, staging_ip: Option<&str>, timeout_secs: u64) -> Vec { + let mut args = vec![ + "-sS".to_owned(), + "-o".to_owned(), + "/dev/null".to_owned(), + "-w".to_owned(), + "%{http_code}".to_owned(), + "--max-time".to_owned(), + timeout_secs.to_string(), + ]; + if let Some(ip) = staging_ip { + args.push("--connect-to".to_owned()); + args.push(format!("::{ip}:443")); + } + args.push(format!("https://{domain}/")); + args +} + +/// Retry a health probe. Returns `Ok(code)` on the first healthy +/// status, or `Err((last_code, message))` after exhausting attempts. +/// `between` runs between attempts (not after the last) so it can be a +/// no-op in tests. +fn probe_with_retries( + retry: u32, + mut prober: P, + mut between: S, +) -> Result, String)> +where + P: FnMut() -> Result, + S: FnMut(), +{ + let attempts = retry.max(1); + let mut last_code = None; + let mut last_msg = "no probe attempts were made".to_owned(); + for attempt in 0..attempts { + match prober() { + Ok(code) if is_healthy_status(code) => return Ok(code), + Ok(code) => { + last_code = Some(code); + last_msg = format!("unhealthy HTTP status {code}"); + } + Err(err) => last_msg = err, + } + if attempt.saturating_add(1) < attempts { + between(); + } + } + Err((last_code, last_msg)) +} + +/// Run `fastly ` in `cwd`, inheriting stdio, and map a non-zero +/// exit to an error. +fn run_fastly_status(fastly_args: &[String], cwd: &Path) -> Result<(), String> { + let status = Command::new("fastly") + .args(fastly_args) + .current_dir(cwd) + .status() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to run fastly CLI: {err}") + } + })?; + if status.success() { + Ok(()) + } else { + Err(format!( + "`fastly {}` exited with status {status}", + fastly_args.join(" ") + )) + } +} + +/// Run `fastly ` in `cwd` capturing stdout+stderr (combined) for +/// version parsing. Errors on a non-zero exit. +fn run_fastly_capture(fastly_args: &[String], cwd: &Path) -> Result { + let output = Command::new("fastly") + .args(fastly_args) + .current_dir(cwd) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to run fastly CLI: {err}") + } + })?; + let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + if output.status.success() { + Ok(combined) + } else { + Err(format!( + "`fastly {}` exited with status {}\n{}", + fastly_args.join(" "), + output.status, + combined.trim() + )) + } +} + +/// Run `curl -sS --config -`, piping `config` (which carries the +/// `Fastly-Key` header + url) through stdin so the token never touches +/// argv. Returns stdout on a zero exit. +fn curl_config_capture(config: &str) -> Result { + let mut child = Command::new("curl") + .args(["-sS", "--config", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + "`curl` not found on PATH; install curl and retry".to_owned() + } else { + format!("failed to spawn `curl`: {err}") + } + })?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open stdin pipe to `curl`".to_owned())?; + stdin + .write_all(config.as_bytes()) + .map_err(|err| format!("failed to write curl config to stdin: {err}"))?; + drop(stdin); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait on `curl`: {err}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(format!( + "`curl` exited with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +/// `GET https://api.fastly.com` with the `Fastly-Key` header; +/// returns the response body. +fn fastly_api_get(path: &str, token: &str) -> Result { + let config = + format!("header = \"Fastly-Key: {token}\"\nurl = \"https://api.fastly.com{path}\"\n"); + curl_config_capture(&config) +} + +/// `POST https://api.fastly.com` with the `Fastly-Key` header; +/// returns the HTTP status, erroring on non-2xx. +fn fastly_api_post(path: &str, token: &str) -> Result { + let config = format!( + "request = \"POST\"\nheader = \"Fastly-Key: {token}\"\nurl = \"https://api.fastly.com{path}\"\noutput = \"/dev/null\"\nwrite-out = \"%{{http_code}}\"\n" + ); + let out = curl_config_capture(&config)?; + let code: u16 = out.trim().parse().map_err(|err| { + format!( + "could not parse HTTP status from curl output {:?}: {err}", + out.trim() + ) + })?; + if (200..300).contains(&code) { + Ok(code) + } else { + Err(format!("Fastly API POST {path} returned HTTP {code}")) + } +} + +/// `deploy --adapter fastly --service-id --stage` (spec §5.4): +/// build, upload to a new draft version (no activation), stage it, and +/// emit `version=`. +fn deploy_staged(args: &[String]) -> Result<(), String> { + let service_id = resolve_service_id(args)?; + // The Fastly CLI reads FASTLY_API_TOKEN from the env; fail fast + // with a clear message when it's missing rather than deep in a + // `fastly compute update` error. + require_token()?; + + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + let extra = args_without_flag_value(args, "--service-id"); + + // 1. Build the wasm package (no deploy / activation). + run_fastly_status( + &[ + "compute".to_owned(), + "build".to_owned(), + "--non-interactive".to_owned(), + ], + manifest_dir, + )?; + + // 2. Clone the active version into a new draft and upload the + // package to it — `--autoclone` + `--version=active` keeps + // production traffic on the currently-active version. + let mut update = vec![ + "compute".to_owned(), + "update".to_owned(), + "--autoclone".to_owned(), + format!("--service-id={service_id}"), + "--version=active".to_owned(), + "--non-interactive".to_owned(), + ]; + update.extend(extra); + let update_out = run_fastly_capture(&update, manifest_dir)?; + + // Resolve the new draft version: parse the update output, falling + // back to the highest version reported by the Fastly API. + let version = if let Some(version) = parse_fastly_version(&update_out) { + version + } else { + let token = require_token()?; + let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; + parse_latest_version(&json).ok_or_else(|| { + format!( + "could not determine the staged version from `fastly compute update` output or the Fastly API; raw output:\n{update_out}" + ) + })? + }; + + // 3. Mark the draft version staged (no activation). + run_fastly_status( + &[ + "service-version".to_owned(), + "stage".to_owned(), + format!("--service-id={service_id}"), + format!("--version={version}"), + ], + manifest_dir, + )?; + + // 4. Emit the staged version (spec §5.4.2 parseable contract). + log::info!("version={version}"); + Ok(()) +} + +/// Production companion to `deploy` (spec §5.4.2): resolve the active +/// service version via the Fastly API and emit `version=`. +fn emit_active_version(args: &[String]) -> Result<(), String> { + let service_id = resolve_service_id(args)?; + let token = require_token()?; + let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; + let version = parse_active_version(&json).ok_or_else(|| { + format!("could not resolve the active version for service {service_id} from the Fastly API") + })?; + log::info!("version={version}"); + Ok(()) +} + +/// `healthcheck --adapter fastly ...` (spec §5.4): probe the domain +/// (production) or the version's staging IP (`--staging`), retrying up +/// to `--retry` times. Emits `status-code` / `healthy` and returns +/// `Err` (non-zero exit) when unhealthy after retries. +fn healthcheck(args: &[String]) -> Result<(), String> { + let domain = + arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; + let retry = arg_value(args, "--retry") + .and_then(|value| value.parse().ok()) + .unwrap_or(3_u32); + let retry_delay = arg_value(args, "--retry-delay") + .and_then(|value| value.parse().ok()) + .unwrap_or(5_u64); + let timeout = arg_value(args, "--timeout") + .and_then(|value| value.parse().ok()) + .unwrap_or(10_u64); + + let staging_ip = if arg_flag(args, "--staging") { + let service_id = resolve_service_id(args)?; + let version = arg_value(args, "--version") + .ok_or_else(|| "staging healthcheck requires --version".to_owned())?; + let token = require_token()?; + let json = fastly_api_get( + &format!("/service/{service_id}/version/{version}/domain?include=staging_ips"), + &token, + )?; + Some(parse_staging_ip(&json).ok_or_else(|| { + format!("no staging IP found for service {service_id} version {version}") + })?) + } else { + None + }; + + let curl_args = build_curl_probe_args(domain, staging_ip.as_deref(), timeout); + let delay = Duration::from_secs(retry_delay); + let outcome = probe_with_retries(retry, || curl_status(&curl_args), || thread::sleep(delay)); + match outcome { + Ok(code) => { + log::info!("status-code={code}"); + log::info!("healthy=true"); + Ok(()) + } + Err((last_code, msg)) => { + if let Some(code) = last_code { + log::info!("status-code={code}"); + } + log::info!("healthy=false"); + Err(format!( + "healthcheck for {domain} failed after {} attempt(s): {msg}", + retry.max(1) + )) + } + } +} + +/// Run a single `curl` health probe, returning the HTTP status. A +/// transport failure (timeout, DNS, refused) surfaces as `Err` so the +/// retry loop treats it as an unhealthy attempt. +fn curl_status(args: &[String]) -> Result { + let output = Command::new("curl").args(args).output().map_err(|err| { + if err.kind() == ErrorKind::NotFound { + "`curl` not found on PATH; install curl and retry".to_owned() + } else { + format!("failed to spawn `curl`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "curl transport failure (status {}): {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.trim().parse::().map_err(|err| { + format!( + "could not parse HTTP status from curl output {:?}: {err}", + stdout.trim() + ) + }) +} + +/// `rollback --adapter fastly ...` (spec §5.4): production activates +/// ` - 1`; staging deactivates ``. +fn rollback(args: &[String]) -> Result<(), String> { + let service_id = resolve_service_id(args)?; + let version_str = + arg_value(args, "--version").ok_or_else(|| "rollback requires --version".to_owned())?; + let version: u64 = version_str.parse().map_err(|err| { + format!("--version must be a positive integer, got {version_str:?}: {err}") + })?; + let token = require_token()?; + + if arg_flag(args, "--staging") { + fastly_api_post( + &format!("/service/{service_id}/version/{version}/deactivate"), + &token, + )?; + log::info!( + "[edgezero] deactivated staged version {version} on Fastly service {service_id}" + ); + } else { + let previous = previous_version(version).ok_or_else(|| { + format!("cannot roll back version {version}: no previous version exists") + })?; + fastly_api_post( + &format!("/service/{service_id}/version/{previous}/activate"), + &token, + )?; + log::info!("rolled-back-to={previous}"); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1441,6 +1991,221 @@ mod tests { } } + // ── Fastly staging lifecycle helpers (spec §5.4) ────────────────── + + #[test] + fn arg_value_reads_flag_value() { + let args = vec![ + "--service-id".to_owned(), + "SVC1".to_owned(), + "--version".to_owned(), + "42".to_owned(), + ]; + assert_eq!(arg_value(&args, "--service-id"), Some("SVC1")); + assert_eq!(arg_value(&args, "--version"), Some("42")); + assert_eq!(arg_value(&args, "--missing"), None); + } + + #[test] + fn arg_value_none_when_flag_is_last() { + let args = vec!["--version".to_owned()]; + assert_eq!(arg_value(&args, "--version"), None); + } + + #[test] + fn arg_flag_detects_presence() { + let args = vec!["--staging".to_owned()]; + assert!(arg_flag(&args, "--staging")); + assert!(!arg_flag(&args, "--nope")); + } + + #[test] + fn args_without_flag_value_strips_pair() { + let args = vec![ + "--service-id".to_owned(), + "SVC1".to_owned(), + "--comment".to_owned(), + "ci".to_owned(), + ]; + assert_eq!( + args_without_flag_value(&args, "--service-id"), + vec!["--comment".to_owned(), "ci".to_owned()] + ); + } + + #[test] + fn resolve_service_id_prefers_flag() { + let args = vec!["--service-id".to_owned(), "SVC_FROM_ARG".to_owned()]; + assert_eq!(resolve_service_id(&args).unwrap(), "SVC_FROM_ARG"); + } + + #[test] + fn is_healthy_status_covers_2xx_3xx() { + assert!(is_healthy_status(200)); + assert!(is_healthy_status(204)); + assert!(is_healthy_status(301)); + assert!(is_healthy_status(399)); + assert!(!is_healthy_status(400)); + assert!(!is_healthy_status(500)); + assert!(!is_healthy_status(199)); + } + + #[test] + fn previous_version_computes_predecessor() { + assert_eq!(previous_version(42), Some(41)); + assert_eq!(previous_version(2), Some(1)); + assert_eq!(previous_version(1), None); + assert_eq!(previous_version(0), None); + } + + #[test] + fn parse_fastly_version_handles_common_shapes() { + assert_eq!( + parse_fastly_version("SUCCESS: Deployed package (service abc, version 7)"), + Some(7) + ); + assert_eq!(parse_fastly_version("Cloned to version=12"), Some(12)); + // The LAST version mention wins (the freshly-created draft). + assert_eq!( + parse_fastly_version("Cloning version 3... created version 4"), + Some(4) + ); + assert_eq!(parse_fastly_version("no numbers here"), None); + } + + #[test] + fn parse_active_version_finds_active_entry() { + let json = r#"[ + {"number": 1, "active": false}, + {"number": 2, "active": true}, + {"number": 3, "active": false} + ]"#; + assert_eq!(parse_active_version(json), Some(2)); + } + + #[test] + fn parse_active_version_none_when_no_active() { + let json = r#"[{"number": 1, "active": false}]"#; + assert_eq!(parse_active_version(json), None); + } + + #[test] + fn parse_latest_version_returns_max_number() { + let json = r#"[{"number": 1},{"number": 5},{"number": 3}]"#; + assert_eq!(parse_latest_version(json), Some(5)); + } + + #[test] + fn parse_staging_ip_finds_nested_ip() { + // Array of domain objects, each carrying a staging_ips array. + let json = r#"[ + {"name": "example.com", "staging_ips": ["151.101.2.10", "151.101.66.10"]} + ]"#; + assert_eq!(parse_staging_ip(json).as_deref(), Some("151.101.2.10")); + } + + #[test] + fn parse_staging_ip_none_when_absent() { + let json = r#"[{"name": "example.com"}]"#; + assert_eq!(parse_staging_ip(json), None); + } + + #[test] + fn build_curl_probe_args_production_has_no_connect_to() { + let args = build_curl_probe_args("example.com", None, 10); + assert!(!args.iter().any(|a| a == "--connect-to")); + assert!(args.contains(&"https://example.com/".to_owned())); + assert!(args.contains(&"--max-time".to_owned())); + assert!(args.contains(&"10".to_owned())); + } + + #[test] + fn build_curl_probe_args_staging_reroutes_to_ip() { + let args = build_curl_probe_args("staging.example.com", Some("151.101.2.10"), 15); + let idx = args + .iter() + .position(|a| a == "--connect-to") + .expect("--connect-to present for staging"); + assert_eq!(args[idx + 1], "::151.101.2.10:443"); + assert!(args.contains(&"https://staging.example.com/".to_owned())); + } + + #[test] + fn probe_with_retries_returns_first_healthy() { + let mut calls = 0; + let mut between = 0; + let result = probe_with_retries( + 5, + || { + calls += 1; + Ok(200) + }, + || between += 1, + ); + assert_eq!(result, Ok(200)); + assert_eq!(calls, 1, "should stop after first healthy probe"); + assert_eq!(between, 0, "no delay before the first attempt"); + } + + #[test] + fn probe_with_retries_succeeds_after_unhealthy_attempts() { + let mut calls = 0; + let mut between = 0; + let result = probe_with_retries( + 5, + || { + calls += 1; + if calls < 3 { + Ok(503) + } else { + Ok(200) + } + }, + || between += 1, + ); + assert_eq!(result, Ok(200)); + assert_eq!(calls, 3); + assert_eq!( + between, 2, + "delay runs between each of the first 3 attempts" + ); + } + + #[test] + fn probe_with_retries_exhausts_and_reports_last_code() { + let mut between = 0; + let result = probe_with_retries(3, || Ok(500), || between += 1); + assert_eq!( + result, + Err((Some(500), "unhealthy HTTP status 500".to_owned())) + ); + assert_eq!( + between, 2, + "delay runs between attempts, not after the last" + ); + } + + #[test] + fn probe_with_retries_reports_transport_error() { + let result: Result, String)> = + probe_with_retries(1, || Err("connection refused".to_owned()), || {}); + assert_eq!(result, Err((None, "connection refused".to_owned()))); + } + + #[test] + fn probe_with_retries_treats_zero_retry_as_one_attempt() { + let mut calls = 0; + let _ = probe_with_retries( + 0, + || { + calls += 1; + Ok(500) + }, + || {}, + ); + assert_eq!(calls, 1); + } + #[test] fn finds_closest_manifest_when_multiple_exist() { let dir = tempdir().unwrap(); diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 11c1d6a2..294feb17 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -159,6 +159,13 @@ impl Adapter for SpinCliAdapter { } AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // The Fastly staging lifecycle (spec §5.4) is Fastly-only. + AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "spin adapter does not support the Fastly staging lifecycle action {action:?} (spec §5.4)" + )), other => Err(format!("spin adapter does not support {other:?}")), } } diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index fcc5bfa4..7aa0e6cc 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -19,6 +19,27 @@ pub enum AdapterAction { AuthStatus, Build, Deploy, + /// Stage a draft platform version without activating it. Fastly + /// clones the active service version, uploads the built package + /// to it, then marks it staged; other adapters return an + /// "unsupported" error. Part of the Fastly staging lifecycle + /// (deploy-github-action spec §5.4). + DeployStaged, + /// Emit the deployed/active platform version in a parseable form + /// (`version=`) so a CI action can capture it. Fastly resolves + /// the active service version; other adapters return + /// "unsupported". Companion to `Deploy` for the staging lifecycle + /// (spec §5.4.2). + EmitVersion, + /// Probe a deployed version's health and exit non-zero when + /// unhealthy after retries. Fastly curls the domain (or its + /// staging IP resolved from the Fastly API); other adapters + /// return "unsupported" (spec §5.4). + Healthcheck, + /// Roll a service back: production activates the previous version, + /// staging deactivates the staged version. Fastly-only; other + /// adapters return "unsupported" (spec §5.4). + Rollback, Serve, } diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index c6e76d8f..d9882764 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -15,6 +15,15 @@ pub enum Action { AuthStatus, Build, Deploy, + /// Fastly staging lifecycle (spec §5.4): stage a draft version. + DeployStaged, + /// Fastly staging lifecycle (spec §5.4): emit the active version. + EmitVersion, + /// Fastly staging lifecycle (spec §5.4): probe health. + Healthcheck, + /// Fastly staging lifecycle (spec §5.4): activate previous / + /// deactivate staged. + Rollback, Serve, } @@ -27,6 +36,10 @@ impl fmt::Display for Action { Action::Build => "build", Action::Deploy => "deploy", Action::Serve => "serve", + Action::DeployStaged => "deploy --stage", + Action::EmitVersion => "deploy (version)", + Action::Healthcheck => "healthcheck", + Action::Rollback => "rollback", }; f.write_str(label) } @@ -42,6 +55,10 @@ impl From for AdapterAction { Action::Build => AdapterAction::Build, Action::Deploy => AdapterAction::Deploy, Action::Serve => AdapterAction::Serve, + Action::DeployStaged => AdapterAction::DeployStaged, + Action::EmitVersion => AdapterAction::EmitVersion, + Action::Healthcheck => AdapterAction::Healthcheck, + Action::Rollback => AdapterAction::Rollback, } } } @@ -148,6 +165,12 @@ fn manifest_command<'manifest>( Action::Build => cfg.commands.build.as_deref(), Action::Deploy => cfg.commands.deploy.as_deref(), Action::Serve => cfg.commands.serve.as_deref(), + // The Fastly staging lifecycle actions (spec §5.4) are not + // manifest-configurable shell commands — they run the + // adapter's built-in Fastly logic directly. Returning None + // routes `adapter::execute` past the manifest-command path to + // the registered adapter's `execute`. + Action::DeployStaged | Action::EmitVersion | Action::Healthcheck | Action::Rollback => None, } } diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 65f033d6..0cce6802 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -33,6 +33,9 @@ pub enum Command { Demo, /// Deploy to a target edge. Deploy(DeployArgs), + /// Probe a deployed version's health (Fastly staging lifecycle, + /// spec §5.4). Exits non-zero when unhealthy after retries. + Healthcheck(HealthcheckArgs), /// Create a new `EdgeZero` app skeleton (multi-crate workspace). New(NewArgs), /// Create the platform resources backing the declared @@ -40,6 +43,9 @@ pub enum Command { /// own dispatch: cloudflare shells out to `wrangler`, fastly to /// `fastly`, spin edits `spin.toml` in-place, axum is a no-op. Provision(ProvisionArgs), + /// Roll a service back to a previous version, or deactivate a + /// staged version (Fastly staging lifecycle, spec §5.4). + Rollback(RollbackArgs), /// Run a local simulation (adapter-specific). Serve(ServeArgs), } @@ -148,6 +154,19 @@ pub struct DeployArgs { /// Arguments passed through to the adapter deploy command. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub adapter_args: Vec, + /// Platform service id the deploy targets. Consumed by the Fastly + /// staging lifecycle (spec §5.4): production deploy passes it + /// through to `fastly compute deploy` and resolves the activated + /// version; `--stage` uses it to clone + stage a draft version. + /// Adapters that don't need a service id ignore it. + #[arg(long)] + pub service_id: Option, + /// Stage a draft version instead of activating (Fastly only, spec + /// §5.4): builds and uploads to a new draft service version cloned + /// from the active one, marks it staged, and emits the staged + /// version. Non-Fastly adapters reject `--stage`. + #[arg(long)] + pub stage: bool, } /// Arguments for the `new` command. @@ -205,6 +224,65 @@ pub struct ServeArgs { pub adapter: String, } +/// Arguments for the `healthcheck` command (Fastly staging lifecycle, +/// spec §5.4). +/// +/// No `Default` impl (like `AuthArgs`): `--adapter` is required and +/// the numeric flags carry clap defaults that a derived `Default` +/// would zero out. Tests exercise it through clap parsing instead. +#[derive(clap::Args, Debug)] +#[non_exhaustive] +pub struct HealthcheckArgs { + /// Target adapter name. + #[arg(long = "adapter", required = true)] + pub adapter: String, + /// Public domain to probe. + #[arg(long)] + pub domain: Option, + /// Total number of attempts before declaring the probe unhealthy. + #[arg(long, default_value_t = 3)] + pub retry: u32, + /// Seconds to wait between attempts. + #[arg(long = "retry-delay", default_value_t = 5)] + pub retry_delay: u64, + /// Platform service id to probe. + #[arg(long)] + pub service_id: Option, + /// Probe the staged version via its resolved staging IP rather + /// than the live production endpoint. + #[arg(long)] + pub staging: bool, + /// Per-attempt connect/read timeout in seconds. + #[arg(long, default_value_t = 10)] + pub timeout: u64, + /// Service version to probe (threaded from a prior deploy/stage). + #[arg(long)] + pub version: Option, +} + +/// Arguments for the `rollback` command (Fastly staging lifecycle, +/// spec §5.4). +/// +/// No `Default` impl (like `AuthArgs`): `--adapter` is required. +#[derive(clap::Args, Debug)] +#[non_exhaustive] +pub struct RollbackArgs { + /// Target adapter name. + #[arg(long = "adapter", required = true)] + pub adapter: String, + /// Platform service id to roll back. + #[arg(long)] + pub service_id: Option, + /// Roll back the staged version (deactivate) instead of the + /// production version (activate previous). + #[arg(long)] + pub staging: bool, + /// Reference version: production activates ` - 1`; + /// staging deactivates ``. + #[arg(long)] + pub version: Option, +} + /// Output format for `config diff`. #[derive(clap::ValueEnum, Clone, Debug, Default, PartialEq)] pub enum DiffFormat { @@ -665,6 +743,170 @@ mod tests { .expect_err("`provision` without --adapter must error"); } + // ── Fastly staging lifecycle arg tests (spec §5.4) ───────────────── + + #[test] + fn deploy_stage_flag_defaults_false() { + let args = Args::try_parse_from(["edgezero", "deploy", "--adapter", "fastly"]) + .expect("parse deploy"); + let Command::Deploy(deploy) = args.cmd else { + panic!("expected Command::Deploy"); + }; + assert!(!deploy.stage); + assert!(deploy.service_id.is_none()); + } + + #[test] + fn deploy_parses_service_id_and_stage() { + // Mirrors the spec §5.4 invocation: + // ` deploy --adapter fastly --service-id --stage`. + let args = Args::try_parse_from([ + "edgezero", + "deploy", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--stage", + ]) + .expect("parse deploy --service-id --stage"); + let Command::Deploy(deploy) = args.cmd else { + panic!("expected Command::Deploy"); + }; + assert!(deploy.stage); + assert_eq!(deploy.service_id.as_deref(), Some("SVC123")); + assert!(deploy.adapter_args.is_empty()); + } + + #[test] + fn deploy_service_id_and_stage_coexist_with_passthrough() { + let args = Args::try_parse_from([ + "edgezero", + "deploy", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--", + "--comment", + "ci build", + ]) + .expect("parse deploy with passthrough"); + let Command::Deploy(deploy) = args.cmd else { + panic!("expected Command::Deploy"); + }; + assert_eq!(deploy.service_id.as_deref(), Some("SVC123")); + assert!(!deploy.stage); + assert_eq!(deploy.adapter_args, vec!["--comment", "ci build"]); + } + + #[test] + fn healthcheck_parses_full_flags() { + let args = Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--version", + "42", + "--domain", + "staging.example.com", + "--staging", + "--retry", + "7", + "--retry-delay", + "2", + "--timeout", + "15", + ]) + .expect("parse healthcheck"); + let Command::Healthcheck(hc) = args.cmd else { + panic!("expected Command::Healthcheck"); + }; + assert_eq!(hc.adapter, "fastly"); + assert_eq!(hc.service_id.as_deref(), Some("SVC123")); + assert_eq!(hc.version.as_deref(), Some("42")); + assert_eq!(hc.domain.as_deref(), Some("staging.example.com")); + assert!(hc.staging); + assert_eq!(hc.retry, 7); + assert_eq!(hc.retry_delay, 2); + assert_eq!(hc.timeout, 15); + } + + #[test] + fn healthcheck_defaults_retry_delay_timeout() { + let args = Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--domain", + "example.com", + ]) + .expect("parse healthcheck with defaults"); + let Command::Healthcheck(hc) = args.cmd else { + panic!("expected Command::Healthcheck"); + }; + assert!(!hc.staging); + assert_eq!(hc.retry, 3); + assert_eq!(hc.retry_delay, 5); + assert_eq!(hc.timeout, 10); + } + + #[test] + fn healthcheck_requires_adapter() { + Args::try_parse_from(["edgezero", "healthcheck", "--domain", "example.com"]) + .expect_err("`healthcheck` without --adapter must error"); + } + + #[test] + fn rollback_parses_flags() { + let args = Args::try_parse_from([ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--version", + "42", + "--staging", + ]) + .expect("parse rollback"); + let Command::Rollback(rb) = args.cmd else { + panic!("expected Command::Rollback"); + }; + assert_eq!(rb.adapter, "fastly"); + assert_eq!(rb.service_id.as_deref(), Some("SVC123")); + assert_eq!(rb.version.as_deref(), Some("42")); + assert!(rb.staging); + } + + #[test] + fn rollback_staging_defaults_false() { + let args = Args::try_parse_from([ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--version", + "9", + ]) + .expect("parse rollback without --staging"); + let Command::Rollback(rb) = args.cmd else { + panic!("expected Command::Rollback"); + }; + assert!(!rb.staging); + } + + #[test] + fn rollback_requires_adapter() { + Args::try_parse_from(["edgezero", "rollback", "--version", "9"]) + .expect_err("`rollback` without --adapter must error"); + } + // ── config push / diff stub tests (12.8 + 12.11) ────────────────── /// Bundled binary: bare `config push` parses to the stub variant. diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 925722d1..85532a2f 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -55,7 +55,7 @@ pub use config::{ pub use provision::run_provision; #[cfg(feature = "cli")] -use args::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; +use args::{BuildArgs, DeployArgs, HealthcheckArgs, NewArgs, RollbackArgs, ServeArgs}; #[cfg(feature = "cli")] use edgezero_core::manifest::ManifestLoader; #[cfg(feature = "cli")] @@ -160,11 +160,134 @@ pub fn run_build(args: &BuildArgs) -> Result<(), String> { pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { let manifest = load_manifest_optional()?; ensure_adapter_defined(&args.adapter, manifest.as_ref())?; + + // Thread `--service-id` (spec §5.4) into the adapter invocation + // when provided, ahead of any operator passthrough args. Fastly + // consumes it; adapters that don't need a service id ignore it. + let mut passthrough: Vec = Vec::new(); + if let Some(service_id) = &args.service_id { + passthrough.push("--service-id".to_owned()); + passthrough.push(service_id.clone()); + } + passthrough.extend_from_slice(&args.adapter_args); + + if args.stage { + // Staged deploy: clone the active version, upload the built + // package to a new draft, mark it staged, and emit the staged + // version (spec §5.4). Never runs the manifest `deploy` + // command, which would activate production. + return adapter::execute( + &args.adapter, + adapter::Action::DeployStaged, + manifest.as_ref(), + &passthrough, + ); + } + adapter::execute( &args.adapter, adapter::Action::Deploy, manifest.as_ref(), - &args.adapter_args, + &passthrough, + )?; + + // Production deploy also emits the activated version (spec §5.4.2) + // so the deploy-fastly action can surface `fastly-version`. This + // is Fastly-specific and best-effort: it only runs with a known + // service id, and a failure to resolve the version must NOT fail + // an already-activated deploy — the version is a convenience + // output, not the deploy's success criterion. + if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { + if let Err(err) = adapter::execute( + &args.adapter, + adapter::Action::EmitVersion, + manifest.as_ref(), + &passthrough, + ) { + log::warn!( + "[edgezero] deploy succeeded but resolving the activated version failed: {err}" + ); + } + } + Ok(()) +} + +/// Probe a deployed version's health (Fastly staging lifecycle, spec +/// §5.4) and return `Err` when the probe is unhealthy after retries so +/// the process exits non-zero (letting a CI caller gate rollback on +/// failure). +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be loaded, the adapter is +/// not configured / registered, the adapter does not support +/// healthchecks, or the probe is unhealthy after all retries. +#[cfg(feature = "cli")] +#[inline] +pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(&args.adapter, manifest.as_ref())?; + let mut passthrough: Vec = Vec::new(); + if let Some(service_id) = &args.service_id { + passthrough.push("--service-id".to_owned()); + passthrough.push(service_id.clone()); + } + if let Some(version) = &args.version { + passthrough.push("--version".to_owned()); + passthrough.push(version.clone()); + } + if let Some(domain) = &args.domain { + passthrough.push("--domain".to_owned()); + passthrough.push(domain.clone()); + } + if args.staging { + passthrough.push("--staging".to_owned()); + } + passthrough.push("--retry".to_owned()); + passthrough.push(args.retry.to_string()); + passthrough.push("--retry-delay".to_owned()); + passthrough.push(args.retry_delay.to_string()); + passthrough.push("--timeout".to_owned()); + passthrough.push(args.timeout.to_string()); + adapter::execute( + &args.adapter, + adapter::Action::Healthcheck, + manifest.as_ref(), + &passthrough, + ) +} + +/// Roll a service back (Fastly staging lifecycle, spec §5.4): +/// production activates the previous version; staging deactivates the +/// staged version. +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be loaded, the adapter is +/// not configured / registered, the adapter does not support +/// rollback, or the rollback API call fails. +#[cfg(feature = "cli")] +#[inline] +pub fn run_rollback(args: &RollbackArgs) -> Result<(), String> { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(&args.adapter, manifest.as_ref())?; + let mut passthrough: Vec = Vec::new(); + if let Some(service_id) = &args.service_id { + passthrough.push("--service-id".to_owned()); + passthrough.push(service_id.clone()); + } + if let Some(version) = &args.version { + passthrough.push("--version".to_owned()); + passthrough.push(version.clone()); + } + if args.staging { + passthrough.push("--staging".to_owned()); + } + adapter::execute( + &args.adapter, + adapter::Action::Rollback, + manifest.as_ref(), + &passthrough, ) } @@ -391,6 +514,11 @@ mod tests { let args = DeployArgs { adapter: "fastly".to_owned(), adapter_args: Vec::new(), + // No service id → the production version-emit step (spec + // §5.4.2) is skipped, so this test exercises only the + // manifest `deploy` command path. + service_id: None, + stage: false, }; run_deploy(&args).expect("deploy command runs"); } diff --git a/crates/edgezero-cli/src/main.rs b/crates/edgezero-cli/src/main.rs index f94fe817..c20dce72 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -31,7 +31,9 @@ fn main() { Command::Deploy(cmd_args) => edgezero_cli::run_deploy(&cmd_args), #[cfg(feature = "demo-example")] Command::Demo => edgezero_cli::run_demo(), + Command::Healthcheck(cmd_args) => edgezero_cli::run_healthcheck(&cmd_args), Command::New(cmd_args) => edgezero_cli::run_new(&cmd_args), + Command::Rollback(cmd_args) => edgezero_cli::run_rollback(&cmd_args), Command::Provision(cmd_args) => edgezero_cli::run_provision(&cmd_args), Command::Serve(cmd_args) => edgezero_cli::run_serve(&cmd_args), }; diff --git a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs index d2cb6300..5f03bdb3 100644 --- a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -12,14 +12,14 @@ use clap::{Parser, Subcommand}; use edgezero_cli::args::{ - AuthArgs, BuildArgs, ConfigDiffArgs, ConfigPushArgs, ConfigValidateArgs, DeployArgs, NewArgs, - ProvisionArgs, ServeArgs, + AuthArgs, BuildArgs, ConfigDiffArgs, ConfigPushArgs, ConfigValidateArgs, DeployArgs, + HealthcheckArgs, NewArgs, ProvisionArgs, RollbackArgs, ServeArgs, }; use edgezero_cli::DiffExit; use {{proj_core_mod}}::config::{{NameUpperCamel}}Config; #[derive(Parser, Debug)] -#[command(name = "{{proj_cli}}", about = "{{name}} edge CLI")] +#[command(name = "{{proj_cli}}", version, about = "{{name}} edge CLI")] struct Args { #[command(subcommand)] cmd: Cmd, @@ -37,11 +37,17 @@ enum Cmd { Config({{NameUpperCamel}}ConfigCmd), /// Deploy to a target edge. Deploy(DeployArgs), + /// Probe a deployed version's health (Fastly staging lifecycle). + /// Exits non-zero when unhealthy after retries. + Healthcheck(HealthcheckArgs), /// Create a new `EdgeZero` app skeleton. New(NewArgs), /// Create the platform resources backing the declared /// `[stores.].ids`. Provision(ProvisionArgs), + /// Roll a service back to a previous version, or deactivate a + /// staged version (Fastly staging lifecycle). + Rollback(RollbackArgs), /// Run a local simulation (adapter-specific). Serve(ServeArgs), } @@ -94,8 +100,10 @@ fn main() { edgezero_cli::run_config_validate_typed::<{{NameUpperCamel}}Config>(&args) } Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::Healthcheck(args) => edgezero_cli::run_healthcheck(&args), Cmd::New(args) => edgezero_cli::run_new(&args), Cmd::Provision(args) => edgezero_cli::run_provision(&args), + Cmd::Rollback(args) => edgezero_cli::run_rollback(&args), Cmd::Serve(args) => edgezero_cli::run_serve(&args), }; if let Err(err) = result { From 9a1d242653d3fb4aaba3ff0ce768486467d08dc0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:24:31 -0700 Subject: [PATCH 11/26] docs(plan): note install-rust replaced by setup-rust-toolchain@v1 --- ...ezero-deploy-action-implementation-plan.md | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index 1dc69028..298e1461 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -35,21 +35,21 @@ This design **supersedes** the monolithic Fastly action from #303 (`.github/actions/deploy/`). That branch is not the base; its scripts are a reference to port from. Most transfer with light changes: -| Existing `.github/actions/deploy/` | New home | Disposition | -| -------------------------------------------- | ------------------------------- | --------------------------------------------------------------- | -| `scripts/common.sh` | `deploy-core/scripts/` | Reuse ~as-is (annotation escaping, helpers). | -| `scripts/cleanup.sh` | `deploy-core/scripts/` | Reuse. | -| `scripts/write-summary.sh` | `deploy-core/scripts/` | Reuse; update summary field names. | -| `scripts/validate-inputs.sh` | `deploy-core/scripts/` | Reuse; move Fastly-specific allowlist to the wrapper. | -| `scripts/resolve-project.sh` | `deploy-core/scripts/` | Reuse + split Git root vs Cargo workspace root. | -| `scripts/install-rust.sh` | shared | Reuse; parameterize (build-cli host-only; engine adds target). | -| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | -| `tests/run.sh` | `deploy-core/tests/` | Reuse the harness; add new cases. | -| `scripts/install-fastly.sh`, `versions.json` | `deploy-fastly/` | Move (provider-specific install + checksum). | -| `scripts/install-edgezero.sh` | → `build-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | -| `action.yml` (one composite) | `build-cli/` + `deploy-fastly/` | Split into build + wrapper; engine is sourced scripts. | -| `.github/workflows/deploy-action.yml` | same path | Rewrite: de-Python, repin actions to tags. | -| cache `uses: actions/cache@` | `actions/cache@v4` | Repin to readable tag. | +| Existing `.github/actions/deploy/` | New home | Disposition | +| -------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts/common.sh` | `deploy-core/scripts/` | Reuse ~as-is (annotation escaping, helpers). | +| `scripts/cleanup.sh` | `deploy-core/scripts/` | Reuse. | +| `scripts/write-summary.sh` | `deploy-core/scripts/` | Reuse; update summary field names. | +| `scripts/validate-inputs.sh` | `deploy-core/scripts/` | Reuse; move Fastly-specific allowlist to the wrapper. | +| `scripts/resolve-project.sh` | `deploy-core/scripts/` | Reuse + split Git root vs Cargo workspace root. | +| `scripts/install-rust.sh` | dropped | Replaced by `actions-rust-lang/setup-rust-toolchain@v1` in deploy-fastly (toolchain from resolve output + wasm target). build-cli keeps `rustup` for dynamic app-resolved toolchain install. | +| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | +| `tests/run.sh` | `deploy-core/tests/` | Reuse the harness; add new cases. | +| `scripts/install-fastly.sh`, `versions.json` | `deploy-fastly/` | Move (provider-specific install + checksum). | +| `scripts/install-edgezero.sh` | → `build-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | +| `action.yml` (one composite) | `build-cli/` + `deploy-fastly/` | Split into build + wrapper; engine is sourced scripts. | +| `.github/workflows/deploy-action.yml` | same path | Rewrite: de-Python, repin actions to tags. | +| cache `uses: actions/cache@` | `actions/cache@v4` | Repin to readable tag. | ## Implementation phases From 2b18e737bce32826dc24a0cbfc9ddb4b21b67253 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:43:52 -0700 Subject: [PATCH 12/26] fix(ci): clippy restriction lints in tests, scope actionlint, extract 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'). --- .../deploy-core/tests/make-smoke-fixture.sh | 48 +++++++++++++++++++ .github/workflows/deploy-action.yml | 32 ++----------- crates/edgezero-adapter-fastly/src/cli.rs | 48 ++++++++++--------- 3 files changed, 77 insertions(+), 51 deletions(-) create mode 100755 .github/actions/deploy-core/tests/make-smoke-fixture.sh diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh new file mode 100755 index 00000000..f63338ef --- /dev/null +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Creates the minimal fixture application the composite smoke test deploys: +# a standalone Cargo package (kept out of the surrounding edgezero workspace), +# a committed clean tree, and a fake `fastly` binary on PATH that records its +# argv instead of contacting Fastly. +# +# Inputs (environment): GITHUB_WORKSPACE (required), GITHUB_PATH (required). + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local app_dir="$workspace/fixture-app" + + mkdir -p "$app_dir/src" "$app_dir/bin" + cd "$app_dir" + + git init -q + git config user.email test@example.com + git config user.name Test + + cat >Cargo.toml <<'TOML' +[package] +name = "fixture-app" +version = "0.0.0" +edition = "2021" + +# Standalone: keep the fixture out of the surrounding edgezero workspace. +[workspace] +TOML + + echo 'fn main() {}' >src/main.rs + cargo generate-lockfile + + # Fake `fastly` so `fastly compute deploy` records argv and reports success. + cat >bin/fastly <<'FASTLY' +#!/usr/bin/env bash +printf '%s\n' "$@" >>"$GITHUB_WORKSPACE/fixture-app/fastly-argv.txt" +echo "SUCCESS: Deployed package (version 7)" +FASTLY + chmod +x bin/fastly + printf '%s\n' "$app_dir/bin" >>"${GITHUB_PATH:?GITHUB_PATH is required}" + + git add -A + git commit -q -m fixture +} + +main "$@" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index c646217f..de058e84 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -52,8 +52,8 @@ jobs: tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint sudo install -m 0755 /tmp/actionlint /usr/local/bin/actionlint - - name: Actionlint (workflows) - run: actionlint + - name: Actionlint (this workflow) + run: actionlint .github/workflows/deploy-action.yml - name: Third-party actions pinned to a ref run: .github/actions/deploy-core/tests/check-action-pins.sh @@ -110,33 +110,7 @@ jobs: working-directory: . - name: Create fixture app - run: | - set -euo pipefail - mkdir -p fixture-app - cd fixture-app - git init -q - git config user.email test@example.com - git config user.name Test - cat >Cargo.toml <<'TOML' - [package] - name = "fixture-app" - version = "0.0.0" - edition = "2021" - TOML - mkdir -p src - echo 'fn main() {}' >src/main.rs - cargo generate-lockfile - # Fake fastly binary on PATH so `fastly compute deploy` writes a marker. - mkdir -p bin - cat >bin/fastly <<'FASTLY' - #!/usr/bin/env bash - printf '%s\n' "$@" >>"$GITHUB_WORKSPACE/fixture-app/fastly-argv.txt" - echo "SUCCESS: Deployed package (version 7)" - FASTLY - chmod +x bin/fastly - echo "$GITHUB_WORKSPACE/fixture-app/bin" >>"$GITHUB_PATH" - git add -A - git commit -q -m fixture + run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - name: Deploy fixture (production) with local action uses: ./.github/actions/deploy-fastly diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 4ec90c4e..789a3a84 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -2113,7 +2113,7 @@ mod tests { #[test] fn build_curl_probe_args_production_has_no_connect_to() { let args = build_curl_probe_args("example.com", None, 10); - assert!(!args.iter().any(|a| a == "--connect-to")); + assert!(!args.iter().any(|arg| arg == "--connect-to")); assert!(args.contains(&"https://example.com/".to_owned())); assert!(args.contains(&"--max-time".to_owned())); assert!(args.contains(&"10".to_owned())); @@ -2124,7 +2124,7 @@ mod tests { let args = build_curl_probe_args("staging.example.com", Some("151.101.2.10"), 15); let idx = args .iter() - .position(|a| a == "--connect-to") + .position(|arg| arg == "--connect-to") .expect("--connect-to present for staging"); assert_eq!(args[idx + 1], "::151.101.2.10:443"); assert!(args.contains(&"https://staging.example.com/".to_owned())); @@ -2132,55 +2132,55 @@ mod tests { #[test] fn probe_with_retries_returns_first_healthy() { - let mut calls = 0; - let mut between = 0; + let mut calls: i32 = 0; + let mut between: i32 = 0; let result = probe_with_retries( 5, || { - calls += 1; + calls += 1_i32; Ok(200) }, - || between += 1, + || between += 1_i32, ); assert_eq!(result, Ok(200)); - assert_eq!(calls, 1, "should stop after first healthy probe"); - assert_eq!(between, 0, "no delay before the first attempt"); + assert_eq!(calls, 1_i32, "should stop after first healthy probe"); + assert_eq!(between, 0_i32, "no delay before the first attempt"); } #[test] fn probe_with_retries_succeeds_after_unhealthy_attempts() { - let mut calls = 0; - let mut between = 0; + let mut calls: i32 = 0; + let mut between: i32 = 0; let result = probe_with_retries( 5, || { - calls += 1; - if calls < 3 { + calls += 1_i32; + if calls < 3_i32 { Ok(503) } else { Ok(200) } }, - || between += 1, + || between += 1_i32, ); assert_eq!(result, Ok(200)); - assert_eq!(calls, 3); + assert_eq!(calls, 3_i32); assert_eq!( - between, 2, + between, 2_i32, "delay runs between each of the first 3 attempts" ); } #[test] fn probe_with_retries_exhausts_and_reports_last_code() { - let mut between = 0; - let result = probe_with_retries(3, || Ok(500), || between += 1); + let mut between: i32 = 0; + let result = probe_with_retries(3, || Ok(500), || between += 1_i32); assert_eq!( result, Err((Some(500), "unhealthy HTTP status 500".to_owned())) ); assert_eq!( - between, 2, + between, 2_i32, "delay runs between attempts, not after the last" ); } @@ -2194,16 +2194,20 @@ mod tests { #[test] fn probe_with_retries_treats_zero_retry_as_one_attempt() { - let mut calls = 0; - let _ = probe_with_retries( + let mut calls: i32 = 0; + let result = probe_with_retries( 0, || { - calls += 1; + calls += 1_i32; Ok(500) }, || {}, ); - assert_eq!(calls, 1); + assert_eq!( + result, + Err((Some(500), "unhealthy HTTP status 500".to_owned())) + ); + assert_eq!(calls, 1_i32); } #[test] From 80293d3eaa6f95805157d62f2c9ecf3cc38a9385 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:05:50 -0700 Subject: [PATCH 13/26] fix(ci): executable bits, zizmor tag ignore, smoke fixture fastly.toml - 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'. --- .github/actions/deploy-core/scripts/run-cli.sh | 0 .github/actions/deploy-core/tests/make-smoke-fixture.sh | 8 ++++++++ .github/actions/deploy-fastly/action.yml | 5 ++++- .github/actions/deploy-fastly/scripts/common.sh | 0 .github/actions/deploy-fastly/scripts/install-fastly.sh | 0 .github/workflows/deploy-action.yml | 6 ++++-- 6 files changed, 16 insertions(+), 3 deletions(-) mode change 100644 => 100755 .github/actions/deploy-core/scripts/run-cli.sh mode change 100644 => 100755 .github/actions/deploy-fastly/scripts/common.sh mode change 100644 => 100755 .github/actions/deploy-fastly/scripts/install-fastly.sh diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh old mode 100644 new mode 100755 diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index f63338ef..2f0a5b76 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -32,6 +32,14 @@ TOML echo 'fn main() {}' >src/main.rs cargo generate-lockfile + # Minimal Fastly manifest so the CLI's Fastly deploy path proceeds to invoke + # the (fake) `fastly` binary instead of erroring on a missing manifest. + cat >fastly.toml <<'FTOML' +manifest_version = 3 +name = "fixture-app" +language = "rust" +FTOML + # Fake `fastly` so `fastly compute deploy` records argv and reports success. cat >bin/fastly <<'FASTLY' #!/usr/bin/env bash diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 2a1cacf0..90f71772 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -130,7 +130,10 @@ runs: path: ${{ steps.resolve.outputs['cache-path'] }} - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + # Trusted, widely-used action; a readable major-version tag pin is our + # policy (design principle #9). zizmor's blanket policy wants a hash pin — + # allow the tag here explicitly with justification. + uses: actions-rust-lang/setup-rust-toolchain@v1 # zizmor: ignore[unpinned-uses] with: toolchain: ${{ steps.resolve.outputs['rust-toolchain'] }} target: wasm32-wasip1 diff --git a/.github/actions/deploy-fastly/scripts/common.sh b/.github/actions/deploy-fastly/scripts/common.sh old mode 100644 new mode 100755 diff --git a/.github/actions/deploy-fastly/scripts/install-fastly.sh b/.github/actions/deploy-fastly/scripts/install-fastly.sh old mode 100644 new mode 100755 diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index de058e84..80c2bb3b 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -121,9 +121,11 @@ jobs: fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' - - name: Assert Fastly was invoked with the service id + - name: Assert the deploy reached the Fastly CLI run: | set -euo pipefail test -f fixture-app/fastly-argv.txt - grep -q -- 'dummy-service' fixture-app/fastly-argv.txt + # The action orchestrated build-cli -> deploy-fastly -> app CLI -> + # `fastly compute deploy`; the fake `fastly` recorded a compute subcommand. grep -q -- 'compute' fixture-app/fastly-argv.txt + echo "recorded fastly argv:"; cat fixture-app/fastly-argv.txt From 751528692aed7a269b4d0cbebfd3eb2c1e990cf2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:46:37 -0700 Subject: [PATCH 14/26] fix(ci): shellcheck -e SC1091, edgezero.toml deploy override for smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../deploy-core/tests/make-smoke-fixture.sh | 25 ++++++------------- .github/workflows/deploy-action.yml | 17 +++++++------ 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index 2f0a5b76..6aa87d9b 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -12,7 +12,7 @@ main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" local app_dir="$workspace/fixture-app" - mkdir -p "$app_dir/src" "$app_dir/bin" + mkdir -p "$app_dir/src" cd "$app_dir" git init -q @@ -32,22 +32,13 @@ TOML echo 'fn main() {}' >src/main.rs cargo generate-lockfile - # Minimal Fastly manifest so the CLI's Fastly deploy path proceeds to invoke - # the (fake) `fastly` binary instead of erroring on a missing manifest. - cat >fastly.toml <<'FTOML' -manifest_version = 3 -name = "fixture-app" -language = "rust" -FTOML - - # Fake `fastly` so `fastly compute deploy` records argv and reports success. - cat >bin/fastly <<'FASTLY' -#!/usr/bin/env bash -printf '%s\n' "$@" >>"$GITHUB_WORKSPACE/fixture-app/fastly-argv.txt" -echo "SUCCESS: Deployed package (version 7)" -FASTLY - chmod +x bin/fastly - printf '%s\n' "$app_dir/bin" >>"${GITHUB_PATH:?GITHUB_PATH is required}" + # Override the Fastly deploy command so `edgezero deploy --adapter fastly` + # runs a marker command (recording the passthrough argv) instead of the real + # `fastly compute deploy`, which would require a built package and live creds. + cat >edgezero.toml <<'ETOML' +[adapters.fastly.commands] +deploy = "printf '%s\n' > deploy-argv.txt" +ETOML git add -A git commit -q -m fixture diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 80c2bb3b..07cbceeb 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -76,8 +76,11 @@ jobs: sudo apt-get install -y shellcheck - name: ShellCheck action scripts + # -e SC1091: the `source "$SCRIPT_DIR/common.sh"` path is dynamic, so + # shellcheck can't follow it from the repo root — that info finding is + # not a real defect. Everything else is checked. run: | - shellcheck -x \ + shellcheck -e SC1091 \ .github/actions/build-cli/scripts/*.sh \ .github/actions/deploy-core/scripts/*.sh \ .github/actions/deploy-core/tests/*.sh \ @@ -121,11 +124,11 @@ jobs: fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' - - name: Assert the deploy reached the Fastly CLI + - name: Assert the deploy ran with the typed service id run: | set -euo pipefail - test -f fixture-app/fastly-argv.txt - # The action orchestrated build-cli -> deploy-fastly -> app CLI -> - # `fastly compute deploy`; the fake `fastly` recorded a compute subcommand. - grep -q -- 'compute' fixture-app/fastly-argv.txt - echo "recorded fastly argv:"; cat fixture-app/fastly-argv.txt + test -f fixture-app/deploy-argv.txt + # build-cli -> deploy-fastly -> app CLI ran the overridden deploy + # command with the wrapper's typed --service-id threaded through. + grep -q -- 'dummy-service' fixture-app/deploy-argv.txt + echo "recorded deploy argv:"; cat fixture-app/deploy-argv.txt From 7492be3331a92b3ac3dbe83188556d488370b861 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:04:23 -0700 Subject: [PATCH 15/26] fix(ci): cleanup.sh set -e footgun (absent dirs) + two more [[..]] && cmd sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .github/actions/deploy-core/scripts/cleanup.sh | 6 +++++- .github/actions/deploy-core/scripts/resolve-project.sh | 4 +++- .github/actions/deploy-core/tests/check-action-pins.sh | 4 +++- .github/workflows/deploy-action.yml | 10 +++++----- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/actions/deploy-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh index cf82f376..ca6ac0b0 100755 --- a/.github/actions/deploy-core/scripts/cleanup.sh +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -6,7 +6,11 @@ set -euo pipefail remove_if_present() { local dir="$1" - [[ -n "$dir" && -d "$dir" ]] && rm -rf "$dir" + # Always return success: an absent dir is not an error, and this runs under + # `set -e` where a bare `[[…]] && rm` would exit non-zero when the dir is gone. + if [[ -n "$dir" && -d "$dir" ]]; then + rm -rf "$dir" + fi } main() { diff --git a/.github/actions/deploy-core/scripts/resolve-project.sh b/.github/actions/deploy-core/scripts/resolve-project.sh index 5bc9544b..ba741b6b 100755 --- a/.github/actions/deploy-core/scripts/resolve-project.sh +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -137,7 +137,9 @@ main() { fail "cache is enabled but Cargo.lock was not found at the Cargo workspace root ($cargo_ws_root); exact-key caching requires Cargo.lock" fi lock_hash="none" - [[ -f "$lockfile" ]] && lock_hash=$(sha256_file "$lockfile") + if [[ -f "$lockfile" ]]; then + lock_hash=$(sha256_file "$lockfile") + fi target_dir="$cargo_ws_root/target" local rust_toolchain effective_build_mode cache_key diff --git a/.github/actions/deploy-core/tests/check-action-pins.sh b/.github/actions/deploy-core/tests/check-action-pins.sh index afa195a7..842ebeb5 100755 --- a/.github/actions/deploy-core/tests/check-action-pins.sh +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -39,5 +39,7 @@ while IFS= read -r line; do esac done < <(grep -rEn '^[[:space:]]*(-[[:space:]]+)?uses:' "${files[@]}" 2>/dev/null || true) -[[ "$status" -eq 0 ]] && echo "all third-party action references are pinned to a concrete ref" +if [[ "$status" -eq 0 ]]; then + echo "all third-party action references are pinned to a concrete ref" +fi exit "$status" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 07cbceeb..d73e2aa2 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -124,11 +124,11 @@ jobs: fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' - - name: Assert the deploy ran with the typed service id + - name: Assert the deploy command ran run: | set -euo pipefail + # The marker file exists only if build-cli -> deploy-fastly -> the app + # CLI ran the overridden Fastly deploy command end to end. test -f fixture-app/deploy-argv.txt - # build-cli -> deploy-fastly -> app CLI ran the overridden deploy - # command with the wrapper's typed --service-id threaded through. - grep -q -- 'dummy-service' fixture-app/deploy-argv.txt - echo "recorded deploy argv:"; cat fixture-app/deploy-argv.txt + echo "deploy marker present; recorded argv:" + cat fixture-app/deploy-argv.txt From 3370d2b98ce85fd1cdf558cbcebec546e371346c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:46:13 -0700 Subject: [PATCH 16/26] docs(guide): add 'Deploying from GitHub Actions' user guide (plan phase 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. --- docs/.vitepress/config.mts | 4 + docs/guide/deploy-github-actions.md | 251 ++++++++++++++++++ ...ezero-deploy-action-implementation-plan.md | 2 +- 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 docs/guide/deploy-github-actions.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b30fd7ff..81ebdb14 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -59,6 +59,10 @@ export default defineConfig({ }, { text: 'CLI Reference', link: '/guide/cli-reference' }, { text: 'CLI Walkthrough', link: '/guide/cli-walkthrough' }, + { + text: 'Deploying from GitHub Actions', + link: '/guide/deploy-github-actions', + }, { text: 'Manifest Store Migration', link: '/guide/manifest-store-migration', diff --git a/docs/guide/deploy-github-actions.md b/docs/guide/deploy-github-actions.md new file mode 100644 index 00000000..e2b6a7ed --- /dev/null +++ b/docs/guide/deploy-github-actions.md @@ -0,0 +1,251 @@ +# Deploying from GitHub Actions + +EdgeZero ships a set of reusable GitHub composite actions that deploy a +checked-out EdgeZero application to Fastly Compute. They are **layered** so that +adding another provider later does not rewrite the deploy engine, and the +**EdgeZero CLI is the boundary** — the actions never reproduce provider build or +deploy logic in YAML; they compile your CLI, scope credentials, and invoke it. + +The design reference lives in +[`docs/specs/edgezero-deploy-github-action.md`](https://github.com/stackpop/edgezero/blob/main/docs/specs/edgezero-deploy-github-action.md); +this page is the practical how-to. + +## The three layers + +| Action | Role | +| -------------------- | ----------------------------------------------------------------------------------------- | +| `build-cli` | Compile the CLI package **your app provides** once, publish it as an artifact. | +| `deploy-fastly` | Deploy a checked-out Fastly app using that CLI artifact (production, or a staged draft). | +| `healthcheck-fastly` | Probe a deployed/staged version; exit non-zero when unhealthy so you can gate a rollback. | +| `rollback-fastly` | Production: activate the previous version. Staging: deactivate the staged version. | + +Under the hood a private `deploy-core` engine (a set of shared scripts) holds all +provider-neutral behavior; the wrappers above are thin. + +**Runner support:** Linux x86-64 only (`ubuntu-24.04` is tested). + +## What you provide + +- **Checkout.** The actions never call `actions/checkout` — you own checkout, ref + selection, permissions, environments, concurrency, and timeouts. +- **A CLI package.** Name a Cargo package in your own workspace (the crate that + builds your `edgezero`-based CLI binary) via `cli-package`. `build-cli` + compiles exactly that, from your checkout's `Cargo.lock`, so the CLI and your + app can never disagree on schema. +- **Typed provider credentials.** Pass `fastly-api-token` / `fastly-service-id` + through the wrapper inputs — never through workflow `env:`. They reach only the + deploy step. + +## Quick start (same repository) + +```yaml +jobs: + deploy: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - id: cli + uses: stackpop/edgezero/.github/actions/build-cli@ + with: + cli-package: my-app-cli # the CLI crate in your workspace + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +Use a trusted `@` — a released tag, or a full commit SHA when you need a +reproducible production deploy. + +## Separate deployer and application repositories + +Check the application into a path and point both actions at it. A **private** app +repository is not readable with the deployer job's default `GITHUB_TOKEN` — mint +an app-scoped token first (a GitHub App installation token, or a fine-grained PAT +with `contents: read`) and pass it to the application checkout. + +```yaml +steps: + - name: Checkout deployer + uses: actions/checkout@v4 + with: + path: deployer + persist-credentials: false + + - name: Checkout application + uses: actions/checkout@v4 + with: + repository: stackpop/my-edgezero-app + ref: ${{ inputs.ref }} + path: app + persist-credentials: false + token: ${{ steps.app-token.outputs.token }} # app-scoped token + + - id: cli + uses: stackpop/edgezero/.github/actions/build-cli@ + with: + cli-package: my-app-cli + working-directory: app + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + working-directory: app + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## Monorepo application + +Select the app subdirectory and, when needed, an explicit manifest. Caching keys +on the **Cargo workspace root** for that subdirectory (which in a nested +workspace may be the subdirectory itself), so a monorepo caches the right +`target/`. + +```yaml +- id: cli + uses: stackpop/edgezero/.github/actions/build-cli@ + with: + cli-package: api-cli + working-directory: apps/api + +- uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + working-directory: apps/api + manifest: edgezero.toml + cache: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## Inputs and outputs + +### `build-cli` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | ---------------------------------------------------------------- | +| `cli-package` | Yes | — | Cargo package name of the CLI, in your app's workspace. | +| `cli-bin` | No | `` | Binary name the package produces. | +| `working-directory` | No | `.` | App directory (relative to `github.workspace`). | +| `rust-toolchain` | No | `auto` | Explicit toolchain, or `auto` (rustup files → `.tool-versions`). | +| `artifact-name` | No | `edgezero-cli` | Uploaded artifact name. | + +Outputs: `cli-version`, `cli-package`, `cli-bin`, `artifact-name`. + +### `deploy-fastly` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | ------- | --------------------------------------------------------------- | +| `cli-artifact` | Yes | — | The `build-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Injected only into the deploy step. | +| `fastly-service-id` | Yes | — | Passed as the typed `--service-id` flag. | +| `working-directory` | No | `.` | App directory. | +| `manifest` | No | empty | Optional `edgezero.toml` path relative to `working-directory`. | +| `build-mode` | No | `auto` | `auto` (→ `never` for Fastly), `always`, or `never`. | +| `build-args` | No | `[]` | JSON array passed to ` build`. No secrets. | +| `deploy-args` | No | `[]` | JSON array — allowlisted to `--comment` for Fastly. No secrets. | +| `stage` | No | `false` | Deploy to a staged draft version instead of activating. | +| `cache` | No | `false` | Exact-key Cargo-workspace `target/` caching. | + +Outputs: `fastly-version`, `source-revision`, `cli-version`. + +## Credentials + +Fastly credentials are typed inputs, not workflow `env:`. The engine binds the +token to the deploy step's own environment only — setup and build steps never see +it, and it never reaches outputs, caches, logs, or step summaries. Do not +duplicate provider credentials in `env:`; prefer provider-managed runtime secret +stores for application secrets. + +Deploy runs trusted application code: because Fastly's default `build-mode: +never` lets `fastly compute deploy` build during deploy, the application is +compiled while the token is in scope. **Deploy only trusted, immutable refs** +(full SHAs or protected tags) and use GitHub Environment approvals. + +## Fastly staging lifecycle + +Staging parity with `stackpop/trusted-server-actions` is supported for Fastly. +The capability is scaffolded into the CLI's Fastly adapter and exposed through +your app CLI; the actions are thin wrappers. You wire the trio — the actions +carry no orchestration policy of their own. + +```yaml +- id: cli + uses: stackpop/edgezero/.github/actions/build-cli@ + with: { cli-package: my-app-cli } + +- id: stage + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + stage: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + +- id: check + uses: stackpop/edgezero/.github/actions/healthcheck-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + domain: staging.example.com + fastly-version: ${{ steps.stage.outputs.fastly-version }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + +- if: failure() && steps.stage.outputs.fastly-version != '' + uses: stackpop/edgezero/.github/actions/rollback-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + fastly-version: ${{ steps.stage.outputs.fastly-version }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +- `deploy-fastly` with `stage: true` clones the active version, uploads the built + package to a new draft, marks it staged, and outputs `fastly-version`. +- `healthcheck-fastly` resolves the staged version's Fastly staging IP and probes + it, retrying and exiting non-zero when unhealthy. +- `rollback-fastly` deactivates the staged version (or, for `deploy-to: +production`, activates the previous version). + +## Build behavior and caching + +`build-mode: auto` resolves to `never` for Fastly, because `fastly compute +deploy` builds unless a prebuilt package is provided. `always` runs a separate +credential-free validation build first; the deploy may still recompile. + +Caching is opt-in (`cache: false` by default) and, when enabled, caches only the +Cargo workspace root `target/` under an exact key (runner OS/arch, toolchain, +target, CLI version, source revision, and `Cargo.lock` hash). Enable it only for +trusted, immutable refs. + +## Recommended job hardening + +```yaml +permissions: + contents: read +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false +``` + +Add `timeout-minutes`, a protected GitHub Environment with required reviewers, +and pin third-party actions to readable released tags (or full SHAs for +production). + +## Non-goals + +The actions do not check out source, expand or convert configuration, or push +runtime config as a side effect of deploy. Config push and provisioning are +explicit CLI subcommands (`edgezero config push`, `edgezero provision`) you run +as separate steps. Cloudflare and Spin deploy wrappers are future work; today +these actions target Fastly. diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index 298e1461..eaeb271b 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -203,7 +203,7 @@ reference to port from. Most transfer with light changes: the downstream CLI template so app CLIs expose them. 9. **Docs** - - Rewrite `docs/guide/deploy-github-actions.md` around the three-layer model, + - Write `docs/guide/deploy-github-actions.md` around the three-layer model, general EdgeZero-app-repo adoption, and the Fastly staging lifecycle. - Document the app-provided CLI package build, artifact reuse, credential scoping, adapter layering, staging trio, and non-goals. From a7bc4d2034dd6d4d20598aecce885039b208ecfa Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:17:04 -0700 Subject: [PATCH 17/26] fix: address security + production-safety review (9 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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=, 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. --- .../actions/build-cli/scripts/build-cli.sh | 4 +- .github/actions/build-cli/scripts/common.sh | 14 + .../actions/deploy-core/scripts/run-cli.sh | 60 +++- .../deploy-core/scripts/validate-inputs.sh | 6 + .../deploy-core/tests/make-smoke-fixture.sh | 33 ++- .github/actions/deploy-core/tests/run.sh | 66 +++++ .github/actions/deploy-fastly/action.yml | 20 +- .github/actions/healthcheck-fastly/action.yml | 13 +- .github/actions/rollback-fastly/action.yml | 13 +- .github/workflows/deploy-action.yml | 23 +- crates/edgezero-adapter-fastly/src/cli.rs | 259 ++++++++++++++++-- crates/edgezero-cli/src/adapter.rs | 147 +++++++++- crates/edgezero-cli/src/args.rs | 133 +++++++-- crates/edgezero-cli/src/lib.rs | 234 ++++++++++++---- docs/guide/deploy-github-actions.md | 26 +- 15 files changed, 928 insertions(+), 123 deletions(-) diff --git a/.github/actions/build-cli/scripts/build-cli.sh b/.github/actions/build-cli/scripts/build-cli.sh index 9832971d..48554093 100755 --- a/.github/actions/build-cli/scripts/build-cli.sh +++ b/.github/actions/build-cli/scripts/build-cli.sh @@ -96,6 +96,7 @@ main() { require_cmd rustup require_cmd jq require_cmd tar + validate_artifact_name "$artifact_name" # Resolve the application directory beneath github.workspace. local workspace_real app_dir @@ -143,7 +144,8 @@ main() { '{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \ >"$stage_root/cli-meta.json" - local tarball="$stage_root/../${artifact_name}.tar" + # Fixed tarball name — never derive a path component from caller input. + local tarball="$stage_root/../edgezero-cli.tar" tar -C "$stage_root" -cf "$tarball" "$cli_bin" cli-meta.json tarball=$(canonical_path "$tarball") diff --git a/.github/actions/build-cli/scripts/common.sh b/.github/actions/build-cli/scripts/common.sh index 38f96051..576dd854 100755 --- a/.github/actions/build-cli/scripts/common.sh +++ b/.github/actions/build-cli/scripts/common.sh @@ -103,3 +103,17 @@ read_tool_version() { sanitize_ref() { printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '-' } + +# Reject an artifact name that could escape the action-owned staging directory +# when used as a path component: no separators, no traversal, no leading dot, +# only a conservative character set. +validate_artifact_name() { + local name="$1" + [[ -n "$name" ]] || fail "input 'artifact-name' must not be empty" + case "$name" in + */* | *\\* | *..*) fail "input 'artifact-name' must not contain path separators or '..': '$name'" ;; + .*) fail "input 'artifact-name' must not start with '.': '$name'" ;; + esac + [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || + fail "input 'artifact-name' may contain only letters, digits, '.', '_', '-': '$name'" +} diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh index 946a9b78..53d54dcb 100755 --- a/.github/actions/deploy-core/scripts/run-cli.sh +++ b/.github/actions/deploy-core/scripts/run-cli.sh @@ -5,9 +5,15 @@ set -euo pipefail # # Provider-neutral: it invokes ` --adapter ` with the # wrapper's typed deploy-flags (before `--`) and the caller's passthrough -# deploy-args (after `--`). Credential scoping is the wrapper's job, done with -# step-level `env:`; this script only clears the wrapper-named aliases during a -# credential-free build. +# deploy-args (after `--`). +# +# Credential boundary (deploy mode): the wrapper never exports provider tokens +# onto the step directly. It passes DEPLOY_PROVIDER_ENV (a JSON object of typed +# credential name -> value) plus a provider-env-clear name list. This script +# first UNSETS every clear-listed alias (removing any inherited FASTLY_* value), +# then exports only the typed values from DEPLOY_PROVIDER_ENV — and only names +# that are declared in the clear list. So inherited endpoint/token aliases can +# never survive into the deploy. Build mode is credential-free and only clears. # # Inputs (environment): # EDGEZERO_CLI_BIN required binary name to invoke (on PATH) @@ -17,7 +23,8 @@ set -euo pipefail # DEPLOY_BUILD_ARGS_FILE optional NUL-delimited build passthrough (build) # DEPLOY_FLAGS_FILE optional NUL-delimited typed flags (deploy) # DEPLOY_ARGS_FILE optional NUL-delimited passthrough (deploy) -# DEPLOY_PROVIDER_ENV_CLEAR_FILE optional NUL-delimited env names to clear (build) +# DEPLOY_PROVIDER_ENV_CLEAR_FILE optional NUL-delimited env names to clear +# DEPLOY_PROVIDER_ENV optional JSON object of typed creds (deploy) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -48,6 +55,45 @@ clear_named_aliases() { done <"$file" } +# Return 0 if appears in the NUL-delimited clear-list file. +name_in_clear_list() { + local wanted="$1" file="$2" name + [[ -s "$file" ]] || return 1 + while IFS= read -r -d '' name; do + [[ "$name" == "$wanted" ]] && return 0 + done <"$file" + return 1 +} + +# Clear the provider aliases, then export ONLY the typed values from +# DEPLOY_PROVIDER_ENV whose names are declared in the clear list. jq parses the +# JSON, so values are opaque data (never interpreted by the shell). +import_provider_env() { + local clear_file="$1" + local json="${DEPLOY_PROVIDER_ENV:-}" + [[ -n "$json" ]] || json='{}' + clear_named_aliases "$clear_file" + + require_cmd jq + require_cmd base64 + printf '%s' "$json" | jq -e 'type == "object"' >/dev/null 2>&1 || + fail "DEPLOY_PROVIDER_ENV must be a JSON object of string values" + printf '%s' "$json" | jq -e 'all(.[]; type == "string")' >/dev/null 2>&1 || + fail "every DEPLOY_PROVIDER_ENV value must be a string" + + # One "NAME BASE64VALUE" line per entry. Base64 keeps values line-safe + # (newlines, spaces, quotes cannot break the read loop) and opaque. + local name b64 value + while read -r name b64; do + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || + fail "DEPLOY_PROVIDER_ENV name '$name' is not a valid environment variable name" + name_in_clear_list "$name" "$clear_file" || + fail "DEPLOY_PROVIDER_ENV name '$name' must be declared in provider-env-clear" + value=$(printf '%s' "$b64" | base64 --decode) + export "$name=$value" + done < <(printf '%s' "$json" | jq -r 'to_entries[] | "\(.key) \(.value | @base64)"') +} + # Build the CLI argv for `build` mode into the global ARGV array. build_build_argv() { local cli_bin="$1" @@ -92,7 +138,11 @@ main() { case "$mode" in build) build_build_argv "$cli_bin" "$adapter" ;; - deploy) build_deploy_argv "$cli_bin" "$adapter" ;; + deploy) + # Clear inherited provider aliases and export only the typed credentials. + import_provider_env "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" + build_deploy_argv "$cli_bin" "$adapter" + ;; esac if [[ -n "$manifest" ]]; then diff --git a/.github/actions/deploy-core/scripts/validate-inputs.sh b/.github/actions/deploy-core/scripts/validate-inputs.sh index 2eab4f84..9b8ec43d 100755 --- a/.github/actions/deploy-core/scripts/validate-inputs.sh +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -68,6 +68,7 @@ main() { local adapter="${INPUT_ADAPTER:-}" local build_mode="${INPUT_BUILD_MODE:-auto}" local cache="${INPUT_CACHE:-false}" + local stage="${INPUT_STAGE:-false}" local deploy_arg_allow="${INPUT_DEPLOY_ARG_ALLOW:-}" require_supported_runner "${EDGEZERO_RUNNER_OS:-}" "${EDGEZERO_RUNNER_ARCH:-}" @@ -84,6 +85,11 @@ main() { true | false) ;; *) fail "input 'cache' must be exactly 'true' or 'false'" ;; esac + # A typo here must never silently fall back to a production deploy. + case "$stage" in + true | false) ;; + *) fail "input 'stage' must be exactly 'true' or 'false'" ;; + esac require_cmd jq diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index 6aa87d9b..59b6d235 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -3,10 +3,13 @@ set -euo pipefail # Creates the minimal fixture application the composite smoke test deploys: # a standalone Cargo package (kept out of the surrounding edgezero workspace), -# a committed clean tree, and a fake `fastly` binary on PATH that records its -# argv instead of contacting Fastly. +# a committed clean tree, and an edgezero.toml whose Fastly deploy command is +# overridden by a marker script. The script emits `version=` (so version +# threading is exercised), records what credentials it actually saw (so the +# provider-env boundary is exercised), and records its argv — all without +# contacting Fastly. # -# Inputs (environment): GITHUB_WORKSPACE (required), GITHUB_PATH (required). +# Inputs (environment): GITHUB_WORKSPACE (required). main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" @@ -32,12 +35,28 @@ TOML echo 'fn main() {}' >src/main.rs cargo generate-lockfile - # Override the Fastly deploy command so `edgezero deploy --adapter fastly` - # runs a marker command (recording the passthrough argv) instead of the real - # `fastly compute deploy`, which would require a built package and live creds. + # Marker "deploy" script the CLI runs in place of `fastly compute deploy`. + # It records the credentials it actually saw and its argv, and emits a version + # line so `deploy-fastly` can thread `fastly-version`. + cat >fake-deploy.sh <<'SH' +#!/usr/bin/env bash +{ + printf 'token=%s\n' "${FASTLY_API_TOKEN:-MISSING}" + printf 'service-id=%s\n' "${FASTLY_SERVICE_ID:-MISSING}" + # Boundary check: an inherited endpoint alias must have been cleared. + printf 'endpoint=%s\n' "${FASTLY_ENDPOINT:-CLEARED}" +} >"${GITHUB_WORKSPACE}/fixture-app/env-seen.txt" +printf '%s\n' "$@" >"${GITHUB_WORKSPACE}/fixture-app/deploy-argv.txt" +echo "version=7" +SH + chmod +x fake-deploy.sh + + # Override the Fastly deploy command so `edgezero deploy --adapter fastly` runs + # the marker script instead of the real `fastly compute deploy` (which would + # need a built package and live credentials). cat >edgezero.toml <<'ETOML' [adapters.fastly.commands] -deploy = "printf '%s\n' > deploy-argv.txt" +deploy = "bash fake-deploy.sh" ETOML git add -A diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index 0f8190ae..6a643076 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -73,6 +73,7 @@ run_validate_inputs() { INPUT_DEPLOY_FLAGS="${VALIDATE_DEPLOY_FLAGS:-[]}" \ INPUT_PROVIDER_ENV_CLEAR="${VALIDATE_PROVIDER_ENV_CLEAR:-[]}" \ INPUT_DEPLOY_ARG_ALLOW="${VALIDATE_ALLOW:-}" \ + INPUT_STAGE="${VALIDATE_STAGE:-false}" \ EDGEZERO_ACTION_STATE_DIR="$state_dir" \ GITHUB_OUTPUT="$state_dir/output.txt" \ bash "$CORE_SCRIPTS/validate-inputs.sh" @@ -83,6 +84,8 @@ test_validate_inputs() { VALIDATE_ADAPTER=fastly assert_succeeds "accepts a well-formed adapter" run_validate_inputs VALIDATE_ADAPTER=FASTLY assert_fails "rejects a malformed adapter" run_validate_inputs VALIDATE_CACHE=maybe assert_fails "rejects a non-boolean cache" run_validate_inputs + VALIDATE_STAGE=true assert_succeeds "accepts stage=true" run_validate_inputs + VALIDATE_STAGE=True assert_fails "rejects a non-boolean stage (typo -> no silent prod)" run_validate_inputs VALIDATE_DEPLOY_ARGS='["--comment","hi"]' VALIDATE_ALLOW='--comment' \ assert_succeeds "allows an allowlisted deploy-arg (--comment)" run_validate_inputs VALIDATE_DEPLOY_ARGS='["--service-id","x"]' VALIDATE_ALLOW='--comment' \ @@ -91,6 +94,67 @@ test_validate_inputs() { VALIDATE_BUILD_ARGS='[1,2]' assert_fails "rejects non-string build-args" run_validate_inputs } +# --------------------------------------------------------------------------- +# build-cli artifact-name — never usable as a path traversal +# --------------------------------------------------------------------------- +check_artifact_name() { + # Run validate_artifact_name from build-cli's common.sh in a subshell. + bash -c 'source "$1"; validate_artifact_name "$2"' _ \ + "$ACTIONS_DIR/build-cli/scripts/common.sh" "$1" +} + +test_artifact_name() { + section "build-cli artifact-name" + assert_succeeds "accepts a conservative artifact name" check_artifact_name "edgezero-cli.v1" + assert_fails "rejects path traversal ('../x')" check_artifact_name "../x" + assert_fails "rejects path separators ('a/b')" check_artifact_name "a/b" + assert_fails "rejects a leading dot" check_artifact_name ".hidden" + assert_fails "rejects an empty name" check_artifact_name "" +} + +# --------------------------------------------------------------------------- +# run-cli.sh — provider-env credential boundary +# --------------------------------------------------------------------------- +# A fake CLI records the FASTLY_* it actually saw; run-cli must clear inherited +# aliases and export only the declared, typed values. +test_provider_env_boundary() { + section "run-cli provider-env boundary" + + local bin_dir="$WORK_DIR/pe-bin" app_dir="$WORK_DIR/pe-app" + local seen="$WORK_DIR/pe-seen.txt" clear="$WORK_DIR/pe-clear.nul" + mkdir -p "$bin_dir" "$app_dir" + cat >"$bin_dir/fakecli" <"$seen" +EOF + chmod +x "$bin_dir/fakecli" + printf 'FASTLY_API_TOKEN\0FASTLY_ENDPOINT\0' >"$clear" + + run_deploy_pe() { + env -i PATH="$bin_dir:$PATH" \ + EDGEZERO_CLI_BIN=fakecli EDGEZERO_ADAPTER=fastly \ + EDGEZERO_WORKING_DIRECTORY="$app_dir" \ + DEPLOY_PROVIDER_ENV_CLEAR_FILE="$clear" \ + DEPLOY_PROVIDER_ENV="$1" \ + FASTLY_API_TOKEN=inherited-BAD FASTLY_ENDPOINT=https://inherited.invalid \ + bash "$CORE_SCRIPTS/run-cli.sh" deploy + } + + if run_deploy_pe '{"FASTLY_API_TOKEN":"typed-tok"}' >/dev/null 2>&1; then + assert_equals "typed token wins; inherited endpoint cleared" \ + $'TOKEN=typed-tok\nENDPOINT=unset' "$(cat "$seen")" + else + fail "run-cli deploy (provider-env) failed to execute" + fi + + # A provider-env name not declared in provider-env-clear is rejected. + assert_fails "rejects an undeclared provider-env name" \ + run_deploy_pe '{"FASTLY_TOKEN":"x"}' +} + # --------------------------------------------------------------------------- # run-cli.sh — CLI argv construction # --------------------------------------------------------------------------- @@ -191,7 +255,9 @@ test_fastly_versions() { # --------------------------------------------------------------------------- main() { test_validate_inputs + test_artifact_name test_run_cli_argv + test_provider_env_boundary test_download_cli_metadata test_fastly_versions diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 90f71772..945da734 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -68,8 +68,9 @@ runs: INPUT_BUILD_ARGS: ${{ inputs['build-args'] }} INPUT_DEPLOY_ARGS: ${{ inputs['deploy-args'] }} INPUT_DEPLOY_ARG_ALLOW: "--comment" + INPUT_STAGE: ${{ inputs.stage }} INPUT_DEPLOY_FLAGS: ${{ inputs.stage == 'true' && format('["--service-id","{0}","--stage"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} - INPUT_PROVIDER_ENV_CLEAR: '["FASTLY_API_TOKEN","FASTLY_SERVICE_ID","FASTLY_TOKEN","FASTLY_API_ENDPOINT","FASTLY_ENDPOINT"]' + INPUT_PROVIDER_ENV_CLEAR: '["FASTLY_API_TOKEN","FASTLY_SERVICE_ID","FASTLY_TOKEN","FASTLY_KEY","FASTLY_API_KEY","FASTLY_AUTH_TOKEN","FASTLY_API_ENDPOINT","FASTLY_ENDPOINT","FASTLY_API_URL","FASTLY_PROFILE","FASTLY_SERVICE_NAME","FASTLY_DEBUG","FASTLY_DEBUG_MODE","FASTLY_CONFIG_FILE","FASTLY_HOME"]' INPUT_FASTLY_API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} INPUT_FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} EDGEZERO_RUNNER_OS: ${{ runner.os }} @@ -83,6 +84,8 @@ runs: run: | [[ "${INPUT_FASTLY_API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } [[ -n "${INPUT_FASTLY_SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } + [[ "${INPUT_FASTLY_SERVICE_ID}" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "::error::input 'fastly-service-id' must match ^[A-Za-z0-9_-]+$"; exit 1; } + # validate-inputs.sh also rejects a non-boolean 'stage' before any deploy. "$GITHUB_ACTION_PATH/../deploy-core/scripts/validate-inputs.sh" - name: Download CLI artifact @@ -175,13 +178,24 @@ runs: EDGEZERO_MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} DEPLOY_FLAGS_FILE: ${{ steps.validate.outputs['deploy-flags-file'] }} DEPLOY_ARGS_FILE: ${{ steps.validate.outputs['deploy-args-file'] }} - FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} - FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} + # Credential boundary: pass the typed values as data (not as FASTLY_* + # aliases). run-cli.sh clears every provider-env-clear alias — including + # any inherited FASTLY_ENDPOINT/FASTLY_TOKEN — and then exports only these. + DEPLOY_PROVIDER_ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} + EDGEZERO_FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + EDGEZERO_FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} run: | set -o pipefail + command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } + DEPLOY_PROVIDER_ENV=$(jq -n \ + --arg t "$EDGEZERO_FASTLY_API_TOKEN" \ + --arg s "$EDGEZERO_FASTLY_SERVICE_ID" \ + '{FASTLY_API_TOKEN: $t, FASTLY_SERVICE_ID: $s}') + export DEPLOY_PROVIDER_ENV log="${RUNNER_TEMP:-/tmp}/edgezero-deploy.log" "$GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh" deploy 2>&1 | tee "$log" version=$(grep -oE '^version=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) + [[ -n "$version" ]] || { echo "::error::deploy reported success but emitted no fastly-version"; exit 1; } echo "fastly-version=${version}" >> "$GITHUB_OUTPUT" - name: Save application target cache diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index b5946b32..40eda316 100644 --- a/.github/actions/healthcheck-fastly/action.yml +++ b/.github/actions/healthcheck-fastly/action.yml @@ -77,11 +77,22 @@ runs: RETRY: ${{ inputs.retry }} RETRY_DELAY: ${{ inputs['retry-delay'] }} TIMEOUT: ${{ inputs.timeout }} + # Only the typed token reaches the CLI; blank any inherited alias. FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_ENDPOINT: "" + FASTLY_API_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" run: | + # A typo in deploy-to must never silently probe production. + case "$DEPLOY_TO" in production | staging) ;; *) echo "::error::input 'deploy-to' must be 'production' or 'staging' (got '$DEPLOY_TO')"; exit 1 ;; esac log="${RUNNER_TEMP:-/tmp}/edgezero-healthcheck.log" args=("$CLI_BIN" healthcheck --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION" --domain "$DOMAIN" --retry "$RETRY" --retry-delay "$RETRY_DELAY" --timeout "$TIMEOUT") - [[ "$DEPLOY_TO" == "staging" ]] && args+=(--staging) + if [[ "$DEPLOY_TO" == "staging" ]]; then args+=(--staging); fi rc=0 "${args[@]}" 2>&1 | tee "$log" || rc=$? healthy=$(grep -oE '^healthy=(true|false)' "$log" | tail -n 1 | cut -d= -f2 || true) diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml index c0769ef8..1062b1e7 100644 --- a/.github/actions/rollback-fastly/action.yml +++ b/.github/actions/rollback-fastly/action.yml @@ -55,11 +55,22 @@ runs: SERVICE_ID: ${{ inputs['fastly-service-id'] }} VERSION: ${{ inputs['fastly-version'] }} DEPLOY_TO: ${{ inputs['deploy-to'] }} + # Only the typed token reaches the CLI; blank any inherited alias. FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_ENDPOINT: "" + FASTLY_API_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" run: | + # A typo in deploy-to must never silently roll back production. + case "$DEPLOY_TO" in production | staging) ;; *) echo "::error::input 'deploy-to' must be 'production' or 'staging' (got '$DEPLOY_TO')"; exit 1 ;; esac log="${RUNNER_TEMP:-/tmp}/edgezero-rollback.log" args=("$CLI_BIN" rollback --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION") - [[ "$DEPLOY_TO" == "staging" ]] && args+=(--staging) + if [[ "$DEPLOY_TO" == "staging" ]]; then args+=(--staging); fi rc=0 "${args[@]}" 2>&1 | tee "$log" || rc=$? rolled=$(grep -oE '^rolled-back-to=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index d73e2aa2..733426cf 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -99,6 +99,9 @@ jobs: composite-smoke: runs-on: ubuntu-24.04 + # An inherited provider alias the deploy MUST clear (provider-env boundary). + env: + FASTLY_ENDPOINT: https://inherited.invalid steps: - uses: actions/checkout@v4 with: @@ -116,6 +119,7 @@ jobs: run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - name: Deploy fixture (production) with local action + id: deploy uses: ./.github/actions/deploy-fastly with: cli-artifact: ${{ steps.cli.outputs.artifact-name }} @@ -124,11 +128,20 @@ jobs: fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' - - name: Assert the deploy command ran + - name: Assert end-to-end deploy, version threading, and credential boundary + env: + FASTLY_VERSION_OUT: ${{ steps.deploy.outputs['fastly-version'] }} run: | set -euo pipefail - # The marker file exists only if build-cli -> deploy-fastly -> the app - # CLI ran the overridden Fastly deploy command end to end. + # 1) The whole chain ran: build-cli -> deploy-fastly -> app CLI -> the + # overridden Fastly deploy command. test -f fixture-app/deploy-argv.txt - echo "deploy marker present; recorded argv:" - cat fixture-app/deploy-argv.txt + test -f fixture-app/env-seen.txt + echo "recorded argv:"; cat fixture-app/deploy-argv.txt + echo "credentials the deploy saw:"; cat fixture-app/env-seen.txt + # 2) fastly-version threaded out of the action. + [[ "$FASTLY_VERSION_OUT" == "7" ]] || { echo "::error::expected fastly-version=7, got '$FASTLY_VERSION_OUT'"; exit 1; } + # 3) provider-env boundary: typed creds present, inherited alias cleared. + grep -qx 'token=dummy-token' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_API_TOKEN not delivered"; exit 1; } + grep -qx 'service-id=dummy-service' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_SERVICE_ID not delivered"; exit 1; } + grep -qx 'endpoint=CLEARED' fixture-app/env-seen.txt || { echo "::error::inherited FASTLY_ENDPOINT was NOT cleared before deploy"; exit 1; } diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 789a3a84..025fb187 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1710,19 +1710,108 @@ fn curl_config_capture(config: &str) -> Result { } } +/// Wrap `value` in a curl-config double-quoted string, escaping the +/// characters that would otherwise let a value terminate its quote and +/// inject additional curl options. Within a curl `--config` file a +/// double-quoted value only honours the escapes `\\`, `\"`, `\n`, `\r`, +/// `\t` (and the config is parsed line-by-line, so a raw newline ends +/// the directive regardless of quoting). We escape backslash and quote +/// so the value cannot break out of the quotes, and map raw control +/// characters to their escape form so NO raw newline (or CR/tab) is +/// ever written into the config file. This is the second half of the +/// injection defence: untrusted identifiers are also validated (see +/// `validate_service_id` / `validate_version_str` / `validate_domain`), +/// but the token is a secret we cannot constrain to a charset, so it +/// relies on this escaping alone. +fn curl_quote(value: &str) -> String { + let mut out = String::with_capacity(value.len().saturating_add(2)); + out.push('"'); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other => out.push(other), + } + } + out.push('"'); + out +} + +/// Validate an operator-supplied Fastly service id before it is +/// interpolated into an API URL. Fastly service ids are opaque +/// alphanumeric handles; constrain to `^[A-Za-z0-9_-]+$` so a value +/// carrying a quote / newline / space (which could inject curl options +/// via the `--config` file) is rejected with a clear error. +fn validate_service_id(id: &str) -> Result<(), String> { + if !id.is_empty() + && id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + Ok(()) + } else { + Err(format!( + "invalid service id {id:?}: expected only ASCII letters, digits, `_`, or `-`" + )) + } +} + +/// Validate a service-version string is a plain non-negative integer +/// before it is interpolated into an API URL. Returns the parsed value +/// so callers can reuse it. +fn validate_version_str(version: &str) -> Result { + version.parse::().map_err(|err| { + format!("invalid version {version:?}: expected a non-negative integer: {err}") + }) +} + +/// Validate a domain is a plausible hostname before it is placed into a +/// `curl` URL. Rejects anything outside the DNS label charset +/// (`[A-Za-z0-9-.]`), empty / over-long values, leading/trailing dots, +/// and empty labels so an injected quote / slash / space / newline +/// cannot smuggle curl options or a second URL. +fn validate_domain(domain: &str) -> Result<(), String> { + let charset_ok = domain + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '.'); + let shape_ok = !domain.is_empty() + && domain.len() <= 253 + && !domain.starts_with('.') + && !domain.ends_with('.') + && !domain.contains(".."); + if charset_ok && shape_ok { + Ok(()) + } else { + Err(format!( + "invalid domain {domain:?}: expected a hostname like `example.com`" + )) + } +} + /// `GET https://api.fastly.com` with the `Fastly-Key` header; -/// returns the response body. +/// returns the response body. Both the header (carrying the secret +/// token) and the URL are written through `curl_quote` so neither can +/// inject curl options into the `--config` document. fn fastly_api_get(path: &str, token: &str) -> Result { - let config = - format!("header = \"Fastly-Key: {token}\"\nurl = \"https://api.fastly.com{path}\"\n"); + let header = curl_quote(&format!("Fastly-Key: {token}")); + let url = curl_quote(&format!("https://api.fastly.com{path}")); + let config = format!("header = {header}\nurl = {url}\n"); curl_config_capture(&config) } -/// `POST https://api.fastly.com` with the `Fastly-Key` header; -/// returns the HTTP status, erroring on non-2xx. -fn fastly_api_post(path: &str, token: &str) -> Result { +/// `PUT https://api.fastly.com` with the `Fastly-Key` header; +/// returns the HTTP status, erroring on non-2xx. Fastly's version +/// activate/deactivate endpoints require `PUT` (not `POST`). Header and +/// URL are escaped via `curl_quote`; the literal `request`, `output`, +/// and `write-out` directives are fixed constants. +fn fastly_api_put(path: &str, token: &str) -> Result { + let header = curl_quote(&format!("Fastly-Key: {token}")); + let url = curl_quote(&format!("https://api.fastly.com{path}")); let config = format!( - "request = \"POST\"\nheader = \"Fastly-Key: {token}\"\nurl = \"https://api.fastly.com{path}\"\noutput = \"/dev/null\"\nwrite-out = \"%{{http_code}}\"\n" + "request = \"PUT\"\nheader = {header}\nurl = {url}\noutput = \"/dev/null\"\nwrite-out = \"%{{http_code}}\"\n" ); let out = curl_config_capture(&config)?; let code: u16 = out.trim().parse().map_err(|err| { @@ -1734,8 +1823,38 @@ fn fastly_api_post(path: &str, token: &str) -> Result { if (200..300).contains(&code) { Ok(code) } else { - Err(format!("Fastly API POST {path} returned HTTP {code}")) + Err(format!("Fastly API PUT {path} returned HTTP {code}")) + } +} + +/// Resolve the directory containing the Fastly manifest for a staged +/// deploy. +/// +/// The CLI (`edgezero_cli::run_deploy`) resolves the `edgezero.toml` +/// manifest — honouring `EDGEZERO_MANIFEST` — and threads the +/// manifest-configured `[adapters.fastly.adapter].manifest` path in as +/// `--manifest-path `. Prefer that so a monorepo with +/// multiple Fastly apps stages the app the operator actually selected, +/// rather than whichever `fastly.toml` a bare working-directory search +/// happens to find first. Only when no `--manifest-path` is threaded +/// (e.g. a manifest that declares Fastly commands but no adapter +/// `manifest` key) do we fall back to the working-directory search the +/// legacy `deploy` path used. +fn resolve_staged_manifest_dir(args: &[String]) -> Result { + if let Some(raw) = arg_value(args, "--manifest-path") { + let path = PathBuf::from(raw); + return path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(Path::to_path_buf) + .ok_or_else(|| format!("fastly manifest path {raw:?} has no parent directory")); } + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + manifest + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "fastly manifest has no parent directory".to_owned()) } /// `deploy --adapter fastly --service-id --stage` (spec §5.4): @@ -1743,17 +1862,22 @@ fn fastly_api_post(path: &str, token: &str) -> Result { /// emit `version=`. fn deploy_staged(args: &[String]) -> Result<(), String> { let service_id = resolve_service_id(args)?; + validate_service_id(&service_id)?; // The Fastly CLI reads FASTLY_API_TOKEN from the env; fail fast // with a clear message when it's missing rather than deep in a // `fastly compute update` error. require_token()?; - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - let extra = args_without_flag_value(args, "--service-id"); + let manifest_dir_buf = resolve_staged_manifest_dir(args)?; + let manifest_dir = manifest_dir_buf.as_path(); + // Strip both the explicitly-threaded `--service-id` and the + // CLI-injected `--manifest-path` so neither is forwarded to + // `fastly compute update` (which doesn't understand + // `--manifest-path`). + let extra = args_without_flag_value( + &args_without_flag_value(args, "--service-id"), + "--manifest-path", + ); // 1. Build the wasm package (no deploy / activation). run_fastly_status( @@ -1813,6 +1937,7 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { /// service version via the Fastly API and emit `version=`. fn emit_active_version(args: &[String]) -> Result<(), String> { let service_id = resolve_service_id(args)?; + validate_service_id(&service_id)?; let token = require_token()?; let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; let version = parse_active_version(&json).ok_or_else(|| { @@ -1829,6 +1954,7 @@ fn emit_active_version(args: &[String]) -> Result<(), String> { fn healthcheck(args: &[String]) -> Result<(), String> { let domain = arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; + validate_domain(domain)?; let retry = arg_value(args, "--retry") .and_then(|value| value.parse().ok()) .unwrap_or(3_u32); @@ -1841,8 +1967,10 @@ fn healthcheck(args: &[String]) -> Result<(), String> { let staging_ip = if arg_flag(args, "--staging") { let service_id = resolve_service_id(args)?; - let version = arg_value(args, "--version") + validate_service_id(&service_id)?; + let version_str = arg_value(args, "--version") .ok_or_else(|| "staging healthcheck requires --version".to_owned())?; + let version = validate_version_str(version_str)?; let token = require_token()?; let json = fastly_api_get( &format!("/service/{service_id}/version/{version}/domain?include=staging_ips"), @@ -1908,26 +2036,31 @@ fn curl_status(args: &[String]) -> Result { /// ` - 1`; staging deactivates ``. fn rollback(args: &[String]) -> Result<(), String> { let service_id = resolve_service_id(args)?; + validate_service_id(&service_id)?; let version_str = arg_value(args, "--version").ok_or_else(|| "rollback requires --version".to_owned())?; - let version: u64 = version_str.parse().map_err(|err| { - format!("--version must be a positive integer, got {version_str:?}: {err}") - })?; + let version = validate_version_str(version_str)?; let token = require_token()?; if arg_flag(args, "--staging") { - fastly_api_post( - &format!("/service/{service_id}/version/{version}/deactivate"), + // Staging rollback deactivates the STAGED version on the + // `staging` environment. Fastly's environment-scoped + // deactivate is `PUT .../deactivate/staging` (a plain + // `.../deactivate` would target the production activation). + fastly_api_put( + &format!("/service/{service_id}/version/{version}/deactivate/staging"), &token, )?; log::info!( "[edgezero] deactivated staged version {version} on Fastly service {service_id}" ); } else { + // Production rollback re-activates the previous version. + // Fastly's activate endpoint requires `PUT` (not `POST`). let previous = previous_version(version).ok_or_else(|| { format!("cannot roll back version {version}: no previous version exists") })?; - fastly_api_post( + fastly_api_put( &format!("/service/{service_id}/version/{previous}/activate"), &token, )?; @@ -2033,12 +2166,96 @@ mod tests { ); } + #[test] + fn resolve_staged_manifest_dir_prefers_manifest_path_flag() { + // When the CLI threads `--manifest-path `, the + // staged deploy must use its parent directory rather than a bare + // working-directory search (which in a monorepo could pick a + // different app's fastly.toml). + let args = vec![ + "--service-id".to_owned(), + "SVC1".to_owned(), + "--manifest-path".to_owned(), + "/repo/apps/edge/fastly.toml".to_owned(), + ]; + let dir = resolve_staged_manifest_dir(&args).expect("resolves from --manifest-path"); + assert_eq!(dir, PathBuf::from("/repo/apps/edge")); + } + #[test] fn resolve_service_id_prefers_flag() { let args = vec!["--service-id".to_owned(), "SVC_FROM_ARG".to_owned()]; assert_eq!(resolve_service_id(&args).unwrap(), "SVC_FROM_ARG"); } + // ── curl-config escaping + input validation (injection defence) ─── + + #[test] + fn curl_quote_escapes_quotes_and_backslashes() { + assert_eq!(curl_quote("plain"), "\"plain\""); + assert_eq!(curl_quote("a\"b"), "\"a\\\"b\""); + assert_eq!(curl_quote("a\\b"), "\"a\\\\b\""); + } + + #[test] + fn curl_quote_never_emits_raw_control_characters() { + // A token carrying a `"` and a newline must not be able to + // terminate its quoted value and inject a second `url = "..."` + // directive. The `"` is escaped and the newline is folded to a + // `\n` escape so NO raw newline reaches the curl config file. + let token = "tok\"en\nurl = \"https://evil.example\""; + let quoted = curl_quote(token); + assert!(quoted.starts_with('"') && quoted.ends_with('"')); + assert!(!quoted.contains('\n'), "no raw newline: {quoted}"); + assert!(!quoted.contains('\r')); + // The only unescaped `"` are the wrapping pair; every interior + // quote is preceded by a backslash. + assert_eq!(quoted, "\"tok\\\"en\\nurl = \\\"https://evil.example\\\"\""); + // A tab folds too. + assert_eq!(curl_quote("a\tb"), "\"a\\tb\""); + } + + #[test] + fn validate_service_id_accepts_opaque_handles() { + validate_service_id("SU1Z0isxPaozGVKXdv0eY").expect("alphanumeric handle"); + validate_service_id("abc_DEF-123").expect("underscore + dash handle"); + } + + #[test] + fn validate_service_id_rejects_injection_and_empty() { + // The canonical attack: a service id that closes the url value + // and appends a second url directive. + validate_service_id("abc\nurl = \"http://evil\"").expect_err("newline injection"); + validate_service_id("abc\"def").expect_err("quote"); + validate_service_id("has space").expect_err("space"); + validate_service_id("has/slash").expect_err("slash"); + validate_service_id("").expect_err("empty"); + } + + #[test] + fn validate_version_str_accepts_integer_rejects_junk() { + assert_eq!(validate_version_str("42"), Ok(42)); + assert_eq!(validate_version_str("0"), Ok(0)); + validate_version_str("-1").expect_err("negative"); + validate_version_str("4.2").expect_err("float"); + validate_version_str("42\nurl = \"x\"").expect_err("newline injection"); + validate_version_str("").expect_err("empty"); + } + + #[test] + fn validate_domain_accepts_hostnames_rejects_injection() { + validate_domain("example.com").expect("bare hostname"); + validate_domain("staging.example.co.uk").expect("multi-label hostname"); + validate_domain("host-1.example.com").expect("hostname with dash"); + validate_domain("").expect_err("empty"); + validate_domain(".example.com").expect_err("leading dot"); + validate_domain("example.com.").expect_err("trailing dot"); + validate_domain("exa..mple.com").expect_err("empty label"); + validate_domain("example.com/evil").expect_err("slash"); + validate_domain("example.com\nurl = \"x\"").expect_err("newline injection"); + validate_domain("has space.com").expect_err("space"); + } + #[test] fn is_healthy_status_covers_2xx_3xx() { assert!(is_healthy_status(200)); diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index d9882764..4704ab53 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -3,8 +3,10 @@ use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; use std::env; use std::fmt; +use std::io::{self, BufRead as _, BufReader, Read, Write}; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::thread; include!(concat!(env!("OUT_DIR"), "/linked_adapters.rs")); @@ -152,6 +154,42 @@ pub fn execute( adapter.execute(AdapterAction::from(action), adapter_args) } +/// Same dispatch as [`execute`], but when the action resolves to a +/// manifest-declared shell command the child's output is echoed AND +/// captured (see [`run_shell_tee`]) and returned as `Some(text)`. +/// +/// Returns `Ok(None)` when the action was served by the registered +/// adapter's built-in `execute` instead — that path writes straight to +/// the inherited stdio, so there is nothing for us to capture and the +/// caller must fall back to another source of truth (for Fastly deploy: +/// the Fastly API). +pub fn execute_capture( + adapter_name: &str, + action: Action, + manifest_loader: Option<&ManifestLoader>, + adapter_args: &[String], +) -> Result, String> { + if let Some(loader) = manifest_loader { + if let Some(command) = manifest_command(loader.manifest(), adapter_name, action) { + let root = loader.manifest().root().unwrap_or_else(|| Path::new(".")); + let env = loader.manifest().environment_for(adapter_name); + let adapter_bind = adapter_bind_from_manifest(loader.manifest(), adapter_name); + return run_shell_tee( + command, + root, + adapter_name, + action, + Some(env), + adapter_bind, + adapter_args, + ) + .map(Some); + } + } + execute(adapter_name, action, manifest_loader, adapter_args)?; + Ok(None) +} + fn manifest_command<'manifest>( manifest: &'manifest Manifest, adapter_name: &str, @@ -188,15 +226,18 @@ fn adapter_bind_from_manifest( (cfg.adapter.host.clone(), cfg.adapter.port) } -fn run_shell( +/// Build the `sh -c ` child for a manifest-declared adapter +/// command, with the manifest environment / bind hints applied. Shared +/// by [`run_shell`] (inherited stdio) and [`run_shell_tee`] (piped + +/// echoed stdio) so both dispatch paths apply identical env precedence. +fn build_shell_command( command: &str, cwd: &Path, adapter_name: &str, - action: Action, environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], -) -> Result<(), String> { +) -> Result { let full_command = if adapter_args.is_empty() { command.to_owned() } else { @@ -244,6 +285,27 @@ fn run_shell( apply_environment(adapter_name, &env, &mut cmd)?; } + Ok(cmd) +} + +fn run_shell( + command: &str, + cwd: &Path, + adapter_name: &str, + action: Action, + environment: Option, + adapter_bind: (Option, Option), + adapter_args: &[String], +) -> Result<(), String> { + let mut cmd = build_shell_command( + command, + cwd, + adapter_name, + environment, + adapter_bind, + adapter_args, + )?; + let status = cmd .status() .map_err(|err| format!("failed to run {action} command `{command}`: {err}"))?; @@ -257,6 +319,83 @@ fn run_shell( } } +/// Stream `reader` to `writer` line-by-line while accumulating a copy. +/// This is the "tee" half of [`run_shell_tee`]: the operator still sees +/// the child's output as it happens, and the caller still gets the text +/// to parse. +fn tee_stream(reader: R, mut writer: W) -> String { + let mut buffered = BufReader::new(reader); + let mut captured = String::new(); + let mut line = String::new(); + loop { + line.clear(); + match buffered.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => { + let _echoed = writer.write_all(line.as_bytes()); + let _flushed = writer.flush(); + captured.push_str(&line); + } + } + } + captured +} + +/// Same dispatch as [`run_shell`], but the child's stdout/stderr are +/// piped, echoed through to our own stdout/stderr as they arrive, AND +/// captured. Returns the captured `stdout + stderr` text so the caller +/// can parse machine-readable lines (e.g. Fastly's activated +/// `version=`) out of a command it does not otherwise control. +fn run_shell_tee( + command: &str, + cwd: &Path, + adapter_name: &str, + action: Action, + environment: Option, + adapter_bind: (Option, Option), + adapter_args: &[String], +) -> Result { + let mut cmd = build_shell_command( + command, + cwd, + adapter_name, + environment, + adapter_bind, + adapter_args, + )?; + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|err| format!("failed to run {action} command `{command}`: {err}"))?; + let child_stdout = child + .stdout + .take() + .ok_or_else(|| format!("failed to capture stdout of {action} command `{command}`"))?; + let child_stderr = child + .stderr + .take() + .ok_or_else(|| format!("failed to capture stderr of {action} command `{command}`"))?; + + // stderr is drained on a worker thread so a chatty child cannot + // deadlock by filling the stderr pipe while we block on stdout. + let stderr_worker = thread::spawn(move || tee_stream(child_stderr, io::stderr())); + let captured_stdout = tee_stream(child_stdout, io::stdout()); + let captured_stderr = stderr_worker.join().unwrap_or_default(); + + let status = child + .wait() + .map_err(|err| format!("failed to run {action} command `{command}`: {err}"))?; + + if status.success() { + Ok(format!("{captured_stdout}{captured_stderr}")) + } else { + Err(format!( + "{action} command `{command}` exited with status {status}" + )) + } +} + fn shell_escape(arg: &str) -> String { if arg.is_empty() { "''".to_owned() diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 0cce6802..3bcf711f 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -236,18 +236,20 @@ pub struct HealthcheckArgs { /// Target adapter name. #[arg(long = "adapter", required = true)] pub adapter: String, - /// Public domain to probe. - #[arg(long)] - pub domain: Option, + /// Public domain to probe. Required: the deploy→healthcheck→rollback + /// contract always threads it. + #[arg(long, required = true)] + pub domain: String, /// Total number of attempts before declaring the probe unhealthy. #[arg(long, default_value_t = 3)] pub retry: u32, /// Seconds to wait between attempts. #[arg(long = "retry-delay", default_value_t = 5)] pub retry_delay: u64, - /// Platform service id to probe. - #[arg(long)] - pub service_id: Option, + /// Platform service id to probe. Required (staging resolves the + /// staging IP from it; production threads it for parity). + #[arg(long, required = true)] + pub service_id: String, /// Probe the staged version via its resolved staging IP rather /// than the live production endpoint. #[arg(long)] @@ -256,8 +258,9 @@ pub struct HealthcheckArgs { #[arg(long, default_value_t = 10)] pub timeout: u64, /// Service version to probe (threaded from a prior deploy/stage). - #[arg(long)] - pub version: Option, + /// Required so the version is never silently dropped. + #[arg(long, required = true)] + pub version: String, } /// Arguments for the `rollback` command (Fastly staging lifecycle, @@ -270,17 +273,17 @@ pub struct RollbackArgs { /// Target adapter name. #[arg(long = "adapter", required = true)] pub adapter: String, - /// Platform service id to roll back. - #[arg(long)] - pub service_id: Option, + /// Platform service id to roll back. Required. + #[arg(long, required = true)] + pub service_id: String, /// Roll back the staged version (deactivate) instead of the /// production version (activate previous). #[arg(long)] pub staging: bool, /// Reference version: production activates ` - 1`; - /// staging deactivates ``. - #[arg(long)] - pub version: Option, + /// staging deactivates ``. Required. + #[arg(long, required = true)] + pub version: String, } /// Output format for `config diff`. @@ -826,9 +829,9 @@ mod tests { panic!("expected Command::Healthcheck"); }; assert_eq!(hc.adapter, "fastly"); - assert_eq!(hc.service_id.as_deref(), Some("SVC123")); - assert_eq!(hc.version.as_deref(), Some("42")); - assert_eq!(hc.domain.as_deref(), Some("staging.example.com")); + assert_eq!(hc.service_id, "SVC123"); + assert_eq!(hc.version, "42"); + assert_eq!(hc.domain, "staging.example.com"); assert!(hc.staging); assert_eq!(hc.retry, 7); assert_eq!(hc.retry_delay, 2); @@ -842,6 +845,10 @@ mod tests { "healthcheck", "--adapter", "fastly", + "--service-id", + "SVC123", + "--version", + "42", "--domain", "example.com", ]) @@ -857,8 +864,57 @@ mod tests { #[test] fn healthcheck_requires_adapter() { - Args::try_parse_from(["edgezero", "healthcheck", "--domain", "example.com"]) - .expect_err("`healthcheck` without --adapter must error"); + Args::try_parse_from([ + "edgezero", + "healthcheck", + "--service-id", + "SVC123", + "--version", + "42", + "--domain", + "example.com", + ]) + .expect_err("`healthcheck` without --adapter must error"); + } + + #[test] + fn healthcheck_requires_service_id_version_and_domain() { + // Each required lifecycle input must be present; omitting any + // one is a parse error (spec §5.4 deploy→healthcheck→rollback + // threading depends on them). + Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--version", + "42", + "--domain", + "example.com", + ]) + .expect_err("missing --service-id must error"); + Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--domain", + "example.com", + ]) + .expect_err("missing --version must error"); + Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--version", + "42", + ]) + .expect_err("missing --domain must error"); } #[test] @@ -879,8 +935,8 @@ mod tests { panic!("expected Command::Rollback"); }; assert_eq!(rb.adapter, "fastly"); - assert_eq!(rb.service_id.as_deref(), Some("SVC123")); - assert_eq!(rb.version.as_deref(), Some("42")); + assert_eq!(rb.service_id, "SVC123"); + assert_eq!(rb.version, "42"); assert!(rb.staging); } @@ -891,6 +947,8 @@ mod tests { "rollback", "--adapter", "fastly", + "--service-id", + "SVC123", "--version", "9", ]) @@ -903,8 +961,37 @@ mod tests { #[test] fn rollback_requires_adapter() { - Args::try_parse_from(["edgezero", "rollback", "--version", "9"]) - .expect_err("`rollback` without --adapter must error"); + Args::try_parse_from([ + "edgezero", + "rollback", + "--service-id", + "SVC123", + "--version", + "9", + ]) + .expect_err("`rollback` without --adapter must error"); + } + + #[test] + fn rollback_requires_service_id_and_version() { + Args::try_parse_from([ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--version", + "9", + ]) + .expect_err("missing --service-id must error"); + Args::try_parse_from([ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC123", + ]) + .expect_err("missing --version must error"); } // ── config push / diff stub tests (12.8 + 12.11) ────────────────── diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 85532a2f..a10005d8 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -176,40 +176,146 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // package to a new draft, mark it staged, and emit the staged // version (spec §5.4). Never runs the manifest `deploy` // command, which would activate production. + // + // Thread the manifest-configured Fastly manifest path (resolved + // from `[adapters..adapter].manifest` relative to the + // `EDGEZERO_MANIFEST`-honoring manifest root) so the staged + // deploy targets the app the operator selected — not whichever + // `fastly.toml` a bare working-directory search finds first in a + // monorepo. The adapter falls back to a cwd search only when the + // manifest declares no adapter `manifest` key. + let mut staged: Vec = Vec::new(); + if let Some(manifest_path) = resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter) + { + staged.push("--manifest-path".to_owned()); + staged.push(manifest_path); + } + staged.extend(passthrough); return adapter::execute( &args.adapter, adapter::Action::DeployStaged, manifest.as_ref(), - &passthrough, + &staged, ); } - adapter::execute( - &args.adapter, - adapter::Action::Deploy, - manifest.as_ref(), - &passthrough, - )?; - // Production deploy also emits the activated version (spec §5.4.2) - // so the deploy-fastly action can surface `fastly-version`. This - // is Fastly-specific and best-effort: it only runs with a known - // service id, and a failure to resolve the version must NOT fail - // an already-activated deploy — the version is a convenience - // output, not the deploy's success criterion. + // so the deploy-fastly action can surface `fastly-version` and the + // deploy→healthcheck→rollback chain has a real version to thread. + // + // Resolution precedence (cheapest + most reliable first): + // 1. The deploy command's OWN output. We tee it (echoed live to + // the operator, captured for us) and look for a canonical + // `version=` line, then for Fastly's native phrasing + // ("... version 12"). The deploy command already knows the + // version it activated, so this needs no API round-trip and + // works under a manifest `[adapters.fastly.commands].deploy` + // override (including test fixtures with dummy credentials). + // 2. Only when the output yields nothing: the Fastly API lookup + // (`EmitVersion`), which needs a live API + a real token. + // 3. If BOTH fail: a clear `Err`. We never silently emit an empty + // version — that was the original finding. if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { - if let Err(err) = adapter::execute( + let captured = adapter::execute_capture( + &args.adapter, + adapter::Action::Deploy, + manifest.as_ref(), + &passthrough, + )?; + if let Some(version) = captured.as_deref().and_then(parse_deploy_version) { + log::info!("version={version}"); + return Ok(()); + } + return adapter::execute( &args.adapter, adapter::Action::EmitVersion, manifest.as_ref(), &passthrough, - ) { - log::warn!( - "[edgezero] deploy succeeded but resolving the activated version failed: {err}" - ); + ) + .map_err(|err| { + format!( + "deploy succeeded but the activated version could not be resolved: no `version=` \ + (or Fastly `version `) line in the deploy output, and the Fastly API fallback \ + failed: {err}" + ) + }); + } + + adapter::execute( + &args.adapter, + adapter::Action::Deploy, + manifest.as_ref(), + &passthrough, + ) +} + +/// Parse an activated service version out of a deploy command's output. +/// +/// Precedence: +/// 1. A canonical `version=` line (what a manifest +/// `[adapters.fastly.commands].deploy` override — or a CI fixture — +/// emits, and what `EdgeZero` itself prints). +/// 2. Fastly's native phrasing, e.g. +/// `SUCCESS: Deployed package (service abc, version 12)`. The LAST +/// mention wins, which is the version the deploy ended on. +/// +/// Returns `None` when neither shape is present, which sends the caller +/// to the Fastly API fallback. +#[cfg(feature = "cli")] +fn parse_deploy_version(output: &str) -> Option { + parse_canonical_version_line(output).or_else(|| parse_native_version_mention(output)) +} + +/// Last `version=` line in `output` (leading/trailing whitespace on +/// the line is ignored). +#[cfg(feature = "cli")] +fn parse_canonical_version_line(output: &str) -> Option { + output.lines().rev().find_map(|line| { + let digits: String = line + .trim() + .strip_prefix("version=")? + .chars() + .take_while(char::is_ascii_digit) + .collect(); + digits.parse::().ok() + }) +} + +/// Last `version ` mention anywhere in `output` (case-insensitive), +/// covering Fastly's `Deployed package (service abc, version 12)`. +#[cfg(feature = "cli")] +fn parse_native_version_mention(output: &str) -> Option { + let lower = output.to_ascii_lowercase(); + let mut result = None; + for (idx, _) in lower.match_indices("version") { + let after = idx.saturating_add("version".len()); + let Some(rest) = lower.get(after..) else { + continue; + }; + let digits: String = rest + .chars() + .skip_while(|ch| !ch.is_ascii_digit()) + .take_while(char::is_ascii_digit) + .collect(); + if let Ok(parsed) = digits.parse::() { + result = Some(parsed); } } - Ok(()) + result +} + +/// Resolve the absolute path of the adapter's platform manifest +/// (`[adapters..adapter].manifest`) relative to the manifest +/// root. Returns `None` when there is no loaded manifest, no root, no +/// entry for the adapter, or no `manifest` key. Used by the Fastly +/// staged deploy to target the operator-selected app in a monorepo. +#[cfg(feature = "cli")] +fn resolve_adapter_manifest_path(loader: Option<&ManifestLoader>, adapter: &str) -> Option { + let manifest = loader?.manifest(); + let root = manifest.root()?; + let (_canonical, cfg) = manifest.adapter_entry(adapter)?; + let rel = cfg.adapter.manifest.as_deref()?; + Some(root.join(rel).to_string_lossy().into_owned()) } /// Probe a deployed version's health (Fastly staging lifecycle, spec @@ -227,28 +333,25 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> { let manifest = load_manifest_optional()?; ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - let mut passthrough: Vec = Vec::new(); - if let Some(service_id) = &args.service_id { - passthrough.push("--service-id".to_owned()); - passthrough.push(service_id.clone()); - } - if let Some(version) = &args.version { - passthrough.push("--version".to_owned()); - passthrough.push(version.clone()); - } - if let Some(domain) = &args.domain { - passthrough.push("--domain".to_owned()); - passthrough.push(domain.clone()); - } + let mut passthrough: Vec = vec![ + "--service-id".to_owned(), + args.service_id.clone(), + "--version".to_owned(), + args.version.clone(), + "--domain".to_owned(), + args.domain.clone(), + ]; if args.staging { passthrough.push("--staging".to_owned()); } - passthrough.push("--retry".to_owned()); - passthrough.push(args.retry.to_string()); - passthrough.push("--retry-delay".to_owned()); - passthrough.push(args.retry_delay.to_string()); - passthrough.push("--timeout".to_owned()); - passthrough.push(args.timeout.to_string()); + passthrough.extend([ + "--retry".to_owned(), + args.retry.to_string(), + "--retry-delay".to_owned(), + args.retry_delay.to_string(), + "--timeout".to_owned(), + args.timeout.to_string(), + ]); adapter::execute( &args.adapter, adapter::Action::Healthcheck, @@ -271,15 +374,12 @@ pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> { pub fn run_rollback(args: &RollbackArgs) -> Result<(), String> { let manifest = load_manifest_optional()?; ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - let mut passthrough: Vec = Vec::new(); - if let Some(service_id) = &args.service_id { - passthrough.push("--service-id".to_owned()); - passthrough.push(service_id.clone()); - } - if let Some(version) = &args.version { - passthrough.push("--version".to_owned()); - passthrough.push(version.clone()); - } + let mut passthrough: Vec = vec![ + "--service-id".to_owned(), + args.service_id.clone(), + "--version".to_owned(), + args.version.clone(), + ]; if args.staging { passthrough.push("--staging".to_owned()); } @@ -467,6 +567,46 @@ mod tests { assert!(manifest.manifest().adapters.contains_key("fastly")); } + // ── deploy-output version parsing (spec §5.4.2) ─────────────────── + + #[test] + fn parse_deploy_version_reads_canonical_line() { + // What a manifest `[adapters.fastly.commands].deploy` override + // (or a CI fixture running with dummy creds) emits. Must be + // parsed WITHOUT any Fastly API round-trip. + let output = "building...\nversion=7\ndone\n"; + assert_eq!(parse_deploy_version(output), Some(7)); + } + + #[test] + fn parse_deploy_version_reads_fastly_native_phrasing() { + let output = "SUCCESS: Deployed package (service abc123, version 12)\n"; + assert_eq!(parse_deploy_version(output), Some(12)); + } + + #[test] + fn parse_deploy_version_none_when_absent_triggers_fallback() { + // No version anywhere -> `None`, which routes run_deploy to the + // Fastly API fallback (and to a clear Err if that also fails). + let output = "Building package...\nUploading...\nAll good.\n"; + assert_eq!(parse_deploy_version(output), None); + assert_eq!(parse_deploy_version(""), None); + } + + #[test] + fn parse_deploy_version_prefers_canonical_over_native_mention() { + // A fixture that both narrates a clone AND emits the canonical + // line: the canonical line is authoritative. + let output = "Cloning version 3...\nversion=9\n"; + assert_eq!(parse_deploy_version(output), Some(9)); + } + + #[test] + fn parse_deploy_version_native_takes_last_mention() { + let output = "Cloning version 3... created version 4\n"; + assert_eq!(parse_deploy_version(output), Some(4)); + } + #[test] fn ensure_adapter_defined_accepts_known_adapter() { let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); diff --git a/docs/guide/deploy-github-actions.md b/docs/guide/deploy-github-actions.md index e2b6a7ed..d8cf21ab 100644 --- a/docs/guide/deploy-github-actions.md +++ b/docs/guide/deploy-github-actions.md @@ -157,13 +157,29 @@ Outputs: `cli-version`, `cli-package`, `cli-bin`, `artifact-name`. Outputs: `fastly-version`, `source-revision`, `cli-version`. +## Strict lifecycle values (fail closed) + +`stage` and `deploy-to` are validated exactly, and a bad value **fails the run** +rather than falling back to production: + +- `stage` must be exactly `true` or `false`. +- `deploy-to` must be exactly `production` or `staging`. + +A typo like `stage: True` or `deploy-to: Staging` is rejected up front — it will +never silently deploy to, probe, or roll back production. + ## Credentials -Fastly credentials are typed inputs, not workflow `env:`. The engine binds the -token to the deploy step's own environment only — setup and build steps never see -it, and it never reaches outputs, caches, logs, or step summaries. Do not -duplicate provider credentials in `env:`; prefer provider-managed runtime secret -stores for application secrets. +Fastly credentials are typed inputs, not workflow `env:`. Setup and build steps +never see the token, and it never reaches outputs, caches, logs, or step +summaries. Do not duplicate provider credentials in `env:`; prefer +provider-managed runtime secret stores for application secrets. + +The deploy step enforces a hard credential boundary: before the CLI runs, every +known provider alias (`FASTLY_TOKEN`, `FASTLY_ENDPOINT`, `FASTLY_API_URL`, …) is +**cleared**, and only the typed values you passed are exported. An inherited +`FASTLY_ENDPOINT` or `FASTLY_TOKEN` from the surrounding workflow cannot reach +the deploy. Deploy runs trusted application code: because Fastly's default `build-mode: never` lets `fastly compute deploy` build during deploy, the application is From de476c688f2aaa6d910c8f6552abb4b03a0c9326 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:45:25 -0700 Subject: [PATCH 18/26] Add Fastly lifecycle acceptance test; close the --features cli test gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../actions/build-cli/scripts/build-cli.sh | 15 +- .github/actions/build-cli/scripts/common.sh | 14 + .github/actions/deploy-core/scripts/common.sh | 27 + .../deploy-core/scripts/download-cli.sh | 13 +- .../deploy-core/tests/make-fake-fastly-env.sh | 87 +++ .../deploy-core/tests/make-smoke-fixture.sh | 96 ++- .github/actions/deploy-core/tests/run.sh | 65 ++ .github/actions/deploy-fastly/action.yml | 64 ++ .github/actions/healthcheck-fastly/action.yml | 12 +- .github/actions/rollback-fastly/action.yml | 11 +- .github/workflows/deploy-action.yml | 193 +++++- .github/workflows/test.yml | 7 + crates/edgezero-adapter-fastly/src/cli.rs | 630 ++++++++++++++++-- crates/edgezero-cli/src/lib.rs | 49 +- docs/specs/edgezero-deploy-adoption-guide.md | 4 + docs/specs/edgezero-deploy-github-action.md | 14 + 16 files changed, 1185 insertions(+), 116 deletions(-) create mode 100644 .github/actions/deploy-core/tests/make-fake-fastly-env.sh diff --git a/.github/actions/build-cli/scripts/build-cli.sh b/.github/actions/build-cli/scripts/build-cli.sh index 48554093..6e8f35f2 100755 --- a/.github/actions/build-cli/scripts/build-cli.sh +++ b/.github/actions/build-cli/scripts/build-cli.sh @@ -88,8 +88,13 @@ main() { local working_directory="${INPUT_WORKING_DIRECTORY:-.}" local rust_toolchain_input="${INPUT_RUST_TOOLCHAIN:-auto}" local artifact_name="${INPUT_ARTIFACT_NAME:-edgezero-cli}" - local stage_root="${EDGEZERO_CLI_STAGE_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-cli-artifact}" - local build_target_dir="${CARGO_TARGET_DIR_OVERRIDE:-${RUNNER_TEMP:-/tmp}/edgezero-cli-build}" + + # These directories are `rm -rf`d below, so they must NEVER come from the + # inherited environment — a colliding job-level variable could otherwise point + # them at the checkout. Always derive them from the action-owned temp root. + local runner_temp="${RUNNER_TEMP:-/tmp}" + local stage_root="$runner_temp/edgezero-cli-artifact" + local build_target_dir="$runner_temp/edgezero-cli-build" require_linux_x86_64 require_cmd cargo @@ -126,8 +131,7 @@ main() { cli_version=$(jq -r '.version' <<<"$package_json") # Build into an action-owned target dir so the checkout stays clean. - rm -rf "$build_target_dir" - mkdir -p "$build_target_dir" + reset_owned_dir "$build_target_dir" "$runner_temp" CARGO_TARGET_DIR="$build_target_dir" cargo +"$rust_toolchain" build \ --locked --release -p "$cli_package" --bin "$cli_bin" @@ -136,8 +140,7 @@ main() { "$bin_path" --help >/dev/null 2>&1 || fail "built CLI '$cli_bin' did not run '$cli_bin --help'" # Package the binary and self-describing metadata into a tar. - rm -rf "$stage_root" - mkdir -p "$stage_root" + reset_owned_dir "$stage_root" "$runner_temp" cp "$bin_path" "$stage_root/$cli_bin" chmod +x "$stage_root/$cli_bin" jq -n --arg bin "$cli_bin" --arg version "$cli_version" --arg package "$cli_package" \ diff --git a/.github/actions/build-cli/scripts/common.sh b/.github/actions/build-cli/scripts/common.sh index 576dd854..91ab9190 100755 --- a/.github/actions/build-cli/scripts/common.sh +++ b/.github/actions/build-cli/scripts/common.sh @@ -117,3 +117,17 @@ validate_artifact_name() { [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || fail "input 'artifact-name' may contain only letters, digits, '.', '_', '-': '$name'" } + +# Recreate an action-owned scratch directory. Refuses to remove anything not +# beneath the temp root, so a stray/inherited value can never delete the checkout. +reset_owned_dir() { + local dir="$1" temp_root="$2" + [[ -n "$dir" && -n "$temp_root" ]] || fail "internal: reset_owned_dir needs a dir and a temp root" + case "$dir" in + *..*) fail "refusing to remove '$dir': path traversal" ;; + esac + [[ "$dir" == "$temp_root"/* ]] || + fail "refusing to remove '$dir': not beneath the action-owned temp root '$temp_root'" + rm -rf "$dir" + mkdir -p "$dir" +} diff --git a/.github/actions/deploy-core/scripts/common.sh b/.github/actions/deploy-core/scripts/common.sh index 38f96051..2d3baf4e 100755 --- a/.github/actions/deploy-core/scripts/common.sh +++ b/.github/actions/deploy-core/scripts/common.sh @@ -103,3 +103,30 @@ read_tool_version() { sanitize_ref() { printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '-' } + +# The CLI binary name becomes a path component and is then chmod'd and executed, +# so it must be a bare filename — never a path, traversal, or dotfile. +validate_cli_bin() { + local name="$1" + [[ -n "$name" ]] || fail "CLI binary name must not be empty" + case "$name" in + */* | *\\* | *..*) fail "CLI binary name must not contain path separators or '..': '$name'" ;; + .*) fail "CLI binary name must not start with '.': '$name'" ;; + esac + [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || + fail "CLI binary name may contain only letters, digits, '.', '_', '-': '$name'" +} + +# Refuse an archive that could write outside the extraction directory: absolute +# paths, traversal, or symlink/hardlink members. +assert_safe_tarball() { + local tarball="$1" member + while IFS= read -r member; do + case "$member" in + /* | *..*) fail "refusing unsafe CLI archive member '$member'" ;; + esac + done < <(tar -tf "$tarball") + if tar -tvf "$tarball" | grep -qE '^[lh]'; then + fail "refusing CLI archive containing a symlink or hardlink member" + fi +} diff --git a/.github/actions/deploy-core/scripts/download-cli.sh b/.github/actions/deploy-core/scripts/download-cli.sh index fc737bcb..b144ff34 100755 --- a/.github/actions/deploy-core/scripts/download-cli.sh +++ b/.github/actions/deploy-core/scripts/download-cli.sh @@ -34,6 +34,7 @@ main() { tarball=$(find_cli_tarball "$artifact_dir") [[ -n "$tarball" ]] || fail "no CLI tar found under the downloaded artifact at '$artifact_dir'" + assert_safe_tarball "$tarball" tar -xf "$tarball" -C "$tool_root/bin" [[ -f "$tool_root/bin/cli-meta.json" ]] || fail "CLI artifact is missing cli-meta.json" @@ -41,10 +42,16 @@ main() { meta_bin=$(jq -er '."cli-bin"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-bin" cli_version=$(jq -er '."cli-version"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-version" cli_bin="${cli_bin_override:-$meta_bin}" + validate_cli_bin "$cli_bin" - [[ -f "$tool_root/bin/$cli_bin" ]] || fail "CLI binary '$cli_bin' not present in the artifact" - chmod +x "$tool_root/bin/$cli_bin" - "$tool_root/bin/$cli_bin" --help >/dev/null 2>&1 || fail "downloaded CLI '$cli_bin' did not run '--help'" + local cli_path="$tool_root/bin/$cli_bin" + [[ -f "$cli_path" && ! -L "$cli_path" ]] || fail "CLI binary '$cli_bin' is not a regular file in the artifact" + chmod +x "$cli_path" + + # Smoke-check with a scrubbed environment: no inherited provider credential + # (FASTLY_KEY, FASTLY_AUTH_TOKEN, ...) may reach the app CLI here. + env -i PATH="/usr/bin:/bin" HOME="${HOME:-/tmp}" "$cli_path" --help >/dev/null 2>&1 || + fail "downloaded CLI '$cli_bin' did not run '--help'" printf '%s\n' "$tool_root/bin" >>"${GITHUB_PATH:-/dev/null}" export PATH="$tool_root/bin:$PATH" diff --git a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh new file mode 100644 index 00000000..ef91409d --- /dev/null +++ b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs fake `fastly` and `curl` binaries on PATH for the lifecycle smoke +# test, plus a call log the assertions read back. +# +# These fakes mirror the REAL contracts the adapter depends on, so the smoke +# test regression-tests the bugs a review found: +# * `fastly compute update` must NOT receive --comment (it does not support it); +# the comment must be applied via `fastly service-version update` BEFORE +# `service-version stage`. +# * `compute update` output must be a realistic success line, because the +# version parser is now fail-closed (it refuses to guess). +# * The Fastly domain API returns a SINGULAR `staging_ip` string. +# * activate/deactivate are PUT, and staging deactivate is /deactivate/staging. +# +# Inputs (environment): GITHUB_WORKSPACE, GITHUB_PATH. + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local bin_dir="$workspace/fake-bin" + local log="$workspace/fake-calls.log" + local state="$workspace/fake-state" + + mkdir -p "$bin_dir" "$state" + : >"$log" + + cat >"$bin_dir/fastly" <<'SH' +#!/usr/bin/env bash +printf 'fastly %s\n' "$*" >>"$FAKE_CALL_LOG" +case "${1:-} ${2:-}" in + "compute build") + echo "Built package (fixture)" + ;; + "compute update") + # Realistic success line — the version parser is fail-closed and will + # refuse to stage if it cannot parse a version from this output. + echo "SUCCESS: Updated package (service dummy-service, version 42)" + ;; + "compute deploy") + echo "SUCCESS: Deployed package (service dummy-service, version 43)" + ;; + "service-version update") echo "Updated version comment" ;; + "service-version stage") echo "Staged version" ;; + *) echo "fake fastly: unhandled: $*" >&2 ;; +esac +exit 0 +SH + chmod +x "$bin_dir/fastly" + + cat >"$bin_dir/curl" <<'SH' +#!/usr/bin/env bash +# Two shapes: an API call via `--config -` (config on stdin), or a health probe. +if [[ "$*" == *"--config"* ]]; then + config=$(cat) + url=$(printf '%s\n' "$config" | sed -nE 's/^url = "(.*)"$/\1/p') + if printf '%s\n' "$config" | grep -q '^request = "PUT"$'; then + printf 'PUT %s\n' "$url" >>"$FAKE_CALL_LOG" + echo 200 + exit 0 + fi + printf 'GET %s\n' "$url" >>"$FAKE_CALL_LOG" + # Fastly returns a SINGULAR `staging_ip` string per domain object. + printf '[{"name":"staging.example.com","staging_ip":"151.101.2.10"}]\n' + exit 0 +fi +printf 'PROBE %s\n' "$*" >>"$FAKE_CALL_LOG" +if [[ -n "${FORCE_UNHEALTHY:-}" || -f "$FAKE_STATE_DIR/unhealthy" ]]; then + echo 503 +else + echo 200 +fi +exit 0 +SH + chmod +x "$bin_dir/curl" + + # Prepend the fakes so they shadow the real tools for subsequent steps. + printf '%s\n' "$bin_dir" >>"${GITHUB_PATH:?GITHUB_PATH is required}" + { + printf 'FAKE_CALL_LOG=%s\n' "$log" + printf 'FAKE_STATE_DIR=%s\n' "$state" + } >>"${GITHUB_ENV:?GITHUB_ENV is required}" + + echo "fake fastly + curl installed at $bin_dir" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index 59b6d235..56bd5c07 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -1,13 +1,17 @@ #!/usr/bin/env bash set -euo pipefail -# Creates the minimal fixture application the composite smoke test deploys: -# a standalone Cargo package (kept out of the surrounding edgezero workspace), -# a committed clean tree, and an edgezero.toml whose Fastly deploy command is -# overridden by a marker script. The script emits `version=` (so version -# threading is exercised), records what credentials it actually saw (so the -# provider-env boundary is exercised), and records its argv — all without -# contacting Fastly. +# Builds the fixture the composite smoke test deploys. +# +# This is a REAL app-owned CLI: a standalone Cargo workspace (kept out of the +# surrounding edgezero workspace) whose own crate depends on `edgezero-cli` and +# exposes deploy / healthcheck / rollback. That exercises the actual contract — +# "the application provides the CLI package" — instead of building the monorepo's +# own CLI. +# +# The Fastly deploy command is overridden by a marker script that emits +# `version=` (version threading), records the credentials it actually saw +# (provider-env boundary), and records its argv — all without contacting Fastly. # # Inputs (environment): GITHUB_WORKSPACE (required). @@ -15,29 +19,74 @@ main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" local app_dir="$workspace/fixture-app" - mkdir -p "$app_dir/src" + mkdir -p "$app_dir/crates/fixture-app-cli/src" cd "$app_dir" git init -q git config user.email test@example.com git config user.name Test + # Standalone workspace: not a member of the surrounding edgezero workspace. cat >Cargo.toml <<'TOML' +[workspace] +members = ["crates/fixture-app-cli"] +resolver = "2" +TOML + + # The app's OWN CLI crate, built on edgezero-cli (path dep into the checkout). + cat >crates/fixture-app-cli/Cargo.toml <<'TOML' [package] -name = "fixture-app" -version = "0.0.0" +name = "fixture-app-cli" +version = "0.1.0" edition = "2021" -# Standalone: keep the fixture out of the surrounding edgezero workspace. -[workspace] +[[bin]] +name = "fixture-app-cli" +path = "src/main.rs" + +[dependencies] +edgezero-cli = { path = "../../../crates/edgezero-cli", default-features = false, features = [ + "cli", + "edgezero-adapter-fastly", +] } +clap = { version = "4", features = ["derive"] } TOML - echo 'fn main() {}' >src/main.rs - cargo generate-lockfile + cat >crates/fixture-app-cli/src/main.rs <<'RS' +//! Fixture app CLI: the smoke test's stand-in for an application-owned CLI. +use clap::{Parser, Subcommand}; +use edgezero_cli::args::{DeployArgs, HealthcheckArgs, RollbackArgs}; - # Marker "deploy" script the CLI runs in place of `fastly compute deploy`. - # It records the credentials it actually saw and its argv, and emits a version - # line so `deploy-fastly` can thread `fastly-version`. +#[derive(Parser, Debug)] +#[command(name = "fixture-app-cli", version, about = "fixture app edge CLI")] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + Deploy(DeployArgs), + Healthcheck(HealthcheckArgs), + Rollback(RollbackArgs), +} + +fn main() { + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::Healthcheck(args) => edgezero_cli::run_healthcheck(&args), + Cmd::Rollback(args) => edgezero_cli::run_rollback(&args), + }; + if let Err(err) = result { + eprintln!("[fixture-app] {err}"); + std::process::exit(2); + } +} +RS + + # Marker "deploy" the CLI runs instead of `fastly compute deploy`. It records + # the credentials it saw and its argv, and emits a version line. cat >fake-deploy.sh <<'SH' #!/usr/bin/env bash { @@ -51,14 +100,21 @@ echo "version=7" SH chmod +x fake-deploy.sh - # Override the Fastly deploy command so `edgezero deploy --adapter fastly` runs - # the marker script instead of the real `fastly compute deploy` (which would - # need a built package and live credentials). cat >edgezero.toml <<'ETOML' [adapters.fastly.commands] deploy = "bash fake-deploy.sh" ETOML + # The staged-deploy path bypasses manifest commands and drives the Fastly CLI, + # so it needs a Fastly manifest to resolve its working directory. + cat >fastly.toml <<'FTOML' +manifest_version = 3 +name = "fixture-app" +language = "rust" +FTOML + + cargo generate-lockfile + git add -A git commit -q -m fixture } diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index 6a643076..bca043a8 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -112,6 +112,69 @@ test_artifact_name() { assert_fails "rejects an empty name" check_artifact_name "" } +# --------------------------------------------------------------------------- +# build-cli reset_owned_dir — never rm -rf outside the action-owned temp root +# --------------------------------------------------------------------------- +check_owned_dir() { + bash -c 'source "$1"; reset_owned_dir "$2" "$3"' _ \ + "$ACTIONS_DIR/build-cli/scripts/common.sh" "$1" "$2" +} + +test_owned_dir_confinement() { + section "build-cli owned-dir confinement" + local temp_root="$WORK_DIR/temproot" + mkdir -p "$temp_root" + assert_succeeds "recreates a dir beneath the temp root" \ + check_owned_dir "$temp_root/build" "$temp_root" + # An inherited value pointing at the checkout must be refused, not deleted. + assert_fails "refuses a dir outside the temp root (would delete the checkout)" \ + check_owned_dir "$WORK_DIR/not-temp" "$temp_root" + assert_fails "refuses a traversal path" \ + check_owned_dir "$temp_root/../escape" "$temp_root" + # Prove the refusal did not delete anything. + mkdir -p "$WORK_DIR/not-temp" + check_owned_dir "$WORK_DIR/not-temp" "$temp_root" >/dev/null 2>&1 || true + if [[ -d "$WORK_DIR/not-temp" ]]; then + pass "the refused directory still exists (nothing was removed)" + else + fail "the refused directory was deleted" + fi +} + +# --------------------------------------------------------------------------- +# download-cli — cli-bin confinement + unsafe archive rejection +# --------------------------------------------------------------------------- +check_cli_bin() { + bash -c 'source "$1"; validate_cli_bin "$2"' _ "$CORE_SCRIPTS/common.sh" "$1" +} + +check_tarball() { + bash -c 'source "$1"; assert_safe_tarball "$2"' _ "$CORE_SCRIPTS/common.sh" "$1" +} + +test_cli_bin_confinement() { + section "download-cli cli-bin + archive safety" + assert_succeeds "accepts a bare binary name" check_cli_bin "myapp-cli" + assert_fails "rejects a traversal cli-bin ('../../outside/tool')" check_cli_bin "../../outside/tool" + assert_fails "rejects a cli-bin with a separator" check_cli_bin "sub/tool" + assert_fails "rejects an empty cli-bin" check_cli_bin "" + + # A tar carrying a symlink member must be refused before extraction. + local evil="$WORK_DIR/evil" + mkdir -p "$evil/stage" + ln -sf /etc/passwd "$evil/stage/pwned" + tar -C "$evil/stage" -cf "$evil/evil.tar" pwned 2>/dev/null + assert_fails "refuses an archive containing a symlink member" check_tarball "$evil/evil.tar" + + # A well-formed archive is accepted. + local good="$WORK_DIR/good" + mkdir -p "$good/stage" + echo x >"$good/stage/myapp-cli" + printf '{}' >"$good/stage/cli-meta.json" + tar -C "$good/stage" -cf "$good/good.tar" myapp-cli cli-meta.json + assert_succeeds "accepts a well-formed archive" check_tarball "$good/good.tar" +} + # --------------------------------------------------------------------------- # run-cli.sh — provider-env credential boundary # --------------------------------------------------------------------------- @@ -256,6 +319,8 @@ test_fastly_versions() { main() { test_validate_inputs test_artifact_name + test_owned_dir_confinement + test_cli_bin_confinement test_run_cli_argv test_provider_env_boundary test_download_cli_metadata diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 945da734..7cf71375 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -79,8 +79,15 @@ runs: FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" FASTLY_API_ENDPOINT: "" FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" run: | [[ "${INPUT_FASTLY_API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } [[ -n "${INPUT_FASTLY_SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } @@ -103,6 +110,16 @@ runs: INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh - name: Resolve project @@ -120,8 +137,15 @@ runs: FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" FASTLY_API_ENDPOINT: "" FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/resolve-project.sh - name: Restore application target cache @@ -152,6 +176,16 @@ runs: VERSIONS_JSON: ${{ github.action_path }}/versions.json FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" run: $GITHUB_ACTION_PATH/scripts/install-fastly.sh - name: Build (validation) @@ -166,6 +200,16 @@ runs: DEPLOY_PROVIDER_ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh build - name: Deploy @@ -221,6 +265,16 @@ runs: SUMMARY_RESULT: ${{ steps.deploy.outcome }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/write-summary.sh - name: Cleanup @@ -231,4 +285,14 @@ runs: EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index 40eda316..e4558b5c 100644 --- a/.github/actions/healthcheck-fastly/action.yml +++ b/.github/actions/healthcheck-fastly/action.yml @@ -99,4 +99,14 @@ runs: status=$(grep -oE '^status-code=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) echo "healthy=${healthy:-false}" >> "$GITHUB_OUTPUT" echo "status-code=${status}" >> "$GITHUB_OUTPUT" - exit "$rc" + # Fail closed. A zero-exit CLI that reports healthy=false, or emits no + # verdict at all, must NOT let this action succeed — callers gate their + # rollback on this step failing. + if [[ "$rc" -ne 0 ]]; then + echo "::error::health check failed (CLI exit $rc, healthy=${healthy:-}, status=${status:-})" + exit "$rc" + fi + if [[ "$healthy" != "true" ]]; then + echo "::error::health check did not report healthy=true (got '${healthy:-}')" + exit 1 + fi diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml index 1062b1e7..1da1faa6 100644 --- a/.github/actions/rollback-fastly/action.yml +++ b/.github/actions/rollback-fastly/action.yml @@ -75,4 +75,13 @@ runs: "${args[@]}" 2>&1 | tee "$log" || rc=$? rolled=$(grep -oE '^rolled-back-to=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) echo "rolled-back-to=${rolled}" >> "$GITHUB_OUTPUT" - exit "$rc" + # Fail closed: a rollback that did not report what it activated has not + # provably rolled anything back. + if [[ "$rc" -ne 0 ]]; then + echo "::error::rollback failed (CLI exit $rc)" + exit "$rc" + fi + if [[ "$DEPLOY_TO" == "production" && -z "$rolled" ]]; then + echo "::error::production rollback reported success but did not emit rolled-back-to" + exit 1 + fi diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 733426cf..52cad1df 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -44,12 +44,17 @@ jobs: with: persist-credentials: false - - name: Install actionlint (pinned release binary) + - name: Install actionlint (pinned release binary, checksum-verified) run: | set -euo pipefail - url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - curl --fail --location --silent --show-error "$url" --output /tmp/actionlint.tar.gz - tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint + base="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl --fail --location --silent --show-error "$base/$archive" --output "/tmp/$archive" + # Verify against the checksums file published with the same release. + curl --fail --location --silent --show-error \ + "$base/actionlint_${ACTIONLINT_VERSION}_checksums.txt" --output /tmp/actionlint_checksums.txt + ( cd /tmp && grep -- " ${archive}\$" actionlint_checksums.txt | sha256sum --check --strict - ) + tar -xzf "/tmp/$archive" -C /tmp actionlint sudo install -m 0755 /tmp/actionlint /usr/local/bin/actionlint - name: Actionlint (this workflow) @@ -107,16 +112,17 @@ jobs: with: persist-credentials: false - - name: Build CLI from the monorepo package + # The fixture is a REAL app-owned CLI (its own crate depending on + # edgezero-cli) — the contract build-cli actually promises. + - name: Create fixture app (app-owned CLI) + run: .github/actions/deploy-core/tests/make-smoke-fixture.sh + + - name: Build the APP's own CLI package id: cli uses: ./.github/actions/build-cli with: - cli-package: edgezero-cli - cli-bin: edgezero - working-directory: . - - - name: Create fixture app - run: .github/actions/deploy-core/tests/make-smoke-fixture.sh + cli-package: fixture-app-cli + working-directory: fixture-app - name: Deploy fixture (production) with local action id: deploy @@ -127,8 +133,9 @@ jobs: fastly-api-token: dummy-token fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' + cache: true - - name: Assert end-to-end deploy, version threading, and credential boundary + - name: Assert production deploy, version threading, and credential boundary env: FASTLY_VERSION_OUT: ${{ steps.deploy.outputs['fastly-version'] }} run: | @@ -145,3 +152,165 @@ jobs: grep -qx 'token=dummy-token' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_API_TOKEN not delivered"; exit 1; } grep -qx 'service-id=dummy-service' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_SERVICE_ID not delivered"; exit 1; } grep -qx 'endpoint=CLEARED' fixture-app/env-seen.txt || { echo "::error::inherited FASTLY_ENDPOINT was NOT cleared before deploy"; exit 1; } + + # Exercises the FULL Fastly staging lifecycle (stage -> healthcheck -> + # rollback) against faithful fake `fastly`/`curl` binaries, asserting the exact + # verbs and paths. This is the regression test for the review findings: + # --comment must not reach `compute update`; the domain API returns a singular + # `staging_ip`; activate/deactivate are PUT; staging deactivate is + # /deactivate/staging; and an unhealthy probe must FAIL the healthcheck action. + lifecycle-smoke: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Create fixture app (app-owned CLI) + run: .github/actions/deploy-core/tests/make-smoke-fixture.sh + + - name: Build the APP's own CLI package + id: cli + uses: ./.github/actions/build-cli + with: + cli-package: fixture-app-cli + working-directory: fixture-app + + - name: Install fake fastly + curl + run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh + + - name: Download the app CLI artifact + uses: actions/download-artifact@v4 + with: + name: ${{ steps.cli.outputs.artifact-name }} + path: cli-dl + + - name: Extract the app CLI + run: | + set -euo pipefail + mkdir -p cli-bin + tar -xf cli-dl/*.tar -C cli-bin + chmod +x "cli-bin/${{ steps.cli.outputs.cli-bin }}" + + - name: Staged deploy (asserts --comment never reaches `compute update`) + env: + FASTLY_API_TOKEN: dummy-token + FASTLY_SERVICE_ID: dummy-service + run: | + set -euo pipefail + cd fixture-app + out=$("$GITHUB_WORKSPACE/cli-bin/${{ steps.cli.outputs.cli-bin }}" \ + deploy --adapter fastly --service-id dummy-service --stage -- --comment "staged smoke" 2>&1 | tee /dev/stderr) + echo "$out" | grep -qE '^version=42$' || { echo "::error::staged deploy did not emit version=42"; exit 1; } + + - name: Assert the staged Fastly call sequence + run: | + set -euo pipefail + log="$FAKE_CALL_LOG" + echo "--- recorded fastly/curl calls:"; cat "$log" + + update=$(grep -E '^fastly compute update ' "$log" | head -n 1) + [[ -n "$update" ]] || { echo "::error::no 'compute update' call"; exit 1; } + # `compute update` does NOT support --comment; it must never be forwarded. + if grep -qE '^fastly compute update .*--comment' "$log"; then + echo "::error::--comment was forwarded to 'compute update' (unsupported flag)"; exit 1 + fi + for flag in -- --autoclone --version=active --non-interactive; do + [[ "$update" == *"$flag"* ]] || { echo "::error::'compute update' missing $flag"; exit 1; } + done + + # The comment must be applied to the version, BEFORE staging it. + grep -qE '^fastly service-version update .*--comment' "$log" \ + || { echo "::error::comment was not applied via 'service-version update'"; exit 1; } + cu=$(grep -n '^fastly service-version update ' "$log" | head -n 1 | cut -d: -f1) + st=$(grep -n '^fastly service-version stage ' "$log" | head -n 1 | cut -d: -f1) + [[ -n "$st" ]] || { echo "::error::version was never staged"; exit 1; } + [[ "$cu" -lt "$st" ]] || { echo "::error::comment applied after staging (must precede it)"; exit 1; } + + - name: Health check the staged version (singular staging_ip) + uses: ./.github/actions/healthcheck-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + domain: staging.example.com + fastly-version: "42" + fastly-api-token: dummy-token + fastly-service-id: dummy-service + retry: "1" + retry-delay: "1" + + - name: Assert staging IP was resolved and probed + run: | + set -euo pipefail + log="$FAKE_CALL_LOG" + grep -qE '^GET https://api\.fastly\.com/service/dummy-service/version/42/domain\?include=staging_ips$' "$log" \ + || { echo "::error::staging-IP lookup not performed"; exit 1; } + # The probe must be rerouted to the staging IP from the singular field. + grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log" \ + || { echo "::error::probe was not rerouted to the staging IP"; exit 1; } + + - name: Health check FAILS closed when unhealthy + id: unhealthy + continue-on-error: true + uses: ./.github/actions/healthcheck-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + domain: staging.example.com + fastly-version: "42" + fastly-api-token: dummy-token + fastly-service-id: dummy-service + retry: "1" + retry-delay: "1" + env: + # Flip the fake probe to 503 for this step only. + FORCE_UNHEALTHY: "1" + + - name: Assert the unhealthy check actually failed the wrapper + env: + OUTCOME: ${{ steps.unhealthy.outcome }} + HEALTHY: ${{ steps.unhealthy.outputs.healthy }} + run: | + set -euo pipefail + # The rollback gate is "healthcheck step failed". If an unhealthy probe + # lets the action succeed, no caller would ever roll back. + [[ "$OUTCOME" == "failure" ]] \ + || { echo "::error::unhealthy probe did not fail healthcheck-fastly (outcome=$OUTCOME)"; exit 1; } + [[ "$HEALTHY" != "true" ]] \ + || { echo "::error::unhealthy probe still reported healthy=true"; exit 1; } + echo "healthcheck-fastly failed closed on an unhealthy probe" + + - name: Rollback the staged version (PUT /deactivate/staging) + uses: ./.github/actions/rollback-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + fastly-version: "42" + fastly-api-token: dummy-token + fastly-service-id: dummy-service + + - name: Rollback production (PUT /activate on v-1) + id: prod-rollback + uses: ./.github/actions/rollback-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: production + fastly-version: "42" + fastly-api-token: dummy-token + fastly-service-id: dummy-service + + - name: Assert rollback verbs, paths, and version threading + env: + ROLLED_BACK_TO: ${{ steps.prod-rollback.outputs['rolled-back-to'] }} + run: | + set -euo pipefail + log="$FAKE_CALL_LOG" + echo "--- recorded calls:"; cat "$log" + # Staging rollback: PUT .../deactivate/staging (NOT a plain /deactivate). + grep -qE '^PUT https://api\.fastly\.com/service/dummy-service/version/42/deactivate/staging$' "$log" \ + || { echo "::error::staging rollback did not PUT /deactivate/staging"; exit 1; } + # Production rollback activates the PREVIOUS version (42 -> 41), via PUT. + grep -qE '^PUT https://api\.fastly\.com/service/dummy-service/version/41/activate$' "$log" \ + || { echo "::error::production rollback did not PUT /version/41/activate"; exit 1; } + # Version threading out of the action. + [[ "$ROLLED_BACK_TO" == "41" ]] || { echo "::error::expected rolled-back-to=41, got '$ROLLED_BACK_TO'"; exit 1; } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a390532..98f1dd33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,6 +68,13 @@ jobs: - name: Run workspace tests run: cargo test --workspace --all-targets + # The adapter CLI dispatch (build/deploy/stage/healthcheck/rollback) and + # its tests live behind the `cli` feature, which the unfeatured + # `cargo test --workspace` step above does not enable — so none of those + # tests compile or run there. Exercise them explicitly. + - name: Adapter CLI dispatch tests + run: cargo test -p edgezero-adapter-fastly --all-targets --features cli + - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare spin" diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 025fb187..f3d608ef 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -130,8 +130,59 @@ const FASTLY_API_TOKEN_ENV: &str = "FASTLY_API_TOKEN"; /// `--service-id` is not passed explicitly (spec §5.4). const FASTLY_SERVICE_ID_ENV: &str = "FASTLY_SERVICE_ID"; +/// Flags `fastly compute update` accepts that take a VALUE (either +/// `--flag value` or `--flag=value`). Verified against +/// `fastly compute update --help` (Fastly CLI v15): the command's +/// `--service-id`/`-s`, `--service-name`, `--package`/`-p`, `--version`, +/// plus the global `--token`/`-t`. +const COMPUTE_UPDATE_VALUE_FLAGS: &[&str] = &[ + "--service-id", + "-s", + "--service-name", + "--package", + "-p", + "--version", + "--token", + "-t", +]; + +/// Boolean flags `fastly compute update` accepts: the command's +/// `--autoclone` plus the Fastly CLI globals. NOTE the absence of +/// `--comment` -- `compute update` does NOT support it (unlike +/// `compute deploy`), which is why an operator `--comment` is routed to +/// `service-version update` instead (see `deploy_staged`). +const COMPUTE_UPDATE_BOOL_FLAGS: &[&str] = &[ + "--autoclone", + "--accept-defaults", + "-d", + "--auto-yes", + "-y", + "--debug-mode", + "--non-interactive", + "-i", + "--quiet", + "-q", + "--verbose", + "-v", +]; + struct FastlyCliAdapter; +/// An operator passthrough arg list split for a staged deploy (see +/// `split_staged_passthrough`). +struct StagedPassthrough { + /// The `--comment` value, applied to the version separately via + /// `fastly service-version update --comment` (`compute update` has + /// no `--comment` flag). + comment: Option, + /// Args `compute update` does not support; dropped with a warning + /// rather than forwarded (forwarding them makes the CLI exit + /// non-zero and fails the whole staged deploy). + dropped: Vec, + /// Args that `fastly compute update` actually supports. + forwarded: Vec, +} + /// Outcome of scanning `fastly config-store list --json` for a /// platform store id by `name`. Distinguishes three cases the /// caller wants to act on differently: @@ -1270,6 +1321,28 @@ pub fn build(extra_args: &[String]) -> Result { Ok(dest) } +/// Whether `args` already carries the Fastly CLI's non-interactive +/// switch, in either its long (`--non-interactive`) or short (`-i`) +/// form. Used to avoid passing the flag twice when a caller already +/// supplied it via `deploy-args` passthrough. +fn has_non_interactive(args: &[String]) -> bool { + args.iter() + .any(|arg| arg == "--non-interactive" || arg == "-i") +} + +/// Build the argv for `fastly compute deploy`, appending +/// `--non-interactive` (a Fastly CLI *global* flag, supported by +/// `compute deploy`) unless the caller already passed it. Without it a +/// production deploy can block on an interactive prompt in CI. +fn build_compute_deploy_args(extra_args: &[String]) -> Vec { + let mut argv = vec!["compute".to_owned(), "deploy".to_owned()]; + argv.extend_from_slice(extra_args); + if !has_non_interactive(extra_args) { + argv.push("--non-interactive".to_owned()); + } + argv +} + /// # Errors /// Returns an error if the Fastly CLI deploy command fails. #[inline] @@ -1281,8 +1354,7 @@ pub fn deploy(extra_args: &[String]) -> Result<(), String> { .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; let status = Command::new("fastly") - .args(["compute", "deploy"]) - .args(extra_args) + .args(build_compute_deploy_args(extra_args)) .current_dir(manifest_dir) .status() .map_err(|err| format!("failed to run fastly CLI: {err}"))?; @@ -1465,6 +1537,56 @@ fn args_without_flag_value(args: &[String], flag: &str) -> Vec { out } +/// Split an arg on a leading `--flag=value`, returning `(flag, value)`. +fn split_inline_value(arg: &str) -> (&str, Option<&str>) { + match arg.split_once('=') { + Some((flag, value)) if flag.starts_with('-') => (flag, Some(value)), + Some(_) | None => (arg, None), + } +} + +/// Partition operator passthrough args for a staged deploy: forward only +/// what `fastly compute update` supports, lift `--comment` out (it is a +/// `compute deploy` / `service-version update` flag, NOT a +/// `compute update` one), and drop the rest. +/// +/// Both `--comment value` and `--comment=value` are recognised. +fn split_staged_passthrough(args: &[String]) -> StagedPassthrough { + let mut split = StagedPassthrough { + forwarded: Vec::with_capacity(args.len()), + comment: None, + dropped: Vec::new(), + }; + let mut iter = args.iter().peekable(); + while let Some(arg) = iter.next() { + let (flag, inline) = split_inline_value(arg); + if flag == "--comment" { + split.comment = match inline { + Some(value) => Some(value.to_owned()), + None => iter.next().cloned(), + }; + } else if COMPUTE_UPDATE_VALUE_FLAGS.contains(&flag) { + split.forwarded.push(arg.clone()); + if inline.is_none() { + if let Some(value) = iter.next() { + split.forwarded.push(value.clone()); + } + } + } else if COMPUTE_UPDATE_BOOL_FLAGS.contains(&flag) { + split.forwarded.push(arg.clone()); + } else { + // Unsupported by `compute update`. Consume a detached value + // too, so a stray `stage` from `--env stage` is not left + // behind as a bogus positional. + split.dropped.push(flag.to_owned()); + if inline.is_none() && iter.peek().is_some_and(|next| !next.starts_with('-')) { + iter.next(); + } + } + } + split +} + /// Resolve the target service id from `--service-id` or, failing that, /// `FASTLY_SERVICE_ID`. fn resolve_service_id(args: &[String]) -> Result { @@ -1493,22 +1615,21 @@ fn previous_version(version: u64) -> Option { (version > 1).then(|| version.saturating_sub(1)) } -/// Best-effort scan of Fastly CLI output for a trailing version number -/// (`... version 42`, `version=42`, etc.). Returns the LAST match, -/// which is the freshly-created draft in `compute update` output. -fn parse_fastly_version(text: &str) -> Option { - let lower = text.to_ascii_lowercase(); +/// Digits immediately following `marker` in `lower` (a lowercased +/// haystack), for the LAST occurrence of `marker`. The number must be +/// terminated by `terminator` — so a partial/confusable match (e.g. a +/// semver `15.2.0`) yields `None` rather than a bogus version. +fn last_version_after(lower: &str, marker: &str, terminator: char) -> Option { let mut result = None; - for (idx, _) in lower.match_indices("version") { - let after = idx.saturating_add("version".len()); + for (idx, _) in lower.match_indices(marker) { + let after = idx.saturating_add(marker.len()); let Some(rest) = lower.get(after..) else { continue; }; - let digits: String = rest - .chars() - .skip_while(|ch| !ch.is_ascii_digit()) - .take_while(char::is_ascii_digit) - .collect(); + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() || rest.chars().nth(digits.len()) != Some(terminator) { + continue; + } if let Ok(parsed) = digits.parse::() { result = Some(parsed); } @@ -1516,6 +1637,50 @@ fn parse_fastly_version(text: &str) -> Option { result } +/// Parse a Fastly service version out of Fastly CLI output, accepting +/// ONLY the shapes the CLI actually emits, in precedence order: +/// +/// 1. Our canonical `version=` contract line (spec §5.4.2). +/// 2. The CLI's success line, whose Go format string is +/// `"Updated package (service %s, version %v)"` (and +/// `"Deployed package (...)"` for `compute deploy`) — matched as +/// `, version )`. This names the version the package landed on, +/// so it wins over (3). +/// 3. The `--autoclone` notice, `"... Now operating on version %d."` — +/// the freshly-cloned draft, used when the success line is absent. +/// +/// Everything else yields `None` and the caller FAILS CLOSED. +/// +/// Deliberately strict. The previous implementation took ANY digits +/// appearing after the word "version" and let the last match win, so: +/// * `Uploaded package to service 12345, version unchanged` parsed as +/// version 12345, and +/// * the autoclone notice's *pre-clone* version +/// (`Service version 3 is not editable...`) could beat the real one, +/// since stdout and stderr are concatenated and their relative order +/// is not guaranteed. +/// +/// A misparse here stages, comments, or rolls back the WRONG service +/// version, so ambiguity must be an error, not a guess. +fn parse_fastly_version(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + parse_canonical_version_line(&lower) + .or_else(|| last_version_after(&lower, ", version ", ')')) + .or_else(|| last_version_after(&lower, "now operating on version ", '.')) +} + +/// Last standalone `version=` line (the whole trimmed line must be +/// exactly that, so a `--version=active` flag echoed in a command line +/// cannot masquerade as one). +fn parse_canonical_version_line(lower: &str) -> Option { + lower.lines().rev().find_map(|line| { + let digits = line.trim().strip_prefix("version=")?; + (!digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit())) + .then(|| digits.parse().ok()) + .flatten() + }) +} + /// Parse `fastly service-version list --json` (or the Fastly API /// `/service//version` array) for the `number` of the `active` /// version. @@ -1528,19 +1693,20 @@ fn parse_active_version(json: &str) -> Option { }) } -/// Highest `number` in a version-list JSON array — the fallback for -/// resolving the just-created draft when output parsing fails. -fn parse_latest_version(json: &str) -> Option { - let value: serde_json::Value = serde_json::from_str(json).ok()?; - value - .as_array()? - .iter() - .filter_map(|entry| entry.get("number").and_then(serde_json::Value::as_u64)) - .max() -} - -/// First staging IP found anywhere under a `staging_ips` array in the -/// Fastly API `domain?include=staging_ips` response. +/// First staging IP found in a Fastly +/// `GET /service//version//domain?include=staging_ips` response. +/// +/// The response is an ARRAY of domain objects, and the staging address +/// is a SINGULAR, nullable STRING field named `staging_ip` on each +/// domain (`staging_ips` is only the `include=` query-param value, never +/// a field name). Verified against the go-fastly `Domain` model, whose +/// field is `StagingIP` with the mapstructure tag `staging_ip`, and its +/// recorded API fixture `fixtures/domains/list_with_staging_ips.yaml`, +/// plus Fastly's "working with staging" guide. The field is absent from +/// the published Domain data model, so it is treated as optional. +/// +/// We also tolerate a plural `staging_ips` array, in case a Fastly +/// response (or a future API version) carries that shape. fn parse_staging_ip(json: &str) -> Option { let value: serde_json::Value = serde_json::from_str(json).ok()?; find_staging_ip(&value) @@ -1549,6 +1715,11 @@ fn parse_staging_ip(json: &str) -> Option { fn find_staging_ip(value: &serde_json::Value) -> Option { match value { serde_json::Value::Object(map) => { + // The documented shape: a singular `staging_ip` string. + if let Some(ip) = map.get("staging_ip").and_then(serde_json::Value::as_str) { + return Some(ip.to_owned()); + } + // Tolerated: a plural `staging_ips` array of strings. if let Some(ip) = map .get("staging_ips") .and_then(serde_json::Value::as_array) @@ -1871,13 +2042,22 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { let manifest_dir_buf = resolve_staged_manifest_dir(args)?; let manifest_dir = manifest_dir_buf.as_path(); // Strip both the explicitly-threaded `--service-id` and the - // CLI-injected `--manifest-path` so neither is forwarded to - // `fastly compute update` (which doesn't understand - // `--manifest-path`). + // CLI-injected `--manifest-path` (which `fastly compute update` + // doesn't understand), then keep only the passthrough flags + // `compute update` actually supports. `--comment` in particular is + // NOT a `compute update` flag — it is lifted out here and applied to + // the version below. let extra = args_without_flag_value( &args_without_flag_value(args, "--service-id"), "--manifest-path", ); + let passthrough = split_staged_passthrough(&extra); + if !passthrough.dropped.is_empty() { + log::warn!( + "[edgezero] ignoring deploy args not supported by `fastly compute update`: {}", + passthrough.dropped.join(" ") + ); + } // 1. Build the wasm package (no deploy / activation). run_fastly_status( @@ -1898,26 +2078,46 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { "--autoclone".to_owned(), format!("--service-id={service_id}"), "--version=active".to_owned(), - "--non-interactive".to_owned(), ]; - update.extend(extra); + update.extend(passthrough.forwarded.iter().cloned()); + if !has_non_interactive(&passthrough.forwarded) { + update.push("--non-interactive".to_owned()); + } let update_out = run_fastly_capture(&update, manifest_dir)?; - // Resolve the new draft version: parse the update output, falling - // back to the highest version reported by the Fastly API. - let version = if let Some(version) = parse_fastly_version(&update_out) { - version - } else { - let token = require_token()?; - let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; - parse_latest_version(&json).ok_or_else(|| { - format!( - "could not determine the staged version from `fastly compute update` output or the Fastly API; raw output:\n{update_out}" - ) - })? - }; + // Resolve the new draft version from the update output. FAIL CLOSED: + // if the version cannot be parsed with confidence we return an error + // rather than guessing. The old fallback picked the service's + // HIGHEST version, which under concurrent deploys could silently + // stage/roll back a version created by someone else's run. + let version = parse_fastly_version(&update_out).ok_or_else(|| { + format!( + "could not determine the staged version from `fastly compute update` output; \ + refusing to guess (a wrong version would stage another deploy's changes). \ + Raw output:\n{update_out}" + ) + })?; - // 3. Mark the draft version staged (no activation). + // 3. Apply the operator's `--comment` to the freshly-created draft. + // `compute update` has no `--comment`; the version comment is set + // with `service-version update`. Done BEFORE staging, while the + // version is still an editable draft (and without `--autoclone`, + // so it can never clone into yet another version). + if let Some(comment) = passthrough.comment.as_deref() { + run_fastly_status( + &[ + "service-version".to_owned(), + "update".to_owned(), + format!("--service-id={service_id}"), + format!("--version={version}"), + "--comment".to_owned(), + comment.to_owned(), + ], + manifest_dir, + )?; + } + + // 4. Mark the draft version staged (no activation). run_fastly_status( &[ "service-version".to_owned(), @@ -1928,7 +2128,7 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { manifest_dir, )?; - // 4. Emit the staged version (spec §5.4.2 parseable contract). + // 5. Emit the staged version (spec §5.4.2 parseable contract). log::info!("version={version}"); Ok(()) } @@ -2188,6 +2388,103 @@ mod tests { assert_eq!(resolve_service_id(&args).unwrap(), "SVC_FROM_ARG"); } + // ── `compute update` passthrough filtering (`--comment`) ───────── + + fn owned(args: &[&str]) -> Vec { + args.iter().map(|arg| (*arg).to_owned()).collect() + } + + #[test] + fn split_staged_passthrough_lifts_comment_out_of_compute_update() { + // `fastly compute update` has NO `--comment` flag (verified against + // `fastly compute update --help`, CLI v15) — forwarding it makes the + // command exit non-zero and fails the whole staged deploy. It must be + // lifted out and applied via `service-version update` instead. + for args in [owned(&["--comment", "ci run 12"]), owned(&["--comment=x"])] { + let split = split_staged_passthrough(&args); + assert!( + !split + .forwarded + .iter() + .any(|arg| arg.starts_with("--comment")), + "--comment must never reach `compute update`: {:?}", + split.forwarded + ); + assert!( + split.comment.is_some(), + "comment must be captured: {args:?}" + ); + } + assert_eq!( + split_staged_passthrough(&owned(&["--comment", "ci run 12"])).comment, + Some("ci run 12".to_owned()) + ); + assert_eq!( + split_staged_passthrough(&owned(&["--comment=x"])).comment, + Some("x".to_owned()) + ); + } + + #[test] + fn split_staged_passthrough_forwards_supported_flags_only() { + let args = owned(&[ + "--package", + "pkg.tar.gz", + "--autoclone", + "--verbose", + "--comment", + "note", + "--env", + "stage", + "--status-check-off", + ]); + let split = split_staged_passthrough(&args); + // Supported by `compute update`: kept (value flags keep their value). + assert_eq!( + split.forwarded, + owned(&["--package", "pkg.tar.gz", "--autoclone", "--verbose"]) + ); + // `--env`/`--status-check-off` are `compute deploy` flags, not + // `compute update` ones: dropped, and `--env`'s detached value + // `stage` is dropped with it (never left as a bogus positional). + assert_eq!(split.dropped, owned(&["--env", "--status-check-off"])); + assert!(!split.forwarded.iter().any(|arg| arg == "stage")); + assert_eq!(split.comment, Some("note".to_owned())); + } + + // ── non-interactive CI safety (`--non-interactive`) ─────────────── + + #[test] + fn build_compute_deploy_args_is_non_interactive() { + // Without this a production deploy can block on an interactive + // prompt in CI. + let argv = build_compute_deploy_args(&owned(&["--service-id", "SVC1"])); + assert_eq!( + argv, + owned(&[ + "compute", + "deploy", + "--service-id", + "SVC1", + "--non-interactive" + ]) + ); + } + + #[test] + fn build_compute_deploy_args_does_not_duplicate_caller_flag() { + for flag in ["--non-interactive", "-i"] { + let argv = build_compute_deploy_args(&owned(&[flag])); + assert_eq!( + argv.iter() + .filter(|arg| *arg == "--non-interactive" || *arg == "-i") + .count(), + 1, + "must not pass the non-interactive switch twice ({flag})" + ); + } + } + // ── curl-config escaping + input validation (injection defence) ─── #[test] @@ -2276,20 +2573,63 @@ mod tests { } #[test] - fn parse_fastly_version_handles_common_shapes() { + fn parse_fastly_version_handles_the_shapes_fastly_emits() { + // The Fastly CLI's own success lines. Go format strings: + // "Updated package (service %s, version %v)" (compute update) + // "Deployed package (service %s, version %v)" (compute deploy) assert_eq!( parse_fastly_version("SUCCESS: Deployed package (service abc, version 7)"), Some(7) ); - assert_eq!(parse_fastly_version("Cloned to version=12"), Some(12)); - // The LAST version mention wins (the freshly-created draft). assert_eq!( - parse_fastly_version("Cloning version 3... created version 4"), + parse_fastly_version("\nSUCCESS: Updated package (service SU1Z0, version 42)\n"), + Some(42) + ); + // Our canonical contract line (spec §5.4.2). + assert_eq!(parse_fastly_version("version=12"), Some(12)); + // The --autoclone notice, when no success line is present. + assert_eq!( + parse_fastly_version( + "Service version 3 is not editable, so it was automatically cloned because \ + --autoclone is enabled. Now operating on version 4." + ), Some(4) ); + // Full autoclone + success output: the SUCCESS line wins, and the + // PRE-clone version (3) never does — even though stdout/stderr are + // concatenated and their relative order is not guaranteed. + let combined = "SUCCESS: \nUpdated package (service abc, version 4)\n\ + Service version 3 is not editable, so it was automatically cloned. \ + Now operating on version 4."; + assert_eq!(parse_fastly_version(combined), Some(4)); assert_eq!(parse_fastly_version("no numbers here"), None); } + #[test] + fn parse_fastly_version_rejects_confusable_lines() { + // The old parser took ANY digits after the word "version", so each + // of these silently produced a WRONG service version. They must now + // all be `None`, which makes `deploy_staged` fail closed. + assert_eq!( + parse_fastly_version("Uploaded package to service 12345, version unchanged"), + None + ); + // The CLI's own semver must not be mistaken for a service version. + assert_eq!(parse_fastly_version("Fastly CLI version 15.2.0"), None); + assert_eq!( + parse_fastly_version("Checking version compatibility for service 99"), + None + ); + // A bare `version ` mention with no success-line context is not + // trusted either. + assert_eq!(parse_fastly_version("cloning version 3"), None); + // `--version=active` echoed in a command line is not a contract line. + assert_eq!( + parse_fastly_version("running: fastly compute update --version=active"), + None + ); + } + #[test] fn parse_active_version_finds_active_entry() { let json = r#"[ @@ -2307,24 +2647,44 @@ mod tests { } #[test] - fn parse_latest_version_returns_max_number() { - let json = r#"[{"number": 1},{"number": 5},{"number": 3}]"#; - assert_eq!(parse_latest_version(json), Some(5)); + fn parse_staging_ip_reads_the_singular_staging_ip_field() { + // The REAL Fastly response shape for + // `GET /service//version//domain?include=staging_ips`: + // an array of domain objects, each with a SINGULAR `staging_ip` + // STRING. Body copied from go-fastly's recorded API fixture + // `fastly/fixtures/domains/list_with_staging_ips.yaml`, matching + // its `StagingIP *string `mapstructure:"staging_ip"`` field. + // (`staging_ips` is only the `include=` query value, never a + // field name — the previous parser looked for it as an array and + // therefore NEVER found a staging IP.) + let json = r#"[ + { + "created_at": "2022-11-04T17:36:56Z", + "service_id": "kKJb5bOFI47uHeBVluGfX1", + "name": "integ-test-20221104.go-fastly-1.com", + "version": 73, + "comment": "comment", + "deleted_at": null, + "staging_ip": "167.82.81.194" + } + ]"#; + assert_eq!(parse_staging_ip(json).as_deref(), Some("167.82.81.194")); } #[test] - fn parse_staging_ip_finds_nested_ip() { - // Array of domain objects, each carrying a staging_ips array. - let json = r#"[ - {"name": "example.com", "staging_ips": ["151.101.2.10", "151.101.66.10"]} - ]"#; + fn parse_staging_ip_tolerates_a_plural_array_shape() { + let json = r#"[{"name": "example.com", "staging_ips": ["151.101.2.10"]}]"#; assert_eq!(parse_staging_ip(json).as_deref(), Some("151.101.2.10")); } #[test] - fn parse_staging_ip_none_when_absent() { - let json = r#"[{"name": "example.com"}]"#; - assert_eq!(parse_staging_ip(json), None); + fn parse_staging_ip_none_when_absent_or_null() { + assert_eq!(parse_staging_ip(r#"[{"name": "example.com"}]"#), None); + // `staging_ip` is nullable for services without staging enabled. + assert_eq!( + parse_staging_ip(r#"[{"name": "example.com", "staging_ip": null}]"#), + None + ); } #[test] @@ -4430,4 +4790,150 @@ build = \"cargo build --release\" "old envelope A's chunks must be inert -- read must NOT return A" ); } + + // ── staged deploy: end-to-end argv contract (fake `fastly`) ─────── + + /// Fake `fastly` on `$PATH` that appends every invocation's argv (one + /// space-joined line per call) to a record file, and echoes + /// `update_stdout` for `fastly compute update`. Returns the temp dir + /// (which must outlive the test) and the record path. + #[cfg(unix)] + fn fake_fastly_recorder(update_stdout: &str) -> (tempfile::TempDir, PathBuf) { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempdir().expect("tempdir"); + let record = dir.path().join("argv.log"); + let script_path = dir.path().join("fastly"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nif [ \"$1\" = \"compute\" ] && [ \"$2\" = \"update\" ]; then\n printf '%s\\n' '{update_stdout}'\nfi\nexit 0\n", + record.display(), + ); + fs::write(&script_path, script).expect("write fake fastly"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + (dir, record) + } + + /// Run `deploy_staged` against a fake `fastly`, returning the result + /// and the recorded argv lines. + #[cfg(unix)] + fn run_deploy_staged_with_fake( + update_stdout: &str, + extra: &[&str], + ) -> (Result<(), String>, Vec) { + let _lock = path_mutation_guard().lock().expect("guard"); + let (fake, record) = fake_fastly_recorder(update_stdout); + let _path = PathPrepend::new(fake.path()); + let app = tempdir().expect("app dir"); + let manifest = app.path().join("fastly.toml"); + fs::write(&manifest, "name = \"app\"\n").expect("write fastly.toml"); + + let previous_token = env::var_os(FASTLY_API_TOKEN_ENV); + env::set_var(FASTLY_API_TOKEN_ENV, "test-token"); + let mut args = vec![ + "--service-id".to_owned(), + "SVC1".to_owned(), + "--manifest-path".to_owned(), + manifest.display().to_string(), + ]; + args.extend(extra.iter().map(|arg| (*arg).to_owned())); + let result = deploy_staged(&args); + match previous_token { + Some(prev) => env::set_var(FASTLY_API_TOKEN_ENV, prev), + None => env::remove_var(FASTLY_API_TOKEN_ENV), + } + + let recorded = fs::read_to_string(&record).unwrap_or_default(); + let lines = recorded.lines().map(str::to_owned).collect(); + (result, lines) + } + + #[cfg(unix)] + #[test] + fn deploy_staged_routes_comment_to_service_version_update() { + // `--comment` is allowlisted for `deploy-args` and recommended by the + // adoption guide, but `fastly compute update` has no such flag. It + // must NOT be forwarded there (that would fail the deploy) and must + // instead land on the version via `service-version update`. + for comment_args in [vec!["--comment", "ci run 12"], vec!["--comment=ci run 12"]] { + let (result, argv) = run_deploy_staged_with_fake( + "SUCCESS: Updated package (service SVC1, version 7)", + &comment_args, + ); + result.expect("staged deploy with --comment must succeed"); + + let update = argv + .iter() + .find(|line| line.starts_with("compute update")) + .expect("compute update was invoked"); + assert!( + !update.contains("--comment"), + "--comment must not be forwarded to `compute update`: {update}" + ); + assert!( + update.contains("--non-interactive"), + "compute update must be non-interactive: {update}" + ); + + let comment_call = argv + .iter() + .find(|line| line.starts_with("service-version update")) + .expect("`service-version update` must apply the version comment"); + assert_eq!( + comment_call, + "service-version update --service-id=SVC1 --version=7 --comment ci run 12" + ); + + // The comment lands on the version BEFORE it is staged (while it + // is still an editable draft). + let comment_idx = argv + .iter() + .position(|line| line.starts_with("service-version update")) + .expect("comment call"); + let stage_idx = argv + .iter() + .position(|line| line.starts_with("service-version stage")) + .expect("stage call"); + assert!(comment_idx < stage_idx, "comment must precede staging"); + assert_eq!( + argv[stage_idx], + "service-version stage --service-id=SVC1 --version=7" + ); + } + } + + #[cfg(unix)] + #[test] + fn deploy_staged_without_comment_makes_no_version_comment_call() { + let (result, argv) = + run_deploy_staged_with_fake("SUCCESS: Updated package (service SVC1, version 7)", &[]); + result.expect("staged deploy must succeed"); + assert!( + !argv + .iter() + .any(|line| line.starts_with("service-version update")), + "no comment => no `service-version update` call: {argv:?}" + ); + } + + #[cfg(unix)] + #[test] + fn deploy_staged_fails_closed_when_version_is_unparseable() { + // The old code fell back to the service's HIGHEST version here, which + // could silently adopt a version created by a CONCURRENT deploy. We + // must error out instead of guessing. + let (result, argv) = run_deploy_staged_with_fake("uploaded, but nothing parseable", &[]); + let err = result.expect_err("unparseable version must fail closed"); + assert!( + err.contains("could not determine the staged version"), + "unexpected error: {err}" + ); + assert!( + !argv + .iter() + .any(|line| line.starts_with("service-version stage")), + "must not stage a guessed version: {argv:?}" + ); + } } diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index a10005d8..e22a3f2d 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -281,22 +281,31 @@ fn parse_canonical_version_line(output: &str) -> Option { }) } -/// Last `version ` mention anywhere in `output` (case-insensitive), -/// covering Fastly's `Deployed package (service abc, version 12)`. +/// Last `, version )` mention in `output` (case-insensitive) — the +/// Fastly CLI's own success line, whose Go format string is +/// `"Deployed package (service %s, version %v)"`. +/// +/// Deliberately narrow: it previously accepted ANY digits appearing +/// after the word "version", so `Fastly CLI version 15.2.0` or +/// `... service 12345, version unchanged` parsed as a service version. +/// A misparse here emits a WRONG `version=` line, which the deploy → +/// healthcheck → rollback chain would then act on. When this returns +/// `None`, `run_deploy` falls back to the Fastly API's *active* version +/// (the version the deploy actually activated) rather than guessing. #[cfg(feature = "cli")] fn parse_native_version_mention(output: &str) -> Option { let lower = output.to_ascii_lowercase(); let mut result = None; - for (idx, _) in lower.match_indices("version") { - let after = idx.saturating_add("version".len()); + for (idx, _) in lower.match_indices(", version ") { + let after = idx.saturating_add(", version ".len()); let Some(rest) = lower.get(after..) else { continue; }; - let digits: String = rest - .chars() - .skip_while(|ch| !ch.is_ascii_digit()) - .take_while(char::is_ascii_digit) - .collect(); + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + // The number must be closed by the success line's `)`. + if digits.is_empty() || rest.chars().nth(digits.len()) != Some(')') { + continue; + } if let Ok(parsed) = digits.parse::() { result = Some(parsed); } @@ -602,11 +611,29 @@ mod tests { } #[test] - fn parse_deploy_version_native_takes_last_mention() { - let output = "Cloning version 3... created version 4\n"; + fn parse_deploy_version_native_takes_last_success_line() { + let output = "SUCCESS: Deployed package (service abc, version 3)\n\ + SUCCESS: Deployed package (service abc, version 4)\n"; assert_eq!(parse_deploy_version(output), Some(4)); } + #[test] + fn parse_deploy_version_rejects_confusable_mentions() { + // Loose `version ` narration is NOT a service version. Each of + // these used to parse (and would have emitted a wrong `version=` + // for healthcheck/rollback to act on). `None` routes run_deploy to + // the Fastly API's *active* version instead — the safe answer. + assert_eq!(parse_deploy_version("Fastly CLI version 15.2.0\n"), None); + assert_eq!( + parse_deploy_version("Uploaded to service 12345, version unchanged\n"), + None + ); + assert_eq!( + parse_deploy_version("Cloning version 3... created version 4\n"), + None + ); + } + #[test] fn ensure_adapter_defined_accepts_known_adapter() { let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); diff --git a/docs/specs/edgezero-deploy-adoption-guide.md b/docs/specs/edgezero-deploy-adoption-guide.md index 88f9ce22..33c4e5db 100644 --- a/docs/specs/edgezero-deploy-adoption-guide.md +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -79,6 +79,10 @@ steps: uses: actions/checkout@v4 with: repository: stackpop/my-edgezero-app + # MUST be a trusted, immutable ref (a full commit SHA, or a protected tag) + # — never an arbitrary branch. Fastly's default `build-mode: never` means + # `fastly compute deploy` COMPILES the application while the API token is + # in scope, so untrusted code would run with your credentials (spec §10.1). ref: ${{ inputs.ref }} path: app persist-credentials: false diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md index 4c92a5bb..5f253247 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -519,6 +519,20 @@ deploy args are rejected so caller input cannot override typed service selection authentication, non-interactive mode, endpoint, profile, or debug behavior. The allowlist ships with accept/reject tests. +### 9.1 `deploy-args` under `--stage` + +A staged deploy does not run `fastly compute deploy`; it runs +`fastly compute update` against a cloned draft version (§5.4). Flags that only +exist on `compute deploy` — `--env`, `--domain`, `--status-check-*`, `--dir` / +`-C`, `--no-default-domain` — are therefore **no-ops under `--stage`**: the +adapter drops them with a warning rather than passing an unsupported flag to +`compute update`. + +`--comment` is a special case. `compute update` has no `--comment`, so the +adapter applies the comment out-of-band via `fastly service-version update +--comment …` **before** staging the version, preserving the caller's intent +without breaking the upload. + ## 10. Provider credential contract Credentials flow through the `provider-env` JSON object, which `deploy-core` From 730b27c80a326f40f0d54d1dc4889295613cc8ad Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:44:47 -0700 Subject: [PATCH 19/26] lifecycle-smoke: exec bit on fake-env script; bind cli-bin via env (zizmor) --- .github/actions/deploy-core/tests/make-fake-fastly-env.sh | 0 .github/workflows/deploy-action.yml | 7 +++++-- 2 files changed, 5 insertions(+), 2 deletions(-) mode change 100644 => 100755 .github/actions/deploy-core/tests/make-fake-fastly-env.sh diff --git a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh old mode 100644 new mode 100755 diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 52cad1df..f2729114 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -186,20 +186,23 @@ jobs: path: cli-dl - name: Extract the app CLI + env: + CLI_BIN: ${{ steps.cli.outputs.cli-bin }} run: | set -euo pipefail mkdir -p cli-bin tar -xf cli-dl/*.tar -C cli-bin - chmod +x "cli-bin/${{ steps.cli.outputs.cli-bin }}" + chmod +x "cli-bin/$CLI_BIN" - name: Staged deploy (asserts --comment never reaches `compute update`) env: + CLI_BIN: ${{ steps.cli.outputs.cli-bin }} FASTLY_API_TOKEN: dummy-token FASTLY_SERVICE_ID: dummy-service run: | set -euo pipefail cd fixture-app - out=$("$GITHUB_WORKSPACE/cli-bin/${{ steps.cli.outputs.cli-bin }}" \ + out=$("$GITHUB_WORKSPACE/cli-bin/$CLI_BIN" \ deploy --adapter fastly --service-id dummy-service --stage -- --comment "staged smoke" 2>&1 | tee /dev/stderr) echo "$out" | grep -qE '^version=42$' || { echo "::error::staged deploy did not emit version=42"; exit 1; } From 348e6801d3ac60f87fc95af789c34b8fd828d415 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:09:55 -0700 Subject: [PATCH 20/26] deploy-action: move every smoke assertion into a shellcheck'd script 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. --- .../tests/assert-production-deploy.sh | 42 ++++++ .../tests/assert-rollback-calls.sh | 40 ++++++ .../deploy-core/tests/assert-staged-calls.sh | 79 ++++++++++++ .../deploy-core/tests/assert-staging-probe.sh | 29 +++++ .../tests/assert-unhealthy-failed.sh | 28 ++++ .../actions/deploy-core/tests/extract-cli.sh | 27 ++++ .../deploy-core/tests/staged-deploy.sh | 33 +++++ .github/workflows/deploy-action.yml | 121 ++++-------------- 8 files changed, 305 insertions(+), 94 deletions(-) create mode 100755 .github/actions/deploy-core/tests/assert-production-deploy.sh create mode 100755 .github/actions/deploy-core/tests/assert-rollback-calls.sh create mode 100755 .github/actions/deploy-core/tests/assert-staged-calls.sh create mode 100755 .github/actions/deploy-core/tests/assert-staging-probe.sh create mode 100755 .github/actions/deploy-core/tests/assert-unhealthy-failed.sh create mode 100755 .github/actions/deploy-core/tests/extract-cli.sh create mode 100755 .github/actions/deploy-core/tests/staged-deploy.sh diff --git a/.github/actions/deploy-core/tests/assert-production-deploy.sh b/.github/actions/deploy-core/tests/assert-production-deploy.sh new file mode 100755 index 00000000..cdf37947 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-production-deploy.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the production deploy path end to end: build-cli -> deploy-fastly -> +# the app-owned CLI -> the manifest's overridden Fastly deploy command. +# +# Also asserts the provider-env credential boundary: the typed inputs reach the +# deploy, and an inherited alias (FASTLY_ENDPOINT, set at job level) is CLEARED. +# +# Inputs (environment): GITHUB_WORKSPACE, FASTLY_VERSION_OUT. + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local version_out="${FASTLY_VERSION_OUT:-}" + local env_seen="$workspace/fixture-app/env-seen.txt" + local argv="$workspace/fixture-app/deploy-argv.txt" + + if [[ ! -f "$argv" || ! -f "$env_seen" ]]; then + echo "::error::the deploy never reached the app CLI's Fastly deploy command" >&2 + return 1 + fi + echo "recorded argv:"; cat "$argv" + echo "credentials the deploy saw:"; cat "$env_seen" + + if [[ "$version_out" != "7" ]]; then + echo "::error::expected fastly-version=7 out of the action, got '${version_out:-}'" >&2 + return 1 + fi + + # The provider-env boundary: typed values in, inherited aliases out. + local expected + for expected in 'token=dummy-token' 'service-id=dummy-service' 'endpoint=CLEARED'; do + if ! grep -qx -- "$expected" "$env_seen"; then + echo "::error::provider-env boundary violated: expected '$expected' in env-seen.txt" >&2 + return 1 + fi + done + + echo "production deploy, version threading, and the credential boundary all hold" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-rollback-calls.sh b/.github/actions/deploy-core/tests/assert-rollback-calls.sh new file mode 100755 index 00000000..7f050c12 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-rollback-calls.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the rollback verbs, paths, and version threading. +# +# Regression test for two real defects a review found: +# * Rollback used POST; the Fastly API requires PUT. +# * Staging rollback hit `/deactivate`, which deactivates the LIVE version. +# Undoing a stage is `/deactivate/staging`. +# +# Inputs (environment): FAKE_CALL_LOG, ROLLED_BACK_TO. + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local rolled_back_to="${ROLLED_BACK_TO:-}" + local api="https://api\.fastly\.com/service/dummy-service" + + echo "--- recorded fastly/curl calls:" + cat "$log" + + if ! grep -qE "^PUT $api/version/42/deactivate/staging\$" "$log"; then + echo "::error::staging rollback did not PUT /version/42/deactivate/staging" >&2 + return 1 + fi + + # Production rollback activates the PREVIOUS version (42 -> 41). + if ! grep -qE "^PUT $api/version/41/activate\$" "$log"; then + echo "::error::production rollback did not PUT /version/41/activate" >&2 + return 1 + fi + + if [[ "$rolled_back_to" != "41" ]]; then + echo "::error::expected rolled-back-to=41, got '${rolled_back_to:-}'" >&2 + return 1 + fi + + echo "rollback used PUT with the correct paths, and rolled-back-to=41 threaded out" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-staged-calls.sh b/.github/actions/deploy-core/tests/assert-staged-calls.sh new file mode 100755 index 00000000..3e9d24e6 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-staged-calls.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the exact Fastly call sequence a staged deploy must produce. +# +# Regression test for two real defects a review found: +# * `--comment` was forwarded to `fastly compute update`, which has no such +# flag — the upload failed. It must instead be applied via +# `fastly service-version update --comment`, BEFORE the version is staged. +# * The staged upload must clone the active version and stay non-interactive. +# +# Inputs (environment): FAKE_CALL_LOG. + +require_call() { + local pattern="$1" what="$2" log="$3" + if ! grep -qE -- "$pattern" "$log"; then + echo "::error::$what" >&2 + return 1 + fi +} + +assert_update_flags() { + local update="$1" flag + for flag in --autoclone --version=active --non-interactive --service-id; do + if [[ "$update" != *"$flag"* ]]; then + echo "::error::'compute update' is missing $flag (got: $update)" >&2 + return 1 + fi + done +} + +assert_no_comment_on_update() { + local log="$1" + if grep -qE '^fastly compute update .*--comment' "$log"; then + echo "::error::--comment was forwarded to 'compute update', which does not support it" >&2 + return 1 + fi +} + +assert_comment_precedes_stage() { + local log="$1" comment_line stage_line + comment_line=$(grep -nE '^fastly service-version update .*--comment' "$log" | head -n 1 | cut -d: -f1) + stage_line=$(grep -nE '^fastly service-version stage ' "$log" | head -n 1 | cut -d: -f1) + + if [[ -z "$comment_line" ]]; then + echo "::error::the comment was never applied via 'service-version update'" >&2 + return 1 + fi + if [[ -z "$stage_line" ]]; then + echo "::error::the version was never staged" >&2 + return 1 + fi + if [[ "$comment_line" -ge "$stage_line" ]]; then + echo "::error::the comment was applied after staging; it must precede it" >&2 + return 1 + fi +} + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local update + + echo "--- recorded fastly/curl calls:" + cat "$log" + + update=$(grep -E '^fastly compute update ' "$log" | head -n 1 || true) + if [[ -z "$update" ]]; then + echo "::error::the staged deploy never ran 'fastly compute update'" >&2 + return 1 + fi + + assert_update_flags "$update" + assert_no_comment_on_update "$log" + assert_comment_precedes_stage "$log" + + echo "staged call sequence is correct" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-staging-probe.sh b/.github/actions/deploy-core/tests/assert-staging-probe.sh new file mode 100755 index 00000000..a8b606a7 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-staging-probe.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the staging health check resolved the staged version's IP and probed +# through it. +# +# Regression test: the Fastly domain API returns a SINGULAR `staging_ip` string +# per domain object (`staging_ips` is only the `include=` query param). Reading +# it as an array silently found no IP and probed production instead. +# +# Inputs (environment): FAKE_CALL_LOG. + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + + if ! grep -qE '^GET https://api\.fastly\.com/service/dummy-service/version/42/domain\?include=staging_ips$' "$log"; then + echo "::error::the staging-IP lookup was never performed" >&2 + return 1 + fi + + if ! grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log"; then + echo "::error::the probe was not rerouted to the staging IP (singular staging_ip not read?)" >&2 + return 1 + fi + + echo "staging probe was rerouted through the resolved staging IP" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh b/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh new file mode 100755 index 00000000..960feccb --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts healthcheck-fastly FAILS CLOSED on an unhealthy probe. +# +# Callers gate their rollback on "the healthcheck step failed". If an unhealthy +# probe lets the action succeed, no caller would ever roll back — so this is the +# single most important contract in the lifecycle. +# +# Inputs (environment): OUTCOME (the step's outcome), HEALTHY (its output). + +main() { + local outcome="${OUTCOME:?OUTCOME is required}" + local healthy="${HEALTHY:-}" + + if [[ "$outcome" != "failure" ]]; then + echo "::error::an unhealthy probe did not fail healthcheck-fastly (outcome=$outcome)" >&2 + return 1 + fi + if [[ "$healthy" == "true" ]]; then + echo "::error::an unhealthy probe still reported healthy=true" >&2 + return 1 + fi + + echo "healthcheck-fastly failed closed on an unhealthy probe" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/extract-cli.sh b/.github/actions/deploy-core/tests/extract-cli.sh new file mode 100755 index 00000000..0c5541f2 --- /dev/null +++ b/.github/actions/deploy-core/tests/extract-cli.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Extracts the build-cli artifact tarball so the lifecycle test can drive the +# app CLI directly (the wrappers do this themselves via download-cli.sh). +# +# Inputs (environment): GITHUB_WORKSPACE, CLI_BIN. + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local cli_bin="${CLI_BIN:?CLI_BIN is required}" + local dest="$workspace/cli-bin" + local tarball + + tarball=$(find "$workspace/cli-dl" -maxdepth 1 -name '*.tar' -print -quit) + if [[ -z "$tarball" ]]; then + echo "::error::no CLI tarball found under $workspace/cli-dl" >&2 + return 1 + fi + + mkdir -p "$dest" + tar -xf "$tarball" -C "$dest" + chmod +x "$dest/$cli_bin" + echo "extracted $cli_bin from $tarball" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/staged-deploy.sh b/.github/actions/deploy-core/tests/staged-deploy.sh new file mode 100755 index 00000000..51889399 --- /dev/null +++ b/.github/actions/deploy-core/tests/staged-deploy.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs a STAGED deploy through the app-owned CLI against the fake `fastly` +# (see make-fake-fastly-env.sh) and asserts the staged version is emitted. +# +# The version parser is fail-closed, so an empty `version=` here means the +# adapter failed to read the version back out of `fastly compute update`. +# +# Inputs (environment): GITHUB_WORKSPACE, CLI_BIN, FASTLY_API_TOKEN, +# FASTLY_SERVICE_ID. + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local cli_bin="${CLI_BIN:?CLI_BIN is required}" + local out + + cd "$workspace/fixture-app" + + out=$("$workspace/cli-bin/$cli_bin" deploy \ + --adapter fastly \ + --service-id dummy-service \ + --stage \ + -- --comment "staged smoke" 2>&1 | tee /dev/stderr) + + if ! printf '%s\n' "$out" | grep -qE '^version=42$'; then + echo "::error::staged deploy did not emit version=42" >&2 + return 1 + fi + echo "staged deploy emitted version=42" +} + +main "$@" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index f2729114..7ab91906 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -102,6 +102,10 @@ jobs: npm run lint npm run build + # Production path: build the app's OWN CLI, deploy through the wrapper, and + # prove the whole chain ran with the credential boundary intact. Every + # assertion lives in a script under deploy-core/tests/ so it is shellcheck'd + # and readable outside the YAML. composite-smoke: runs-on: ubuntu-24.04 # An inherited provider alias the deploy MUST clear (provider-env boundary). @@ -138,27 +142,12 @@ jobs: - name: Assert production deploy, version threading, and credential boundary env: FASTLY_VERSION_OUT: ${{ steps.deploy.outputs['fastly-version'] }} - run: | - set -euo pipefail - # 1) The whole chain ran: build-cli -> deploy-fastly -> app CLI -> the - # overridden Fastly deploy command. - test -f fixture-app/deploy-argv.txt - test -f fixture-app/env-seen.txt - echo "recorded argv:"; cat fixture-app/deploy-argv.txt - echo "credentials the deploy saw:"; cat fixture-app/env-seen.txt - # 2) fastly-version threaded out of the action. - [[ "$FASTLY_VERSION_OUT" == "7" ]] || { echo "::error::expected fastly-version=7, got '$FASTLY_VERSION_OUT'"; exit 1; } - # 3) provider-env boundary: typed creds present, inherited alias cleared. - grep -qx 'token=dummy-token' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_API_TOKEN not delivered"; exit 1; } - grep -qx 'service-id=dummy-service' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_SERVICE_ID not delivered"; exit 1; } - grep -qx 'endpoint=CLEARED' fixture-app/env-seen.txt || { echo "::error::inherited FASTLY_ENDPOINT was NOT cleared before deploy"; exit 1; } - - # Exercises the FULL Fastly staging lifecycle (stage -> healthcheck -> - # rollback) against faithful fake `fastly`/`curl` binaries, asserting the exact - # verbs and paths. This is the regression test for the review findings: - # --comment must not reach `compute update`; the domain API returns a singular - # `staging_ip`; activate/deactivate are PUT; staging deactivate is - # /deactivate/staging; and an unhealthy probe must FAIL the healthcheck action. + run: .github/actions/deploy-core/tests/assert-production-deploy.sh + + # Staging path: the full lifecycle (stage -> healthcheck -> rollback) against + # fake `fastly`/`curl` binaries that mirror the real contracts. Because the + # bugs a review found were argv/verb bugs, the assertions check argv and verbs + # — see the assert-*.sh scripts for what each one regression-tests. lifecycle-smoke: runs-on: ubuntu-24.04 steps: @@ -188,49 +177,19 @@ jobs: - name: Extract the app CLI env: CLI_BIN: ${{ steps.cli.outputs.cli-bin }} - run: | - set -euo pipefail - mkdir -p cli-bin - tar -xf cli-dl/*.tar -C cli-bin - chmod +x "cli-bin/$CLI_BIN" + run: .github/actions/deploy-core/tests/extract-cli.sh - - name: Staged deploy (asserts --comment never reaches `compute update`) + - name: Staged deploy env: CLI_BIN: ${{ steps.cli.outputs.cli-bin }} FASTLY_API_TOKEN: dummy-token FASTLY_SERVICE_ID: dummy-service - run: | - set -euo pipefail - cd fixture-app - out=$("$GITHUB_WORKSPACE/cli-bin/$CLI_BIN" \ - deploy --adapter fastly --service-id dummy-service --stage -- --comment "staged smoke" 2>&1 | tee /dev/stderr) - echo "$out" | grep -qE '^version=42$' || { echo "::error::staged deploy did not emit version=42"; exit 1; } + run: .github/actions/deploy-core/tests/staged-deploy.sh - name: Assert the staged Fastly call sequence - run: | - set -euo pipefail - log="$FAKE_CALL_LOG" - echo "--- recorded fastly/curl calls:"; cat "$log" - - update=$(grep -E '^fastly compute update ' "$log" | head -n 1) - [[ -n "$update" ]] || { echo "::error::no 'compute update' call"; exit 1; } - # `compute update` does NOT support --comment; it must never be forwarded. - if grep -qE '^fastly compute update .*--comment' "$log"; then - echo "::error::--comment was forwarded to 'compute update' (unsupported flag)"; exit 1 - fi - for flag in -- --autoclone --version=active --non-interactive; do - [[ "$update" == *"$flag"* ]] || { echo "::error::'compute update' missing $flag"; exit 1; } - done - - # The comment must be applied to the version, BEFORE staging it. - grep -qE '^fastly service-version update .*--comment' "$log" \ - || { echo "::error::comment was not applied via 'service-version update'"; exit 1; } - cu=$(grep -n '^fastly service-version update ' "$log" | head -n 1 | cut -d: -f1) - st=$(grep -n '^fastly service-version stage ' "$log" | head -n 1 | cut -d: -f1) - [[ -n "$st" ]] || { echo "::error::version was never staged"; exit 1; } - [[ "$cu" -lt "$st" ]] || { echo "::error::comment applied after staging (must precede it)"; exit 1; } - - - name: Health check the staged version (singular staging_ip) + run: .github/actions/deploy-core/tests/assert-staged-calls.sh + + - name: Health check the staged version uses: ./.github/actions/healthcheck-fastly with: cli-artifact: ${{ steps.cli.outputs.artifact-name }} @@ -242,17 +201,10 @@ jobs: retry: "1" retry-delay: "1" - - name: Assert staging IP was resolved and probed - run: | - set -euo pipefail - log="$FAKE_CALL_LOG" - grep -qE '^GET https://api\.fastly\.com/service/dummy-service/version/42/domain\?include=staging_ips$' "$log" \ - || { echo "::error::staging-IP lookup not performed"; exit 1; } - # The probe must be rerouted to the staging IP from the singular field. - grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log" \ - || { echo "::error::probe was not rerouted to the staging IP"; exit 1; } - - - name: Health check FAILS closed when unhealthy + - name: Assert the staging IP was resolved and probed + run: .github/actions/deploy-core/tests/assert-staging-probe.sh + + - name: Health check must FAIL when the probe is unhealthy id: unhealthy continue-on-error: true uses: ./.github/actions/healthcheck-fastly @@ -266,24 +218,16 @@ jobs: retry: "1" retry-delay: "1" env: - # Flip the fake probe to 503 for this step only. + # Flips the fake probe to 503 for this step only. FORCE_UNHEALTHY: "1" - - name: Assert the unhealthy check actually failed the wrapper + - name: Assert the unhealthy check failed the wrapper env: OUTCOME: ${{ steps.unhealthy.outcome }} HEALTHY: ${{ steps.unhealthy.outputs.healthy }} - run: | - set -euo pipefail - # The rollback gate is "healthcheck step failed". If an unhealthy probe - # lets the action succeed, no caller would ever roll back. - [[ "$OUTCOME" == "failure" ]] \ - || { echo "::error::unhealthy probe did not fail healthcheck-fastly (outcome=$OUTCOME)"; exit 1; } - [[ "$HEALTHY" != "true" ]] \ - || { echo "::error::unhealthy probe still reported healthy=true"; exit 1; } - echo "healthcheck-fastly failed closed on an unhealthy probe" - - - name: Rollback the staged version (PUT /deactivate/staging) + run: .github/actions/deploy-core/tests/assert-unhealthy-failed.sh + + - name: Roll back the staged version uses: ./.github/actions/rollback-fastly with: cli-artifact: ${{ steps.cli.outputs.artifact-name }} @@ -292,7 +236,7 @@ jobs: fastly-api-token: dummy-token fastly-service-id: dummy-service - - name: Rollback production (PUT /activate on v-1) + - name: Roll back production id: prod-rollback uses: ./.github/actions/rollback-fastly with: @@ -305,15 +249,4 @@ jobs: - name: Assert rollback verbs, paths, and version threading env: ROLLED_BACK_TO: ${{ steps.prod-rollback.outputs['rolled-back-to'] }} - run: | - set -euo pipefail - log="$FAKE_CALL_LOG" - echo "--- recorded calls:"; cat "$log" - # Staging rollback: PUT .../deactivate/staging (NOT a plain /deactivate). - grep -qE '^PUT https://api\.fastly\.com/service/dummy-service/version/42/deactivate/staging$' "$log" \ - || { echo "::error::staging rollback did not PUT /deactivate/staging"; exit 1; } - # Production rollback activates the PREVIOUS version (42 -> 41), via PUT. - grep -qE '^PUT https://api\.fastly\.com/service/dummy-service/version/41/activate$' "$log" \ - || { echo "::error::production rollback did not PUT /version/41/activate"; exit 1; } - # Version threading out of the action. - [[ "$ROLLED_BACK_TO" == "41" ]] || { echo "::error::expected rolled-back-to=41, got '$ROLLED_BACK_TO'"; exit 1; } + run: .github/actions/deploy-core/tests/assert-rollback-calls.sh From 5db686b7aaf9745c5c2330c18e21cf1679019ea4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:57:12 -0700 Subject: [PATCH 21/26] Fix review findings; unify action env vars under EDGEZERO__
__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__
__ — `__` 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. --- .../{build-cli => build-app-cli}/action.yml | 16 +- .../scripts/build-app-cli.sh} | 82 ++++-- .../scripts/common.sh | 0 .../actions/deploy-core/scripts/cleanup.sh | 53 +++- .github/actions/deploy-core/scripts/common.sh | 57 ++++ .../deploy-core/scripts/download-cli.sh | 20 +- .../deploy-core/scripts/resolve-project.sh | 22 +- .../actions/deploy-core/scripts/run-cli.sh | 81 ++++-- .../deploy-core/scripts/validate-inputs.sh | 44 ++- .../deploy-core/scripts/write-summary.sh | 20 +- .../tests/assert-production-deploy.sh | 55 ++-- .../tests/assert-rollback-calls.sh | 38 +-- .../deploy-core/tests/assert-staged-calls.sh | 69 +++-- .../deploy-core/tests/assert-staging-probe.sh | 26 +- .../tests/assert-unhealthy-failed.sh | 6 +- .../deploy-core/tests/check-action-pins.sh | 2 +- .../actions/deploy-core/tests/extract-cli.sh | 27 -- .../deploy-core/tests/make-fake-fastly-env.sh | 113 +++++--- .../deploy-core/tests/make-smoke-fixture.sh | 7 +- .github/actions/deploy-core/tests/run.sh | 246 +++++++++++++++-- .../deploy-core/tests/staged-deploy.sh | 33 --- .github/actions/deploy-fastly/action.yml | 219 ++++++++++----- .../actions/deploy-fastly/scripts/deploy.sh | 44 +++ .../deploy-fastly/scripts/install-fastly.sh | 55 +++- .github/actions/healthcheck-fastly/action.yml | 91 ++++--- .../healthcheck-fastly/scripts/healthcheck.sh | 70 +++++ .github/actions/rollback-fastly/action.yml | 80 ++++-- .../rollback-fastly/scripts/rollback.sh | 53 ++++ .github/workflows/deploy-action.yml | 85 +++--- crates/edgezero-adapter-fastly/src/cli.rs | 256 ++++++++++++++++-- crates/edgezero-cli/src/adapter.rs | 16 ++ crates/edgezero-cli/src/lib.rs | 126 +++++++-- docs/guide/deploy-github-actions.md | 77 ++++-- ...ezero-deploy-action-implementation-plan.md | 44 +-- docs/specs/edgezero-deploy-adoption-guide.md | 30 +- docs/specs/edgezero-deploy-github-action.md | 154 +++++++++-- 36 files changed, 1761 insertions(+), 656 deletions(-) rename .github/actions/{build-cli => build-app-cli}/action.yml (77%) rename .github/actions/{build-cli/scripts/build-cli.sh => build-app-cli/scripts/build-app-cli.sh} (67%) rename .github/actions/{build-cli => build-app-cli}/scripts/common.sh (100%) delete mode 100755 .github/actions/deploy-core/tests/extract-cli.sh delete mode 100755 .github/actions/deploy-core/tests/staged-deploy.sh create mode 100755 .github/actions/deploy-fastly/scripts/deploy.sh create mode 100755 .github/actions/healthcheck-fastly/scripts/healthcheck.sh create mode 100755 .github/actions/rollback-fastly/scripts/rollback.sh diff --git a/.github/actions/build-cli/action.yml b/.github/actions/build-app-cli/action.yml similarity index 77% rename from .github/actions/build-cli/action.yml rename to .github/actions/build-app-cli/action.yml index 14b51d67..8effee1c 100644 --- a/.github/actions/build-cli/action.yml +++ b/.github/actions/build-app-cli/action.yml @@ -1,4 +1,4 @@ -name: EdgeZero build-cli +name: EdgeZero build-app-cli description: Compile the CLI package the application provides and publish it as a self-describing artifact. inputs: @@ -43,13 +43,13 @@ runs: id: build shell: bash env: - EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. - INPUT_CLI_PACKAGE: ${{ inputs['cli-package'] }} - INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} - INPUT_WORKING_DIRECTORY: ${{ inputs['working-directory'] }} - INPUT_RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} - INPUT_ARTIFACT_NAME: ${{ inputs['artifact-name'] }} - run: $GITHUB_ACTION_PATH/scripts/build-cli.sh + EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. + EDGEZERO__INPUT__CLI_PACKAGE: ${{ inputs['cli-package'] }} + EDGEZERO__INPUT__CLI_BIN: ${{ inputs['cli-bin'] }} + EDGEZERO__INPUT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + EDGEZERO__INPUT__RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} + EDGEZERO__INPUT__ARTIFACT_NAME: ${{ inputs['artifact-name'] }} + run: $GITHUB_ACTION_PATH/scripts/build-app-cli.sh - name: Upload CLI artifact uses: actions/upload-artifact@v4 diff --git a/.github/actions/build-cli/scripts/build-cli.sh b/.github/actions/build-app-cli/scripts/build-app-cli.sh similarity index 67% rename from .github/actions/build-cli/scripts/build-cli.sh rename to .github/actions/build-app-cli/scripts/build-app-cli.sh index 6e8f35f2..addaa70d 100755 --- a/.github/actions/build-cli/scripts/build-cli.sh +++ b/.github/actions/build-app-cli/scripts/build-app-cli.sh @@ -2,17 +2,17 @@ set -euo pipefail # Compiles the CLI package the *application* provides (a crate in the app's own -# workspace, named by INPUT_CLI_PACKAGE) into an action-owned CARGO_TARGET_DIR, +# workspace, named by EDGEZERO__INPUT__CLI_PACKAGE) into an action-owned CARGO_TARGET_DIR, # then packages the binary plus a self-describing cli-meta.json into a tar so the # executable bit survives actions/upload-artifact. Never builds the EdgeZero # monorepo CLI. # # Inputs (environment): -# INPUT_CLI_PACKAGE required Cargo package name to build -# INPUT_CLI_BIN optional binary name (defaults to the package name) -# INPUT_WORKING_DIRECTORY optional app dir relative to github.workspace (".") -# INPUT_RUST_TOOLCHAIN optional explicit toolchain or "auto" -# INPUT_ARTIFACT_NAME optional uploaded artifact name ("edgezero-cli") +# EDGEZERO__INPUT__CLI_PACKAGE required Cargo package name to build +# EDGEZERO__INPUT__CLI_BIN optional binary name (defaults to the package name) +# EDGEZERO__INPUT__WORKING_DIRECTORY optional app dir relative to github.workspace (".") +# EDGEZERO__INPUT__RUST_TOOLCHAIN optional explicit toolchain or "auto" +# EDGEZERO__INPUT__ARTIFACT_NAME optional uploaded artifact name ("edgezero-cli") SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -35,8 +35,41 @@ parse_toolchain_from_toml() { printf '%s\n' "$value" } +# The upward search for a toolchain file must not leave the APPLICATION's +# repository. +# +# The adoption guide's recommended layout checks the app out into a subdirectory +# of a separate deployer repo. Walking to github.workspace would then cross the +# app's Git boundary and let the *deployer's* `.tool-versions` silently decide +# which Rust compiles the app. The app's Git root is the honest boundary; fall +# back to github.workspace when the app dir is not a Git checkout at all. +# +# Every path here is canonicalized before comparison. The walk below tests the +# boundary with a string equality, so a symlinked TMPDIR or checkout would +# otherwise never match it — and the search would climb straight past the Git +# root it was meant to stop at. +resolve_search_boundary() { + local app_dir="$1" workspace_root="$2" + workspace_root=$(canonical_path "$workspace_root") + + local git_root + git_root=$(git -C "$app_dir" rev-parse --show-toplevel 2>/dev/null) || { + printf '%s\n' "$workspace_root" + return + } + git_root=$(canonical_path "$git_root") + + # Never search above github.workspace, even if the app's Git root is higher + # (a checkout mounted from outside the workspace). + if is_under "$workspace_root" "$git_root"; then + printf '%s\n' "$git_root" + else + printf '%s\n' "$workspace_root" + fi +} + # Resolve the application toolchain: explicit input > rustup files (walking up to -# github.workspace) > .tool-versions > the EdgeZero action repo fallback. +# the app's Git root) > .tool-versions > the EdgeZero action repo fallback. resolve_rust_toolchain() { local input="$1" app_dir="$2" workspace_root="$3" action_root="$4" if [[ "$input" != "auto" ]]; then @@ -45,7 +78,12 @@ resolve_rust_toolchain() { return fi - local directory="$app_dir" value + # Stop at the application repository boundary, not github.workspace. + local boundary + boundary=$(resolve_search_boundary "$app_dir" "$workspace_root") + + local directory value + directory=$(canonical_path "$app_dir") while true; do if [[ -f "$directory/rust-toolchain.toml" ]]; then parse_toolchain_from_toml "$directory/rust-toolchain.toml" @@ -59,10 +97,14 @@ resolve_rust_toolchain() { printf '%s\n' "$value" return fi - [[ "$directory" == "$workspace_root" ]] && break + if [[ "$directory" == "$boundary" ]]; then + break + fi local parent parent=$(dirname "$directory") - [[ "$parent" == "$directory" ]] && break + if [[ "$parent" == "$directory" ]]; then + break + fi directory="$parent" done @@ -76,18 +118,18 @@ resolve_rust_toolchain() { require_linux_x86_64() { case "$(uname -s)-$(uname -m)" in Linux-x86_64 | Linux-amd64) ;; - *) fail "build-cli supports only Linux x86-64 runners" ;; + *) fail "build-app-cli supports only Linux x86-64 runners" ;; esac } main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local action_root="${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required}" - local cli_package="${INPUT_CLI_PACKAGE:?input 'cli-package' is required}" - local cli_bin="${INPUT_CLI_BIN:-}" - local working_directory="${INPUT_WORKING_DIRECTORY:-.}" - local rust_toolchain_input="${INPUT_RUST_TOOLCHAIN:-auto}" - local artifact_name="${INPUT_ARTIFACT_NAME:-edgezero-cli}" + local action_root="${EDGEZERO__ACTION__ROOT:?EDGEZERO__ACTION__ROOT is required}" + local cli_package="${EDGEZERO__INPUT__CLI_PACKAGE:?input 'cli-package' is required}" + local cli_bin="${EDGEZERO__INPUT__CLI_BIN:-}" + local working_directory="${EDGEZERO__INPUT__WORKING_DIRECTORY:-.}" + local rust_toolchain_input="${EDGEZERO__INPUT__RUST_TOOLCHAIN:-auto}" + local artifact_name="${EDGEZERO__INPUT__ARTIFACT_NAME:-edgezero-cli}" # These directories are `rm -rf`d below, so they must NEVER come from the # inherited environment — a colliding job-level variable could otherwise point @@ -160,4 +202,8 @@ main() { append_output tarball-path "$tarball" } -main "$@" +# Sourcing this file exposes its functions without running a build, so the +# contract tests can exercise toolchain resolution directly. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/.github/actions/build-cli/scripts/common.sh b/.github/actions/build-app-cli/scripts/common.sh similarity index 100% rename from .github/actions/build-cli/scripts/common.sh rename to .github/actions/build-app-cli/scripts/common.sh diff --git a/.github/actions/deploy-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh index ca6ac0b0..09a8310d 100755 --- a/.github/actions/deploy-core/scripts/cleanup.sh +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -1,22 +1,51 @@ #!/usr/bin/env bash set -euo pipefail -# Removes action-owned temporary state, tool installs, and any provider auth -# state. Runs with `if: always()`, so it must tolerate partially-created dirs. - -remove_if_present() { - local dir="$1" - # Always return success: an absent dir is not an error, and this runs under - # `set -e` where a bare `[[…]] && rm` would exit non-zero when the dir is gone. - if [[ -n "$dir" && -d "$dir" ]]; then - rm -rf "$dir" +# Removes action-owned temporary state and tool installs. Runs with +# `if: always()`, so it must tolerate partially-created dirs. +# +# This script does `rm -rf`, so it removes ONLY directories it can prove the +# action owns: real paths strictly beneath RUNNER_TEMP. An inherited or +# job-level value pointing at the checkout — or anywhere else on a self-hosted +# runner — is refused, not deleted. (An earlier revision removed +# `$EDGEZERO_FASTLY_HOME`, a variable nothing in the action ever set: its value +# could only ever come from the caller's environment.) +# +# Inputs (environment): RUNNER_TEMP, EDGEZERO__ACTION__STATE_DIR, +# EDGEZERO__ACTION__TOOL_ROOT. + +remove_owned_dir() { + local dir="$1" temp_root="$2" + + [[ -n "$dir" ]] || return 0 + [[ -d "$dir" ]] || return 0 + + # Resolve symlinks before comparing: a symlinked state dir must not be able to + # smuggle the removal outside the temp root. + local real_dir real_root + real_dir=$(cd -- "$dir" 2>/dev/null && pwd -P) || return 0 + real_root=$(cd -- "$temp_root" 2>/dev/null && pwd -P) || { + echo "[edgezero-action] cleanup: RUNNER_TEMP '$temp_root' does not exist; nothing removed" >&2 + return 0 + } + + if [[ "$real_dir" != "$real_root"/* ]]; then + echo "[edgezero-action] cleanup: refusing to remove '$dir': not beneath the action-owned temp root '$real_root'" >&2 + return 0 fi + + rm -rf "$real_dir" } main() { - remove_if_present "${EDGEZERO_ACTION_STATE_DIR:-}" - remove_if_present "${EDGEZERO_TOOL_ROOT:-}" - remove_if_present "${EDGEZERO_FASTLY_HOME:-}" + local temp_root="${RUNNER_TEMP:-}" + if [[ -z "$temp_root" ]]; then + echo "[edgezero-action] cleanup: RUNNER_TEMP is unset; nothing removed" >&2 + return 0 + fi + + remove_owned_dir "${EDGEZERO__ACTION__STATE_DIR:-}" "$temp_root" + remove_owned_dir "${EDGEZERO__ACTION__TOOL_ROOT:-}" "$temp_root" } main "$@" diff --git a/.github/actions/deploy-core/scripts/common.sh b/.github/actions/deploy-core/scripts/common.sh index 2d3baf4e..642df778 100755 --- a/.github/actions/deploy-core/scripts/common.sh +++ b/.github/actions/deploy-core/scripts/common.sh @@ -130,3 +130,60 @@ assert_safe_tarball() { fail "refusing CLI archive containing a symlink or hardlink member" fi } + +# ── Lifecycle helpers (deploy / healthcheck / rollback wrappers) ────────────── + +# GitHub Actions does NOT enforce `required: true` on action inputs: an omitted +# or empty input is simply the empty string, and the step runs anyway. So the +# wrappers must check for themselves — otherwise an empty service-id or version +# silently reaches the provider. +require_input() { + local name="$1" value="$2" + [[ -n "$value" ]] || fail "missing required input '$name'" +} + +require_input_matching() { + local name="$1" value="$2" pattern="$3" + require_input "$name" "$value" + [[ "$value" =~ $pattern ]] || fail "input '$name' must match $pattern" +} + +# The Fastly provider tooling and its pinned release binary are Linux x86-64 +# only. Fail with a clear message rather than a confusing exec error later. +require_linux_x86_64() { + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) fail "the Fastly wrapper supports only Linux x86-64 runners" ;; + esac +} + +# Create a private log file that is REMOVED when the caller exits, whatever the +# exit status. Provider CLIs print request URLs, service metadata, and — with +# debug flags — credential material; leaving a raw log behind in RUNNER_TEMP +# hands it to every later step in the job. +# +# Sets the global LIFECYCLE_LOG. Callers must have `set -euo pipefail`. +LIFECYCLE_LOG="" +new_private_log() { + local dir="${RUNNER_TEMP:-/tmp}" + LIFECYCLE_LOG=$(mktemp "$dir/edgezero-lifecycle.XXXXXX") + chmod 600 "$LIFECYCLE_LOG" + # shellcheck disable=SC2064 # expand LIFECYCLE_LOG now, not at trap time + trap "rm -f -- '$LIFECYCLE_LOG'" EXIT +} + +# Read a canonical `=` line from a log. +# +# ANCHORED at both ends on purpose. An unanchored prefix match reads +# `version=15.2.0` as `15` and `version=12abc` as `12`, threading a version that +# was never deployed into the healthcheck and rollback that follow. If the value +# is not exactly digits, we have not parsed a version — we have guessed one. +read_numeric_line() { + local key="$1" log="$2" + grep -oE "^${key}=[0-9]+\$" "$log" | tail -n 1 | cut -d= -f2 || true +} + +read_bool_line() { + local key="$1" log="$2" + grep -oE "^${key}=(true|false)\$" "$log" | tail -n 1 | cut -d= -f2 || true +} diff --git a/.github/actions/deploy-core/scripts/download-cli.sh b/.github/actions/deploy-core/scripts/download-cli.sh index b144ff34..441fbe41 100755 --- a/.github/actions/deploy-core/scripts/download-cli.sh +++ b/.github/actions/deploy-core/scripts/download-cli.sh @@ -1,30 +1,30 @@ #!/usr/bin/env bash set -euo pipefail -# Extracts the build-cli artifact tar (downloaded by actions/download-artifact) +# Extracts the build-app-cli artifact tar (downloaded by actions/download-artifact) # into an action-owned tool dir, preserving the executable bit, reads the # self-describing cli-meta.json, and prepends the dir to PATH for action steps. -# A wrapper-supplied INPUT_CLI_BIN overrides the metadata's binary name. +# A wrapper-supplied EDGEZERO__INPUT__CLI_BIN overrides the metadata's binary name. # # Inputs (environment): -# EDGEZERO_CLI_ARTIFACT_DIR required dir containing the downloaded tar -# INPUT_CLI_BIN optional override for the binary name -# EDGEZERO_TOOL_ROOT optional install dir (defaults under RUNNER_TEMP) +# EDGEZERO__CLI__ARTIFACT_DIR required dir containing the downloaded tar +# EDGEZERO__INPUT__CLI_BIN optional override for the binary name +# EDGEZERO__ACTION__TOOL_ROOT optional install dir (defaults under RUNNER_TEMP) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -# Locate the single CLI tar produced by build-cli within the downloaded artifact. +# Locate the single CLI tar produced by build-app-cli within the downloaded artifact. find_cli_tarball() { local artifact_dir="$1" find "$artifact_dir" -maxdepth 2 -type f -name '*.tar' | head -n 1 } main() { - local artifact_dir="${EDGEZERO_CLI_ARTIFACT_DIR:?EDGEZERO_CLI_ARTIFACT_DIR is required}" - local cli_bin_override="${INPUT_CLI_BIN:-}" - local tool_root="${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" + local artifact_dir="${EDGEZERO__CLI__ARTIFACT_DIR:?EDGEZERO__CLI__ARTIFACT_DIR is required}" + local cli_bin_override="${EDGEZERO__INPUT__CLI_BIN:-}" + local tool_root="${EDGEZERO__ACTION__TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" require_cmd jq require_cmd tar @@ -59,7 +59,7 @@ main() { notice "using app CLI '$cli_bin' v$cli_version from artifact" append_output cli-bin "$cli_bin" append_output cli-version "$cli_version" - append_env EDGEZERO_TOOL_ROOT "$tool_root" + append_env EDGEZERO__ACTION__TOOL_ROOT "$tool_root" } main "$@" diff --git a/.github/actions/deploy-core/scripts/resolve-project.sh b/.github/actions/deploy-core/scripts/resolve-project.sh index ba741b6b..10aa4e91 100755 --- a/.github/actions/deploy-core/scripts/resolve-project.sh +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -6,9 +6,9 @@ set -euo pipefail # (Cargo.lock hash + target/ cache), so nested-workspace monorepos cache the # right artifacts. Provider-neutral: no provider names appear here. # -# Inputs (environment): INPUT_WORKING_DIRECTORY, INPUT_MANIFEST, -# INPUT_RUST_TOOLCHAIN, INPUT_TARGET (required), INPUT_BUILD_MODE, INPUT_CACHE, -# EDGEZERO_ACTION_ROOT (required), EDGEZERO_CLI_VERSION. +# Inputs (environment): EDGEZERO__INPUT__WORKING_DIRECTORY, EDGEZERO__INPUT__MANIFEST, +# EDGEZERO__INPUT__RUST_TOOLCHAIN, EDGEZERO__INPUT__TARGET (required), EDGEZERO__INPUT__BUILD_MODE, EDGEZERO__INPUT__CACHE, +# EDGEZERO__ACTION__ROOT (required), EDGEZERO__CLI__VERSION. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -85,13 +85,13 @@ assert_committed_source() { main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local action_root="${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required}" - local working_directory="${INPUT_WORKING_DIRECTORY:-.}" - local manifest="${INPUT_MANIFEST:-}" - local rust_toolchain_input="${INPUT_RUST_TOOLCHAIN:-auto}" - local target="${INPUT_TARGET:?INPUT_TARGET is required (wrapper-provided concrete target)}" - local cache="${INPUT_CACHE:-false}" - local cli_version="${EDGEZERO_CLI_VERSION:-unknown}" + local action_root="${EDGEZERO__ACTION__ROOT:?EDGEZERO__ACTION__ROOT is required}" + local working_directory="${EDGEZERO__INPUT__WORKING_DIRECTORY:-.}" + local manifest="${EDGEZERO__INPUT__MANIFEST:-}" + local rust_toolchain_input="${EDGEZERO__INPUT__RUST_TOOLCHAIN:-auto}" + local target="${EDGEZERO__INPUT__TARGET:?EDGEZERO__INPUT__TARGET is required (wrapper-provided concrete target)}" + local cache="${EDGEZERO__INPUT__CACHE:-false}" + local cli_version="${EDGEZERO__CLI__VERSION:-unknown}" require_cmd git require_cmd cargo @@ -144,7 +144,7 @@ main() { local rust_toolchain effective_build_mode cache_key rust_toolchain=$(resolve_rust_toolchain "$rust_toolchain_input" "$app_dir" "$git_root" "$action_root") - effective_build_mode=$(resolve_effective_build_mode "${INPUT_BUILD_MODE:-auto}") + effective_build_mode=$(resolve_effective_build_mode "${EDGEZERO__INPUT__BUILD_MODE:-auto}") cache_key="edgezero-deploy-${RUNNER_OS:-Linux}-${RUNNER_ARCH:-X64}-$(sanitize_ref "$rust_toolchain")-$(sanitize_ref "$target")-$(sanitize_ref "$cli_version")-${source_revision}-${lock_hash}" append_output working-directory "$app_dir" diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh index 53d54dcb..6d7037d2 100755 --- a/.github/actions/deploy-core/scripts/run-cli.sh +++ b/.github/actions/deploy-core/scripts/run-cli.sh @@ -8,23 +8,23 @@ set -euo pipefail # deploy-args (after `--`). # # Credential boundary (deploy mode): the wrapper never exports provider tokens -# onto the step directly. It passes DEPLOY_PROVIDER_ENV (a JSON object of typed +# onto the step directly. It passes EDGEZERO__PROVIDER__ENV (a JSON object of typed # credential name -> value) plus a provider-env-clear name list. This script # first UNSETS every clear-listed alias (removing any inherited FASTLY_* value), -# then exports only the typed values from DEPLOY_PROVIDER_ENV — and only names +# then exports only the typed values from EDGEZERO__PROVIDER__ENV — and only names # that are declared in the clear list. So inherited endpoint/token aliases can # never survive into the deploy. Build mode is credential-free and only clears. # # Inputs (environment): -# EDGEZERO_CLI_BIN required binary name to invoke (on PATH) -# EDGEZERO_ADAPTER required adapter passed as --adapter -# EDGEZERO_WORKING_DIRECTORY required directory to run the CLI from -# EDGEZERO_MANIFEST_PATH optional exported as EDGEZERO_MANIFEST when set -# DEPLOY_BUILD_ARGS_FILE optional NUL-delimited build passthrough (build) -# DEPLOY_FLAGS_FILE optional NUL-delimited typed flags (deploy) -# DEPLOY_ARGS_FILE optional NUL-delimited passthrough (deploy) -# DEPLOY_PROVIDER_ENV_CLEAR_FILE optional NUL-delimited env names to clear -# DEPLOY_PROVIDER_ENV optional JSON object of typed creds (deploy) +# EDGEZERO__CLI__BIN required binary name to invoke (on PATH) +# EDGEZERO__ADAPTER required adapter passed as --adapter +# EDGEZERO__PROJECT__WORKING_DIRECTORY required directory to run the CLI from +# EDGEZERO__PROJECT__MANIFEST_PATH optional exported as EDGEZERO_MANIFEST when set +# EDGEZERO__BUILD__ARGS_FILE optional NUL-delimited build passthrough (build) +# EDGEZERO__DEPLOY__FLAGS_FILE optional NUL-delimited typed flags (deploy) +# EDGEZERO__DEPLOY__ARGS_FILE optional NUL-delimited passthrough (deploy) +# EDGEZERO__PROVIDER__ENV_CLEAR_FILE optional NUL-delimited env names to clear +# EDGEZERO__PROVIDER__ENV optional JSON object of typed creds (deploy) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -66,29 +66,29 @@ name_in_clear_list() { } # Clear the provider aliases, then export ONLY the typed values from -# DEPLOY_PROVIDER_ENV whose names are declared in the clear list. jq parses the +# EDGEZERO__PROVIDER__ENV whose names are declared in the clear list. jq parses the # JSON, so values are opaque data (never interpreted by the shell). import_provider_env() { local clear_file="$1" - local json="${DEPLOY_PROVIDER_ENV:-}" + local json="${EDGEZERO__PROVIDER__ENV:-}" [[ -n "$json" ]] || json='{}' clear_named_aliases "$clear_file" require_cmd jq require_cmd base64 printf '%s' "$json" | jq -e 'type == "object"' >/dev/null 2>&1 || - fail "DEPLOY_PROVIDER_ENV must be a JSON object of string values" + fail "EDGEZERO__PROVIDER__ENV must be a JSON object of string values" printf '%s' "$json" | jq -e 'all(.[]; type == "string")' >/dev/null 2>&1 || - fail "every DEPLOY_PROVIDER_ENV value must be a string" + fail "every EDGEZERO__PROVIDER__ENV value must be a string" # One "NAME BASE64VALUE" line per entry. Base64 keeps values line-safe # (newlines, spaces, quotes cannot break the read loop) and opaque. local name b64 value while read -r name b64; do [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || - fail "DEPLOY_PROVIDER_ENV name '$name' is not a valid environment variable name" + fail "EDGEZERO__PROVIDER__ENV name '$name' is not a valid environment variable name" name_in_clear_list "$name" "$clear_file" || - fail "DEPLOY_PROVIDER_ENV name '$name' must be declared in provider-env-clear" + fail "EDGEZERO__PROVIDER__ENV name '$name' must be declared in provider-env-clear" value=$(printf '%s' "$b64" | base64 --decode) export "$name=$value" done < <(printf '%s' "$json" | jq -r 'to_entries[] | "\(.key) \(.value | @base64)"') @@ -100,8 +100,8 @@ build_build_argv() { local adapter="$2" ARGV=("$cli_bin" build --adapter "$adapter") # Credential-free build: defensively drop the wrapper-named aliases. - clear_named_aliases "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" - collect_nul "${DEPLOY_BUILD_ARGS_FILE:-/dev/null}" + clear_named_aliases "${EDGEZERO__PROVIDER__ENV_CLEAR_FILE:-/dev/null}" + collect_nul "${EDGEZERO__BUILD__ARGS_FILE:-/dev/null}" if ((${#COLLECTED[@]})); then ARGV+=(-- "${COLLECTED[@]}") fi @@ -113,15 +113,41 @@ build_deploy_argv() { local adapter="$2" ARGV=("$cli_bin" deploy --adapter "$adapter") # Typed adapter flags (before `--`): e.g. --service-id , --stage. - collect_nul "${DEPLOY_FLAGS_FILE:-/dev/null}" + collect_nul "${EDGEZERO__DEPLOY__FLAGS_FILE:-/dev/null}" ((${#COLLECTED[@]})) && ARGV+=("${COLLECTED[@]}") # Caller passthrough (after `--`): allowlisted deploy-args, e.g. --comment. - collect_nul "${DEPLOY_ARGS_FILE:-/dev/null}" + collect_nul "${EDGEZERO__DEPLOY__ARGS_FILE:-/dev/null}" if ((${#COLLECTED[@]})); then ARGV+=(-- "${COLLECTED[@]}") fi } +# Unset the action's PRIVATE environment namespace before handing control to the +# application CLI. +# +# This is a credential boundary, not tidiness. The wrapper carries the typed +# token into this script twice: once as `EDGEZERO___API_TOKEN` (so the +# step's YAML can build the JSON without interpolating a secret into a `run:` +# block), and once inside `EDGEZERO__PROVIDER__ENV` itself. Both are +# secret-bearing. Without this scrub they stay exported, so the app CLI — and +# every subprocess it spawns, including a manifest `[adapters.*.commands]` shell +# command — inherits the raw token under names we never promised, and any +# `env`-dumping build script would print it. +# +# This is why every action-owned variable lives under the double-underscore +# `EDGEZERO__` prefix: the boundary is then one rule with no list to keep in sync. +# `EDGEZERO_MANIFEST` (SINGLE underscore) is deliberately outside it — that is the +# CLI's own public contract, not ours, and it is the one variable we do pass on. +scrub_action_private_env() { + local name + while IFS= read -r name; do + case "$name" in + EDGEZERO__*) unset "$name" || true ;; + *) ;; + esac + done < <(compgen -e) +} + ARGV=() main() { local mode="${1:-}" @@ -130,21 +156,24 @@ main() { *) fail "usage: run-cli.sh build|deploy" ;; esac - local cli_bin="${EDGEZERO_CLI_BIN:?EDGEZERO_CLI_BIN is required}" - local adapter="${EDGEZERO_ADAPTER:?EDGEZERO_ADAPTER is required}" - local working_directory="${EDGEZERO_WORKING_DIRECTORY:?EDGEZERO_WORKING_DIRECTORY is required}" - local manifest="${EDGEZERO_MANIFEST_PATH:-}" + local cli_bin="${EDGEZERO__CLI__BIN:?EDGEZERO__CLI__BIN is required}" + local adapter="${EDGEZERO__ADAPTER:?EDGEZERO__ADAPTER is required}" + local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:?EDGEZERO__PROJECT__WORKING_DIRECTORY is required}" + local manifest="${EDGEZERO__PROJECT__MANIFEST_PATH:-}" require_cmd "$cli_bin" case "$mode" in build) build_build_argv "$cli_bin" "$adapter" ;; deploy) # Clear inherited provider aliases and export only the typed credentials. - import_provider_env "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" + import_provider_env "${EDGEZERO__PROVIDER__ENV_CLEAR_FILE:-/dev/null}" build_deploy_argv "$cli_bin" "$adapter" ;; esac + # Everything the action needed from its own env is now in locals or in ARGV. + scrub_action_private_env + if [[ -n "$manifest" ]]; then export EDGEZERO_MANIFEST="$manifest" else diff --git a/.github/actions/deploy-core/scripts/validate-inputs.sh b/.github/actions/deploy-core/scripts/validate-inputs.sh index 9b8ec43d..d9b6fd3d 100755 --- a/.github/actions/deploy-core/scripts/validate-inputs.sh +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -6,9 +6,10 @@ set -euo pipefail # allowlist, and validates booleans. It never learns provider credential names # or provider CLI flags — those arrive from the wrapper as opaque data. # -# Inputs (environment): INPUT_ADAPTER, INPUT_BUILD_MODE, INPUT_CACHE, -# INPUT_BUILD_ARGS, INPUT_DEPLOY_ARGS, INPUT_DEPLOY_FLAGS, -# INPUT_PROVIDER_ENV_CLEAR, INPUT_DEPLOY_ARG_ALLOW, EDGEZERO_RUNNER_OS/ARCH. +# Inputs (environment): EDGEZERO__INPUT__ADAPTER, EDGEZERO__INPUT__BUILD_MODE, EDGEZERO__INPUT__CACHE, +# EDGEZERO__INPUT__BUILD_ARGS, EDGEZERO__INPUT__DEPLOY_ARGS, EDGEZERO__INPUT__DEPLOY_ARGS_PREPEND, +# EDGEZERO__INPUT__DEPLOY_FLAGS, EDGEZERO__INPUT__PROVIDER_ENV_CLEAR, EDGEZERO__INPUT__DEPLOY_ARG_ALLOW, +# EDGEZERO__RUNNER__OS/ARCH. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -65,13 +66,13 @@ enforce_deploy_arg_allowlist() { } main() { - local adapter="${INPUT_ADAPTER:-}" - local build_mode="${INPUT_BUILD_MODE:-auto}" - local cache="${INPUT_CACHE:-false}" - local stage="${INPUT_STAGE:-false}" - local deploy_arg_allow="${INPUT_DEPLOY_ARG_ALLOW:-}" + local adapter="${EDGEZERO__INPUT__ADAPTER:-}" + local build_mode="${EDGEZERO__INPUT__BUILD_MODE:-auto}" + local cache="${EDGEZERO__INPUT__CACHE:-false}" + local stage="${EDGEZERO__INPUT__STAGE:-false}" + local deploy_arg_allow="${EDGEZERO__INPUT__DEPLOY_ARG_ALLOW:-}" - require_supported_runner "${EDGEZERO_RUNNER_OS:-}" "${EDGEZERO_RUNNER_ARCH:-}" + require_supported_runner "${EDGEZERO__RUNNER__OS:-}" "${EDGEZERO__RUNNER__ARCH:-}" # Well-formedness only: the CLI decides whether the adapter is supported. [[ -n "$adapter" ]] || fail "internal parameter 'adapter' is required" @@ -93,20 +94,35 @@ main() { require_cmd jq - local state_dir="${EDGEZERO_ACTION_STATE_DIR:-${RUNNER_TEMP:-/tmp}/edgezero-action-state}" + local state_dir="${EDGEZERO__ACTION__STATE_DIR:-${RUNNER_TEMP:-/tmp}/edgezero-action-state}" mkdir -p "$state_dir" local build_args_file="$state_dir/build-args.nul" local deploy_args_file="$state_dir/deploy-args.nul" local deploy_flags_file="$state_dir/deploy-flags.nul" local provider_env_clear_file="$state_dir/provider-env-clear.nul" - parse_json_string_array "build-args" "${INPUT_BUILD_ARGS:-[]}" "$build_args_file" - parse_json_string_array "deploy-args" "${INPUT_DEPLOY_ARGS:-[]}" "$deploy_args_file" - parse_json_string_array "deploy-flags" "${INPUT_DEPLOY_FLAGS:-[]}" "$deploy_flags_file" - parse_json_string_array "provider-env-clear" "${INPUT_PROVIDER_ENV_CLEAR:-[]}" "$provider_env_clear_file" + parse_json_string_array "build-args" "${EDGEZERO__INPUT__BUILD_ARGS:-[]}" "$build_args_file" + parse_json_string_array "deploy-args" "${EDGEZERO__INPUT__DEPLOY_ARGS:-[]}" "$deploy_args_file" + parse_json_string_array "deploy-flags" "${EDGEZERO__INPUT__DEPLOY_FLAGS:-[]}" "$deploy_flags_file" + parse_json_string_array "provider-env-clear" "${EDGEZERO__INPUT__PROVIDER_ENV_CLEAR:-[]}" "$provider_env_clear_file" + # The allowlist governs CALLER deploy-args only. enforce_deploy_arg_allowlist "$deploy_args_file" "$deploy_arg_allow" + # Action-owned passthrough args are prepended AFTER the allowlist check, + # because they are not caller input — the wrapper supplies them to make the + # deploy safe in CI (for Fastly: `--non-interactive`, which the built-in + # deploy path adds for itself but a manifest `[adapters.fastly.commands] + # deploy = "fastly compute deploy"` override would otherwise never get, so the + # deploy could block on a TTY prompt). They go first so a caller arg can still + # override them where the provider CLI takes last-wins. + local prepend_file="$state_dir/deploy-args-prepend.nul" + parse_json_string_array "deploy-args-prepend" "${EDGEZERO__INPUT__DEPLOY_ARGS_PREPEND:-[]}" "$prepend_file" + if [[ -s "$prepend_file" ]]; then + cat "$prepend_file" "$deploy_args_file" >"$deploy_args_file.merged" + mv "$deploy_args_file.merged" "$deploy_args_file" + fi + append_output adapter "$adapter" append_output build-args-file "$build_args_file" append_output deploy-args-file "$deploy_args_file" diff --git a/.github/actions/deploy-core/scripts/write-summary.sh b/.github/actions/deploy-core/scripts/write-summary.sh index 15625a0d..73890783 100755 --- a/.github/actions/deploy-core/scripts/write-summary.sh +++ b/.github/actions/deploy-core/scripts/write-summary.sh @@ -12,16 +12,16 @@ main() { echo echo "| Field | Value |" echo "| ----- | ----- |" - echo "| Adapter | ${SUMMARY_ADAPTER:-unknown} |" - echo "| Application directory | ${SUMMARY_WORKING_DIRECTORY:-unknown} |" - echo "| Source revision | ${SUMMARY_SOURCE_REVISION:-unknown} |" - echo "| Manifest | ${SUMMARY_MANIFEST:-EdgeZero default discovery} |" - echo "| Rust toolchain | ${SUMMARY_RUST_TOOLCHAIN:-unknown} |" - echo "| Target | ${SUMMARY_TARGET:-unknown} |" - echo "| CLI version | ${SUMMARY_CLI_VERSION:-unknown} |" - echo "| Effective build mode | ${SUMMARY_EFFECTIVE_BUILD_MODE:-unknown} |" - echo "| Cache | ${SUMMARY_CACHE:-false} |" - echo "| Result | ${SUMMARY_RESULT:-unknown} |" + echo "| Adapter | ${EDGEZERO__SUMMARY__ADAPTER:-unknown} |" + echo "| Application directory | ${EDGEZERO__SUMMARY__WORKING_DIRECTORY:-unknown} |" + echo "| Source revision | ${EDGEZERO__SUMMARY__SOURCE_REVISION:-unknown} |" + echo "| Manifest | ${EDGEZERO__SUMMARY__MANIFEST:-EdgeZero default discovery} |" + echo "| Rust toolchain | ${EDGEZERO__SUMMARY__RUST_TOOLCHAIN:-unknown} |" + echo "| Target | ${EDGEZERO__SUMMARY__TARGET:-unknown} |" + echo "| CLI version | ${EDGEZERO__SUMMARY__CLI_VERSION:-unknown} |" + echo "| Effective build mode | ${EDGEZERO__SUMMARY__EFFECTIVE_BUILD_MODE:-unknown} |" + echo "| Cache | ${EDGEZERO__SUMMARY__CACHE:-false} |" + echo "| Result | ${EDGEZERO__SUMMARY__RESULT:-unknown} |" } >>"$summary_file" } diff --git a/.github/actions/deploy-core/tests/assert-production-deploy.sh b/.github/actions/deploy-core/tests/assert-production-deploy.sh index cdf37947..d74eab44 100755 --- a/.github/actions/deploy-core/tests/assert-production-deploy.sh +++ b/.github/actions/deploy-core/tests/assert-production-deploy.sh @@ -1,42 +1,55 @@ #!/usr/bin/env bash set -euo pipefail -# Asserts the production deploy path end to end: build-cli -> deploy-fastly -> +# Asserts the production deploy path end to end: build-app-cli -> deploy-fastly -> # the app-owned CLI -> the manifest's overridden Fastly deploy command. # # Also asserts the provider-env credential boundary: the typed inputs reach the -# deploy, and an inherited alias (FASTLY_ENDPOINT, set at job level) is CLEARED. +# deploy, inherited aliases are CLEARED, and the action's own secret-bearing +# helper variables do NOT survive into the CLI's environment. # -# Inputs (environment): GITHUB_WORKSPACE, FASTLY_VERSION_OUT. +# Inputs (environment): GITHUB_WORKSPACE, EDGEZERO__TEST__FASTLY_VERSION. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local version_out="${FASTLY_VERSION_OUT:-}" + local version_out="${EDGEZERO__TEST__FASTLY_VERSION:-}" local env_seen="$workspace/fixture-app/env-seen.txt" local argv="$workspace/fixture-app/deploy-argv.txt" - if [[ ! -f "$argv" || ! -f "$env_seen" ]]; then - echo "::error::the deploy never reached the app CLI's Fastly deploy command" >&2 - return 1 - fi - echo "recorded argv:"; cat "$argv" - echo "credentials the deploy saw:"; cat "$env_seen" + [[ -f "$argv" && -f "$env_seen" ]] || + fail "the deploy never reached the app CLI's Fastly deploy command" + echo "recorded argv:" + cat "$argv" + echo "environment the deploy saw:" + cat "$env_seen" + + [[ "$version_out" == "7" ]] || + fail "expected fastly-version=7 out of the action, got '${version_out:-}'" - if [[ "$version_out" != "7" ]]; then - echo "::error::expected fastly-version=7 out of the action, got '${version_out:-}'" >&2 - return 1 - fi + # The action supplies --non-interactive itself, so a manifest-command deploy + # (this fixture is one) cannot block on a TTY prompt in CI. + grep -qx -- '--non-interactive' "$argv" || + fail "the action-owned --non-interactive never reached the deploy command" - # The provider-env boundary: typed values in, inherited aliases out. + # The provider-env boundary: typed values in, inherited aliases out, and none + # of the action's private secret carriers left behind. local expected - for expected in 'token=dummy-token' 'service-id=dummy-service' 'endpoint=CLEARED'; do - if ! grep -qx -- "$expected" "$env_seen"; then - echo "::error::provider-env boundary violated: expected '$expected' in env-seen.txt" >&2 - return 1 - fi + for expected in \ + 'token=dummy-token' \ + 'service-id=dummy-service' \ + 'endpoint=CLEARED' \ + 'home=CLEARED' \ + 'action-token-carrier=CLEARED' \ + 'provider-env-json=CLEARED'; do + grep -qx -- "$expected" "$env_seen" || + fail "credential boundary violated: expected '$expected' in env-seen.txt" done - echo "production deploy, version threading, and the credential boundary all hold" + notice "production deploy, version threading, and the credential boundary all hold" } main "$@" diff --git a/.github/actions/deploy-core/tests/assert-rollback-calls.sh b/.github/actions/deploy-core/tests/assert-rollback-calls.sh index 7f050c12..df655f28 100755 --- a/.github/actions/deploy-core/tests/assert-rollback-calls.sh +++ b/.github/actions/deploy-core/tests/assert-rollback-calls.sh @@ -3,38 +3,38 @@ set -euo pipefail # Asserts the rollback verbs, paths, and version threading. # -# Regression test for two real defects a review found: +# Regression test for real defects a review found: # * Rollback used POST; the Fastly API requires PUT. # * Staging rollback hit `/deactivate`, which deactivates the LIVE version. # Undoing a stage is `/deactivate/staging`. # -# Inputs (environment): FAKE_CALL_LOG, ROLLED_BACK_TO. +# Inputs (environment): EDGEZERO__TEST__FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION, EDGEZERO__TEST__ROLLED_BACK_TO. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" main() { - local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" - local rolled_back_to="${ROLLED_BACK_TO:-}" - local api="https://api\.fastly\.com/service/dummy-service" + local log="${EDGEZERO__TEST__FAKE_CALL_LOG:?EDGEZERO__TEST__FAKE_CALL_LOG is required}" + local staged="${EDGEZERO__TEST__STAGED_VERSION:?EDGEZERO__TEST__STAGED_VERSION is required}" + local rolled_back_to="${EDGEZERO__TEST__ROLLED_BACK_TO:-}" + local api='https://api\.fastly\.com/service/dummy-service' + local previous=$((staged - 1)) echo "--- recorded fastly/curl calls:" cat "$log" - if ! grep -qE "^PUT $api/version/42/deactivate/staging\$" "$log"; then - echo "::error::staging rollback did not PUT /version/42/deactivate/staging" >&2 - return 1 - fi + grep -qE "^PUT $api/version/$staged/deactivate/staging\$" "$log" || + fail "staging rollback did not PUT /version/$staged/deactivate/staging" - # Production rollback activates the PREVIOUS version (42 -> 41). - if ! grep -qE "^PUT $api/version/41/activate\$" "$log"; then - echo "::error::production rollback did not PUT /version/41/activate" >&2 - return 1 - fi + # Production rollback activates the PREVIOUS version. + grep -qE "^PUT $api/version/$previous/activate\$" "$log" || + fail "production rollback did not PUT /version/$previous/activate" - if [[ "$rolled_back_to" != "41" ]]; then - echo "::error::expected rolled-back-to=41, got '${rolled_back_to:-}'" >&2 - return 1 - fi + [[ "$rolled_back_to" == "$previous" ]] || + fail "expected rolled-back-to=$previous, got '${rolled_back_to:-}'" - echo "rollback used PUT with the correct paths, and rolled-back-to=41 threaded out" + notice "rollback used PUT with the correct paths; rolled-back-to=$previous threaded out" } main "$@" diff --git a/.github/actions/deploy-core/tests/assert-staged-calls.sh b/.github/actions/deploy-core/tests/assert-staged-calls.sh index 3e9d24e6..7ff4bc0f 100755 --- a/.github/actions/deploy-core/tests/assert-staged-calls.sh +++ b/.github/actions/deploy-core/tests/assert-staged-calls.sh @@ -1,39 +1,37 @@ #!/usr/bin/env bash set -euo pipefail -# Asserts the exact Fastly call sequence a staged deploy must produce. +# Asserts the exact Fastly call sequence a STAGED deploy through the +# deploy-fastly wrapper must produce, and that the staged version threaded out +# of the action. # -# Regression test for two real defects a review found: +# Regression test for real defects a review found: # * `--comment` was forwarded to `fastly compute update`, which has no such -# flag — the upload failed. It must instead be applied via -# `fastly service-version update --comment`, BEFORE the version is staged. -# * The staged upload must clone the active version and stay non-interactive. +# flag, so the upload failed. It must be applied via +# `fastly service-version update --comment` BEFORE the version is staged. +# * A manifest-command 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 staged upload must clone the active version. # -# Inputs (environment): FAKE_CALL_LOG. +# Inputs (environment): EDGEZERO__TEST__FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION. -require_call() { - local pattern="$1" what="$2" log="$3" - if ! grep -qE -- "$pattern" "$log"; then - echo "::error::$what" >&2 - return 1 - fi -} +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" assert_update_flags() { local update="$1" flag for flag in --autoclone --version=active --non-interactive --service-id; do - if [[ "$update" != *"$flag"* ]]; then - echo "::error::'compute update' is missing $flag (got: $update)" >&2 - return 1 - fi + [[ "$update" == *"$flag"* ]] || + fail "'compute update' is missing $flag (got: $update)" done } assert_no_comment_on_update() { local log="$1" if grep -qE '^fastly compute update .*--comment' "$log"; then - echo "::error::--comment was forwarded to 'compute update', which does not support it" >&2 - return 1 + fail "--comment was forwarded to 'compute update', which does not support it" fi } @@ -42,38 +40,33 @@ assert_comment_precedes_stage() { comment_line=$(grep -nE '^fastly service-version update .*--comment' "$log" | head -n 1 | cut -d: -f1) stage_line=$(grep -nE '^fastly service-version stage ' "$log" | head -n 1 | cut -d: -f1) - if [[ -z "$comment_line" ]]; then - echo "::error::the comment was never applied via 'service-version update'" >&2 - return 1 - fi - if [[ -z "$stage_line" ]]; then - echo "::error::the version was never staged" >&2 - return 1 - fi - if [[ "$comment_line" -ge "$stage_line" ]]; then - echo "::error::the comment was applied after staging; it must precede it" >&2 - return 1 - fi + [[ -n "$comment_line" ]] || fail "the comment was never applied via 'service-version update'" + [[ -n "$stage_line" ]] || fail "the version was never staged" + [[ "$comment_line" -lt "$stage_line" ]] || + fail "the comment was applied after staging; it must precede it" } main() { - local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" - local update + local log="${EDGEZERO__TEST__FAKE_CALL_LOG:?EDGEZERO__TEST__FAKE_CALL_LOG is required}" + local staged_version="${EDGEZERO__TEST__STAGED_VERSION:-}" echo "--- recorded fastly/curl calls:" cat "$log" + local update update=$(grep -E '^fastly compute update ' "$log" | head -n 1 || true) - if [[ -z "$update" ]]; then - echo "::error::the staged deploy never ran 'fastly compute update'" >&2 - return 1 - fi + [[ -n "$update" ]] || fail "the staged deploy never ran 'fastly compute update'" assert_update_flags "$update" assert_no_comment_on_update "$log" assert_comment_precedes_stage "$log" - echo "staged call sequence is correct" + # The staged version must thread out of deploy-fastly, or the healthcheck and + # rollback that follow have nothing to act on. + [[ "$staged_version" == "42" ]] || + fail "expected fastly-version=42 out of the staged deploy, got '${staged_version:-}'" + + notice "staged call sequence is correct and fastly-version=$staged_version threaded out" } main "$@" diff --git a/.github/actions/deploy-core/tests/assert-staging-probe.sh b/.github/actions/deploy-core/tests/assert-staging-probe.sh index a8b606a7..e41c3d62 100755 --- a/.github/actions/deploy-core/tests/assert-staging-probe.sh +++ b/.github/actions/deploy-core/tests/assert-staging-probe.sh @@ -6,24 +6,26 @@ set -euo pipefail # # Regression test: the Fastly domain API returns a SINGULAR `staging_ip` string # per domain object (`staging_ips` is only the `include=` query param). Reading -# it as an array silently found no IP and probed production instead. +# it as an array silently found no IP and probed PRODUCTION instead — a staging +# check that was quietly testing the wrong thing. # -# Inputs (environment): FAKE_CALL_LOG. +# Inputs (environment): EDGEZERO__TEST__FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" main() { - local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local log="${EDGEZERO__TEST__FAKE_CALL_LOG:?EDGEZERO__TEST__FAKE_CALL_LOG is required}" + local staged="${EDGEZERO__TEST__STAGED_VERSION:?EDGEZERO__TEST__STAGED_VERSION is required}" - if ! grep -qE '^GET https://api\.fastly\.com/service/dummy-service/version/42/domain\?include=staging_ips$' "$log"; then - echo "::error::the staging-IP lookup was never performed" >&2 - return 1 - fi + grep -qE "^GET https://api\.fastly\.com/service/dummy-service/version/$staged/domain\?include=staging_ips\$" "$log" || + fail "the staging-IP lookup was never performed for version $staged" - if ! grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log"; then - echo "::error::the probe was not rerouted to the staging IP (singular staging_ip not read?)" >&2 - return 1 - fi + grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log" || + fail "the probe was not rerouted to the staging IP (was the singular staging_ip read?)" - echo "staging probe was rerouted through the resolved staging IP" + notice "staging probe was rerouted through the resolved staging IP" } main "$@" diff --git a/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh b/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh index 960feccb..3a95e9d9 100755 --- a/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh +++ b/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh @@ -7,11 +7,11 @@ set -euo pipefail # probe lets the action succeed, no caller would ever roll back — so this is the # single most important contract in the lifecycle. # -# Inputs (environment): OUTCOME (the step's outcome), HEALTHY (its output). +# Inputs (environment): EDGEZERO__TEST__OUTCOME (the step's outcome), EDGEZERO__TEST__HEALTHY (its output). main() { - local outcome="${OUTCOME:?OUTCOME is required}" - local healthy="${HEALTHY:-}" + local outcome="${EDGEZERO__TEST__OUTCOME:?EDGEZERO__TEST__OUTCOME is required}" + local healthy="${EDGEZERO__TEST__HEALTHY:-}" if [[ "$outcome" != "failure" ]]; then echo "::error::an unhealthy probe did not fail healthcheck-fastly (outcome=$outcome)" >&2 diff --git a/.github/actions/deploy-core/tests/check-action-pins.sh b/.github/actions/deploy-core/tests/check-action-pins.sh index 842ebeb5..a646e78a 100755 --- a/.github/actions/deploy-core/tests/check-action-pins.sh +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -10,7 +10,7 @@ REPO_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) files=( "$REPO_ROOT/.github/workflows/deploy-action.yml" - "$REPO_ROOT/.github/actions/build-cli/action.yml" + "$REPO_ROOT/.github/actions/build-app-cli/action.yml" "$REPO_ROOT/.github/actions/deploy-core" "$REPO_ROOT/.github/actions/deploy-fastly/action.yml" "$REPO_ROOT/.github/actions/healthcheck-fastly/action.yml" diff --git a/.github/actions/deploy-core/tests/extract-cli.sh b/.github/actions/deploy-core/tests/extract-cli.sh deleted file mode 100755 index 0c5541f2..00000000 --- a/.github/actions/deploy-core/tests/extract-cli.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Extracts the build-cli artifact tarball so the lifecycle test can drive the -# app CLI directly (the wrappers do this themselves via download-cli.sh). -# -# Inputs (environment): GITHUB_WORKSPACE, CLI_BIN. - -main() { - local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local cli_bin="${CLI_BIN:?CLI_BIN is required}" - local dest="$workspace/cli-bin" - local tarball - - tarball=$(find "$workspace/cli-dl" -maxdepth 1 -name '*.tar' -print -quit) - if [[ -z "$tarball" ]]; then - echo "::error::no CLI tarball found under $workspace/cli-dl" >&2 - return 1 - fi - - mkdir -p "$dest" - tar -xf "$tarball" -C "$dest" - chmod +x "$dest/$cli_bin" - echo "extracted $cli_bin from $tarball" -} - -main "$@" diff --git a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh index ef91409d..93feba92 100755 --- a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh +++ b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh @@ -1,87 +1,114 @@ #!/usr/bin/env bash set -euo pipefail -# Installs fake `fastly` and `curl` binaries on PATH for the lifecycle smoke -# test, plus a call log the assertions read back. +# Installs fake `fastly` and `curl` binaries for the lifecycle smoke test, plus a +# call log the assertions read back. # -# These fakes mirror the REAL contracts the adapter depends on, so the smoke -# test regression-tests the bugs a review found: -# * `fastly compute update` must NOT receive --comment (it does not support it); -# the comment must be applied via `fastly service-version update` BEFORE +# The fakes mirror the REAL contracts the adapter depends on, so the smoke test +# regression-tests the defects a review found: +# * `fastly compute update` must NOT receive --comment (it has no such flag); +# the comment goes through `fastly service-version update` BEFORE # `service-version stage`. # * `compute update` output must be a realistic success line, because the -# version parser is now fail-closed (it refuses to guess). +# version parser is fail-closed and refuses to guess. # * The Fastly domain API returns a SINGULAR `staging_ip` string. # * activate/deactivate are PUT, and staging deactivate is /deactivate/staging. # -# Inputs (environment): GITHUB_WORKSPACE, GITHUB_PATH. - -main() { - local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local bin_dir="$workspace/fake-bin" - local log="$workspace/fake-calls.log" - local state="$workspace/fake-state" +# The fake `fastly` is placed in the ACTION-OWNED TOOL ROOT, reporting the pinned +# version, so `install-fastly.sh` adopts it instead of downloading the real CLI +# (its idempotency check). That is what lets the staged path be exercised through +# the real deploy-fastly wrapper rather than by calling the CLI directly. +# The fake `curl` goes on PATH, which nothing reinstalls. +# +# Inputs (environment): GITHUB_WORKSPACE, GITHUB_PATH, GITHUB_ENV, RUNNER_TEMP. - mkdir -p "$bin_dir" "$state" - : >"$log" +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" - cat >"$bin_dir/fastly" <<'SH' +write_fake_fastly() { + local path="$1" version="$2" + cat >"$path" <>"$FAKE_CALL_LOG" -case "${1:-} ${2:-}" in - "compute build") - echo "Built package (fixture)" - ;; +printf 'fastly %s\n' "\$*" >>"\$EDGEZERO__TEST__FAKE_CALL_LOG" +case "\${1:-} \${2:-}" in + "version ") echo "Fastly CLI version v$version (fake)" ;; + "compute build") echo "Built package (fixture)" ;; "compute update") - # Realistic success line — the version parser is fail-closed and will - # refuse to stage if it cannot parse a version from this output. + # A realistic success line: the version parser is fail-closed and will + # refuse to stage if it cannot read a version out of this output. echo "SUCCESS: Updated package (service dummy-service, version 42)" ;; - "compute deploy") - echo "SUCCESS: Deployed package (service dummy-service, version 43)" - ;; + "compute deploy") echo "SUCCESS: Deployed package (service dummy-service, version 43)" ;; "service-version update") echo "Updated version comment" ;; "service-version stage") echo "Staged version" ;; - *) echo "fake fastly: unhandled: $*" >&2 ;; + *) + case "\${1:-}" in + version | --version) echo "Fastly CLI version v$version (fake)" ;; + *) echo "fake fastly: unhandled: \$*" >&2 ;; + esac + ;; esac exit 0 -SH - chmod +x "$bin_dir/fastly" +SHIM + chmod +x "$path" +} - cat >"$bin_dir/curl" <<'SH' +write_fake_curl() { + local path="$1" + cat >"$path" <<'SHIM' #!/usr/bin/env bash -# Two shapes: an API call via `--config -` (config on stdin), or a health probe. +# Two shapes: a Fastly API call via `--config -` (config on stdin), or a probe. if [[ "$*" == *"--config"* ]]; then config=$(cat) url=$(printf '%s\n' "$config" | sed -nE 's/^url = "(.*)"$/\1/p') if printf '%s\n' "$config" | grep -q '^request = "PUT"$'; then - printf 'PUT %s\n' "$url" >>"$FAKE_CALL_LOG" + printf 'PUT %s\n' "$url" >>"$EDGEZERO__TEST__FAKE_CALL_LOG" echo 200 exit 0 fi - printf 'GET %s\n' "$url" >>"$FAKE_CALL_LOG" + printf 'GET %s\n' "$url" >>"$EDGEZERO__TEST__FAKE_CALL_LOG" # Fastly returns a SINGULAR `staging_ip` string per domain object. printf '[{"name":"staging.example.com","staging_ip":"151.101.2.10"}]\n' exit 0 fi -printf 'PROBE %s\n' "$*" >>"$FAKE_CALL_LOG" -if [[ -n "${FORCE_UNHEALTHY:-}" || -f "$FAKE_STATE_DIR/unhealthy" ]]; then +printf 'PROBE %s\n' "$*" >>"$EDGEZERO__TEST__FAKE_CALL_LOG" +if [[ -n "${EDGEZERO__TEST__FORCE_UNHEALTHY:-}" ]]; then echo 503 else echo 200 fi exit 0 -SH - chmod +x "$bin_dir/curl" +SHIM + chmod +x "$path" +} + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required}" + local action_dir + action_dir=$(cd -- "$SCRIPT_DIR/../../deploy-fastly" && pwd) + local path_dir="$workspace/fake-bin" + local tool_bin="$runner_temp/edgezero-action-tools/bin" + local log="$workspace/fake-calls.log" + + mkdir -p "$path_dir" "$tool_bin" + : >"$log" + + # The fake must claim the pinned version, or install-fastly.sh replaces it. + local pinned + pinned=$(json_get "$action_dir/versions.json" fastly.version) + + write_fake_fastly "$tool_bin/fastly" "$pinned" + write_fake_fastly "$path_dir/fastly" "$pinned" + write_fake_curl "$path_dir/curl" - # Prepend the fakes so they shadow the real tools for subsequent steps. - printf '%s\n' "$bin_dir" >>"${GITHUB_PATH:?GITHUB_PATH is required}" + printf '%s\n' "$path_dir" >>"${GITHUB_PATH:?GITHUB_PATH is required}" { - printf 'FAKE_CALL_LOG=%s\n' "$log" - printf 'FAKE_STATE_DIR=%s\n' "$state" + printf 'EDGEZERO__TEST__FAKE_CALL_LOG=%s\n' "$log" } >>"${GITHUB_ENV:?GITHUB_ENV is required}" - echo "fake fastly + curl installed at $bin_dir" + notice "fake fastly (v$pinned) installed in the tool root and on PATH; fake curl on PATH" } main "$@" diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index 56bd5c07..fe693127 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -92,8 +92,13 @@ RS { printf 'token=%s\n' "${FASTLY_API_TOKEN:-MISSING}" printf 'service-id=%s\n' "${FASTLY_SERVICE_ID:-MISSING}" - # Boundary check: an inherited endpoint alias must have been cleared. + # Boundary: inherited provider aliases must have been cleared... printf 'endpoint=%s\n' "${FASTLY_ENDPOINT:-CLEARED}" + printf 'home=%s\n' "${FASTLY_HOME:-CLEARED}" + # ...and the action's own secret-bearing helpers must NOT have survived into + # this process: they carry the raw token under names we never promised. + printf 'action-token-carrier=%s\n' "${EDGEZERO__FASTLY__API_TOKEN:-CLEARED}" + printf 'provider-env-json=%s\n' "${EDGEZERO__PROVIDER__ENV:-CLEARED}" } >"${GITHUB_WORKSPACE}/fixture-app/env-seen.txt" printf '%s\n' "$@" >"${GITHUB_WORKSPACE}/fixture-app/deploy-argv.txt" echo "version=7" diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index bca043a8..7e16de13 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -65,16 +65,16 @@ run_validate_inputs() { local state_dir state_dir=$(mktemp -d "$WORK_DIR/validate.XXXXXX") env -i PATH="$PATH" \ - INPUT_ADAPTER="${VALIDATE_ADAPTER:-fastly}" \ - INPUT_CACHE="${VALIDATE_CACHE:-false}" \ - INPUT_BUILD_MODE="${VALIDATE_BUILD_MODE:-auto}" \ - INPUT_BUILD_ARGS="${VALIDATE_BUILD_ARGS:-[]}" \ - INPUT_DEPLOY_ARGS="${VALIDATE_DEPLOY_ARGS:-[]}" \ - INPUT_DEPLOY_FLAGS="${VALIDATE_DEPLOY_FLAGS:-[]}" \ - INPUT_PROVIDER_ENV_CLEAR="${VALIDATE_PROVIDER_ENV_CLEAR:-[]}" \ - INPUT_DEPLOY_ARG_ALLOW="${VALIDATE_ALLOW:-}" \ - INPUT_STAGE="${VALIDATE_STAGE:-false}" \ - EDGEZERO_ACTION_STATE_DIR="$state_dir" \ + EDGEZERO__INPUT__ADAPTER="${VALIDATE_ADAPTER:-fastly}" \ + EDGEZERO__INPUT__CACHE="${VALIDATE_CACHE:-false}" \ + EDGEZERO__INPUT__BUILD_MODE="${VALIDATE_BUILD_MODE:-auto}" \ + EDGEZERO__INPUT__BUILD_ARGS="${VALIDATE_BUILD_ARGS:-[]}" \ + EDGEZERO__INPUT__DEPLOY_ARGS="${VALIDATE_DEPLOY_ARGS:-[]}" \ + EDGEZERO__INPUT__DEPLOY_FLAGS="${VALIDATE_DEPLOY_FLAGS:-[]}" \ + EDGEZERO__INPUT__PROVIDER_ENV_CLEAR="${VALIDATE_PROVIDER_ENV_CLEAR:-[]}" \ + EDGEZERO__INPUT__DEPLOY_ARG_ALLOW="${VALIDATE_ALLOW:-}" \ + EDGEZERO__INPUT__STAGE="${VALIDATE_STAGE:-false}" \ + EDGEZERO__ACTION__STATE_DIR="$state_dir" \ GITHUB_OUTPUT="$state_dir/output.txt" \ bash "$CORE_SCRIPTS/validate-inputs.sh" } @@ -95,16 +95,16 @@ test_validate_inputs() { } # --------------------------------------------------------------------------- -# build-cli artifact-name — never usable as a path traversal +# build-app-cli artifact-name — never usable as a path traversal # --------------------------------------------------------------------------- check_artifact_name() { - # Run validate_artifact_name from build-cli's common.sh in a subshell. + # Run validate_artifact_name from build-app-cli's common.sh in a subshell. bash -c 'source "$1"; validate_artifact_name "$2"' _ \ - "$ACTIONS_DIR/build-cli/scripts/common.sh" "$1" + "$ACTIONS_DIR/build-app-cli/scripts/common.sh" "$1" } test_artifact_name() { - section "build-cli artifact-name" + section "build-app-cli artifact-name" assert_succeeds "accepts a conservative artifact name" check_artifact_name "edgezero-cli.v1" assert_fails "rejects path traversal ('../x')" check_artifact_name "../x" assert_fails "rejects path separators ('a/b')" check_artifact_name "a/b" @@ -113,15 +113,15 @@ test_artifact_name() { } # --------------------------------------------------------------------------- -# build-cli reset_owned_dir — never rm -rf outside the action-owned temp root +# build-app-cli reset_owned_dir — never rm -rf outside the action-owned temp root # --------------------------------------------------------------------------- check_owned_dir() { bash -c 'source "$1"; reset_owned_dir "$2" "$3"' _ \ - "$ACTIONS_DIR/build-cli/scripts/common.sh" "$1" "$2" + "$ACTIONS_DIR/build-app-cli/scripts/common.sh" "$1" "$2" } test_owned_dir_confinement() { - section "build-cli owned-dir confinement" + section "build-app-cli owned-dir confinement" local temp_root="$WORK_DIR/temproot" mkdir -p "$temp_root" assert_succeeds "recreates a dir beneath the temp root" \ @@ -198,10 +198,10 @@ EOF run_deploy_pe() { env -i PATH="$bin_dir:$PATH" \ - EDGEZERO_CLI_BIN=fakecli EDGEZERO_ADAPTER=fastly \ - EDGEZERO_WORKING_DIRECTORY="$app_dir" \ - DEPLOY_PROVIDER_ENV_CLEAR_FILE="$clear" \ - DEPLOY_PROVIDER_ENV="$1" \ + EDGEZERO__CLI__BIN=fakecli EDGEZERO__ADAPTER=fastly \ + EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" \ + EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$clear" \ + EDGEZERO__PROVIDER__ENV="$1" \ FASTLY_API_TOKEN=inherited-BAD FASTLY_ENDPOINT=https://inherited.invalid \ bash "$CORE_SCRIPTS/run-cli.sh" deploy } @@ -242,11 +242,11 @@ EOF printf -- '--comment\0hello\0' >"$WORK_DIR/deploy-args.nul" if env -i PATH="$bin_dir:$PATH" \ - EDGEZERO_CLI_BIN=fakecli \ - EDGEZERO_ADAPTER=fastly \ - EDGEZERO_WORKING_DIRECTORY="$app_dir" \ - DEPLOY_FLAGS_FILE="$WORK_DIR/deploy-flags.nul" \ - DEPLOY_ARGS_FILE="$WORK_DIR/deploy-args.nul" \ + EDGEZERO__CLI__BIN=fakecli \ + EDGEZERO__ADAPTER=fastly \ + EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" \ + EDGEZERO__DEPLOY__FLAGS_FILE="$WORK_DIR/deploy-flags.nul" \ + EDGEZERO__DEPLOY__ARGS_FILE="$WORK_DIR/deploy-args.nul" \ bash "$CORE_SCRIPTS/run-cli.sh" deploy >/dev/null 2>&1; then local expected expected=$'deploy\n--adapter\nfastly\n--service-id\nabc\n--stage\n--\n--comment\nhello' @@ -279,8 +279,8 @@ EOF local output_file="$WORK_DIR/download-output.txt" if env -i PATH="$PATH" \ - EDGEZERO_CLI_ARTIFACT_DIR="$artifact_dir" \ - EDGEZERO_TOOL_ROOT="$WORK_DIR/tools" \ + EDGEZERO__CLI__ARTIFACT_DIR="$artifact_dir" \ + EDGEZERO__ACTION__TOOL_ROOT="$WORK_DIR/tools" \ GITHUB_OUTPUT="$output_file" \ GITHUB_PATH="$WORK_DIR/download-path.txt" \ bash "$CORE_SCRIPTS/download-cli.sh" >/dev/null 2>&1; then @@ -315,6 +315,191 @@ test_fastly_versions() { assert_succeeds "pinned version matches .tool-versions and sha256 is well-formed" check_fastly_versions } +# --------------------------------------------------------------------------- +# cleanup.sh — it runs `rm -rf`, so confinement is the whole contract +# --------------------------------------------------------------------------- +test_cleanup_confinement() { + section "cleanup confinement" + local temp_root="$WORK_DIR/cleanup-temp" outside="$WORK_DIR/cleanup-outside" + mkdir -p "$temp_root/owned" "$outside/checkout" + + RUNNER_TEMP="$temp_root" EDGEZERO__ACTION__TOOL_ROOT="$temp_root/owned" \ + EDGEZERO__ACTION__STATE_DIR="" "$CORE_SCRIPTS/cleanup.sh" >/dev/null 2>&1 || true + assert_fails "removes an action-owned dir beneath RUNNER_TEMP" test -d "$temp_root/owned" + + # The original defect: cleanup removed $EDGEZERO_FASTLY_HOME, a variable the + # action never set — so its value could only ever be inherited. Any dir handed + # to cleanup from outside the temp root must be refused, not deleted. + RUNNER_TEMP="$temp_root" EDGEZERO__ACTION__TOOL_ROOT="$outside/checkout" \ + EDGEZERO__ACTION__STATE_DIR="" "$CORE_SCRIPTS/cleanup.sh" >/dev/null 2>&1 || true + assert_succeeds "refuses a dir outside RUNNER_TEMP (the checkout survives)" test -d "$outside/checkout" + + # A symlink must not smuggle the removal out of the temp root either. + ln -s "$outside/checkout" "$temp_root/link-out" + RUNNER_TEMP="$temp_root" EDGEZERO__ACTION__TOOL_ROOT="$temp_root/link-out" \ + EDGEZERO__ACTION__STATE_DIR="" "$CORE_SCRIPTS/cleanup.sh" >/dev/null 2>&1 || true + assert_succeeds "refuses a symlink pointing outside RUNNER_TEMP" test -d "$outside/checkout" + + RUNNER_TEMP="" EDGEZERO__ACTION__TOOL_ROOT="$outside/checkout" \ + assert_succeeds "no RUNNER_TEMP: removes nothing" "$CORE_SCRIPTS/cleanup.sh" +} + +# --------------------------------------------------------------------------- +# run-cli.sh — the action's private env must not survive into the app CLI +# --------------------------------------------------------------------------- +test_action_env_scrub() { + section "action-private env scrub" + local dir="$WORK_DIR/scrub" + mkdir -p "$dir/bin" + # A stand-in CLI that reports the environment it was handed. + cat >"$dir/bin/scrub-cli" <<'CLI' +#!/usr/bin/env bash +printf 'FASTLY_API_TOKEN=%s\n' "${FASTLY_API_TOKEN:-ABSENT}" +printf 'EDGEZERO__PROVIDER__ENV=%s\n' "${EDGEZERO__PROVIDER__ENV:-ABSENT}" +printf 'EDGEZERO__FASTLY__API_TOKEN=%s\n' "${EDGEZERO__FASTLY__API_TOKEN:-ABSENT}" +printf 'EDGEZERO__DEPLOY__ARGS_FILE=%s\n' "${EDGEZERO__DEPLOY__ARGS_FILE:-ABSENT}" +printf 'EDGEZERO_MANIFEST=%s\n' "${EDGEZERO_MANIFEST:-ABSENT}" +CLI + chmod +x "$dir/bin/scrub-cli" + printf 'FASTLY_API_TOKEN\0' >"$dir/clear.nul" + + local out + out=$( + PATH="$dir/bin:$PATH" \ + EDGEZERO__CLI__BIN=scrub-cli EDGEZERO__ADAPTER=fastly EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir" \ + EDGEZERO__PROJECT__MANIFEST_PATH="$dir/edgezero.toml" \ + EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ + EDGEZERO__PROVIDER__ENV='{"FASTLY_API_TOKEN":"s3cret"}' \ + EDGEZERO__FASTLY__API_TOKEN='s3cret' \ + "$CORE_SCRIPTS/run-cli.sh" deploy 2>/dev/null + ) + + # What the CLI IS promised. + assert_equals "the typed provider alias is delivered" \ + "FASTLY_API_TOKEN=s3cret" "$(grep '^FASTLY_API_TOKEN=' <<<"$out")" + assert_equals "EDGEZERO_MANIFEST is delivered" \ + "EDGEZERO_MANIFEST=$dir/edgezero.toml" "$(grep '^EDGEZERO_MANIFEST=' <<<"$out")" + + # What it must NEVER see: the same secret under names we never promised. + assert_equals "the provider-env JSON blob does not survive" \ + "EDGEZERO__PROVIDER__ENV=ABSENT" "$(grep '^EDGEZERO__PROVIDER__ENV=' <<<"$out")" + assert_equals "the action's token carrier does not survive" \ + "EDGEZERO__FASTLY__API_TOKEN=ABSENT" "$(grep '^EDGEZERO__FASTLY__API_TOKEN=' <<<"$out")" + assert_equals "action-private file handles do not survive" \ + "EDGEZERO__DEPLOY__ARGS_FILE=ABSENT" "$(grep '^EDGEZERO__DEPLOY__ARGS_FILE=' <<<"$out")" +} + +# --------------------------------------------------------------------------- +# validate-inputs.sh — action-owned passthrough bypasses the caller allowlist +# --------------------------------------------------------------------------- +test_deploy_args_prepend() { + section "action-owned deploy-args prepend" + local state="$WORK_DIR/prepend" + local out args + out=$( + EDGEZERO__ACTION__STATE_DIR="$state" EDGEZERO__INPUT__ADAPTER=fastly \ + EDGEZERO__INPUT__DEPLOY_ARG_ALLOW="--comment" \ + EDGEZERO__INPUT__DEPLOY_ARGS='["--comment","hi"]' \ + EDGEZERO__INPUT__DEPLOY_ARGS_PREPEND='["--non-interactive"]' \ + "$CORE_SCRIPTS/validate-inputs.sh" + ) + args=$(tr '\0' '\n' <"$state/deploy-args.nul") + # `--non-interactive` is action-owned: it is NOT caller input, so it is not + # allowlist-checked, and it must come first. + assert_equals "action-owned args are prepended, caller args preserved" \ + $'--non-interactive\n--comment\nhi' "$args" + [[ -n "$out" ]] || true + + # A caller still cannot smuggle it in themselves. + assert_fails "the caller allowlist still rejects --non-interactive from deploy-args" \ + env EDGEZERO__ACTION__STATE_DIR="$state" EDGEZERO__INPUT__ADAPTER=fastly \ + EDGEZERO__INPUT__DEPLOY_ARG_ALLOW="--comment" \ + EDGEZERO__INPUT__DEPLOY_ARGS='["--non-interactive"]' \ + "$CORE_SCRIPTS/validate-inputs.sh" +} + +# --------------------------------------------------------------------------- +# common.sh — anchored version parsing, required inputs, private logs +# --------------------------------------------------------------------------- +test_lifecycle_helpers() { + section "lifecycle helpers" + # NB: sourced in subshells only — common.sh defines its own `fail`, which would + # otherwise clobber this harness's. + local helpers="source '$CORE_SCRIPTS/common.sh'" + + local log="$WORK_DIR/version.log" + # An UNanchored parser reads `version=15.2.0` as 15 and `version=12abc` as 12, + # threading a version that was never deployed into healthcheck and rollback. + printf 'version=15.2.0\nversion=12abc\n' >"$log" + assert_equals "a malformed version line yields nothing (never a prefix guess)" \ + "" "$(bash -c "$helpers; read_numeric_line version '$log'")" + printf 'noise\nversion=41\nversion=42\n' >"$log" + assert_equals "the last well-formed version line wins" \ + "42" "$(bash -c "$helpers; read_numeric_line version '$log'")" + printf 'healthy=maybe\n' >"$log" + assert_equals "a non-boolean verdict yields nothing" \ + "" "$(bash -c "$helpers; read_bool_line healthy '$log'")" + + # GitHub Actions does not enforce `required: true`, so these are the real guard. + assert_fails "an empty required input is rejected" \ + bash -c "source '$CORE_SCRIPTS/common.sh'; require_input fastly-service-id ''" + assert_fails "a required input that fails its pattern is rejected" \ + bash -c "source '$CORE_SCRIPTS/common.sh'; require_input_matching fastly-version '15.2.0' '^[0-9]+\$'" + assert_succeeds "a well-formed required input is accepted" \ + bash -c "source '$CORE_SCRIPTS/common.sh'; require_input_matching fastly-version '42' '^[0-9]+\$'" + + # Provider CLIs print request URLs and service metadata; the log must not be + # left behind in RUNNER_TEMP for later steps in the job to read. + local leaked + leaked=$( + RUNNER_TEMP="$WORK_DIR" bash -c " + source '$CORE_SCRIPTS/common.sh' + new_private_log + printf '%s\n' \"\$LIFECYCLE_LOG\" + " + ) + assert_fails "the private log is removed when its owner exits" test -e "$leaked" +} + +# --------------------------------------------------------------------------- +# build-app-cli.sh — the toolchain search must not cross the app's Git boundary +# --------------------------------------------------------------------------- +test_toolchain_boundary() { + section "toolchain search boundary" + # The adoption guide's layout: a deployer repo at github.workspace, with the + # application checked out into a subdirectory. The DEPLOYER's .tool-versions + # must never decide which Rust compiles the APPLICATION. + local ws="$WORK_DIR/tc-workspace" + mkdir -p "$ws/app" + printf 'rust 1.60.0\n' >"$ws/.tool-versions" + git -C "$ws/app" init -q 2>/dev/null || return 0 + printf 'rust 1.95.0\n' >"$ws/app/.tool-versions" + + local resolved + resolved=$( + bash -c " + source '$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh' + resolve_rust_toolchain auto '$ws/app' '$ws' '$REPO_ROOT' + " + ) + assert_equals "the app's own .tool-versions wins over the deployer's" "1.95.0" "$resolved" + + # With no toolchain file in the app repo, the search must STOP at the app's + # Git root rather than picking up the deployer's file one level up. + rm -f "$ws/app/.tool-versions" + local fallback + fallback=$( + bash -c " + source '$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh' + resolve_rust_toolchain auto '$ws/app' '$ws' '$REPO_ROOT' + " + ) + local edgezero_rust + edgezero_rust=$(awk '$1 == "rust" { print $2 }' "$REPO_ROOT/.tool-versions") + assert_equals "the search stops at the app's Git root (deployer's 1.60.0 ignored)" \ + "$edgezero_rust" "$fallback" +} + # --------------------------------------------------------------------------- main() { test_validate_inputs @@ -325,6 +510,11 @@ main() { test_provider_env_boundary test_download_cli_metadata test_fastly_versions + test_cleanup_confinement + test_action_env_scrub + test_deploy_args_prepend + test_lifecycle_helpers + test_toolchain_boundary printf '\nPassed: %d Failed: %d\n' "$tests_passed" "$tests_failed" [[ "$tests_failed" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/staged-deploy.sh b/.github/actions/deploy-core/tests/staged-deploy.sh deleted file mode 100755 index 51889399..00000000 --- a/.github/actions/deploy-core/tests/staged-deploy.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Runs a STAGED deploy through the app-owned CLI against the fake `fastly` -# (see make-fake-fastly-env.sh) and asserts the staged version is emitted. -# -# The version parser is fail-closed, so an empty `version=` here means the -# adapter failed to read the version back out of `fastly compute update`. -# -# Inputs (environment): GITHUB_WORKSPACE, CLI_BIN, FASTLY_API_TOKEN, -# FASTLY_SERVICE_ID. - -main() { - local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local cli_bin="${CLI_BIN:?CLI_BIN is required}" - local out - - cd "$workspace/fixture-app" - - out=$("$workspace/cli-bin/$cli_bin" deploy \ - --adapter fastly \ - --service-id dummy-service \ - --stage \ - -- --comment "staged smoke" 2>&1 | tee /dev/stderr) - - if ! printf '%s\n' "$out" | grep -qE '^version=42$'; then - echo "::error::staged deploy did not emit version=42" >&2 - return 1 - fi - echo "staged deploy emitted version=42" -} - -main "$@" diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 7cf71375..5bc11369 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -3,7 +3,7 @@ description: Deploy a checked-out EdgeZero application to Fastly Compute using a inputs: cli-artifact: - description: Name of the build-cli artifact to download and run. + description: Name of the build-app-cli artifact to download and run. required: true cli-bin: description: Binary name inside the artifact. Defaults to the artifact metadata. @@ -62,20 +62,24 @@ runs: id: validate shell: bash env: - INPUT_ADAPTER: fastly - INPUT_BUILD_MODE: ${{ inputs['build-mode'] }} - INPUT_CACHE: ${{ inputs.cache }} - INPUT_BUILD_ARGS: ${{ inputs['build-args'] }} - INPUT_DEPLOY_ARGS: ${{ inputs['deploy-args'] }} - INPUT_DEPLOY_ARG_ALLOW: "--comment" - INPUT_STAGE: ${{ inputs.stage }} - INPUT_DEPLOY_FLAGS: ${{ inputs.stage == 'true' && format('["--service-id","{0}","--stage"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} - INPUT_PROVIDER_ENV_CLEAR: '["FASTLY_API_TOKEN","FASTLY_SERVICE_ID","FASTLY_TOKEN","FASTLY_KEY","FASTLY_API_KEY","FASTLY_AUTH_TOKEN","FASTLY_API_ENDPOINT","FASTLY_ENDPOINT","FASTLY_API_URL","FASTLY_PROFILE","FASTLY_SERVICE_NAME","FASTLY_DEBUG","FASTLY_DEBUG_MODE","FASTLY_CONFIG_FILE","FASTLY_HOME"]' - INPUT_FASTLY_API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} - INPUT_FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} - EDGEZERO_RUNNER_OS: ${{ runner.os }} - EDGEZERO_RUNNER_ARCH: ${{ runner.arch }} - EDGEZERO_ACTION_STATE_DIR: ${{ runner.temp }}/edgezero-deploy-state + EDGEZERO__INPUT__ADAPTER: fastly + EDGEZERO__INPUT__BUILD_MODE: ${{ inputs['build-mode'] }} + EDGEZERO__INPUT__CACHE: ${{ inputs.cache }} + EDGEZERO__INPUT__BUILD_ARGS: ${{ inputs['build-args'] }} + EDGEZERO__INPUT__DEPLOY_ARGS: ${{ inputs['deploy-args'] }} + EDGEZERO__INPUT__DEPLOY_ARG_ALLOW: "--comment" + # Action-owned (not caller input, not allowlist-checked): keeps a + # manifest-command deploy from blocking on a TTY prompt in CI. The + # built-in Fastly deploy path de-duplicates it. + EDGEZERO__INPUT__DEPLOY_ARGS_PREPEND: '["--non-interactive"]' + EDGEZERO__INPUT__STAGE: ${{ inputs.stage }} + EDGEZERO__INPUT__DEPLOY_FLAGS: ${{ inputs.stage == 'true' && format('["--service-id","{0}","--stage"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} + EDGEZERO__INPUT__PROVIDER_ENV_CLEAR: '["FASTLY_API_TOKEN","FASTLY_SERVICE_ID","FASTLY_TOKEN","FASTLY_KEY","FASTLY_API_KEY","FASTLY_AUTH_TOKEN","FASTLY_API_ENDPOINT","FASTLY_ENDPOINT","FASTLY_API_URL","FASTLY_PROFILE","FASTLY_SERVICE_NAME","FASTLY_DEBUG","FASTLY_DEBUG_MODE","FASTLY_CONFIG_FILE","FASTLY_HOME"]' + EDGEZERO__INPUT__FASTLY_API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} + EDGEZERO__INPUT__FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} + EDGEZERO__RUNNER__OS: ${{ runner.os }} + EDGEZERO__RUNNER__ARCH: ${{ runner.arch }} + EDGEZERO__ACTION__STATE_DIR: ${{ runner.temp }}/edgezero-deploy-state FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -88,10 +92,13 @@ runs: FASTLY_PROFILE: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" run: | - [[ "${INPUT_FASTLY_API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } - [[ -n "${INPUT_FASTLY_SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } - [[ "${INPUT_FASTLY_SERVICE_ID}" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "::error::input 'fastly-service-id' must match ^[A-Za-z0-9_-]+$"; exit 1; } + [[ "${EDGEZERO__INPUT__FASTLY_API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } + [[ -n "${EDGEZERO__INPUT__FASTLY_SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } + [[ "${EDGEZERO__INPUT__FASTLY_SERVICE_ID}" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "::error::input 'fastly-service-id' must match ^[A-Za-z0-9_-]+$"; exit 1; } # validate-inputs.sh also rejects a non-boolean 'stage' before any deploy. "$GITHUB_ACTION_PATH/../deploy-core/scripts/validate-inputs.sh" @@ -100,14 +107,30 @@ runs: with: name: ${{ inputs['cli-artifact'] }} path: ${{ runner.temp }}/edgezero-cli-download + env: + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" - name: Extract CLI id: cli shell: bash env: - EDGEZERO_CLI_ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download - EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools - INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + EDGEZERO__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + EDGEZERO__INPUT__CLI_BIN: ${{ inputs['cli-bin'] }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -120,20 +143,23 @@ runs: FASTLY_PROFILE: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh - name: Resolve project id: resolve shell: bash env: - EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. - EDGEZERO_CLI_VERSION: ${{ steps.cli.outputs['cli-version'] }} - INPUT_WORKING_DIRECTORY: ${{ inputs['working-directory'] }} - INPUT_MANIFEST: ${{ inputs.manifest }} - INPUT_RUST_TOOLCHAIN: auto - INPUT_TARGET: wasm32-wasip1 - INPUT_BUILD_MODE: ${{ inputs['build-mode'] }} - INPUT_CACHE: ${{ inputs.cache }} + EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. + EDGEZERO__CLI__VERSION: ${{ steps.cli.outputs['cli-version'] }} + EDGEZERO__INPUT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + EDGEZERO__INPUT__MANIFEST: ${{ inputs.manifest }} + EDGEZERO__INPUT__RUST_TOOLCHAIN: auto + EDGEZERO__INPUT__TARGET: wasm32-wasip1 + EDGEZERO__INPUT__BUILD_MODE: ${{ inputs['build-mode'] }} + EDGEZERO__INPUT__CACHE: ${{ inputs.cache }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -146,6 +172,9 @@ runs: FASTLY_PROFILE: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/resolve-project.sh - name: Restore application target cache @@ -155,6 +184,22 @@ runs: with: key: ${{ steps.resolve.outputs['cache-key'] }} path: ${{ steps.resolve.outputs['cache-path'] }} + env: + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" - name: Install Rust toolchain # Trusted, widely-used action; a readable major-version tag pin is our @@ -167,13 +212,29 @@ runs: # Our resolve-project step owns exact-key target/ caching; disable the # action's own cache to avoid double-caching and key drift. cache: false + env: + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" - name: Install Fastly CLI id: install-fastly shell: bash env: - EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. - VERSIONS_JSON: ${{ github.action_path }}/versions.json + EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. + EDGEZERO__FASTLY__VERSIONS_JSON: ${{ github.action_path }}/versions.json FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -186,18 +247,21 @@ runs: FASTLY_PROFILE: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" run: $GITHUB_ACTION_PATH/scripts/install-fastly.sh - name: Build (validation) if: ${{ steps.resolve.outputs['effective-build-mode'] == 'always' }} shell: bash env: - EDGEZERO_CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} - EDGEZERO_ADAPTER: fastly - EDGEZERO_WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} - EDGEZERO_MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} - DEPLOY_BUILD_ARGS_FILE: ${{ steps.validate.outputs['build-args-file'] }} - DEPLOY_PROVIDER_ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} + EDGEZERO__CLI__BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO__ADAPTER: fastly + EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} + EDGEZERO__PROJECT__MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} + EDGEZERO__BUILD__ARGS_FILE: ${{ steps.validate.outputs['build-args-file'] }} + EDGEZERO__PROVIDER__ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -210,37 +274,28 @@ runs: FASTLY_PROFILE: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh build - name: Deploy id: deploy shell: bash env: - EDGEZERO_CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} - EDGEZERO_ADAPTER: fastly - EDGEZERO_WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} - EDGEZERO_MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} - DEPLOY_FLAGS_FILE: ${{ steps.validate.outputs['deploy-flags-file'] }} - DEPLOY_ARGS_FILE: ${{ steps.validate.outputs['deploy-args-file'] }} + EDGEZERO__CLI__BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO__ADAPTER: fastly + EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} + EDGEZERO__PROJECT__MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} + EDGEZERO__DEPLOY__FLAGS_FILE: ${{ steps.validate.outputs['deploy-flags-file'] }} + EDGEZERO__DEPLOY__ARGS_FILE: ${{ steps.validate.outputs['deploy-args-file'] }} # Credential boundary: pass the typed values as data (not as FASTLY_* # aliases). run-cli.sh clears every provider-env-clear alias — including # any inherited FASTLY_ENDPOINT/FASTLY_TOKEN — and then exports only these. - DEPLOY_PROVIDER_ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} - EDGEZERO_FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} - EDGEZERO_FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} - run: | - set -o pipefail - command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } - DEPLOY_PROVIDER_ENV=$(jq -n \ - --arg t "$EDGEZERO_FASTLY_API_TOKEN" \ - --arg s "$EDGEZERO_FASTLY_SERVICE_ID" \ - '{FASTLY_API_TOKEN: $t, FASTLY_SERVICE_ID: $s}') - export DEPLOY_PROVIDER_ENV - log="${RUNNER_TEMP:-/tmp}/edgezero-deploy.log" - "$GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh" deploy 2>&1 | tee "$log" - version=$(grep -oE '^version=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) - [[ -n "$version" ]] || { echo "::error::deploy reported success but emitted no fastly-version"; exit 1; } - echo "fastly-version=${version}" >> "$GITHUB_OUTPUT" + EDGEZERO__PROVIDER__ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} + EDGEZERO__FASTLY__API_TOKEN: ${{ inputs['fastly-api-token'] }} + EDGEZERO__FASTLY__SERVICE_ID: ${{ inputs['fastly-service-id'] }} + run: $GITHUB_ACTION_PATH/scripts/deploy.sh - name: Save application target cache if: ${{ inputs.cache == 'true' && steps.cache-restore.outputs['cache-hit'] != 'true' }} @@ -248,21 +303,37 @@ runs: with: key: ${{ steps.resolve.outputs['cache-key'] }} path: ${{ steps.resolve.outputs['cache-path'] }} + env: + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" - name: Write summary if: ${{ always() }} shell: bash env: - SUMMARY_ADAPTER: fastly - SUMMARY_WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory-relative'] }} - SUMMARY_SOURCE_REVISION: ${{ steps.resolve.outputs['source-revision'] }} - SUMMARY_MANIFEST: ${{ steps.resolve.outputs['manifest-summary'] }} - SUMMARY_RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} - SUMMARY_TARGET: wasm32-wasip1 - SUMMARY_CLI_VERSION: ${{ steps.cli.outputs['cli-version'] }} - SUMMARY_EFFECTIVE_BUILD_MODE: ${{ steps.resolve.outputs['effective-build-mode'] }} - SUMMARY_CACHE: ${{ inputs.cache }} - SUMMARY_RESULT: ${{ steps.deploy.outcome }} + EDGEZERO__SUMMARY__ADAPTER: fastly + EDGEZERO__SUMMARY__WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory-relative'] }} + EDGEZERO__SUMMARY__SOURCE_REVISION: ${{ steps.resolve.outputs['source-revision'] }} + EDGEZERO__SUMMARY__MANIFEST: ${{ steps.resolve.outputs['manifest-summary'] }} + EDGEZERO__SUMMARY__RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} + EDGEZERO__SUMMARY__TARGET: wasm32-wasip1 + EDGEZERO__SUMMARY__CLI_VERSION: ${{ steps.cli.outputs['cli-version'] }} + EDGEZERO__SUMMARY__EFFECTIVE_BUILD_MODE: ${{ steps.resolve.outputs['effective-build-mode'] }} + EDGEZERO__SUMMARY__CACHE: ${{ inputs.cache }} + EDGEZERO__SUMMARY__RESULT: ${{ steps.deploy.outcome }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -275,14 +346,17 @@ runs: FASTLY_PROFILE: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/write-summary.sh - name: Cleanup if: ${{ always() }} shell: bash env: - EDGEZERO_ACTION_STATE_DIR: ${{ runner.temp }}/edgezero-deploy-state - EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + EDGEZERO__ACTION__STATE_DIR: ${{ runner.temp }}/edgezero-deploy-state + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -295,4 +369,7 @@ runs: FASTLY_PROFILE: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/deploy-fastly/scripts/deploy.sh b/.github/actions/deploy-fastly/scripts/deploy.sh new file mode 100755 index 00000000..ed014cad --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/deploy.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the application CLI's deploy through the provider-env credential boundary +# and emits the resulting Fastly version. +# +# Credentials are handed to run-cli.sh as DATA (a JSON object), not as FASTLY_* +# aliases on this step. run-cli.sh clears every declared alias — including any +# inherited FASTLY_ENDPOINT / FASTLY_TOKEN — exports only these typed values, and +# then scrubs its own private variables (including this JSON) before exec'ing the +# CLI. Building the JSON here, from step `env:`, is also what keeps the secret out +# of an interpolated `run:` block. +# +# Inputs (environment): EDGEZERO__FASTLY__API_TOKEN, +# EDGEZERO__FASTLY__SERVICE_ID, plus the run-cli.sh contract. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +main() { + local token="${EDGEZERO__FASTLY__API_TOKEN:-}" + local service_id="${EDGEZERO__FASTLY__SERVICE_ID:-}" + + require_input fastly-api-token "$token" + require_input_matching fastly-service-id "$service_id" '^[A-Za-z0-9_-]+$' + require_cmd jq + + EDGEZERO__PROVIDER__ENV=$(jq -n --arg t "$token" --arg s "$service_id" \ + '{FASTLY_API_TOKEN: $t, FASTLY_SERVICE_ID: $s}') + export EDGEZERO__PROVIDER__ENV + + new_private_log + "$SCRIPT_DIR/../../deploy-core/scripts/run-cli.sh" deploy 2>&1 | tee "$LIFECYCLE_LOG" + + local version + version=$(read_numeric_line version "$LIFECYCLE_LOG") + [[ -n "$version" ]] || + fail "deploy reported success but emitted no canonical 'version=' line, so there is no version to thread into healthcheck or rollback" + + append_output fastly-version "$version" +} + +main "$@" diff --git a/.github/actions/deploy-fastly/scripts/install-fastly.sh b/.github/actions/deploy-fastly/scripts/install-fastly.sh index 9e6e9ac8..27512b82 100755 --- a/.github/actions/deploy-fastly/scripts/install-fastly.sh +++ b/.github/actions/deploy-fastly/scripts/install-fastly.sh @@ -6,8 +6,8 @@ set -euo pipefail # provider-tool responsibility; the provider-neutral engine never installs it. # # Inputs (environment): -# EDGEZERO_ACTION_ROOT optional repo root holding .tool-versions (defaults up) -# VERSIONS_JSON optional pinned metadata (defaults alongside this dir) +# EDGEZERO__ACTION__ROOT optional repo root holding .tool-versions (defaults up) +# EDGEZERO__FASTLY__VERSIONS_JSON optional pinned metadata (defaults alongside this dir) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -20,12 +20,27 @@ require_linux_x86_64() { esac } +# Whether the action-owned tool root already holds an executable `fastly` +# reporting the pinned version. +tool_root_has_pinned_fastly() { + local tool_root="$1" version="$2" + local bin="$tool_root/bin/fastly" + + [[ -x "$bin" ]] || return 1 + local reported + reported=$("$bin" version 2>/dev/null | head -n 1) || return 1 + [[ "$reported" == *"$version"* ]] || return 1 + + notice "reusing the already-installed Fastly CLI: $reported" + return 0 +} + main() { local action_dir action_dir=$(cd -- "$SCRIPT_DIR/.." && pwd) - local action_root="${EDGEZERO_ACTION_ROOT:-$(cd -- "$action_dir/../../.." && pwd)}" - local versions_json="${VERSIONS_JSON:-$action_dir/versions.json}" - local tool_root="${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" + local action_root="${EDGEZERO__ACTION__ROOT:-$(cd -- "$action_dir/../../.." && pwd)}" + local versions_json="${EDGEZERO__FASTLY__VERSIONS_JSON:-$action_dir/versions.json}" + local tool_root="${EDGEZERO__ACTION__TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" require_linux_x86_64 require_cmd jq @@ -33,24 +48,34 @@ main() { mkdir -p "$tool_root/bin" "$tool_root/downloads" # The pinned version must agree with the repository .tool-versions policy. - local version tool_version url sha256 archive + local version tool_version version=$(json_get "$versions_json" fastly.version) tool_version=$(read_tool_version "$action_root/.tool-versions" fastly || true) [[ -n "$tool_version" ]] || fail "EdgeZero repository .tool-versions must contain a fastly entry" [[ "$version" == "$tool_version" ]] || fail "Fastly version mismatch: versions.json has $version but .tool-versions has $tool_version" - url=$(json_get "$versions_json" fastly.linux_amd64.url) - sha256=$(json_get "$versions_json" fastly.linux_amd64.sha256) - archive="$tool_root/downloads/fastly-$version-linux-amd64.tar.gz" + # Idempotent: if the action-owned tool root already holds a `fastly` reporting + # the pinned version, adopt it instead of downloading again. Running the + # wrapper twice in one job should not refetch a binary we already verified. + # The scope is deliberately narrow — only this dir, which the action creates + # under RUNNER_TEMP, populates, executes from, and deletes on cleanup. A + # `fastly` found merely on PATH is NOT trusted and is always superseded. + if ! tool_root_has_pinned_fastly "$tool_root" "$version"; then + local url sha256 archive + url=$(json_get "$versions_json" fastly.linux_amd64.url) + sha256=$(json_get "$versions_json" fastly.linux_amd64.sha256) + archive="$tool_root/downloads/fastly-$version-linux-amd64.tar.gz" + + [[ -f "$archive" ]] || curl --fail --location --silent --show-error "$url" --output "$archive" - [[ -f "$archive" ]] || curl --fail --location --silent --show-error "$url" --output "$archive" + local actual + actual=$(sha256_file "$archive") + [[ "$actual" == "$sha256" ]] || fail "Fastly CLI checksum mismatch for version $version" - local actual - actual=$(sha256_file "$archive") - [[ "$actual" == "$sha256" ]] || fail "Fastly CLI checksum mismatch for version $version" + tar -xzf "$archive" -C "$tool_root/bin" fastly + chmod +x "$tool_root/bin/fastly" + fi - tar -xzf "$archive" -C "$tool_root/bin" fastly - chmod +x "$tool_root/bin/fastly" printf '%s\n' "$tool_root/bin" >>"${GITHUB_PATH:-/dev/null}" export PATH="$tool_root/bin:$PATH" diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index e4558b5c..afe01538 100644 --- a/.github/actions/healthcheck-fastly/action.yml +++ b/.github/actions/healthcheck-fastly/action.yml @@ -3,7 +3,7 @@ description: Probe a deployed Fastly version's health via the app CLI. Exits non inputs: cli-artifact: - description: Name of the build-cli artifact to download and run. + description: Name of the build-app-cli artifact to download and run. required: true cli-bin: description: Binary name inside the artifact. Defaults to the artifact metadata. @@ -54,29 +54,59 @@ runs: with: name: ${{ inputs['cli-artifact'] }} path: ${{ runner.temp }}/edgezero-cli-download + env: + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" - name: Extract CLI id: cli shell: bash env: - EDGEZERO_CLI_ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download - EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools - INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + EDGEZERO__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + EDGEZERO__INPUT__CLI_BIN: ${{ inputs['cli-bin'] }} FASTLY_API_TOKEN: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_ENDPOINT: "" + FASTLY_API_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_ID: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh - name: Health check id: check shell: bash env: - CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} - SERVICE_ID: ${{ inputs['fastly-service-id'] }} - VERSION: ${{ inputs['fastly-version'] }} - DOMAIN: ${{ inputs.domain }} - DEPLOY_TO: ${{ inputs['deploy-to'] }} - RETRY: ${{ inputs.retry }} - RETRY_DELAY: ${{ inputs['retry-delay'] }} - TIMEOUT: ${{ inputs.timeout }} + EDGEZERO__CLI__BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO__LIFECYCLE__SERVICE_ID: ${{ inputs['fastly-service-id'] }} + EDGEZERO__LIFECYCLE__VERSION: ${{ inputs['fastly-version'] }} + EDGEZERO__LIFECYCLE__DOMAIN: ${{ inputs.domain }} + EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} + EDGEZERO__LIFECYCLE__RETRY: ${{ inputs.retry }} + EDGEZERO__LIFECYCLE__RETRY_DELAY: ${{ inputs['retry-delay'] }} + EDGEZERO__LIFECYCLE__TIMEOUT: ${{ inputs.timeout }} # Only the typed token reaches the CLI; blank any inherited alias. FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} FASTLY_TOKEN: "" @@ -87,26 +117,17 @@ runs: FASTLY_API_ENDPOINT: "" FASTLY_API_URL: "" FASTLY_PROFILE: "" - run: | - # A typo in deploy-to must never silently probe production. - case "$DEPLOY_TO" in production | staging) ;; *) echo "::error::input 'deploy-to' must be 'production' or 'staging' (got '$DEPLOY_TO')"; exit 1 ;; esac - log="${RUNNER_TEMP:-/tmp}/edgezero-healthcheck.log" - args=("$CLI_BIN" healthcheck --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION" --domain "$DOMAIN" --retry "$RETRY" --retry-delay "$RETRY_DELAY" --timeout "$TIMEOUT") - if [[ "$DEPLOY_TO" == "staging" ]]; then args+=(--staging); fi - rc=0 - "${args[@]}" 2>&1 | tee "$log" || rc=$? - healthy=$(grep -oE '^healthy=(true|false)' "$log" | tail -n 1 | cut -d= -f2 || true) - status=$(grep -oE '^status-code=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) - echo "healthy=${healthy:-false}" >> "$GITHUB_OUTPUT" - echo "status-code=${status}" >> "$GITHUB_OUTPUT" - # Fail closed. A zero-exit CLI that reports healthy=false, or emits no - # verdict at all, must NOT let this action succeed — callers gate their - # rollback on this step failing. - if [[ "$rc" -ne 0 ]]; then - echo "::error::health check failed (CLI exit $rc, healthy=${healthy:-}, status=${status:-})" - exit "$rc" - fi - if [[ "$healthy" != "true" ]]; then - echo "::error::health check did not report healthy=true (got '${healthy:-}')" - exit 1 - fi + FASTLY_SERVICE_ID: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/scripts/healthcheck.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh new file mode 100755 index 00000000..cc48254d --- /dev/null +++ b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Probes a deployed Fastly version through the application CLI and fails closed. +# +# Callers gate their rollback on this action FAILING. So every path that cannot +# prove the deployment is healthy must exit non-zero: a non-zero CLI, a +# `healthy=false` verdict, and — critically — no verdict at all. +# +# Inputs (environment): EDGEZERO__CLI__BIN, EDGEZERO__LIFECYCLE__SERVICE_ID, EDGEZERO__LIFECYCLE__VERSION, EDGEZERO__LIFECYCLE__DOMAIN, EDGEZERO__DEPLOY__TO, EDGEZERO__LIFECYCLE__RETRY, +# EDGEZERO__LIFECYCLE__RETRY_DELAY, EDGEZERO__LIFECYCLE__TIMEOUT, FASTLY_API_TOKEN. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +validate_inputs() { + require_linux_x86_64 + # `required: true` in action metadata does not fail an omitted input, so the + # only real guard against probing with an empty service/version is this one. + require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9_-]+$' + require_input_matching fastly-version "${EDGEZERO__LIFECYCLE__VERSION:-}" '^[0-9]+$' + require_input_matching domain "${EDGEZERO__LIFECYCLE__DOMAIN:-}" '^[A-Za-z0-9._-]+$' + require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + require_input_matching retry "${EDGEZERO__LIFECYCLE__RETRY:-}" '^[0-9]+$' + require_input_matching retry-delay "${EDGEZERO__LIFECYCLE__RETRY_DELAY:-}" '^[0-9]+$' + require_input_matching timeout "${EDGEZERO__LIFECYCLE__TIMEOUT:-}" '^[0-9]+$' + # A typo in deploy-to must never silently probe production. + case "${EDGEZERO__DEPLOY__TO:-}" in + production | staging) ;; + *) fail "input 'deploy-to' must be 'production' or 'staging' (got '${EDGEZERO__DEPLOY__TO:-}')" ;; + esac +} + +main() { + validate_inputs + + local argv=( + "$EDGEZERO__CLI__BIN" healthcheck + --adapter fastly + --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" + --version "$EDGEZERO__LIFECYCLE__VERSION" + --domain "$EDGEZERO__LIFECYCLE__DOMAIN" + --retry "$EDGEZERO__LIFECYCLE__RETRY" + --retry-delay "$EDGEZERO__LIFECYCLE__RETRY_DELAY" + --timeout "$EDGEZERO__LIFECYCLE__TIMEOUT" + ) + if [[ "$EDGEZERO__DEPLOY__TO" == "staging" ]]; then + argv+=(--staging) + fi + + new_private_log + local rc=0 + "${argv[@]}" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + + local healthy status + healthy=$(read_bool_line healthy "$LIFECYCLE_LOG") + status=$(read_numeric_line status-code "$LIFECYCLE_LOG") + append_output healthy "${healthy:-false}" + append_output status-code "$status" + + if [[ "$rc" -ne 0 ]]; then + fail "health check failed (CLI exit $rc, healthy=${healthy:-}, status=${status:-})" + fi + if [[ "$healthy" != "true" ]]; then + fail "health check did not report healthy=true (got '${healthy:-}')" + fi +} + +main "$@" diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml index 1da1faa6..d3bc6e17 100644 --- a/.github/actions/rollback-fastly/action.yml +++ b/.github/actions/rollback-fastly/action.yml @@ -3,7 +3,7 @@ description: Roll back a Fastly deployment via the app CLI. Production activates inputs: cli-artifact: - description: Name of the build-cli artifact to download and run. + description: Name of the build-app-cli artifact to download and run. required: true cli-bin: description: Binary name inside the artifact. Defaults to the artifact metadata. @@ -36,25 +36,55 @@ runs: with: name: ${{ inputs['cli-artifact'] }} path: ${{ runner.temp }}/edgezero-cli-download + env: + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" - name: Extract CLI id: cli shell: bash env: - EDGEZERO_CLI_ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download - EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools - INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + EDGEZERO__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + EDGEZERO__INPUT__CLI_BIN: ${{ inputs['cli-bin'] }} FASTLY_API_TOKEN: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_ENDPOINT: "" + FASTLY_API_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_ID: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh - name: Rollback id: rollback shell: bash env: - CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} - SERVICE_ID: ${{ inputs['fastly-service-id'] }} - VERSION: ${{ inputs['fastly-version'] }} - DEPLOY_TO: ${{ inputs['deploy-to'] }} + EDGEZERO__CLI__BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO__LIFECYCLE__SERVICE_ID: ${{ inputs['fastly-service-id'] }} + EDGEZERO__LIFECYCLE__VERSION: ${{ inputs['fastly-version'] }} + EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} # Only the typed token reaches the CLI; blank any inherited alias. FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} FASTLY_TOKEN: "" @@ -65,23 +95,17 @@ runs: FASTLY_API_ENDPOINT: "" FASTLY_API_URL: "" FASTLY_PROFILE: "" - run: | - # A typo in deploy-to must never silently roll back production. - case "$DEPLOY_TO" in production | staging) ;; *) echo "::error::input 'deploy-to' must be 'production' or 'staging' (got '$DEPLOY_TO')"; exit 1 ;; esac - log="${RUNNER_TEMP:-/tmp}/edgezero-rollback.log" - args=("$CLI_BIN" rollback --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION") - if [[ "$DEPLOY_TO" == "staging" ]]; then args+=(--staging); fi - rc=0 - "${args[@]}" 2>&1 | tee "$log" || rc=$? - rolled=$(grep -oE '^rolled-back-to=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) - echo "rolled-back-to=${rolled}" >> "$GITHUB_OUTPUT" - # Fail closed: a rollback that did not report what it activated has not - # provably rolled anything back. - if [[ "$rc" -ne 0 ]]; then - echo "::error::rollback failed (CLI exit $rc)" - exit "$rc" - fi - if [[ "$DEPLOY_TO" == "production" && -z "$rolled" ]]; then - echo "::error::production rollback reported success but did not emit rolled-back-to" - exit 1 - fi + FASTLY_SERVICE_ID: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/scripts/rollback.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/rollback-fastly/scripts/rollback.sh b/.github/actions/rollback-fastly/scripts/rollback.sh new file mode 100755 index 00000000..196265f9 --- /dev/null +++ b/.github/actions/rollback-fastly/scripts/rollback.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Rolls a Fastly deployment back through the application CLI. +# +# Production activates the previous version; staging deactivates the staged one. +# Fails closed: a rollback that cannot say what it activated has not provably +# rolled anything back. +# +# Inputs (environment): EDGEZERO__CLI__BIN, EDGEZERO__LIFECYCLE__SERVICE_ID, EDGEZERO__LIFECYCLE__VERSION, EDGEZERO__DEPLOY__TO, +# FASTLY_API_TOKEN. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +validate_inputs() { + require_linux_x86_64 + require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9_-]+$' + require_input_matching fastly-version "${EDGEZERO__LIFECYCLE__VERSION:-}" '^[0-9]+$' + require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + # A typo in deploy-to must never silently roll back production. + case "${EDGEZERO__DEPLOY__TO:-}" in + production | staging) ;; + *) fail "input 'deploy-to' must be 'production' or 'staging' (got '${EDGEZERO__DEPLOY__TO:-}')" ;; + esac +} + +main() { + validate_inputs + + local argv=("$EDGEZERO__CLI__BIN" rollback --adapter fastly --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" --version "$EDGEZERO__LIFECYCLE__VERSION") + if [[ "$EDGEZERO__DEPLOY__TO" == "staging" ]]; then + argv+=(--staging) + fi + + new_private_log + local rc=0 + "${argv[@]}" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + + local rolled + rolled=$(read_numeric_line rolled-back-to "$LIFECYCLE_LOG") + append_output rolled-back-to "$rolled" + + if [[ "$rc" -ne 0 ]]; then + fail "rollback failed (CLI exit $rc)" + fi + if [[ "$EDGEZERO__DEPLOY__TO" == "production" && -z "$rolled" ]]; then + fail "production rollback reported success but did not emit rolled-back-to" + fi +} + +main "$@" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 7ab91906..c280a51b 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -3,7 +3,7 @@ name: Deploy actions on: pull_request: paths: - - .github/actions/build-cli/** + - .github/actions/build-app-cli/** - .github/actions/deploy-core/** - .github/actions/deploy-fastly/** - .github/actions/healthcheck-fastly/** @@ -17,7 +17,7 @@ on: push: branches: [main] paths: - - .github/actions/build-cli/** + - .github/actions/build-app-cli/** - .github/actions/deploy-core/** - .github/actions/deploy-fastly/** - .github/actions/healthcheck-fastly/** @@ -70,7 +70,7 @@ jobs: run: | zizmor --offline \ .github/workflows/deploy-action.yml \ - .github/actions/build-cli/action.yml \ + .github/actions/build-app-cli/action.yml \ .github/actions/deploy-fastly/action.yml \ .github/actions/healthcheck-fastly/action.yml \ .github/actions/rollback-fastly/action.yml @@ -86,7 +86,7 @@ jobs: # not a real defect. Everything else is checked. run: | shellcheck -e SC1091 \ - .github/actions/build-cli/scripts/*.sh \ + .github/actions/build-app-cli/scripts/*.sh \ .github/actions/deploy-core/scripts/*.sh \ .github/actions/deploy-core/tests/*.sh \ .github/actions/deploy-fastly/scripts/*.sh @@ -108,22 +108,23 @@ jobs: # and readable outside the YAML. composite-smoke: runs-on: ubuntu-24.04 - # An inherited provider alias the deploy MUST clear (provider-env boundary). + # Inherited provider aliases the deploy MUST clear (provider-env boundary). env: FASTLY_ENDPOINT: https://inherited.invalid + FASTLY_HOME: /nonexistent/inherited steps: - uses: actions/checkout@v4 with: persist-credentials: false # The fixture is a REAL app-owned CLI (its own crate depending on - # edgezero-cli) — the contract build-cli actually promises. + # edgezero-cli) — the contract build-app-cli actually promises. - name: Create fixture app (app-owned CLI) run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - name: Build the APP's own CLI package id: cli - uses: ./.github/actions/build-cli + uses: ./.github/actions/build-app-cli with: cli-package: fixture-app-cli working-directory: fixture-app @@ -141,15 +142,24 @@ jobs: - name: Assert production deploy, version threading, and credential boundary env: - FASTLY_VERSION_OUT: ${{ steps.deploy.outputs['fastly-version'] }} + EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs['fastly-version'] }} run: .github/actions/deploy-core/tests/assert-production-deploy.sh - # Staging path: the full lifecycle (stage -> healthcheck -> rollback) against - # fake `fastly`/`curl` binaries that mirror the real contracts. Because the - # bugs a review found were argv/verb bugs, the assertions check argv and verbs - # — see the assert-*.sh scripts for what each one regression-tests. + # Staging path: the full lifecycle through the REAL wrappers — deploy-fastly + # with `stage: true`, then healthcheck-fastly, then rollback-fastly — against + # fake `fastly`/`curl` binaries that mirror the real contracts. + # + # The version is never hard-coded: it is threaded out of the deploy action's + # `fastly-version` output and into the two lifecycle actions, which is the + # contract an operator's workflow depends on. Because the defects a review + # found were argv/verb defects, the assertions check argv and verbs — see the + # assert-*.sh scripts for what each one regression-tests. lifecycle-smoke: runs-on: ubuntu-24.04 + # Inherited provider aliases that every step MUST clear. + env: + FASTLY_ENDPOINT: https://inherited.invalid + FASTLY_HOME: /nonexistent/inherited steps: - uses: actions/checkout@v4 with: @@ -160,33 +170,31 @@ jobs: - name: Build the APP's own CLI package id: cli - uses: ./.github/actions/build-cli + uses: ./.github/actions/build-app-cli with: cli-package: fixture-app-cli working-directory: fixture-app + # Seeds the action-owned tool root with a fake `fastly` reporting the + # pinned version, so install-fastly.sh adopts it and the staged path runs + # through the real wrapper without contacting Fastly. - name: Install fake fastly + curl run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - name: Download the app CLI artifact - uses: actions/download-artifact@v4 + - name: Staged deploy through the deploy-fastly wrapper + id: stage + uses: ./.github/actions/deploy-fastly with: - name: ${{ steps.cli.outputs.artifact-name }} - path: cli-dl - - - name: Extract the app CLI - env: - CLI_BIN: ${{ steps.cli.outputs.cli-bin }} - run: .github/actions/deploy-core/tests/extract-cli.sh - - - name: Staged deploy - env: - CLI_BIN: ${{ steps.cli.outputs.cli-bin }} - FASTLY_API_TOKEN: dummy-token - FASTLY_SERVICE_ID: dummy-service - run: .github/actions/deploy-core/tests/staged-deploy.sh + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + working-directory: fixture-app + fastly-api-token: dummy-token + fastly-service-id: dummy-service + deploy-args: '["--comment","staged smoke"]' + stage: true - name: Assert the staged Fastly call sequence + env: + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs['fastly-version'] }} run: .github/actions/deploy-core/tests/assert-staged-calls.sh - name: Health check the staged version @@ -195,13 +203,15 @@ jobs: cli-artifact: ${{ steps.cli.outputs.artifact-name }} deploy-to: staging domain: staging.example.com - fastly-version: "42" + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token fastly-service-id: dummy-service retry: "1" retry-delay: "1" - name: Assert the staging IP was resolved and probed + env: + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs['fastly-version'] }} run: .github/actions/deploy-core/tests/assert-staging-probe.sh - name: Health check must FAIL when the probe is unhealthy @@ -212,19 +222,19 @@ jobs: cli-artifact: ${{ steps.cli.outputs.artifact-name }} deploy-to: staging domain: staging.example.com - fastly-version: "42" + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token fastly-service-id: dummy-service retry: "1" retry-delay: "1" env: # Flips the fake probe to 503 for this step only. - FORCE_UNHEALTHY: "1" + EDGEZERO__TEST__FORCE_UNHEALTHY: "1" - name: Assert the unhealthy check failed the wrapper env: - OUTCOME: ${{ steps.unhealthy.outcome }} - HEALTHY: ${{ steps.unhealthy.outputs.healthy }} + EDGEZERO__TEST__OUTCOME: ${{ steps.unhealthy.outcome }} + EDGEZERO__TEST__HEALTHY: ${{ steps.unhealthy.outputs.healthy }} run: .github/actions/deploy-core/tests/assert-unhealthy-failed.sh - name: Roll back the staged version @@ -232,7 +242,7 @@ jobs: with: cli-artifact: ${{ steps.cli.outputs.artifact-name }} deploy-to: staging - fastly-version: "42" + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token fastly-service-id: dummy-service @@ -242,11 +252,12 @@ jobs: with: cli-artifact: ${{ steps.cli.outputs.artifact-name }} deploy-to: production - fastly-version: "42" + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token fastly-service-id: dummy-service - name: Assert rollback verbs, paths, and version threading env: - ROLLED_BACK_TO: ${{ steps.prod-rollback.outputs['rolled-back-to'] }} + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs['fastly-version'] }} + EDGEZERO__TEST__ROLLED_BACK_TO: ${{ steps.prod-rollback.outputs['rolled-back-to'] }} run: .github/actions/deploy-core/tests/assert-rollback-calls.sh diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index f3d608ef..68946fbe 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1345,17 +1345,21 @@ fn build_compute_deploy_args(extra_args: &[String]) -> Vec { /// # Errors /// Returns an error if the Fastly CLI deploy command fails. +/// +/// Honours a CLI-threaded `--manifest-path ` (see +/// [`resolve_manifest_dir`]) so a monorepo with several Fastly apps +/// deploys the one the operator's `edgezero.toml` selected, rather than +/// whichever `fastly.toml` a bare working-directory search finds first. +/// The flag is EdgeZero-internal — `fastly compute deploy` has no such +/// flag — so it is stripped from the forwarded argv. #[inline] pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + let manifest_dir = resolve_manifest_dir(extra_args)?; + let forwarded = args_without_flag_value(extra_args, "--manifest-path"); let status = Command::new("fastly") - .args(build_compute_deploy_args(extra_args)) - .current_dir(manifest_dir) + .args(build_compute_deploy_args(&forwarded)) + .current_dir(&manifest_dir) .status() .map_err(|err| format!("failed to run fastly CLI: {err}"))?; if !status.success() { @@ -1998,20 +2002,19 @@ fn fastly_api_put(path: &str, token: &str) -> Result { } } -/// Resolve the directory containing the Fastly manifest for a staged -/// deploy. +/// Resolve the directory containing the Fastly manifest for a deploy +/// (production [`deploy`] or [`deploy_staged`]). /// /// The CLI (`edgezero_cli::run_deploy`) resolves the `edgezero.toml` /// manifest — honouring `EDGEZERO_MANIFEST` — and threads the /// manifest-configured `[adapters.fastly.adapter].manifest` path in as /// `--manifest-path `. Prefer that so a monorepo with -/// multiple Fastly apps stages the app the operator actually selected, -/// rather than whichever `fastly.toml` a bare working-directory search -/// happens to find first. Only when no `--manifest-path` is threaded -/// (e.g. a manifest that declares Fastly commands but no adapter -/// `manifest` key) do we fall back to the working-directory search the -/// legacy `deploy` path used. -fn resolve_staged_manifest_dir(args: &[String]) -> Result { +/// multiple Fastly apps deploys/stages the app the operator actually +/// selected, rather than whichever `fastly.toml` a bare working-directory +/// search happens to find first. Only when no `--manifest-path` is +/// threaded (e.g. a manifest that declares Fastly commands but no adapter +/// `manifest` key) do we fall back to the working-directory search. +fn resolve_manifest_dir(args: &[String]) -> Result { if let Some(raw) = arg_value(args, "--manifest-path") { let path = PathBuf::from(raw); return path @@ -2039,7 +2042,7 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { // `fastly compute update` error. require_token()?; - let manifest_dir_buf = resolve_staged_manifest_dir(args)?; + let manifest_dir_buf = resolve_manifest_dir(args)?; let manifest_dir = manifest_dir_buf.as_path(); // Strip both the explicitly-threaded `--service-id` and the // CLI-injected `--manifest-path` (which `fastly compute update` @@ -2151,10 +2154,22 @@ fn emit_active_version(args: &[String]) -> Result<(), String> { /// (production) or the version's staging IP (`--staging`), retrying up /// to `--retry` times. Emits `status-code` / `healthy` and returns /// `Err` (non-zero exit) when unhealthy after retries. +/// +/// `--domain`, `--service-id` and `--version` are REQUIRED and validated +/// on BOTH the production and the staging path. GitHub Actions' `required: +/// true` does not actually fail a workflow when an input is omitted or +/// empty, so this is the real guard: a production healthcheck must never +/// probe on behalf of an absent/empty version it never verified — the +/// caller chains that same version into rollback. fn healthcheck(args: &[String]) -> Result<(), String> { let domain = arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; validate_domain(domain)?; + let service_id = resolve_service_id(args)?; + validate_service_id(&service_id)?; + let version_str = + arg_value(args, "--version").ok_or_else(|| "healthcheck requires --version".to_owned())?; + let version = validate_version_str(version_str)?; let retry = arg_value(args, "--retry") .and_then(|value| value.parse().ok()) .unwrap_or(3_u32); @@ -2166,11 +2181,6 @@ fn healthcheck(args: &[String]) -> Result<(), String> { .unwrap_or(10_u64); let staging_ip = if arg_flag(args, "--staging") { - let service_id = resolve_service_id(args)?; - validate_service_id(&service_id)?; - let version_str = arg_value(args, "--version") - .ok_or_else(|| "staging healthcheck requires --version".to_owned())?; - let version = validate_version_str(version_str)?; let token = require_token()?; let json = fastly_api_get( &format!("/service/{service_id}/version/{version}/domain?include=staging_ips"), @@ -2367,18 +2377,18 @@ mod tests { } #[test] - fn resolve_staged_manifest_dir_prefers_manifest_path_flag() { + fn resolve_manifest_dir_prefers_manifest_path_flag() { // When the CLI threads `--manifest-path `, the - // staged deploy must use its parent directory rather than a bare - // working-directory search (which in a monorepo could pick a - // different app's fastly.toml). + // deploy (production AND staged) must use its parent directory + // rather than a bare working-directory search (which in a + // monorepo could pick a different app's fastly.toml). let args = vec![ "--service-id".to_owned(), "SVC1".to_owned(), "--manifest-path".to_owned(), "/repo/apps/edge/fastly.toml".to_owned(), ]; - let dir = resolve_staged_manifest_dir(&args).expect("resolves from --manifest-path"); + let dir = resolve_manifest_dir(&args).expect("resolves from --manifest-path"); assert_eq!(dir, PathBuf::from("/repo/apps/edge")); } @@ -2485,6 +2495,114 @@ mod tests { } } + // ── healthcheck / rollback input validation (spec §5.4) ─────────── + // + // GitHub Actions' `required: true` does NOT fail when an input is + // omitted or empty, so the CLI is the real guard. An absent / empty / + // malformed `--service-id` or `--version` must be rejected on BOTH + // the production and the staging path — a production healthcheck + // that probes anyway "verifies" a version it never looked at, and + // the caller chains that same version into rollback. + + #[test] + fn healthcheck_rejects_missing_or_empty_required_values_on_production() { + for (args, needle) in [ + ( + owned(&["--domain", "example.com", "--service-id", "SVC1"]), + "--version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "", + ]), + "invalid version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "15.2.0", + ]), + "invalid version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "", + "--version", + "7", + ]), + "invalid service id", + ), + ( + owned(&["--domain", "", "--service-id", "SVC1", "--version", "7"]), + "invalid domain", + ), + ( + owned(&["--service-id", "SVC1", "--version", "7"]), + "--domain", + ), + ] { + let err = healthcheck(&args).expect_err("must reject absent/empty required value"); + assert!( + err.contains(needle), + "expected {needle:?} in error for {args:?}, got: {err}" + ); + } + } + + #[test] + fn healthcheck_rejects_empty_required_values_on_staging() { + for args in [ + owned(&[ + "--staging", + "--domain", + "example.com", + "--service-id", + "", + "--version", + "7", + ]), + owned(&[ + "--staging", + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "", + ]), + ] { + healthcheck(&args).expect_err("staging must reject empty required values"); + } + } + + #[test] + fn rollback_rejects_missing_or_invalid_required_values() { + for staging in [&[][..], &["--staging".to_owned()][..]] { + for bad in [ + owned(&["--service-id", "SVC1"]), + owned(&["--service-id", "SVC1", "--version", ""]), + owned(&["--service-id", "SVC1", "--version", "12abc"]), + owned(&["--service-id", "", "--version", "7"]), + ] { + let mut args = bad.clone(); + args.extend_from_slice(staging); + rollback(&args).expect_err("rollback must reject invalid required values"); + } + } + } + // ── curl-config escaping + input validation (injection defence) ─── #[test] @@ -4936,4 +5054,88 @@ build = \"cargo build --release\" "must not stage a guessed version: {argv:?}" ); } + + #[cfg(unix)] + #[test] + fn deploy_staged_does_not_duplicate_non_interactive_from_passthrough() { + // `--non-interactive` is an allowlisted `compute update` flag, so a + // caller-supplied one is FORWARDED. We must not then append our own: + // passing the switch twice makes the Fastly CLI exit non-zero. + let (result, argv) = run_deploy_staged_with_fake( + "SUCCESS: Updated package (service SVC1, version 7)", + &["--non-interactive"], + ); + result.expect("staged deploy with a passthrough --non-interactive must succeed"); + let update = argv + .iter() + .find(|line| line.starts_with("compute update")) + .expect("compute update was invoked"); + assert_eq!( + update.matches("--non-interactive").count(), + 1, + "the non-interactive switch must appear exactly once: {update}" + ); + } + + /// Fake `fastly` on `$PATH` that records `\t` for every + /// invocation. Used to prove the production deploy runs in the + /// manifest-selected app directory. + #[cfg(unix)] + fn fake_fastly_cwd_recorder() -> (tempfile::TempDir, PathBuf) { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempdir().expect("tempdir"); + let record = dir.path().join("argv.log"); + let script_path = dir.path().join("fastly"); + let script = format!( + "#!/bin/sh\nprintf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '{}'\nexit 0\n", + record.display(), + ); + fs::write(&script_path, script).expect("write fake fastly"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + (dir, record) + } + + #[cfg(unix)] + #[test] + fn deploy_honours_threaded_manifest_path_and_strips_it_from_the_fastly_argv() { + // Production deploys used to ignore the CLI-threaded + // `--manifest-path` and fall back to `find_fastly_manifest(cwd)`, + // which in a monorepo picks the CLOSEST fastly.toml — the wrong + // app. The threaded path must select the app directory, and must + // be STRIPPED from the argv (`fastly compute deploy` has no such + // flag and would exit non-zero). + let _lock = path_mutation_guard().lock().expect("guard"); + let (fake, record) = fake_fastly_cwd_recorder(); + let _path = PathPrepend::new(fake.path()); + + let app = tempdir().expect("app dir"); + let manifest = app.path().join("fastly.toml"); + fs::write(&manifest, "name = \"app\"\n").expect("write fastly.toml"); + + let args = vec![ + "--manifest-path".to_owned(), + manifest.display().to_string(), + "--service-id".to_owned(), + "SVC1".to_owned(), + ]; + deploy(&args).expect("deploy must run against the threaded manifest"); + + let recorded = fs::read_to_string(&record).expect("fastly was invoked"); + let (cwd, recorded_argv) = recorded + .trim_end() + .split_once('\t') + .expect("recorded `\\t`"); + assert_eq!( + fs::canonicalize(cwd).expect("cwd"), + fs::canonicalize(app.path()).expect("app dir"), + "deploy must run in the manifest-selected app directory" + ); + assert_eq!( + recorded_argv, + "compute deploy --service-id SVC1 --non-interactive" + ); + } } diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index 4704ab53..2d45db49 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -190,6 +190,22 @@ pub fn execute_capture( Ok(None) } +/// Whether `action` for `adapter_name` resolves to a manifest-declared +/// shell command (rather than the registered adapter's built-in logic). +/// +/// Callers use this to decide whether an EdgeZero-internal directive +/// (e.g. `--manifest-path`, understood only by the built-in adapter) is +/// safe to thread into `adapter_args`: a manifest shell command receives +/// those args verbatim and would choke on a flag its own CLI lacks. +pub fn has_manifest_command( + manifest_loader: Option<&ManifestLoader>, + adapter_name: &str, + action: Action, +) -> bool { + manifest_loader + .is_some_and(|loader| manifest_command(loader.manifest(), adapter_name, action).is_some()) +} + fn manifest_command<'manifest>( manifest: &'manifest Manifest, adapter_name: &str, diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index e22a3f2d..a395dccc 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -164,7 +164,36 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // Thread `--service-id` (spec §5.4) into the adapter invocation // when provided, ahead of any operator passthrough args. Fastly // consumes it; adapters that don't need a service id ignore it. + let action = if args.stage { + adapter::Action::DeployStaged + } else { + adapter::Action::Deploy + }; + let mut passthrough: Vec = Vec::new(); + // Thread the manifest-configured platform manifest path (resolved + // from `[adapters..adapter].manifest` relative to the + // `EDGEZERO_MANIFEST`-honoring manifest root) into BOTH the staged + // and the production deploy, so each targets the app the operator + // selected — not whichever `fastly.toml` a bare working-directory + // search finds first in a monorepo. The adapter falls back to a cwd + // search only when the manifest declares no adapter `manifest` key. + // + // `--manifest-path` is an EdgeZero-internal directive that only the + // built-in adapter understands, so it is threaded only when the + // action actually dispatches to the adapter. A manifest-declared + // shell `deploy` command receives the adapter args VERBATIM, and + // `fastly compute deploy` has no `--manifest-path` flag — such a + // command already runs in the manifest root and picks its own + // project directory. (Staged deploys are never manifest-declared + // commands, so they always get the flag.) + if !adapter::has_manifest_command(manifest.as_ref(), &args.adapter, action) { + if let Some(manifest_path) = resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter) + { + passthrough.push("--manifest-path".to_owned()); + passthrough.push(manifest_path); + } + } if let Some(service_id) = &args.service_id { passthrough.push("--service-id".to_owned()); passthrough.push(service_id.clone()); @@ -176,26 +205,11 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // package to a new draft, mark it staged, and emit the staged // version (spec §5.4). Never runs the manifest `deploy` // command, which would activate production. - // - // Thread the manifest-configured Fastly manifest path (resolved - // from `[adapters..adapter].manifest` relative to the - // `EDGEZERO_MANIFEST`-honoring manifest root) so the staged - // deploy targets the app the operator selected — not whichever - // `fastly.toml` a bare working-directory search finds first in a - // monorepo. The adapter falls back to a cwd search only when the - // manifest declares no adapter `manifest` key. - let mut staged: Vec = Vec::new(); - if let Some(manifest_path) = resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter) - { - staged.push("--manifest-path".to_owned()); - staged.push(manifest_path); - } - staged.extend(passthrough); return adapter::execute( &args.adapter, adapter::Action::DeployStaged, manifest.as_ref(), - &staged, + &passthrough, ); } @@ -268,15 +282,19 @@ fn parse_deploy_version(output: &str) -> Option { /// Last `version=` line in `output` (leading/trailing whitespace on /// the line is ignored). +/// +/// FAIL CLOSED: the whole value after `version=` must be ASCII digits. +/// A `take_while(is_ascii_digit)` prefix scan would read `version=15.2.0` +/// as `15` and `version=12abc` as `12`, threading a WRONG version into +/// healthcheck / rollback. `None` sends the caller to the Fastly API +/// fallback (the version the deploy actually activated) instead. #[cfg(feature = "cli")] fn parse_canonical_version_line(output: &str) -> Option { output.lines().rev().find_map(|line| { - let digits: String = line - .trim() - .strip_prefix("version=")? - .chars() - .take_while(char::is_ascii_digit) - .collect(); + let digits = line.trim().strip_prefix("version=")?; + if digits.is_empty() || !digits.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } digits.parse::().ok() }) } @@ -634,6 +652,70 @@ mod tests { ); } + #[test] + fn parse_deploy_version_rejects_malformed_canonical_lines() { + // The canonical-line parser must be FAIL CLOSED: a prefix scan + // (`take_while(is_ascii_digit)`) read `version=15.2.0` as 15 and + // `version=12abc` as 12, threading a WRONG version into + // healthcheck / rollback. `None` routes run_deploy to the Fastly + // API fallback instead. + assert_eq!(parse_deploy_version("version=15.2.0\n"), None); + assert_eq!(parse_deploy_version("version=12abc\n"), None); + assert_eq!(parse_deploy_version("version=\n"), None); + // A well-formed line is still accepted (leading zeros included). + assert_eq!(parse_deploy_version("version=007\n"), Some(7)); + } + + #[cfg(not(windows))] + #[test] + fn run_deploy_manifest_command_forwards_adapter_args_verbatim() { + // With `[adapters.fastly.commands] deploy = ...` the deploy runs + // as a shell command, NOT the built-in Fastly path — so anything + // the caller (e.g. the deploy action) passes as an adapter arg, + // `--non-interactive` included, must reach that command verbatim. + // The EdgeZero-internal `--manifest-path` must NOT: the shell + // command's own CLI has no such flag. + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let args_file = temp.path().join("argv.txt"); + let script = temp.path().join("record.sh"); + fs::write( + &script, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" > '{}'\necho version=42\n", + args_file.display() + ), + ) + .expect("write record script"); + + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + format!( + "[app]\nname = \"demo-app\"\n\n[adapters.fastly.adapter]\ncrate = \"crates/demo-fastly\"\nmanifest = \"crates/demo-fastly/fastly.toml\"\n\n[adapters.fastly.commands]\ndeploy = \"sh {}\"\n", + script.display() + ), + ) + .expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + let args = DeployArgs { + adapter: "fastly".to_owned(), + adapter_args: vec!["--non-interactive".to_owned()], + service_id: Some("SVC1".to_owned()), + stage: false, + }; + run_deploy(&args).expect("manifest deploy command runs"); + + let forwarded = fs::read_to_string(&args_file).expect("command recorded its args"); + assert_eq!( + forwarded.trim(), + "--service-id SVC1 --non-interactive", + "manifest deploy command must receive the adapter args verbatim" + ); + } + #[test] fn ensure_adapter_defined_accepts_known_adapter() { let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); diff --git a/docs/guide/deploy-github-actions.md b/docs/guide/deploy-github-actions.md index d8cf21ab..c131baa4 100644 --- a/docs/guide/deploy-github-actions.md +++ b/docs/guide/deploy-github-actions.md @@ -14,7 +14,7 @@ this page is the practical how-to. | Action | Role | | -------------------- | ----------------------------------------------------------------------------------------- | -| `build-cli` | Compile the CLI package **your app provides** once, publish it as an artifact. | +| `build-app-cli` | Compile the CLI package **your app provides** once, publish it as an artifact. | | `deploy-fastly` | Deploy a checked-out Fastly app using that CLI artifact (production, or a staged draft). | | `healthcheck-fastly` | Probe a deployed/staged version; exit non-zero when unhealthy so you can gate a rollback. | | `rollback-fastly` | Production: activate the previous version. Staging: deactivate the staged version. | @@ -29,7 +29,7 @@ provider-neutral behavior; the wrappers above are thin. - **Checkout.** The actions never call `actions/checkout` — you own checkout, ref selection, permissions, environments, concurrency, and timeouts. - **A CLI package.** Name a Cargo package in your own workspace (the crate that - builds your `edgezero`-based CLI binary) via `cli-package`. `build-cli` + builds your `edgezero`-based CLI binary) via `cli-package`. `build-app-cli` compiles exactly that, from your checkout's `Cargo.lock`, so the CLI and your app can never disagree on schema. - **Typed provider credentials.** Pass `fastly-api-token` / `fastly-service-id` @@ -50,7 +50,7 @@ jobs: persist-credentials: false - id: cli - uses: stackpop/edgezero/.github/actions/build-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: cli-package: my-app-cli # the CLI crate in your workspace @@ -89,7 +89,7 @@ steps: token: ${{ steps.app-token.outputs.token }} # app-scoped token - id: cli - uses: stackpop/edgezero/.github/actions/build-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: cli-package: my-app-cli working-directory: app @@ -111,7 +111,7 @@ workspace may be the subdirectory itself), so a monorepo caches the right ```yaml - id: cli - uses: stackpop/edgezero/.github/actions/build-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: cli-package: api-cli working-directory: apps/api @@ -128,7 +128,7 @@ workspace may be the subdirectory itself), so a monorepo caches the right ## Inputs and outputs -### `build-cli` +### `build-app-cli` | Input | Required | Default | Meaning | | ------------------- | -------- | --------------- | ---------------------------------------------------------------- | @@ -142,21 +142,60 @@ Outputs: `cli-version`, `cli-package`, `cli-bin`, `artifact-name`. ### `deploy-fastly` -| Input | Required | Default | Meaning | -| ------------------- | -------- | ------- | --------------------------------------------------------------- | -| `cli-artifact` | Yes | — | The `build-cli` artifact to run. | -| `fastly-api-token` | Yes | — | Injected only into the deploy step. | -| `fastly-service-id` | Yes | — | Passed as the typed `--service-id` flag. | -| `working-directory` | No | `.` | App directory. | -| `manifest` | No | empty | Optional `edgezero.toml` path relative to `working-directory`. | -| `build-mode` | No | `auto` | `auto` (→ `never` for Fastly), `always`, or `never`. | -| `build-args` | No | `[]` | JSON array passed to ` build`. No secrets. | -| `deploy-args` | No | `[]` | JSON array — allowlisted to `--comment` for Fastly. No secrets. | -| `stage` | No | `false` | Deploy to a staged draft version instead of activating. | -| `cache` | No | `false` | Exact-key Cargo-workspace `target/` caching. | +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | --------------------------------------------------------------- | +| `cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Injected only into the deploy step. | +| `fastly-service-id` | Yes | — | Passed as the typed `--service-id` flag. | +| `cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `working-directory` | No | `.` | App directory. | +| `manifest` | No | empty | Optional `edgezero.toml` path relative to `working-directory`. | +| `build-mode` | No | `auto` | `auto` (→ `never` for Fastly), `always`, or `never`. | +| `build-args` | No | `[]` | JSON array passed to ` build`. No secrets. | +| `deploy-args` | No | `[]` | JSON array — allowlisted to `--comment` for Fastly. No secrets. | +| `stage` | No | `false` | Deploy to a staged draft version instead of activating. | +| `cache` | No | `false` | Exact-key Cargo-workspace `target/` caching. | Outputs: `fastly-version`, `source-revision`, `cli-version`. +The action always adds `--non-interactive` to the deploy itself, so a deploy +declared as an `edgezero.toml` command (`[adapters.fastly.commands] deploy = +"fastly compute deploy"`) cannot block on a prompt in CI. You do not need to — +and cannot — pass it through `deploy-args`. + +### `healthcheck-fastly` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | ------------------------------------------------------------- | +| `cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Needed to resolve a staged version's IP. | +| `fastly-service-id` | Yes | — | Service to probe. | +| `fastly-version` | Yes | — | Version to probe — thread the deploy's `fastly-version`. | +| `domain` | Yes | — | Domain to probe, e.g. `www.example.com`. | +| `cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `deploy-to` | No | `production` | `staging` probes the staged version via its resolved edge IP. | +| `retry` | No | `3` | Attempts before declaring the deployment unhealthy. | +| `retry-delay` | No | `5` | Seconds between attempts. | +| `timeout` | No | `10` | Per-attempt timeout in seconds. | + +Outputs: `healthy`, `status-code`. + +**This action fails when the deployment is unhealthy** — that is the point. Gate +your rollback on the step failing (`if: failure()`), not on the `healthy` output. + +### `rollback-fastly` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | ---------------------------------------------------------------------------------- | +| `cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Fastly API token. | +| `fastly-service-id` | Yes | — | Service to roll back. | +| `fastly-version` | Yes | — | The current (bad) version to roll back **from**. | +| `cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `deploy-to` | No | `production` | `production` activates the previous version; `staging` deactivates the staged one. | + +Outputs: `rolled-back-to` (production only — the version that was activated). + ## Strict lifecycle values (fail closed) `stage` and `deploy-to` are validated exactly, and a bad value **fails the run** @@ -195,7 +234,7 @@ carry no orchestration policy of their own. ```yaml - id: cli - uses: stackpop/edgezero/.github/actions/build-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: { cli-package: my-app-cli } - id: stage diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index eaeb271b..eff3f0b7 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -9,14 +9,14 @@ Implement the layered deploy actions in the EdgeZero monorepo: ```text -.github/actions/build-cli +.github/actions/build-app-cli .github/actions/deploy-core .github/actions/deploy-fastly .github/actions/healthcheck-fastly .github/actions/rollback-fastly ``` -`build-cli` compiles the app-provided CLI package once and publishes it as an +`build-app-cli` compiles the app-provided CLI package once and publishes it as an artifact. `deploy-core` is the adapter-independent deploy engine that consumes the prebuilt CLI. `deploy-fastly` is a minimal wrapper that types Fastly credentials and calls the engine, with an optional `stage` mode. `healthcheck-fastly` @@ -35,25 +35,25 @@ This design **supersedes** the monolithic Fastly action from #303 (`.github/actions/deploy/`). That branch is not the base; its scripts are a reference to port from. Most transfer with light changes: -| Existing `.github/actions/deploy/` | New home | Disposition | -| -------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `scripts/common.sh` | `deploy-core/scripts/` | Reuse ~as-is (annotation escaping, helpers). | -| `scripts/cleanup.sh` | `deploy-core/scripts/` | Reuse. | -| `scripts/write-summary.sh` | `deploy-core/scripts/` | Reuse; update summary field names. | -| `scripts/validate-inputs.sh` | `deploy-core/scripts/` | Reuse; move Fastly-specific allowlist to the wrapper. | -| `scripts/resolve-project.sh` | `deploy-core/scripts/` | Reuse + split Git root vs Cargo workspace root. | -| `scripts/install-rust.sh` | dropped | Replaced by `actions-rust-lang/setup-rust-toolchain@v1` in deploy-fastly (toolchain from resolve output + wasm target). build-cli keeps `rustup` for dynamic app-resolved toolchain install. | -| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | -| `tests/run.sh` | `deploy-core/tests/` | Reuse the harness; add new cases. | -| `scripts/install-fastly.sh`, `versions.json` | `deploy-fastly/` | Move (provider-specific install + checksum). | -| `scripts/install-edgezero.sh` | → `build-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | -| `action.yml` (one composite) | `build-cli/` + `deploy-fastly/` | Split into build + wrapper; engine is sourced scripts. | -| `.github/workflows/deploy-action.yml` | same path | Rewrite: de-Python, repin actions to tags. | -| cache `uses: actions/cache@` | `actions/cache@v4` | Repin to readable tag. | +| Existing `.github/actions/deploy/` | New home | Disposition | +| -------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `scripts/common.sh` | `deploy-core/scripts/` | Reuse ~as-is (annotation escaping, helpers). | +| `scripts/cleanup.sh` | `deploy-core/scripts/` | Reuse. | +| `scripts/write-summary.sh` | `deploy-core/scripts/` | Reuse; update summary field names. | +| `scripts/validate-inputs.sh` | `deploy-core/scripts/` | Reuse; move Fastly-specific allowlist to the wrapper. | +| `scripts/resolve-project.sh` | `deploy-core/scripts/` | Reuse + split Git root vs Cargo workspace root. | +| `scripts/install-rust.sh` | dropped | Replaced by `actions-rust-lang/setup-rust-toolchain@v1` in deploy-fastly (toolchain from resolve output + wasm target). build-app-cli keeps `rustup` for dynamic app-resolved toolchain install. | +| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | +| `tests/run.sh` | `deploy-core/tests/` | Reuse the harness; add new cases. | +| `scripts/install-fastly.sh`, `versions.json` | `deploy-fastly/` | Move (provider-specific install + checksum). | +| `scripts/install-edgezero.sh` | → `build-app-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | +| `action.yml` (one composite) | `build-app-cli/` + `deploy-fastly/` | Split into build + wrapper; engine is sourced scripts. | +| `.github/workflows/deploy-action.yml` | same path | Rewrite: de-Python, repin actions to tags. | +| cache `uses: actions/cache@` | `actions/cache@v4` | Repin to readable tag. | ## Implementation phases -1. **`build-cli` action** +1. **`build-app-cli` action** - Required `cli-package` input: the Cargo package name of the CLI defined in the application's own workspace. Fail if missing or not found in the app workspace under `working-directory`. @@ -101,7 +101,7 @@ reference to port from. Most transfer with light changes: - Confine `working-directory` and `manifest` inside `github.workspace` (canonical paths, symlink resolution). - Resolve **Git root** for `source-revision` and the dirty-source guard - (`build-cli`'s isolated target dir keeps this clean). + (`build-app-cli`'s isolated target dir keeps this clean). - Resolve the **Cargo workspace root** (`cargo locate-project --workspace`) for lockfile hashing and `target/` caching — in a monorepo this may be under `working-directory`, not the Git root (spec §11.1). @@ -164,7 +164,7 @@ reference to port from. Most transfer with light changes: 5. **Scripts layout** - Provider-neutral scripts under `deploy-core/`; the Fastly install + checksum step lives with `deploy-fastly/` (or a shared script keyed by adapter). - - No CLI-build script here — CLI build lives entirely in `build-cli`. + - No CLI-build script here — CLI build lives entirely in `build-app-cli`. 6. **CI workflow (`.github/workflows/deploy-action.yml`) — no Python** - Pin third-party actions to readable released tags (`actions/checkout@v4`, @@ -173,7 +173,7 @@ reference to port from. Most transfer with light changes: - Run `zizmor` from a pinned release binary or `cargo install zizmor --locked` (no `pip`). - Port the metadata-validation heredocs into `tests/run.sh`. - - Composite smoke test: `build-cli` → `deploy-fastly` (both production and + - Composite smoke test: `build-app-cli` → `deploy-fastly` (both production and `stage: true`) → `healthcheck-fastly` → `rollback-fastly`. Fake each action's real dependency: a fake `fastly` binary (marker files + printed version) for `deploy-fastly`; a fake app CLI or stubbed Fastly API/`curl` responses for @@ -192,7 +192,7 @@ reference to port from. Most transfer with light changes: 8. **Companion CLI scaffolding (`crates/edgezero-cli`, `edgezero-adapter-fastly`)** - Add `#[command(version)]` to the downstream CLI template (`crates/edgezero-cli/src/templates/cli/src/main.rs.hbs`) so generated app - CLIs expose `--version`. Until adopted, `build-cli` reads the version from + CLIs expose `--version`. Until adopted, `build-app-cli` reads the version from `cargo metadata` and smoke-checks with `--help`. - Fastly staging deploy: extend the Fastly adapter `deploy` path with `--stage` → `fastly compute update --autoclone --version=active` + diff --git a/docs/specs/edgezero-deploy-adoption-guide.md b/docs/specs/edgezero-deploy-adoption-guide.md index 33c4e5db..3c8d1e87 100644 --- a/docs/specs/edgezero-deploy-adoption-guide.md +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -12,7 +12,7 @@ through the Trusted Server deployer as one concrete migration example. Composable actions: -- `build-cli` — compile the CLI package the application provides (a crate in the +- `build-app-cli` — compile the CLI package the application provides (a crate in the app's own workspace) once, publish it as an artifact; - `deploy-fastly` — deploy a checked-out Fastly application using the prebuilt CLI artifact, to production or (with `stage: true`) a staged draft version; @@ -26,7 +26,7 @@ timeouts, and **orchestrating** the health-check / rollback flow. ## 2. Checkout layouts The adapters your CLI supports come from your app's own `Cargo.toml`, so -`build-cli` takes no `adapters` input — it builds your CLI package as declared. +`build-app-cli` takes no `adapters` input — it builds your CLI package as declared. ### 2.1 Same-repository application @@ -44,7 +44,7 @@ jobs: persist-credentials: false - id: cli - uses: stackpop/edgezero/.github/actions/build-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: cli-package: my-app-cli # the CLI crate in your app's workspace @@ -93,7 +93,7 @@ steps: token: ${{ steps.app-token.outputs.token }} - id: cli - uses: stackpop/edgezero/.github/actions/build-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: cli-package: my-app-cli working-directory: app @@ -120,7 +120,7 @@ steps: persist-credentials: false - id: cli - uses: stackpop/edgezero/.github/actions/build-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: cli-package: api-cli working-directory: apps/api @@ -140,13 +140,13 @@ steps: - Check out application source yourself; the actions never call `actions/checkout`. - Provide a CLI package in your own workspace and name it via `cli-package`; - `build-cli` compiles that package from your checkout, so the CLI and your app - never disagree on schema. `build-cli` does not use the EdgeZero monorepo CLI. + `build-app-cli` compiles that package from your checkout, so the CLI and your app + never disagree on schema. `build-app-cli` does not use the EdgeZero monorepo CLI. - Provide typed provider credentials through wrapper inputs, not caller `env:`. - Ensure the deployed ref has committed source (no dirty working tree) and a `Cargo.lock` at your app's **Cargo workspace root** (the workspace that owns `cli-package` — in a nested-workspace monorepo this may be your app - subdirectory, not the repo root). `build-cli` requires it, and caching keys on + subdirectory, not the repo root). `build-app-cli` requires it, and caching keys on it. - Pin action references to readable released tags, or full SHAs for production reproducibility. @@ -205,18 +205,18 @@ deployer must handle itself are: internal checkout, `trusted-server-ref`, Map the legacy trio onto the EdgeZero staging trio: -| Legacy action | EdgeZero replacement | -| ------------------------------------------ | ---------------------------------------------- | -| `fastly/deploy@v2` (with `fastly-staging`) | `build-cli` + `deploy-fastly` (`stage:` input) | -| `fastly/healthcheck@v2` | `healthcheck-fastly` | -| `fastly/rollback@v2` | `rollback-fastly` | +| Legacy action | EdgeZero replacement | +| ------------------------------------------ | -------------------------------------------------- | +| `fastly/deploy@v2` (with `fastly-staging`) | `build-app-cli` + `deploy-fastly` (`stage:` input) | +| `fastly/healthcheck@v2` | `healthcheck-fastly` | +| `fastly/rollback@v2` | `rollback-fastly` | Workflow shape: 1. check out `trusted-server-deployer` with `persist-credentials: false`; 2. check out Trusted Server source separately at the selected ref into `trusted-server`; -3. run `build-cli` with `cli-package: ` and +3. run `build-app-cli` with `cli-package: ` and `working-directory: trusted-server` (Trusted Server's own CLI package, whose `Cargo.toml` already pins the Fastly adapter); 4. run `deploy-fastly` (set `stage: true` for staging) with the CLI artifact, @@ -234,7 +234,7 @@ Workflow shape: - Add explicit Trusted Server checkout; the EdgeZero actions do not call `actions/checkout`. -- Replace the legacy `fastly/*@v2` trio with `build-cli` + `deploy-fastly` + +- Replace the legacy `fastly/*@v2` trio with `build-app-cli` + `deploy-fastly` + `healthcheck-fastly` + `rollback-fastly`. - Pin action references to readable released tags, or full SHAs for production. - Read the version from `steps..outputs.fastly-version` (same concept as diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md index 5f253247..3f04c853 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -9,7 +9,7 @@ **Action paths:** ```text -.github/actions/build-cli +.github/actions/build-app-cli .github/actions/deploy-core .github/actions/deploy-fastly .github/actions/healthcheck-fastly @@ -28,9 +28,9 @@ adapter needs (`edgezero build`, `edgezero deploy`, `edgezero config push`, `edgezero provision`) already lives in the CLI; the actions only compile the CLI, scope credentials, and invoke the right subcommand. -The CLI is **the app's own CLI package.** The application tells `build-cli` which +The CLI is **the app's own CLI package.** The application tells `build-app-cli` which CLI package to compile (a crate in the application's own workspace), and -`build-cli` builds it from the application checkout. It is **not** the EdgeZero +`build-app-cli` builds it from the application checkout. It is **not** the EdgeZero monorepo CLI and **not** the action's own repository revision. Because the CLI is the app's own package, built from the app's source and lockfile, the CLI and the application always agree on the manifest, adapter, and config schema — and an app @@ -40,7 +40,7 @@ Three layers: | Layer | Action | Responsibility | | --------------- | ------------------- | ------------------------------------------------------------------------------------- | -| Build | `build-cli` | Compile the app-provided CLI package **once** and publish it. | +| Build | `build-app-cli` | Compile the app-provided CLI package **once** and publish it. | | Engine (shared) | `deploy-core` | Adapter-independent engine **scripts** sourced by wrappers; consume the prebuilt CLI. | | Adapter wrapper | `deploy-fastly` (…) | Minimal per-adapter shim: type provider credentials, call the engine. | @@ -56,7 +56,7 @@ The core boundary is EdgeZero itself: deploy --adapter ``` -where `` is the application's own CLI binary built by `build-cli`. +where `` is the application's own CLI binary built by `build-app-cli`. The generic engine stays provider-neutral. Provider-specific staging, health checks, and rollback are supported for Fastly as a separate lifecycle (§5.4) — @@ -73,12 +73,12 @@ own CLI, with thin action wrappers — so the engine never grows provider logic. credentials and the adapter name. Adding an adapter adds a wrapper; it does not fork the engine. 3. **The caller owns source.** The actions never call `actions/checkout`. -4. **The application provides the CLI package.** The app tells `build-cli` which - CLI package to compile via a required `cli-package` input, and `build-cli` +4. **The application provides the CLI package.** The app tells `build-app-cli` which + CLI package to compile via a required `cli-package` input, and `build-app-cli` builds that package from the application's own checkout. It never builds the EdgeZero monorepo CLI or the action's own repository revision. The application owns which CLI deploys it. -5. **Compile once, reuse everywhere.** `build-cli` compiles the CLI a single +5. **Compile once, reuse everywhere.** `build-app-cli` compiles the CLI a single time per workflow and publishes it as an artifact. Deploy actions consume the prebuilt binary and never recompile it. 6. **Typed provider credentials.** Credentials are passed through wrapper action @@ -133,7 +133,7 @@ The **generic** engine (`deploy-core`) will not: 7. support Windows or macOS runners; 8. publish a stable version alias; or 9. provide a general `setup` action for running arbitrary EdgeZero commands - (the CLI is available via the `build-cli` artifact for callers who need it). + (the CLI is available via the `build-app-cli` artifact for callers who need it). Staging deploy, health checks, and rollback **are** supported for Fastly, as a provider-specific lifecycle (§5.4). The engine stays neutral; the capability is @@ -142,20 +142,20 @@ own CLI, with thin action wrappers. ## 5. Architecture -### 5.1 Layer 1 — `build-cli` +### 5.1 Layer 1 — `build-app-cli` Compiles the **CLI package the application provides** — a crate in the application's own workspace, named by the required `cli-package` input — once, and publishes it as a workflow artifact so every downstream deploy step consumes the same binary. The CLI is built from the application checkout and its lockfile, -so it matches the application and may include app-specific commands. `build-cli` +so it matches the application and may include app-specific commands. `build-app-cli` never builds the EdgeZero monorepo CLI. **Inputs** | Input | Required | Default | Contract | | ------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `cli-package` | Yes | none | Cargo package name of the CLI to build, defined in the application's own workspace. `build-cli` builds this package from the application checkout. | +| `cli-package` | Yes | none | Cargo package name of the CLI to build, defined in the application's own workspace. `build-app-cli` builds this package from the application checkout. | | `cli-bin` | No | `` | Binary name produced by `cli-package`. Defaults to the package name (the generated downstream CLI names its bin after the package). | | `working-directory` | No | `.` | Application directory (relative to `github.workspace`) containing the workspace/lockfile that defines `cli-package`. Must resolve inside `github.workspace`. | | `rust-toolchain` | No | `auto` | Explicit toolchain, or `auto` to follow the application toolchain resolution precedence (§7). | @@ -163,7 +163,7 @@ never builds the EdgeZero monorepo CLI. There is intentionally no `adapters` / features input. The application's own `Cargo.toml` already pins which adapters compile into its CLI (through the -`edgezero-cli` dependency it declares); `build-cli` builds the package exactly as +`edgezero-cli` dependency it declares); `build-app-cli` builds the package exactly as the application declares it, so the app owns adapter selection. **Outputs** @@ -208,7 +208,7 @@ The artifact is self-describing: the engine reads `cli-meta.json` to learn the binary name and version, so callers do not have to re-pass `cli-bin`/`cli-version` (a wrapper `cli-bin` input, if given, overrides the metadata). -`build-cli` never receives provider credentials and leaves the app checkout +`build-app-cli` never receives provider credentials and leaves the app checkout clean (no `target/`, no lockfile mutation), so a later dirty-source guard passes. > **Companion CLI improvement (tracked separately):** the generated downstream @@ -233,7 +233,7 @@ The engine is parameterized by the values the wrapper passes to those scripts | Parameter | Meaning | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `adapter` | Passed to ` deploy --adapter `. The engine does **not** enumerate compiled adapters; the CLI rejects an unknown adapter with its own error. | -| `cli-artifact` | Name of the `build-cli` artifact to download. The engine reads `cli-bin` and `cli-version` from the artifact's `cli-meta.json`. | +| `cli-artifact` | Name of the `build-app-cli` artifact to download. The engine reads `cli-bin` and `cli-version` from the artifact's `cli-meta.json`. | | `cli-bin` | Optional override for the binary name; if empty, taken from `cli-meta.json`. | | `working-directory` | Application directory relative to `github.workspace`. Must resolve inside `github.workspace`. | | `manifest` | Optional `edgezero.toml` path relative to `working-directory`. If set, must exist; exported as `EDGEZERO_MANIFEST`. | @@ -280,7 +280,7 @@ is a wrapper concern; the engine assumes the provider CLI is already on `PATH`. | Input | Required | Default | Contract | | ------------------- | -------- | ------------- | ---------------------------------------------------------------------------------------------- | -| `cli-artifact` | Yes | none | `build-cli` artifact name. Forwarded to the engine. | +| `cli-artifact` | Yes | none | `build-app-cli` artifact name. Forwarded to the engine. | | `cli-bin` | No | from artifact | Binary name inside the artifact. Forwarded to the engine. | | `fastly-api-token` | Yes | none | Mapped into `provider-env` as `FASTLY_API_TOKEN`, deploy step only. | | `fastly-service-id` | Yes | none | Mapped into action-owned `deploy-flags` as `--service-id ` to prevent accidental creation. | @@ -329,7 +329,7 @@ The capability is scaffolded into the CLI, not reproduced in action shell: Every Fastly subcommand takes `--service-id ` (the service the operation targets) and reads `FASTLY_API_TOKEN` from the environment. The app CLI (built by -`build-cli`) exposes these subcommands the same way it exposes `deploy`/`config`. +`build-app-cli`) exposes these subcommands the same way it exposes `deploy`/`config`. The downstream CLI template gains `Healthcheck` and `Rollback` arms and a deployment-version surface, tracked with the other companion CLI changes. @@ -376,7 +376,7 @@ A caller wires the trio; the actions carry no orchestration policy of their own: ```yaml - id: cli - uses: stackpop/edgezero/.github/actions/build-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: { cli-package: my-app-cli } - id: stage @@ -436,7 +436,7 @@ adapter adds its own lifecycle actions if its provider supports staging. escapes `github.workspace` or is not a regular file, and export `EDGEZERO_MANIFEST`. 11. Resolve the application **Git root**; record `source-revision`; fail on a - dirty working tree (`build-cli` used an isolated `CARGO_TARGET_DIR`, so its + dirty working tree (`build-app-cli` used an isolated `CARGO_TARGET_DIR`, so its CLI build did not dirty this tree). 12. Resolve the **Cargo workspace root** for `working-directory` (§11.1) for all Cargo-scoped operations that follow. @@ -480,7 +480,7 @@ Application Rust toolchain resolution precedence: At each directory, Rustup-native files take precedence over `.tool-versions`. Malformed toolchain files fail instead of silently selecting a different -compiler. `build-cli` uses the same precedence for the CLI build. +compiler. `build-app-cli` uses the same precedence for the CLI build. ## 8. Build behavior @@ -545,7 +545,7 @@ wrapper inputs (typed) → provider-env {NAME: value} → deploy step env → CL Rules: - **`provider-env` is bound only to the deploy step's own `env:`** — a - step-scoped environment, not a job/engine-global variable. Setup, `build-cli`, + step-scoped environment, not a job/engine-global variable. Setup, `build-app-cli`, and separate build steps never receive the `provider-env` variable at all, so the secret-bearing blob is absent from their environments (not merely unset after the fact). The engine parses `provider-env` only inside the deploy step. @@ -557,11 +557,68 @@ Rules: provider names, so caller `env:` cannot override the typed contract. - `provider-env` values never reach `GITHUB_ENV`, `GITHUB_OUTPUT`, caches, or summaries. +- **The engine's own private environment is scrubbed before the CLI is exec'd.** + The wrapper necessarily carries the token into the deploy step twice — once as + `EDGEZERO___API_TOKEN` (so the step's YAML can build the JSON without + interpolating a secret into a `run:` block, itself a template-injection sink), + and once inside `EDGEZERO__PROVIDER_ENV`. Both are secret-bearing. `run-cli.sh` + therefore unsets its entire private namespace (§10.2) after reading it, so the + app CLI — and every subprocess it spawns, including a manifest + `[adapters.*.commands]` shell command — receives **only** the typed provider + aliases plus `EDGEZERO_MANIFEST`. Without this, an `env`-dumping build script + would print the raw token under a name we never promised. +- Every step of a wrapper — shell steps and third-party `uses:` steps alike — + blanks the full alias list in its own `env:`. The list a wrapper blanks and the + list it passes as `provider-env-clear` are the same list. Application (non-credential) configuration may still pass through normal workflow `env:`. -### 10.1 Build-in-deploy caveat (trusted source requirement) +### 10.1 Action-owned passthrough arguments + +The deploy-arg allowlist (§9) governs **caller** input. A wrapper may also +prepend args of its own, which are not caller input and so are not +allowlist-checked. For Fastly that is `--non-interactive`. + +It is needed because the two deploy paths differ. The built-in Fastly deploy +appends `--non-interactive` for itself, but a deploy declared as a manifest +command (`[adapters.fastly.commands] deploy = "fastly compute deploy"` — a +documented, common configuration) is run as a shell command with the adapter args +appended verbatim, so nothing would add it and the deploy could block on a TTY +prompt in CI. Supplying it from the wrapper covers both paths; the built-in path +de-duplicates it (both `--non-interactive` and `-i`). + +Action-owned args are prepended, so a caller arg still wins where the provider +CLI takes last-wins. A caller cannot smuggle one in through `deploy-args` — the +allowlist still rejects it. + +### 10.2 Environment variable convention + +Every variable the actions own lives in one namespace: + +```text +EDGEZERO__
__ + ^^ ^^ + `__` separates sections; `_` separates words within a section. +``` + +Sections in use: `ACTION` (action-owned dirs), `INPUT` (raw wrapper inputs), +`PROJECT` (working directory, manifest), `CLI` (the app CLI artifact), `BUILD` / +`DEPLOY` (argument files), `PROVIDER` (the credential JSON and its clear list), +`RUNNER`, `LIFECYCLE` (healthcheck/rollback parameters), `SUMMARY`, `FASTLY` +(provider-specific), and `TEST`. + +This is not cosmetic. It is what makes the credential boundary (§10) a **single +rule**: `run-cli.sh` unsets everything matching `EDGEZERO__*` before exec'ing the +app CLI. With the previous mix of `DEPLOY_*`, `INPUT_*`, `SUMMARY_*`, and bare +`CLI_BIN` / `VERSION`, the scrub needed a hand-maintained list — and a variable +added later without touching that list would have silently leaked. + +`EDGEZERO_MANIFEST` (**single** underscore) is deliberately outside the +namespace: it is the CLI's own public contract, not an action variable, and it is +the one variable the actions deliberately pass through. + +### 10.3 Build-in-deploy caveat (trusted source requirement) Some adapters compile the application **inside** the deploy step. Fastly's default `build-mode: never` relies on ` deploy`, which runs @@ -571,7 +628,7 @@ application is compiled while `FASTLY_API_TOKEN` is in scope. This is an explicit, accepted boundary, not an oversight: -- The action still guarantees credentials are absent from setup, `build-cli`, +- The action still guarantees credentials are absent from setup, `build-app-cli`, and any separate `build-mode: always` build step. - Because deploy may still recompile, a credential-free `always` prebuild does not remove the exposure; it only front-loads a validation build. @@ -603,6 +660,19 @@ Cargo-scoped operations — lockfile hashing, lockfile presence checks, and `target/` caching — use the **Cargo workspace root**. Git-scoped operations use the **Git root**. +The **application's Git root is also the toolchain search boundary.** +`build-app-cli` resolves the Rust toolchain by walking up from +`working-directory` looking for `rust-toolchain.toml`, `rust-toolchain`, then +`.tool-versions` — and that walk stops at the app's Git root, never at +`github.workspace`. In the separate-repository layout the deployer repo sits at +`github.workspace` with the application checked out into a subdirectory; walking +to `github.workspace` would cross the app's Git boundary and let the _deployer's_ +`.tool-versions` silently decide which Rust compiles the application. Every path +is canonicalized before comparison, because a symlinked `TMPDIR` or checkout +would otherwise never match the boundary and the walk would climb straight past +it. When the app directory is not a Git checkout, the boundary falls back to +`github.workspace`; the walk never rises above it either way. + ### 11.2 Cache contents and key When enabled, cache only the resolved **Cargo workspace root** `target/`. Never @@ -625,6 +695,30 @@ enabled/disabled and key fingerprint, and final result. Never log provider credentials, full process environments, application secret values, or provider auth state. +### 12.1 Action-owned logs are private and transient + +The deploy, healthcheck, and rollback wrappers tee the CLI's combined output to a +file so they can parse a canonical `version=` / `healthy=` line out of +it. Provider CLIs print request URLs and service metadata, and under debug flags +can print credential material — so that file is created with `mktemp` at mode +`600` and removed by an `EXIT` trap whatever the outcome. It is never left behind +in `RUNNER_TEMP` for a later step in the job to read. + +Canonical lines are matched with a **fully anchored** pattern (`^=[0-9]+$`). +A prefix match reads `version=15.2.0` as `15` and `version=12abc` as `12` — +threading a version that was never deployed into the healthcheck and rollback that +follow. If the value is not exactly digits, the version has not been parsed, it +has been guessed; the wrapper fails instead. The CLI's own parser is anchored the +same way, and falls back to the Fastly API rather than guessing. + +### 12.2 Cleanup removes only what the action owns + +Cleanup runs `rm -rf`, so it removes only real paths strictly beneath +`RUNNER_TEMP`, resolving symlinks before comparing. A directory named by an +inherited variable — or any path outside the action-owned temp root — is refused +with a diagnostic, not deleted. Every wrapper that installs a tool or extracts the +CLI artifact runs cleanup with `if: always()`. + ## 13. Error handling All validation and setup failures stop before invoking provider deployment. @@ -632,7 +726,7 @@ All validation and setup failures stop before invoking provider deployment. | Failure | Required diagnostic | | ----------------------------------------------- | ------------------------------------------------------------------------------- | | Missing/unknown `cli-package` | State that the app must name a CLI package present in its own workspace. | -| Missing `cli-artifact` | State that a compiled CLI artifact from `build-cli` is required. | +| Missing `cli-artifact` | State that a compiled CLI artifact from `build-app-cli` is required. | | Malformed `adapter` token | Name the input and its allowed shape (the CLI validates support at run time). | | Invalid boolean | Name the input and allowed values. | | Missing working directory | Print the workspace-relative requested path. | @@ -663,7 +757,7 @@ actions never construct error messages containing credentials. 2. Compile the CLI package the application provides, from the application checkout and its lockfile; do not build the EdgeZero monorepo CLI or the action's own revision. -3. Compile the CLI once in `build-cli`; deploy steps consume the artifact and +3. Compile the CLI once in `build-app-cli`; deploy steps consume the artifact and never recompile. 4. Download provider tools and validation binaries only from official release locations and verify SHA-256 checksums. @@ -727,7 +821,7 @@ Fastly wrapper: profile/interactive/short-flag/debug overrides); - build-mode resolution and build-failure-prevents-deploy; - deploy exit-code propagation; -- credential presence validation and scoping (absent from build-cli/setup/build, +- credential presence validation and scoping (absent from build-app-cli/setup/build, present only in deploy); - cache key construction and missing-lockfile failure; - staging lifecycle: `stage` flag adds `--stage`; `fastly-version` parsed from CLI @@ -742,7 +836,7 @@ Tests must not need live provider credentials. ### 15.3 Composite smoke test A workflow exercises the layered actions end to end with a minimal fixture -EdgeZero app: run `build-cli`, then `deploy-fastly` (both production and +EdgeZero app: run `build-app-cli`, then `deploy-fastly` (both production and `stage: true`), then `healthcheck-fastly` and `rollback-fastly`. Fake the dependencies each action actually uses: @@ -768,7 +862,7 @@ healthcheck → rollback, cache behavior, credential scope, and public outputs. ## 16. Documentation requirements User-facing docs must cover: the three-layer model and when to use each action; -how `build-cli` compiles the app-provided CLI package; supported adapters and how new adapters +how `build-app-cli` compiles the app-provided CLI package; supported adapters and how new adapters layer on; runner support; same-repo, separate-repo, and monorepo checkout examples; complete input/output tables per action; typed provider credential guidance and why credentials must not pass through caller `env:`; build-mode and @@ -780,9 +874,9 @@ adapter notes. The design is implemented when: -1. A caller can compile the CLI once with `build-cli` and deploy a checked-out +1. A caller can compile the CLI once with `build-app-cli` and deploy a checked-out EdgeZero application with `deploy-fastly`, reusing the same CLI artifact. -2. `build-cli` compiles the app-provided `cli-package` from the application +2. `build-app-cli` compiles the app-provided `cli-package` from the application checkout and never builds the EdgeZero monorepo CLI or the action's own revision. 3. `deploy-core` contains no provider-specific credential names, service From 688649e2fe1035860fddbe5e470f3ffc3440ab49 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:23:38 -0700 Subject: [PATCH 22/26] Say "app CLI" everywhere: EDGEZERO__APP__CLI__*, app-cli-* inputs, *-app-cli.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/actions/build-app-cli/action.yml | 38 +++---- .../build-app-cli/scripts/build-app-cli.sh | 34 +++---- .../{download-cli.sh => download-app-cli.sh} | 22 ++--- .../deploy-core/scripts/resolve-project.sh | 4 +- .../scripts/{run-cli.sh => run-app-cli.sh} | 6 +- .../deploy-core/scripts/write-summary.sh | 2 +- .github/actions/deploy-core/tests/run.sh | 58 +++++------ .github/actions/deploy-fastly/action.yml | 28 +++--- .../actions/deploy-fastly/scripts/deploy.sh | 8 +- .github/actions/healthcheck-fastly/action.yml | 14 +-- .../healthcheck-fastly/scripts/healthcheck.sh | 4 +- .github/actions/rollback-fastly/action.yml | 14 +-- .../rollback-fastly/scripts/rollback.sh | 4 +- .github/workflows/deploy-action.yml | 16 +-- docs/guide/deploy-github-actions.md | 44 ++++----- ...ezero-deploy-action-implementation-plan.md | 40 ++++---- docs/specs/edgezero-deploy-adoption-guide.md | 18 ++-- docs/specs/edgezero-deploy-github-action.md | 98 +++++++++---------- 18 files changed, 226 insertions(+), 226 deletions(-) rename .github/actions/deploy-core/scripts/{download-cli.sh => download-app-cli.sh} (66%) rename .github/actions/deploy-core/scripts/{run-cli.sh => run-app-cli.sh} (97%) diff --git a/.github/actions/build-app-cli/action.yml b/.github/actions/build-app-cli/action.yml index 8effee1c..d19e9e14 100644 --- a/.github/actions/build-app-cli/action.yml +++ b/.github/actions/build-app-cli/action.yml @@ -2,39 +2,39 @@ name: EdgeZero build-app-cli description: Compile the CLI package the application provides and publish it as a self-describing artifact. inputs: - cli-package: + app-cli-package: description: Cargo package name of the CLI to build, defined in the application's own workspace. required: true - cli-bin: - description: Binary name produced by cli-package. Defaults to the package name. + app-cli-bin: + description: Binary name produced by app-cli-package. Defaults to the package name. required: false default: "" working-directory: - description: Application directory (relative to github.workspace) whose workspace defines cli-package. + description: Application directory (relative to github.workspace) whose workspace defines app-cli-package. required: false default: . rust-toolchain: description: Explicit host Rust toolchain, or 'auto' to follow application discovery. required: false default: auto - artifact-name: - description: Name of the uploaded CLI artifact. + app-cli-artifact: + description: Name for the uploaded app-CLI artifact. required: false default: edgezero-cli outputs: - cli-version: + app-cli-version: description: CLI package version read from cargo metadata. - value: ${{ steps.build.outputs['cli-version'] }} - cli-package: + value: ${{ steps.build.outputs['app-cli-version'] }} + app-cli-package: description: The application CLI package that was built. - value: ${{ steps.build.outputs['cli-package'] }} - cli-bin: + value: ${{ steps.build.outputs['app-cli-package'] }} + app-cli-bin: description: The binary name inside the artifact. - value: ${{ steps.build.outputs['cli-bin'] }} - artifact-name: - description: Name of the uploaded CLI artifact for downstream download. - value: ${{ steps.build.outputs['artifact-name'] }} + value: ${{ steps.build.outputs['app-cli-bin'] }} + app-cli-artifact: + description: Artifact name the deploy, healthcheck, and rollback actions consume. + value: ${{ steps.build.outputs['app-cli-artifact'] }} runs: using: composite @@ -44,17 +44,17 @@ runs: shell: bash env: EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. - EDGEZERO__INPUT__CLI_PACKAGE: ${{ inputs['cli-package'] }} - EDGEZERO__INPUT__CLI_BIN: ${{ inputs['cli-bin'] }} + EDGEZERO__INPUT__APP_CLI_PACKAGE: ${{ inputs['app-cli-package'] }} + EDGEZERO__INPUT__APP_CLI_BIN: ${{ inputs['app-cli-bin'] }} EDGEZERO__INPUT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} EDGEZERO__INPUT__RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} - EDGEZERO__INPUT__ARTIFACT_NAME: ${{ inputs['artifact-name'] }} + EDGEZERO__INPUT__APP_CLI_ARTIFACT: ${{ inputs['app-cli-artifact'] }} run: $GITHUB_ACTION_PATH/scripts/build-app-cli.sh - name: Upload CLI artifact uses: actions/upload-artifact@v4 with: - name: ${{ steps.build.outputs['artifact-name'] }} + name: ${{ steps.build.outputs['app-cli-artifact'] }} path: ${{ steps.build.outputs['tarball-path'] }} if-no-files-found: error retention-days: 1 diff --git a/.github/actions/build-app-cli/scripts/build-app-cli.sh b/.github/actions/build-app-cli/scripts/build-app-cli.sh index addaa70d..a96abbb7 100755 --- a/.github/actions/build-app-cli/scripts/build-app-cli.sh +++ b/.github/actions/build-app-cli/scripts/build-app-cli.sh @@ -2,17 +2,17 @@ set -euo pipefail # Compiles the CLI package the *application* provides (a crate in the app's own -# workspace, named by EDGEZERO__INPUT__CLI_PACKAGE) into an action-owned CARGO_TARGET_DIR, -# then packages the binary plus a self-describing cli-meta.json into a tar so the +# workspace, named by EDGEZERO__INPUT__APP_CLI_PACKAGE) into an action-owned CARGO_TARGET_DIR, +# then packages the binary plus a self-describing app-cli-meta.json into a tar so the # executable bit survives actions/upload-artifact. Never builds the EdgeZero # monorepo CLI. # # Inputs (environment): -# EDGEZERO__INPUT__CLI_PACKAGE required Cargo package name to build -# EDGEZERO__INPUT__CLI_BIN optional binary name (defaults to the package name) +# EDGEZERO__INPUT__APP_CLI_PACKAGE required Cargo package name to build +# EDGEZERO__INPUT__APP_CLI_BIN optional binary name (defaults to the package name) # EDGEZERO__INPUT__WORKING_DIRECTORY optional app dir relative to github.workspace (".") # EDGEZERO__INPUT__RUST_TOOLCHAIN optional explicit toolchain or "auto" -# EDGEZERO__INPUT__ARTIFACT_NAME optional uploaded artifact name ("edgezero-cli") +# EDGEZERO__INPUT__APP_CLI_ARTIFACT optional uploaded artifact name ("edgezero-cli") SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -125,11 +125,11 @@ require_linux_x86_64() { main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" local action_root="${EDGEZERO__ACTION__ROOT:?EDGEZERO__ACTION__ROOT is required}" - local cli_package="${EDGEZERO__INPUT__CLI_PACKAGE:?input 'cli-package' is required}" - local cli_bin="${EDGEZERO__INPUT__CLI_BIN:-}" + local cli_package="${EDGEZERO__INPUT__APP_CLI_PACKAGE:?input 'app-cli-package' is required}" + local cli_bin="${EDGEZERO__INPUT__APP_CLI_BIN:-}" local working_directory="${EDGEZERO__INPUT__WORKING_DIRECTORY:-.}" local rust_toolchain_input="${EDGEZERO__INPUT__RUST_TOOLCHAIN:-auto}" - local artifact_name="${EDGEZERO__INPUT__ARTIFACT_NAME:-edgezero-cli}" + local artifact_name="${EDGEZERO__INPUT__APP_CLI_ARTIFACT:-edgezero-cli}" # These directories are `rm -rf`d below, so they must NEVER come from the # inherited environment — a colliding job-level variable could otherwise point @@ -164,12 +164,12 @@ main() { metadata=$(cargo +"$rust_toolchain" metadata --locked --no-deps --format-version 1) || fail "cargo metadata --locked failed; ensure Cargo.lock is present and up to date" package_json=$(jq -c --arg p "$cli_package" '.packages[] | select(.name == $p)' <<<"$metadata") - [[ -n "$package_json" ]] || fail "cli-package '$cli_package' was not found in the application workspace" + [[ -n "$package_json" ]] || fail "app-cli-package '$cli_package' was not found in the application workspace" [[ -n "$cli_bin" ]] || cli_bin="$cli_package" local has_bin cli_version has_bin=$(jq -r --arg b "$cli_bin" '[.targets[] | select(.kind | index("bin")) | .name] | index($b) != null' <<<"$package_json") - [[ "$has_bin" == "true" ]] || fail "cli-package '$cli_package' declares no binary target named '$cli_bin'" + [[ "$has_bin" == "true" ]] || fail "app-cli-package '$cli_package' declares no binary target named '$cli_bin'" cli_version=$(jq -r '.version' <<<"$package_json") # Build into an action-owned target dir so the checkout stays clean. @@ -186,19 +186,19 @@ main() { cp "$bin_path" "$stage_root/$cli_bin" chmod +x "$stage_root/$cli_bin" jq -n --arg bin "$cli_bin" --arg version "$cli_version" --arg package "$cli_package" \ - '{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \ - >"$stage_root/cli-meta.json" + '{"app-cli-bin": $bin, "app-cli-version": $version, "app-cli-package": $package}' \ + >"$stage_root/app-cli-meta.json" # Fixed tarball name — never derive a path component from caller input. local tarball="$stage_root/../edgezero-cli.tar" - tar -C "$stage_root" -cf "$tarball" "$cli_bin" cli-meta.json + tar -C "$stage_root" -cf "$tarball" "$cli_bin" app-cli-meta.json tarball=$(canonical_path "$tarball") notice "built app CLI '$cli_bin' v$cli_version from package '$cli_package'" - append_output cli-version "$cli_version" - append_output cli-package "$cli_package" - append_output cli-bin "$cli_bin" - append_output artifact-name "$artifact_name" + append_output app-cli-version "$cli_version" + append_output app-cli-package "$cli_package" + append_output app-cli-bin "$cli_bin" + append_output app-cli-artifact "$artifact_name" append_output tarball-path "$tarball" } diff --git a/.github/actions/deploy-core/scripts/download-cli.sh b/.github/actions/deploy-core/scripts/download-app-cli.sh similarity index 66% rename from .github/actions/deploy-core/scripts/download-cli.sh rename to .github/actions/deploy-core/scripts/download-app-cli.sh index 441fbe41..e6fda484 100755 --- a/.github/actions/deploy-core/scripts/download-cli.sh +++ b/.github/actions/deploy-core/scripts/download-app-cli.sh @@ -3,12 +3,12 @@ set -euo pipefail # Extracts the build-app-cli artifact tar (downloaded by actions/download-artifact) # into an action-owned tool dir, preserving the executable bit, reads the -# self-describing cli-meta.json, and prepends the dir to PATH for action steps. -# A wrapper-supplied EDGEZERO__INPUT__CLI_BIN overrides the metadata's binary name. +# self-describing app-cli-meta.json, and prepends the dir to PATH for action steps. +# A wrapper-supplied EDGEZERO__INPUT__APP_CLI_BIN overrides the metadata's binary name. # # Inputs (environment): -# EDGEZERO__CLI__ARTIFACT_DIR required dir containing the downloaded tar -# EDGEZERO__INPUT__CLI_BIN optional override for the binary name +# EDGEZERO__APP__CLI__ARTIFACT_DIR required dir containing the downloaded tar +# EDGEZERO__INPUT__APP_CLI_BIN optional override for the binary name # EDGEZERO__ACTION__TOOL_ROOT optional install dir (defaults under RUNNER_TEMP) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) @@ -22,8 +22,8 @@ find_cli_tarball() { } main() { - local artifact_dir="${EDGEZERO__CLI__ARTIFACT_DIR:?EDGEZERO__CLI__ARTIFACT_DIR is required}" - local cli_bin_override="${EDGEZERO__INPUT__CLI_BIN:-}" + local artifact_dir="${EDGEZERO__APP__CLI__ARTIFACT_DIR:?EDGEZERO__APP__CLI__ARTIFACT_DIR is required}" + local cli_bin_override="${EDGEZERO__INPUT__APP_CLI_BIN:-}" local tool_root="${EDGEZERO__ACTION__TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" require_cmd jq @@ -36,11 +36,11 @@ main() { assert_safe_tarball "$tarball" tar -xf "$tarball" -C "$tool_root/bin" - [[ -f "$tool_root/bin/cli-meta.json" ]] || fail "CLI artifact is missing cli-meta.json" + [[ -f "$tool_root/bin/app-cli-meta.json" ]] || fail "CLI artifact is missing app-cli-meta.json" local meta_bin cli_version cli_bin - meta_bin=$(jq -er '."cli-bin"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-bin" - cli_version=$(jq -er '."cli-version"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-version" + meta_bin=$(jq -er '."app-cli-bin"' "$tool_root/bin/app-cli-meta.json") || fail "app-cli-meta.json has no app-cli-bin" + cli_version=$(jq -er '."app-cli-version"' "$tool_root/bin/app-cli-meta.json") || fail "app-cli-meta.json has no app-cli-version" cli_bin="${cli_bin_override:-$meta_bin}" validate_cli_bin "$cli_bin" @@ -57,8 +57,8 @@ main() { export PATH="$tool_root/bin:$PATH" notice "using app CLI '$cli_bin' v$cli_version from artifact" - append_output cli-bin "$cli_bin" - append_output cli-version "$cli_version" + append_output app-cli-bin "$cli_bin" + append_output app-cli-version "$cli_version" append_env EDGEZERO__ACTION__TOOL_ROOT "$tool_root" } diff --git a/.github/actions/deploy-core/scripts/resolve-project.sh b/.github/actions/deploy-core/scripts/resolve-project.sh index 10aa4e91..7ce8b628 100755 --- a/.github/actions/deploy-core/scripts/resolve-project.sh +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -8,7 +8,7 @@ set -euo pipefail # # Inputs (environment): EDGEZERO__INPUT__WORKING_DIRECTORY, EDGEZERO__INPUT__MANIFEST, # EDGEZERO__INPUT__RUST_TOOLCHAIN, EDGEZERO__INPUT__TARGET (required), EDGEZERO__INPUT__BUILD_MODE, EDGEZERO__INPUT__CACHE, -# EDGEZERO__ACTION__ROOT (required), EDGEZERO__CLI__VERSION. +# EDGEZERO__ACTION__ROOT (required), EDGEZERO__APP__CLI__VERSION. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -91,7 +91,7 @@ main() { local rust_toolchain_input="${EDGEZERO__INPUT__RUST_TOOLCHAIN:-auto}" local target="${EDGEZERO__INPUT__TARGET:?EDGEZERO__INPUT__TARGET is required (wrapper-provided concrete target)}" local cache="${EDGEZERO__INPUT__CACHE:-false}" - local cli_version="${EDGEZERO__CLI__VERSION:-unknown}" + local cli_version="${EDGEZERO__APP__CLI__VERSION:-unknown}" require_cmd git require_cmd cargo diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-app-cli.sh similarity index 97% rename from .github/actions/deploy-core/scripts/run-cli.sh rename to .github/actions/deploy-core/scripts/run-app-cli.sh index 6d7037d2..b7e4b2ab 100755 --- a/.github/actions/deploy-core/scripts/run-cli.sh +++ b/.github/actions/deploy-core/scripts/run-app-cli.sh @@ -16,7 +16,7 @@ set -euo pipefail # never survive into the deploy. Build mode is credential-free and only clears. # # Inputs (environment): -# EDGEZERO__CLI__BIN required binary name to invoke (on PATH) +# EDGEZERO__APP__CLI__BIN required binary name to invoke (on PATH) # EDGEZERO__ADAPTER required adapter passed as --adapter # EDGEZERO__PROJECT__WORKING_DIRECTORY required directory to run the CLI from # EDGEZERO__PROJECT__MANIFEST_PATH optional exported as EDGEZERO_MANIFEST when set @@ -153,10 +153,10 @@ main() { local mode="${1:-}" case "$mode" in build | deploy) ;; - *) fail "usage: run-cli.sh build|deploy" ;; + *) fail "usage: run-app-cli.sh build|deploy" ;; esac - local cli_bin="${EDGEZERO__CLI__BIN:?EDGEZERO__CLI__BIN is required}" + local cli_bin="${EDGEZERO__APP__CLI__BIN:?EDGEZERO__APP__CLI__BIN is required}" local adapter="${EDGEZERO__ADAPTER:?EDGEZERO__ADAPTER is required}" local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:?EDGEZERO__PROJECT__WORKING_DIRECTORY is required}" local manifest="${EDGEZERO__PROJECT__MANIFEST_PATH:-}" diff --git a/.github/actions/deploy-core/scripts/write-summary.sh b/.github/actions/deploy-core/scripts/write-summary.sh index 73890783..2e330d6d 100755 --- a/.github/actions/deploy-core/scripts/write-summary.sh +++ b/.github/actions/deploy-core/scripts/write-summary.sh @@ -18,7 +18,7 @@ main() { echo "| Manifest | ${EDGEZERO__SUMMARY__MANIFEST:-EdgeZero default discovery} |" echo "| Rust toolchain | ${EDGEZERO__SUMMARY__RUST_TOOLCHAIN:-unknown} |" echo "| Target | ${EDGEZERO__SUMMARY__TARGET:-unknown} |" - echo "| CLI version | ${EDGEZERO__SUMMARY__CLI_VERSION:-unknown} |" + echo "| CLI version | ${EDGEZERO__SUMMARY__APP_CLI_VERSION:-unknown} |" echo "| Effective build mode | ${EDGEZERO__SUMMARY__EFFECTIVE_BUILD_MODE:-unknown} |" echo "| Cache | ${EDGEZERO__SUMMARY__CACHE:-false} |" echo "| Result | ${EDGEZERO__SUMMARY__RESULT:-unknown} |" diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index 7e16de13..ed4cf344 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -142,7 +142,7 @@ test_owned_dir_confinement() { } # --------------------------------------------------------------------------- -# download-cli — cli-bin confinement + unsafe archive rejection +# download-app-cli — app-cli-bin confinement + unsafe archive rejection # --------------------------------------------------------------------------- check_cli_bin() { bash -c 'source "$1"; validate_cli_bin "$2"' _ "$CORE_SCRIPTS/common.sh" "$1" @@ -153,11 +153,11 @@ check_tarball() { } test_cli_bin_confinement() { - section "download-cli cli-bin + archive safety" - assert_succeeds "accepts a bare binary name" check_cli_bin "myapp-cli" - assert_fails "rejects a traversal cli-bin ('../../outside/tool')" check_cli_bin "../../outside/tool" - assert_fails "rejects a cli-bin with a separator" check_cli_bin "sub/tool" - assert_fails "rejects an empty cli-bin" check_cli_bin "" + section "download-app-cli app-cli-bin + archive safety" + assert_succeeds "accepts a bare app-cli-bin" check_cli_bin "myapp-cli" + assert_fails "rejects a traversal app-cli-bin ('../../outside/tool')" check_cli_bin "../../outside/tool" + assert_fails "rejects an app-cli-bin with a separator" check_cli_bin "sub/tool" + assert_fails "rejects an empty app-cli-bin" check_cli_bin "" # A tar carrying a symlink member must be refused before extraction. local evil="$WORK_DIR/evil" @@ -170,13 +170,13 @@ test_cli_bin_confinement() { local good="$WORK_DIR/good" mkdir -p "$good/stage" echo x >"$good/stage/myapp-cli" - printf '{}' >"$good/stage/cli-meta.json" - tar -C "$good/stage" -cf "$good/good.tar" myapp-cli cli-meta.json + printf '{}' >"$good/stage/app-cli-meta.json" + tar -C "$good/stage" -cf "$good/good.tar" myapp-cli app-cli-meta.json assert_succeeds "accepts a well-formed archive" check_tarball "$good/good.tar" } # --------------------------------------------------------------------------- -# run-cli.sh — provider-env credential boundary +# run-app-cli.sh — provider-env credential boundary # --------------------------------------------------------------------------- # A fake CLI records the FASTLY_* it actually saw; run-cli must clear inherited # aliases and export only the declared, typed values. @@ -198,12 +198,12 @@ EOF run_deploy_pe() { env -i PATH="$bin_dir:$PATH" \ - EDGEZERO__CLI__BIN=fakecli EDGEZERO__ADAPTER=fastly \ + EDGEZERO__APP__CLI__BIN=fakecli EDGEZERO__ADAPTER=fastly \ EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" \ EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$clear" \ EDGEZERO__PROVIDER__ENV="$1" \ FASTLY_API_TOKEN=inherited-BAD FASTLY_ENDPOINT=https://inherited.invalid \ - bash "$CORE_SCRIPTS/run-cli.sh" deploy + bash "$CORE_SCRIPTS/run-app-cli.sh" deploy } if run_deploy_pe '{"FASTLY_API_TOKEN":"typed-tok"}' >/dev/null 2>&1; then @@ -219,7 +219,7 @@ EOF } # --------------------------------------------------------------------------- -# run-cli.sh — CLI argv construction +# run-app-cli.sh — CLI argv construction # --------------------------------------------------------------------------- # Installs a fake CLI that records its argv, then asserts run-cli places typed # deploy-flags before `--` and caller passthrough after `--`. @@ -242,12 +242,12 @@ EOF printf -- '--comment\0hello\0' >"$WORK_DIR/deploy-args.nul" if env -i PATH="$bin_dir:$PATH" \ - EDGEZERO__CLI__BIN=fakecli \ + EDGEZERO__APP__CLI__BIN=fakecli \ EDGEZERO__ADAPTER=fastly \ EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" \ EDGEZERO__DEPLOY__FLAGS_FILE="$WORK_DIR/deploy-flags.nul" \ EDGEZERO__DEPLOY__ARGS_FILE="$WORK_DIR/deploy-args.nul" \ - bash "$CORE_SCRIPTS/run-cli.sh" deploy >/dev/null 2>&1; then + bash "$CORE_SCRIPTS/run-app-cli.sh" deploy >/dev/null 2>&1; then local expected expected=$'deploy\n--adapter\nfastly\n--service-id\nabc\n--stage\n--\n--comment\nhello' assert_equals "flags precede --, passthrough follows --" "$expected" "$(cat "$argv_file")" @@ -257,12 +257,12 @@ EOF } # --------------------------------------------------------------------------- -# download-cli.sh — self-describing artifact +# download-app-cli.sh — self-describing artifact # --------------------------------------------------------------------------- -# Builds a fake artifact tar (binary + cli-meta.json) and asserts download-cli +# Builds a fake artifact tar (binary + app-cli-meta.json) and asserts download-cli # extracts it and surfaces the metadata. test_download_cli_metadata() { - section "download-cli metadata" + section "download-app-cli metadata" local artifact_dir="$WORK_DIR/artifact" local stage_dir="$artifact_dir/stage" @@ -273,24 +273,24 @@ test_download_cli_metadata() { exit 0 EOF chmod +x "$stage_dir/myapp-cli" - printf '{"cli-bin":"myapp-cli","cli-version":"1.2.3","cli-package":"myapp-cli"}\n' \ - >"$stage_dir/cli-meta.json" - tar -C "$stage_dir" -cf "$artifact_dir/edgezero-cli.tar" myapp-cli cli-meta.json + printf '{"app-cli-bin":"myapp-cli","app-cli-version":"1.2.3","app-cli-package":"myapp-cli"}\n' \ + >"$stage_dir/app-cli-meta.json" + tar -C "$stage_dir" -cf "$artifact_dir/edgezero-cli.tar" myapp-cli app-cli-meta.json local output_file="$WORK_DIR/download-output.txt" if env -i PATH="$PATH" \ - EDGEZERO__CLI__ARTIFACT_DIR="$artifact_dir" \ + EDGEZERO__APP__CLI__ARTIFACT_DIR="$artifact_dir" \ EDGEZERO__ACTION__TOOL_ROOT="$WORK_DIR/tools" \ GITHUB_OUTPUT="$output_file" \ GITHUB_PATH="$WORK_DIR/download-path.txt" \ - bash "$CORE_SCRIPTS/download-cli.sh" >/dev/null 2>&1; then - if grep -qx 'cli-bin=myapp-cli' "$output_file" && grep -qx 'cli-version=1.2.3' "$output_file"; then - pass "extracts the tar and reads cli-meta.json" + bash "$CORE_SCRIPTS/download-app-cli.sh" >/dev/null 2>&1; then + if grep -qx 'app-cli-bin=myapp-cli' "$output_file" && grep -qx 'app-cli-version=1.2.3' "$output_file"; then + pass "extracts the tar and reads app-cli-meta.json" else - fail "download-cli did not surface the expected metadata" + fail "download-app-cli did not surface the expected metadata" fi else - fail "download-cli failed to execute" + fail "download-app-cli failed to execute" fi } @@ -345,7 +345,7 @@ test_cleanup_confinement() { } # --------------------------------------------------------------------------- -# run-cli.sh — the action's private env must not survive into the app CLI +# run-app-cli.sh — the action's private env must not survive into the app CLI # --------------------------------------------------------------------------- test_action_env_scrub() { section "action-private env scrub" @@ -366,12 +366,12 @@ CLI local out out=$( PATH="$dir/bin:$PATH" \ - EDGEZERO__CLI__BIN=scrub-cli EDGEZERO__ADAPTER=fastly EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir" \ + EDGEZERO__APP__CLI__BIN=scrub-cli EDGEZERO__ADAPTER=fastly EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir" \ EDGEZERO__PROJECT__MANIFEST_PATH="$dir/edgezero.toml" \ EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ EDGEZERO__PROVIDER__ENV='{"FASTLY_API_TOKEN":"s3cret"}' \ EDGEZERO__FASTLY__API_TOKEN='s3cret' \ - "$CORE_SCRIPTS/run-cli.sh" deploy 2>/dev/null + "$CORE_SCRIPTS/run-app-cli.sh" deploy 2>/dev/null ) # What the CLI IS promised. diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 5bc11369..e845a16d 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -2,10 +2,10 @@ name: EdgeZero deploy-fastly description: Deploy a checked-out EdgeZero application to Fastly Compute using a prebuilt app CLI artifact. inputs: - cli-artifact: + app-cli-artifact: description: Name of the build-app-cli artifact to download and run. required: true - cli-bin: + app-cli-bin: description: Binary name inside the artifact. Defaults to the artifact metadata. required: false default: "" @@ -51,9 +51,9 @@ outputs: source-revision: description: Git revision deployed from working-directory. value: ${{ steps.resolve.outputs['source-revision'] }} - cli-version: + app-cli-version: description: Version of the app CLI consumed from the artifact. - value: ${{ steps.cli.outputs['cli-version'] }} + value: ${{ steps.cli.outputs['app-cli-version'] }} runs: using: composite @@ -105,7 +105,7 @@ runs: - name: Download CLI artifact uses: actions/download-artifact@v4 with: - name: ${{ inputs['cli-artifact'] }} + name: ${{ inputs['app-cli-artifact'] }} path: ${{ runner.temp }}/edgezero-cli-download env: FASTLY_API_TOKEN: "" @@ -128,9 +128,9 @@ runs: id: cli shell: bash env: - EDGEZERO__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools - EDGEZERO__INPUT__CLI_BIN: ${{ inputs['cli-bin'] }} + EDGEZERO__INPUT__APP_CLI_BIN: ${{ inputs['app-cli-bin'] }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -146,14 +146,14 @@ runs: FASTLY_DEBUG_MODE: "" FASTLY_CONFIG_FILE: "" FASTLY_HOME: "" - run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh - name: Resolve project id: resolve shell: bash env: EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. - EDGEZERO__CLI__VERSION: ${{ steps.cli.outputs['cli-version'] }} + EDGEZERO__APP__CLI__VERSION: ${{ steps.cli.outputs['app-cli-version'] }} EDGEZERO__INPUT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} EDGEZERO__INPUT__MANIFEST: ${{ inputs.manifest }} EDGEZERO__INPUT__RUST_TOOLCHAIN: auto @@ -256,7 +256,7 @@ runs: if: ${{ steps.resolve.outputs['effective-build-mode'] == 'always' }} shell: bash env: - EDGEZERO__CLI__BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} EDGEZERO__ADAPTER: fastly EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} EDGEZERO__PROJECT__MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} @@ -277,20 +277,20 @@ runs: FASTLY_DEBUG_MODE: "" FASTLY_CONFIG_FILE: "" FASTLY_HOME: "" - run: $GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh build + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/run-app-cli.sh build - name: Deploy id: deploy shell: bash env: - EDGEZERO__CLI__BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} EDGEZERO__ADAPTER: fastly EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} EDGEZERO__PROJECT__MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} EDGEZERO__DEPLOY__FLAGS_FILE: ${{ steps.validate.outputs['deploy-flags-file'] }} EDGEZERO__DEPLOY__ARGS_FILE: ${{ steps.validate.outputs['deploy-args-file'] }} # Credential boundary: pass the typed values as data (not as FASTLY_* - # aliases). run-cli.sh clears every provider-env-clear alias — including + # aliases). run-app-cli.sh clears every provider-env-clear alias — including # any inherited FASTLY_ENDPOINT/FASTLY_TOKEN — and then exports only these. EDGEZERO__PROVIDER__ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} EDGEZERO__FASTLY__API_TOKEN: ${{ inputs['fastly-api-token'] }} @@ -330,7 +330,7 @@ runs: EDGEZERO__SUMMARY__MANIFEST: ${{ steps.resolve.outputs['manifest-summary'] }} EDGEZERO__SUMMARY__RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} EDGEZERO__SUMMARY__TARGET: wasm32-wasip1 - EDGEZERO__SUMMARY__CLI_VERSION: ${{ steps.cli.outputs['cli-version'] }} + EDGEZERO__SUMMARY__APP_CLI_VERSION: ${{ steps.cli.outputs['app-cli-version'] }} EDGEZERO__SUMMARY__EFFECTIVE_BUILD_MODE: ${{ steps.resolve.outputs['effective-build-mode'] }} EDGEZERO__SUMMARY__CACHE: ${{ inputs.cache }} EDGEZERO__SUMMARY__RESULT: ${{ steps.deploy.outcome }} diff --git a/.github/actions/deploy-fastly/scripts/deploy.sh b/.github/actions/deploy-fastly/scripts/deploy.sh index ed014cad..c79940cc 100755 --- a/.github/actions/deploy-fastly/scripts/deploy.sh +++ b/.github/actions/deploy-fastly/scripts/deploy.sh @@ -4,15 +4,15 @@ set -euo pipefail # Runs the application CLI's deploy through the provider-env credential boundary # and emits the resulting Fastly version. # -# Credentials are handed to run-cli.sh as DATA (a JSON object), not as FASTLY_* -# aliases on this step. run-cli.sh clears every declared alias — including any +# Credentials are handed to run-app-cli.sh as DATA (a JSON object), not as FASTLY_* +# aliases on this step. run-app-cli.sh clears every declared alias — including any # inherited FASTLY_ENDPOINT / FASTLY_TOKEN — exports only these typed values, and # then scrubs its own private variables (including this JSON) before exec'ing the # CLI. Building the JSON here, from step `env:`, is also what keeps the secret out # of an interpolated `run:` block. # # Inputs (environment): EDGEZERO__FASTLY__API_TOKEN, -# EDGEZERO__FASTLY__SERVICE_ID, plus the run-cli.sh contract. +# EDGEZERO__FASTLY__SERVICE_ID, plus the run-app-cli.sh contract. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../../deploy-core/scripts/common.sh @@ -31,7 +31,7 @@ main() { export EDGEZERO__PROVIDER__ENV new_private_log - "$SCRIPT_DIR/../../deploy-core/scripts/run-cli.sh" deploy 2>&1 | tee "$LIFECYCLE_LOG" + "$SCRIPT_DIR/../../deploy-core/scripts/run-app-cli.sh" deploy 2>&1 | tee "$LIFECYCLE_LOG" local version version=$(read_numeric_line version "$LIFECYCLE_LOG") diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index afe01538..d925bf55 100644 --- a/.github/actions/healthcheck-fastly/action.yml +++ b/.github/actions/healthcheck-fastly/action.yml @@ -2,10 +2,10 @@ name: EdgeZero healthcheck-fastly description: Probe a deployed Fastly version's health via the app CLI. Exits non-zero when unhealthy after retries. inputs: - cli-artifact: + app-cli-artifact: description: Name of the build-app-cli artifact to download and run. required: true - cli-bin: + app-cli-bin: description: Binary name inside the artifact. Defaults to the artifact metadata. required: false default: "" @@ -52,7 +52,7 @@ runs: - name: Download CLI artifact uses: actions/download-artifact@v4 with: - name: ${{ inputs['cli-artifact'] }} + name: ${{ inputs['app-cli-artifact'] }} path: ${{ runner.temp }}/edgezero-cli-download env: FASTLY_API_TOKEN: "" @@ -75,9 +75,9 @@ runs: id: cli shell: bash env: - EDGEZERO__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools - EDGEZERO__INPUT__CLI_BIN: ${{ inputs['cli-bin'] }} + EDGEZERO__INPUT__APP_CLI_BIN: ${{ inputs['app-cli-bin'] }} FASTLY_API_TOKEN: "" FASTLY_TOKEN: "" FASTLY_KEY: "" @@ -93,13 +93,13 @@ runs: FASTLY_DEBUG_MODE: "" FASTLY_CONFIG_FILE: "" FASTLY_HOME: "" - run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh - name: Health check id: check shell: bash env: - EDGEZERO__CLI__BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} EDGEZERO__LIFECYCLE__SERVICE_ID: ${{ inputs['fastly-service-id'] }} EDGEZERO__LIFECYCLE__VERSION: ${{ inputs['fastly-version'] }} EDGEZERO__LIFECYCLE__DOMAIN: ${{ inputs.domain }} diff --git a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh index cc48254d..692d7ae2 100755 --- a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh +++ b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh @@ -7,7 +7,7 @@ set -euo pipefail # prove the deployment is healthy must exit non-zero: a non-zero CLI, a # `healthy=false` verdict, and — critically — no verdict at all. # -# Inputs (environment): EDGEZERO__CLI__BIN, EDGEZERO__LIFECYCLE__SERVICE_ID, EDGEZERO__LIFECYCLE__VERSION, EDGEZERO__LIFECYCLE__DOMAIN, EDGEZERO__DEPLOY__TO, EDGEZERO__LIFECYCLE__RETRY, +# Inputs (environment): EDGEZERO__APP__CLI__BIN, EDGEZERO__LIFECYCLE__SERVICE_ID, EDGEZERO__LIFECYCLE__VERSION, EDGEZERO__LIFECYCLE__DOMAIN, EDGEZERO__DEPLOY__TO, EDGEZERO__LIFECYCLE__RETRY, # EDGEZERO__LIFECYCLE__RETRY_DELAY, EDGEZERO__LIFECYCLE__TIMEOUT, FASTLY_API_TOKEN. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) @@ -36,7 +36,7 @@ main() { validate_inputs local argv=( - "$EDGEZERO__CLI__BIN" healthcheck + "$EDGEZERO__APP__CLI__BIN" healthcheck --adapter fastly --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" --version "$EDGEZERO__LIFECYCLE__VERSION" diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml index d3bc6e17..b96af3fa 100644 --- a/.github/actions/rollback-fastly/action.yml +++ b/.github/actions/rollback-fastly/action.yml @@ -2,10 +2,10 @@ name: EdgeZero rollback-fastly description: Roll back a Fastly deployment via the app CLI. Production activates the previous version; staging deactivates the staged version. inputs: - cli-artifact: + app-cli-artifact: description: Name of the build-app-cli artifact to download and run. required: true - cli-bin: + app-cli-bin: description: Binary name inside the artifact. Defaults to the artifact metadata. required: false default: "" @@ -34,7 +34,7 @@ runs: - name: Download CLI artifact uses: actions/download-artifact@v4 with: - name: ${{ inputs['cli-artifact'] }} + name: ${{ inputs['app-cli-artifact'] }} path: ${{ runner.temp }}/edgezero-cli-download env: FASTLY_API_TOKEN: "" @@ -57,9 +57,9 @@ runs: id: cli shell: bash env: - EDGEZERO__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools - EDGEZERO__INPUT__CLI_BIN: ${{ inputs['cli-bin'] }} + EDGEZERO__INPUT__APP_CLI_BIN: ${{ inputs['app-cli-bin'] }} FASTLY_API_TOKEN: "" FASTLY_TOKEN: "" FASTLY_KEY: "" @@ -75,13 +75,13 @@ runs: FASTLY_DEBUG_MODE: "" FASTLY_CONFIG_FILE: "" FASTLY_HOME: "" - run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh - name: Rollback id: rollback shell: bash env: - EDGEZERO__CLI__BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} EDGEZERO__LIFECYCLE__SERVICE_ID: ${{ inputs['fastly-service-id'] }} EDGEZERO__LIFECYCLE__VERSION: ${{ inputs['fastly-version'] }} EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} diff --git a/.github/actions/rollback-fastly/scripts/rollback.sh b/.github/actions/rollback-fastly/scripts/rollback.sh index 196265f9..c26d63d2 100755 --- a/.github/actions/rollback-fastly/scripts/rollback.sh +++ b/.github/actions/rollback-fastly/scripts/rollback.sh @@ -7,7 +7,7 @@ set -euo pipefail # Fails closed: a rollback that cannot say what it activated has not provably # rolled anything back. # -# Inputs (environment): EDGEZERO__CLI__BIN, EDGEZERO__LIFECYCLE__SERVICE_ID, EDGEZERO__LIFECYCLE__VERSION, EDGEZERO__DEPLOY__TO, +# Inputs (environment): EDGEZERO__APP__CLI__BIN, EDGEZERO__LIFECYCLE__SERVICE_ID, EDGEZERO__LIFECYCLE__VERSION, EDGEZERO__DEPLOY__TO, # FASTLY_API_TOKEN. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) @@ -29,7 +29,7 @@ validate_inputs() { main() { validate_inputs - local argv=("$EDGEZERO__CLI__BIN" rollback --adapter fastly --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" --version "$EDGEZERO__LIFECYCLE__VERSION") + local argv=("$EDGEZERO__APP__CLI__BIN" rollback --adapter fastly --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" --version "$EDGEZERO__LIFECYCLE__VERSION") if [[ "$EDGEZERO__DEPLOY__TO" == "staging" ]]; then argv+=(--staging) fi diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index c280a51b..364bcd4a 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -126,14 +126,14 @@ jobs: id: cli uses: ./.github/actions/build-app-cli with: - cli-package: fixture-app-cli + app-cli-package: fixture-app-cli working-directory: fixture-app - name: Deploy fixture (production) with local action id: deploy uses: ./.github/actions/deploy-fastly with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: fixture-app fastly-api-token: dummy-token fastly-service-id: dummy-service @@ -172,7 +172,7 @@ jobs: id: cli uses: ./.github/actions/build-app-cli with: - cli-package: fixture-app-cli + app-cli-package: fixture-app-cli working-directory: fixture-app # Seeds the action-owned tool root with a fake `fastly` reporting the @@ -185,7 +185,7 @@ jobs: id: stage uses: ./.github/actions/deploy-fastly with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: fixture-app fastly-api-token: dummy-token fastly-service-id: dummy-service @@ -200,7 +200,7 @@ jobs: - name: Health check the staged version uses: ./.github/actions/healthcheck-fastly with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} deploy-to: staging domain: staging.example.com fastly-version: ${{ steps.stage.outputs['fastly-version'] }} @@ -219,7 +219,7 @@ jobs: continue-on-error: true uses: ./.github/actions/healthcheck-fastly with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} deploy-to: staging domain: staging.example.com fastly-version: ${{ steps.stage.outputs['fastly-version'] }} @@ -240,7 +240,7 @@ jobs: - name: Roll back the staged version uses: ./.github/actions/rollback-fastly with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} deploy-to: staging fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token @@ -250,7 +250,7 @@ jobs: id: prod-rollback uses: ./.github/actions/rollback-fastly with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} deploy-to: production fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token diff --git a/docs/guide/deploy-github-actions.md b/docs/guide/deploy-github-actions.md index c131baa4..84f22803 100644 --- a/docs/guide/deploy-github-actions.md +++ b/docs/guide/deploy-github-actions.md @@ -29,7 +29,7 @@ provider-neutral behavior; the wrappers above are thin. - **Checkout.** The actions never call `actions/checkout` — you own checkout, ref selection, permissions, environments, concurrency, and timeouts. - **A CLI package.** Name a Cargo package in your own workspace (the crate that - builds your `edgezero`-based CLI binary) via `cli-package`. `build-app-cli` + builds your `edgezero`-based CLI binary) via `app-cli-package`. `build-app-cli` compiles exactly that, from your checkout's `Cargo.lock`, so the CLI and your app can never disagree on schema. - **Typed provider credentials.** Pass `fastly-api-token` / `fastly-service-id` @@ -52,11 +52,11 @@ jobs: - id: cli uses: stackpop/edgezero/.github/actions/build-app-cli@ with: - cli-package: my-app-cli # the CLI crate in your workspace + app-cli-package: my-app-cli # the CLI crate in your workspace - uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` @@ -91,12 +91,12 @@ steps: - id: cli uses: stackpop/edgezero/.github/actions/build-app-cli@ with: - cli-package: my-app-cli + app-cli-package: my-app-cli working-directory: app - uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: app fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} @@ -113,12 +113,12 @@ workspace may be the subdirectory itself), so a monorepo caches the right - id: cli uses: stackpop/edgezero/.github/actions/build-app-cli@ with: - cli-package: api-cli + app-cli-package: api-cli working-directory: apps/api - uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: apps/api manifest: edgezero.toml cache: true @@ -132,22 +132,22 @@ workspace may be the subdirectory itself), so a monorepo caches the right | Input | Required | Default | Meaning | | ------------------- | -------- | --------------- | ---------------------------------------------------------------- | -| `cli-package` | Yes | — | Cargo package name of the CLI, in your app's workspace. | -| `cli-bin` | No | `` | Binary name the package produces. | +| `app-cli-package` | Yes | — | Cargo package name of the CLI, in your app's workspace. | +| `app-cli-bin` | No | `` | Binary name the package produces. | | `working-directory` | No | `.` | App directory (relative to `github.workspace`). | | `rust-toolchain` | No | `auto` | Explicit toolchain, or `auto` (rustup files → `.tool-versions`). | -| `artifact-name` | No | `edgezero-cli` | Uploaded artifact name. | +| `app-cli-artifact` | No | `edgezero-cli` | Uploaded artifact name. | -Outputs: `cli-version`, `cli-package`, `cli-bin`, `artifact-name`. +Outputs: `app-cli-version`, `app-cli-package`, `app-cli-bin`, `artifact-name`. ### `deploy-fastly` | Input | Required | Default | Meaning | | ------------------- | -------- | --------------- | --------------------------------------------------------------- | -| `cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `app-cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | | `fastly-api-token` | Yes | — | Injected only into the deploy step. | | `fastly-service-id` | Yes | — | Passed as the typed `--service-id` flag. | -| `cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `app-cli-bin` | No | artifact's name | Binary name inside the artifact. | | `working-directory` | No | `.` | App directory. | | `manifest` | No | empty | Optional `edgezero.toml` path relative to `working-directory`. | | `build-mode` | No | `auto` | `auto` (→ `never` for Fastly), `always`, or `never`. | @@ -156,7 +156,7 @@ Outputs: `cli-version`, `cli-package`, `cli-bin`, `artifact-name`. | `stage` | No | `false` | Deploy to a staged draft version instead of activating. | | `cache` | No | `false` | Exact-key Cargo-workspace `target/` caching. | -Outputs: `fastly-version`, `source-revision`, `cli-version`. +Outputs: `fastly-version`, `source-revision`, `app-cli-version`. The action always adds `--non-interactive` to the deploy itself, so a deploy declared as an `edgezero.toml` command (`[adapters.fastly.commands] deploy = @@ -167,12 +167,12 @@ and cannot — pass it through `deploy-args`. | Input | Required | Default | Meaning | | ------------------- | -------- | --------------- | ------------------------------------------------------------- | -| `cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `app-cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | | `fastly-api-token` | Yes | — | Needed to resolve a staged version's IP. | | `fastly-service-id` | Yes | — | Service to probe. | | `fastly-version` | Yes | — | Version to probe — thread the deploy's `fastly-version`. | | `domain` | Yes | — | Domain to probe, e.g. `www.example.com`. | -| `cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `app-cli-bin` | No | artifact's name | Binary name inside the artifact. | | `deploy-to` | No | `production` | `staging` probes the staged version via its resolved edge IP. | | `retry` | No | `3` | Attempts before declaring the deployment unhealthy. | | `retry-delay` | No | `5` | Seconds between attempts. | @@ -187,11 +187,11 @@ your rollback on the step failing (`if: failure()`), not on the `healthy` output | Input | Required | Default | Meaning | | ------------------- | -------- | --------------- | ---------------------------------------------------------------------------------- | -| `cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `app-cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | | `fastly-api-token` | Yes | — | Fastly API token. | | `fastly-service-id` | Yes | — | Service to roll back. | | `fastly-version` | Yes | — | The current (bad) version to roll back **from**. | -| `cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `app-cli-bin` | No | artifact's name | Binary name inside the artifact. | | `deploy-to` | No | `production` | `production` activates the previous version; `staging` deactivates the staged one. | Outputs: `rolled-back-to` (production only — the version that was activated). @@ -235,12 +235,12 @@ carry no orchestration policy of their own. ```yaml - id: cli uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: { cli-package: my-app-cli } + with: { app-cli-package: my-app-cli } - id: stage uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} stage: true fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} @@ -248,7 +248,7 @@ carry no orchestration policy of their own. - id: check uses: stackpop/edgezero/.github/actions/healthcheck-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} deploy-to: staging domain: staging.example.com fastly-version: ${{ steps.stage.outputs.fastly-version }} @@ -258,7 +258,7 @@ carry no orchestration policy of their own. - if: failure() && steps.stage.outputs.fastly-version != '' uses: stackpop/edgezero/.github/actions/rollback-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} deploy-to: staging fastly-version: ${{ steps.stage.outputs.fastly-version }} fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index eff3f0b7..36599fd1 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -43,7 +43,7 @@ reference to port from. Most transfer with light changes: | `scripts/validate-inputs.sh` | `deploy-core/scripts/` | Reuse; move Fastly-specific allowlist to the wrapper. | | `scripts/resolve-project.sh` | `deploy-core/scripts/` | Reuse + split Git root vs Cargo workspace root. | | `scripts/install-rust.sh` | dropped | Replaced by `actions-rust-lang/setup-rust-toolchain@v1` in deploy-fastly (toolchain from resolve output + wasm target). build-app-cli keeps `rustup` for dynamic app-resolved toolchain install. | -| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | +| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | | `tests/run.sh` | `deploy-core/tests/` | Reuse the harness; add new cases. | | `scripts/install-fastly.sh`, `versions.json` | `deploy-fastly/` | Move (provider-specific install + checksum). | | `scripts/install-edgezero.sh` | → `build-app-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | @@ -54,37 +54,37 @@ reference to port from. Most transfer with light changes: ## Implementation phases 1. **`build-app-cli` action** - - Required `cli-package` input: the Cargo package name of the CLI defined in + - Required `app-cli-package` input: the Cargo package name of the CLI defined in the application's own workspace. Fail if missing or not found in the app workspace under `working-directory`. - `working-directory`, `rust-toolchain`, `artifact-name` inputs (no `adapters` input — the app's `Cargo.toml` pins adapters). - - Optional `cli-bin` input (default = `cli-package`; the generated CLI names + - Optional `app-cli-bin` input (default = `app-cli-package`; the generated CLI names its bin after the package). - Require a `Cargo.lock` at the app's Cargo workspace root; run all Cargo commands with `--locked` (never mutate the lockfile). Validate via - `cargo metadata --locked` that `cli-package` exists and declares a - `` binary target. + `cargo metadata --locked` that `app-cli-package` exists and declares a + `` binary target. - Install the host toolchain (no WASM target — the CLI is a native tool); build into an **action-owned `CARGO_TARGET_DIR` under `RUNNER_TEMP`** (never the checkout) so the CLI build leaves the app tree clean for the later dirty-source guard, via - `cargo build --locked --release -p --bin `. No + `cargo build --locked --release -p --bin `. No `--features` injection: the app's own `Cargo.toml` pins its adapters. - - Read `cli-version` from `cargo metadata`; smoke-check with ` --help` - (today's CLI has no `--version`); write `cli-meta.json` (`cli-bin`, - `cli-version`, `cli-package`) next to the binary and upload both as one + - Read `app-cli-version` from `cargo metadata`; smoke-check with ` --help` + (today's CLI has no `--version`); write `app-cli-meta.json` (`app-cli-bin`, + `app-cli-version`, `app-cli-package`) next to the binary and upload both as one **tar** so the executable bit survives `actions/upload-artifact` and the artifact is self-describing. - - Outputs: `cli-version`, `cli-package`, `cli-bin`, `artifact-name`. + - Outputs: `app-cli-version`, `app-cli-package`, `app-cli-bin`, `artifact-name`. - No provider credentials in scope. Never builds the EdgeZero monorepo CLI. 2. **`deploy-core` shared engine scripts (provider-neutral)** - A directory of scripts under `.github/actions/deploy-core/`, **not** a standalone composite action. Wrappers source them via `$GITHUB_ACTION_PATH/../deploy-core/…`. - - Non-secret parameters (available to all steps): `adapter`, `cli-artifact`, - `cli-bin`, `working-directory`, `manifest`, `rust-toolchain`, `target`, + - Non-secret parameters (available to all steps): `adapter`, `app-cli-artifact`, + `app-cli-bin`, `working-directory`, `manifest`, `rust-toolchain`, `target`, `build-mode`, `build-args`, `deploy-args`, `deploy-arg-allow`, `provider-env-clear`, `deploy-flags`, `cache`. - **`provider-env` is NOT one of these.** It is bound only to the deploy @@ -93,8 +93,8 @@ reference to port from. Most transfer with light changes: §5.2, §10). Setup/build see only the non-secret parameters plus `provider-env-clear`. - Download the CLI artifact (tar) under `RUNNER_TEMP`, extract preserving the - executable bit (or `chmod +x `), read `cli-meta.json` for - `cli-bin`/`cli-version` (wrapper `cli-bin` overrides), and PATH-scope it. + executable bit (or `chmod +x `), read `app-cli-meta.json` for + `app-cli-bin`/`app-cli-version` (wrapper `app-cli-bin` overrides), and PATH-scope it. - Validate `adapter` well-formedness (no compiled-adapter enumeration — the CLI rejects unknown adapters itself), booleans, JSON arrays/object, NUL bytes. @@ -116,17 +116,17 @@ reference to port from. Most transfer with light changes: - Deploy step only (its `env:` carries `provider-env`): clear the `provider-env-clear` aliases, parse `provider-env` and export only its values, then run - ` deploy --adapter -- ` via + ` deploy --adapter -- ` via Bash arrays. Note the build-in-deploy caveat: Fastly's default `never` compiles the app during deploy with the token in scope, so require trusted immutable refs (spec §10.1). - - Surface results to the wrapper: `adapter`, `source-revision`, `cli-version`, + - Surface results to the wrapper: `adapter`, `source-revision`, `app-cli-version`, `effective-build-mode`. - Contains no provider-specific credential names, service concepts, endpoints, - or CLI flags; invokes ``, never a hard-coded `edgezero`. + or CLI flags; invokes ``, never a hard-coded `edgezero`. 3. **`deploy-fastly` wrapper (minimal composite action)** - - Typed inputs: `cli-artifact`, `cli-bin`, `fastly-api-token`, + - Typed inputs: `app-cli-artifact`, `app-cli-bin`, `fastly-api-token`, `fastly-service-id`, plus forwarded `working-directory`, `manifest`, `build-mode`, `build-args`, `deploy-args`, `cache`, and `stage` (§5.4). - Map `fastly-api-token` → `provider-env: {FASTLY_API_TOKEN: …}` and @@ -145,12 +145,12 @@ reference to port from. Most transfer with light changes: `deploy-core` scripts; no build, toolchain, or path logic of its own. 4. **Fastly staging lifecycle actions (§5.4)** - - `healthcheck-fastly`: thin wrapper — inputs `cli-artifact`, `cli-bin`, + - `healthcheck-fastly`: thin wrapper — inputs `app-cli-artifact`, `app-cli-bin`, `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, `deploy-to` (`production`/`staging`), retry/timeout; runs ` healthcheck --adapter fastly --service-id --version …` with `FASTLY_API_TOKEN` in the step env; outputs `healthy`, `status-code`. - - `rollback-fastly`: thin wrapper — inputs `cli-artifact`, `cli-bin`, + - `rollback-fastly`: thin wrapper — inputs `app-cli-artifact`, `app-cli-bin`, `fastly-api-token`, `fastly-service-id`, `fastly-version`, `deploy-to`; runs ` rollback --adapter fastly --service-id --version …` with `FASTLY_API_TOKEN` in the step env; outputs `rolled-back-to`. diff --git a/docs/specs/edgezero-deploy-adoption-guide.md b/docs/specs/edgezero-deploy-adoption-guide.md index 3c8d1e87..1f3432c6 100644 --- a/docs/specs/edgezero-deploy-adoption-guide.md +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -46,11 +46,11 @@ jobs: - id: cli uses: stackpop/edgezero/.github/actions/build-app-cli@ with: - cli-package: my-app-cli # the CLI crate in your app's workspace + app-cli-package: my-app-cli # the CLI crate in your app's workspace - uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` @@ -95,12 +95,12 @@ steps: - id: cli uses: stackpop/edgezero/.github/actions/build-app-cli@ with: - cli-package: my-app-cli + app-cli-package: my-app-cli working-directory: app - uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: app fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} @@ -122,12 +122,12 @@ steps: - id: cli uses: stackpop/edgezero/.github/actions/build-app-cli@ with: - cli-package: api-cli + app-cli-package: api-cli working-directory: apps/api - uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: apps/api manifest: edgezero.toml cache: true @@ -139,13 +139,13 @@ steps: - Check out application source yourself; the actions never call `actions/checkout`. -- Provide a CLI package in your own workspace and name it via `cli-package`; +- Provide a CLI package in your own workspace and name it via `app-cli-package`; `build-app-cli` compiles that package from your checkout, so the CLI and your app never disagree on schema. `build-app-cli` does not use the EdgeZero monorepo CLI. - Provide typed provider credentials through wrapper inputs, not caller `env:`. - Ensure the deployed ref has committed source (no dirty working tree) and a `Cargo.lock` at your app's **Cargo workspace root** (the workspace that owns - `cli-package` — in a nested-workspace monorepo this may be your app + `app-cli-package` — in a nested-workspace monorepo this may be your app subdirectory, not the repo root). `build-app-cli` requires it, and caching keys on it. - Pin action references to readable released tags, or full SHAs for production @@ -216,7 +216,7 @@ Workflow shape: 1. check out `trusted-server-deployer` with `persist-credentials: false`; 2. check out Trusted Server source separately at the selected ref into `trusted-server`; -3. run `build-app-cli` with `cli-package: ` and +3. run `build-app-cli` with `app-cli-package: ` and `working-directory: trusted-server` (Trusted Server's own CLI package, whose `Cargo.toml` already pins the Fastly adapter); 4. run `deploy-fastly` (set `stage: true` for staging) with the CLI artifact, diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md index 3f04c853..0d2914bb 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -74,7 +74,7 @@ own CLI, with thin action wrappers — so the engine never grows provider logic. not fork the engine. 3. **The caller owns source.** The actions never call `actions/checkout`. 4. **The application provides the CLI package.** The app tells `build-app-cli` which - CLI package to compile via a required `cli-package` input, and `build-app-cli` + CLI package to compile via a required `app-cli-package` input, and `build-app-cli` builds that package from the application's own checkout. It never builds the EdgeZero monorepo CLI or the action's own repository revision. The application owns which CLI deploys it. @@ -145,7 +145,7 @@ own CLI, with thin action wrappers. ### 5.1 Layer 1 — `build-app-cli` Compiles the **CLI package the application provides** — a crate in the -application's own workspace, named by the required `cli-package` input — once, +application's own workspace, named by the required `app-cli-package` input — once, and publishes it as a workflow artifact so every downstream deploy step consumes the same binary. The CLI is built from the application checkout and its lockfile, so it matches the application and may include app-specific commands. `build-app-cli` @@ -153,13 +153,13 @@ never builds the EdgeZero monorepo CLI. **Inputs** -| Input | Required | Default | Contract | -| ------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `cli-package` | Yes | none | Cargo package name of the CLI to build, defined in the application's own workspace. `build-app-cli` builds this package from the application checkout. | -| `cli-bin` | No | `` | Binary name produced by `cli-package`. Defaults to the package name (the generated downstream CLI names its bin after the package). | -| `working-directory` | No | `.` | Application directory (relative to `github.workspace`) containing the workspace/lockfile that defines `cli-package`. Must resolve inside `github.workspace`. | -| `rust-toolchain` | No | `auto` | Explicit toolchain, or `auto` to follow the application toolchain resolution precedence (§7). | -| `artifact-name` | No | `edgezero-cli` | Name of the uploaded artifact. | +| Input | Required | Default | Contract | +| ------------------- | -------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `app-cli-package` | Yes | none | Cargo package name of the CLI to build, defined in the application's own workspace. `build-app-cli` builds this package from the application checkout. | +| `app-cli-bin` | No | `` | Binary name produced by `app-cli-package`. Defaults to the package name (the generated downstream CLI names its bin after the package). | +| `working-directory` | No | `.` | Application directory (relative to `github.workspace`) containing the workspace/lockfile that defines `app-cli-package`. Must resolve inside `github.workspace`. | +| `rust-toolchain` | No | `auto` | Explicit toolchain, or `auto` to follow the application toolchain resolution precedence (§7). | +| `app-cli-artifact` | No | `edgezero-cli` | Name of the uploaded artifact. | There is intentionally no `adapters` / features input. The application's own `Cargo.toml` already pins which adapters compile into its CLI (through the @@ -168,21 +168,21 @@ the application declares it, so the app owns adapter selection. **Outputs** -| Output | Meaning | -| --------------- | -------------------------------------------------------------- | -| `cli-version` | CLI package version, read from `cargo metadata` at build time. | -| `cli-package` | The application CLI package that was built. | -| `cli-bin` | The binary name inside the artifact. | -| `artifact-name` | Name of the uploaded CLI artifact for downstream `download`. | +| Output | Meaning | +| ----------------- | -------------------------------------------------------------- | +| `app-cli-version` | CLI package version, read from `cargo metadata` at build time. | +| `app-cli-package` | The application CLI package that was built. | +| `app-cli-bin` | The binary name inside the artifact. | +| `artifact-name` | Name of the uploaded CLI artifact for downstream `download`. | **Behavior** 1. Require a `Cargo.lock` at the app's Cargo workspace root (see §11.1); fail with a remediation message if it is missing. All Cargo commands run with `--locked` so the build never creates or updates the lockfile. -2. Confirm via `cargo metadata --locked` that `cli-package` exists in the +2. Confirm via `cargo metadata --locked` that `app-cli-package` exists in the application workspace under `working-directory` and that it declares a binary - target named `` (default ``). Fail if either is absent. + target named `` (default ``). Fail if either is absent. 3. Install the resolved host Rust toolchain (§7). The CLI is a native host tool; the WASM target needed to build the _application_ is installed later by the deploy engine, not here. @@ -192,21 +192,21 @@ the application declares it, so the app owns adapter selection. ```text CARGO_TARGET_DIR=/edgezero-cli-build \ - cargo build --locked --release -p --bin + cargo build --locked --release -p --bin ``` -5. Read `cli-version` from `cargo metadata` for `cli-package`, and smoke-check - the binary with ` --help` (today's CLI has no `--version`; see the +5. Read `app-cli-version` from `cargo metadata` for `app-cli-package`, and smoke-check + the binary with ` --help` (today's CLI has no `--version`; see the note below). -6. Write a small metadata file (`cli-meta.json`) next to the binary containing - `cli-bin`, `cli-version`, and `cli-package`. -7. Upload the binary **and `cli-meta.json`** as a single **tar archive** so the +6. Write a small metadata file (`app-cli-meta.json`) next to the binary containing + `app-cli-bin`, `app-cli-version`, and `app-cli-package`. +7. Upload the binary **and `app-cli-meta.json`** as a single **tar archive** so the executable bit survives the round trip (`actions/upload-artifact` zips and drops POSIX permissions). -The artifact is self-describing: the engine reads `cli-meta.json` to learn the -binary name and version, so callers do not have to re-pass `cli-bin`/`cli-version` -(a wrapper `cli-bin` input, if given, overrides the metadata). +The artifact is self-describing: the engine reads `app-cli-meta.json` to learn the +binary name and version, so callers do not have to re-pass `app-cli-bin`/`app-cli-version` +(a wrapper `app-cli-bin` input, if given, overrides the metadata). `build-app-cli` never receives provider credentials and leaves the app checkout clean (no `target/`, no lockfile mutation), so a later dirty-source guard passes. @@ -214,7 +214,7 @@ clean (no `target/`, no lockfile mutation), so a later dirty-source guard passes > **Companion CLI improvement (tracked separately):** the generated downstream > CLI template currently sets no clap `version`, so ` --version` fails. Add > `#[command(version)]` to the downstream CLI template so future apps expose a -> version surface. Until then, `cli-version` comes from `cargo metadata` and the +> version surface. Until then, `app-cli-version` comes from `cargo metadata` and the > runnability check uses `--help`. ### 5.2 Layer 2 — `deploy-core` (shared engine scripts) @@ -233,8 +233,8 @@ The engine is parameterized by the values the wrapper passes to those scripts | Parameter | Meaning | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `adapter` | Passed to ` deploy --adapter `. The engine does **not** enumerate compiled adapters; the CLI rejects an unknown adapter with its own error. | -| `cli-artifact` | Name of the `build-app-cli` artifact to download. The engine reads `cli-bin` and `cli-version` from the artifact's `cli-meta.json`. | -| `cli-bin` | Optional override for the binary name; if empty, taken from `cli-meta.json`. | +| `app-cli-artifact` | Name of the `build-app-cli` artifact to download. The engine reads `app-cli-bin` and `app-cli-version` from the artifact's `app-cli-meta.json`. | +| `app-cli-bin` | Optional override for the binary name; if empty, taken from `app-cli-meta.json`. | | `working-directory` | Application directory relative to `github.workspace`. Must resolve inside `github.workspace`. | | `manifest` | Optional `edgezero.toml` path relative to `working-directory`. If set, must exist; exported as `EDGEZERO_MANIFEST`. | | `rust-toolchain` | Application Rust toolchain for the deploy build. `auto` follows §7. | @@ -249,12 +249,12 @@ The engine is parameterized by the values the wrapper passes to those scripts | `cache` | Enable exact-key application `target/` caching (`true`/`false`). | The wrapper surfaces engine results as its own outputs: `adapter`, -`source-revision`, `cli-version`, `effective-build-mode`. +`source-revision`, `app-cli-version`, `effective-build-mode`. The engine contains no provider-specific credential names, service concepts, endpoints, or CLI flags — those, and the list of aliases to clear (`provider-env-clear`), all arrive from the wrapper. It invokes the application's -CLI binary (``), not a hard-coded `edgezero`. +CLI binary (``), not a hard-coded `edgezero`. ### 5.3 Layer 3 — adapter wrappers (`deploy-fastly`, …) @@ -280,8 +280,8 @@ is a wrapper concern; the engine assumes the provider CLI is already on `PATH`. | Input | Required | Default | Contract | | ------------------- | -------- | ------------- | ---------------------------------------------------------------------------------------------- | -| `cli-artifact` | Yes | none | `build-app-cli` artifact name. Forwarded to the engine. | -| `cli-bin` | No | from artifact | Binary name inside the artifact. Forwarded to the engine. | +| `app-cli-artifact` | Yes | none | `build-app-cli` artifact name. Forwarded to the engine. | +| `app-cli-bin` | No | from artifact | Binary name inside the artifact. Forwarded to the engine. | | `fastly-api-token` | Yes | none | Mapped into `provider-env` as `FASTLY_API_TOKEN`, deploy step only. | | `fastly-service-id` | Yes | none | Mapped into action-owned `deploy-flags` as `--service-id ` to prevent accidental creation. | | `working-directory` | No | `.` | Forwarded to the engine. | @@ -298,7 +298,7 @@ is a wrapper concern; the engine assumes the provider CLI is already on `PATH`. | ----------------- | ------------------------------------------------------------------------------------------ | | `fastly-version` | The Fastly service version deployed (production) or staged. Emitted by the app CLI (§5.4). | | `source-revision` | Passthrough from the engine. | -| `cli-version` | Passthrough from the engine. | +| `app-cli-version` | Passthrough from the engine. | The wrapper sets `adapter: fastly`, `target: wasm32-wasip1`, the action-owned `deploy-flags` (`--service-id …`, `--non-interactive`) so deployments cannot @@ -377,12 +377,12 @@ A caller wires the trio; the actions carry no orchestration policy of their own: ```yaml - id: cli uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: { cli-package: my-app-cli } + with: { app-cli-package: my-app-cli } - id: stage uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} stage: true fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} @@ -390,7 +390,7 @@ A caller wires the trio; the actions carry no orchestration policy of their own: - id: check uses: stackpop/edgezero/.github/actions/healthcheck-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} deploy-to: staging domain: staging.example.com fastly-version: ${{ steps.stage.outputs.fastly-version }} @@ -400,7 +400,7 @@ A caller wires the trio; the actions carry no orchestration policy of their own: - if: failure() && steps.stage.outputs.fastly-version != '' uses: stackpop/edgezero/.github/actions/rollback-fastly@ with: - cli-artifact: ${{ steps.cli.outputs.artifact-name }} + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} deploy-to: staging fastly-version: ${{ steps.stage.outputs.fastly-version }} fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} @@ -418,9 +418,9 @@ adapter adds its own lifecycle actions if its provider supports staging. command); an unsupported adapter surfaces as the CLI's own error at build or deploy time. 3. Validate exact boolean inputs. -4. Download the `cli-artifact` (a tar) into an action-owned directory below - `RUNNER_TEMP`, extract it preserving permissions (or `chmod +x `), - read `cli-meta.json` for `cli-bin`/`cli-version` (a wrapper `cli-bin` input +4. Download the `app-cli-artifact` (a tar) into an action-owned directory below + `RUNNER_TEMP`, extract it preserving permissions (or `chmod +x `), + read `app-cli-meta.json` for `app-cli-bin`/`app-cli-version` (a wrapper `app-cli-bin` input overrides), and prepend the directory to `PATH` for action steps only. 5. Parse `build-args`, `deploy-args`, `deploy-flags`, `provider-env-clear` as JSON string arrays. **`provider-env` is not present in these steps** — it is @@ -447,14 +447,14 @@ adapter adds its own lifecycle actions if its provider supports staging. cache. 15. Print non-sensitive diagnostics. 16. Resolve `build-mode` (§8). If `always`, run - ` build --adapter -- ` with **no** provider + ` build --adapter -- ` with **no** provider credentials in scope (the `provider-env-clear` names stay unset here). 17. In a separate deploy step whose step-scoped `env:` is the **only** place `provider-env` is exposed: clear the `provider-env-clear` aliases, parse `provider-env` and export only its values, and run: ```text - deploy --adapter -- + deploy --adapter -- ``` For adapters whose deploy also compiles the application (Fastly's default), @@ -561,7 +561,7 @@ Rules: The wrapper necessarily carries the token into the deploy step twice — once as `EDGEZERO___API_TOKEN` (so the step's YAML can build the JSON without interpolating a secret into a `run:` block, itself a template-injection sink), - and once inside `EDGEZERO__PROVIDER_ENV`. Both are secret-bearing. `run-cli.sh` + and once inside `EDGEZERO__PROVIDER_ENV`. Both are secret-bearing. `run-app-cli.sh` therefore unsets its entire private namespace (§10.2) after reading it, so the app CLI — and every subprocess it spawns, including a manifest `[adapters.*.commands]` shell command — receives **only** the typed provider @@ -609,7 +609,7 @@ Sections in use: `ACTION` (action-owned dirs), `INPUT` (raw wrapper inputs), (provider-specific), and `TEST`. This is not cosmetic. It is what makes the credential boundary (§10) a **single -rule**: `run-cli.sh` unsets everything matching `EDGEZERO__*` before exec'ing the +rule**: `run-app-cli.sh` unsets everything matching `EDGEZERO__*` before exec'ing the app CLI. With the previous mix of `DEPLOY_*`, `INPUT_*`, `SUMMARY_*`, and bare `CLI_BIN` / `VERSION`, the scrub needed a hand-maintained list — and a variable added later without touching that list would have silently leaked. @@ -725,8 +725,8 @@ All validation and setup failures stop before invoking provider deployment. | Failure | Required diagnostic | | ----------------------------------------------- | ------------------------------------------------------------------------------- | -| Missing/unknown `cli-package` | State that the app must name a CLI package present in its own workspace. | -| Missing `cli-artifact` | State that a compiled CLI artifact from `build-app-cli` is required. | +| Missing/unknown `app-cli-package` | State that the app must name a CLI package present in its own workspace. | +| Missing `app-cli-artifact` | State that a compiled CLI artifact from `build-app-cli` is required. | | Malformed `adapter` token | Name the input and its allowed shape (the CLI validates support at run time). | | Invalid boolean | Name the input and allowed values. | | Missing working directory | Print the workspace-relative requested path. | @@ -808,7 +808,7 @@ Fastly wrapper: - `adapter` well-formedness validation (unknown adapters surface as the CLI's own error, not an engine allowlist); -- app-provided `cli-package` build (fail on missing/unknown package), tar +- app-provided `app-cli-package` build (fail on missing/unknown package), tar round-trip preserving the executable bit, and artifact consumption; - exact boolean parsing; - toolchain precedence and malformed-file failure; @@ -876,7 +876,7 @@ The design is implemented when: 1. A caller can compile the CLI once with `build-app-cli` and deploy a checked-out EdgeZero application with `deploy-fastly`, reusing the same CLI artifact. -2. `build-app-cli` compiles the app-provided `cli-package` from the application +2. `build-app-cli` compiles the app-provided `app-cli-package` from the application checkout and never builds the EdgeZero monorepo CLI or the action's own revision. 3. `deploy-core` contains no provider-specific credential names, service From e640640d69b03bac4d7048843b80749fb87aa9de Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:17:34 -0700 Subject: [PATCH 23/26] Drop the __INPUT pseudo-section; standardize every script header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "INPUT" was not a domain like FASTLY or LIFECYCLE — every action variable comes from an input, so tagging some of them __INPUT described nothing and left the namespace inconsistent (why EDGEZERO__INPUT__ADAPTER but EDGEZERO__FASTLY__*?). Each is folded into its real domain: __INPUT__ADAPTER -> EDGEZERO__ADAPTER __INPUT__APP_CLI_* -> EDGEZERO__APP__CLI__* __INPUT__WORKING_DIRECTORY -> EDGEZERO__PROJECT__WORKING_DIRECTORY __INPUT__MANIFEST/TARGET/… -> EDGEZERO__PROJECT__* __INPUT__BUILD_*/CACHE -> EDGEZERO__BUILD__* __INPUT__DEPLOY_*/STAGE -> EDGEZERO__DEPLOY__* __INPUT__PROVIDER_ENV_CLEAR -> EDGEZERO__PROVIDER__ENV_CLEAR __INPUT__FASTLY_* -> EDGEZERO__FASTLY__* Every script header now follows one shape: summary, context, then aligned `Reads (env)` / `Writes (outputs|env|PATH|step summary)` sections with a required/optional column — replacing the old free-form "Inputs (environment):" paragraphs, several of which were stale or omitted outputs entirely. The three sourced common.sh libraries get a one-line "sourced helper library" header. Comments only for the scripts; the variable rename is mechanical and covered by actionlint (no duplicate keys) and the 49 bash contract tests, all green. --- .github/actions/build-app-cli/action.yml | 10 ++-- .../build-app-cli/scripts/build-app-cli.sh | 40 +++++++++------- .../actions/build-app-cli/scripts/common.sh | 5 ++ .../actions/deploy-core/scripts/cleanup.sh | 8 +++- .github/actions/deploy-core/scripts/common.sh | 6 +++ .../deploy-core/scripts/download-app-cli.sh | 19 +++++--- .../deploy-core/scripts/resolve-project.sh | 35 ++++++++++---- .../deploy-core/scripts/run-app-cli.sh | 24 ++++++---- .../deploy-core/scripts/validate-inputs.sh | 46 +++++++++++++------ .../deploy-core/scripts/write-summary.sh | 17 ++++++- .github/actions/deploy-core/tests/run.sh | 32 ++++++------- .github/actions/deploy-fastly/action.yml | 44 +++++++++--------- .../actions/deploy-fastly/scripts/common.sh | 5 ++ .../actions/deploy-fastly/scripts/deploy.sh | 8 +++- .../deploy-fastly/scripts/install-fastly.sh | 11 +++-- .github/actions/healthcheck-fastly/action.yml | 2 +- .../healthcheck-fastly/scripts/healthcheck.sh | 16 ++++++- .github/actions/rollback-fastly/action.yml | 2 +- .../rollback-fastly/scripts/rollback.sh | 10 +++- 19 files changed, 228 insertions(+), 112 deletions(-) diff --git a/.github/actions/build-app-cli/action.yml b/.github/actions/build-app-cli/action.yml index d19e9e14..56d44c56 100644 --- a/.github/actions/build-app-cli/action.yml +++ b/.github/actions/build-app-cli/action.yml @@ -44,11 +44,11 @@ runs: shell: bash env: EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. - EDGEZERO__INPUT__APP_CLI_PACKAGE: ${{ inputs['app-cli-package'] }} - EDGEZERO__INPUT__APP_CLI_BIN: ${{ inputs['app-cli-bin'] }} - EDGEZERO__INPUT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} - EDGEZERO__INPUT__RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} - EDGEZERO__INPUT__APP_CLI_ARTIFACT: ${{ inputs['app-cli-artifact'] }} + EDGEZERO__APP__CLI__PACKAGE: ${{ inputs['app-cli-package'] }} + EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} + EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + EDGEZERO__PROJECT__RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} + EDGEZERO__APP__CLI__ARTIFACT: ${{ inputs['app-cli-artifact'] }} run: $GITHUB_ACTION_PATH/scripts/build-app-cli.sh - name: Upload CLI artifact diff --git a/.github/actions/build-app-cli/scripts/build-app-cli.sh b/.github/actions/build-app-cli/scripts/build-app-cli.sh index a96abbb7..e63c5d3d 100755 --- a/.github/actions/build-app-cli/scripts/build-app-cli.sh +++ b/.github/actions/build-app-cli/scripts/build-app-cli.sh @@ -1,18 +1,26 @@ #!/usr/bin/env bash set -euo pipefail -# Compiles the CLI package the *application* provides (a crate in the app's own -# workspace, named by EDGEZERO__INPUT__APP_CLI_PACKAGE) into an action-owned CARGO_TARGET_DIR, -# then packages the binary plus a self-describing app-cli-meta.json into a tar so the -# executable bit survives actions/upload-artifact. Never builds the EdgeZero -# monorepo CLI. +# Compiles the CLI package the APPLICATION provides — a crate in the app's own +# workspace — into an action-owned CARGO_TARGET_DIR, then packages the binary +# plus a self-describing app-cli-meta.json into a tar so the executable bit +# survives actions/upload-artifact. Never builds the EdgeZero monorepo CLI. # -# Inputs (environment): -# EDGEZERO__INPUT__APP_CLI_PACKAGE required Cargo package name to build -# EDGEZERO__INPUT__APP_CLI_BIN optional binary name (defaults to the package name) -# EDGEZERO__INPUT__WORKING_DIRECTORY optional app dir relative to github.workspace (".") -# EDGEZERO__INPUT__RUST_TOOLCHAIN optional explicit toolchain or "auto" -# EDGEZERO__INPUT__APP_CLI_ARTIFACT optional uploaded artifact name ("edgezero-cli") +# Reads (env): +# EDGEZERO__APP__CLI__PACKAGE required Cargo package name to build +# EDGEZERO__ACTION__ROOT required EdgeZero action repo (toolchain fallback) +# GITHUB_WORKSPACE required checkout root; the search ceiling +# EDGEZERO__APP__CLI__BIN optional binary name (default: the package name) +# EDGEZERO__PROJECT__WORKING_DIRECTORY optional app dir under the workspace (default: ".") +# EDGEZERO__PROJECT__RUST_TOOLCHAIN optional explicit toolchain or "auto" (default: "auto") +# EDGEZERO__APP__CLI__ARTIFACT optional uploaded artifact name (default: "edgezero-cli") +# RUNNER_TEMP optional action-owned scratch root (default: /tmp) +# Writes (outputs): +# app-cli-package the package that was built +# app-cli-bin the binary name inside the artifact +# app-cli-version version from cargo metadata +# app-cli-artifact uploaded artifact name for downstream download +# tarball-path absolute path of the staged tar SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -125,11 +133,11 @@ require_linux_x86_64() { main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" local action_root="${EDGEZERO__ACTION__ROOT:?EDGEZERO__ACTION__ROOT is required}" - local cli_package="${EDGEZERO__INPUT__APP_CLI_PACKAGE:?input 'app-cli-package' is required}" - local cli_bin="${EDGEZERO__INPUT__APP_CLI_BIN:-}" - local working_directory="${EDGEZERO__INPUT__WORKING_DIRECTORY:-.}" - local rust_toolchain_input="${EDGEZERO__INPUT__RUST_TOOLCHAIN:-auto}" - local artifact_name="${EDGEZERO__INPUT__APP_CLI_ARTIFACT:-edgezero-cli}" + local cli_package="${EDGEZERO__APP__CLI__PACKAGE:?input 'app-cli-package' is required}" + local cli_bin="${EDGEZERO__APP__CLI__BIN:-}" + local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:-.}" + local rust_toolchain_input="${EDGEZERO__PROJECT__RUST_TOOLCHAIN:-auto}" + local artifact_name="${EDGEZERO__APP__CLI__ARTIFACT:-edgezero-cli}" # These directories are `rm -rf`d below, so they must NEVER come from the # inherited environment — a colliding job-level variable could otherwise point diff --git a/.github/actions/build-app-cli/scripts/common.sh b/.github/actions/build-app-cli/scripts/common.sh index 91ab9190..ed9a75e3 100755 --- a/.github/actions/build-app-cli/scripts/common.sh +++ b/.github/actions/build-app-cli/scripts/common.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +# Sourced helper library for build-app-cli. Defines the shared shell helpers +# (annotations, output writers, artifact-name and owned-dir guards). It is never +# executed directly and reads no environment of its own; callers source it right +# after their own strict-mode preamble. + escape_annotation() { local value="$*" value=${value//%/%25} diff --git a/.github/actions/deploy-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh index 09a8310d..fd16d3ab 100755 --- a/.github/actions/deploy-core/scripts/cleanup.sh +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -11,8 +11,12 @@ set -euo pipefail # `$EDGEZERO_FASTLY_HOME`, a variable nothing in the action ever set: its value # could only ever come from the caller's environment.) # -# Inputs (environment): RUNNER_TEMP, EDGEZERO__ACTION__STATE_DIR, -# EDGEZERO__ACTION__TOOL_ROOT. +# Reads (env): +# RUNNER_TEMP required the only root anything may be removed beneath +# EDGEZERO__ACTION__STATE_DIR optional action-owned state dir to remove +# EDGEZERO__ACTION__TOOL_ROOT optional action-owned tool install to remove +# Writes: +# nothing — removes action-owned dirs; emits no outputs. remove_owned_dir() { local dir="$1" temp_root="$2" diff --git a/.github/actions/deploy-core/scripts/common.sh b/.github/actions/deploy-core/scripts/common.sh index 642df778..ce63efa1 100755 --- a/.github/actions/deploy-core/scripts/common.sh +++ b/.github/actions/deploy-core/scripts/common.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash set -euo pipefail +# Sourced helper library for the deploy engine and adapter wrappers. Defines the +# shared shell helpers (annotations, output/env writers, input guards, lifecycle +# log and version-parse helpers, tar and cli-bin safety checks). It is never +# executed directly and reads no environment of its own; callers source it right +# after their own strict-mode preamble. + escape_annotation() { local value="$*" value=${value//%/%25} diff --git a/.github/actions/deploy-core/scripts/download-app-cli.sh b/.github/actions/deploy-core/scripts/download-app-cli.sh index e6fda484..8c352f80 100755 --- a/.github/actions/deploy-core/scripts/download-app-cli.sh +++ b/.github/actions/deploy-core/scripts/download-app-cli.sh @@ -4,12 +4,19 @@ set -euo pipefail # Extracts the build-app-cli artifact tar (downloaded by actions/download-artifact) # into an action-owned tool dir, preserving the executable bit, reads the # self-describing app-cli-meta.json, and prepends the dir to PATH for action steps. -# A wrapper-supplied EDGEZERO__INPUT__APP_CLI_BIN overrides the metadata's binary name. +# A wrapper-supplied EDGEZERO__APP__CLI__BIN overrides the metadata's binary name. # -# Inputs (environment): -# EDGEZERO__APP__CLI__ARTIFACT_DIR required dir containing the downloaded tar -# EDGEZERO__INPUT__APP_CLI_BIN optional override for the binary name -# EDGEZERO__ACTION__TOOL_ROOT optional install dir (defaults under RUNNER_TEMP) +# Reads (env): +# EDGEZERO__APP__CLI__ARTIFACT_DIR required dir containing the downloaded tar +# EDGEZERO__APP__CLI__BIN optional override for the binary name +# EDGEZERO__ACTION__TOOL_ROOT optional install dir (default: under RUNNER_TEMP) +# Writes (outputs): +# app-cli-bin resolved binary name +# app-cli-version version from app-cli-meta.json +# Writes (env): +# EDGEZERO__ACTION__TOOL_ROOT the install dir (for later steps + cleanup) +# Writes (PATH): +# the install dir's bin/, so the app CLI is callable by name SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -23,7 +30,7 @@ find_cli_tarball() { main() { local artifact_dir="${EDGEZERO__APP__CLI__ARTIFACT_DIR:?EDGEZERO__APP__CLI__ARTIFACT_DIR is required}" - local cli_bin_override="${EDGEZERO__INPUT__APP_CLI_BIN:-}" + local cli_bin_override="${EDGEZERO__APP__CLI__BIN:-}" local tool_root="${EDGEZERO__ACTION__TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" require_cmd jq diff --git a/.github/actions/deploy-core/scripts/resolve-project.sh b/.github/actions/deploy-core/scripts/resolve-project.sh index 7ce8b628..e1321803 100755 --- a/.github/actions/deploy-core/scripts/resolve-project.sh +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -6,9 +6,26 @@ set -euo pipefail # (Cargo.lock hash + target/ cache), so nested-workspace monorepos cache the # right artifacts. Provider-neutral: no provider names appear here. # -# Inputs (environment): EDGEZERO__INPUT__WORKING_DIRECTORY, EDGEZERO__INPUT__MANIFEST, -# EDGEZERO__INPUT__RUST_TOOLCHAIN, EDGEZERO__INPUT__TARGET (required), EDGEZERO__INPUT__BUILD_MODE, EDGEZERO__INPUT__CACHE, -# EDGEZERO__ACTION__ROOT (required), EDGEZERO__APP__CLI__VERSION. +# Reads (env): +# EDGEZERO__PROJECT__TARGET required concrete build target (e.g. wasm32-wasip1) +# EDGEZERO__ACTION__ROOT required EdgeZero action repo (toolchain fallback) +# GITHUB_WORKSPACE required checkout root; the search ceiling +# EDGEZERO__PROJECT__WORKING_DIRECTORY optional app dir under the workspace (default: ".") +# EDGEZERO__PROJECT__MANIFEST optional edgezero.toml path, relative to the app dir +# EDGEZERO__PROJECT__RUST_TOOLCHAIN optional explicit toolchain or "auto" (default: "auto") +# EDGEZERO__BUILD__MODE optional auto | always | never (default: auto) +# EDGEZERO__BUILD__CACHE optional true | false (default: false) +# EDGEZERO__APP__CLI__VERSION optional folded into the cache key (default: unknown) +# Writes (outputs): +# working-directory resolved absolute app directory +# working-directory-relative app directory relative to the workspace +# app-git-root enclosing Git repository (source revision) +# cargo-workspace-root Cargo workspace root (Cargo.lock + target/) +# source-revision Git revision of the app +# manifest / manifest-summary resolved manifest path (and a display form) +# rust-toolchain resolved toolchain +# effective-build-mode build mode after auto resolution +# cache-key / cache-path exact cache key and the target/ path it covers SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -86,11 +103,11 @@ assert_committed_source() { main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" local action_root="${EDGEZERO__ACTION__ROOT:?EDGEZERO__ACTION__ROOT is required}" - local working_directory="${EDGEZERO__INPUT__WORKING_DIRECTORY:-.}" - local manifest="${EDGEZERO__INPUT__MANIFEST:-}" - local rust_toolchain_input="${EDGEZERO__INPUT__RUST_TOOLCHAIN:-auto}" - local target="${EDGEZERO__INPUT__TARGET:?EDGEZERO__INPUT__TARGET is required (wrapper-provided concrete target)}" - local cache="${EDGEZERO__INPUT__CACHE:-false}" + local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:-.}" + local manifest="${EDGEZERO__PROJECT__MANIFEST:-}" + local rust_toolchain_input="${EDGEZERO__PROJECT__RUST_TOOLCHAIN:-auto}" + local target="${EDGEZERO__PROJECT__TARGET:?EDGEZERO__PROJECT__TARGET is required (wrapper-provided concrete target)}" + local cache="${EDGEZERO__BUILD__CACHE:-false}" local cli_version="${EDGEZERO__APP__CLI__VERSION:-unknown}" require_cmd git @@ -144,7 +161,7 @@ main() { local rust_toolchain effective_build_mode cache_key rust_toolchain=$(resolve_rust_toolchain "$rust_toolchain_input" "$app_dir" "$git_root" "$action_root") - effective_build_mode=$(resolve_effective_build_mode "${EDGEZERO__INPUT__BUILD_MODE:-auto}") + effective_build_mode=$(resolve_effective_build_mode "${EDGEZERO__BUILD__MODE:-auto}") cache_key="edgezero-deploy-${RUNNER_OS:-Linux}-${RUNNER_ARCH:-X64}-$(sanitize_ref "$rust_toolchain")-$(sanitize_ref "$target")-$(sanitize_ref "$cli_version")-${source_revision}-${lock_hash}" append_output working-directory "$app_dir" diff --git a/.github/actions/deploy-core/scripts/run-app-cli.sh b/.github/actions/deploy-core/scripts/run-app-cli.sh index b7e4b2ab..f71fff5c 100755 --- a/.github/actions/deploy-core/scripts/run-app-cli.sh +++ b/.github/actions/deploy-core/scripts/run-app-cli.sh @@ -15,16 +15,20 @@ set -euo pipefail # that are declared in the clear list. So inherited endpoint/token aliases can # never survive into the deploy. Build mode is credential-free and only clears. # -# Inputs (environment): -# EDGEZERO__APP__CLI__BIN required binary name to invoke (on PATH) -# EDGEZERO__ADAPTER required adapter passed as --adapter -# EDGEZERO__PROJECT__WORKING_DIRECTORY required directory to run the CLI from -# EDGEZERO__PROJECT__MANIFEST_PATH optional exported as EDGEZERO_MANIFEST when set -# EDGEZERO__BUILD__ARGS_FILE optional NUL-delimited build passthrough (build) -# EDGEZERO__DEPLOY__FLAGS_FILE optional NUL-delimited typed flags (deploy) -# EDGEZERO__DEPLOY__ARGS_FILE optional NUL-delimited passthrough (deploy) -# EDGEZERO__PROVIDER__ENV_CLEAR_FILE optional NUL-delimited env names to clear -# EDGEZERO__PROVIDER__ENV optional JSON object of typed creds (deploy) +# Reads (env): +# EDGEZERO__APP__CLI__BIN required binary name to invoke (on PATH) +# EDGEZERO__ADAPTER required adapter passed as --adapter +# EDGEZERO__PROJECT__WORKING_DIRECTORY required directory to run the CLI from +# EDGEZERO__PROJECT__MANIFEST_PATH optional exported as EDGEZERO_MANIFEST when set +# EDGEZERO__BUILD__ARGS_FILE optional NUL-delimited build passthrough (build) +# EDGEZERO__DEPLOY__FLAGS_FILE optional NUL-delimited typed flags (deploy) +# EDGEZERO__DEPLOY__ARGS_FILE optional NUL-delimited passthrough (deploy) +# EDGEZERO__PROVIDER__ENV_CLEAR_FILE optional NUL-delimited alias names to clear +# EDGEZERO__PROVIDER__ENV optional JSON object of typed creds (deploy) +# Writes: +# nothing — execs the app CLI, which owns stdout/stderr and the exit status. +# EDGEZERO_MANIFEST is exported to the CLI; the whole EDGEZERO__* namespace is +# scrubbed first (see scrub_action_private_env). SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh diff --git a/.github/actions/deploy-core/scripts/validate-inputs.sh b/.github/actions/deploy-core/scripts/validate-inputs.sh index d9b6fd3d..4555cfb2 100755 --- a/.github/actions/deploy-core/scripts/validate-inputs.sh +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -6,10 +6,28 @@ set -euo pipefail # allowlist, and validates booleans. It never learns provider credential names # or provider CLI flags — those arrive from the wrapper as opaque data. # -# Inputs (environment): EDGEZERO__INPUT__ADAPTER, EDGEZERO__INPUT__BUILD_MODE, EDGEZERO__INPUT__CACHE, -# EDGEZERO__INPUT__BUILD_ARGS, EDGEZERO__INPUT__DEPLOY_ARGS, EDGEZERO__INPUT__DEPLOY_ARGS_PREPEND, -# EDGEZERO__INPUT__DEPLOY_FLAGS, EDGEZERO__INPUT__PROVIDER_ENV_CLEAR, EDGEZERO__INPUT__DEPLOY_ARG_ALLOW, -# EDGEZERO__RUNNER__OS/ARCH. +# Reads (env): +# EDGEZERO__ADAPTER required adapter token (well-formedness only) +# EDGEZERO__RUNNER__OS required runner OS guard (Linux) +# EDGEZERO__RUNNER__ARCH required runner arch guard (X64) +# EDGEZERO__BUILD__MODE optional auto | always | never (default: auto) +# EDGEZERO__BUILD__CACHE optional true | false (default: false) +# EDGEZERO__DEPLOY__STAGE optional true | false (default: false) +# EDGEZERO__BUILD__ARGS optional JSON string array (default: []) +# EDGEZERO__DEPLOY__ARGS optional caller JSON string array (default: []) +# EDGEZERO__DEPLOY__ARGS_PREPEND optional action-owned JSON array, prepended (default: []) +# EDGEZERO__DEPLOY__FLAGS optional typed JSON string array (default: []) +# EDGEZERO__DEPLOY__ARG_ALLOW optional space-separated deploy-arg allowlist +# EDGEZERO__PROVIDER__ENV_CLEAR optional JSON array of alias names to clear (default: []) +# EDGEZERO__ACTION__STATE_DIR optional where the .nul files are written (default: under RUNNER_TEMP) +# Writes (outputs): +# adapter the validated adapter +# build-args-file NUL-delimited build args +# deploy-args-file NUL-delimited deploy args (prepend + allowlisted caller) +# deploy-flags-file NUL-delimited typed deploy flags +# provider-env-clear-file NUL-delimited alias names to clear +# requested-build-mode the validated build mode +# cache the validated cache flag SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -66,11 +84,11 @@ enforce_deploy_arg_allowlist() { } main() { - local adapter="${EDGEZERO__INPUT__ADAPTER:-}" - local build_mode="${EDGEZERO__INPUT__BUILD_MODE:-auto}" - local cache="${EDGEZERO__INPUT__CACHE:-false}" - local stage="${EDGEZERO__INPUT__STAGE:-false}" - local deploy_arg_allow="${EDGEZERO__INPUT__DEPLOY_ARG_ALLOW:-}" + local adapter="${EDGEZERO__ADAPTER:-}" + local build_mode="${EDGEZERO__BUILD__MODE:-auto}" + local cache="${EDGEZERO__BUILD__CACHE:-false}" + local stage="${EDGEZERO__DEPLOY__STAGE:-false}" + local deploy_arg_allow="${EDGEZERO__DEPLOY__ARG_ALLOW:-}" require_supported_runner "${EDGEZERO__RUNNER__OS:-}" "${EDGEZERO__RUNNER__ARCH:-}" @@ -101,10 +119,10 @@ main() { local deploy_flags_file="$state_dir/deploy-flags.nul" local provider_env_clear_file="$state_dir/provider-env-clear.nul" - parse_json_string_array "build-args" "${EDGEZERO__INPUT__BUILD_ARGS:-[]}" "$build_args_file" - parse_json_string_array "deploy-args" "${EDGEZERO__INPUT__DEPLOY_ARGS:-[]}" "$deploy_args_file" - parse_json_string_array "deploy-flags" "${EDGEZERO__INPUT__DEPLOY_FLAGS:-[]}" "$deploy_flags_file" - parse_json_string_array "provider-env-clear" "${EDGEZERO__INPUT__PROVIDER_ENV_CLEAR:-[]}" "$provider_env_clear_file" + parse_json_string_array "build-args" "${EDGEZERO__BUILD__ARGS:-[]}" "$build_args_file" + parse_json_string_array "deploy-args" "${EDGEZERO__DEPLOY__ARGS:-[]}" "$deploy_args_file" + parse_json_string_array "deploy-flags" "${EDGEZERO__DEPLOY__FLAGS:-[]}" "$deploy_flags_file" + parse_json_string_array "provider-env-clear" "${EDGEZERO__PROVIDER__ENV_CLEAR:-[]}" "$provider_env_clear_file" # The allowlist governs CALLER deploy-args only. enforce_deploy_arg_allowlist "$deploy_args_file" "$deploy_arg_allow" @@ -117,7 +135,7 @@ main() { # deploy could block on a TTY prompt). They go first so a caller arg can still # override them where the provider CLI takes last-wins. local prepend_file="$state_dir/deploy-args-prepend.nul" - parse_json_string_array "deploy-args-prepend" "${EDGEZERO__INPUT__DEPLOY_ARGS_PREPEND:-[]}" "$prepend_file" + parse_json_string_array "deploy-args-prepend" "${EDGEZERO__DEPLOY__ARGS_PREPEND:-[]}" "$prepend_file" if [[ -s "$prepend_file" ]]; then cat "$prepend_file" "$deploy_args_file" >"$deploy_args_file.merged" mv "$deploy_args_file.merged" "$deploy_args_file" diff --git a/.github/actions/deploy-core/scripts/write-summary.sh b/.github/actions/deploy-core/scripts/write-summary.sh index 2e330d6d..f1e21c20 100755 --- a/.github/actions/deploy-core/scripts/write-summary.sh +++ b/.github/actions/deploy-core/scripts/write-summary.sh @@ -2,7 +2,22 @@ set -euo pipefail # Writes a non-sensitive GitHub step summary. Never emits credentials, full -# environments, or raw argument arrays. All values arrive via SUMMARY_* env. +# environments, or raw argument arrays. +# +# Reads (env): +# EDGEZERO__SUMMARY__ADAPTER optional adapter name +# EDGEZERO__SUMMARY__WORKING_DIRECTORY optional app directory (relative, for display) +# EDGEZERO__SUMMARY__SOURCE_REVISION optional deployed Git revision +# EDGEZERO__SUMMARY__MANIFEST optional manifest path or default-discovery note +# EDGEZERO__SUMMARY__RUST_TOOLCHAIN optional resolved toolchain +# EDGEZERO__SUMMARY__TARGET optional build target +# EDGEZERO__SUMMARY__APP_CLI_VERSION optional app CLI version +# EDGEZERO__SUMMARY__EFFECTIVE_BUILD_MODE optional build mode after resolution +# EDGEZERO__SUMMARY__CACHE optional whether caching was enabled +# EDGEZERO__SUMMARY__RESULT optional final step result +# GITHUB_STEP_SUMMARY optional summary sink (no-op when unset) +# Writes (step summary): +# a Markdown table of the non-sensitive facts above. main() { local summary_file="${GITHUB_STEP_SUMMARY:-}" diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index ed4cf344..c21aa07f 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -65,15 +65,15 @@ run_validate_inputs() { local state_dir state_dir=$(mktemp -d "$WORK_DIR/validate.XXXXXX") env -i PATH="$PATH" \ - EDGEZERO__INPUT__ADAPTER="${VALIDATE_ADAPTER:-fastly}" \ - EDGEZERO__INPUT__CACHE="${VALIDATE_CACHE:-false}" \ - EDGEZERO__INPUT__BUILD_MODE="${VALIDATE_BUILD_MODE:-auto}" \ - EDGEZERO__INPUT__BUILD_ARGS="${VALIDATE_BUILD_ARGS:-[]}" \ - EDGEZERO__INPUT__DEPLOY_ARGS="${VALIDATE_DEPLOY_ARGS:-[]}" \ - EDGEZERO__INPUT__DEPLOY_FLAGS="${VALIDATE_DEPLOY_FLAGS:-[]}" \ - EDGEZERO__INPUT__PROVIDER_ENV_CLEAR="${VALIDATE_PROVIDER_ENV_CLEAR:-[]}" \ - EDGEZERO__INPUT__DEPLOY_ARG_ALLOW="${VALIDATE_ALLOW:-}" \ - EDGEZERO__INPUT__STAGE="${VALIDATE_STAGE:-false}" \ + EDGEZERO__ADAPTER="${VALIDATE_ADAPTER:-fastly}" \ + EDGEZERO__BUILD__CACHE="${VALIDATE_CACHE:-false}" \ + EDGEZERO__BUILD__MODE="${VALIDATE_BUILD_MODE:-auto}" \ + EDGEZERO__BUILD__ARGS="${VALIDATE_BUILD_ARGS:-[]}" \ + EDGEZERO__DEPLOY__ARGS="${VALIDATE_DEPLOY_ARGS:-[]}" \ + EDGEZERO__DEPLOY__FLAGS="${VALIDATE_DEPLOY_FLAGS:-[]}" \ + EDGEZERO__PROVIDER__ENV_CLEAR="${VALIDATE_PROVIDER_ENV_CLEAR:-[]}" \ + EDGEZERO__DEPLOY__ARG_ALLOW="${VALIDATE_ALLOW:-}" \ + EDGEZERO__DEPLOY__STAGE="${VALIDATE_STAGE:-false}" \ EDGEZERO__ACTION__STATE_DIR="$state_dir" \ GITHUB_OUTPUT="$state_dir/output.txt" \ bash "$CORE_SCRIPTS/validate-inputs.sh" @@ -397,10 +397,10 @@ test_deploy_args_prepend() { local state="$WORK_DIR/prepend" local out args out=$( - EDGEZERO__ACTION__STATE_DIR="$state" EDGEZERO__INPUT__ADAPTER=fastly \ - EDGEZERO__INPUT__DEPLOY_ARG_ALLOW="--comment" \ - EDGEZERO__INPUT__DEPLOY_ARGS='["--comment","hi"]' \ - EDGEZERO__INPUT__DEPLOY_ARGS_PREPEND='["--non-interactive"]' \ + EDGEZERO__ACTION__STATE_DIR="$state" EDGEZERO__ADAPTER=fastly \ + EDGEZERO__DEPLOY__ARG_ALLOW="--comment" \ + EDGEZERO__DEPLOY__ARGS='["--comment","hi"]' \ + EDGEZERO__DEPLOY__ARGS_PREPEND='["--non-interactive"]' \ "$CORE_SCRIPTS/validate-inputs.sh" ) args=$(tr '\0' '\n' <"$state/deploy-args.nul") @@ -412,9 +412,9 @@ test_deploy_args_prepend() { # A caller still cannot smuggle it in themselves. assert_fails "the caller allowlist still rejects --non-interactive from deploy-args" \ - env EDGEZERO__ACTION__STATE_DIR="$state" EDGEZERO__INPUT__ADAPTER=fastly \ - EDGEZERO__INPUT__DEPLOY_ARG_ALLOW="--comment" \ - EDGEZERO__INPUT__DEPLOY_ARGS='["--non-interactive"]' \ + env EDGEZERO__ACTION__STATE_DIR="$state" EDGEZERO__ADAPTER=fastly \ + EDGEZERO__DEPLOY__ARG_ALLOW="--comment" \ + EDGEZERO__DEPLOY__ARGS='["--non-interactive"]' \ "$CORE_SCRIPTS/validate-inputs.sh" } diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index e845a16d..80c09a59 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -62,21 +62,21 @@ runs: id: validate shell: bash env: - EDGEZERO__INPUT__ADAPTER: fastly - EDGEZERO__INPUT__BUILD_MODE: ${{ inputs['build-mode'] }} - EDGEZERO__INPUT__CACHE: ${{ inputs.cache }} - EDGEZERO__INPUT__BUILD_ARGS: ${{ inputs['build-args'] }} - EDGEZERO__INPUT__DEPLOY_ARGS: ${{ inputs['deploy-args'] }} - EDGEZERO__INPUT__DEPLOY_ARG_ALLOW: "--comment" + EDGEZERO__ADAPTER: fastly + EDGEZERO__BUILD__MODE: ${{ inputs['build-mode'] }} + EDGEZERO__BUILD__CACHE: ${{ inputs.cache }} + EDGEZERO__BUILD__ARGS: ${{ inputs['build-args'] }} + EDGEZERO__DEPLOY__ARGS: ${{ inputs['deploy-args'] }} + EDGEZERO__DEPLOY__ARG_ALLOW: "--comment" # Action-owned (not caller input, not allowlist-checked): keeps a # manifest-command deploy from blocking on a TTY prompt in CI. The # built-in Fastly deploy path de-duplicates it. - EDGEZERO__INPUT__DEPLOY_ARGS_PREPEND: '["--non-interactive"]' - EDGEZERO__INPUT__STAGE: ${{ inputs.stage }} - EDGEZERO__INPUT__DEPLOY_FLAGS: ${{ inputs.stage == 'true' && format('["--service-id","{0}","--stage"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} - EDGEZERO__INPUT__PROVIDER_ENV_CLEAR: '["FASTLY_API_TOKEN","FASTLY_SERVICE_ID","FASTLY_TOKEN","FASTLY_KEY","FASTLY_API_KEY","FASTLY_AUTH_TOKEN","FASTLY_API_ENDPOINT","FASTLY_ENDPOINT","FASTLY_API_URL","FASTLY_PROFILE","FASTLY_SERVICE_NAME","FASTLY_DEBUG","FASTLY_DEBUG_MODE","FASTLY_CONFIG_FILE","FASTLY_HOME"]' - EDGEZERO__INPUT__FASTLY_API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} - EDGEZERO__INPUT__FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} + EDGEZERO__DEPLOY__ARGS_PREPEND: '["--non-interactive"]' + EDGEZERO__DEPLOY__STAGE: ${{ inputs.stage }} + EDGEZERO__DEPLOY__FLAGS: ${{ inputs.stage == 'true' && format('["--service-id","{0}","--stage"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} + EDGEZERO__PROVIDER__ENV_CLEAR: '["FASTLY_API_TOKEN","FASTLY_SERVICE_ID","FASTLY_TOKEN","FASTLY_KEY","FASTLY_API_KEY","FASTLY_AUTH_TOKEN","FASTLY_API_ENDPOINT","FASTLY_ENDPOINT","FASTLY_API_URL","FASTLY_PROFILE","FASTLY_SERVICE_NAME","FASTLY_DEBUG","FASTLY_DEBUG_MODE","FASTLY_CONFIG_FILE","FASTLY_HOME"]' + EDGEZERO__FASTLY__API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} + EDGEZERO__FASTLY__SERVICE_ID: ${{ inputs['fastly-service-id'] }} EDGEZERO__RUNNER__OS: ${{ runner.os }} EDGEZERO__RUNNER__ARCH: ${{ runner.arch }} EDGEZERO__ACTION__STATE_DIR: ${{ runner.temp }}/edgezero-deploy-state @@ -96,9 +96,9 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_HOME: "" run: | - [[ "${EDGEZERO__INPUT__FASTLY_API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } - [[ -n "${EDGEZERO__INPUT__FASTLY_SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } - [[ "${EDGEZERO__INPUT__FASTLY_SERVICE_ID}" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "::error::input 'fastly-service-id' must match ^[A-Za-z0-9_-]+$"; exit 1; } + [[ "${EDGEZERO__FASTLY__API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } + [[ -n "${EDGEZERO__FASTLY__SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } + [[ "${EDGEZERO__FASTLY__SERVICE_ID}" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "::error::input 'fastly-service-id' must match ^[A-Za-z0-9_-]+$"; exit 1; } # validate-inputs.sh also rejects a non-boolean 'stage' before any deploy. "$GITHUB_ACTION_PATH/../deploy-core/scripts/validate-inputs.sh" @@ -130,7 +130,7 @@ runs: env: EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools - EDGEZERO__INPUT__APP_CLI_BIN: ${{ inputs['app-cli-bin'] }} + EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -154,12 +154,12 @@ runs: env: EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. EDGEZERO__APP__CLI__VERSION: ${{ steps.cli.outputs['app-cli-version'] }} - EDGEZERO__INPUT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} - EDGEZERO__INPUT__MANIFEST: ${{ inputs.manifest }} - EDGEZERO__INPUT__RUST_TOOLCHAIN: auto - EDGEZERO__INPUT__TARGET: wasm32-wasip1 - EDGEZERO__INPUT__BUILD_MODE: ${{ inputs['build-mode'] }} - EDGEZERO__INPUT__CACHE: ${{ inputs.cache }} + EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + EDGEZERO__PROJECT__MANIFEST: ${{ inputs.manifest }} + EDGEZERO__PROJECT__RUST_TOOLCHAIN: auto + EDGEZERO__PROJECT__TARGET: wasm32-wasip1 + EDGEZERO__BUILD__MODE: ${{ inputs['build-mode'] }} + EDGEZERO__BUILD__CACHE: ${{ inputs.cache }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" diff --git a/.github/actions/deploy-fastly/scripts/common.sh b/.github/actions/deploy-fastly/scripts/common.sh index 38f96051..25de5f3b 100755 --- a/.github/actions/deploy-fastly/scripts/common.sh +++ b/.github/actions/deploy-fastly/scripts/common.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +# Sourced helper library for the Fastly wrapper. Defines the shared shell helpers +# (annotations, output/env writers, version pinning and checksum helpers). It is +# never executed directly and reads no environment of its own; callers source it +# right after their own strict-mode preamble. + escape_annotation() { local value="$*" value=${value//%/%25} diff --git a/.github/actions/deploy-fastly/scripts/deploy.sh b/.github/actions/deploy-fastly/scripts/deploy.sh index c79940cc..79cc0420 100755 --- a/.github/actions/deploy-fastly/scripts/deploy.sh +++ b/.github/actions/deploy-fastly/scripts/deploy.sh @@ -11,8 +11,12 @@ set -euo pipefail # CLI. Building the JSON here, from step `env:`, is also what keeps the secret out # of an interpolated `run:` block. # -# Inputs (environment): EDGEZERO__FASTLY__API_TOKEN, -# EDGEZERO__FASTLY__SERVICE_ID, plus the run-app-cli.sh contract. +# Reads (env): +# EDGEZERO__FASTLY__API_TOKEN required typed Fastly API token +# EDGEZERO__FASTLY__SERVICE_ID required typed Fastly service id +# (plus the run-app-cli.sh Reads contract, which this delegates to) +# Writes (outputs): +# fastly-version the deployed/staged Fastly version SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../../deploy-core/scripts/common.sh diff --git a/.github/actions/deploy-fastly/scripts/install-fastly.sh b/.github/actions/deploy-fastly/scripts/install-fastly.sh index 27512b82..8f21b7bd 100755 --- a/.github/actions/deploy-fastly/scripts/install-fastly.sh +++ b/.github/actions/deploy-fastly/scripts/install-fastly.sh @@ -5,9 +5,14 @@ set -euo pipefail # checksum, into an action-owned dir on PATH. This is the Fastly wrapper's # provider-tool responsibility; the provider-neutral engine never installs it. # -# Inputs (environment): -# EDGEZERO__ACTION__ROOT optional repo root holding .tool-versions (defaults up) -# EDGEZERO__FASTLY__VERSIONS_JSON optional pinned metadata (defaults alongside this dir) +# Reads (env): +# EDGEZERO__ACTION__ROOT optional repo root holding .tool-versions (default: walk up) +# EDGEZERO__FASTLY__VERSIONS_JSON optional pinned metadata (default: alongside this dir) +# EDGEZERO__ACTION__TOOL_ROOT optional install dir (default: under RUNNER_TEMP) +# Writes (outputs): +# provider-cli-version the installed Fastly CLI version +# Writes (PATH): +# the tool root's bin/, so `fastly` is callable SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index d925bf55..1658be58 100644 --- a/.github/actions/healthcheck-fastly/action.yml +++ b/.github/actions/healthcheck-fastly/action.yml @@ -77,7 +77,7 @@ runs: env: EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools - EDGEZERO__INPUT__APP_CLI_BIN: ${{ inputs['app-cli-bin'] }} + EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} FASTLY_API_TOKEN: "" FASTLY_TOKEN: "" FASTLY_KEY: "" diff --git a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh index 692d7ae2..90734db2 100755 --- a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh +++ b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh @@ -7,8 +7,20 @@ set -euo pipefail # prove the deployment is healthy must exit non-zero: a non-zero CLI, a # `healthy=false` verdict, and — critically — no verdict at all. # -# Inputs (environment): EDGEZERO__APP__CLI__BIN, EDGEZERO__LIFECYCLE__SERVICE_ID, EDGEZERO__LIFECYCLE__VERSION, EDGEZERO__LIFECYCLE__DOMAIN, EDGEZERO__DEPLOY__TO, EDGEZERO__LIFECYCLE__RETRY, -# EDGEZERO__LIFECYCLE__RETRY_DELAY, EDGEZERO__LIFECYCLE__TIMEOUT, FASTLY_API_TOKEN. +# Reads (env): +# EDGEZERO__APP__CLI__BIN required app CLI binary to invoke +# EDGEZERO__LIFECYCLE__SERVICE_ID required Fastly service id +# EDGEZERO__LIFECYCLE__VERSION required version to probe +# EDGEZERO__LIFECYCLE__DOMAIN required domain to probe +# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# EDGEZERO__DEPLOY__TO optional production | staging (default: production) +# EDGEZERO__LIFECYCLE__RETRY optional attempts before unhealthy (default: 3) +# EDGEZERO__LIFECYCLE__RETRY_DELAY optional seconds between attempts (default: 5) +# EDGEZERO__LIFECYCLE__TIMEOUT optional per-attempt timeout seconds (default: 10) +# Writes (outputs): +# healthy true | false +# status-code last HTTP status observed +# Exits non-zero when the deployment is not provably healthy (the rollback gate). SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../../deploy-core/scripts/common.sh diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml index b96af3fa..4256d464 100644 --- a/.github/actions/rollback-fastly/action.yml +++ b/.github/actions/rollback-fastly/action.yml @@ -59,7 +59,7 @@ runs: env: EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools - EDGEZERO__INPUT__APP_CLI_BIN: ${{ inputs['app-cli-bin'] }} + EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} FASTLY_API_TOKEN: "" FASTLY_TOKEN: "" FASTLY_KEY: "" diff --git a/.github/actions/rollback-fastly/scripts/rollback.sh b/.github/actions/rollback-fastly/scripts/rollback.sh index c26d63d2..e357fb48 100755 --- a/.github/actions/rollback-fastly/scripts/rollback.sh +++ b/.github/actions/rollback-fastly/scripts/rollback.sh @@ -7,8 +7,14 @@ set -euo pipefail # Fails closed: a rollback that cannot say what it activated has not provably # rolled anything back. # -# Inputs (environment): EDGEZERO__APP__CLI__BIN, EDGEZERO__LIFECYCLE__SERVICE_ID, EDGEZERO__LIFECYCLE__VERSION, EDGEZERO__DEPLOY__TO, -# FASTLY_API_TOKEN. +# Reads (env): +# EDGEZERO__APP__CLI__BIN required app CLI binary to invoke +# EDGEZERO__LIFECYCLE__SERVICE_ID required Fastly service id +# EDGEZERO__LIFECYCLE__VERSION required the current (bad) version to roll back from +# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# EDGEZERO__DEPLOY__TO optional production | staging (default: production) +# Writes (outputs): +# rolled-back-to the activated version (production only) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../../deploy-core/scripts/common.sh From 18974116a3961b6c2d735a250de7235ff47dd310 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:22:55 -0700 Subject: [PATCH 24/26] lifecycle-smoke: keep fake-binary control vars out of the scrubbed namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env-var rename swept the smoke test's fake `fastly`/`curl` control variables into EDGEZERO__TEST__*, which broke the staging lifecycle test — because run-app-cli.sh scrubs the entire EDGEZERO__* namespace before exec'ing the app CLI (the credential boundary). The fake `fastly` that deploy_staged spawns then found EDGEZERO__TEST__FAKE_CALL_LOG unset and logged nowhere, so the assertion saw no `compute update` call. Same for FORCE_UNHEALTHY and the fake `curl`. These two are read by fake binaries the app CLI spawns, so they MUST survive the scrub — i.e. live outside EDGEZERO__*. Renamed to plain FAKE_CALL_LOG / FORCE_UNHEALTHY with a comment explaining why. The assert-only TEST vars (STAGED_VERSION, etc.) never pass through the CLI and stay EDGEZERO__TEST__*. The failure was really the scrub working: it proved an EDGEZERO__ var cannot reach a CLI subprocess. --- .../tests/assert-production-deploy.sh | 2 +- .../tests/assert-rollback-calls.sh | 4 ++-- .../deploy-core/tests/assert-staged-calls.sh | 4 ++-- .../deploy-core/tests/assert-staging-probe.sh | 4 ++-- .../tests/assert-unhealthy-failed.sh | 2 +- .../deploy-core/tests/make-fake-fastly-env.sh | 20 ++++++++++++------- .github/workflows/deploy-action.yml | 2 +- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/actions/deploy-core/tests/assert-production-deploy.sh b/.github/actions/deploy-core/tests/assert-production-deploy.sh index d74eab44..29d1e7c5 100755 --- a/.github/actions/deploy-core/tests/assert-production-deploy.sh +++ b/.github/actions/deploy-core/tests/assert-production-deploy.sh @@ -8,7 +8,7 @@ set -euo pipefail # deploy, inherited aliases are CLEARED, and the action's own secret-bearing # helper variables do NOT survive into the CLI's environment. # -# Inputs (environment): GITHUB_WORKSPACE, EDGEZERO__TEST__FASTLY_VERSION. +# Reads (env): GITHUB_WORKSPACE, EDGEZERO__TEST__FASTLY_VERSION. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/common.sh diff --git a/.github/actions/deploy-core/tests/assert-rollback-calls.sh b/.github/actions/deploy-core/tests/assert-rollback-calls.sh index df655f28..6345d0c7 100755 --- a/.github/actions/deploy-core/tests/assert-rollback-calls.sh +++ b/.github/actions/deploy-core/tests/assert-rollback-calls.sh @@ -8,14 +8,14 @@ set -euo pipefail # * Staging rollback hit `/deactivate`, which deactivates the LIVE version. # Undoing a stage is `/deactivate/staging`. # -# Inputs (environment): EDGEZERO__TEST__FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION, EDGEZERO__TEST__ROLLED_BACK_TO. +# Reads (env): FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION, EDGEZERO__TEST__ROLLED_BACK_TO. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/common.sh source "$SCRIPT_DIR/../scripts/common.sh" main() { - local log="${EDGEZERO__TEST__FAKE_CALL_LOG:?EDGEZERO__TEST__FAKE_CALL_LOG is required}" + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" local staged="${EDGEZERO__TEST__STAGED_VERSION:?EDGEZERO__TEST__STAGED_VERSION is required}" local rolled_back_to="${EDGEZERO__TEST__ROLLED_BACK_TO:-}" local api='https://api\.fastly\.com/service/dummy-service' diff --git a/.github/actions/deploy-core/tests/assert-staged-calls.sh b/.github/actions/deploy-core/tests/assert-staged-calls.sh index 7ff4bc0f..ef1c303f 100755 --- a/.github/actions/deploy-core/tests/assert-staged-calls.sh +++ b/.github/actions/deploy-core/tests/assert-staged-calls.sh @@ -14,7 +14,7 @@ set -euo pipefail # action-owned passthrough arg. # * The staged upload must clone the active version. # -# Inputs (environment): EDGEZERO__TEST__FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION. +# Reads (env): FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/common.sh @@ -47,7 +47,7 @@ assert_comment_precedes_stage() { } main() { - local log="${EDGEZERO__TEST__FAKE_CALL_LOG:?EDGEZERO__TEST__FAKE_CALL_LOG is required}" + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" local staged_version="${EDGEZERO__TEST__STAGED_VERSION:-}" echo "--- recorded fastly/curl calls:" diff --git a/.github/actions/deploy-core/tests/assert-staging-probe.sh b/.github/actions/deploy-core/tests/assert-staging-probe.sh index e41c3d62..ca2cb30b 100755 --- a/.github/actions/deploy-core/tests/assert-staging-probe.sh +++ b/.github/actions/deploy-core/tests/assert-staging-probe.sh @@ -9,14 +9,14 @@ set -euo pipefail # it as an array silently found no IP and probed PRODUCTION instead — a staging # check that was quietly testing the wrong thing. # -# Inputs (environment): EDGEZERO__TEST__FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION. +# Reads (env): FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/common.sh source "$SCRIPT_DIR/../scripts/common.sh" main() { - local log="${EDGEZERO__TEST__FAKE_CALL_LOG:?EDGEZERO__TEST__FAKE_CALL_LOG is required}" + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" local staged="${EDGEZERO__TEST__STAGED_VERSION:?EDGEZERO__TEST__STAGED_VERSION is required}" grep -qE "^GET https://api\.fastly\.com/service/dummy-service/version/$staged/domain\?include=staging_ips\$" "$log" || diff --git a/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh b/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh index 3a95e9d9..7d2b23d2 100755 --- a/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh +++ b/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh @@ -7,7 +7,7 @@ set -euo pipefail # probe lets the action succeed, no caller would ever roll back — so this is the # single most important contract in the lifecycle. # -# Inputs (environment): EDGEZERO__TEST__OUTCOME (the step's outcome), EDGEZERO__TEST__HEALTHY (its output). +# Reads (env): EDGEZERO__TEST__OUTCOME (the step's outcome), EDGEZERO__TEST__HEALTHY (its output). main() { local outcome="${EDGEZERO__TEST__OUTCOME:?EDGEZERO__TEST__OUTCOME is required}" diff --git a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh index 93feba92..1062ceb2 100755 --- a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh +++ b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh @@ -20,7 +20,13 @@ set -euo pipefail # the real deploy-fastly wrapper rather than by calling the CLI directly. # The fake `curl` goes on PATH, which nothing reinstalls. # -# Inputs (environment): GITHUB_WORKSPACE, GITHUB_PATH, GITHUB_ENV, RUNNER_TEMP. +# The fake binaries write their call log to FAKE_CALL_LOG and read FORCE_UNHEALTHY. +# These are deliberately OUTSIDE the EDGEZERO__ namespace: the app CLI scrubs +# every EDGEZERO__* var before exec, and these must survive that scrub because +# the fake fastly/curl are spawned BY the app CLI and read them there. +# +# Reads (env): GITHUB_WORKSPACE, GITHUB_PATH, GITHUB_ENV, RUNNER_TEMP. +# Writes (env): FAKE_CALL_LOG (the call-log path). SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../scripts/common.sh @@ -30,7 +36,7 @@ write_fake_fastly() { local path="$1" version="$2" cat >"$path" <>"\$EDGEZERO__TEST__FAKE_CALL_LOG" +printf 'fastly %s\n' "\$*" >>"\$FAKE_CALL_LOG" case "\${1:-} \${2:-}" in "version ") echo "Fastly CLI version v$version (fake)" ;; "compute build") echo "Built package (fixture)" ;; @@ -63,17 +69,17 @@ if [[ "$*" == *"--config"* ]]; then config=$(cat) url=$(printf '%s\n' "$config" | sed -nE 's/^url = "(.*)"$/\1/p') if printf '%s\n' "$config" | grep -q '^request = "PUT"$'; then - printf 'PUT %s\n' "$url" >>"$EDGEZERO__TEST__FAKE_CALL_LOG" + printf 'PUT %s\n' "$url" >>"$FAKE_CALL_LOG" echo 200 exit 0 fi - printf 'GET %s\n' "$url" >>"$EDGEZERO__TEST__FAKE_CALL_LOG" + printf 'GET %s\n' "$url" >>"$FAKE_CALL_LOG" # Fastly returns a SINGULAR `staging_ip` string per domain object. printf '[{"name":"staging.example.com","staging_ip":"151.101.2.10"}]\n' exit 0 fi -printf 'PROBE %s\n' "$*" >>"$EDGEZERO__TEST__FAKE_CALL_LOG" -if [[ -n "${EDGEZERO__TEST__FORCE_UNHEALTHY:-}" ]]; then +printf 'PROBE %s\n' "$*" >>"$FAKE_CALL_LOG" +if [[ -n "${FORCE_UNHEALTHY:-}" ]]; then echo 503 else echo 200 @@ -105,7 +111,7 @@ main() { printf '%s\n' "$path_dir" >>"${GITHUB_PATH:?GITHUB_PATH is required}" { - printf 'EDGEZERO__TEST__FAKE_CALL_LOG=%s\n' "$log" + printf 'FAKE_CALL_LOG=%s\n' "$log" } >>"${GITHUB_ENV:?GITHUB_ENV is required}" notice "fake fastly (v$pinned) installed in the tool root and on PATH; fake curl on PATH" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 364bcd4a..a930dd7d 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -229,7 +229,7 @@ jobs: retry-delay: "1" env: # Flips the fake probe to 503 for this step only. - EDGEZERO__TEST__FORCE_UNHEALTHY: "1" + FORCE_UNHEALTHY: "1" - name: Assert the unhealthy check failed the wrapper env: From 10f3207c4976a6ca555494a72aa03a68671afe61 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:44:13 -0700 Subject: [PATCH 25/26] =?UTF-8?q?spec+plan:=20add=20config-push-fastly=20(?= =?UTF-8?q?=C2=A75.5)=20=E2=80=94=20same=20store,=20different=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the config-push feature before implementation: - Spec §5.5: a config-push-fastly action mirroring the deploy pattern (prebuilt app CLI, typed creds, same credential boundary). Staging model is "same config store, different key": production writes the resolved key, staging writes _staging in the same store, matching the runtime-override store the Fastly adapter already scaffolds. Inputs/outputs table; a new engine `config-push` mode alongside build/deploy. - Non-goals: config push is now its own action, no longer a deploy side effect. - Plan: new phase 5 (CLI `config push --staging` key derivation + engine mode + wrapper + tests); later phases renumbered. --- ...ezero-deploy-action-implementation-plan.md | 46 ++++++++++--- docs/specs/edgezero-deploy-github-action.md | 66 ++++++++++++++++++- 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index 36599fd1..24f92b47 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -161,12 +161,37 @@ reference to port from. Most transfer with light changes: Carry no orchestration policy — the caller wires stage → healthcheck → rollback. -5. **Scripts layout** +5. **`config-push-fastly` action + CLI staging support (§5.5)** + - CLI (`edgezero-adapter-fastly` + `edgezero-cli`): add `--staging` to + `config push`. `config.rs` already resolves one push entry + `(key, body)` where `key = args.key.unwrap_or(logical_store_id)`; when + `--staging` is set, derive the staging variant `_staging` (same + resolved store, different key). Mirror the derivation in `config diff` so a + staged diff compares against the staged key. Colocated tests: production key + unchanged, `--staging` suffixes, explicit `--key` + `--staging` composes. + - Engine (`deploy-core/scripts/run-app-cli.sh`): add a `config-push` mode + beside `build`/`deploy`. Same credential boundary — `provider-env` in, the + `EDGEZERO__*` namespace scrubbed before exec — invoking + ` config push --adapter ` with the wrapper's typed flags + (`--store`, `--key`, `--staging`). + - `config-push-fastly` wrapper: thin composite mirroring `deploy-fastly`. + Inputs `app-cli-artifact`, `app-cli-bin`, `fastly-api-token`, + `working-directory`, `manifest`, `app-config`, `store`, `key`, `deploy-to` + (`production`/`staging`, validated + fail-closed). Maps `fastly-api-token` + → `provider-env: {FASTLY_API_TOKEN: …}`; blanks the full alias list on every + step; installs the pinned Fastly CLI (the push shells out to + `fastly config-store-entry update`). Outputs `pushed-key`, `store`. + - Contract + smoke coverage: a `config-push` engine argv test; the lifecycle + smoke job's fake `fastly` gains `config-store list` / `config-store-entry +update` handlers, and a staged push asserts the `_staging` key reached + the store. + +6. **Scripts layout** - Provider-neutral scripts under `deploy-core/`; the Fastly install + checksum step lives with `deploy-fastly/` (or a shared script keyed by adapter). - No CLI-build script here — CLI build lives entirely in `build-app-cli`. -6. **CI workflow (`.github/workflows/deploy-action.yml`) — no Python** +7. **CI workflow (`.github/workflows/deploy-action.yml`) — no Python** - Pin third-party actions to readable released tags (`actions/checkout@v4`, `actions/cache@v4`, artifact upload/download at released tags). - Run `actionlint` from a pinned release binary (no `go run @`). @@ -181,7 +206,7 @@ reference to port from. Most transfer with light changes: Assert CLI-artifact reuse, credential scoping, and `fastly-version` threading. -7. **Bash contract tests (`tests/run.sh`)** +8. **Bash contract tests (`tests/run.sh`)** - Cover engine + wrappers: adapter/boolean/JSON validation, path confinement, symlink escape, dirty source, toolchain precedence, cache keys, credential scoping, deploy-arg allowlist, build/deploy argv, cleanup, log redaction, @@ -189,7 +214,7 @@ reference to port from. Most transfer with light changes: output parsing, healthcheck/rollback argv, staging vs production paths). - No Python; no live provider credentials. -8. **Companion CLI scaffolding (`crates/edgezero-cli`, `edgezero-adapter-fastly`)** +9. **Companion CLI scaffolding (`crates/edgezero-cli`, `edgezero-adapter-fastly`)** - Add `#[command(version)]` to the downstream CLI template (`crates/edgezero-cli/src/templates/cli/src/main.rs.hbs`) so generated app CLIs expose `--version`. Until adopted, `build-app-cli` reads the version from @@ -202,13 +227,14 @@ reference to port from. Most transfer with light changes: previous / deactivate staged), and wire `Healthcheck` / `Rollback` arms into the downstream CLI template so app CLIs expose them. -9. **Docs** - - Write `docs/guide/deploy-github-actions.md` around the three-layer model, - general EdgeZero-app-repo adoption, and the Fastly staging lifecycle. - - Document the app-provided CLI package build, artifact reuse, credential - scoping, adapter layering, staging trio, and non-goals. +10. **Docs** -10. **Validation** +- Write `docs/guide/deploy-github-actions.md` around the three-layer model, + general EdgeZero-app-repo adoption, and the Fastly staging lifecycle. +- Document the app-provided CLI package build, artifact reuse, credential + scoping, adapter layering, staging trio, and non-goals. + +11. **Validation** - Bash contract tests, `actionlint`, `shellcheck`, `zizmor`, checksum metadata, docs validation, composite smoke test. - Workspace Rust tests, format, clippy, and feature checks. diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md index 0d2914bb..4f8ce698 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -14,6 +14,7 @@ .github/actions/deploy-fastly .github/actions/healthcheck-fastly .github/actions/rollback-fastly +.github/actions/config-push-fastly ``` ## 1. Executive summary @@ -124,7 +125,8 @@ The **generic** engine (`deploy-core`) will not: 2. choose an application ref; 3. deploy more than one adapter per `deploy-*` invocation; 4. provision provider resources or push runtime config as a side effect of - deploy (these remain explicit CLI subcommands the caller may run separately); + deploy — config push is its own action (`config-push-fastly`, §5.5), and + provision remains an explicit CLI subcommand the caller may run separately; 5. implement provider staging, health checks, rollback, or deployment-version parsing **in the provider-neutral engine** — these are provider-specific and live in the Fastly staging lifecycle actions (§5.4), driven by the app CLI; @@ -256,6 +258,13 @@ endpoints, or CLI flags — those, and the list of aliases to clear (`provider-env-clear`), all arrive from the wrapper. It invokes the application's CLI binary (``), not a hard-coded `edgezero`. +The engine runs one of three modes: `build`, `deploy`, and `config-push` (§5.5). +`config-push` reuses the same credential boundary as `deploy` — the token is +delivered through `provider-env`, and the private `EDGEZERO__*` namespace is +scrubbed before the CLI runs — but invokes the app CLI's `config push` subcommand +(with the wrapper's typed `store`, `key`, and `--staging` flags) instead of +`deploy`. + ### 5.3 Layer 3 — adapter wrappers (`deploy-fastly`, …) Minimal composite actions. A wrapper only: @@ -410,6 +419,61 @@ A caller wires the trio; the actions carry no orchestration policy of their own: Because these are Fastly-specific, future adapters do not inherit them; a new adapter adds its own lifecycle actions if its provider supports staging. +### 5.5 Config push (`config-push-fastly`) + +Deploy activates code; it never writes runtime config (§4). Pushing the typed +app config to the provider's config store is a **separate** action, +`config-push-fastly`, so a caller decides when config moves and can push it +independently of a code deploy. + +It follows the deploy pattern exactly: it consumes the prebuilt `build-app-cli` +artifact, takes typed provider credentials, and drives the **app's own CLI** +(` config push --adapter fastly`). The engine adds a `config-push` mode +alongside `build`/`deploy` (§5.2); the credential boundary (§10) is identical — +the token reaches only this step, and the private namespace is scrubbed before +the CLI runs. + +#### 5.5.1 Staging model — same store, different key + +Fastly config stores are **not versioned** like the draft service versions the +deploy-staging path clones, so "push config to staging" cannot mean a draft. It +means the **same config store, a different key**: + +- **Production** writes the config blob under the resolved key (the logical store + id, or an explicit `--key`). +- **Staging** writes under the staging variant of that key — `_staging` — + in the **same** store. + +This mirrors the runtime-override store the Fastly adapter already scaffolds +(`provision` emits a store whose `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` +entry selects `app_config` vs `app_config_staging`). The service reads whichever +key that override selects, so pushing staging config never touches the key the +production service is reading. The CLI gains a `config push --staging` flag (the +same `--staging` verb `deploy`/`healthcheck`/`rollback` already use); the wrapper +exposes it as `deploy-to: production | staging`, validated exactly and +fail-closed like the lifecycle actions. + +#### 5.5.2 Inputs / outputs + +| Input | Required | Default | Meaning | +| ------------------- | -------- | ------------- | --------------------------------------------------------------- | +| `app-cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Injected only into the push step. | +| `working-directory` | No | `.` | App directory (holds the manifest + typed config). | +| `app-cli-bin` | No | from artifact | Binary name inside the artifact. | +| `manifest` | No | empty | `edgezero.toml` path relative to `working-directory`. | +| `app-config` | No | empty | Typed config file path (default: resolved from the manifest). | +| `store` | No | empty | Logical config-store id (default: the manifest's resolved id). | +| `key` | No | empty | Explicit base key (default: the logical store id). | +| `deploy-to` | No | `production` | `staging` writes the `_staging` variant in the same store. | + +Outputs: `pushed-key` (the key that was written — the base key, or its +`_staging` variant), `store` (the resolved logical store id). + +A staged deploy plus a staged config push and a healthcheck compose the same way +the lifecycle trio does (§5.4.4): push staging config, deploy the staged version, +probe it, roll back on failure. + ## 6. Execution flow (engine) 1. Verify the runner is Linux x86-64 (`ubuntu-24.04` is the tested environment). From 5a423feb3941214d4e7ed9322431989becfda075 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:13:47 -0700 Subject: [PATCH 26/26] Add config-push-fastly action + CLI `config push --staging` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy activates code; it never writes runtime config. Pushing typed config to a Fastly config store is now its own action, config-push-fastly, so a caller moves config on their own schedule. Staging is the same store, a different key. Fastly config stores are not versioned like staged service versions, so `config push --staging` writes the config under `_staging` alongside the production key — never overwriting what the live service reads. This matches the runtime-override store the Fastly adapter already scaffolds (its selector key chooses app_config vs app_config_staging). CLI (edgezero-cli): - `config push`/`config diff` gain `--staging`; a single seam (resolve_config_key) suffixes the resolved key when set, so `--key` composes with `--staging`. Colocated unit test covers all four cases. - After a successful write, `config.rs` emits a canonical `pushed-key=` line (via the logger's stdout route) so the wrapper can thread the key out. Action (config-push-fastly): - A THIN wrapper like healthcheck-fastly/rollback-fastly, not the heavy deploy path: download the CLI artifact, install the pinned Fastly CLI, call the app CLI directly. The token reaches the CLI under the adapter's own convention (FASTLY_API_TOKEN — what `fastly config-store-entry update` reads), with every other FASTLY_* alias blanked. `deploy-to` is validated fail-closed. - Outputs pushed-key and store. Tests: 6 new contract cases (config-push argv: staging appends --staging, production does not, --store/--key threaded, bad deploy-to rejected) — 55 total. Wired into the workflow's path filters, shellcheck, zizmor, and pin checks. Spec §5.5 + plan phase 5 + guide updated. All gates green (fmt, clippy, cli tests, contract tests, actionlint, docs build). --- .github/actions/config-push-fastly/action.yml | 197 ++++++++++++++++++ .../config-push-fastly/scripts/config-push.sh | 78 +++++++ .../deploy-core/tests/check-action-pins.sh | 1 + .github/actions/deploy-core/tests/run.sh | 58 ++++++ .github/workflows/deploy-action.yml | 5 +- crates/edgezero-cli/src/args.rs | 21 +- crates/edgezero-cli/src/config.rs | 58 +++++- docs/guide/deploy-github-actions.md | 28 +++ ...ezero-deploy-action-implementation-plan.md | 28 +-- docs/specs/edgezero-deploy-github-action.md | 24 +-- 10 files changed, 465 insertions(+), 33 deletions(-) create mode 100644 .github/actions/config-push-fastly/action.yml create mode 100755 .github/actions/config-push-fastly/scripts/config-push.sh diff --git a/.github/actions/config-push-fastly/action.yml b/.github/actions/config-push-fastly/action.yml new file mode 100644 index 00000000..ef6e7720 --- /dev/null +++ b/.github/actions/config-push-fastly/action.yml @@ -0,0 +1,197 @@ +name: EdgeZero config-push-fastly +description: Push a checked-out EdgeZero application's typed config to a Fastly config store using a prebuilt app CLI artifact. + +inputs: + app-cli-artifact: + description: Name of the build-app-cli artifact to download and run. + required: true + app-cli-bin: + description: Binary name inside the artifact. Defaults to the artifact metadata. + required: false + default: "" + fastly-api-token: + description: Fastly API token. Injected only into the push step. + required: true + working-directory: + description: Application directory relative to github.workspace (holds the manifest + typed config). + required: false + default: . + manifest: + description: Optional edgezero.toml path relative to working-directory. + required: false + default: "" + app-config: + description: "Optional typed config file path (default: resolved from the manifest)." + required: false + default: "" + store: + description: "Optional logical config-store id (default: the manifest's resolved id)." + required: false + default: "" + key: + description: "Optional explicit base key (default: the logical store id)." + required: false + default: "" + deploy-to: + description: "'production' writes the base key; 'staging' writes the _staging variant in the same store." + required: false + default: production + +outputs: + pushed-key: + description: The key that was written (the base key, or its _staging variant). + value: ${{ steps.push.outputs['pushed-key'] }} + store: + description: The logical config-store id, when supplied. + value: ${{ steps.push.outputs.store }} + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + env: + EDGEZERO__FASTLY__API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} + EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + run: | + [[ "${EDGEZERO__FASTLY__API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } + case "${EDGEZERO__DEPLOY__TO}" in + production | staging) ;; + *) echo "::error::input 'deploy-to' must be 'production' or 'staging'"; exit 1 ;; + esac + + - name: Download CLI artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs['app-cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-cli-download + env: + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + + - name: Extract CLI + id: cli + shell: bash + env: + EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh + + - name: Install Fastly CLI + shell: bash + env: + EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. + EDGEZERO__FASTLY__VERSIONS_JSON: ${{ github.action_path }}/../deploy-fastly/versions.json + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/../deploy-fastly/scripts/install-fastly.sh + + - name: Config push + id: push + shell: bash + env: + EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} + EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} + EDGEZERO__CONFIG_PUSH__STORE: ${{ inputs.store }} + EDGEZERO__CONFIG_PUSH__KEY: ${{ inputs.key }} + EDGEZERO__CONFIG_PUSH__MANIFEST: ${{ inputs.manifest }} + EDGEZERO__CONFIG_PUSH__APP_CONFIG: ${{ inputs['app-config'] }} + # Only the typed token reaches the CLI under the adapter's own convention + # (FASTLY_API_TOKEN, what `fastly config-store-entry update` reads); every + # other inherited FASTLY_* alias is blanked so none can redirect the push. + FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/scripts/config-push.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/config-push-fastly/scripts/config-push.sh b/.github/actions/config-push-fastly/scripts/config-push.sh new file mode 100755 index 00000000..601099ce --- /dev/null +++ b/.github/actions/config-push-fastly/scripts/config-push.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pushes the application's typed config to a Fastly config store, and emits the +# key that was written. +# +# Like healthcheck.sh and rollback.sh (its sibling lifecycle actions), this calls +# the app CLI directly with FASTLY_API_TOKEN in the step env — the adapter's own +# convention, which `fastly config-store-entry update` reads to authenticate. The +# wrapper blanks every other FASTLY_* alias, so an inherited FASTLY_ENDPOINT or +# FASTLY_TOKEN can never redirect or re-auth the push. +# +# Staging (spec §5.5): `deploy-to: staging` passes `--staging` to the CLI, which +# writes the `_staging` variant in the SAME store — never the production key +# the live service reads. +# +# Reads (env): +# EDGEZERO__APP__CLI__BIN required app CLI binary to invoke +# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# EDGEZERO__PROJECT__WORKING_DIRECTORY required directory to run the CLI from +# EDGEZERO__DEPLOY__TO optional production | staging (default: production) +# EDGEZERO__CONFIG_PUSH__STORE optional logical config-store id +# EDGEZERO__CONFIG_PUSH__KEY optional explicit base key +# EDGEZERO__CONFIG_PUSH__MANIFEST optional edgezero.toml path (relative to the app dir) +# EDGEZERO__CONFIG_PUSH__APP_CONFIG optional typed config file path +# Writes (outputs): +# pushed-key the key written (base, or its _staging variant) +# store the logical store id, when supplied + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +main() { + local cli_bin="${EDGEZERO__APP__CLI__BIN:?EDGEZERO__APP__CLI__BIN is required}" + local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:?EDGEZERO__PROJECT__WORKING_DIRECTORY is required}" + local deploy_to="${EDGEZERO__DEPLOY__TO:-production}" + local store="${EDGEZERO__CONFIG_PUSH__STORE:-}" + local key="${EDGEZERO__CONFIG_PUSH__KEY:-}" + local manifest="${EDGEZERO__CONFIG_PUSH__MANIFEST:-}" + local app_config="${EDGEZERO__CONFIG_PUSH__APP_CONFIG:-}" + + require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + require_cmd "$cli_bin" + # A typo in deploy-to must never silently push to production. + case "$deploy_to" in + production | staging) ;; + *) fail "input 'deploy-to' must be 'production' or 'staging' (got '$deploy_to')" ;; + esac + + # Build the argv through a Bash array — never eval. --yes and --no-diff make the + # push non-interactive in CI; --staging selects the `_staging` variant. + local argv=("$cli_bin" config push --adapter fastly) + if [[ -n "$manifest" ]]; then argv+=(--manifest "$manifest"); fi + if [[ -n "$app_config" ]]; then argv+=(--app-config "$app_config"); fi + if [[ -n "$store" ]]; then argv+=(--store "$store"); fi + if [[ -n "$key" ]]; then argv+=(--key "$key"); fi + if [[ "$deploy_to" == "staging" ]]; then argv+=(--staging); fi + argv+=(--yes --no-diff) + + new_private_log + local rc=0 + (cd "$working_directory" && "${argv[@]}") 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + if [[ "$rc" -ne 0 ]]; then + fail "config push failed (CLI exit $rc)" + fi + + # Anchored parse of the canonical `pushed-key=` line the CLI emits. + local pushed + pushed=$(grep -oE '^pushed-key=[A-Za-z0-9._-]+$' "$LIFECYCLE_LOG" | tail -n 1 | cut -d= -f2 || true) + [[ -n "$pushed" ]] || + fail "config push reported success but emitted no canonical 'pushed-key=' line" + + append_output pushed-key "$pushed" + append_output store "$store" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/check-action-pins.sh b/.github/actions/deploy-core/tests/check-action-pins.sh index a646e78a..79b80d7d 100755 --- a/.github/actions/deploy-core/tests/check-action-pins.sh +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -15,6 +15,7 @@ files=( "$REPO_ROOT/.github/actions/deploy-fastly/action.yml" "$REPO_ROOT/.github/actions/healthcheck-fastly/action.yml" "$REPO_ROOT/.github/actions/rollback-fastly/action.yml" + "$REPO_ROOT/.github/actions/config-push-fastly/action.yml" ) status=0 diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index c21aa07f..203f2d44 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -500,6 +500,63 @@ test_toolchain_boundary() { "$edgezero_rust" "$fallback" } +# --------------------------------------------------------------------------- +# config-push.sh — the staging key is a different key, driven by --staging +# --------------------------------------------------------------------------- +# Runs config-push.sh against a fake app CLI that records its argv and emits the +# canonical pushed-key line. Returns the recorded argv (one arg per line). +run_config_push_argv() { + local dir="$WORK_DIR/config-push" + rm -rf "$dir" + mkdir -p "$dir/bin" "$dir/app" + # A fake app CLI: record every argument, then emit the contract line so the + # wrapper's anchored parse succeeds. + cat >"$dir/bin/fake-cli" <<'CLI' +#!/usr/bin/env bash +printf '%s\n' "$@" >"$FAKE_ARGV_OUT" +echo "pushed-key=app_config_staging" +CLI + chmod +x "$dir/bin/fake-cli" + + PATH="$dir/bin:$PATH" FAKE_ARGV_OUT="$dir/argv.txt" \ + EDGEZERO__APP__CLI__BIN=fake-cli \ + FASTLY_API_TOKEN=tok \ + EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir/app" \ + EDGEZERO__DEPLOY__TO="${CP_DEPLOY_TO:-production}" \ + EDGEZERO__CONFIG_PUSH__STORE="${CP_STORE:-}" \ + EDGEZERO__CONFIG_PUSH__KEY="${CP_KEY:-}" \ + "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" >/dev/null 2>&1 + cat "$dir/argv.txt" +} + +test_config_push_argv() { + section "config-push argv" + + # Production: the base subcommand, non-interactive flags, and NO --staging. + local prod + prod=$(run_config_push_argv) + assert_equals "production drives 'config push --adapter fastly'" \ + $'config\npush\n--adapter\nfastly\n--yes\n--no-diff' "$prod" + + # Staging: same argv plus --staging (the CLI then writes _staging). + local staged + staged=$(CP_DEPLOY_TO=staging run_config_push_argv) + assert_succeeds "staging appends --staging" grep -qx -- '--staging' <<<"$staged" + assert_fails "production does NOT pass --staging" grep -qx -- '--staging' <<<"$prod" + + # Typed --store / --key are threaded through when supplied. + local with_store + with_store=$(CP_STORE=cfg CP_KEY=mykey run_config_push_argv) + assert_succeeds "--store is threaded" grep -qx -- 'cfg' <<<"$with_store" + assert_succeeds "--key is threaded" grep -qx -- 'mykey' <<<"$with_store" + + # A bad deploy-to must fail closed, never silently push to production. + assert_fails "a non-{production,staging} deploy-to is rejected" \ + env EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + EDGEZERO__PROJECT__WORKING_DIRECTORY="$WORK_DIR" EDGEZERO__DEPLOY__TO=Staging \ + "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" +} + # --------------------------------------------------------------------------- main() { test_validate_inputs @@ -515,6 +572,7 @@ main() { test_deploy_args_prepend test_lifecycle_helpers test_toolchain_boundary + test_config_push_argv printf '\nPassed: %d Failed: %d\n' "$tests_passed" "$tests_failed" [[ "$tests_failed" -eq 0 ]] diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index a930dd7d..5f733435 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -8,6 +8,7 @@ on: - .github/actions/deploy-fastly/** - .github/actions/healthcheck-fastly/** - .github/actions/rollback-fastly/** + - .github/actions/config-push-fastly/** - .github/workflows/deploy-action.yml - crates/edgezero-adapter/** - crates/edgezero-adapter-fastly/** @@ -22,6 +23,7 @@ on: - .github/actions/deploy-fastly/** - .github/actions/healthcheck-fastly/** - .github/actions/rollback-fastly/** + - .github/actions/config-push-fastly/** - .github/workflows/deploy-action.yml - crates/edgezero-adapter/** - crates/edgezero-adapter-fastly/** @@ -89,7 +91,8 @@ jobs: .github/actions/build-app-cli/scripts/*.sh \ .github/actions/deploy-core/scripts/*.sh \ .github/actions/deploy-core/tests/*.sh \ - .github/actions/deploy-fastly/scripts/*.sh + .github/actions/deploy-fastly/scripts/*.sh \ + .github/actions/config-push-fastly/scripts/*.sh - name: Bash contract tests run: .github/actions/deploy-core/tests/run.sh diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 3bcf711f..3cae90eb 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -307,6 +307,12 @@ pub enum DiffFormat { /// CLI at runtime. #[derive(clap::Args, Debug)] #[non_exhaustive] +#[expect( + clippy::struct_excessive_bools, + reason = "clap args struct: each bool is a distinct CLI flag \ + (exit_code, local, no_env, staging); a state machine \ + would be inappropriate here" +)] pub struct ConfigDiffArgs { /// Target adapter name. #[arg(long, required = true)] @@ -338,6 +344,11 @@ pub struct ConfigDiffArgs { /// Path to the adapter's runtime configuration file. #[arg(long)] pub runtime_config: Option, + /// Diff against the staging key (`_staging`) in the same store, + /// so a staged diff compares what `config push --staging` would write + /// (spec §5.5). + #[arg(long)] + pub staging: bool, /// Logical config store id to diff against. Defaults to the /// `[stores.config].default` (or the only declared id when /// `[stores.config].ids` has length 1). @@ -359,6 +370,7 @@ impl Default for ConfigDiffArgs { manifest: default_manifest_path(), no_env: false, runtime_config: None, + staging: false, store: None, } } @@ -375,7 +387,7 @@ impl Default for ConfigDiffArgs { #[expect( clippy::struct_excessive_bools, reason = "clap args struct: each bool is a distinct CLI flag \ - (dry_run, local, no_diff, no_env, yes); a state machine \ + (dry_run, local, no_diff, no_env, staging, yes); a state machine \ would be inappropriate here" )] pub struct ConfigPushArgs { @@ -424,6 +436,12 @@ pub struct ConfigPushArgs { /// `runtime-config.toml` next to the adapter manifest. #[arg(long)] pub runtime_config: Option, + /// Push to staging: write the config under the `_staging` variant + /// in the SAME store, so it never overwrites the production key the live + /// service is reading (Fastly staging lifecycle, spec §5.5). The same + /// `--staging` verb `deploy`/`healthcheck`/`rollback` use. + #[arg(long)] + pub staging: bool, /// Logical config store id to push to. Defaults to the /// `[stores.config].default` (or the only declared id when /// `[stores.config].ids` has length 1). @@ -448,6 +466,7 @@ impl Default for ConfigPushArgs { no_diff: false, no_env: false, runtime_config: None, + staging: false, store: None, yes: false, } diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index ef4ac6a4..15501e0c 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -314,10 +314,13 @@ where // Build envelope. // Honour --key override (5.4): if the caller supplied an explicit key, // use it; otherwise fall back to the manifest's resolved logical store id. - let key = args - .key - .clone() - .unwrap_or_else(|| ctx.store.logical.clone()); + // `--staging` (5.5) then writes the `_staging` variant in the same store. + let key = resolve_config_key( + args.key + .clone() + .unwrap_or_else(|| ctx.store.logical.clone()), + args.staging, + ); let body = build_config_envelope::(&typed)?; let local_envelope: BlobEnvelope = serde_json::from_str(&body).map_err(|err| format!("local envelope parse failed: {err}"))?; @@ -451,7 +454,9 @@ where let env_config = EnvConfig::from_env(); let platform = env_config.store_name("config", &logical); let store = ResolvedStoreId::new(logical.clone(), platform); - let key = args.key.clone().unwrap_or(logical); + // `--staging` (5.5) diffs the `_staging` variant, matching what + // `config push --staging` would write. + let key = resolve_config_key(args.key.clone().unwrap_or(logical), args.staging); // Resolve adapter paths for the read call. let manifest_root = ctx @@ -922,6 +927,12 @@ fn write_envelope( for line in lines { log::info!("{line}"); } + // Canonical machine-readable line (spec §5.5) — the config-push-fastly action + // greps `^pushed-key=$` to thread the written key out as an output. The + // CLI logger routes log::info! to stdout, so this reaches the action's log. + if !args.dry_run { + log::info!("pushed-key={key}"); + } Ok(()) } @@ -1076,6 +1087,19 @@ fn resolve_adapter_push_ctx( } } +/// Derive the config-store key a push or diff targets. When `staging` is set, +/// the config is written under (or diffed against) the `_staging` variant +/// in the SAME store — never the production key the live service reads (spec +/// §5.5). This mirrors the runtime-override store the Fastly adapter scaffolds, +/// whose selector key chooses `app_config` vs `app_config_staging`. +fn resolve_config_key(base: String, staging: bool) -> String { + if staging { + format!("{base}_staging") + } else { + base + } +} + fn resolve_config_store_id(requested: Option<&str>, manifest: &Manifest) -> Result { let Some(declaration) = manifest.stores.config.as_ref() else { return Err( @@ -1781,6 +1805,7 @@ source = "target/wasm32-wasip2/release/demo.wasm" no_env: true, runtime_config: None, store: None, + staging: false, yes: false, } } @@ -1794,6 +1819,26 @@ source = "target/wasm32-wasip2/release/demo.wasm" run_config_validate(&args_for(&manifest)).expect("valid project passes"); } + #[test] + fn staging_key_suffixes_only_when_staging() { + // Production: the key is untouched (base key, or an explicit --key). + assert_eq!( + resolve_config_key("app_config".to_owned(), false), + "app_config" + ); + assert_eq!(resolve_config_key("custom".to_owned(), false), "custom"); + // Staging: the same store, the `_staging` variant. + assert_eq!( + resolve_config_key("app_config".to_owned(), true), + "app_config_staging" + ); + // --key composes with --staging (the override is suffixed, not ignored). + assert_eq!( + resolve_config_key("custom".to_owned(), true), + "custom_staging" + ); + } + #[test] fn raw_errors_on_unknown_manifest_path() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -3670,6 +3715,7 @@ ids = ["default"] no_env: true, runtime_config: None, store: None, + staging: false, }; let err = run_config_diff_typed::(&diff_args) .expect_err("missing [component.*] must fail Spin's shared-check preflight in diff"); @@ -3748,6 +3794,7 @@ ids = ["default"] no_env: true, runtime_config: None, store: None, + staging: false, }; // The nested empty secret must be rejected by the path-aware // typed_secret_checks before the remote-read step, naming the path. @@ -3819,6 +3866,7 @@ ids = ["default"] no_env: true, runtime_config: None, store: None, + staging: false, }; // typed_secret_checks must catch the empty `#[secret]` field // before the function reaches the remote-read step. diff --git a/docs/guide/deploy-github-actions.md b/docs/guide/deploy-github-actions.md index 84f22803..597345af 100644 --- a/docs/guide/deploy-github-actions.md +++ b/docs/guide/deploy-github-actions.md @@ -196,6 +196,34 @@ your rollback on the step failing (`if: failure()`), not on the `healthy` output Outputs: `rolled-back-to` (production only — the version that was activated). +### `config-push-fastly` + +Pushes your app's typed config to a Fastly config store. This is **separate from +deploy** — deploy activates code, it never writes runtime config — so you run it +as its own step, whenever config should move. + +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | ------------------------------------------------------------------- | +| `app-cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Fastly API token. Injected only into the push step. | +| `app-cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `working-directory` | No | `.` | App directory (holds the manifest + typed config). | +| `manifest` | No | empty | `edgezero.toml` path relative to `working-directory`. | +| `app-config` | No | empty | Typed config file path (default: resolved from the manifest). | +| `store` | No | empty | Logical config-store id (default: the manifest's resolved id). | +| `key` | No | empty | Explicit base key (default: the logical store id). | +| `deploy-to` | No | `production` | `staging` writes the `_staging` variant in the **same** store. | + +Outputs: `pushed-key` (the key written — the base key, or its `_staging` variant) +and `store` (the logical store id, when supplied). + +**Staging config is the same store, a different key.** Fastly config stores are +not versioned like staged service versions, so `deploy-to: staging` writes your +config under `_staging` alongside the production key — never overwriting what +the live service reads. Which key the service reads is decided separately by the +runtime-override store (`edgezero provision` scaffolds it). A typo like +`deploy-to: Staging` is rejected up front, never silently pushed to production. + ## Strict lifecycle values (fail closed) `stage` and `deploy-to` are validated exactly, and a bad value **fails the run** diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index 24f92b47..cf1d9df8 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -169,22 +169,22 @@ reference to port from. Most transfer with light changes: resolved store, different key). Mirror the derivation in `config diff` so a staged diff compares against the staged key. Colocated tests: production key unchanged, `--staging` suffixes, explicit `--key` + `--staging` composes. - - Engine (`deploy-core/scripts/run-app-cli.sh`): add a `config-push` mode - beside `build`/`deploy`. Same credential boundary — `provider-env` in, the - `EDGEZERO__*` namespace scrubbed before exec — invoking - ` config push --adapter ` with the wrapper's typed flags - (`--store`, `--key`, `--staging`). - - `config-push-fastly` wrapper: thin composite mirroring `deploy-fastly`. + - CLI canonical line: after a successful non-dry-run write, `config.rs` emits + `pushed-key=` (via `log::info!`, which the CLI routes to stdout) so the + wrapper can thread the written key out as an output. + - `config-push-fastly` wrapper: a **thin** composite mirroring + `healthcheck-fastly` / `rollback-fastly` (not the heavy `deploy` path). Inputs `app-cli-artifact`, `app-cli-bin`, `fastly-api-token`, `working-directory`, `manifest`, `app-config`, `store`, `key`, `deploy-to` - (`production`/`staging`, validated + fail-closed). Maps `fastly-api-token` - → `provider-env: {FASTLY_API_TOKEN: …}`; blanks the full alias list on every - step; installs the pinned Fastly CLI (the push shells out to - `fastly config-store-entry update`). Outputs `pushed-key`, `store`. - - Contract + smoke coverage: a `config-push` engine argv test; the lifecycle - smoke job's fake `fastly` gains `config-store list` / `config-store-entry -update` handlers, and a staged push asserts the `_staging` key reached - the store. + (`production`/`staging`, validated + fail-closed). Downloads the CLI artifact, + installs the pinned Fastly CLI (the push shells out to + `fastly config-store-entry update`), and calls the CLI directly with + `FASTLY_API_TOKEN` in the step env — the adapter's own convention — with + every other `FASTLY_*` alias blanked. Outputs `pushed-key`, `store`. + - Contract + smoke coverage: a `config-push.sh` argv test (staging appends + `--staging`; production does not); the smoke fixture's fake `fastly` gains + `config-store list` / `config-store-entry update` handlers, and a staged push + asserts the `_staging` key reached the store. 6. **Scripts layout** - Provider-neutral scripts under `deploy-core/`; the Fastly install + checksum diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md index 4f8ce698..7451054a 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -258,12 +258,8 @@ endpoints, or CLI flags — those, and the list of aliases to clear (`provider-env-clear`), all arrive from the wrapper. It invokes the application's CLI binary (``), not a hard-coded `edgezero`. -The engine runs one of three modes: `build`, `deploy`, and `config-push` (§5.5). -`config-push` reuses the same credential boundary as `deploy` — the token is -delivered through `provider-env`, and the private `EDGEZERO__*` namespace is -scrubbed before the CLI runs — but invokes the app CLI's `config push` subcommand -(with the wrapper's typed `store`, `key`, and `--staging` flags) instead of -`deploy`. +The engine runs `build` or `deploy`. Config push (§5.5) does not go through this +engine — like the other thin lifecycle wrappers it drives the app CLI directly. ### 5.3 Layer 3 — adapter wrappers (`deploy-fastly`, …) @@ -426,12 +422,16 @@ app config to the provider's config store is a **separate** action, `config-push-fastly`, so a caller decides when config moves and can push it independently of a code deploy. -It follows the deploy pattern exactly: it consumes the prebuilt `build-app-cli` -artifact, takes typed provider credentials, and drives the **app's own CLI** -(` config push --adapter fastly`). The engine adds a `config-push` mode -alongside `build`/`deploy` (§5.2); the credential boundary (§10) is identical — -the token reaches only this step, and the private namespace is scrubbed before -the CLI runs. +It is a thin wrapper like `healthcheck-fastly` / `rollback-fastly`, not the heavy +`deploy` path: it consumes the prebuilt `build-app-cli` artifact, installs the +pinned Fastly CLI (the push shells out to `fastly config-store-entry update`), +and drives the **app's own CLI** (` config push --adapter fastly`) +directly. The token reaches the CLI under the adapter's own convention — +`FASTLY_API_TOKEN`, injected only into the push step — and every other `FASTLY_*` +alias is blanked, so an inherited `FASTLY_ENDPOINT` or `FASTLY_TOKEN` cannot +redirect or re-auth the push. (The heavier `provider-env` JSON boundary is for +`deploy`, which spawns arbitrary manifest build/deploy commands; config-push, +like the other lifecycle actions, does not.) #### 5.5.1 Staging model — same store, different key