Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b946250
Design: layered EdgeZero deploy actions with Fastly staging lifecycle
aram356 Jul 9, 2026
49f5668
Plan: align deploy-core credential scoping with the spec
aram356 Jul 9, 2026
7446fbb
Spec/plan review: lifecycle service-id, provider-tool install, concre…
aram356 Jul 9, 2026
6f18573
Self-review: wire staging lifecycle through error/testing/acceptance;…
aram356 Jul 9, 2026
f3e09ef
Merge branch 'main' into feature/edgezero-deploy-actions
aram356 Jul 9, 2026
193c045
impl(actions): build-cli + deploy-core engine foundation
aram356 Jul 10, 2026
a668a8f
impl(actions): deploy-fastly Fastly CLI installer + versions.json
aram356 Jul 10, 2026
658e6da
impl(actions): run-cli engine script + deploy-fastly/healthcheck/roll…
aram356 Jul 10, 2026
1007c37
impl(actions): readability pass on run-cli, build-cli, tests
aram356 Jul 10, 2026
344d244
impl(actions): consistent main() structure, CI workflow, use setup-ru…
aram356 Jul 10, 2026
06ac765
impl(cli): Fastly staging lifecycle scaffolding (deploy --stage, heal…
aram356 Jul 10, 2026
747cf70
Merge remote-tracking branch 'origin/feature/edgezero-deploy-actions'…
aram356 Jul 10, 2026
9a1d242
docs(plan): note install-rust replaced by setup-rust-toolchain@v1
aram356 Jul 10, 2026
2b18e73
fix(ci): clippy restriction lints in tests, scope actionlint, extract…
aram356 Jul 10, 2026
80293d3
fix(ci): executable bits, zizmor tag ignore, smoke fixture fastly.toml
aram356 Jul 11, 2026
7515286
fix(ci): shellcheck -e SC1091, edgezero.toml deploy override for smoke
aram356 Jul 11, 2026
7492be3
fix(ci): cleanup.sh set -e footgun (absent dirs) + two more [[..]] &&…
aram356 Jul 11, 2026
3370d2b
docs(guide): add 'Deploying from GitHub Actions' user guide (plan pha…
aram356 Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/actions/build-cli/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: EdgeZero build-cli
description: Compile the CLI package the application provides and publish it as a self-describing artifact.

inputs:
cli-package:
description: Cargo package name of the CLI to build, defined in the application's own workspace.
required: true
cli-bin:
description: Binary name produced by cli-package. Defaults to the package name.
required: false
default: ""
working-directory:
description: Application directory (relative to github.workspace) whose workspace defines cli-package.
required: false
default: .
rust-toolchain:
description: Explicit host Rust toolchain, or 'auto' to follow application discovery.
required: false
default: auto
artifact-name:
description: Name of the uploaded CLI artifact.
required: false
default: edgezero-cli

outputs:
cli-version:
description: CLI package version read from cargo metadata.
value: ${{ steps.build.outputs['cli-version'] }}
cli-package:
description: The application CLI package that was built.
value: ${{ steps.build.outputs['cli-package'] }}
cli-bin:
description: The binary name inside the artifact.
value: ${{ steps.build.outputs['cli-bin'] }}
artifact-name:
description: Name of the uploaded CLI artifact for downstream download.
value: ${{ steps.build.outputs['artifact-name'] }}

runs:
using: composite
steps:
- name: Build application CLI package
id: build
shell: bash
env:
EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../..
INPUT_CLI_PACKAGE: ${{ inputs['cli-package'] }}
INPUT_CLI_BIN: ${{ inputs['cli-bin'] }}
INPUT_WORKING_DIRECTORY: ${{ inputs['working-directory'] }}
INPUT_RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }}
INPUT_ARTIFACT_NAME: ${{ inputs['artifact-name'] }}
run: $GITHUB_ACTION_PATH/scripts/build-cli.sh

- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.build.outputs['artifact-name'] }}
path: ${{ steps.build.outputs['tarball-path'] }}
if-no-files-found: error
retention-days: 1
158 changes: 158 additions & 0 deletions .github/actions/build-cli/scripts/build-cli.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail

# Compiles the CLI package the *application* provides (a crate in the app's own
# workspace, named by INPUT_CLI_PACKAGE) into an action-owned CARGO_TARGET_DIR,
# then packages the binary plus a self-describing cli-meta.json into a tar so the
# executable bit survives actions/upload-artifact. Never builds the EdgeZero
# monorepo CLI.
#
# Inputs (environment):
# INPUT_CLI_PACKAGE required Cargo package name to build
# INPUT_CLI_BIN optional binary name (defaults to the package name)
# INPUT_WORKING_DIRECTORY optional app dir relative to github.workspace (".")
# INPUT_RUST_TOOLCHAIN optional explicit toolchain or "auto"
# INPUT_ARTIFACT_NAME optional uploaded artifact name ("edgezero-cli")

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"

# --- Rust toolchain resolution helpers ---------------------------------------
parse_toolchain_from_channel_file() {
local file="$1"
local value
value=$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$file" | awk 'NF { print; exit }')
[[ -n "$value" ]] || fail "malformed Rust toolchain file: $file"
printf '%s\n' "$value"
}

parse_toolchain_from_toml() {
local file="$1"
local value
value=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*["'\''`]([^"'\''`]+)["'\''`][[:space:]]*$/\1/p' "$file" | head -n 1)
[[ -n "$value" ]] || fail "malformed Rust toolchain TOML file: $file"
printf '%s\n' "$value"
}

# Resolve the application toolchain: explicit input > rustup files (walking up to
# github.workspace) > .tool-versions > the EdgeZero action repo fallback.
resolve_rust_toolchain() {
local input="$1" app_dir="$2" workspace_root="$3" action_root="$4"
if [[ "$input" != "auto" ]]; then
[[ -n "$input" ]] || fail "input 'rust-toolchain' cannot be empty"
printf '%s\n' "$input"
return
fi

local directory="$app_dir" value
while true; do
if [[ -f "$directory/rust-toolchain.toml" ]]; then
parse_toolchain_from_toml "$directory/rust-toolchain.toml"
return
fi
if [[ -f "$directory/rust-toolchain" ]]; then
parse_toolchain_from_channel_file "$directory/rust-toolchain"
return
fi
if [[ -f "$directory/.tool-versions" ]] && value=$(read_tool_version "$directory/.tool-versions" rust) && [[ -n "$value" ]]; then
printf '%s\n' "$value"
return
fi
[[ "$directory" == "$workspace_root" ]] && break
local parent
parent=$(dirname "$directory")
[[ "$parent" == "$directory" ]] && break
directory="$parent"
done

if [[ -f "$action_root/.tool-versions" ]] && value=$(read_tool_version "$action_root/.tool-versions" rust) && [[ -n "$value" ]]; then
printf '%s\n' "$value"
return
fi
fail "could not resolve Rust toolchain; checked rust-toolchain.toml, rust-toolchain, .tool-versions; set input 'rust-toolchain' explicitly"
}

require_linux_x86_64() {
case "$(uname -s)-$(uname -m)" in
Linux-x86_64 | Linux-amd64) ;;
*) fail "build-cli supports only Linux x86-64 runners" ;;
esac
}

main() {
local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}"
local action_root="${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required}"
local cli_package="${INPUT_CLI_PACKAGE:?input 'cli-package' is required}"
local cli_bin="${INPUT_CLI_BIN:-}"
local working_directory="${INPUT_WORKING_DIRECTORY:-.}"
local rust_toolchain_input="${INPUT_RUST_TOOLCHAIN:-auto}"
local artifact_name="${INPUT_ARTIFACT_NAME:-edgezero-cli}"
local stage_root="${EDGEZERO_CLI_STAGE_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-cli-artifact}"
local build_target_dir="${CARGO_TARGET_DIR_OVERRIDE:-${RUNNER_TEMP:-/tmp}/edgezero-cli-build}"

require_linux_x86_64
require_cmd cargo
require_cmd rustup
require_cmd jq
require_cmd tar

# Resolve the application directory beneath github.workspace.
local workspace_real app_dir
workspace_real=$(canonical_path "$workspace")
[[ -d "$workspace/$working_directory" ]] || fail "working-directory '$working_directory' does not exist or is not a directory"
app_dir=$(canonical_path "$workspace/$working_directory")
is_under "$workspace_real" "$app_dir" || fail "input 'working-directory' must resolve inside github.workspace"

# Install the host toolchain only. The CLI is a native tool; the WASM target
# the *application* needs is installed later by the deploy engine.
local rust_toolchain
rust_toolchain=$(resolve_rust_toolchain "$rust_toolchain_input" "$app_dir" "$workspace_real" "$action_root")
rustup toolchain install "$rust_toolchain" --profile minimal

# Require a committed lockfile and validate the package + binary target.
cd "$app_dir"
local metadata package_json
metadata=$(cargo +"$rust_toolchain" metadata --locked --no-deps --format-version 1) ||
fail "cargo metadata --locked failed; ensure Cargo.lock is present and up to date"
package_json=$(jq -c --arg p "$cli_package" '.packages[] | select(.name == $p)' <<<"$metadata")
[[ -n "$package_json" ]] || fail "cli-package '$cli_package' was not found in the application workspace"

[[ -n "$cli_bin" ]] || cli_bin="$cli_package"
local has_bin cli_version
has_bin=$(jq -r --arg b "$cli_bin" '[.targets[] | select(.kind | index("bin")) | .name] | index($b) != null' <<<"$package_json")
[[ "$has_bin" == "true" ]] || fail "cli-package '$cli_package' declares no binary target named '$cli_bin'"
cli_version=$(jq -r '.version' <<<"$package_json")

# Build into an action-owned target dir so the checkout stays clean.
rm -rf "$build_target_dir"
mkdir -p "$build_target_dir"
CARGO_TARGET_DIR="$build_target_dir" cargo +"$rust_toolchain" build \
--locked --release -p "$cli_package" --bin "$cli_bin"

local bin_path="$build_target_dir/release/$cli_bin"
[[ -x "$bin_path" ]] || fail "build did not produce an executable at $bin_path"
"$bin_path" --help >/dev/null 2>&1 || fail "built CLI '$cli_bin' did not run '$cli_bin --help'"

# Package the binary and self-describing metadata into a tar.
rm -rf "$stage_root"
mkdir -p "$stage_root"
cp "$bin_path" "$stage_root/$cli_bin"
chmod +x "$stage_root/$cli_bin"
jq -n --arg bin "$cli_bin" --arg version "$cli_version" --arg package "$cli_package" \
'{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \
>"$stage_root/cli-meta.json"

local tarball="$stage_root/../${artifact_name}.tar"
tar -C "$stage_root" -cf "$tarball" "$cli_bin" cli-meta.json
tarball=$(canonical_path "$tarball")

notice "built app CLI '$cli_bin' v$cli_version from package '$cli_package'"
append_output cli-version "$cli_version"
append_output cli-package "$cli_package"
append_output cli-bin "$cli_bin"
append_output artifact-name "$artifact_name"
append_output tarball-path "$tarball"
}

main "$@"
105 changes: 105 additions & 0 deletions .github/actions/build-cli/scripts/common.sh
Original file line number Diff line number Diff line change
@@ -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_.=-' '-'
}
22 changes: 22 additions & 0 deletions .github/actions/deploy-core/scripts/cleanup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail

# Removes action-owned temporary state, tool installs, and any provider auth
# state. Runs with `if: always()`, so it must tolerate partially-created dirs.

remove_if_present() {
local dir="$1"
# Always return success: an absent dir is not an error, and this runs under
# `set -e` where a bare `[[…]] && rm` would exit non-zero when the dir is gone.
if [[ -n "$dir" && -d "$dir" ]]; then
rm -rf "$dir"
fi
}

main() {
remove_if_present "${EDGEZERO_ACTION_STATE_DIR:-}"
remove_if_present "${EDGEZERO_TOOL_ROOT:-}"
remove_if_present "${EDGEZERO_FASTLY_HOME:-}"
}

main "$@"
Loading
Loading