From 78a0bb760dee54cf44971419dcd4e90686229820 Mon Sep 17 00:00:00 2001 From: phall Date: Fri, 21 Aug 2026 20:22:50 -0400 Subject: [PATCH] feat(actions): add safe local workflow runner --- checks/00-binaries.sh | 5 +- docs/PLAYBOOKS.md | 61 +++++++++++ dot_local/bin/executable_gha-local | 170 +++++++++++++++++++++++++++++ scripts/bootstrap-darwin.sh | 4 +- tests/gha-local-smoke.sh | 155 ++++++++++++++++++++++++++ 5 files changed, 392 insertions(+), 3 deletions(-) create mode 100755 dot_local/bin/executable_gha-local create mode 100755 tests/gha-local-smoke.sh diff --git a/checks/00-binaries.sh b/checks/00-binaries.sh index 8fccfed..9b0af41 100644 --- a/checks/00-binaries.sh +++ b/checks/00-binaries.sh @@ -28,6 +28,9 @@ want_bin open-websearch "harness-neutral web research" want_bin lstags "ls + Finder tags (cargo install via run_onchange)" case "$(uname -s)" in - Darwin) want_bin ghostty "Mac terminal" ;; + Darwin) + want_bin ghostty "Mac terminal" + want_bin act "local GitHub Actions runner" + ;; Linux) want_bin wezterm "Pi terminal (optional)" ;; esac diff --git a/docs/PLAYBOOKS.md b/docs/PLAYBOOKS.md index e79acd3..09eeeb1 100644 --- a/docs/PLAYBOOKS.md +++ b/docs/PLAYBOOKS.md @@ -402,3 +402,64 @@ When you add a recipe, follow the structure: Don't add a playbook for a task that's already covered by an existing one. Compose — don't fork. + +--- + +## P13. Running a GitHub Actions job locally + +Use `gha-local` for trusted Linux workflow jobs that do not need exact hosted +runner fidelity. It wraps `act`, disables automatic loading of repository +`.env`, `.input`, `.secrets`, and `.vars` files, disables bind mode, and keeps +act's artifact server off unless explicitly requested. It also prevents act's +unconditional actrc discovery: `act` runs from a private empty directory with +`HOME` and `XDG_CONFIG_HOME` set to private empty directories, so repository, +user-global, and XDG actrc files are not read. `--directory` still points at the +trusted checkout, and default or relative `--workflows` paths are made absolute +to that checkout. + +```sh +cd ~/src/example + +gha-local list -W .github/workflows/ci.yml +gha-local check -W .github/workflows/ci.yml +gha-local run test pull_request -W .github/workflows/ci.yml +``` + +Pass additional `act` options after `--` only when the checkout and inputs are +trusted. For example, opt into a deliberately prepared secrets file: + +```sh +gha-local run deploy workflow_dispatch -- \ + -W .github/workflows/deploy.yml \ + --secret-file "$HOME/.config/gha-local/example.secrets" +``` + +**Boundaries:** + +- `gha-local` saves GitHub-hosted minutes; it does not reproduce OIDC, + environment approvals, concurrency, hosted image contents, or every service. +- On Apple Silicon it defaults containers to `linux/amd64` for action-image + compatibility. Set `GHA_LOCAL_CONTAINER_ARCH=linux/arm64` for an ARM-native + workflow. +- Do not use `act` as proof for a release or deployment. Run the repository's + canonical local gate as the acceptance check. +- Native macOS/Xcode jobs should use the repository's own local recipe. They + do not become faithful macOS jobs merely by mapping a label in `act`. +- A GitHub self-hosted runner is a separate, persistent remote-code-execution + boundary. Adopt one only for a private, single-writer repository with a + documented host threat model; `gha-local` is the safe default for arbitrary + owned repositories. + +After changing the wrapper: + +```sh +bash -n ~/dotfiles/dot_local/bin/executable_gha-local \ + ~/dotfiles/tests/gha-local-smoke.sh +shellcheck ~/dotfiles/dot_local/bin/executable_gha-local \ + ~/dotfiles/tests/gha-local-smoke.sh +bash ~/dotfiles/tests/gha-local-smoke.sh +chezmoi diff +chezmoi apply +dot-doctor +dot-bench +``` diff --git a/dot_local/bin/executable_gha-local b/dot_local/bin/executable_gha-local new file mode 100755 index 0000000..c5c32a2 --- /dev/null +++ b/dot_local/bin/executable_gha-local @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Run trusted GitHub Actions jobs locally without consuming hosted minutes. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + gha-local list [act options] + gha-local check [act options] + gha-local run JOB [EVENT] [-- act options] + +Examples: + gha-local list -W .github/workflows/ci.yml + gha-local check + gha-local run test pull_request -W .github/workflows/ci.yml + +Repository .actrc, .env, .input, .secrets, and .vars files are disabled by +default, as are user-global actrc files. Pass an explicit act option after -- +when a trusted local run genuinely needs one. +EOF +} + +die() { + printf 'gha-local: %s\n' "$*" >&2 + exit 1 +} + +subcommand="${1:-}" +[[ -n "$subcommand" ]] || { usage; exit 64; } +shift +if [[ "$subcommand" == "-h" || "$subcommand" == "--help" || "$subcommand" == "help" ]]; then + usage + exit 0 +fi + +command -v git >/dev/null 2>&1 || die "git is required" +command -v act >/dev/null 2>&1 || die "act is required (macOS: brew install act)" + +root="$(git rev-parse --show-toplevel 2>/dev/null)" \ + || die "run this inside a Git repository" +root="$(cd "$root" && pwd -P)" + +# act automatically reads actrc files from XDG_CONFIG_HOME, HOME, and its +# invocation directory, with no option to disable that behavior. Invoke it from +# an empty private environment while pointing --directory at the trusted +# checkout. The wrapper stays alive so its EXIT trap can remove the runtime. +umask 077 +runtime_dir="$(mktemp -d "${TMPDIR:-/tmp}/gha-local.XXXXXXXX")" \ + || die "could not create a private runtime directory" +runtime_dir="$(cd "$runtime_dir" && pwd -P)" +private_home="$runtime_dir/home" +private_xdg="$runtime_dir/xdg" +invocation_dir="$runtime_dir/run" +mkdir -p "$private_home" "$private_xdg" "$invocation_dir" +cleanup() { + rm -rf -- "$runtime_dir" +} +trap cleanup EXIT + +# act otherwise auto-loads these conventional files. Local workflow emulation +# must not silently hand repository credentials to arbitrary action code. +# Explicit trailing options remain able to opt into trusted files or override +# these defaults for a deliberate run. +safe_inputs=( + --env-file /dev/null + --input-file /dev/null + --secret-file /dev/null + --var-file /dev/null + --artifact-server-addr 127.0.0.1 + --artifact-server-path "" + --bind=false +) + +arch_args=() +if [[ -n "${GHA_LOCAL_CONTAINER_ARCH:-}" ]]; then + arch_args=(--container-architecture "$GHA_LOCAL_CONTAINER_ARCH") +elif [[ "$(uname -s)" == "Darwin" && "$(uname -m)" == "arm64" ]]; then + # Most published action images still assume amd64. Docker Desktop and + # OrbStack emulate it on Apple Silicon more reliably than act's host-arch + # default, at the cost of speed. + arch_args=(--container-architecture linux/amd64) +fi + +# Workflow paths would otherwise be resolved from the private invocation +# directory. Keep the default and explicit relative paths anchored to the +# trusted checkout while preserving all other arguments verbatim. +act_args=() +normalize_act_args() { + local workflow_seen=false option path + + while (( $# > 0 )); do + option="$1" + shift + case "$option" in + -W|--workflows) + (( $# > 0 )) || die "$option requires a workflow path" + path="$1" + shift + [[ "$path" == /* ]] || path="$root/$path" + act_args+=("$option" "$path") + workflow_seen=true + ;; + -W=*|--workflows=*) + path="${option#*=}" + [[ "$path" == /* ]] || path="$root/$path" + act_args+=("${option%%=*}=$path") + workflow_seen=true + ;; + *) + act_args+=("$option") + ;; + esac + done + + if [[ "$workflow_seen" == false ]]; then + act_args=(--workflows "$root/.github/workflows" "${act_args[@]}") + fi +} + +run_act() { + ( + cd "$invocation_dir" + export HOME="$private_home" + export XDG_CONFIG_HOME="$private_xdg" + act --directory "$root" "$@" + ) +} + +case "$subcommand" in + list) + normalize_act_args "$@" + run_act --list "${safe_inputs[@]}" "${act_args[@]}" + ;; + check) + normalize_act_args "$@" + run_act --validate --dryrun "${safe_inputs[@]}" "${act_args[@]}" + ;; + run) + [[ $# -gt 0 ]] || { usage >&2; exit 64; } + job="$1" + shift + event="${GHA_LOCAL_EVENT:-workflow_dispatch}" + if [[ $# -gt 0 && "$1" != "--" && "$1" != -* ]]; then + event="$1" + shift + fi + [[ "${1:-}" == "--" ]] && shift + normalize_act_args "$@" + + command -v docker >/dev/null 2>&1 \ + || die "docker is required to execute jobs (check/list do not need it)" + docker info >/dev/null 2>&1 \ + || die "Docker is not reachable; start Docker Desktop or OrbStack" + + printf 'gha-local: trusted checkout %s\n' "$root" >&2 + printf 'gha-local: event=%s job=%s (GitHub secrets disabled)\n' "$event" "$job" >&2 + run_act "$event" -j "$job" \ + "${safe_inputs[@]}" \ + "${arch_args[@]}" \ + "${act_args[@]}" + ;; + -h|--help|help) + usage + ;; + *) + usage >&2 + exit 64 + ;; +esac diff --git a/scripts/bootstrap-darwin.sh b/scripts/bootstrap-darwin.sh index 132ba99..0be6372 100755 --- a/scripts/bootstrap-darwin.sh +++ b/scripts/bootstrap-darwin.sh @@ -53,8 +53,8 @@ brew_formulae=( # AI agent multiplexer (self-manages its Claude/opencode hooks via # `herdr integration install` — see the integration step below). herdr - # Editor + git workflow - neovim gh git tig gitui lazygit + # Editor + git and local CI workflow + neovim gh git tig gitui lazygit act # Misc direnv coreutils ) diff --git a/tests/gha-local-smoke.sh b/tests/gha-local-smoke.sh new file mode 100755 index 0000000..3f3f185 --- /dev/null +++ b/tests/gha-local-smoke.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Focused smoke coverage for gha-local's actrc and checkout isolation. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +wrapper="$repo_root/dot_local/bin/executable_gha-local" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/gha-local-smoke.XXXXXXXX")" +test_root="$(cd "$test_root" && pwd -P)" +cleanup() { + rm -rf -- "$test_root" +} +trap cleanup EXIT + +fake_bin="$test_root/bin" +fake_home="$test_root/global-home" +fake_xdg="$test_root/global-xdg" +checkout="$test_root/checkout" +mkdir -p "$fake_bin" "$fake_home" "$fake_xdg/act" \ + "$checkout/.github/workflows" + +git -C "$checkout" init -q +: > "$checkout/.github/workflows/ci.yml" + +# These files would make a real act invocation unsafe or invalid if any of its +# three automatic actrc lookup locations leaked through the wrapper. +printf '%s\n' '--definitely-invalid-global-option' > "$fake_home/.actrc" +printf '%s\n' '--bind --directory /untrusted' > "$checkout/.actrc" +printf '%s\n' '--definitely-invalid-xdg-option' > "$fake_xdg/act/actrc" + +cat > "$fake_bin/act" <<'FAKE_ACT' +#!/usr/bin/env bash +set -euo pipefail + +for actrc in "$XDG_CONFIG_HOME/act/actrc" "$HOME/.actrc" "$PWD/.actrc"; do + if [[ -e "$actrc" ]]; then + printf 'fake act: automatically loaded actrc: %s\n' "$actrc" >&2 + exit 90 + fi +done + +{ + printf 'pwd=%s\n' "$PWD" + printf 'home=%s\n' "$HOME" + printf 'xdg=%s\n' "$XDG_CONFIG_HOME" + printf 'arg=%s\n' "$@" +} > "$GHA_LOCAL_TEST_CAPTURE" +FAKE_ACT +chmod +x "$fake_bin/act" + +cat > "$fake_bin/docker" <<'FAKE_DOCKER' +#!/usr/bin/env bash +[[ "${1:-}" == "info" ]] +FAKE_DOCKER +chmod +x "$fake_bin/docker" + +fail() { + printf 'not ok: %s\n' "$*" >&2 + exit 1 +} + +assert_line() { + local file="$1" expected="$2" + grep -Fqx -- "$expected" "$file" \ + || fail "missing '$expected' in $file" +} + +assert_no_line() { + local file="$1" unexpected="$2" + if grep -Fqx -- "$unexpected" "$file"; then + fail "unexpected '$unexpected' in $file" + fi +} + +assert_common_defaults() { + local capture="$1" invocation home xdg runtime + invocation="$(sed -n 's/^pwd=//p' "$capture")" + home="$(sed -n 's/^home=//p' "$capture")" + xdg="$(sed -n 's/^xdg=//p' "$capture")" + runtime="${invocation%/run}" + + [[ "$invocation" == "$runtime/run" ]] \ + || fail "act invocation directory was not private" + [[ "$home" == "$runtime/home" ]] \ + || fail "act HOME was not isolated with its runtime" + [[ "$xdg" == "$runtime/xdg" ]] \ + || fail "act XDG_CONFIG_HOME was not isolated with its runtime" + [[ ! -e "$runtime" ]] \ + || fail "private act runtime was not removed" + + assert_line "$capture" "arg=--directory" + assert_line "$capture" "arg=$checkout" + assert_line "$capture" "arg=--env-file" + assert_line "$capture" "arg=--input-file" + assert_line "$capture" "arg=--secret-file" + assert_line "$capture" "arg=--var-file" + assert_line "$capture" "arg=/dev/null" + assert_line "$capture" "arg=--artifact-server-addr" + assert_line "$capture" "arg=127.0.0.1" + assert_line "$capture" "arg=--artifact-server-path" + assert_line "$capture" "arg=" + assert_line "$capture" "arg=--bind=false" + assert_no_line "$capture" "pwd=$checkout" + assert_no_line "$capture" "home=$fake_home" + assert_no_line "$capture" "xdg=$fake_xdg" +} + +run_wrapper() { + local capture="$1" + shift + ( + cd "$checkout" + PATH="$fake_bin:$PATH" \ + HOME="$fake_home" \ + XDG_CONFIG_HOME="$fake_xdg" \ + GHA_LOCAL_TEST_CAPTURE="$capture" \ + "$wrapper" "$@" + ) +} + +list_capture="$test_root/list.capture" +run_wrapper "$list_capture" list -W .github/workflows/ci.yml --verbose +assert_common_defaults "$list_capture" +assert_line "$list_capture" "arg=--list" +assert_line "$list_capture" "arg=-W" +assert_line "$list_capture" "arg=$checkout/.github/workflows/ci.yml" +assert_line "$list_capture" "arg=--verbose" + +check_capture="$test_root/check.capture" +run_wrapper "$check_capture" check --action-offline-mode +assert_common_defaults "$check_capture" +assert_line "$check_capture" "arg=--validate" +assert_line "$check_capture" "arg=--dryrun" +assert_line "$check_capture" "arg=--workflows" +assert_line "$check_capture" "arg=$checkout/.github/workflows" +assert_line "$check_capture" "arg=--action-offline-mode" + +run_capture="$test_root/run.capture" +( + export GHA_LOCAL_CONTAINER_ARCH=linux/test64 + run_wrapper "$run_capture" run test pull_request -- \ + --workflows=.github/workflows/ci.yml \ + --platform test-runner=test-image +) +assert_common_defaults "$run_capture" +assert_line "$run_capture" "arg=pull_request" +assert_line "$run_capture" "arg=-j" +assert_line "$run_capture" "arg=test" +assert_line "$run_capture" "arg=--container-architecture" +assert_line "$run_capture" "arg=linux/test64" +assert_line "$run_capture" "arg=--workflows=$checkout/.github/workflows/ci.yml" +assert_line "$run_capture" "arg=--platform" +assert_line "$run_capture" "arg=test-runner=test-image" + +printf 'ok: gha-local isolates actrc files and preserves checkout arguments\n'