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:
parent
e6f9f1f9f9
commit
f363db42c7
4 changed files with 159 additions and 0 deletions
13
.env.example
13
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
94
scripts/refresh-runner-image.sh
Executable 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"
|
||||
10
setup.sh
10
setup.sh
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue