Stop unattended-upgrades from cancelling GitHub Actions jobs; patch warm-pool runners#287
Merged
Merged
Conversation
…arm-pool runners Two related fixes for the self-hosted actions-runner fleet, both scoped to the runner role (development, sandbox, and global copies kept in sync, as #277 did). The design notes live in .claude/plans/TASK-runner-unattended-upgrades-cancels-jobs.md. 1. unattended-upgrades no longer cancels running jobs. Ubuntu's daily unattended-upgrades runs a needrestart pass that restarts every service linked against an updated library -- including actions-runner.service. Restarting the runner mid-job cancels the running GitHub job (the job dies; the instance stays healthy), and a job killed mid `terraform apply` leaks test infrastructure. The kill point is random because apt-daily-upgrade.timer has a large RandomizedDelaySec. profile::github_runner::service now: - drops /etc/needrestart/conf.d/90-actions-runner.conf with a TARGETED override ($nrconf{override_rc} => 0 for actions-runner.service only), so patching continues and every other service still auto-restarts -- only the runner is exempt; and - installs gha-restart-when-idle.timer, which restarts the runner to load the patched libraries only when it is idle (no Runner.Worker) AND a restart is actually pending (a "(deleted)" library mapping in the process tree) -- ground truth that does not depend on how needrestart lists an exempt service. This deliberately lives in the runner role, not the shared profile::unattended_upgrades, so it cannot collide with the Elasticsearch needrestart drop-in (profile::elastic::service): the two roles never share a host, each owns a distinctly-named conf.d file, and needrestart is declared via ensure_packages. Elastic uses a GLOBAL list-only mode; the runner uses a targeted per-service override -- different mechanisms, no conflict. 2. Warm-pool runners are patched before they hibernate. The runner ASG uses a Hibernated warm pool. A hibernated instance never runs the daily upgrade timer, and a hibernation resume is not a boot (systemd boot units do not re-run), so pooled instances would serve jobs on stale libraries. profile::github_runner now applies pending security upgrades once during provisioning -- before the instance hibernates -- via a per-boot exec (tmpfs marker, written only on success so a failure retries). Fresh launches driven by max_instance_lifetime re-run it, bounding staleness. Running it at provisioning (not at resume) keeps the fast hibernation resume untouched. Not yet exercised on a live runner: the /proc/maps, pgrep and hibernation mechanics are Linux-only. puppet-lint, shellcheck and perl-syntax all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
infrahouse8
approved these changes
Jul 22, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related fixes for the self-hosted actions-runner fleet, scoped to the runner role and synced across development, sandbox, and global copies (as #277 did). Design notes:
.claude/plans/TASK-runner-unattended-upgrades-cancels-jobs.md.1. unattended-upgrades no longer cancels running jobs
Symptom: GitHub Actions jobs on the runner fleet get cancelled mid-run (
The operation was canceled) at random points. The runner instance stays healthy — it's the job that dies. Jobs killed mid-terraform applyleak AWS test resources.Cause (from the runner's journal): Ubuntu's daily unattended-upgrades runs a
needrestartpass that restarts every service linked against an updated library — includingactions-runner.service. Restarting the runner mid-job cancels the job. Random kill point =apt-daily-upgrade.timer's largeRandomizedDelaySec.Fix (
profile::github_runner::service):/etc/needrestart/conf.d/90-actions-runner.conf— a targeted$nrconf{override_rc} => 0foractions-runner.serviceonly. Patching continues, every other service still auto-restarts; only the runner is exempt.gha-restart-when-idle.timer— restarts the runner to load the patched libraries, but only when it's idle (noRunner.Worker) and a restart is actually pending (a(deleted)library mapping in the runner's process tree — ground truth, independent of how needrestart lists an exempted service). Re-checks idle right before acting.2. Warm-pool runners are patched before they hibernate
The runner ASG uses a Hibernated warm pool. A hibernated instance never runs the daily upgrade timer, and a hibernation resume is not a boot (systemd boot units don't re-run), so pooled instances would resume and serve jobs on stale libraries.
Fix (
profile::github_runner): apply pending security upgrades once during provisioning, before the instance hibernates, via a per-bootexec(tmpfs marker, written only on success so a failure retries). Fresh launches driven bymax_instance_lifetimere-run it, bounding staleness. Doing it at provisioning — not at resume — keeps the fast hibernation resume untouched.Does not conflict with Elasticsearch (verified)
profile::elastic::servicealso manages needrestart, so this was a deliberate concern:profile::unattended_upgrades— never lands on ES nodes.90-actions-runner.confvs elastic's90-elastic-no-auto-restart.conf).needrestartdeclared viastdlib::ensure_packages(not a barepackage{}) — no duplicate-declaration collision.$nrconf{restart} = 'l'; the runner uses a targeted per-service override. Different mechanisms, and the two roles never share a host.Testing
puppet-lint --fail-on-warningsclean on all four module paths;shellcheckclean on the idle-restart script; the needrestart drop-in validated as parseable Perl with the regex confirmed to matchactions-runner.service.Not yet exercised on a live runner —
/proc/maps,pgrep, and hibernation mechanics are Linux-only. The real validation is: trigger an upgrade during a job on a runner and confirm the job survives, noStopping actions-runner.servicein the journal, and an idle runner with pending patches gets restarted.One thing to weigh
The provisioning
exechastimeout => 1200, which equals the ASG bootstrap lifecycle hook (heartbeat_timeout = 1200,default_result = ABANDON). The upgrade shares that provisioning budget; a weeks-stale AMI with a large security backlog could compete with the hook. Usually a non-issue on a fresh AMI (seconds–minutes), but worth capping the timeout lower if provisioning is tight.🤖 Generated with Claude Code