feat(runner): auto-rebuild runner image when consumer yarn.lock changes

Drops the manual refresh dance. A new `runner-refresh` compose service
polls Forgejo every RUNNER_REFRESH_INTERVAL seconds (default 300),
fetches yarn.lock + package.json from each repo in
RUNNER_CACHE_SEED_REPOS, hashes them, and rebuilds
forgejo-stack/job:latest whenever the hash changes. `docker create`
on the rebuilt tag is automatic — forgejo-runner uses forcePull=false
so subsequent job containers pick up the refreshed image without a
runner restart.

- scripts/refresh-runner-image.sh: idempotent; hash-compares against
  cache-seed/.last-fetched.sha256 to skip rebuilds when nothing
  changed. Uses `sed` (not `grep -oP`) so it works under busybox
  inside docker:cli's alpine base.
- docker-compose.yml: adds the `runner-refresh` service (docker:cli
  + docker.sock + project bind-mount + bash/curl install). Idles
  via `sleep infinity` when RUNNER_CACHE_SEED_REPOS is unset, so
  the service is safe to leave running on stacks that don't pre-warm.
- setup.sh: one-time prime after Forgejo is healthy so fresh installs
  bake the cache before the runner takes its first job. Subsequent
  refreshes are driven by the service.
- .env.example: documents RUNNER_CACHE_SEED_REPOS and
  RUNNER_REFRESH_INTERVAL.

Verified end-to-end: pushed a yarn.lock mutation → refresh tick
detected diff → rebuilt image in ~25s → second tick reported
"lockfiles unchanged; image current".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Randa 2026-05-16 11:23:46 +04:00
parent e6f9f1f9f9
commit f363db42c7
4 changed files with 159 additions and 0 deletions

View file

@ -47,6 +47,19 @@ RUNNER_NAME=local-runner
# volume names used above.
# RUNNER_VALID_VOLUMES=forgejo-stack-hostedtoolcache
# ---------- Runner image yarn cache pre-warm ----------
# Comma-separated list of `<owner>/<repo>[@<branch>]` entries. The
# `runner-refresh` service polls each repo's yarn.lock + package.json and
# rebuilds forgejo-stack/job:latest whenever they change, so the in-image
# yarn offline cache stays current automatically. Branch defaults to the
# repo's default_branch. First-listed repo wins — its lockfile is what
# gets baked. Leave empty to disable pre-warm (the runner-refresh service
# idles in that case).
# RUNNER_CACHE_SEED_REPOS=forgejo_admin/exifcleaner-web
# Seconds between polls. 300 = 5 min. Lower for tighter latency on lockfile
# changes; bump if you don't want frequent docker builds.
# RUNNER_REFRESH_INTERVAL=300
# ---------- Internal network MTU ----------
# Default 1500 works on bare-metal LAN. Lower this when the host's default
# route goes through a VPN/tunnel (WireGuard typically ~1320-1420). Symptom of

View file

@ -147,6 +147,48 @@ services:
done
exec forgejo-runner daemon --config /data/config.yaml
# Watches the consumer repo's yarn.lock and rebuilds forgejo-stack/job:latest
# whenever it changes, so the in-image yarn offline cache stays current
# without manual intervention. Polls every RUNNER_REFRESH_INTERVAL seconds
# (default 300); a single iteration is ~1s when nothing changed, ~90s when
# rebuilding. Idle if RUNNER_CACHE_SEED_REPOS is unset.
runner-refresh:
image: docker:cli
restart: unless-stopped
depends_on:
forgejo:
condition: service_healthy
environment:
FORGEJO_BASE_URL: http://forgejo:3000
FORGEJO_ADMIN_TOKEN: ${FORGEJO_ADMIN_TOKEN}
RUNNER_CACHE_SEED_REPOS: ${RUNNER_CACHE_SEED_REPOS:-}
RUNNER_JOB_IMAGE: ${RUNNER_JOB_IMAGE:-forgejo-stack/job:latest}
INTERVAL: ${RUNNER_REFRESH_INTERVAL:-300}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Project dir mounted RW so the refresh script can update
# runner-image/cache-seed/ and feed it back into `docker build`.
- .:/stack
working_dir: /stack
networks: [internal]
entrypoint:
- /bin/sh
- -c
- |
set -e
# docker:cli is alpine-based and lacks bash + curl by default; the
# refresh script needs both. Install once per container lifetime.
apk add --no-cache bash curl >/dev/null
if [ -z "$$RUNNER_CACHE_SEED_REPOS" ]; then
echo "runner-refresh: RUNNER_CACHE_SEED_REPOS unset; idling"
exec sleep infinity
fi
while true; do
bash /stack/scripts/refresh-runner-image.sh || \
echo "runner-refresh: iteration failed; retrying in $$INTERVAL s"
sleep "$$INTERVAL"
done
forgejo-mcp:
image: ${FORGEJO_MCP_IMAGE}
restart: unless-stopped

94
scripts/refresh-runner-image.sh Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
# refresh-runner-image.sh — rebuild forgejo-stack/job:latest when any
# configured consumer repo's yarn.lock or package.json has changed.
#
# Idempotent: hashes the fetched files, no-ops when nothing changed since last
# run. Safe to invoke on a timer (see the `runner-refresh` compose service)
# or by hand. The image tag is overwritten in place — forgejo-runner pulls
# `:latest` from the local daemon on each `docker create`, so subsequent job
# containers pick up the rebuilt image without restarting the runner.
#
# Inputs (env):
# RUNNER_CACHE_SEED_REPOS Comma-separated `<owner>/<repo>[@<branch>]`. If
# unset, the script exits 0 — pre-warm disabled.
# Branch defaults to the repo's default_branch.
# FORGEJO_BASE_URL Internal URL to Forgejo. The compose service sets
# http://forgejo:3000; from the host, falls back to
# FORGEJO_ROOT_URL in .env.
# FORGEJO_ADMIN_TOKEN Token with read access to the seed repos.
# RUNNER_JOB_IMAGE Tag to (re)build. Default forgejo-stack/job:latest.
set -euo pipefail
TARGET="${RUNNER_JOB_IMAGE:-forgejo-stack/job:latest}"
REPOS="${RUNNER_CACHE_SEED_REPOS:-}"
BASE_URL="${FORGEJO_BASE_URL:-${FORGEJO_ROOT_URL:-http://localhost:3000}}"
BASE_URL="${BASE_URL%/}"
TOKEN="${FORGEJO_ADMIN_TOKEN:-}"
HERE="$(cd "$(dirname "$0")/.." && pwd)"
SEED_DIR="$HERE/runner-image/cache-seed"
STATE_FILE="$SEED_DIR/.last-fetched.sha256"
log() { printf '[refresh-runner-image] %s\n' "$*"; }
if [[ -z "$REPOS" ]]; then
log "RUNNER_CACHE_SEED_REPOS unset — nothing to do"
exit 0
fi
mkdir -p "$SEED_DIR"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
curl_auth=()
[[ -n "$TOKEN" ]] && curl_auth=(-H "Authorization: token $TOKEN")
# Fetch every (lockfile, package.json) pair under TMP/. Names are
# deterministic so the resulting concat hash is stable across runs.
ok_count=0
first_repo=""
for entry in ${REPOS//,/ }; do
repo="${entry%@*}"
branch="${entry#*@}"
[[ "$repo" == "$branch" ]] && branch=""
if [[ -z "$branch" ]]; then
# sed instead of `grep -oP` — busybox grep (alpine docker:cli) has no PCRE.
branch=$(curl -fsS "${curl_auth[@]}" "$BASE_URL/api/v1/repos/$repo" 2>/dev/null \
| sed -n 's/.*"default_branch":"\([^"]*\)".*/\1/p' \
| head -n1)
branch="${branch:-master}"
fi
[[ -z "$first_repo" ]] && first_repo="${repo//\//_}"
for f in yarn.lock package.json; do
if curl -fsS "${curl_auth[@]}" "$BASE_URL/$repo/raw/branch/$branch/$f" \
-o "$TMP/${repo//\//_}__$f" 2>/dev/null; then
ok_count=$((ok_count + 1))
fi
done
done
if [[ "$ok_count" -eq 0 ]]; then
log "no lockfiles reachable (forgejo down or repos empty) — skipping"
exit 0
fi
new_hash=$(find "$TMP" -type f | sort | xargs cat | sha256sum | awk '{print $1}')
old_hash=$(cat "$STATE_FILE" 2>/dev/null || echo none)
if [[ "$new_hash" == "$old_hash" ]]; then
log "lockfiles unchanged ($new_hash); image current"
exit 0
fi
# First-listed repo wins: its lockfile is what gets baked. Multi-repo support
# would need either a meta-package or a separate image per consumer.
cp "$TMP/${first_repo}__yarn.lock" "$SEED_DIR/yarn.lock"
cp "$TMP/${first_repo}__package.json" "$SEED_DIR/package.json"
log "lockfiles changed (was $old_hash, now $new_hash); rebuilding $TARGET"
docker build -t "$TARGET" "$HERE/runner-image/"
echo "$new_hash" > "$STATE_FILE"
log "rebuild done; next job container will use refreshed yarn cache"

View file

@ -101,6 +101,16 @@ docker compose up -d
log_info "waiting for Forgejo HTTP API"
wait_for_url "${FORGEJO_ROOT_URL%/}/api/v1/version" 180
# 6a. Prime the runner image's yarn offline cache from the configured consumer
# repo(s), if any. No-op when RUNNER_CACHE_SEED_REPOS is unset or when the
# repos don't exist yet (first-run before bootstrap creates them).
# The runner-refresh compose service keeps this current afterwards.
if [[ -n "${RUNNER_CACHE_SEED_REPOS:-}" ]]; then
log_info "priming runner image yarn cache (repos: $RUNNER_CACHE_SEED_REPOS)"
RUNNER_JOB_IMAGE="$JOB_IMAGE_TAG" bash "$HERE/scripts/refresh-runner-image.sh" || \
log_warn "initial cache prime failed; runner-refresh will retry"
fi
# 7. Run bootstrap
log_info "running bootstrap-forgejo.sh (mode=$MODE)"
MODE="$MODE" MCP_MODE="$MCP_MODE" bash "$HERE/scripts/bootstrap-forgejo.sh"