Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
524b8d0
fix(fastly): apply canonical store selectors at deploy
aram356 Sep 16, 2026
07ffc29
test(actions): model Fastly resource link IDs
aram356 Sep 16, 2026
8efad3c
docs: move deploy specs under superpowers
aram356 Sep 16, 2026
1c1e8c3
docs: align deploy design artifact layout
aram356 Sep 16, 2026
263f7b2
docs: design version-scoped Fastly selectors
aram356 Sep 16, 2026
2da415e
docs: plan version-scoped Fastly selectors
aram356 Sep 16, 2026
1c2ba63
refactor(deploy): let adapters claim managed deployment
aram356 Sep 16, 2026
63eaaba
feat(fastly): define versioned runtime descriptors
aram356 Sep 16, 2026
b3d5a94
feat(fastly): load runtime config by service version
aram356 Sep 16, 2026
578524f
docs: drop legacy Fastly selector migration
aram356 Sep 16, 2026
96ea0ac
fix(fastly): validate managed deploy inputs before dispatch
aram356 Sep 16, 2026
b905ca9
docs: require immutable Fastly application releases
aram356 Sep 16, 2026
d281592
feat(fastly): plan descriptors and resource links before deploy
aram356 Sep 16, 2026
18de384
fix(fastly): prepare selectors before publishing versions
aram356 Sep 17, 2026
cb69341
fix(actions): retain failed Fastly deploy versions
aram356 Sep 17, 2026
17db1c0
test(actions): model version-scoped Fastly descriptors
aram356 Sep 17, 2026
7ed31eb
fix(fastly): compile host-only tests for host targets
aram356 Sep 17, 2026
1eea68f
docs(fastly): explain version-scoped runtime selectors
aram356 Sep 17, 2026
ea8937d
chore: generalize lifecycle examples
aram356 Sep 17, 2026
4ba48c3
fix(actions): tighten Fastly lifecycle contracts
aram356 Sep 17, 2026
b5fef1a
fix(config): align staging keys with runtime descriptors
aram356 Sep 17, 2026
1ff1222
fix(config): apply manifest store defaults consistently
aram356 Sep 17, 2026
ae75f9b
test(fastly): redact sensitive assertion diagnostics
aram356 Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 33 additions & 52 deletions .github/actions/config-push-fastly/action.yml
Original file line number Diff line number Diff line change
@@ -1,76 +1,61 @@
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.
description: Push publisher runtime config using the CLI and manifest from a verified immutable application release.

inputs:
app-cli-artifact:
description: Name of the build-app-cli artifact to download and run.
app-release-archive:
description: Path to the pinned application release archive.
required: true
app-release-sha256:
description: Expected lowercase SHA-256 digest of the application release archive.
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.
description: Fastly API token, scoped only to provider operations.
required: true
working-directory:
description: Application directory relative to github.workspace (holds the manifest + typed config).
description: Directory relative to github.workspace containing a publisher-owned app-config file.
required: false
default: .
manifest:
description: Optional edgezero.toml path relative to working-directory.
required: false
default: ""
app-config:
description: "Optional typed config file path relative to working-directory (default: resolved from the manifest). Mutually exclusive with app-config-inline."
description: Typed runtime config file relative to working-directory; exactly one config input is required.
required: false
default: ""
app-config-inline:
description: "Optional raw typed-config content (TOML) supplied inline instead of from a checked-out file — for config that lives in a GitHub variable with no file on disk. Written to an action-owned temp file and passed to the CLI. Mutually exclusive with app-config."
description: Inline typed runtime config; exactly one config input is required.
required: false
default: ""
no-env:
description: "When 'true', pass --no-env so the CLI does NOT overlay <APP_NAME>__…__<KEY> environment variables onto the typed config before pushing. Defaults to 'false'."
description: "When 'true', skip the typed runtime environment overlay."
required: false
default: "false"
store:
description: "Optional logical config-store id (default: the manifest's resolved id)."
required: false
default: ""
key:
description: "Optional explicit base key for a PRODUCTION push (default: the logical store id). Not allowed with deploy-to: staging, whose key is derived."
description: Optional logical Config Store ID declared by the bundled application manifest.
required: false
default: ""
deploy-to:
description: "'production' writes the base key; 'staging' writes the <logical-store-id>_staging variant in the same store (the key the staging selector points at)."
description: "Select the canonical environment key, with production/staging fallback when absent."
required: false
default: production

outputs:
pushed-key:
description: The key that was written (the base key, or the derived _staging variant).
description: Key written by the application CLI.
value: ${{ steps.push.outputs['pushed-key'] }}
store:
description: The logical config-store id the CLI resolved (always emitted, not only when the `store` input was supplied).
description: Logical Config Store ID resolved by the bundled manifest.
value: ${{ steps.push.outputs.store }}
mutation-attempted:
description: "'true', emitted immediately BEFORE the config-push CLI runs (so a cancel/timeout mid-mutation can preserve it; a hard runner loss can still drop it, so absence is not proof the store is unchanged, and a cancel in the tiny pre-run window is a conservative false positive). On failure, read this via `if: always()` and reconcile — do not assume the config store is unchanged."
description: "'true' when the config-push CLI was invoked."
value: ${{ steps.push.outputs['mutation-attempted'] }}
provider-cli-version:
description: The pinned Fastly CLI version this action installed and ran.
description: Pinned Fastly CLI version installed by this action.
value: ${{ steps.install-fastly.outputs['provider-cli-version'] }}

runs:
using: composite
steps:
# A UNIQUE per-invocation workspace root under RUNNER_TEMP, so two concurrent
# invocations in one job (e.g. `background: true`) never collide on fixed temp
# paths (CLI download, extracted tools). The cleanup step removes it.
- name: Prepare action workspace
id: ws
shell: bash
# Runs before validation, so it scrubs like every other step: blank the
# shipped aliases and BASH_ENV/ENV (a caller's job env could otherwise point
# BASH_ENV at checkout code that runs at bash startup with a token in scope).
env:
BASH_ENV: ""
ENV: ""
Expand All @@ -97,10 +82,12 @@ runs:
env:
BASH_ENV: ""
ENV: ""
EDGEZERO__APP__CLI__ARTIFACT_PRESENT: ${{ inputs['app-cli-artifact'] != '' && 'true' || 'false' }}
EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT: ${{ inputs['app-release-archive'] != '' && 'true' || 'false' }}
EDGEZERO__APP__RELEASE__SHA256_PRESENT: ${{ inputs['app-release-sha256'] != '' && 'true' || 'false' }}
EDGEZERO__FASTLY__API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }}
EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }}
EDGEZERO__CONFIG_PUSH__KEY_PRESENT: ${{ inputs.key != '' && 'true' || 'false' }}
EDGEZERO__CONFIG_PUSH__APP_CONFIG_PRESENT: ${{ inputs['app-config'] != '' && 'true' || 'false' }}
EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE_PRESENT: ${{ inputs['app-config-inline'] != '' && 'true' || 'false' }}
FASTLY_API_TOKEN: ""
FASTLY_SERVICE_ID: ""
FASTLY_TOKEN: ""
Expand All @@ -119,12 +106,15 @@ runs:
FASTLY_HOME: ""
run: exec "$GITHUB_ACTION_PATH/scripts/validate.sh"

- name: Download CLI artifact
uses: actions/download-artifact@v8
with:
name: ${{ inputs['app-cli-artifact'] }}
path: ${{ steps.ws.outputs.root }}/cli-download
- name: Verify application release
id: release
shell: bash
env:
BASH_ENV: ""
ENV: ""
EDGEZERO__APP__RELEASE__ARCHIVE: ${{ inputs['app-release-archive'] }}
EDGEZERO__APP__RELEASE__SHA256: ${{ inputs['app-release-sha256'] }}
EDGEZERO__APP__RELEASE__ROOT: ${{ steps.ws.outputs.root }}/release
FASTLY_API_TOKEN: ""
FASTLY_SERVICE_ID: ""
FASTLY_TOKEN: ""
Expand All @@ -141,16 +131,16 @@ runs:
FASTLY_CONFIG_FILE: ""
FASTLY_CARGO_PROFILE: ""
FASTLY_HOME: ""
run: exec "$GITHUB_ACTION_PATH/../fastly-common/scripts/prepare-release.sh"

- name: Extract CLI
- name: Extract application CLI
id: cli
shell: bash
env:
BASH_ENV: ""
ENV: ""
EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ steps.ws.outputs.root }}/cli-download
EDGEZERO__APP__CLI__ARCHIVE: ${{ steps.release.outputs['app-cli-archive'] }}
EDGEZERO__ACTION__TOOL_ROOT: ${{ steps.ws.outputs.root }}/tools
EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }}
FASTLY_API_TOKEN: ""
FASTLY_SERVICE_ID: ""
FASTLY_TOKEN: ""
Expand Down Expand Up @@ -199,26 +189,17 @@ runs:
id: push
shell: bash
env:
# BASH_ENV/ENV are sourced at bash startup, before this script can scrub —
# blank them so a caller's job env cannot run code here with the token.
BASH_ENV: ""
ENV: ""
# Mint the sensitive lifecycle log under the per-invocation workspace so the
# Cleanup step removes it wholesale even if the in-process EXIT trap cannot fire.
EDGEZERO__ACTION__WORKSPACE: ${{ steps.ws.outputs.root }}
EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }}
EDGEZERO__APP__CLI__PATH: ${{ steps.cli.outputs['app-cli-path'] }}
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__MANIFEST: ${{ steps.release.outputs['application-manifest'] }}
EDGEZERO__CONFIG_PUSH__APP_CONFIG: ${{ inputs['app-config'] }}
EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE: ${{ inputs['app-config-inline'] }}
EDGEZERO__CONFIG_PUSH__NO_ENV: ${{ inputs['no-env'] }}
# 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: ""
Expand Down
55 changes: 22 additions & 33 deletions .github/actions/config-push-fastly/scripts/config-push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ set -euo pipefail
# wrapper blanks every other FASTLY_* alias, so an inherited FASTLY_ENDPOINT or
# FASTLY_TOKEN can never redirect or re-auth the push.
#
# Staging: `deploy-to: staging` passes `--staging` to the CLI, which writes the
# `<logical-store-id>_staging` variant in the SAME store — the key the staging
# selector points a staged version at, never the production key the live service
# reads. `key` is production-only (the wrapper rejects key + staging up front).
# Staging: `deploy-to: staging` passes `--staging` to the CLI. The canonical
# `EDGEZERO__STORES__CONFIG__<ID>__KEY` selected by the caller's environment wins;
# when absent, both config push and the managed runtime descriptor fall back to
# `<logical-store-id>_staging`. Production and staging may select the same or
# different physical stores. The managed action does not accept a separate key;
# config push and deploy therefore use the same canonical resolution.
#
# Path confinement: working-directory, manifest, and app-config are
# caller strings handed to a credential-bearing CLI, so each is canonicalized
# (resolving symlinks) and required to stay inside the application directory
# beneath github.workspace. Absolute paths, `..` traversal, and symlink escapes
# are rejected rather than read.
# The manifest is an absolute verified member of the immutable application
# release. Publisher-owned app-config files remain confined beneath the selected
# working directory; inline config is written to an action-owned temporary file.
#
# Reads (env):
# EDGEZERO__APP__CLI__PATH optional absolute path to the app CLI (preferred; avoids PATH shadowing)
Expand All @@ -29,15 +29,14 @@ set -euo pipefail
# GITHUB_WORKSPACE required confinement root
# 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__MANIFEST required verified absolute release manifest
# EDGEZERO__CONFIG_PUSH__APP_CONFIG optional typed config file path (relative to the app dir)
# EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE optional raw inline typed-config content (exclusive with APP_CONFIG)
# EDGEZERO__CONFIG_PUSH__NO_ENV optional 'true' to pass --no-env (skip the env overlay); default false
# RUNNER_TEMP optional scratch root for the inline-config temp file (default: /tmp)
# Writes (outputs):
# mutation-attempted true, emitted before the CLI runs (reconcile signal)
# pushed-key the key written (base, or its _staging variant)
# pushed-key canonical environment key, or the target fallback
# store the logical store id the CLI resolved

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
Expand Down Expand Up @@ -66,14 +65,16 @@ main() {
local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE 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:-}"
local app_config_inline="${EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE:-}"
local no_env="${EDGEZERO__CONFIG_PUSH__NO_ENV:-false}"
local inline_file=""

require_input fastly-api-token "${FASTLY_API_TOKEN:-}"
require_input application-manifest "$manifest"
[[ "$manifest" == /* && -f "$manifest" && ! -L "$manifest" ]] ||
fail "the bundled application manifest must be an absolute regular file"
require_cmd "$cli_bin"
require_cmd git
# A typo in deploy-to must never silently push to production.
Expand All @@ -89,8 +90,9 @@ main() {
esac
# A file path and inline content name the same thing two ways; requiring
# exactly one avoids a silent precedence surprise.
if [[ -n "$app_config" && -n "$app_config_inline" ]]; then
fail "inputs 'app-config' and 'app-config-inline' are mutually exclusive"
if [[ -z "$app_config" && -z "$app_config_inline" ]] ||
[[ -n "$app_config" && -n "$app_config_inline" ]]; then
fail "exactly one of 'app-config' or 'app-config-inline' is required"
fi

# Confine the app directory to github.workspace, then every path to the app.
Expand All @@ -101,19 +103,8 @@ main() {
app_dir=$(canonical_path "$workspace/$working_directory")
is_under "$workspace_real" "$app_dir" ||
fail "input 'working-directory' must resolve inside github.workspace"
if [[ -n "$manifest" ]]; then
manifest=$(confine_to_app "$manifest" "$app_dir" manifest)
elif [[ -e "$app_dir/edgezero.toml" ]]; then
# Default discovery is confined too: the CLI reads `edgezero.toml` from the
# app dir, and a committed symlink there could point its deploy/store config
# outside the app while this step holds provider credentials.
local default_manifest
default_manifest=$(canonical_path "$app_dir/edgezero.toml")
is_under "$app_dir" "$default_manifest" ||
fail "the default 'edgezero.toml' resolves outside the application directory — refusing to read a manifest that escapes it"
fi
# Committed-source guard: config pushed from the CHECKED-OUT tree (a manifest or an
# app-config FILE) must come from committed source, so the store the live service
# Committed-source guard: config pushed from a checked-out app-config FILE must
# come from committed source, so the store the live service
# reads always corresponds to a revision that can be reconciled later — the same
# guarantee deploy gets from resolve-project.sh. Inline config is caller-supplied
# CONTENT (a workflow variable), not the tree, so it is exempt.
Expand Down Expand Up @@ -158,12 +149,10 @@ main() {
fi

# Build the argv through a Bash array — never eval. --yes and --no-diff make the
# push non-interactive in CI; --staging selects the `<logical>_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
# push non-interactive in CI; --staging selects the canonical environment key
# or its `<logical>_staging` fallback.
local argv=("$cli_bin" config push --adapter fastly --manifest "$manifest" --app-config "$app_config")
if [[ -n "$store" ]]; then argv+=(--store "$store"); fi
if [[ -n "$key" ]]; then argv+=(--key "$key"); fi
if [[ "$deploy_to" == "staging" ]]; then argv+=(--staging); fi
if [[ "$no_env" == "true" ]]; then argv+=(--no-env); fi
argv+=(--yes --no-diff)
Expand Down
19 changes: 9 additions & 10 deletions .github/actions/config-push-fastly/scripts/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,30 @@ set -euo pipefail
# action.yml `run:`) so it is shellcheck'd and contract-tested.
#
# Reads (env):
# EDGEZERO__APP__CLI__ARTIFACT_PRESENT required "true" when app-cli-artifact is non-empty
# EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT required release archive presence flag
# EDGEZERO__APP__RELEASE__SHA256_PRESENT required release digest presence flag
# EDGEZERO__FASTLY__API_TOKEN_PRESENT required "true" when fastly-api-token is non-empty
# EDGEZERO__DEPLOY__TO optional production | staging (default: production)
# EDGEZERO__CONFIG_PUSH__KEY_PRESENT optional "true" when an explicit key was 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() {
require_present app-cli-artifact "${EDGEZERO__APP__CLI__ARTIFACT_PRESENT:-}"
require_present app-release-archive "${EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT:-}"
require_present app-release-sha256 "${EDGEZERO__APP__RELEASE__SHA256_PRESENT:-}"
require_present fastly-api-token "${EDGEZERO__FASTLY__API_TOKEN_PRESENT:-}"
local has_file="${EDGEZERO__CONFIG_PUSH__APP_CONFIG_PRESENT:-false}"
local has_inline="${EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE_PRESENT:-false}"
if [[ "$has_file" == "$has_inline" ]]; then
fail "exactly one of 'app-config' or 'app-config-inline' is required"
fi
local deploy_to="${EDGEZERO__DEPLOY__TO:-production}"
# 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 '${EDGEZERO__DEPLOY__TO:-}')" ;;
esac
# A staging push derives its key from the store's logical id (`<id>_staging`),
# which is what the staging selector store points a staged version at. An
# explicit `key` would be written to a key nothing reads, so the CLI refuses
# the combination — reject it here with a clearer, earlier message.
if [[ "$deploy_to" == "staging" && "${EDGEZERO__CONFIG_PUSH__KEY_PRESENT:-}" == "true" ]]; then
fail "input 'key' cannot be combined with deploy-to: staging; the staging key is derived from the store's logical id (<id>_staging). Push to production with 'key', or push staging without it."
fi
}

main "$@"
Loading
Loading