Layered EdgeZero deploy actions + Fastly staging lifecycle (design + impl, supersedes #303) #42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |