#!/usr/bin/env bash # # provision-gitea-runner.sh — one-shot, idempotent host setup for an # act_runner LXC. Speeds up Gitea Actions runs by: # # 1. Disabling forced docker pulls (image refresh moves to a cron). # 2. Mounting persistent host volumes for Go module/build caches and # the act-actions clone cache. # 3. Pre-pulling the runner-images container image. # 4. Pre-cloning a configurable list of GitHub actions into the # act cache so jobs don't fetch them on every run. # 5. Installing golangci-lint (latest v2.x) at /usr/local/bin. # 6. Setting up a nightly cron to refresh image + action clones + # golangci-lint. # # The script is generic — no per-project state. Point it at any LXC # running act_runner as a systemd service and it will provision the # host. Re-runs are safe; they reconcile state. # # Usage: sudo ./provision-gitea-runner.sh # # Configurable via environment variables (defaults shown): # # CACHE_BASE=/var/cache/gitea-runner # ACT_RUNNER_CONFIG=/etc/act_runner/config.yaml # RUNNER_IMAGE=docker.gitea.com/runner-images:ubuntu-latest # ACTIONS_TO_PRECLONE=(actions/checkout@v4 actions/setup-go@v5 # actions/upload-artifact@v4 # golangci/golangci-lint-action@v7) # # To add more pre-cloned actions later, edit /etc/cron.d/gitea-runner-refresh # (the ACTIONS list is materialised into the cron script). set -euo pipefail # ---------- defaults --------------------------------------------------- : "${CACHE_BASE:=/var/cache/gitea-runner}" : "${ACT_RUNNER_CONFIG:=/etc/act_runner/config.yaml}" : "${RUNNER_IMAGE:=docker.gitea.com/runner-images:ubuntu-latest}" DEFAULT_ACTIONS=( "actions/checkout@v4" "actions/setup-go@v5" "actions/upload-artifact@v4" "golangci/golangci-lint-action@v7" ) # Allow caller to override by exporting ACTIONS_TO_PRECLONE as a # space-separated string (env vars can't carry arrays cleanly). if [[ -n "${ACTIONS_TO_PRECLONE:-}" ]]; then read -r -a ACTIONS <<<"${ACTIONS_TO_PRECLONE}" else ACTIONS=("${DEFAULT_ACTIONS[@]}") fi # ---------- helpers ---------------------------------------------------- log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m==>\033[0m %s\n' "$*" >&2; exit 1; } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing: $1 (install it first)" } # sha256_url — act_runner names its action-clone dirs after # sha256(URL). Verified against a real run log: # url=https://github.com/actions/checkout # sha256=c3fe249fe73091a17d6638fe1341e7bd0bcc3466ce52323c0688e83e2463a4ab sha256_url() { printf '%s' "$1" | sha256sum | awk '{print $1}' } # ---------- pre-flight ------------------------------------------------- [[ $EUID -eq 0 ]] || die "run as root (the act_runner service writes /var/lib/act_runner as root)" require_cmd systemctl require_cmd docker require_cmd git require_cmd curl require_cmd python3 # PyYAML for the config edit. Install if missing — Ubuntu 24.04 ships # python3-yaml in the default repos. if ! python3 -c 'import yaml' 2>/dev/null; then log "installing python3-yaml (needed for safe YAML edits)" apt-get update -qq apt-get install -y -qq python3-yaml fi [[ -f "$ACT_RUNNER_CONFIG" ]] || die "$ACT_RUNNER_CONFIG not found — is act_runner installed?" systemctl list-unit-files act_runner.service >/dev/null 2>&1 || \ die "act_runner.service not found — register the runner first" log "pre-flight OK" log " cache base : $CACHE_BASE" log " config file : $ACT_RUNNER_CONFIG" log " runner image : $RUNNER_IMAGE" log " actions to clone : ${ACTIONS[*]}" # ---------- 1. cache directories --------------------------------------- log "creating cache directories under $CACHE_BASE" for sub in go-mod go-build act-actions; do install -d -m 0755 -o root -g root "$CACHE_BASE/$sub" done # ---------- 2. edit /etc/act_runner/config.yaml ------------------------ # # Three keys are reconciled to known values: # # container.force_pull : false (we keep the image fresh via cron) # container.options : "-v " (auto-mount caches # into every job container) # container.valid_volumes: [] (whitelist so the # container.options mounts are accepted) # # Other keys are preserved verbatim. The edit is idempotent: re-running # yields the same file content. log "patching $ACT_RUNNER_CONFIG" # Backup once (only if no .pre-provision backup exists yet). if [[ ! -f "${ACT_RUNNER_CONFIG}.pre-provision" ]]; then cp -p "$ACT_RUNNER_CONFIG" "${ACT_RUNNER_CONFIG}.pre-provision" log " saved pristine copy to ${ACT_RUNNER_CONFIG}.pre-provision" fi CONTAINER_OPTIONS_VALUE="-v ${CACHE_BASE}/go-mod:/root/go/pkg/mod:rw -v ${CACHE_BASE}/go-build:/root/.cache/go-build:rw -v ${CACHE_BASE}/act-actions:/root/.cache/act:rw" CACHE_BASE="$CACHE_BASE" CONTAINER_OPTIONS_VALUE="$CONTAINER_OPTIONS_VALUE" \ ACT_RUNNER_CONFIG="$ACT_RUNNER_CONFIG" \ python3 - <<'PY' import os, sys, yaml cfg_path = os.environ['ACT_RUNNER_CONFIG'] cache_base = os.environ['CACHE_BASE'] container_options = os.environ['CONTAINER_OPTIONS_VALUE'] with open(cfg_path) as f: cfg = yaml.safe_load(f) or {} cfg.setdefault('container', {}) cfg['container']['force_pull'] = False cfg['container']['options'] = container_options # Whitelist every cache subdir explicitly so jobs that try to bind-mount # them via workflow-side `volumes:` (rare but possible) are accepted. desired_vols = [ f"{cache_base}/go-mod", f"{cache_base}/go-build", f"{cache_base}/act-actions", ] existing = cfg['container'].get('valid_volumes') or [] merged = list(dict.fromkeys(existing + desired_vols)) # de-dup, preserve order cfg['container']['valid_volumes'] = merged # Write back with stable formatting. yaml.dump preserves enough # structure for act_runner to parse; comments in the original config # do get stripped — that's why we preserve the .pre-provision backup. with open(cfg_path + '.tmp', 'w') as f: yaml.safe_dump(cfg, f, default_flow_style=False, sort_keys=False) os.replace(cfg_path + '.tmp', cfg_path) print(f" container.force_pull : false") print(f" container.options : {container_options}") print(f" container.valid_volumes: {merged}") PY # ---------- 3. pre-pull the runner image ------------------------------- log "pulling $RUNNER_IMAGE (one-time; cron refreshes it nightly)" docker pull "$RUNNER_IMAGE" # ---------- 4. pre-clone the actions list ------------------------------ # # act_runner expects clones at $cache/ with the ref already # checked out. We clone the default branch then fetch + check out the # requested ref. Re-running fetches updates rather than re-cloning. log "pre-cloning actions into $CACHE_BASE/act-actions" for spec in "${ACTIONS[@]}"; do if [[ "$spec" != *@* ]]; then warn " skip '$spec' — must be owner/repo@ref" continue fi repo="${spec%@*}" ref="${spec##*@}" url="https://github.com/${repo}" dir="${CACHE_BASE}/act-actions/$(sha256_url "$url")" if [[ -d "$dir/.git" ]]; then log " refresh $repo @ $ref" git -C "$dir" fetch --quiet --tags --prune origin else log " clone $repo @ $ref → $dir" git clone --quiet "$url" "$dir" fi # Detach onto the requested ref. Works for branches, tags, and SHAs. if ! git -C "$dir" -c advice.detachedHead=false checkout --quiet "$ref" 2>/dev/null; then # If `ref` is a remote branch we haven't tracked yet, try origin/. git -C "$dir" -c advice.detachedHead=false checkout --quiet "origin/$ref" fi done # ---------- 5. golangci-lint ------------------------------------------- # # Install the latest v2.x at /usr/local/bin/golangci-lint. Workflows # that pin a specific version via the action's `version:` arg will # still re-download — but jobs that don't pin (or pin to "latest"/"v2") # get the host-installed binary for free. log "installing/updating golangci-lint (latest v2.x) → /usr/local/bin" GOLANGCI_INSTALL_URL="https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh" # `-b` = install dir, `-d` = quiet "downloading" lines, no version arg # means "latest" — which install.sh resolves to the latest v2 release # from GitHub releases. curl -fsSL "$GOLANGCI_INSTALL_URL" | sh -s -- -b /usr/local/bin >/dev/null /usr/local/bin/golangci-lint --version || warn "golangci-lint install verification failed" # ---------- 6. nightly refresh cron ------------------------------------ # # Re-pulls the runner image, refreshes the action clones, and updates # golangci-lint. Runs at 03:17 to dodge top-of-hour CI bursts. CRON_PATH=/etc/cron.d/gitea-runner-refresh REFRESH_SCRIPT=/usr/local/sbin/gitea-runner-refresh log "writing $REFRESH_SCRIPT and $CRON_PATH" # Materialise the actions list into the script so the cron is # self-contained and surviving an edit to this file. ACTIONS_LITERAL="" for s in "${ACTIONS[@]}"; do ACTIONS_LITERAL="${ACTIONS_LITERAL} \"$s\"\n" done cat >"$REFRESH_SCRIPT" </dev/null # 2. Refresh action clones. for spec in "\${ACTIONS[@]}"; do [[ "\$spec" == *@* ]] || continue repo="\${spec%@*}"; ref="\${spec##*@}" url="https://github.com/\$repo" dir="\$CACHE_BASE/act-actions/\$(sha256_url "\$url")" if [[ -d "\$dir/.git" ]]; then git -C "\$dir" fetch --quiet --tags --prune origin || true git -C "\$dir" -c advice.detachedHead=false checkout --quiet "\$ref" 2>/dev/null \\ || git -C "\$dir" -c advice.detachedHead=false checkout --quiet "origin/\$ref" || true fi done # 3. Refresh golangci-lint (latest v2.x). Tolerate transient # GitHub-rate-limit failures — next night will retry. curl -fsSL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \\ | sh -s -- -b /usr/local/bin >/dev/null 2>&1 || true EOF chmod 0755 "$REFRESH_SCRIPT" cat >"$CRON_PATH" <> /var/log/gitea-runner-refresh.log 2>&1 EOF chmod 0644 "$CRON_PATH" # ---------- 7. restart act_runner -------------------------------------- log "restarting act_runner.service to pick up the new config" systemctl restart act_runner.service sleep 2 systemctl is-active --quiet act_runner.service \ || die "act_runner did not come back up — check 'journalctl -u act_runner -n 50'" # ---------- 8. container-create benchmark ------------------------------ # # Reports cold + warm `docker run --rm true` time. Sanity check # that overlay setup is fast on this host. Numbers > ~5s indicate a # slow filesystem or DNS issue worth investigating separately. log "benchmark: docker run --rm $RUNNER_IMAGE true" { printf ' cold (post-pull) : ' /usr/bin/time -f '%e s' docker run --rm "$RUNNER_IMAGE" true 2>&1 | tail -1 printf ' warm (immediate) : ' /usr/bin/time -f '%e s' docker run --rm "$RUNNER_IMAGE" true 2>&1 | tail -1 } || warn "benchmark failed — non-fatal" # ---------- done ------------------------------------------------------- cat < Provisioning complete\033[0m What changed on this host: * /etc/act_runner/config.yaml — force_pull off, container.options + valid_volumes set for the cache mounts. Pristine copy preserved at ${ACT_RUNNER_CONFIG}.pre-provision. * $CACHE_BASE/{go-mod,go-build,act-actions} — persistent caches. * /usr/local/bin/golangci-lint — latest v2.x. * $REFRESH_SCRIPT and $CRON_PATH — nightly refresh @ 03:17. * Runner image pre-pulled. \033[1;33mNote on Go cache + setup-go:\033[0m if your workflow uses \`actions/setup-go\` with \`cache: true\`, the action will still tar/untar the cache via the Gitea cache backend on every job — partially defeating the persistent volume. For full speed-up, drop \`cache: true\` from the workflow once the persistent volume is warm. Per-project decision; this script doesn't touch workflows. EOF