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..9832971d --- /dev/null +++ b/.github/actions/build-cli/scripts/build-cli.sh @@ -0,0 +1,158 @@ +#!/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. +# +# 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" + +# --- Rust toolchain resolution helpers --------------------------------------- +parse_toolchain_from_channel_file() { + local file="$1" + local value + 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_toolchain_from_toml() { + local file="$1" + local value + 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() { + 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 + while true; do + if [[ -f "$directory/rust-toolchain.toml" ]]; then + parse_toolchain_from_toml "$directory/rust-toolchain.toml" + return + fi + if [[ -f "$directory/rust-toolchain" ]]; then + 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_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 + 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" +} + +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/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..ca6ac0b0 --- /dev/null +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -0,0 +1,22 @@ +#!/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" + 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/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..fc737bcb --- /dev/null +++ b/.github/actions/deploy-core/scripts/download-cli.sh @@ -0,0 +1,58 @@ +#!/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 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" + +# 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 +} + +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}" + + require_cmd jq + require_cmd tar + mkdir -p "$tool_root/bin" + + local tarball + tarball=$(find_cli_tarball "$artifact_dir") + [[ -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" + + 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}" + + [[ -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" +} + +main "$@" 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..ba741b6b --- /dev/null +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -0,0 +1,163 @@ +#!/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. +# +# 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" + +# --- 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_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() { + 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 + while true; do + if [[ -f "$directory/rust-toolchain.toml" ]]; then + parse_toolchain_from_toml "$directory/rust-toolchain.toml" + return + fi + if [[ -f "$directory/rust-toolchain" ]]; then + 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" == "$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 + 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" +} + +resolve_effective_build_mode() { + case "${1:-auto}" in + auto | never) printf 'never\n' ;; + always) printf 'always\n' ;; + *) fail "input 'build-mode' must be one of: auto, always, never" ;; + esac +} + +# 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" + if [[ -f "$lockfile" ]]; then + lock_hash=$(sha256_file "$lockfile") + fi + 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/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh new file mode 100755 index 00000000..946a9b78 --- /dev/null +++ b/.github/actions/deploy-core/scripts/run-cli.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the application CLI (build or deploy) through Bash arrays — never eval. +# +# 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" + +# 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 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" + [[ -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 + fi + done <"$file" +} + +# 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 +} + +# 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" + + 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[@]}" +} + +main "$@" 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..2eab4f84 --- /dev/null +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -0,0 +1,113 @@ +#!/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. +# +# 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" + +# 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" + printf '%s' "$value" | jq -e 'all(.[]; type == "string")' >/dev/null || + fail "every element of parameter '$name' must be a string" + 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_file" +} + +# 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 [[ "$expecting_value" == "true" ]]; then + expecting_value=false + continue + fi + local flag="${arg%%=*}" matched=false candidate + for candidate in "${permitted[@]}"; do + if [[ "$flag" == "$candidate" ]]; then + matched=true + [[ "$arg" == *=* ]] || expecting_value=true + break + fi + done + [[ "$matched" == "true" ]] || + 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" +} + +main "$@" 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..15625a0d --- /dev/null +++ b/.github/actions/deploy-core/scripts/write-summary.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +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. + +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..842ebeb5 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -0,0 +1,45 @@ +#!/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) + +if [[ "$status" -eq 0 ]]; then + echo "all third-party action references are pinned to a concrete ref" +fi +exit "$status" 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..6aa87d9b --- /dev/null +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -0,0 +1,47 @@ +#!/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" + 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 + + # 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 +} + +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 "$@" diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml new file mode 100644 index 00000000..90f71772 --- /dev/null +++ b/.github/actions/deploy-fastly/action.yml @@ -0,0 +1,220 @@ +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 toolchain + # 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 + # 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 + 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/deploy-fastly/scripts/common.sh b/.github/actions/deploy-fastly/scripts/common.sh new file mode 100755 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 100755 index 00000000..9e6e9ac8 --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/install-fastly.sh @@ -0,0 +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" + +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 + 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/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" +} 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" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml new file mode 100644 index 00000000..d73e2aa2 --- /dev/null +++ b/.github/workflows/deploy-action.yml @@ -0,0 +1,134 @@ +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 (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-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 + # -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/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: .github/actions/deploy-core/tests/make-smoke-fixture.sh + + - 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 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 + echo "deploy marker present; recorded argv:" + cat fixture-app/deploy-argv.txt 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..789a3a84 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,225 @@ 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(|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())); + } + + #[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(|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())); + } + + #[test] + fn probe_with_retries_returns_first_healthy() { + let mut calls: i32 = 0; + let mut between: i32 = 0; + let result = probe_with_retries( + 5, + || { + calls += 1_i32; + Ok(200) + }, + || between += 1_i32, + ); + assert_eq!(result, Ok(200)); + 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: i32 = 0; + let mut between: i32 = 0; + let result = probe_with_retries( + 5, + || { + calls += 1_i32; + if calls < 3_i32 { + Ok(503) + } else { + Ok(200) + } + }, + || between += 1_i32, + ); + assert_eq!(result, Ok(200)); + assert_eq!(calls, 3_i32); + assert_eq!( + 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: 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_i32, + "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: i32 = 0; + let result = probe_with_retries( + 0, + || { + calls += 1_i32; + Ok(500) + }, + || {}, + ); + assert_eq!( + result, + Err((Some(500), "unhealthy HTTP status 500".to_owned())) + ); + assert_eq!(calls, 1_i32); + } + #[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 { 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 new file mode 100644 index 00000000..eaeb271b --- /dev/null +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -0,0 +1,222 @@ +# 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` | 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 + +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/…`. + - 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. + - 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 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. + - 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 + 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`. + - **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. + +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 --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 --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 + 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`. 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, + 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** + - 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. **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 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-adoption-guide.md b/docs/specs/edgezero-deploy-adoption-guide.md new file mode 100644 index 00000000..88f9ce22 --- /dev/null +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -0,0 +1,251 @@ +# 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 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 + +- 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..4c92a5bb --- /dev/null +++ b/docs/specs/edgezero-deploy-github-action.md @@ -0,0 +1,828 @@ +# 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` | 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. | +| `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`, 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. Provider **tooling** (the Fastly CLI) +is a wrapper concern; the engine assumes the provider CLI is already on `PATH`. + +**`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 --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 ``. | + +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 + +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 --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`. 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 + `FASTLY_API_TOKEN` in the step env; on production emits `rolled-back-to`. Needs + no application source or build. + +`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. They need no Fastly CLI install +(they call the Fastly API, not `fastly compute …`). + +#### 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 + **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. +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. | +| 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. + +## 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; +- 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. + +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 +`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 + +- 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. 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 + +| 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: