Skip to content

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

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

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

Workflow file for this run

name: Deploy actions
on:
pull_request:
paths:
- .github/actions/build-app-cli/**
- .github/actions/deploy-core/**
- .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/**
- crates/edgezero-cli/**
- docs/guide/deploy-github-actions.md
- docs/specs/**
push:
branches: [main]
paths:
- .github/actions/build-app-cli/**
- .github/actions/deploy-core/**
- .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/**
- 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, checksum-verified)
run: |
set -euo pipefail
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)
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
- 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-app-cli/action.yml \
.github/actions/deploy-fastly/action.yml \
.github/actions/healthcheck-fastly/action.yml \
.github/actions/rollback-fastly/action.yml \
.github/actions/config-push-fastly/action.yml
- name: Install ShellCheck
run: |
sudo apt-get update
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 -e SC1091 \
.github/actions/*/scripts/*.sh \
.github/actions/deploy-core/tests/*.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
# 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
# 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-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-app-cli
with:
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:
app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }}
working-directory: fixture-app
fastly-api-token: dummy-token
fastly-service-id: dummy-service
deploy-args: '["--comment","smoke"]'
cache: true
- name: Assert production deploy, version threading, and credential boundary
env:
EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs['fastly-version'] }}
run: .github/actions/deploy-core/tests/assert-production-deploy.sh
# Config push: the real config-push-fastly wrapper against a fake `fastly`,
# proving the whole chain — artifact download, Fastly CLI install, the app
# CLI's TYPED `config push` (the bundled stub cannot do this), and the
# staging-key contract. Config push is deliberately NOT part of deploy, so it
# gets its own job.
config-push-smoke:
runs-on: ubuntu-24.04
# Inherited provider aliases the push MUST clear.
env:
FASTLY_ENDPOINT: https://inherited.invalid
FASTLY_HOME: /nonexistent/inherited
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Create fixture app (app-owned CLI with typed config)
run: .github/actions/deploy-core/tests/make-smoke-fixture.sh
- name: Build the APP's own CLI package
id: cli
uses: ./.github/actions/build-app-cli
with:
app-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: Push config to staging
id: staged
uses: ./.github/actions/config-push-fastly
with:
app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }}
working-directory: fixture-app
fastly-api-token: dummy-token
deploy-to: staging
# Asserted before the re-seed below, which truncates the call log.
- name: Assert staging wrote the _staging key and never the production key
env:
EDGEZERO__TEST__EXPECT_KEY: app_config_staging
EDGEZERO__TEST__REJECT_KEY: app_config
EDGEZERO__TEST__PUSHED_KEY: ${{ steps.staged.outputs['pushed-key'] }}
EDGEZERO__TEST__PUSHED_STORE: ${{ steps.staged.outputs.store }}
run: .github/actions/deploy-core/tests/assert-config-push.sh
# Each action's cleanup runs with `if: always()` and removes the SHARED
# action-owned tool root, so a second tool-installing action in the same
# job reinstalls the Fastly CLI from scratch. A real job re-downloads it;
# this job re-seeds the fake instead, keeping the test hermetic (and this
# resets the call log, so each push is asserted against its own).
- name: Re-seed fake fastly (the previous push's cleanup removed the tool root)
run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh
- name: Push config to production
id: prod
uses: ./.github/actions/config-push-fastly
with:
app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }}
working-directory: fixture-app
fastly-api-token: dummy-token
- name: Assert production wrote the base key and never the staging key
env:
EDGEZERO__TEST__EXPECT_KEY: app_config
EDGEZERO__TEST__REJECT_KEY: app_config_staging
EDGEZERO__TEST__PUSHED_KEY: ${{ steps.prod.outputs['pushed-key'] }}
EDGEZERO__TEST__PUSHED_STORE: ${{ steps.prod.outputs.store }}
run: .github/actions/deploy-core/tests/assert-config-push.sh
# 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:
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-app-cli
with:
app-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: Staged deploy through the deploy-fastly wrapper
id: stage
uses: ./.github/actions/deploy-fastly
with:
app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }}
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
uses: ./.github/actions/healthcheck-fastly
with:
app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }}
deploy-to: staging
domain: staging.example.com
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
id: unhealthy
continue-on-error: true
uses: ./.github/actions/healthcheck-fastly
with:
app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }}
deploy-to: staging
domain: staging.example.com
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"
- name: Assert the unhealthy check failed the wrapper
env:
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
uses: ./.github/actions/rollback-fastly
with:
app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }}
deploy-to: staging
fastly-version: ${{ steps.stage.outputs['fastly-version'] }}
fastly-api-token: dummy-token
fastly-service-id: dummy-service
- name: Roll back production
id: prod-rollback
uses: ./.github/actions/rollback-fastly
with:
app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }}
deploy-to: production
fastly-version: ${{ steps.stage.outputs['fastly-version'] }}
# Fastly cannot infer the previously-live version, so the caller passes
# it explicitly (a real caller wires deploy-fastly's `previous-version`).
rollback-to: "40"
fastly-api-token: dummy-token
fastly-service-id: dummy-service
- name: Assert rollback verbs, paths, and version threading
env:
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
# A production rollback with NO rollback-to must fail closed rather than
# guess a target — Fastly cannot infer the previously-live version.
- name: Production rollback without a target must fail
id: rollback-no-target
continue-on-error: true
uses: ./.github/actions/rollback-fastly
with:
app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }}
deploy-to: production
fastly-version: ${{ steps.stage.outputs['fastly-version'] }}
fastly-api-token: dummy-token
fastly-service-id: dummy-service
- name: Assert the targetless production rollback was refused
env:
EDGEZERO__TEST__OUTCOME: ${{ steps.rollback-no-target.outcome }}
run: |
[[ "${EDGEZERO__TEST__OUTCOME}" == "failure" ]] ||
{ echo "::error::a production rollback with no rollback-to must fail, got '${EDGEZERO__TEST__OUTCOME}'"; exit 1; }
echo "targetless production rollback correctly refused"