Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ jobs:
run: python3 scripts/test-check-kickoff.py
- name: scripts/agent-prepare, agent-finish
run: bash scripts/test-agent-work.sh
- name: scripts/runner-setup.sh (private-repo check)
run: sh scripts/test-runner-setup.sh

test:
name: Tests (${{ matrix.name }})
Expand Down
89 changes: 89 additions & 0 deletions docs/AGENT_WORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,92 @@ PR creation. Fix the reported problem and retry finish. Neither helper merges or
- Draft PR on the first push; take it to ready only when the gates pass and CI is green.
- A different worker than the author reviews the PR.
- No auto-merge. Merging is the maintainer's (`CONTRIBUTING.md`).

## Self-hosted runner

On a dedicated Ubuntu 24.04 or newer machine with systemd (x86_64 or aarch64), run the
bootstrap from a trusted checkout. It creates the non-sudo `fizzy-worker` account, installs
Zig at the version in `scripts/cloud-setup.sh`, jj, gh, git, zstd and Linux build/test libraries,
restores the published Zig dependencies, and registers an Actions runner with the `fizzy`
label. It installs the runner's systemd service as `fizzy-worker`, plus a daily cleanup timer.
macOS and Windows provisioning are separate follow-up work.

```sh
# Preview on any machine: no root, token, downloads or system changes needed.
scripts/runner-setup.sh --dry-run

# Read a short-lived registration token from GitHub's Settings > Actions > Runners.
# The token enters neither shell history nor command arguments.
read -rs -p 'Runner registration token: ' RUNNER_TOKEN; echo
export RUNNER_TOKEN
sudo --preserve-env=RUNNER_TOKEN bash scripts/runner-setup.sh
unset RUNNER_TOKEN
```

**Private repositories only. Never register this runner on a public repository, and never let it
run fork PRs:** a workflow there would execute a stranger's code as `fizzy-worker`. `RUNNER_URL`
defaults to the private `https://github.com/fizzyedit/work` and must be a
`https://github.com/<owner>/<repo>` URL. The script confirms it is private with
`gh api repos/<owner>/<repo> --jq .private` and refuses otherwise, including when `gh` is missing
or not logged in; after checking by hand, `FIZZY_RUNNER_ALLOW_UNCHECKED=1` skips the check.
`RUNNER_NAME` defaults to the hostname and
`RUNNER_LABELS` to `fizzy` (comma-separated additional labels). Preserve these variables through
sudo when overriding them. The registration token is passed to `config.sh` through
`ACTIONS_RUNNER_INPUT_TOKEN`; it is never written by the bootstrap. The runner stores its own
service credentials in `/home/fizzy-worker/actions-runner`, outside the checkout.

The runner's `.env` sets `FIZZY_BUILD_SLOTS` (default 2) and `FIZZY_BUILD_MAX_LOAD` (default
the CPU count) for every job, so `scripts/zb` bounds concurrent builds on the box; override either in
the environment when running the bootstrap, through sudo like the others.

Reruns preserve an existing registration and service identity; they refresh tools, dependencies
and cleanup units. Stop jobs and builds before rerunning. `ZIG_VERSION`, `JJ_VERSION` and
`RUNNER_VERSION` can pin versions (without a leading `v`); jj and an unconfigured runner default
to GitHub's latest releases. `DEPS_CACHE_URL` can select another trusted cache archive. The jj, runner and deps-cache
downloads are not checksummed (the box is trusted), and `latest` is resolved when the script runs,
so two runs can install different versions; pin the versions for a reproducible box. Keep
both scripts beside `cloud-setup.sh` when copying the bootstrap to the box. A live installation
requires a maintainer's machine and token; local dry-run and shellcheck checks do not prove
registration or service startup.

Use this only for trusted workflows: jobs execute with the worker account's access. The account
has no sudo or container-admin group membership. Runner installation does not provide a model
API credential or a GitHub push token. Configure any worker harness separately, keeping its
push credentials outside the model's reach as described under Trust boundary above.

The runner uses `/home/fizzy-worker/actions-runner/_work`. Put additional jj workspaces in
`/home/fizzy-worker/workspaces`; keep their shared repo outside any disposable workspace.
Cleanup runs as `fizzy-worker`, retains the global `~/.cache/zig/p` archive cache, and removes
untracked `.zig-cache`, `zig-out` and `zig-pkg` trees in these two roots, including runaway
`zig-pkg/.tmp-*` trees. A successful jj or git tracked-file query is required before deleting
a cache. Any live Zig, ZLS or Actions job process vetoes the whole sweep; inspection failures
also retain the data. Builds started manually should be stopped before manual cleanup.

Deleting a whole workspace is opt-in. As `fizzy-worker`, enroll a disposable workspace with a
file named `<workspace-name>.pr` containing only its PR URL:

```sh
mkdir -p ~/.config/fizzy/workspaces
printf '%s\n' 'https://github.com/fizzyedit/fizzy/pull/123' > ~/.config/fizzy/workspaces/ci-example.pr
# This authorizes removing ~/workspaces/ci-example after that PR merges.
/usr/local/sbin/fizzy-runner-clean --dry-run
```

Enrollment authorizes deleting the clean merged checkout, including its tracked source and
ignored files. Keep valuable ignored files elsewhere. The cleaner checks GitHub reports
`MERGED`, and that a clean standalone git clone's HEAD, or an empty jj workspace's parent,
matches the PR head commit. Linked jj workspaces are forgotten before removal; the shared
repo is retained. Standalone jj repositories and git worktrees are retained. Dirty, divergent,
unregistered and unsupported workspaces are retained. Failed GitHub lookups retain workspaces;
authenticate gh separately as `fizzy-worker` if private PRs need checking. The runner registration
token is not a gh API token.

```sh
systemctl status fizzy-runner-clean.timer
journalctl -u fizzy-runner-clean.service
sudo -u fizzy-worker -H /usr/local/sbin/fizzy-runner-clean --dry-run
```

Each removal logs its estimated freed KiB and the sweep logs a total. The timer skips active
jobs rather than stopping them; the next daily sweep retries. It never sweeps unrelated users'
projects or deletes unregistered workspace directories.
164 changes: 164 additions & 0 deletions scripts/runner-clean.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
# Clean only this dedicated worker's disposable build data and enrolled PR workspaces.
set -euo pipefail

dry_run=false
case "${1:-}" in
--dry-run) dry_run=true ;;
'') ;;
*) echo "usage: $0 [--dry-run]" >&2; exit 2 ;;
esac
[ "$#" -le 1 ] || exit 2
worker_home=${FIZZY_WORKER_HOME:-/home/fizzy-worker}
if [ "$(uname -s)" != Linux ]; then
if "$dry_run"; then
echo "Would inspect Linux /proc for active Zig, ZLS and Actions jobs."
echo "Would clean untracked caches under $worker_home/{workspaces,actions-runner/_work}."
echo "Would check enrolled PRs in $worker_home/.config/fizzy/workspaces/*.pr."
exit 0
fi
echo 'runner-clean requires Linux /proc' >&2; exit 1
fi
[ -d "$worker_home" ] && [ ! -L "$worker_home" ] || exit 0
worker_home=$(realpath -e "$worker_home")
exec 9>"$worker_home/.runner-clean.lock"
flock -n 9 || exit 0

# A whole-sweep veto also protects builds whose compiler's cwd is outside the project.
# Check all users: a root-started build can still be using a worker's packages.
busy() {
local proc comm
if [ ! -r /proc/self/comm ]; then
echo 'Cannot inspect /proc; retaining all data'
return 0
fi
for proc in /proc/[0-9]*; do
if ! IFS= read -r comm 2>/dev/null < "$proc/comm"; then
[ ! -d "$proc" ] && continue
echo "Cannot inspect $proc; retaining all data"
return 0
fi
case "$comm" in
zig|zls|Runner.Worker*) echo "Busy process ${proc##*/} ($comm); retaining all data"; return 0 ;;
esac
done
return 1
}
busy && exit 0
tracked_file=$(mktemp "$worker_home/.runner-clean.XXXXXX")
trap 'rm -f -- "$tracked_file"' EXIT
freed=0
remove() {
local path=$1 kb
busy && return 0
kb=$(du -sk -- "$path" | cut -f1)
Comment on lines +53 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep cleanup mutually exclusive with runner jobs

When a queued Actions job starts after this busy check, the cleaner proceeds to du and then removes the cache while the new job may already be using it; measuring a large cache can make this race window substantial. Because the setup leaves the Actions listener running while enabling this timer, checking /proc is not sufficient synchronization—pause the listener or use a lock/job hook shared with runner jobs before deleting anything.

Useful? React with 👍 / 👎.

if "$dry_run"; then
printf 'Would remove %q (%s KiB)\n' "$path" "$kb"
else
rm -rf --one-file-system -- "$path"
freed=$((freed + kb))
printf 'Removed %q (%s KiB)\n' "$path" "$kb"
fi
}

# A successful tracked-file query is required; an unrelated parent git repo is not evidence.
clean_caches() {
local repo=$1 cache relative file is_tracked parent nested
local -a tracked
if [ -d "$repo/.jj" ]; then
jj -R "$repo" --ignore-working-copy file list -T 'path ++ "\0"' > "$tracked_file" || return 0
elif [ -e "$repo/.git" ]; then
git -C "$repo" ls-files -z > "$tracked_file" || return 0
else
return 0
fi
mapfile -d '' -t tracked < "$tracked_file"
while IFS= read -r -d '' cache; do
# Nested repositories must use their own tracked-file list.
parent=${cache%/*}
nested=false
while [ "$parent" != "$repo" ]; do
if [ -e "$parent/.git" ] || [ -d "$parent/.jj" ]; then nested=true; break; fi
parent=${parent%/*}
done
"$nested" && continue
nested=$(find "$cache" \( -name .git -o -name .jj \) -print -quit)
if [ -n "$nested" ]; then
printf 'Retaining cache containing repository metadata %q\n' "$cache"
continue
fi
relative=${cache#"$repo/"}
is_tracked=false
for file in "${tracked[@]}"; do
case "$file" in "$relative"|"$relative"/*) is_tracked=true; break ;; esac
done
if "$is_tracked"; then
printf 'Retaining tracked cache %q\n' "$cache"
continue
fi
remove "$cache"
done < <(find "$repo" -type d \( -name .git -o -name .jj \) -prune -o \
-type d \( -name .zig-cache -o -name zig-out -o -name zig-pkg \) -prune -print0)
}

for base in "$worker_home/workspaces" "$worker_home/actions-runner/_work"; do
[ -d "$base" ] && [ ! -L "$base" ] || continue
# Discover nested Actions checkouts as well as top-level jj workspaces, without entering caches.
while IFS= read -r -d '' metadata; do
clean_caches "${metadata%/*}"
done < <(find "$base" -type d \( -name .zig-cache -o -name zig-out -o -name zig-pkg \) -prune -o \
\( -name .git -o -name .jj \) -print0 -prune)
done

for enrollment in "$worker_home"/.config/fizzy/workspaces/*.pr; do
[ -f "$enrollment" ] && [ ! -L "$enrollment" ] || continue
name=${enrollment##*/}; name=${name%.pr}
[[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] || continue
workspace=$worker_home/workspaces/$name
[ -d "$workspace" ] && [ ! -L "$workspace" ] || continue
# Nested symlinks could otherwise make metadata checks inspect a different checkout.
[ "$(realpath -e "$workspace")" = "$workspace" ] || continue
pr=$(cat "$enrollment")
[[ "$pr" =~ ^https://github\.com/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/pull/[0-9]+$ ]] || continue
if ! result=$(gh pr view "$pr" --json state,headRefOid --jq '[.state,.headRefOid] | @tsv'); then
echo "Cannot look up $pr; retaining $workspace"; continue
fi
read -r state head <<< "$result"
[ "$state" = MERGED ] && [[ "$head" =~ ^[0-9a-f]{40}$ ]] || continue
# Nested repositories and submodules have their own changes; retain their container.
nested=$(find "$workspace" -mindepth 1 \( -path "$workspace/.git" -o -path "$workspace/.jj" \) -prune -o \
\( -name .git -o -name .jj \) -print -quit)
[ -z "$nested" ] || continue
if [ -d "$workspace/.jj" ]; then
# Shared repo storage must live outside the directory being removed.
[ -f "$workspace/.jj/repo" ] && [ ! -L "$workspace/.jj" ] || continue
repo_pointer=$(cat "$workspace/.jj/repo")
repo_path=$(realpath -e "$workspace/.jj/$repo_pointer") || continue
case "$repo_path/" in "$workspace/"*) continue ;; esac
# Snapshot first, so edits made after the last jj command cannot be discarded.
if "$dry_run"; then
echo "Would snapshot $workspace, check for edits and match its parent to $head; forget and remove only if clean"
continue
fi
diff=$(jj -R "$workspace" diff --summary) || continue
[ -z "$diff" ] || continue
parent=$(jj -R "$workspace" log --no-graph -r @- -T 'commit_id') || continue
[ "$parent" = "$head" ] || continue
# Ignored files are disposable only because the maintainer explicitly enrolled this workspace.
busy && break
if ! "$dry_run"; then jj -R "$workspace" workspace forget || continue; fi
elif [ -d "$workspace/.git" ] && [ ! -L "$workspace/.git" ]; then
status=$(git -C "$workspace" status --porcelain --untracked-files=all) || continue
[ -z "$status" ] || continue
current=$(git -C "$workspace" rev-parse HEAD) || continue
[ "$current" = "$head" ] || continue
# A clone with other worktrees owns metadata they need; retain it.
worktrees=$(git -C "$workspace" worktree list --porcelain | awk '/^worktree / { n++ } END {print n}')
[ "$worktrees" = 1 ] || continue
else
echo "Retaining unsupported workspace $workspace"; continue
fi
remove "$workspace"
if ! "$dry_run" && [ ! -e "$workspace" ]; then rm -- "$enrollment"; fi
done
printf 'Cleanup freed %s KiB\n' "$freed"
Loading
Loading