diff --git a/.github/actions/deploy/README.md b/.github/actions/deploy/README.md new file mode 100644 index 00000000..c6904026 --- /dev/null +++ b/.github/actions/deploy/README.md @@ -0,0 +1,13 @@ +# EdgeZero deploy action + +Pre-release composite action for deploying a checked-out EdgeZero application to Fastly Compute. + +```yaml +- uses: stackpop/edgezero/.github/actions/deploy@ + with: + adapter: fastly + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +See `docs/guide/deploy-github-actions.md` for the full contract, examples, and security guidance. diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml new file mode 100644 index 00000000..8fbccd17 --- /dev/null +++ b/.github/actions/deploy/action.yml @@ -0,0 +1,337 @@ +name: EdgeZero deploy +description: Deploy a checked-out EdgeZero application to Fastly Compute. + +inputs: + adapter: + description: Adapter to deploy. v0 supports only 'fastly'. + 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: "" + rust-toolchain: + description: Explicit Rust toolchain or 'auto' for project discovery. + required: false + default: auto + 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 edgezero build after --. + required: false + default: "[]" + deploy-args: + description: JSON array of Fastly comment args appended after action-owned deploy flags. + required: false + default: "[]" + cache: + description: Enable exact-key application target/ caching. + required: false + default: "false" + fastly-api-token: + description: Fastly API token. Injected only into the deploy step. + required: true + fastly-service-id: + description: Fastly service ID used by the action-owned deploy flag. + required: true + +outputs: + adapter: + description: Normalized adapter. + value: ${{ steps.validate.outputs.adapter }} + source-revision: + description: Git revision deployed from working-directory. + value: ${{ steps.resolve.outputs['source-revision'] }} + edgezero-revision: + description: EdgeZero action/CLI revision selected by the uses ref. + value: ${{ steps.resolve.outputs['edgezero-revision'] }} + provider-cli-version: + description: Installed Fastly CLI semantic version. + value: ${{ steps.install-fastly.outputs['provider-cli-version'] }} + effective-build-mode: + description: Resolved build behavior. + value: ${{ steps.resolve.outputs['effective-build-mode'] }} + +runs: + using: composite + steps: + - name: Validate inputs + id: validate + shell: bash + env: + INPUT_ADAPTER: ${{ inputs.adapter }} + INPUT_BUILD_MODE: ${{ inputs['build-mode'] }} + INPUT_CACHE: ${{ inputs.cache }} + INPUT_BUILD_ARGS: ${{ inputs['build-args'] }} + INPUT_DEPLOY_ARGS: ${{ inputs['deploy-args'] }} + 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_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/scripts/validate-inputs.sh + + - name: Resolve project + id: resolve + shell: bash + env: + EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. + EDGEZERO_ACTION_REF: ${{ github.action_ref }} + INPUT_WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + INPUT_MANIFEST: ${{ inputs.manifest }} + INPUT_RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} + INPUT_BUILD_MODE: ${{ inputs['build-mode'] }} + INPUT_CACHE: ${{ inputs.cache }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/scripts/resolve-project.sh + + - name: Restore application target cache + if: ${{ inputs.cache == 'true' }} + id: cache-restore + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 + env: + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + with: + path: ${{ steps.resolve.outputs['cache-path'] }} + key: ${{ steps.resolve.outputs['cache-key'] }} + fail-on-cache-miss: false + + - name: Install Rust target + shell: bash + env: + RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} + RUST_TARGET: wasm32-wasip1 + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/scripts/install-rust.sh + + - name: Install EdgeZero CLI + shell: bash + env: + EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. + ACTION_RUST_TOOLCHAIN: ${{ steps.resolve.outputs['action-rust-toolchain'] }} + EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-deploy-tools + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/scripts/install-edgezero.sh + + - name: Install Fastly CLI + id: install-fastly + shell: bash + env: + EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. + EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-deploy-tools + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/scripts/install-fastly.sh + + - name: Build + if: ${{ steps.resolve.outputs['effective-build-mode'] == 'always' }} + shell: bash + env: + WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} + MANIFEST: ${{ steps.resolve.outputs.manifest }} + ARGS_FILE: ${{ steps.validate.outputs['build-args-file'] }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/scripts/run-edgezero.sh build + + - name: Deploy + shell: bash + env: + WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} + MANIFEST: ${{ steps.resolve.outputs.manifest }} + ARGS_FILE: ${{ steps.validate.outputs['deploy-args-file'] }} + FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/scripts/run-edgezero.sh deploy + + - name: Save application target cache + if: ${{ success() && inputs.cache == 'true' && steps.cache-restore.outputs['cache-hit'] != 'true' && steps.resolve.outputs['cache-path'] != '' && steps.resolve.outputs['cache-key'] != '' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 + env: + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + with: + path: ${{ steps.resolve.outputs['cache-path'] }} + key: ${{ steps.resolve.outputs['cache-key'] }} + + - name: Write summary + if: ${{ always() }} + shell: bash + env: + ADAPTER: fastly + APP_REL: ${{ steps.resolve.outputs['working-directory-relative'] }} + SOURCE_REVISION: ${{ steps.resolve.outputs['source-revision'] }} + MANIFEST_SUMMARY: ${{ steps.resolve.outputs['manifest-summary'] }} + EDGEZERO_REVISION: ${{ steps.resolve.outputs['edgezero-revision'] }} + PROVIDER_CLI_VERSION: ${{ steps.install-fastly.outputs['provider-cli-version'] }} + REQUESTED_BUILD_MODE: ${{ inputs['build-mode'] }} + EFFECTIVE_BUILD_MODE: ${{ steps.resolve.outputs['effective-build-mode'] }} + CACHE_ENABLED: ${{ inputs.cache }} + EDGEZERO_ACTION_RESULT: ${{ job.status }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/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-deploy-tools + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_PROFILE: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_HOME: "" + FASTLY_SERVICE_NAME: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + INPUT_FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/scripts/cleanup.sh diff --git a/.github/actions/deploy/scripts/cleanup.sh b/.github/actions/deploy/scripts/cleanup.sh new file mode 100755 index 00000000..d7e21eec --- /dev/null +++ b/.github/actions/deploy/scripts/cleanup.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=${EDGEZERO_ACTION_STATE_DIR:-} +if [[ -n "$ROOT" && -d "$ROOT" ]]; then + rm -rf "$ROOT" +fi + +TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-} +if [[ -n "$TOOL_ROOT" && -d "$TOOL_ROOT" ]]; then + rm -rf "$TOOL_ROOT" +fi + +# Remove action-owned Fastly auth state if future installers create it. +if [[ -n "${EDGEZERO_FASTLY_HOME:-}" && -d "$EDGEZERO_FASTLY_HOME" ]]; then + rm -rf "$EDGEZERO_FASTLY_HOME" +fi diff --git a/.github/actions/deploy/scripts/common.sh b/.github/actions/deploy/scripts/common.sh new file mode 100755 index 00000000..38f96051 --- /dev/null +++ b/.github/actions/deploy/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/scripts/install-edgezero.sh b/.github/actions/deploy/scripts/install-edgezero.sh new file mode 100755 index 00000000..32e60ad1 --- /dev/null +++ b/.github/actions/deploy/scripts/install-edgezero.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} +TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} +RUST_TOOLCHAIN=${ACTION_RUST_TOOLCHAIN:?ACTION_RUST_TOOLCHAIN is required} +mkdir -p "$TOOL_ROOT" +require_cmd cargo +require_cmd rustup + +rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal +cargo +"$RUST_TOOLCHAIN" install \ + --path "$ACTION_ROOT/crates/edgezero-cli" \ + --root "$TOOL_ROOT" \ + --locked \ + --force \ + --no-default-features \ + --features cli,edgezero-adapter-fastly +[[ -x "$TOOL_ROOT/bin/edgezero" ]] || fail "EdgeZero CLI installation did not produce $TOOL_ROOT/bin/edgezero" +printf '%s\n' "$TOOL_ROOT/bin" >>"${GITHUB_PATH:-/dev/null}" +export PATH="$TOOL_ROOT/bin:$PATH" +edgezero --help >/dev/null diff --git a/.github/actions/deploy/scripts/install-fastly.sh b/.github/actions/deploy/scripts/install-fastly.sh new file mode 100755 index 00000000..5debc582 --- /dev/null +++ b/.github/actions/deploy/scripts/install-fastly.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ACTION_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +ACTION_ROOT=${EDGEZERO_ACTION_ROOT:-$(cd -- "$ACTION_DIR/../../.." && pwd)} +VERSIONS_JSON=${VERSIONS_JSON:-$ACTION_DIR/versions.json} +TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} +mkdir -p "$TOOL_ROOT/bin" "$TOOL_ROOT/downloads" + +require_cmd jq +VERSION=$(json_get "$VERSIONS_JSON" fastly.version) +TOOL_VERSION=$(read_tool_version "$ACTION_ROOT/.tool-versions" fastly || true) +[[ -n "$TOOL_VERSION" ]] || fail "EdgeZero repository .tool-versions must contain a fastly entry" +[[ "$VERSION" == "$TOOL_VERSION" ]] || fail "Fastly version mismatch: versions.json has $VERSION but .tool-versions has $TOOL_VERSION" +URL=$(json_get "$VERSIONS_JSON" fastly.linux_amd64.url) +SHA256=$(json_get "$VERSIONS_JSON" fastly.linux_amd64.sha256) +ARCHIVE="$TOOL_ROOT/downloads/fastly-$VERSION-linux-amd64.tar.gz" + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64|Linux-amd64) ;; + *) fail "Fastly v0 action supports only Linux x86-64 runners" ;; +esac + +if [[ ! -f "$ARCHIVE" ]]; then + require_cmd curl + curl --fail --location --silent --show-error "$URL" --output "$ARCHIVE" +fi + +ACTUAL=$(sha256_file "$ARCHIVE") +[[ "$ACTUAL" == "$SHA256" ]] || fail "Fastly CLI checksum mismatch for version $VERSION" + +tar -xzf "$ARCHIVE" -C "$TOOL_ROOT/bin" fastly +chmod +x "$TOOL_ROOT/bin/fastly" +printf '%s\n' "$TOOL_ROOT/bin" >>"${GITHUB_PATH:-/dev/null}" +export PATH="$TOOL_ROOT/bin:$PATH" +PROVIDER_CLI_VERSION=$(fastly version 2>/dev/null || fastly --version 2>/dev/null || true) +PROVIDER_CLI_VERSION=${PROVIDER_CLI_VERSION%%$'\n'*} +[[ -n "$PROVIDER_CLI_VERSION" ]] || fail "installed Fastly CLI did not report a version" +printf '%s\n' "$PROVIDER_CLI_VERSION" +append_output provider-cli-version "$PROVIDER_CLI_VERSION" diff --git a/.github/actions/deploy/scripts/install-rust.sh b/.github/actions/deploy/scripts/install-rust.sh new file mode 100755 index 00000000..fe62e2f3 --- /dev/null +++ b/.github/actions/deploy/scripts/install-rust.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +RUST_TOOLCHAIN=${RUST_TOOLCHAIN:?RUST_TOOLCHAIN is required} +TARGET=${RUST_TARGET:-wasm32-wasip1} +require_cmd rustup +rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal +rustup target add "$TARGET" --toolchain "$RUST_TOOLCHAIN" +append_env RUSTUP_TOOLCHAIN "$RUST_TOOLCHAIN" diff --git a/.github/actions/deploy/scripts/resolve-project.sh b/.github/actions/deploy/scripts/resolve-project.sh new file mode 100755 index 00000000..483a091b --- /dev/null +++ b/.github/actions/deploy/scripts/resolve-project.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +WORKSPACE=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} +ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} +WORKING_DIRECTORY=${INPUT_WORKING_DIRECTORY:-.} +MANIFEST=${INPUT_MANIFEST:-} +RUST_TOOLCHAIN_INPUT=${INPUT_RUST_TOOLCHAIN:-auto} +CACHE=${INPUT_CACHE:-false} +ACTION_REF=${EDGEZERO_ACTION_REF:-} + +WORKSPACE_REAL=$(canonical_path "$WORKSPACE") +APP_INPUT="$WORKSPACE/$WORKING_DIRECTORY" +[[ -d "$APP_INPUT" ]] || fail "working-directory '$WORKING_DIRECTORY' does not exist or is not a directory" +APP_DIR=$(canonical_path "$APP_INPUT") +is_under "$WORKSPACE_REAL" "$APP_DIR" || fail "input 'working-directory' must resolve inside github.workspace" +APP_REL=$(relative_to "$WORKSPACE_REAL" "$APP_DIR") + +if [[ -n "$MANIFEST" ]]; then + MANIFEST_INPUT="$APP_DIR/$MANIFEST" + [[ -f "$MANIFEST_INPUT" ]] || fail "manifest '$APP_REL/$MANIFEST' does not exist or is not a regular file" + MANIFEST_PATH=$(canonical_path "$MANIFEST_INPUT") + is_under "$WORKSPACE_REAL" "$MANIFEST_PATH" || fail "input 'manifest' must resolve inside github.workspace" + MANIFEST_REL=$(relative_to "$WORKSPACE_REAL" "$MANIFEST_PATH") +else + MANIFEST_PATH="" + MANIFEST_REL="EdgeZero default discovery" +fi + +APP_GIT_ROOT=$(git -C "$APP_DIR" rev-parse --show-toplevel 2>/dev/null || true) +[[ -n "$APP_GIT_ROOT" ]] || fail "working-directory '$APP_REL' is not inside a Git repository" +APP_GIT_ROOT=$(canonical_path "$APP_GIT_ROOT") +is_under "$WORKSPACE_REAL" "$APP_GIT_ROOT" || fail "application Git root must resolve inside github.workspace" +SOURCE_REVISION=$(git -C "$APP_GIT_ROOT" rev-parse HEAD) +if ! git -C "$APP_GIT_ROOT" diff --quiet --ignore-submodules -- || \ + ! git -C "$APP_GIT_ROOT" diff --cached --quiet --ignore-submodules -- || \ + [[ -n "$(git -C "$APP_GIT_ROOT" ls-files --others --exclude-standard)" ]]; then + fail "deployments require committed source; working tree for '$APP_REL' is dirty" +fi + +parse_rust_toolchain_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_rust_toolchain_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" +} + +parse_rust_toolchain_from_tool_versions() { + local file="$1" + awk '$1 == "rust" { found=1; if (NF < 2) exit 2; print $2; exit } END { if (!found) exit 1 }' "$file" +} + +resolve_rust_toolchain() { + if [[ "$RUST_TOOLCHAIN_INPUT" != "auto" ]]; then + [[ -n "$RUST_TOOLCHAIN_INPUT" ]] || fail "input 'rust-toolchain' cannot be empty" + printf '%s\n' "$RUST_TOOLCHAIN_INPUT" + return + fi + + local directory="$APP_DIR" + local value status + while true; do + if [[ -f "$directory/rust-toolchain.toml" ]]; then + parse_rust_toolchain_toml "$directory/rust-toolchain.toml" + return + fi + if [[ -f "$directory/rust-toolchain" ]]; then + parse_rust_toolchain_file "$directory/rust-toolchain" + return + fi + if [[ -f "$directory/.tool-versions" ]]; then + set +e + value=$(parse_rust_toolchain_from_tool_versions "$directory/.tool-versions") + status=$? + set -e + case "$status" in + 0) printf '%s\n' "$value"; return ;; + 1) ;; + 2) fail "malformed .tool-versions rust entry: $directory/.tool-versions" ;; + *) fail "could not parse .tool-versions rust entry: $directory/.tool-versions" ;; + esac + fi + [[ "$directory" == "$APP_GIT_ROOT" ]] && break + local next + next=$(dirname "$directory") + [[ "$next" == "$directory" ]] && break + directory="$next" + done + + if [[ -f "$ACTION_ROOT/.tool-versions" ]]; then + set +e + value=$(parse_rust_toolchain_from_tool_versions "$ACTION_ROOT/.tool-versions") + status=$? + set -e + case "$status" in + 0) printf '%s\n' "$value"; return ;; + 1) ;; + 2) fail "malformed .tool-versions rust entry: $ACTION_ROOT/.tool-versions" ;; + *) fail "could not parse .tool-versions rust entry: $ACTION_ROOT/.tool-versions" ;; + esac + fi + fail "could not resolve Rust toolchain; checked rust-toolchain.toml, rust-toolchain, and .tool-versions; set input rust-toolchain explicitly" +} + +RUST_TOOLCHAIN=$(resolve_rust_toolchain) +ACTION_RUST_TOOLCHAIN=$(read_tool_version "$ACTION_ROOT/.tool-versions" rust || true) +[[ -n "$ACTION_RUST_TOOLCHAIN" ]] || fail "EdgeZero repository .tool-versions must contain a rust entry" + +LOCKFILE="$APP_GIT_ROOT/Cargo.lock" +if [[ "$CACHE" == "true" && ! -f "$LOCKFILE" ]]; then + fail "cache is enabled but Cargo.lock was not found at application Git root; exact-key caching requires Cargo.lock" +fi +LOCK_HASH="none" +if [[ -f "$LOCKFILE" ]]; then + LOCK_HASH=$(sha256_file "$LOCKFILE") +fi + +if EDGEZERO_REVISION=$(git -C "$ACTION_ROOT" rev-parse HEAD 2>/dev/null); then + : +elif [[ "$ACTION_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then + EDGEZERO_REVISION=$ACTION_REF +else + fail "could not resolve immutable EdgeZero action revision; pin the action with a full commit SHA" +fi +CACHE_KEY="edgezero-deploy-${RUNNER_OS:-Linux}-${RUNNER_ARCH:-X64}-$(sanitize_ref "$RUST_TOOLCHAIN")-wasm32-wasip1-${EDGEZERO_REVISION}-${SOURCE_REVISION}-${LOCK_HASH}" +TARGET_DIR="$APP_GIT_ROOT/target" + +effective_build_mode() { + case "${INPUT_BUILD_MODE:-auto}" in + auto) printf 'never\n' ;; + always) printf 'always\n' ;; + never) printf 'never\n' ;; + *) fail "input 'build-mode' must be one of: auto, always, never" ;; + esac +} +EFFECTIVE_BUILD_MODE=$(effective_build_mode) + +append_output working-directory "$APP_DIR" +append_output working-directory-relative "$APP_REL" +append_output manifest "$MANIFEST_PATH" +append_output manifest-summary "$MANIFEST_REL" +append_output app-git-root "$APP_GIT_ROOT" +append_output source-revision "$SOURCE_REVISION" +append_output rust-toolchain "$RUST_TOOLCHAIN" +append_output action-rust-toolchain "$ACTION_RUST_TOOLCHAIN" +append_output edgezero-revision "$EDGEZERO_REVISION" +append_output effective-build-mode "$EFFECTIVE_BUILD_MODE" +append_output cache-key "$CACHE_KEY" +append_output cache-path "$TARGET_DIR" diff --git a/.github/actions/deploy/scripts/run-edgezero.sh b/.github/actions/deploy/scripts/run-edgezero.sh new file mode 100755 index 00000000..3a07e9ef --- /dev/null +++ b/.github/actions/deploy/scripts/run-edgezero.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +MODE=${1:?usage: run-edgezero.sh build|deploy} +case "$MODE" in build|deploy) ;; *) fail "mode must be build or deploy" ;; esac + +WORKING_DIRECTORY=${WORKING_DIRECTORY:?WORKING_DIRECTORY is required} +MANIFEST=${MANIFEST:-} +require_cmd edgezero + +clear_fastly_aliases() { + unset FASTLY_API_TOKEN FASTLY_SERVICE_ID FASTLY_TOKEN FASTLY_PROFILE \ + FASTLY_API_ENDPOINT FASTLY_ENDPOINT FASTLY_API_URL FASTLY_DEBUG \ + FASTLY_DEBUG_MODE FASTLY_CONFIG_FILE FASTLY_HOME FASTLY_SERVICE_NAME \ + FASTLY_API_KEY FASTLY_AUTH_TOKEN INPUT_FASTLY_API_TOKEN || true +} + +case "$MODE" in + build) + ARGS_FILE=${ARGS_FILE:?ARGS_FILE is required} + clear_fastly_aliases + args=(edgezero build --adapter fastly) + if [[ -s "$ARGS_FILE" ]]; then + args+=(--) + while IFS= read -r -d '' item; do + args+=("$item") + done <"$ARGS_FILE" + fi + ;; + deploy) + ARGS_FILE=${ARGS_FILE:?ARGS_FILE is required} + FASTLY_API_TOKEN_VALUE=${FASTLY_API_TOKEN:-} + FASTLY_SERVICE_ID_VALUE=${FASTLY_SERVICE_ID:-} + [[ -n "$FASTLY_API_TOKEN_VALUE" ]] || fail "missing required deploy environment FASTLY_API_TOKEN" + [[ -n "$FASTLY_SERVICE_ID_VALUE" ]] || fail "missing required deploy environment FASTLY_SERVICE_ID" + clear_fastly_aliases + export FASTLY_API_TOKEN="$FASTLY_API_TOKEN_VALUE" + export FASTLY_SERVICE_ID="$FASTLY_SERVICE_ID_VALUE" + args=(edgezero deploy --adapter fastly -- --service-id "$FASTLY_SERVICE_ID_VALUE" --non-interactive) + if [[ -s "$ARGS_FILE" ]]; then + while IFS= read -r -d '' item; do + args+=("$item") + done <"$ARGS_FILE" + fi + ;; +esac + +if [[ -n "$MANIFEST" ]]; then + export EDGEZERO_MANIFEST="$MANIFEST" +else + unset EDGEZERO_MANIFEST || true +fi + +cd "$WORKING_DIRECTORY" +echo "[edgezero-action] running EdgeZero $MODE for adapter fastly" +"${args[@]}" diff --git a/.github/actions/deploy/scripts/validate-inputs.sh b/.github/actions/deploy/scripts/validate-inputs.sh new file mode 100755 index 00000000..7c3ee750 --- /dev/null +++ b/.github/actions/deploy/scripts/validate-inputs.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ADAPTER=${INPUT_ADAPTER:-} +BUILD_MODE=${INPUT_BUILD_MODE:-auto} +CACHE=${INPUT_CACHE:-false} +BUILD_ARGS=${INPUT_BUILD_ARGS:-[]} +DEPLOY_ARGS=${INPUT_DEPLOY_ARGS:-[]} +FASTLY_API_TOKEN_PRESENT=${INPUT_FASTLY_API_TOKEN_PRESENT:-false} +FASTLY_SERVICE_ID=${INPUT_FASTLY_SERVICE_ID:-} +RUNNER_OS_VALUE=${EDGEZERO_RUNNER_OS:-} +RUNNER_ARCH_VALUE=${EDGEZERO_RUNNER_ARCH:-} + +if [[ -n "$RUNNER_OS_VALUE" || -n "$RUNNER_ARCH_VALUE" ]]; then + [[ "$RUNNER_OS_VALUE" == "Linux" && "$RUNNER_ARCH_VALUE" == "X64" ]] || \ + fail "EdgeZero deploy v0 supports only Linux x86-64 runners; received ${RUNNER_OS_VALUE:-unknown}/${RUNNER_ARCH_VALUE:-unknown}" +fi + +[[ -n "$ADAPTER" ]] || fail "input 'adapter' is required; v0 supports only 'fastly'" +case "$ADAPTER" in + fastly) ;; + axum) fail "adapter 'axum' has no EdgeZero remote deployment contract" ;; + cloudflare|spin) fail "adapter '$ADAPTER' is planned for future work but is not implemented in v0; v0 supports only 'fastly'" ;; + *) fail "unsupported adapter '$ADAPTER'; v0 supports only 'fastly'" ;; +esac + +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 + +case "$FASTLY_API_TOKEN_PRESENT" in + true|false) ;; + *) fail "internal credential-presence value for fastly-api-token must be exactly 'true' or 'false'" ;; +esac +[[ "$FASTLY_API_TOKEN_PRESENT" == "true" ]] || fail "missing required input 'fastly-api-token'" +[[ -n "$FASTLY_SERVICE_ID" ]] || fail "missing required input 'fastly-service-id'" +if [[ "$FASTLY_SERVICE_ID" == *[[:cntrl:]]* ]]; then + fail "input 'fastly-service-id' must not contain control characters" +fi + +require_cmd jq + +parse_args() { + local name="$1" + local value="$2" + local out="$3" + if ! printf '%s' "$value" | jq -e 'type == "array"' >/dev/null 2>&1; then + fail "input '$name' must be a JSON array of strings" + fi + if ! printf '%s' "$value" | jq -e 'all(.[]; type == "string")' >/dev/null; then + fail "every element of input '$name' must be a string" + fi + if printf '%s' "$value" | jq -e 'any(.[]; contains("\u0000"))' >/dev/null; then + fail "input '$name' contains a NUL byte, which cannot be passed as an OS argument" + fi + printf '%s' "$value" | jq -jr '.[] | ., "\u0000"' >"$out" +} + +STATE_DIR=${EDGEZERO_ACTION_STATE_DIR:-${RUNNER_TEMP:-/tmp}/edgezero-action-state} +mkdir -p "$STATE_DIR" +BUILD_ARGS_FILE="$STATE_DIR/build-args.nul" +DEPLOY_ARGS_FILE="$STATE_DIR/deploy-args.nul" +parse_args "build-args" "$BUILD_ARGS" "$BUILD_ARGS_FILE" +parse_args "deploy-args" "$DEPLOY_ARGS" "$DEPLOY_ARGS_FILE" + +validate_deploy_args_allowlist() { + local file="$1" + local item + local expect_comment_value=false + local position=0 + while IFS= read -r -d '' item; do + position=$((position + 1)) + if [[ "$expect_comment_value" == "true" ]]; then + expect_comment_value=false + continue + fi + case "$item" in + --comment=*) ;; + --comment) expect_comment_value=true ;; + *) fail "deploy-args allows only Fastly comment flags ('--comment VALUE' or '--comment=VALUE'); rejected argument $position" ;; + esac + done <"$file" + [[ "$expect_comment_value" == "false" ]] || fail "deploy-args '--comment' must be followed by a value" +} +validate_deploy_args_allowlist "$DEPLOY_ARGS_FILE" + +append_output adapter fastly +append_output build-args-file "$BUILD_ARGS_FILE" +append_output deploy-args-file "$DEPLOY_ARGS_FILE" +append_output requested-build-mode "$BUILD_MODE" +append_output cache "$CACHE" diff --git a/.github/actions/deploy/scripts/write-summary.sh b/.github/actions/deploy/scripts/write-summary.sh new file mode 100755 index 00000000..ed0cbe9f --- /dev/null +++ b/.github/actions/deploy/scripts/write-summary.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +SUMMARY=${GITHUB_STEP_SUMMARY:-} +[[ -n "$SUMMARY" ]] || exit 0 +{ + echo "## EdgeZero deploy" + echo + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Adapter | ${ADAPTER:-fastly} |" + echo "| Application directory | ${APP_REL:-unknown} |" + echo "| Application revision | ${SOURCE_REVISION:-unknown} |" + echo "| Manifest | ${MANIFEST_SUMMARY:-EdgeZero default discovery} |" + echo "| EdgeZero revision | ${EDGEZERO_REVISION:-unknown} |" + echo "| Fastly CLI | ${PROVIDER_CLI_VERSION:-unknown} |" + echo "| Build mode | ${REQUESTED_BUILD_MODE:-auto} → ${EFFECTIVE_BUILD_MODE:-unknown} |" + echo "| Cache | ${CACHE_ENABLED:-false} |" + echo "| Result | ${EDGEZERO_ACTION_RESULT:-unknown} |" +} >>"$SUMMARY" diff --git a/.github/actions/deploy/tests/run.sh b/.github/actions/deploy/tests/run.sh new file mode 100755 index 00000000..02ddbab9 --- /dev/null +++ b/.github/actions/deploy/tests/run.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) +ACTION_DIR="$ROOT/.github/actions/deploy" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } + +export EDGEZERO_RUNNER_OS=Linux +export EDGEZERO_RUNNER_ARCH=X64 + +run_expect_fail() { + local name="$1" + shift + set +e + "$@" >"$TMP/$name.out" 2>"$TMP/$name.err" + local status=$? + set -e + [[ $status -ne 0 ]] || fail "$name unexpectedly succeeded" +} + +run_expect_ok() { + local name="$1" + shift + "$@" >"$TMP/$name.out" 2>"$TMP/$name.err" || { + cat "$TMP/$name.out" >&2 || true + cat "$TMP/$name.err" >&2 || true + fail "$name failed" + } +} + +# action metadata parses, exposes the expected v0 API, and clears provider env in non-deploy steps. +ruby - "$ACTION_DIR/action.yml" <<'RUBY' +require "yaml" +y = YAML.load_file(ARGV[0]) +abort "not composite" unless y["runs"]["using"] == "composite" +abort "missing adapter input" unless y["inputs"].key?("adapter") +abort "unexpected cloudflare input" if y["inputs"].key?("cloudflare-api-token") +abort "unexpected fermyon input" if y["inputs"].key?("fermyon-cloud-token") +aliases = %w[ + FASTLY_API_TOKEN FASTLY_SERVICE_ID FASTLY_TOKEN FASTLY_PROFILE + FASTLY_API_ENDPOINT FASTLY_ENDPOINT FASTLY_API_URL FASTLY_DEBUG + FASTLY_DEBUG_MODE FASTLY_CONFIG_FILE FASTLY_HOME FASTLY_SERVICE_NAME + FASTLY_API_KEY FASTLY_AUTH_TOKEN INPUT_FASTLY_API_TOKEN +] +y["runs"]["steps"].each do |step| + next if step["name"] == "Deploy" + env = step["env"] || {} + aliases.each do |name| + abort "#{step["name"]} does not clear #{name}" unless env[name] == "" + end +end +RUBY + +# validate-inputs rejects unsupported runners early. +GITHUB_OUTPUT="$TMP/reject-runner.out" \ +EDGEZERO_RUNNER_OS=macOS \ +EDGEZERO_RUNNER_ARCH=X64 \ +INPUT_ADAPTER=fastly \ +INPUT_FASTLY_API_TOKEN_PRESENT=true \ +INPUT_FASTLY_SERVICE_ID=svc \ +run_expect_fail reject-runner "$ACTION_DIR/scripts/validate-inputs.sh" +grep -q "Linux x86-64" "$TMP/reject-runner.err" || fail "runner rejection message missing" + +# validate-inputs accepts fastly and writes parsed args without logging values. +GITHUB_OUTPUT="$TMP/validate.out" \ +EDGEZERO_ACTION_STATE_DIR="$TMP/state" \ +INPUT_ADAPTER=fastly \ +INPUT_BUILD_MODE=auto \ +INPUT_CACHE=false \ +INPUT_BUILD_ARGS='["--feature", "value with space"]' \ +INPUT_DEPLOY_ARGS='["--comment", "safe"]' \ +INPUT_FASTLY_API_TOKEN_PRESENT=true \ +INPUT_FASTLY_SERVICE_ID=svc123 \ +run_expect_ok validate "$ACTION_DIR/scripts/validate-inputs.sh" +grep -q '^adapter=fastly$' "$TMP/validate.out" || fail "validate did not output adapter" + +# validate-inputs rejects unsupported and future adapters. +GITHUB_OUTPUT="$TMP/reject.out" INPUT_ADAPTER=cloudflare INPUT_FASTLY_API_TOKEN_PRESENT=true INPUT_FASTLY_SERVICE_ID=svc run_expect_fail reject-cloudflare "$ACTION_DIR/scripts/validate-inputs.sh" +grep -q "not implemented in v0" "$TMP/reject-cloudflare.err" || fail "cloudflare rejection message missing" +GITHUB_OUTPUT="$TMP/reject-axum.out" INPUT_ADAPTER=axum INPUT_FASTLY_API_TOKEN_PRESENT=true INPUT_FASTLY_SERVICE_ID=svc run_expect_fail reject-axum "$ACTION_DIR/scripts/validate-inputs.sh" +grep -q "no EdgeZero remote deployment contract" "$TMP/reject-axum.err" || fail "axum rejection message missing" +GITHUB_OUTPUT="$TMP/reject-spin.out" INPUT_ADAPTER=spin INPUT_FASTLY_API_TOKEN_PRESENT=true INPUT_FASTLY_SERVICE_ID=svc run_expect_fail reject-spin "$ACTION_DIR/scripts/validate-inputs.sh" +grep -q "not implemented in v0" "$TMP/reject-spin.err" || fail "spin rejection message missing" +GITHUB_OUTPUT="$TMP/reject-unknown.out" INPUT_ADAPTER=banana INPUT_FASTLY_API_TOKEN_PRESENT=true INPUT_FASTLY_SERVICE_ID=svc run_expect_fail reject-unknown "$ACTION_DIR/scripts/validate-inputs.sh" +grep -q "unsupported adapter" "$TMP/reject-unknown.err" || fail "unknown adapter rejection message missing" + +# validate-inputs rejects dangerous Fastly override flags. +GITHUB_OUTPUT="$TMP/reject-service.out" \ +EDGEZERO_ACTION_STATE_DIR="$TMP/state2" \ +INPUT_ADAPTER=fastly \ +INPUT_FASTLY_API_TOKEN_PRESENT=true \ +INPUT_FASTLY_SERVICE_ID=svc \ +INPUT_DEPLOY_ARGS='["--service-id", "other"]' \ +run_expect_fail reject-service "$ACTION_DIR/scripts/validate-inputs.sh" +grep -q "deploy-args allows only Fastly comment flags" "$TMP/reject-service.err" || fail "dangerous flag rejection missing" +GITHUB_OUTPUT="$TMP/reject-interactive.out" \ +EDGEZERO_ACTION_STATE_DIR="$TMP/state3" \ +INPUT_ADAPTER=fastly \ +INPUT_FASTLY_API_TOKEN_PRESENT=true \ +INPUT_FASTLY_SERVICE_ID=svc \ +INPUT_DEPLOY_ARGS='["--non-interactive=false"]' \ +run_expect_fail reject-interactive "$ACTION_DIR/scripts/validate-inputs.sh" +grep -q "deploy-args allows only Fastly comment flags" "$TMP/reject-interactive.err" || fail "non-interactive override rejection missing" +GITHUB_OUTPUT="$TMP/reject-short-service.out" \ +EDGEZERO_ACTION_STATE_DIR="$TMP/state4" \ +INPUT_ADAPTER=fastly \ +INPUT_FASTLY_API_TOKEN_PRESENT=true \ +INPUT_FASTLY_SERVICE_ID=svc \ +INPUT_DEPLOY_ARGS='["-s", "other"]' \ +run_expect_fail reject-short-service "$ACTION_DIR/scripts/validate-inputs.sh" +grep -q "deploy-args allows only Fastly comment flags" "$TMP/reject-short-service.err" || fail "short service override rejection missing" + +# annotations escape percent and newlines. +run_expect_fail escaped-annotation bash -c "source '$ACTION_DIR/scripts/common.sh'; fail \$'bad%line\nnext'" +grep -q '::error::bad%25line%0Anext' "$TMP/escaped-annotation.err" || fail "annotation escaping missing" + +# resolve-project discovers app state, manifest, source revision, and fastly auto build mode. +APP="$TMP/workspace/app" +mkdir -p "$APP" +git -C "$TMP/workspace" init -q +git -C "$TMP/workspace" config user.email test@example.com +git -C "$TMP/workspace" config user.name Test +git -C "$TMP/workspace" config commit.gpgsign false +cat >"$TMP/workspace/Cargo.lock" <<'LOCK' +# fixture lockfile +LOCK +cat >"$APP/edgezero.toml" <<'TOML' +[adapters.fastly.commands] +deploy = "echo deploy" +TOML +cat >"$APP/.tool-versions" <<'TOOLS' +rust 1.88.0 +TOOLS +git -C "$TMP/workspace" add . +git -C "$TMP/workspace" commit -q -m init +GITHUB_OUTPUT="$TMP/resolve.out" \ +GITHUB_WORKSPACE="$TMP/workspace" \ +EDGEZERO_ACTION_ROOT="$ROOT" \ +INPUT_WORKING_DIRECTORY=app \ +INPUT_MANIFEST=edgezero.toml \ +INPUT_RUST_TOOLCHAIN=auto \ +INPUT_BUILD_MODE=auto \ +INPUT_CACHE=true \ +RUNNER_OS=Linux \ +RUNNER_ARCH=X64 \ +run_expect_ok resolve "$ACTION_DIR/scripts/resolve-project.sh" +grep -q '^effective-build-mode=never$' "$TMP/resolve.out" || fail "fastly auto did not resolve to never" +grep -q '^rust-toolchain=1.88.0$' "$TMP/resolve.out" || fail "toolchain discovery failed" +grep -q '^source-revision=' "$TMP/resolve.out" || fail "source revision missing" + +# resolve-project can use the pinned action ref when the action root is not a Git checkout. +ACTION_NOGIT="$TMP/action-root-no-git" +mkdir -p "$ACTION_NOGIT" +cp "$ROOT/.tool-versions" "$ACTION_NOGIT/.tool-versions" +GITHUB_OUTPUT="$TMP/resolve-ref.out" \ +GITHUB_WORKSPACE="$TMP/workspace" \ +EDGEZERO_ACTION_ROOT="$ACTION_NOGIT" \ +EDGEZERO_ACTION_REF=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ +INPUT_WORKING_DIRECTORY=app \ +INPUT_MANIFEST=edgezero.toml \ +INPUT_RUST_TOOLCHAIN=auto \ +INPUT_BUILD_MODE=auto \ +INPUT_CACHE=false \ +RUNNER_OS=Linux \ +RUNNER_ARCH=X64 \ +run_expect_ok resolve-ref "$ACTION_DIR/scripts/resolve-project.sh" +grep -q '^edgezero-revision=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa$' "$TMP/resolve-ref.out" || fail "action ref revision fallback failed" + +# resolve-project rejects malformed .tool-versions instead of silently falling back. +BAD_WS="$TMP/bad-toolchain" +mkdir -p "$BAD_WS/app" +git -C "$BAD_WS" init -q +git -C "$BAD_WS" config user.email test@example.com +git -C "$BAD_WS" config user.name Test +git -C "$BAD_WS" config commit.gpgsign false +cat >"$BAD_WS/Cargo.lock" <<'LOCK' +# fixture lockfile +LOCK +cat >"$BAD_WS/app/edgezero.toml" <<'TOML' +[adapters.fastly.commands] +deploy = "echo deploy" +TOML +printf 'rust\n' >"$BAD_WS/app/.tool-versions" +git -C "$BAD_WS" add . +git -C "$BAD_WS" commit -q -m init +GITHUB_OUTPUT="$TMP/bad-toolchain.out" \ +GITHUB_WORKSPACE="$BAD_WS" \ +EDGEZERO_ACTION_ROOT="$ROOT" \ +INPUT_WORKING_DIRECTORY=app \ +INPUT_MANIFEST=edgezero.toml \ +INPUT_RUST_TOOLCHAIN=auto \ +INPUT_BUILD_MODE=auto \ +INPUT_CACHE=false \ +run_expect_fail bad-toolchain "$ACTION_DIR/scripts/resolve-project.sh" +grep -q "malformed .tool-versions rust entry" "$TMP/bad-toolchain.err" || fail "malformed toolchain rejection missing" + +# resolve-project rejects dirty source. +echo dirty >>"$APP/file.txt" +GITHUB_OUTPUT="$TMP/dirty.out" \ +GITHUB_WORKSPACE="$TMP/workspace" \ +EDGEZERO_ACTION_ROOT="$ROOT" \ +INPUT_WORKING_DIRECTORY=app \ +INPUT_RUST_TOOLCHAIN=auto \ +INPUT_BUILD_MODE=auto \ +INPUT_CACHE=false \ +run_expect_fail dirty "$ACTION_DIR/scripts/resolve-project.sh" +grep -q "working tree.*dirty" "$TMP/dirty.err" || fail "dirty tree rejection missing" +git -C "$TMP/workspace" checkout -- file.txt 2>/dev/null || rm -f "$APP/file.txt" + +# run-edgezero preserves argument boundaries and keeps deploy credentials scoped. +FAKEBIN="$TMP/bin" +mkdir -p "$FAKEBIN" +cat >"$FAKEBIN/edgezero" <<'SH' +#!/usr/bin/env bash +printf '%s\0' "$@" >"$EDGEZERO_CAPTURE_ARGS" +if [[ " ${*} " == *" deploy "* ]]; then + [[ -n "${FASTLY_API_TOKEN:-}" ]] || exit 64 + [[ -n "${FASTLY_SERVICE_ID:-}" ]] || exit 65 + [[ -z "${FASTLY_TOKEN:-}" ]] || exit 66 + [[ -z "${FASTLY_PROFILE:-}" ]] || exit 67 + [[ -z "${INPUT_FASTLY_API_TOKEN:-}" ]] || exit 68 +fi +SH +chmod +x "$FAKEBIN/edgezero" +printf '%s\0' '--comment' 'hello world' >"$TMP/args.nul" +PATH="$FAKEBIN:$PATH" \ +WORKING_DIRECTORY="$APP" \ +ARGS_FILE="$TMP/args.nul" \ +MANIFEST="$APP/edgezero.toml" \ +EDGEZERO_CAPTURE_ARGS="$TMP/captured.nul" \ +FASTLY_API_TOKEN=token \ +FASTLY_SERVICE_ID=svc \ +FASTLY_TOKEN=caller-leak \ +FASTLY_PROFILE=caller-profile \ +INPUT_FASTLY_API_TOKEN=caller-input-leak \ +run_expect_ok run-deploy "$ACTION_DIR/scripts/run-edgezero.sh" deploy +captured=() +while IFS= read -r -d '' item; do + captured+=("$item") +done <"$TMP/captured.nul" +expected=(deploy --adapter fastly -- --service-id svc --non-interactive --comment "hello world") +[[ ${#captured[@]} -eq ${#expected[@]} ]] || fail "captured deploy arg count mismatch" +for i in "${!expected[@]}"; do + [[ "${captured[$i]}" == "${expected[$i]}" ]] || fail "captured deploy arg $i mismatch: expected '${expected[$i]}'" +done + +# build mode must not require Fastly credentials. +PATH="$FAKEBIN:$PATH" \ +WORKING_DIRECTORY="$APP" \ +ARGS_FILE="$TMP/args.nul" \ +MANIFEST="$APP/edgezero.toml" \ +EDGEZERO_CAPTURE_ARGS="$TMP/captured-build.nul" \ +run_expect_ok run-build "$ACTION_DIR/scripts/run-edgezero.sh" build + +printf 'deploy action script tests passed\n' diff --git a/.github/actions/deploy/versions.json b/.github/actions/deploy/versions.json new file mode 100644 index 00000000..1304e487 --- /dev/null +++ b/.github/actions/deploy/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/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml new file mode 100644 index 00000000..8c7c18c5 --- /dev/null +++ b/.github/workflows/deploy-action.yml @@ -0,0 +1,131 @@ +name: Deploy action + +on: + pull_request: + paths: + - .github/actions/deploy/** + - .github/workflows/deploy-action.yml + - crates/edgezero-adapter/** + - crates/edgezero-adapter-fastly/** + - crates/edgezero-cli/** + - docs/guide/deploy-github-actions.md + - docs/.vitepress/config.mts + - docs/specs/** + push: + branches: [main] + paths: + - .github/actions/deploy/** + - .github/workflows/deploy-action.yml + - crates/edgezero-adapter/** + - crates/edgezero-adapter-fastly/** + - crates/edgezero-cli/** + - docs/guide/deploy-github-actions.md + - docs/.vitepress/config.mts + - docs/specs/** + +permissions: + contents: read + +jobs: + contract-tests: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + with: + persist-credentials: false + - name: Actionlint workflows + run: go run github.com/rhysd/actionlint/cmd/actionlint@03d0035246f3e81f36aed592ffb4bebf33a03106 + - name: Workflow security checks + run: | + python3 - <<'PY' + import re + from pathlib import Path + + checked = [ + Path('.github/actions/deploy/action.yml'), + Path('.github/workflows/deploy-action.yml'), + ] + remote_use = re.compile(r'uses:\s+([^\s#]+)') + full_sha = re.compile(r'.+@[0-9a-fA-F]{40}$') + for path in checked: + for line_no, line in enumerate(path.read_text().splitlines(), 1): + match = remote_use.search(line) + if not match: + continue + target = match.group(1).strip('"\'') + if target.startswith('./') or target.startswith('docker://'): + continue + if not full_sha.fullmatch(target): + raise SystemExit(f'{path}:{line_no}: remote action is not pinned to a full SHA: {target}') + PY + - name: Run zizmor workflow scanner + run: | + python3 -m pip install --user zizmor==1.16.3 + "$(python3 -m site --user-base)/bin/zizmor" --offline .github/actions/deploy/action.yml .github/workflows/deploy-action.yml + - name: Run deploy action script contract tests + run: .github/actions/deploy/tests/run.sh + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + - name: Shellcheck deploy action scripts + run: shellcheck .github/actions/deploy/scripts/*.sh .github/actions/deploy/tests/run.sh + - name: Verify Fastly checksum metadata + run: | + python3 - <<'PY' + import json + from pathlib import Path + + with open('.github/actions/deploy/versions.json', encoding='utf-8') as f: + data = json.load(f) + tool_versions = dict( + line.split(None, 1) for line in Path('.tool-versions').read_text().splitlines() if line.strip() + ) + assert data['fastly']['version'] == tool_versions['fastly'] + checksum = data['fastly']['linux_amd64']['sha256'] + assert len(checksum) == 64 and all(c in '0123456789abcdef' for c in checksum) + PY + - name: Validate deploy action documentation + 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + with: + persist-credentials: false + - name: Create fixture app + run: | + set -euo pipefail + mkdir -p fixture-app + cd fixture-app + git init -q + git config user.email test@example.com + git config user.name Test + cat >Cargo.lock <<'LOCK' + # fixture lockfile + LOCK + cat >edgezero.toml <<'TOML' + [adapters.fastly.commands] + deploy = "printf '%s\\n' > deploy-args.txt" + TOML + git add . + git commit -q -m fixture + - name: Deploy fixture with local action + uses: ./.github/actions/deploy + with: + adapter: fastly + working-directory: fixture-app + manifest: edgezero.toml + deploy-args: '["--comment", "smoke"]' + fastly-api-token: dummy-token + fastly-service-id: dummy-service + - name: Assert fixture deploy command + run: | + set -euo pipefail + diff -u <(printf '%s\n' --service-id dummy-service --non-interactive --comment smoke) fixture-app/deploy-args.txt diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index c34d0ccb..6aaa8832 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -32,9 +32,9 @@ jobs: id: node-version run: | if [ -f .tool-versions ]; then - echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" else - echo "node-version=20" >> $GITHUB_OUTPUT + echo "node-version=20" >> "$GITHUB_OUTPUT" fi - name: Setup Node.js diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 8a842537..c2a08979 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -34,7 +34,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up rust toolchain @@ -95,7 +95,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust tool chain @@ -125,7 +125,7 @@ jobs: - name: Retrieve Node.js version id: node-version working-directory: . - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93b5ba2e..2aa21c8a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust tool chain @@ -119,7 +119,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust tool chain diff --git a/CLAUDE.md b/CLAUDE.md index 6a0495c5..81da89d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ scripts/ # Build/deploy/test helper scripts - **Rust**: 1.95.0 (from `.tool-versions`) - **Node.js**: 24.12.0 (for docs site only) -- **Fastly CLI**: 15.1.0 +- **Fastly CLI**: v15.1.0 - **Edition**: 2021 - **Resolver**: 2 - **License**: Apache-2.0 diff --git a/README.md b/README.md index aacee78a..2071b621 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Full documentation is available at **[stackpop.github.io/edgezero](https://stack - [Configuration](https://stackpop.github.io/edgezero/guide/configuration) - `edgezero.toml` reference - [CLI Reference](https://stackpop.github.io/edgezero/guide/cli-reference) - All CLI commands - [Blob App-Config Migration](https://stackpop.github.io/edgezero/guide/blob-app-config-migration) - Typed `AppConfig` extractor + `config push` / `config diff` workflow +- [Deploy with GitHub Actions](https://stackpop.github.io/edgezero/guide/deploy-github-actions) - Full-SHA-pinned Fastly deploy action ## Supported Platforms diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b30fd7ff..e833d1c3 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -27,6 +27,10 @@ export default defineConfig({ { text: 'What is EdgeZero?', link: '/guide/what-is-edgezero' }, { text: 'Getting Started', link: '/guide/getting-started' }, { text: 'Architecture', link: '/guide/architecture' }, + { + text: 'Deploy with GitHub Actions', + link: '/guide/deploy-github-actions', + }, { text: 'Roadmap', link: '/guide/roadmap' }, ], }, diff --git a/docs/guide/deploy-github-actions.md b/docs/guide/deploy-github-actions.md new file mode 100644 index 00000000..9c054420 --- /dev/null +++ b/docs/guide/deploy-github-actions.md @@ -0,0 +1,198 @@ +# Deploy with GitHub Actions + +EdgeZero includes a pre-release composite GitHub Action for deploying a checked-out application to Fastly Compute. + +::: warning Pre-release +The deploy action is currently full-SHA only. Do not use a moving branch or tag in production workflows. +::: + +## Action path + +```yaml +uses: stackpop/edgezero/.github/actions/deploy@ +``` + +The action lives inside the EdgeZero monorepo at `.github/actions/deploy`. + +## Supported adapters + +| Adapter | Status | +| ------------ | ----------------------------------- | +| `fastly` | Supported in v0 | +| `cloudflare` | Future work | +| `spin` | Future work | +| `axum` | Not supported for remote deployment | + +The `adapter` input is required and must be `fastly` in v0. + +## Minimal same-repository workflow + +```yaml +name: Deploy + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-24.04 + environment: production + timeout-minutes: 30 + concurrency: + group: fastly-production + cancel-in-progress: false + steps: + - uses: actions/checkout@ + with: + persist-credentials: false + + - uses: stackpop/edgezero/.github/actions/deploy@ + with: + adapter: fastly + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## Separate deployer and application repositories + +```yaml +steps: + - name: Checkout deployer + uses: actions/checkout@ + with: + path: deployer + persist-credentials: false + + - name: Checkout application + uses: actions/checkout@ + with: + repository: stackpop/my-edgezero-app + ref: ${{ inputs.ref }} + path: app + persist-credentials: false + + - name: Deploy application + uses: stackpop/edgezero/.github/actions/deploy@ + with: + adapter: fastly + working-directory: app + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## Monorepo or non-root manifest + +```yaml +- uses: stackpop/edgezero/.github/actions/deploy@ + with: + adapter: fastly + working-directory: apps/api + manifest: edgezero.toml + cache: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## Inputs + +| Input | Required | Default | Description | +| ------------------- | -------- | ------- | ------------------------------------------------------------------------------- | +| `adapter` | Yes | none | Must be `fastly`. | +| `working-directory` | No | `.` | App directory relative to `github.workspace`. | +| `manifest` | No | empty | Optional `edgezero.toml` path relative to `working-directory`. | +| `rust-toolchain` | No | `auto` | Explicit Rust toolchain or automatic discovery. | +| `build-mode` | No | `auto` | `auto`, `always`, or `never`. Fastly `auto` resolves to `never`. | +| `build-args` | No | `[]` | JSON array passed after `edgezero build --adapter fastly --`. | +| `deploy-args` | No | `[]` | JSON array of Fastly comment args appended after the action-owned deploy flags. | +| `cache` | No | `false` | Enables exact-key application `target/` caching. | +| `fastly-api-token` | Yes | none | Fastly token, scoped to the deploy step. | +| `fastly-service-id` | Yes | none | Fastly service ID used by the action-owned deploy flag. | + +## Outputs + +| Output | Description | +| ---------------------- | ------------------------------------------------ | +| `adapter` | Normalized adapter, `fastly`. | +| `source-revision` | Git revision deployed from `working-directory`. | +| `edgezero-revision` | EdgeZero action/CLI revision used by the action. | +| `provider-cli-version` | Installed Fastly CLI version. | +| `effective-build-mode` | Resolved build behavior. | + +## Credential scope + +Pass Fastly credentials through typed inputs, not `env:`: + +```yaml +with: + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +The action maps the token to `FASTLY_API_TOKEN` only for the deploy step, and passes the service ID as the action-owned Fastly `--service-id` deploy flag. Setup and separate build steps do not receive those typed credentials. + +Application settings may still use workflow `env:` when the app genuinely needs them during build or deploy. Do not duplicate Fastly credentials in `env:`. + +## EdgeZero CLI revision + +The action builds and runs the `edgezero` binary from the same EdgeZero commit selected by the `uses:` ref. It does not install the CLI from the application repository or from the application's Cargo dependencies. Pin the action to a full commit SHA to pin the CLI implementation. + +## Build behavior + +`build-mode` controls whether the action runs a separate EdgeZero build before deploy: + +| Value | Behavior | +| -------- | ---------------------------------------------------------------------------------------------- | +| `auto` | Uses the Fastly v0 policy. | +| `always` | Runs `edgezero build --adapter fastly` before deploy. | +| `never` | Skips the separate build and relies on Fastly deploy/publish to build or consume the artifact. | + +For Fastly v0, `auto` resolves to `never` because Fastly `compute deploy`/`compute publish` builds unless a prebuilt package is supplied. Use `always` only when you want a separate validation build and can tolerate a possible second compile during deploy. + +## Manifest selection + +By default, the action runs in `working-directory` and leaves `EDGEZERO_MANIFEST` unset. EdgeZero then loads `edgezero.toml` from the working directory when present, or uses its built-in Fastly fallback. + +Set `manifest` when the application manifest is not the default `working-directory/edgezero.toml`: + +```yaml +with: + adapter: fastly + working-directory: apps/api + manifest: edgezero.toml +``` + +The action does not guess between multiple `fastly.toml` files in a monorepo. Choose a deterministic `working-directory` or define explicit Fastly commands in `edgezero.toml`. + +## Passthrough arguments + +`build-args` and `deploy-args` are JSON arrays of strings. For v0, `deploy-args` is intentionally narrow: only Fastly deploy comments are accepted (`--comment VALUE` or `--comment=VALUE`). Service, auth, endpoint, interactive, debug, short override, and unknown future Fastly flags are rejected. + +## Caching + +Caching is disabled by default. When `cache: true`, the action caches only the application Git root `target/` directory with an exact key that includes the runner, Rust toolchain, Fastly target, EdgeZero revision, source revision, and `Cargo.lock` hash. + +Enable caching only for trusted immutable refs and applications whose builds do not write secret-derived data into `target/`. + +## Security and workflow policy + +Use least-privilege permissions unless your workflow needs more: + +```yaml +permissions: + contents: read +``` + +Deployment jobs should use protected GitHub Environments, explicit timeouts, and per-environment concurrency with `cancel-in-progress: false` so two deployments do not race the same Fastly service. + +Check out trusted immutable refs and use `persist-credentials: false` on checkout steps. EdgeZero manifests and provider commands are executable deployment code; do not deploy untrusted pull request refs with provider credentials. + +## Fastly-specific behavior kept out of v0 + +The generic action does not expose a Fastly service version. If a workflow needs health checks or rollback, keep that logic in the caller workflow or a future Fastly-specific action. For production rollback, prefer recording the active Fastly version before deploy and reactivating it if a caller-owned health check fails. + +## Non-goals + +The deploy action does not check out source, provision Fastly resources, push secrets, stage deployments, perform health checks, roll back failures, or expose Fastly service versions. 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..fe108ad7 --- /dev/null +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -0,0 +1,64 @@ +# EdgeZero Deploy GitHub Action Implementation Plan + +**Status:** Implemented in this worktree + +**Spec:** `docs/specs/edgezero-deploy-github-action.md` + +## Scope + +Implement the Fastly-only v0 deploy action in the EdgeZero monorepo at `.github/actions/deploy/action.yml`. + +The action deploys already checked-out application source. It does not perform checkout, Fastly staging, runtime config mutation, health checks, rollback, or provider-specific deployment metadata parsing. + +## Implementation phases + +1. **Action metadata** + - Define required `adapter` input and v0 Fastly-only contract. + - Add `working-directory`, `manifest`, `rust-toolchain`, `build-mode`, JSON `build-args`/`deploy-args`, `cache`, and typed Fastly inputs. + - Expose `adapter`, `source-revision`, `edgezero-revision`, `provider-cli-version`, and `effective-build-mode` outputs. + +2. **Input validation** + - Reject missing/unsupported adapters before setup. + - Reject Cloudflare, Spin, Axum, and unknown adapters in v0. + - Validate exact booleans, build mode, JSON array arguments, string-only entries, and NUL bytes. + - Allow only Fastly deploy comment args and reject service selection, auth, endpoint, profile, debug, interactive behavior, short override flags, and unknown future flags. + +3. **Project resolution** + - Canonicalize `working-directory` and optional `manifest` inside `github.workspace`. + - Resolve source Git root and committed source revision. + - Fail on dirty source. + - Resolve Rust toolchain using explicit input, Rustup files, `.tool-versions`, then EdgeZero repo fallback. + - Build exact cache key from OS, arch, Rust toolchain, target, EdgeZero revision, source revision, and lockfile hash. + +4. **Tool installation** + - Install application Rust toolchain and `wasm32-wasip1` target. + - Set `RUSTUP_TOOLCHAIN` for subsequent application build/deploy commands. + - Build the action-owned `edgezero` binary from the selected EdgeZero action commit with only Fastly CLI support. + - Install pinned Fastly CLI from official release artifact and verify checksum. + +5. **Execution** + - Resolve Fastly `build-mode: auto` to `never`. + - Run optional build without Fastly credentials. + - Run deploy with Bash arrays, typed Fastly token, action-owned `--service-id`, action-owned `--non-interactive`, then safe caller deploy args. + - Clear inherited Fastly auth aliases from every non-provider step and clear/re-export typed values in deploy. + +6. **Caching and cleanup** + - Restore cache only when `cache: true`. + - Save cache only after successful execution and non-empty resolved key/path. + - Cache only application Git root `target/`. + - Clean action-owned state/tool directories with `if: always()`. + +7. **Logging and docs** + - Write non-sensitive summary fields. + - Document full-SHA invocation, examples, inputs/outputs, credential scope, build behavior, cache behavior, and non-goals. + +8. **Validation** + - Script contract tests for validation, path confinement, toolchain parsing, cache keys, credential scoping, and build/deploy argv. + - Actionlint, ShellCheck, zizmor, checksum metadata validation, docs validation, and composite smoke test workflow. + - Workspace Rust tests, format, clippy, and feature checks. + +## Known follow-up candidates + +- Add provider-specific Fastly staging/rollback actions separately if needed. +- Add Cloudflare and Spin through new specs. +- Replace source-built CLI with prebuilt attested EdgeZero CLI binaries when available. diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md new file mode 100644 index 00000000..4847ce0f --- /dev/null +++ b/docs/specs/edgezero-deploy-github-action.md @@ -0,0 +1,777 @@ +# EdgeZero Deploy GitHub Action — Fastly v0 Spec + +**Status:** Implemented Fastly v0 contract + +**Date:** 2026-07-02 + +**Delivery target:** implementation in the `stackpop/edgezero` monorepo + +**Action path:** `.github/actions/deploy` + +**Pre-release identity:** + +```yaml +uses: stackpop/edgezero/.github/actions/deploy@ +``` + +## 1. Executive summary + +Add a reusable GitHub composite action to the EdgeZero monorepo that deploys a +checked-out EdgeZero application to Fastly Compute. + +The first version intentionally supports only one deployable adapter: + +| Adapter | v0 status | Target | +| ------------ | ------------------------ | -------------------------------------- | +| `fastly` | Supported | `wasm32-wasip1` | +| `cloudflare` | Future | Not implemented in v0 | +| `spin` | Future preview candidate | Not implemented in v0 | +| `axum` | Excluded | No EdgeZero remote deployment contract | + +The action does not check out application source. The caller owns checkout, +repository permissions, ref selection, GitHub Environment policy, concurrency, +timeouts, and any health check or rollback process. + +The action owns repeatable deployment setup: + +1. validate public inputs; +2. resolve the checked-out application directory and optional `edgezero.toml`; +3. resolve Rust from the application or this repository's `.tool-versions`; +4. install `wasm32-wasip1`; +5. install an action-owned EdgeZero CLI from the same EdgeZero commit selected + by the `uses:` ref; +6. install the pinned Fastly CLI version defined by this repository's tool + version policy; +7. optionally restore/save an exact-key application build cache; +8. apply the Fastly build-mode policy; +9. run EdgeZero build when required; and +10. run EdgeZero deploy with typed Fastly credentials scoped to provider + mutation steps. + +The core boundary is EdgeZero itself: + +```text +edgezero build --adapter fastly +edgezero deploy --adapter fastly +``` + +Provider-specific staging, deployment IDs, health checks, and rollback are out +of scope for this generic deploy action. + +## 2. Design principles + +1. **EdgeZero is the deployment boundary.** The action invokes the EdgeZero CLI + instead of reproducing provider build/deploy logic in YAML or shell. +2. **The caller owns source.** The action never calls `actions/checkout`. +3. **Fastly-only v0, future-compatible API.** The `adapter` input is required + even though only `fastly` is accepted in v0. +4. **Full-SHA pre-release only.** Initial consumers pin the action to a full + EdgeZero commit SHA. No `v1` alias, Marketplace publication, or release tag + is defined by this spec. +5. **Use repository tool versions.** The action's EdgeZero CLI build toolchain + and default application Rust toolchain come from the EdgeZero monorepo's + `.tool-versions` file. +6. **Typed provider credentials.** Fastly credentials are passed through action + inputs, not caller `env:`, so setup and separate build steps do not inherit + the API token. Deploy is the only provider mutation step that receives the token. +7. **No shell string APIs.** Passthrough arguments are JSON arrays and are + invoked without `eval`. +8. **Safe by default.** Caching is opt-in, deploys require committed source, + and provider credentials are not written to outputs, summaries, caches, or + action-global environment files. + +## 3. Goals + +1. Deploy any checked-out EdgeZero Fastly application from GitHub Actions. +2. Support same-repository, separate-repository, private-repository, and + monorepo checkout layouts. +3. Require explicit `adapter: fastly` for v0. +4. Respect the application's `edgezero.toml` when present. +5. Support explicit `working-directory` and explicit `manifest` selection. +6. Build and install the EdgeZero CLI from the action commit selected by + `uses:`. +7. Install the Fastly CLI reproducibly from a pinned version. +8. Accept typed Fastly credentials and expose them only to provider mutation steps. +9. Support JSON-array build and deploy passthrough arguments. +10. Support opt-in exact-key application `target/` caching. +11. Produce actionable validation failures before deployment begins. +12. Avoid logging provider credentials or action-managed secret values. + +## 4. Non-goals + +The v0 action will not: + +1. check out application source; +2. choose an application ref; +3. deploy Cloudflare Workers, Fermyon Spin, or Axum applications; +4. deploy more than one adapter per invocation; +5. provision Fastly services, domains, dictionaries, config stores, secrets, or + other provider resources; +6. push runtime config or secrets to Fastly; +7. implement Fastly staging; +8. parse or expose a Fastly service version; +9. perform health checks; +10. perform automatic rollback; +11. configure GitHub job permissions, environments, approvals, concurrency, or + timeouts; +12. support Windows or macOS runners; +13. publish a stable version alias; or +14. provide a general `setup` action for running arbitrary EdgeZero commands. + +## 5. Public action contract + +### 5.1 Invocation path + +Production examples must pin a full commit SHA: + +```yaml +- uses: stackpop/edgezero/.github/actions/deploy@ + with: + adapter: fastly + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +The action lives at: + +```text +.github/actions/deploy/action.yml +``` + +The root of the EdgeZero repository remains the Rust workspace root and does +not become a GitHub Action entry point. + +### 5.2 Inputs + +| Input | Required | Default | Contract | +| ------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `adapter` | Yes | none | Must be exactly `fastly` in v0. Unknown adapters, `cloudflare`, `spin`, and `axum` fail before setup. | +| `working-directory` | No | `.` | Application directory relative to `github.workspace`. Must resolve inside `github.workspace`. | +| `manifest` | No | empty | Optional `edgezero.toml` path relative to `working-directory`. If set, the file must exist and is exported as `EDGEZERO_MANIFEST`. | +| `rust-toolchain` | No | `auto` | Explicit application Rust toolchain or automatic discovery. | +| `build-mode` | No | `auto` | One of `auto`, `always`, or `never`. For Fastly, `auto` resolves to `never`. | +| `build-args` | No | `[]` | JSON array of strings passed after `edgezero build --adapter fastly --`. Must not contain secrets. | +| `deploy-args` | No | `[]` | JSON array of caller-supplied Fastly comment arguments appended after the action-owned deploy flags. Must not contain secrets. Other Fastly deploy args are rejected in v0. | +| `cache` | No | `false` | Enable exact-key application `target/` caching. Accepts only `true` or `false`. | +| `fastly-api-token` | Yes | none | Fastly API token. Injected only into the EdgeZero deploy step as `FASTLY_API_TOKEN`. | +| `fastly-service-id` | Yes | none | Fastly service ID used by the action-owned deploy flag to prevent accidental service creation. | + +Boolean inputs accept only the literal strings `true` and `false`. + +`build-args` and `deploy-args` must parse as JSON arrays containing only string +values. Strings containing NUL bytes are rejected because operating-system +arguments cannot represent them. + +### 5.3 Outputs + +| Output | Meaning | +| ---------------------- | --------------------------------------------------------- | +| `adapter` | Normalized adapter, always `fastly` in v0. | +| `source-revision` | Git commit deployed from `working-directory`. | +| `edgezero-revision` | EdgeZero action/CLI revision selected by the `uses:` ref. | +| `provider-cli-version` | Installed Fastly CLI version. | +| `effective-build-mode` | Resolved build behavior, `always` or `never`. | + +The action intentionally does not expose a Fastly service version or deployment +ID. Provider-specific deployment metadata requires a separate design. + +## 6. Checkout and source contract + +The caller must check out application source before invoking the action. + +### 6.1 Same-repository application + +```yaml +jobs: + deploy: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@ + with: + persist-credentials: false + + - uses: stackpop/edgezero/.github/actions/deploy@ + with: + adapter: fastly + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +### 6.2 Separate orchestration and application repositories + +```yaml +jobs: + deploy: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout deployment repository + uses: actions/checkout@ + with: + path: deployer + persist-credentials: false + + - name: Checkout application + uses: actions/checkout@ + with: + repository: stackpop/my-edgezero-app + ref: ${{ inputs.ref }} + path: app + persist-credentials: false + + - name: Deploy application + uses: stackpop/edgezero/.github/actions/deploy@ + with: + adapter: fastly + working-directory: app + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +### 6.3 Monorepo application + +```yaml +- uses: stackpop/edgezero/.github/actions/deploy@ + with: + adapter: fastly + working-directory: apps/api + manifest: edgezero.toml + cache: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## 7. Execution flow + +The action executes these steps in order: + +1. Verify the runner is Linux x86-64. GitHub-hosted `ubuntu-24.04` is the + tested environment. +2. Validate `adapter`; only `fastly` is accepted. +3. Validate exact boolean inputs. +4. Parse `build-args` and `deploy-args` as JSON string arrays. +5. Reject NUL-containing argument values. +6. Reject Fastly deploy passthrough flags that override typed authentication, + service selection, endpoint selection, or debug/authentication behavior. +7. Resolve `working-directory` beneath `github.workspace` using canonical paths + and symlink resolution. +8. Fail if the working directory does not exist or is not a directory. +9. If `manifest` is non-empty: + +- resolve it relative to `working-directory`; +- fail if it resolves outside `github.workspace`; +- fail if it does not exist or is not a regular file; and +- export it as `EDGEZERO_MANIFEST` for EdgeZero CLI invocations. + +10. Resolve the application Git root from `working-directory`. +11. Record `source-revision` and fail if the application working tree has + uncommitted or staged changes. +12. Resolve the application Rust toolchain. +13. Install the Rust toolchain and `wasm32-wasip1` target. +14. Build/install the action-owned EdgeZero CLI from the action repository root + at the selected action commit. +15. Install the pinned Fastly CLI. +16. If `cache: true`, restore the exact-key application `target/` cache. +17. Print non-sensitive diagnostics. +18. Resolve `build-mode`: + - `always` runs a separate EdgeZero build; + - `never` skips the separate build; and + - `auto` resolves to `never` for Fastly. +19. If effective build mode is `always`, run: + + ```text + edgezero build --adapter fastly -- + ``` + + The build step receives no Fastly credential values from typed inputs. + +20. In a separate deploy step, set only the typed Fastly API token and run + EdgeZero deploy with action-owned Fastly CI flags followed by caller + `deploy-args`: + + ```text + edgezero deploy --adapter fastly -- --service-id --non-interactive + ``` + + The action owns `--service-id` and `--non-interactive` so deployments cannot + prompt in CI or silently create/select an unintended service. + +21. Clean action-owned temporary tool, auth, log, and cache state with + `if: always()` where GitHub permits cleanup to run. +22. Save the application cache when enabled and safe to save. +23. Set outputs and write a non-sensitive GitHub step summary with + `if: always()`. + +When an argument array is empty, the trailing `--` may be omitted. + +## 8. Toolchain and tool installation + +### 8.1 Rust toolchain resolution + +Application Rust toolchain resolution uses this 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. + +The action-owned EdgeZero CLI build also uses the EdgeZero repository root +`.tool-versions` `rust` entry. + +### 8.2 EdgeZero CLI installation + +The action installs the EdgeZero CLI from the same repository revision selected +by the `uses:` ref. It does not install the CLI from the caller application's +Cargo dependencies, branch, tag, or path. + +For v0, the installed executable is the current repository binary name, +`edgezero`. The crate/package remains `edgezero-cli`, but the action invokes the +installed binary. If the project later renames the installed binary, the action +implementation and documentation must change together. The public action +contract should not require callers to know the internal executable path. + +The CLI is installed into an action-owned directory below `RUNNER_TEMP` and +prepended to `PATH` for action steps only. + +### 8.3 Fastly CLI installation + +The Fastly CLI version is pinned by the EdgeZero repository tool-version policy. +The initial implementation should follow the repository `.tool-versions` Fastly +CLI version and record the exact installer artifact and SHA-256 checksum in the +action implementation metadata. + +The installer must: + +- use an official Fastly distribution; +- verify the downloaded artifact checksum; +- place the binary in an action-owned directory below `RUNNER_TEMP`; +- print `fastly --version`; and +- avoid printing authentication state. + +## 9. Build behavior + +The public `build-mode` input controls whether the action runs a separate +EdgeZero build before deploy. + +| Value | Behavior | +| -------- | ------------------------------------------------------------------------------------- | +| `auto` | Apply the v0 Fastly policy. | +| `always` | Run `edgezero build --adapter fastly` before deploy. | +| `never` | Skip the separate build and rely on deploy to build or consume the required artifact. | + +The v0 Fastly `auto` policy is: + +| Adapter | Effective mode | Reason | +| -------- | -------------- | --------------------------------------------------------------------- | +| `fastly` | `never` | Fastly `compute deploy` builds unless a prebuilt package is provided. | + +`always` is useful for a separate validation build, but Fastly deploy may still +compile again. `never` is the default Fastly behavior and assumes the deploy +command is self-contained. + +## 10. Manifest behavior + +When `manifest` is empty, the action leaves `EDGEZERO_MANIFEST` unset and runs +from `working-directory`. EdgeZero then applies its normal behavior: + +- load `edgezero.toml` from the current working directory when present; or +- use the built-in Fastly adapter fallback when no default manifest exists. + +When `manifest` is provided, the action sets `EDGEZERO_MANIFEST` to the +canonical absolute path of that file. Missing explicit manifests are hard +errors. + +Provider manifest discovery remains EdgeZero's responsibility. The action must +not guess between multiple `fastly.toml` files. Monorepos with multiple Fastly +manifests should select a deterministic `working-directory` or define explicit +Fastly commands in `edgezero.toml`. + +Because v0 injects action-owned Fastly deploy flags after `--`, a manifest-defined +Fastly deploy command used with this action must forward or accept Fastly +Compute deploy/publish flags such as `--service-id`, `--non-interactive`, and +caller-supplied safe flags like `--comment`. Wrapper scripts are allowed, but +they must preserve this contract. + +## 11. Fastly credential contract + +Fastly authentication uses typed action inputs only: + +```yaml +with: + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +The deploy step maps these inputs as follows: + +| Input | Deploy-step use | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `fastly-api-token` | Exported only for deploy as `FASTLY_API_TOKEN`. | +| `fastly-service-id` | Passed as the action-owned `--service-id` deploy flag and may also be exported as `FASTLY_SERVICE_ID` for Fastly CLI fallback compatibility. | + +Setup and separate build steps must clear Fastly authentication aliases from +their environments, including values accidentally provided through caller +`env:`. + +The deploy step must clear known Fastly authentication and endpoint aliases +before exporting only the typed values needed for v0. This prevents caller `env:` +from silently overriding the typed credential contract. + +Application configuration may still be passed through normal workflow `env:`: + +```yaml +- uses: stackpop/edgezero/.github/actions/deploy@ + with: + adapter: fastly + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + env: + MY_APP_SETTING: ${{ vars.MY_APP_SETTING }} +``` + +Callers should not duplicate provider authentication credentials in `env:`. +Runtime secrets should prefer provider-managed runtime secret stores rather +than deploy-time environment variables. + +## 12. Passthrough arguments + +`build-args` and `deploy-args` are JSON arrays so argument boundaries are +explicit: + +```yaml +with: + build-args: '["--features", "fastly"]' + deploy-args: '["--comment", "deployed by GitHub Actions"]' +``` + +The action must: + +- parse arrays with `jq` or equivalent safe JSON parsing; +- reject non-arrays; +- reject non-string entries; +- reject strings containing NUL bytes; +- construct commands as Bash arrays; +- never use `eval`; and +- avoid printing the raw JSON input arrays during validation. + +Arguments must not contain secrets. EdgeZero, provider CLIs, or manifest-defined +wrapper commands may print command arguments as part of normal diagnostics; +GitHub secret masking is a final defense, not the primary security boundary. + +For v0, `deploy-args` are intentionally allowlisted to Fastly deploy comments: +`--comment VALUE` or `--comment=VALUE`. All other caller-supplied deploy args +are rejected so future Fastly flags cannot bypass the typed credential/service +contract, non-interactive mode, or endpoint/debug behavior. The +implementation must maintain accept/reject tests for allowed comments and +blocked service ID, service name, API token, endpoint, profile, interactive, +short-flag, and debug-mode overrides. + +## 13. Caching + +The `cache` input enables opt-in application build caching. + +Caching defaults to `false` because deployment builds run trusted application +code and build output may contain sensitive generated data. + +When enabled, the action caches only the canonical application Git root +`target/` directory. It must not cache: + +- provider authentication files; +- action-owned tool installations; +- Fastly logs; +- temporary deploy state; +- arbitrary workspace paths; or +- files outside the application Git root. + +The cache key must be exact and include at least: + +- runner OS; +- runner architecture; +- resolved Rust toolchain; +- Rust target, `wasm32-wasip1`; +- EdgeZero action/CLI revision; +- application source revision; and +- application `Cargo.lock` hash. + +The action must not use broad restore prefixes in v0. If `cache: true` and the +application lockfile cannot be found, the action fails before deployment with a +remediation message. + +Callers may enable caching only for trusted immutable refs and applications +whose builds do not write secret-derived data into `target/`. + +## 14. Logging and summary + +The action should log and summarize non-sensitive facts: + +- selected adapter; +- resolved application directory relative to `github.workspace`; +- source revision; +- explicit manifest path or default discovery; +- Rust toolchain and target; +- EdgeZero action/CLI revision; +- Fastly CLI version; +- requested and effective build mode; +- cache enabled/disabled and cache key fingerprint, not full secrets or args; +- final result. + +The action must not log: + +- `fastly-api-token`; +- full process environments; +- application secret values; +- provider authentication state; or +- values written to provider auth files. + +## 15. Error handling + +All validation and setup failures must stop before invoking Fastly deployment. + +Expected failures and diagnostics: + +| Failure | Required diagnostic | +| --------------------------------------- | ------------------------------------------------------------------------------ | +| Missing `adapter` | State that `adapter` is required and v0 supports `fastly`. | +| Unsupported adapter | State that v0 supports only `fastly`. | +| `axum` selected | State that Axum has no EdgeZero remote deployment contract. | +| `cloudflare` or `spin` selected | State that the adapter is planned for future work but not implemented in v0. | +| Invalid boolean | Name the input and allowed values. | +| Missing working directory | Print the workspace-relative requested path. | +| Path escapes workspace | Name the input and state that paths must stay under `github.workspace`. | +| Missing explicit manifest | Print the workspace-relative requested path. | +| Invalid JSON arguments | Name the invalid input without printing its value. | +| Non-string argument entry | State that every array element must be a string. | +| Unsupported Fastly deploy arg | State the allowlist and rejected argument 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. | +| EdgeZero CLI installation fails | Print the action revision and Rust toolchain, not secrets. | +| Fastly CLI installation fails | Print the pinned Fastly version and installer source. | +| Missing Fastly credential input | Name the missing input, never its value. | +| Build command fails | Preserve exit status and state that deploy was not attempted. | +| Deploy command fails | Preserve exit status and state that rollback is caller-owned. | +| Cleanup fails | Mark the action failed and identify the cleanup area without printing secrets. | + +Provider CLI stderr may pass through so Fastly API errors remain actionable. +The action must not construct its own error messages containing credentials. + +## 16. Security requirements + +1. Require production examples to pin this action and third-party actions to + full commit SHAs. +2. Do not accept caller-selected EdgeZero CLI source, branch, tag, or commit. +3. Install the EdgeZero CLI from the selected action repository revision. +4. Use the EdgeZero repository `.tool-versions` Rust version for the action CLI + build and application fallback. +5. Download provider tools only from official release locations and verify + SHA-256 checksums. +6. Install action-owned binaries below `RUNNER_TEMP`. +7. Use Bash arrays; never use `eval`. +8. Allow-list `adapter` before using it in file selection or command arguments. +9. Treat the checked-out application and `edgezero.toml` as executable code. +10. Require trusted immutable source refs for deployment workflows. +11. Inject Fastly credentials only into the EdgeZero deploy step. +12. Do not write Fastly credentials to `GITHUB_ENV`, `GITHUB_OUTPUT`, caches, or summaries. +13. Clear provider auth aliases from non-provider steps. +14. Reject caller paths outside `github.workspace`, including symlink escapes. +15. Escape percent, carriage return, and newline characters before emitting + user-influenced GitHub annotations or masking commands. +16. Reject carriage returns and newlines in single-line output values. +17. Disable caching by default and use exact keys only when enabled. +18. Do not automatically retry Fastly deployment. Retries are limited to + idempotent downloads. +19. Do not use `github.token` for provider authentication. +20. Document least-privilege workflow permissions: `contents: read` unless the + caller has additional needs. +21. Document caller-owned environment protection, concurrency, and timeouts. +22. Allowlist Fastly passthrough deploy args to comments so caller input cannot + override typed service selection, authentication, non-interactive mode, + endpoint, profile, debug controls, or future Fastly flags. + +## 17. Testing strategy + +### 17.1 Static validation + +CI for the action must run: + +- `actionlint` over workflow files; +- `shellcheck` over shell scripts; +- YAML parsing for `.github/actions/deploy/action.yml`; +- metadata contract tests for public inputs and outputs; +- a check that no unsupported provider credential inputs exist in v0; +- a workflow security scanner such as `zizmor`; +- checksum verification for provider installer metadata; +- a check that action tool versions agree with `.tool-versions`; and +- Markdown/example validation. + +### 17.2 Script contract tests + +Use temporary directories and fake binaries to test: + +- required `adapter` validation; +- `fastly` acceptance; +- `cloudflare`, `spin`, `axum`, and unknown adapter rejection; +- exact boolean parsing; +- toolchain precedence; +- malformed toolchain files; +- working-directory confinement; +- symlink escape rejection; +- dirty source rejection; +- source revision output; +- explicit and default manifest behavior; +- JSON argument parsing; +- argument boundary preservation; +- rejected non-string and NUL-containing arguments; +- unsupported Fastly deploy-arg rejection, including short override flags; +- build-mode resolution; +- build failure preventing deploy; +- deploy exit-code propagation; +- credential presence validation; +- credentials absent from setup and separate build processes; +- credentials present only in deploy; +- cache key construction; +- missing lockfile failure when cache is enabled; +- cleanup on success and failure; and +- redaction of credentials from action-owned logs. + +These tests must not need live Fastly credentials. + +### 17.3 Composite-action smoke tests + +A GitHub Actions workflow should exercise the local composite action with a +minimal fixture EdgeZero Fastly app. + +The smoke test should: + +1. check out this repository; +2. create or use a fixture application; +3. install the real pinned Rust and Fastly tools where practical; +4. invoke `./.github/actions/deploy` locally; +5. use manifest build/deploy commands or fake Fastly binaries that write marker + files instead of contacting Fastly; +6. assert invocation order, working directory, argument boundaries, cache behavior, + and credential scope; and +7. verify public outputs. + +### 17.4 Installer tests + +Scheduled or manually triggered CI should verify that the pinned Fastly CLI +installer still produces a runnable binary matching the expected version. + +This test verifies installation only and must not deploy. + +### 17.5 Live deployment gate + +A protected manual workflow should eventually deploy a disposable Fastly fixture +before any stable version alias is created. + +The live gate must: + +- run only from protected release branches or explicitly approved manual + dispatch; +- use isolated Fastly resources; +- never run for pull requests from forks; +- verify the deployed endpoint or provider deployment record; +- clean up through provider-specific steps; and +- treat rollback/cleanup as caller-owned provider logic, not generic action + behavior. + +This live gate is not required to publish the initial full-SHA pre-release, but +it is required before advertising a stable version alias. + +## 18. Documentation requirements + +Before implementation is considered complete, user-facing docs must include: + +1. action location and full-SHA pinning guidance; +2. supported adapter table showing Fastly-only v0; +3. runner support; +4. same-repository checkout example; +5. separate-repository checkout example; +6. monorepo `working-directory` and `manifest` example; +7. complete input and output tables; +8. typed Fastly credential guidance; +9. explanation of why provider credentials should not be passed through + caller `env:`; +10. build-mode behavior; +11. cache behavior and security caveats; +12. trusted-ref requirement; +13. least-privilege permissions example; +14. protected environment, timeout, and concurrency recommendations; +15. explicit non-goals; and +16. future adapter notes for Cloudflare and Spin. + +## 19. Acceptance criteria + +The v0 design is implemented when all of the following are true: + +1. A caller can check out an EdgeZero Fastly application and invoke + `stackpop/edgezero/.github/actions/deploy@`. +2. The action requires `adapter: fastly`. +3. Unknown adapters, `cloudflare`, `spin`, and `axum` fail before tool + installation. +4. The action contains no hard-coded application repository, application path, + Fastly domain, deployment environment, or service ID. +5. The action invokes EdgeZero CLI for build and deploy operations. +6. The EdgeZero CLI is built from the selected action commit. +7. Rust versions come from application discovery or the EdgeZero repo + `.tool-versions` fallback. +8. Fastly selects `wasm32-wasip1` and installs the pinned Fastly CLI. +9. The caller can select a non-root working directory and explicit manifest. +10. Typed Fastly credentials reach only deploy. +11. Fastly credentials never appear in outputs, caches, action-owned logs, or summaries. +12. Passthrough argument boundaries are preserved. +13. `build-mode: auto` resolves to `never` for Fastly. +14. A failed required build prevents deployment. +15. A failed deployment returns a failing action status and does not trigger + rollback. +16. `cache: true` uses exact keys and caches only the application Git root + `target/` directory. +17. Static checks, script contract tests, composite smoke tests, and installer + tests pass. +18. README or docs examples include same-repository, separate-repository, + and monorepo checkout models. + +## 20. Risks and mitigations + +| Risk | Mitigation | +| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| EdgeZero CLI and application manifest schema are incompatible | Pin the action to a full EdgeZero commit SHA and publish compatibility notes before stable aliases. | +| Fastly deploy builds while credentials are in scope | Require trusted immutable refs; keep separate build credential-free; document caching caveats. | +| Mutable refs execute unexpected manifest commands | Caller owns checkout; document full SHA/tag protection and GitHub Environment approvals. | +| Caching stores sensitive generated output | Disable by default; exact keys only; cache only `target/`; document when not to enable. | +| Provider CLI installer changes or disappears | Pin versions and checksums; run scheduled installer tests. | +| Monorepo has multiple `fastly.toml` files | Require deterministic `working-directory` or explicit `edgezero.toml`; action does not guess. | +| Generic action grows provider-specific behavior | Keep staging, rollback, health checks, and deployment metadata out of v0. | + +## 21. Future work + +Future designs may add: + +1. Cloudflare Workers deployment; +2. Spin/Fermyon Cloud preview deployment; +3. provider-specific deployment metadata outputs; +4. Fastly staging as a separate provider-specific action; +5. generic or provider-specific health checks; +6. provider-specific rollback actions; +7. reusable setup action for multiple EdgeZero commands; +8. release artifact reuse between build and deploy jobs; +9. prebuilt and attested EdgeZero CLI binaries; +10. stable version aliases such as `v1`; and +11. Linux arm64, macOS, or other runner support. + +## 22. 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: diff --git a/docs/specs/trusted-server-deployer-migration-plan.md b/docs/specs/trusted-server-deployer-migration-plan.md new file mode 100644 index 00000000..3d1b3be3 --- /dev/null +++ b/docs/specs/trusted-server-deployer-migration-plan.md @@ -0,0 +1,70 @@ +# Trusted Server Deployer Migration Plan + +**Status:** Draft migration plan + +**Repository reviewed:** the Trusted Server deployer repository + +## Current deployer behavior + +The deployer repository currently orchestrates Trusted Server Fastly deployments with: + +- `.github/workflows/deploy.yml` for manual deploys; +- `.github/workflows/daily-deploy.yml` for scheduled deploys; +- `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`. + +## Compatibility gap + +The EdgeZero generic deploy action intentionally does not support: + +- internal Trusted Server checkout; +- `trusted-server-ref`; +- `trusted-server-config` JSON expansion; +- automatic conversion of legacy JSON config to Fastly Config Store TOML; +- Fastly staging; +- Fastly service-version output; +- health checks; or +- rollback. + +Therefore the deployer cannot switch all existing behavior to the generic action in one step. + +## Recommended migration + +Start with a new production-only workflow and leave existing staging on the old action path until a Fastly-specific staging design exists. + +Production workflow shape: + +1. checkout `trusted-server-deployer` with `persist-credentials: false`; +2. checkout Trusted Server source separately at the selected ref into `trusted-server`; +3. write pre-deploy summary; +4. capture the currently active Fastly version before deploy; +5. invoke `stackpop/edgezero/.github/actions/deploy@` with: + - `adapter: fastly`; + - `working-directory: trusted-server`; + - `manifest: edgezero.toml` when present on all deployed refs; + - `cache: true` if trusted and safe; + - typed Fastly credentials; + - optional safe `deploy-args` such as `--comment`; +6. run deployer-owned production health check; +7. on health-check failure, reactivate the captured previous Fastly version; +8. write post-deploy summary from generic action outputs and rollback baseline. + +## Required deployer changes + +- Add explicit Trusted Server checkout; the generic action will not call `actions/checkout`. +- Pin action references to full SHAs for production examples. +- Use `ubuntu-24.04`, least-privilege `contents: read`, `timeout-minutes`, protected environments, and per-domain concurrency with `cancel-in-progress: false`. +- Update post-deploy summary to stop requiring `steps.deploy.outputs.fastly-version`. +- Move health check and rollback logic into deployer-local scripts/actions or keep old provider-specific actions pinned to full SHAs. +- Audit `TRUSTED_SERVER_CONFIG`; if still needed, keep config expansion and provider mutation in the deployer workflow or a Trusted Server-specific helper before invoking the generic deploy action. +- Confirm the canonical Trusted Server repository/ref has root `Cargo.lock`, `Cargo.toml`, `fastly.toml`, and preferably `edgezero.toml`. + +## Gotchas + +- The current `daily-deploy.yml` appears to stage but health-check/rollback production by default. Decide whether the scheduled workflow is production or staging and fix the mismatch before migration. +- Generic deploy has no Fastly staging contract. Keep staging legacy or design a separate Fastly staging action. +- Generic deploy has no Fastly version output. Capture the active Fastly version before deploy for production rollback. +- The reviewed local Trusted Server checkout has root `edgezero.toml` and `fastly.toml`, but the existing old action targets `IABTechLab/trusted-server`; verify the actual deployment refs before switching.