Last active
September 12, 2025 09:23
-
-
Save RichardTMiles/02e4fb243aabfc3c99e4b20eb412c121 to your computer and use it in GitHub Desktop.
Setup Github Actions on a Proxmox Server
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Proxmox "GOD SCRIPT" — idempotent, hands-free GitHub runner VM | |
| # - Ensures storage/snippets | |
| # - Downloads Ubuntu cloud image | |
| # - Creates/repairs VM (disk flags, cloud-init, boot order) | |
| # - Resizes VM disk and auto-grows root FS on first boot | |
| # - Injects SSH key | |
| # - Writes cloud-init user-data that installs qemu-guest-agent + GitHub Actions runner | |
| # - Starts VM and verifies runner service via guest agent | |
| set -uo pipefail | |
| # ===== CONFIG (override via env) ===== | |
| VMID="${VMID:-900}" | |
| VMNAME="${VMNAME:-github-runner}" | |
| MEM_MB="${MEM_MB:-4096}" | |
| CORES="${CORES:-2}" | |
| SOCKETS="${SOCKETS:-1}" | |
| CPU_TYPE="${CPU_TYPE:-host}" | |
| BRIDGE="${BRIDGE:-vmbr0}" | |
| STORAGE="${STORAGE:-local-lvm}" | |
| CLOUDINIT_CTLR="${CLOUDINIT_CTLR:-ide2}" | |
| SCSIHW="${SCSIHW:-virtio-scsi-pci}" | |
| OS_TYPE="${OS_TYPE:-l26}" | |
| AGENT_ENABLED="${AGENT_ENABLED:-1}" | |
| CI_USER="${CI_USER:-ubuntu}" | |
| # Disk size to *target* (not increment) before first boot | |
| DISK_SIZE="${DISK_SIZE:-20G}" | |
| # Ubuntu cloud image (jammy). Switch to noble by overriding IMG_URL/FILE. | |
| IMG_URL="${IMG_URL:-https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img}" | |
| IMG_FILE="${IMG_FILE:-/var/lib/vz/template/iso/jammy-server-cloudimg-amd64.img}" | |
| # Networking (leave IP_CIDR empty for DHCP) | |
| IP_CIDR="${IP_CIDR:-}" # e.g. 192.168.1.200/24 | |
| GW_IP="${GW_IP:-}" # e.g. 192.168.1.1 | |
| NAMESERVER="${NAMESERVER:-}" # e.g. 1.1.1.1 | |
| # SSH key | |
| SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_rsa.pub}" | |
| # GitHub Runner (use fresh, short-lived registration token) | |
| GH_RUNNER_URL="${GH_RUNNER_URL:-}" # https://github.com/<org> OR https://github.com/<owner>/<repo> | |
| GH_RUNNER_TOKEN="${GH_RUNNER_TOKEN:-}" # registration token (NOT a PAT) | |
| GH_RUNNER_LABELS="${GH_RUNNER_LABELS:-proxmox,r640}" | |
| GH_RUNNER_DIR="${GH_RUNNER_DIR:-/opt/actions-runner}" | |
| # Cloud-init snippet | |
| SNIPPETS_DIR="${SNIPPETS_DIR:-/var/lib/vz/snippets}" | |
| CI_SNIPPET_NAME="${CI_SNIPPET_NAME:-user-data-${VMID}.yaml}" | |
| CI_SNIPPET_PATH="${SNIPPETS_DIR}/${CI_SNIPPET_NAME}" | |
| # Verification / timeouts | |
| VERIFY_RUNNER="${VERIFY_RUNNER:-1}" # 1 = wait for service | |
| VERIFY_TIMEOUT_SEC="${VERIFY_TIMEOUT_SEC:-180}" | |
| # ===== Helpers ===== | |
| log(){ printf "\033[1;36m[%s]\033[0m %s\n" "$(date +%H:%M:%S)" "$*"; } | |
| warn(){ printf "\033[1;33m[WARN]\033[0m %s\n" "$*"; } | |
| err(){ printf "\033[1;31m[ERR ]\033[0m %s\n" "$*" >&2; } | |
| qm_exists(){ qm status "$1" &>/dev/null; } | |
| qm_running(){ [[ "$(qm status "$1" 2>/dev/null | awk '{print $2}')" == running ]]; } | |
| disk_attached(){ qm config "$1" 2>/dev/null | grep -q '^scsi0:'; } | |
| ci_attached(){ qm config "$1" 2>/dev/null | grep -q "^${CLOUDINIT_CTLR}: .*cloudinit"; } | |
| boot_ok(){ qm config "$1" 2>/dev/null | grep -q '^boot: .*scsi0'; } | |
| image_present(){ [[ -f "$IMG_FILE" ]]; } | |
| storage_has_disk(){ pvesm list "$STORAGE" 2>/dev/null | awk '{print $1}' | grep -q "^$STORAGE:vm-${VMID}-disk-0$"; } | |
| needs_discard_flag(){ | |
| local l; l="$(qm config "$1" 2>/dev/null | sed -n 's/^scsi0: //p')" | |
| [[ -n "$l" ]] || return 1 | |
| echo "$l" | grep -q 'discard=on' && echo "$l" | grep -q 'ssd=1' && return 1 | |
| return 0 | |
| } | |
| ensure_dir(){ [[ -d "$1" ]] || { log "mkdir $1"; mkdir -p "$1" || { err "mkdir $1 failed"; exit 1; }; }; } | |
| # ===== Steps ===== | |
| ensure_snippets(){ | |
| # Ensure local storage allows snippets | |
| if ! pvesm status | awk '{print $1" "$2" "$3}' | grep -q '^local '; then | |
| warn "Storage 'local' not found; cicustom may fail if no snippets storage is available." | |
| fi | |
| pvesm set local --content iso,vztmpl,backup,rootdir,images,snippets >/dev/null 2>&1 || true | |
| } | |
| download_image(){ | |
| if image_present; then log "Cloud image present: $IMG_FILE"; return; fi | |
| ensure_dir "$(dirname "$IMG_FILE")"; log "Downloading cloud image → $IMG_FILE"; | |
| command -v wget >/dev/null || { err "wget missing (apt-get install -y wget)"; exit 1; } | |
| wget -O "$IMG_FILE" "$IMG_URL" || { err "download failed"; exit 1; } | |
| } | |
| write_ci_snippet(){ | |
| ensure_dir "$SNIPPETS_DIR" | |
| cat >"$CI_SNIPPET_PATH" <<EOF | |
| #cloud-config | |
| users: | |
| - default | |
| - name: ${CI_USER} | |
| sudo: ALL=(ALL) NOPASSWD:ALL | |
| shell: /bin/bash | |
| ssh_authorized_keys: | |
| $( if [[ -f "$SSH_PUBKEY_FILE" ]]; then sed 's/^/ - /' "$SSH_PUBKEY_FILE"; else echo " -"; fi ) | |
| package_update: true | |
| packages: [curl, tar, ca-certificates, qemu-guest-agent, cloud-guest-utils] | |
| # Ensure the guest grows its root filesystem to the resized virtual disk | |
| growpart: true | |
| resize_rootfs: true | |
| # Extra belt-and-suspenders in case the distro module skips: | |
| bootcmd: | |
| - [ sh, -xc, "mkdir -p /run/growtmp && mount -t tmpfs -o size=64m tmpfs /run/growtmp || true" ] | |
| - [ sh, -xc, "TMPDIR=/run/growtmp growpart /dev/sda 1 || true" ] | |
| - [ sh, -xc, "resize2fs /dev/sda1 || true" ] | |
| write_files: | |
| - path: /usr/local/bin/gh-runner-install.sh | |
| permissions: '0755' | |
| owner: root:root | |
| content: | | |
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # Allow env overrides at runtime | |
| URL="\${URL:-${GH_RUNNER_URL}}" | |
| TOKEN="\${TOKEN:-${GH_RUNNER_TOKEN}}" | |
| LABELS="\${LABELS:-${GH_RUNNER_LABELS}}" | |
| RUNNER_DIR="\${RUNNER_DIR:-${GH_RUNNER_DIR}}" | |
| USER="\${USER:-${CI_USER}}" | |
| if [[ -z "\${URL}" || -z "\${TOKEN}" ]]; then | |
| echo "[WARN] Missing GH vars; skipping runner install"; exit 0; fi | |
| # Map architecture | |
| case "\$(uname -m)" in | |
| x86_64) runner_arch="x64" ;; | |
| aarch64|arm64) runner_arch="arm64" ;; | |
| *) echo "[ERR] Unsupported arch: \$(uname -m)"; exit 1 ;; | |
| esac | |
| mkdir -p "\${RUNNER_DIR}"; chown -R "\${USER}:\${USER}" "\${RUNNER_DIR}"; cd "\${RUNNER_DIR}" | |
| # Resolve latest runner without jq (redirect), with API fallback | |
| latest_url="\$(curl -ILs -o /dev/null -w '%{url_effective}' https://github.com/actions/runner/releases/latest)" | |
| version="\${latest_url##*/tag/}" | |
| DL_URL="https://github.com/actions/runner/releases/download/\${version}/actions-runner-linux-\${runner_arch}-\${version#v}.tar.gz" | |
| if [[ -z "\${version}" || -z "\${DL_URL}" ]]; then | |
| DL_URL="\$(curl -fsSL -H 'User-Agent: curl' -H 'Accept: application/vnd.github+json' \ | |
| https://api.github.com/repos/actions/runner/releases/latest \ | |
| | grep -o "https://[^\\\"]*actions-runner-linux-\${runner_arch}-[0-9.]*\\.tar\\.gz" | head -n1)" | |
| fi | |
| [ -n "\${DL_URL}" ] || { echo "[ERR] Could not resolve runner URL"; exit 1; } | |
| # Download & extract as target user | |
| su - "\${USER}" -c "cd '\${RUNNER_DIR}' && curl -fsSL -o actions-runner.tar.gz '\${DL_URL}' && tar xzf actions-runner.tar.gz && rm actions-runner.tar.gz" | |
| # Optional native deps | |
| if [[ -f ./bin/installdependencies.sh ]]; then ./bin/installdependencies.sh || true; fi | |
| # Register service | |
| su - "\${USER}" -c "cd '\${RUNNER_DIR}' && ./config.sh --unattended --replace --url '\${URL}' --token '\${TOKEN}' --labels '\${LABELS}' --name \$(hostname)" | |
| ./svc.sh install; ./svc.sh start | |
| runcmd: | |
| - [ systemctl, start, qemu-guest-agent ] # unit is static; start is enough | |
| - [ bash, -lc, "/usr/local/bin/gh-runner-install.sh || true" ] # don't mark boot failed on transient issues | |
| EOF | |
| log "Wrote cloud-init snippet: $CI_SNIPPET_PATH" | |
| } | |
| create_or_update_vm(){ | |
| if qm_exists "$VMID"; then | |
| log "VM $VMID exists; ensuring settings" | |
| qm set "$VMID" --name "$VMNAME" --memory "$MEM_MB" --cores "$CORES" --sockets "$SOCKETS" --cpu "$CPU_TYPE" >/dev/null || true | |
| qm set "$VMID" --scsihw "$SCSIHW" >/dev/null || true | |
| qm set "$VMID" --agent enabled=${AGENT_ENABLED} >/dev/null || true | |
| qm set "$VMID" --net0 "virtio,bridge=${BRIDGE}" >/dev/null || true | |
| else | |
| log "Creating VM $VMID ($VMNAME)" | |
| qm create "$VMID" --name "$VMNAME" --memory "$MEM_MB" --cores "$CORES" --sockets "$SOCKETS" --cpu "$CPU_TYPE" \ | |
| --net0 "virtio,bridge=${BRIDGE}" --scsihw "$SCSIHW" --${CLOUDINIT_CTLR} "${STORAGE}:cloudinit" \ | |
| --serial0 socket --vga serial0 --ostype "$OS_TYPE" --agent enabled=${AGENT_ENABLED} || { err "qm create failed"; exit 1; } | |
| fi | |
| # Attach snippet | |
| qm set "$VMID" --cicustom "user=local:snippets/$(basename "$CI_SNIPPET_PATH")" >/dev/null || warn "Could not set cicustom; ensure 'local' allows snippets" | |
| } | |
| import_and_attach_disk(){ | |
| if ! storage_has_disk; then | |
| log "Importing cloud image as VM disk into $STORAGE" | |
| qm importdisk "$VMID" "$IMG_FILE" "$STORAGE" || { err "importdisk failed"; exit 1; } | |
| else | |
| log "Disk already imported" | |
| fi | |
| local disk_path="${STORAGE}:vm-${VMID}-disk-0" | |
| if disk_attached "$VMID"; then | |
| if needs_discard_flag "$VMID"; then | |
| log "Updating scsi0 flags" | |
| qm set "$VMID" --scsi0 "${disk_path},discard=on,ssd=1" || { err "set scsi0 failed"; exit 1; } | |
| else | |
| log "scsi0 OK" | |
| fi | |
| else | |
| log "Attaching scsi0" | |
| qm set "$VMID" --scsi0 "${disk_path},discard=on,ssd=1" || { err "attach scsi0 failed"; exit 1; } | |
| fi | |
| # Grow virtual disk to target size BEFORE first boot | |
| log "Resizing scsi0 to ${DISK_SIZE}" | |
| qm resize "$VMID" scsi0 "${DISK_SIZE}" || warn "qm resize failed (check storage type/permissions)" | |
| } | |
| configure_network_and_boot(){ | |
| if [[ -n "$IP_CIDR" ]]; then | |
| local ipcfg="ip=${IP_CIDR}"; [[ -n "$GW_IP" ]] && ipcfg+=",gw=${GW_IP}" | |
| log "Static net: $ipcfg"; qm set "$VMID" --ipconfig0 "$ipcfg" >/dev/null || warn "ipconfig0 failed" | |
| else | |
| log "DHCP net"; qm set "$VMID" --ipconfig0 "ip=dhcp" >/dev/null || warn "ipconfig0 failed" | |
| fi | |
| [[ -n "$NAMESERVER" ]] && qm set "$VMID" --nameserver "$NAMESERVER" >/dev/null || true | |
| [[ -f "$SSH_PUBKEY_FILE" ]] && qm set "$VMID" --sshkeys "$SSH_PUBKEY_FILE" >/dev/null || warn "No SSH key at $SSH_PUBKEY_FILE" | |
| boot_ok "$VMID" || qm set "$VMID" --boot "order=scsi0;${CLOUDINIT_CTLR}" >/dev/null || warn "boot order set failed" | |
| } | |
| force_first_boot(){ | |
| # Ensure cloud-init will run runner installation on next boot | |
| log "Resetting cloud-init for fresh first-boot" | |
| qm cloudinit reset "$VMID" >/dev/null 2>&1 || true | |
| } | |
| start_vm(){ | |
| if qm_running "$VMID"; then log "VM already running"; else log "Starting VM"; qm start "$VMID" || { err "qm start failed"; exit 1; }; fi | |
| } | |
| wait_for_runner(){ | |
| [[ "$VERIFY_RUNNER" == 1 ]] || { log "Skipping runner verification"; return; } | |
| log "Waiting for guest agent and runner service (timeout ${VERIFY_TIMEOUT_SEC}s)" | |
| local t=0 step=5 | |
| # 1) Wait for qemu-guest-agent to respond | |
| while (( t < VERIFY_TIMEOUT_SEC )); do | |
| if qm guest ping "$VMID" >/dev/null 2>&1; then | |
| log "Guest agent is responsive" | |
| break | |
| fi | |
| sleep "$step"; t=$((t+step)) | |
| done | |
| if ! qm guest ping "$VMID" >/dev/null 2>&1; then | |
| warn "Guest agent did not become ready in time"; return | |
| fi | |
| # 2) Wait for actions runner to be active (parse out-data without jq) | |
| while (( t < VERIFY_TIMEOUT_SEC )); do | |
| status="$( | |
| qm guest exec "$VMID" --verbose -- bash -lc "systemctl is-active 'actions.runner*' || true" 2>/dev/null \ | |
| | sed -n 's/^out-data: //p' | tr -d '\0' | |
| )" | |
| if echo "$status" | grep -q '^active'; then | |
| log "Runner service is ACTIVE" | |
| return | |
| fi | |
| sleep "$step"; t=$((t+step)) | |
| done | |
| warn "Runner service not confirmed within timeout. | |
| Check inside VM: | |
| systemctl status 'actions.runner*' --no-pager | |
| tail -n +200 /var/log/cloud-init-output.log" | |
| } | |
| # ===== RUN ===== | |
| log "== GOD SCRIPT: ensure VM ${VMID} (${VMNAME}) ==" | |
| ensure_snippets | |
| download_image | |
| write_ci_snippet | |
| create_or_update_vm | |
| import_and_attach_disk | |
| configure_network_and_boot | |
| force_first_boot | |
| start_vm | |
| wait_for_runner | |
| log "Done. View: qm config ${VMID}; console: qm terminal ${VMID}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment