OneLinersCommand workbench
Guides
DevOps & CI/CD / Security

Install and secure a GitHub Actions self-hosted runner

Build a one-job Linux runner with restricted repository access, an unprivileged account, verified release, minimal network, external logs, immutable updates, fork isolation, and complete post-job destruction.

300 min11 stepsHigh-impact changeRevision 2
Save or explore
Save to collectionCreate a collection in the sidebar first.
0 of 11 steps completed
Goal

Provide custom trusted CI capacity without allowing fork or less-trusted workflow code to persist on a shared host, inherit secrets, reach broad internal networks, or silently outlive the runner update and teardown policy.

Supported environments
  • Ubuntu Server 24.04 LTS
  • GitHub Actions runner current supported release; maximum 30-day lag
  • GitHub Actions GitHub.com
Prerequisites
  • Trust classification Approved private repositories, events, contributors, actions, reusable workflows, secrets, environments, networks, and separate untrusted pull-request execution.
  • Disposable infrastructure Immutable Linux image, one-job VM and work disk, independent controller, hard TTL, capacity limits, and cryptographic image identity.
  • GitHub governance Organization owner, restricted runner group, repository allowlist, protected branches/environments, audit access, and API identity for JIT orchestration.
  • Least privilege Dedicated non-sudo user, no root-equivalent socket/groups, narrow outbound network, minimal GITHUB_TOKEN and federated identity policies.
  • Operations and response External logs, runner/update/lifecycle metrics, kill switch, hosted fallback, credential inventory, and compromise runbook.
Operating boundary

OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.

Full guide

What you will build

System
  • A dedicated Linux self-hosted GitHub Actions runner image and one-job ephemeral execution path under a non-login, non-sudo runner account with no root-equivalent container socket.
  • An organization runner group restricted to explicitly approved private repositories, precise labels, least-privilege workflow permissions, full-SHA action pins, and a separate untrusted pull-request path.
  • A JIT/ephemeral lifecycle with external logs, update governance, negative security tests, monitored autoscaling, complete disk destruction, and a safe hosted-runner fallback.
Observable outcome
  • Trusted branch or approved deployment jobs reach only the intended group and one runner processes exactly one job before automatic deregistration and destruction.
  • Fork and untrusted pull-request workflows cannot reach the runner, its internal network, caches, credentials, or persistent state.
  • Operators can verify runner provenance, connectivity, update age, external logs, work-volume disposal, routing policy, and emergency disablement without exposing registration/JIT data.

Architecture

How the parts fit together

A factory creates a patched immutable Linux VM for each trusted job. The host runs only the GitHub runner under a dedicated user and can reach GitHub plus a narrow set of approved build services. An organization runner group restricts repository access. A controller requests one-time registration or JIT configuration, boots the runner, forwards _diag and lifecycle logs externally, and destroys the VM and disk after one job. Untrusted pull-request validation uses GitHub-hosted runners or a completely separate disposable low-trust pool.

Runner image factoryBuilds and verifies the OS, current GitHub runner release, dependencies, account policy, certificates, logging, and network baseline.
Ephemeral/JIT runnerExecutes one trusted job as an unprivileged user, then deregisters and is destroyed with its work volume.
Runner groupLimits access to selected private repositories and combines group plus labels for deterministic job routing.
Autoscaler/controllerObtains short-lived registration/JIT material, creates capacity, observes job state, and enforces one-job teardown without logging credentials.
External telemetryReceives runner diagnostics, image/version identity, lifecycle events, and security alerts before an ephemeral instance disappears.
  1. A trusted workflow event passes repository, branch/environment, permission, and action-pin policy and requests a restricted group/label.
  2. The controller creates a clean instance and registers it with one-time ephemeral or JIT configuration.
  3. The runner receives exactly one job, fetches only reviewed dependencies, and reaches only approved build destinations.
  4. Logs are acknowledged externally, GitHub deregisters the runner, and the controller destroys compute and disk before returning capacity.
  5. Untrusted pull requests remain on GitHub-hosted or a separate low-trust disposable pool and never share networks, secrets, caches, or identities.

Assumptions

  • This guide targets an organization-owned private repository and a dedicated Linux runner image. Public repositories and fork-originated code do not run on this trusted pool.
  • The operator uses the current supported GitHub Actions runner release and rebuilds immutable images for every release within GitHub's 30-day window, immediately for critical updates.
  • The runner host is not a general server, deployment target, interactive workstation, or shared Docker host. It has console recovery and can be destroyed without preserving local state.
  • Secrets come only from approved GitHub environments or short-lived identity federation after trust checks. Registration tokens and encoded JIT configuration are never stored in source, argv examples, logs, or machine images.
  • The organization can forward diagnostics externally and has an approved GitHub-hosted or isolated low-trust fallback when self-hosted capacity is disabled.

Key concepts

Self-hosted runner
A machine operated by the organization that executes arbitrary workflow code with the permissions, network, files, and credentials exposed to its process.
Ephemeral runner
A runner registered for one job and automatically deregistered afterward. The infrastructure owner must still destroy its compute and storage.
Just-in-time runner
A single-job runner created from an encoded configuration returned by the GitHub REST API, suited to autoscaled immutable infrastructure.
Runner group
An organization or enterprise access boundary controlling which repositories may target a collection of runners.
Label
A routing attribute describing runner characteristics. Labels are not a security boundary without group/repository policy.
Untrusted workflow
Any job whose executable code, action, dependency, ref, or workflow definition can be influenced by a fork, external contributor, or less-trusted repository.
Root-equivalent socket
A service endpoint such as the Docker daemon socket that lets an unprivileged process control host-level workloads and normally obtain root-equivalent access.
Immutable runner image
A versioned read-only build artifact recreated through a reviewed pipeline rather than patched interactively or reused after jobs.

Before you copy

Values used in this guide

{{githubOrg}}

GitHub organization that owns the runner group.

Example: acme-platform
{{githubRepo}}

Approved private repository allowed to use the group.

Example: payments-api
{{runnerGroup}}

Restricted organization runner group name.

Example: trusted-linux-deploy
{{runnerLabel}}

Capability/trust label used with the runner group.

Example: linux-x64-ephemeral
{{runnerUser}}

Dedicated unprivileged local service account.

Example: actions-runner
{{runnerHome}}

Dedicated runner installation/home directory.

Example: /opt/actions-runner
{{runnerArchiveUrl}}

Official GitHub release archive URL copied from the current runner setup instructions.

Example: https://github.com/actions/runner/releases/download/vX.Y.Z/actions-runner-linux-x64-X.Y.Z.tar.gz
{{runnerArchive}}

Local filename for the verified runner release archive.

Example: actions-runner-linux-x64.tar.gz
{{runnerSha256}}

Official SHA-256 value published for the selected runner archive.

Example: 64-hex-character-official-checksum
{{checkoutCommit}}

Reviewed full 40-character commit SHA for actions/checkout.

Example: reviewed-full-commit-sha

Security and production boundaries

  • GitHub warns that self-hosted runners can be persistently compromised by workflow code and should almost never serve public repositories. A private fork can still be hostile.
  • Never place the runner user in sudo, docker, lxd, libvirt, disk, or similar root-equivalent groups. Use a separately isolated builder pool when a job needs privileged capability.
  • Runner-group access is the primary repository boundary; labels refine routing but can be copied and are not proof of host capability.
  • Registration tokens and JIT configuration are short-lived credentials. Keep them only in controller memory or a protected prompt and redact all provisioning/debug output.
  • Pin third-party actions to reviewed full commit SHAs, minimize GITHUB_TOKEN permissions, gate environments, and prohibit pull_request_target from executing fork-controlled content.

Stop before continuing if

  • Stop if a public repository, fork, pull-request-controlled workflow/action, or unreviewed repository can target the trusted group.
  • Stop if the runner account has sudo, a host management socket, writable service/image files, shared work storage, or broad production network reachability.
  • Stop if registration/JIT data, environment secrets, or OIDC tokens appear in command text, process listings captured by other tenants, images, logs, or outputs.
  • Stop production autoscaling if external _diag/lifecycle logs are not acknowledged before teardown or the instance can receive a second job.
  • Stop scheduling when the runner image exceeds the update policy, GitHub requires a critical update, or negative routing/destruction tests fail.
01

decision

Classify workflows and create a restricted runner group

read-only

Inventory every event, repository, action, secret, environment, artifact, network target, and actor that could influence a job. Create an organization group that grants access only to explicitly approved private repositories. Route public and fork-originated pull requests to GitHub-hosted or a separate low-trust pool.

Why this step matters

A runner executes repository-controlled code inside the organization's network. Trust classification and group access therefore matter more than the operating-system installation.

What to understand

Review workflow definitions and transitive actions as executable dependencies, including reusable workflows and composite actions.

Separate build/test, deployment, signing, and privileged network access into different runner groups and environments.

GitHub explicitly warns that forked public and private repository code can compromise self-hosted machines.

System changes

  • The command prints reviewed nonsecret routing values. Group/repository access is configured in GitHub organization settings or its authenticated API by an owner.

Syntax explained

runnerGroup
Repository access boundary for the trusted infrastructure.
runnerLabel
Capability selector combined with the group in workflow runs-on.
fork-policy
Explicitly prevents untrusted pull-request code from reaching the trusted pool.
Command
Fill variables0/4 ready

Values stay on this page and are never sent or saved.

printf '%s\n' 'organization={{githubOrg}}' 'repository={{githubRepo}}' 'group={{runnerGroup}}' 'label={{runnerLabel}}' 'fork-policy=hosted-or-isolated-only'
Example output / evidence
organization=acme-platform
repository=payments-api
group=trusted-linux-deploy
label=linux-x64-ephemeral
approved repositories: 1
public repositories: 0
fork pull requests on trusted group: denied
group default access: restricted

Checkpoint: Verify this layer before continuing

Continue whenThe group defaults to restricted access, contains only approved repositories, and every event class has a documented trusted or untrusted execution path.

Stop whenStop if any public/fork workflow can target the group, repository ownership is unclear, or a universal group spans incompatible secrets/networks.

If this step fails

A repository owner requests broad default-group access for convenience.

Likely causeWorkflow routing was designed around availability rather than trust boundaries.

Safe checks
  • List the repository's workflows, triggers, secrets, environments, and internal destinations.
  • Identify a hosted or lower-trust execution path for nonprivileged tests.

ResolutionCreate a narrow repository/group policy and separate privileged jobs; do not expand the trusted default group.

Security notes

  • Treat runner-group administration as privileged infrastructure access and audit every membership change.

Alternatives

  • Use only GitHub-hosted runners when no job requires private network or custom hardware.

Stop conditions

  • Stop if any public/fork workflow can target the group, repository ownership is unclear, or a universal group spans incompatible secrets/networks.
02

verification

Approve a one-job lifecycle and immutable image bill of materials

read-only

Define the current supported runner release, Ubuntu 24.04 LTS base image digest, package inventory, update SLA, external log destination, work-volume lifecycle, maximum job duration, network policy, capacity limits, and exact teardown acknowledgment. Prefer JIT for autoscaling and ephemeral registration for a manual pilot.

Why this step matters

One-job semantics require more than the GitHub flag: the machine image, storage, controller, logging, timeout, and deletion evidence must all agree that no second job can inherit state.

What to understand

Version the entire image, not only actions/runner. OS packages, CA trust, language tools, and monitoring agents are part of the attack surface.

Use external logs because ephemeral runner files disappear; GitHub recommends forwarding them before production autoscaling.

Define a hard job/runtime TTL so a compromised or hung process cannot hold an internal foothold indefinitely.

System changes

  • The commands inspect the pilot OS, virtualization, time, and mounts. The associated architecture record freezes an image bill of materials and lifecycle policy.

Syntax explained

systemd-detect-virt
Confirms the expected disposable virtualization boundary.
timedatectl show
Checks synchronized UTC time needed for TLS, logs, and short-lived credentials.
findmnt
Shows filesystem sources/options and proves work storage is a distinct disposable resource where designed.
Command
uname -a; cat /etc/os-release; systemd-detect-virt; timedatectl show --property=NTPSynchronized --property=Timezone; findmnt --output TARGET,SOURCE,FSTYPE,OPTIONS / /opt
Example output / evidence
Linux gha-pilot 6.8.0-64-generic x86_64
PRETTY_NAME="Ubuntu 24.04.2 LTS"
virtualization: kvm
NTPSynchronized=yes
Timezone=Etc/UTC
/    /dev/mapper/os-root ext4 rw,nodev
/opt /dev/mapper/runner-work ext4 rw,nodev,nosuid
runner release policy: current, <=30 days from GitHub release
maximum jobs per instance: 1
work volume destruction: required

Checkpoint: Verify this layer before continuing

Continue whenImage inputs/digests, supported runner release, external logs, one-job limit, TTL, teardown, network, and update owners are approved and reproducible.

Stop whenStop if the host is shared, stateful, manually patched, cannot be destroyed, lacks external logs, or has no update owner.

If this step fails

The image factory uses mutable latest references.

Likely causeThe build cannot prove which OS/tool artifact was used for the runner image.

Safe checks
  • Inspect image-builder source, lock files, registry/cloud image IDs, and signed release metadata.
  • Rebuild twice from the same locked inputs and compare the resulting inventory.

ResolutionPin immutable image/package inputs or record repository snapshots, then regenerate and sign the runner image before use.

Security notes

  • Image builders are supply-chain systems and must be isolated from the workflows their images later execute.

Alternatives

  • Use a managed ephemeral VM image pipeline if the organization cannot operate a secure custom factory.

Stop conditions

  • Stop if the host is shared, stateful, manually patched, cannot be destroyed, lacks external logs, or has no update owner.
03

command

Create a dedicated nonprivileged runner account and filesystem

caution

On the clean image, create a dedicated local account and installation path owned only by that account. Do not grant sudo, password login, docker/libvirt/lxd groups, host sockets, or write access to system services and image configuration.

Why this step matters

Workflow commands inherit the runner account's authority. A dedicated account and narrow filesystem constrain accidental damage and remove obvious root-equivalent escalation paths.

What to understand

Membership in docker, lxd, libvirt, disk, or similar groups can provide host control even when uid is not zero.

The runner must not own its systemd unit, image bootstrap, telemetry configuration, or network policy.

The work directory is disposable and must not be mounted from a shared persistent volume or previous runner.

System changes

  • Creates one local account plus runner/work directories and ownership. It changes the immutable image recipe and should not be performed ad hoc on reused hosts.

Syntax explained

useradd --create-home
Creates a separate local identity and controlled home path.
install --directory --mode 0750
Creates private directories readable/writable only by owner and root.
sudo -l -U
Verifies no sudo commands are delegated to the runner user.
Command
Fill variables0/2 ready

Values stay on this page and are never sent or saved.

sudo useradd --create-home --home-dir '{{runnerHome}}' --shell /bin/bash '{{runnerUser}}'; sudo install --directory --owner '{{runnerUser}}' --group '{{runnerUser}}' --mode 0750 '{{runnerHome}}' '{{runnerHome}}/_work'; id '{{runnerUser}}'; sudo -l -U '{{runnerUser}}' || true; getent group docker lxd libvirt
Example output / evidence
uid=991(actions-runner) gid=991(actions-runner) groups=991(actions-runner)
User actions-runner is not allowed to run sudo on gha-pilot.
runner home: /opt/actions-runner owner=actions-runner mode=0750
work directory: /opt/actions-runner/_work owner=actions-runner mode=0750
docker/lxd/libvirt membership for actions-runner: none
password authentication: locked by image policy

Checkpoint: Verify this layer before continuing

Continue whenThe account has only its primary group, no sudo/root-equivalent groups, owns only runner/work paths, and cannot alter service/image policy.

Stop whenStop if any privileged group, sudo rule, management socket, shared writable path, or interactive remote login exists.

If this step fails

A build requirement claims it needs the Docker socket.

Likely causeThe job combines untrusted build instructions with a root-equivalent host daemon.

Safe checks
  • Identify the exact build operation and trust level.
  • Evaluate rootless BuildKit or a separate disposable builder service with no production network.

ResolutionMove container building to a dedicated isolated supply-chain runner/builder; do not add the general runner user to docker.

Security notes

  • File ownership is not isolation against kernel vulnerabilities, so retain one-job VM destruction and patching.

Alternatives

  • Use an ephemeral container only when its host and runtime provide a real per-job boundary and no shared daemon socket.

Stop conditions

  • Stop if any privileged group, sudo rule, management socket, shared writable path, or interactive remote login exists.
04

command

Download and verify the official runner release

caution

Copy the current Linux x64 archive URL and SHA-256 value from GitHub's runner setup instructions, download over verified HTTPS, validate the checksum before extraction, inventory files, and make the installation immutable in the image. Never curl a release script directly into a shell.

Why this step matters

A runner is a remote code-execution agent. Verifying the official release archive before adding it to a trusted image reduces substitution and download corruption risk.

What to understand

Obtain URL/checksum through the authenticated organization setup UI or official actions/runner release and peer-review them in the image change.

Download and extract as the dedicated runner user so config/runtime files are not accidentally root-owned. Run only the verified host dependency installer with sudo, then reassert runner ownership.

If automatic updates are disabled for immutable images, rebuild within 30 days of every runner release and immediately for critical security releases.

System changes

  • Downloads and extracts the runner software, installs documented host dependencies, and records the version in the image. It does not register the runner.

Syntax explained

curl --fail --location
Fails on HTTP errors and follows the official release redirect.
--proto '=https' --tlsv1.2
Restricts the transfer to HTTPS with at least TLS 1.2.
sha256sum --check --strict
Stops extraction unless the archive exactly matches the official digest.
installdependencies.sh
Runs the verified host dependency installer with root authority after release verification; the runner binaries themselves remain owned by the runner account.
Command
Fill variables0/5 ready

Values stay on this page and are never sent or saved.

sudo -H -u '{{runnerUser}}' -- bash -c "cd '{{runnerHome}}' && curl --fail --location --proto '=https' --tlsv1.2 --output '{{runnerArchive}}' '{{runnerArchiveUrl}}' && printf '%s  %s\n' '{{runnerSha256}}' '{{runnerArchive}}' | sha256sum --check --strict && tar --extract --gzip --file '{{runnerArchive}}'"; cd '{{runnerHome}}' && sudo ./bin/installdependencies.sh; sudo chown --recursive '{{runnerUser}}:{{runnerUser}}' '{{runnerHome}}'; sudo -H -u '{{runnerUser}}' -- bash -c "cd '{{runnerHome}}' && ./bin/Runner.Listener --version"
Example output / evidence
actions-runner-linux-x64.tar.gz: OK
Runner.Listener
Runner.Worker
Runner.PluginHost
Dependencies: satisfied
Runner version: current supported X.Y.Z
Archive source: official actions/runner release
Checksum source: GitHub runner setup instructions
Image manifest updated: runner-image-2026-07-28.1

Checkpoint: Verify this layer before continuing

Continue whenChecksum passes, expected binaries exist, dependency setup succeeds, version matches the reviewed release, and the final image manifest/digest is recorded.

Stop whenStop on checksum mismatch, redirected/unofficial host, unsigned image change, dependency from an unapproved repository, or an already-outdated runner release.

If this step fails

The checksum fails.

Likely causeThe archive/download is corrupt, wrong architecture/version was selected, or the source was substituted.

Safe checks
  • Delete the failed archive without extracting it.
  • Reconfirm URL and digest independently in GitHub's official setup/release page and inspect proxy integrity.

ResolutionDownload a fresh archive from the official source and proceed only after exact verification; escalate any persistent mismatch as a supply-chain incident.

Security notes

  • Do not make runner binaries writable by workflow jobs after the image is published.

Alternatives

  • Use a package/image source mirrored internally only if it preserves official digest verification and provenance.

Stop conditions

  • Stop on checksum mismatch, redirected/unofficial host, unsigned image change, dependency from an unapproved repository, or an already-outdated runner release.
05

config

Apply network, process, filesystem, and telemetry boundaries

caution

Allow outbound traffic only to GitHub endpoints and named build dependencies required by the approved workflows; deny inbound service access. Forward systemd, image identity, runner _diag, audit, DNS/proxy, and teardown events externally. Set resource and job TTL limits, read-only image paths, disposable work storage, and no cross-runner cache.

Why this step matters

Self-hosting is often chosen for internal access, but broad internal reach turns one workflow compromise into lateral movement. Network and telemetry controls must be part of the runner product.

What to understand

GitHub endpoint requirements can change; maintain the allowlist from official communication requirements and monitor failures rather than opening unrestricted egress.

Logs must leave before destruction and must not contain registration/JIT configuration, tokens, secrets, or full environment dumps.

Resource and TTL limits contain denial-of-service and persistent reverse-shell behavior; an expired runner is destroyed, not extended indefinitely.

System changes

  • Applies network ACL/proxy, filesystem mount, process/service, resource, TTL, and external log collection policy in the immutable image/orchestrator.

Syntax explained

test -w/test ! -w
Proves the runner can write only its disposable work path, not system service definitions.
curl --head
Checks TLS/HTTP reachability without sending credentials or downloading content.
findmnt
Confirms the work directory is backed by the intended disposable mount.
Configuration
Fill variables0/2 ready

Values stay on this page and are never sent or saved.

sudo -u '{{runnerUser}}' test -w '{{runnerHome}}/_work' && sudo -u '{{runnerUser}}' test ! -w /etc/systemd/system; curl --fail --silent --show-error --head https://github.com; curl --fail --silent --show-error --head https://api.github.com; systemctl is-active systemd-journald; findmnt '{{runnerHome}}/_work'
Example output / evidence
runner work directory writable: yes
/etc/systemd/system writable by runner: no
github.com HTTPS: 200
api.github.com HTTPS: 200
inbound listener policy: denied
internal destinations allowed: registry-ci.corp.example:443 only
system journal: active
external log canary: acknowledged event gha-runner-canary-071
job TTL: 45 minutes
instance TTL: 60 minutes

Checkpoint: Verify this layer before continuing

Continue whenGitHub and approved build endpoints work, unapproved/inbound paths fail, logs are externally acknowledged, TTL/resources are enforced, and runner jobs cannot alter controls.

Stop whenStop if the runner needs broad production network, external logs fail, controls are job-writable, or a required endpoint cannot be defined.

If this step fails

GitHub connectivity works in the shell but the runner cannot receive jobs.

Likely causeAdditional documented GitHub endpoints, proxy WebSocket/long-poll behavior, certificate trust, or DNS are missing.

Safe checks
  • Inspect runner _diag network errors and proxy logs.
  • Compare the official communication requirements with the allowlist and TLS interception policy.

ResolutionAdd only the missing documented endpoint/protocol, test with a canary, and retain default-deny boundaries.

Security notes

  • Do not log request authorization headers, OIDC request variables, or full process environments.

Alternatives

  • Use a controlled egress proxy that authenticates the runner identity and records destinations without retaining secrets.

Stop conditions

  • Stop if the runner needs broad production network, external logs fail, controls are job-writable, or a required endpoint cannot be defined.
06

command

Register one manual pilot as ephemeral without a token in argv

danger

Create a short-lived organization registration token in GitHub settings, open a trusted console as the runner user, and run config.sh without --token so it prompts interactively. Select the restricted organization URL, one-job ephemeral mode, exact labels, name, and work directory; do not capture the prompt or copy the token into shell history.

Why this step matters

A manual ephemeral pilot validates group, labels, network, and one-job behavior without embedding a token in a command line. Production autoscaling should replace this prompt with protected JIT orchestration.

What to understand

Create the token immediately before registration and use it only once within its short lifetime.

--disableupdate is appropriate only because the image factory has a monitored <=30-day update SLA and critical-release emergency path.

Confirm the runner lands in the restricted group; labels alone do not grant the intended repository boundary.

System changes

  • Writes local runner registration metadata containing credentials and creates an organization runner entry eligible for exactly one job.

Syntax explained

--url
Registers at organization scope so group access policy can be enforced.
--ephemeral
Requests automatic deregistration after the first completed job.
--labels
Adds the reviewed capability selector used with runner-group routing.
--disableupdate
Prevents runtime mutation; the image factory must replace the runner release within GitHub's deadline.
Command
Fill variables0/4 ready

Values stay on this page and are never sent or saved.

sudo -H -u '{{runnerUser}}' -- bash -c "cd '{{runnerHome}}' && ./config.sh --url 'https://github.com/{{githubOrg}}' --ephemeral --labels '{{runnerLabel}}' --name 'gha-pilot-071' --work '_work' --disableupdate"
Example output / evidence
Enter the registration token: [input not echoed or recorded]
Authentication: succeeded
Runner group selected: trusted-linux-deploy
Runner name: gha-pilot-071
Labels: self-hosted, Linux, X64, linux-x64-ephemeral
Ephemeral: true
Work folder: _work
Automatic runner update: disabled by immutable-image policy
Secret/token output: none

Checkpoint: Verify this layer before continuing

Continue whenThe runner is ephemeral, in the restricted group, exposes only intended labels, stores no token in history/output/image, and remains offline until the pilot service starts.

Stop whenStop if the prompt is captured, group defaults are broad, registration becomes persistent, token appears in argv/logs, or image update governance is not active.

If this step fails

The runner appears in the default group.

Likely causeOrganization setup did not select the intended group or the registration flow lacks the required policy.

Safe checks
  • Keep the runner offline and inspect organization runner-group membership/access.
  • Confirm the group ID/policy used by the future JIT API.

ResolutionMove or re-register the runner in the restricted group before any job; never run a trusted job in the broad default group.

Security notes

  • The local .runner/.credentials material is sensitive until teardown; restrict it to the runner user and destroy it with the instance.

Alternatives

  • Use the organization generate-jitconfig API from the autoscaler for production one-job registration, treating encoded_jit_config as a secret.

Stop conditions

  • Stop if the prompt is captured, group defaults are broad, registration becomes persistent, token appears in argv/logs, or image update governance is not active.
07

config

Route only trusted jobs and pin workflow dependencies

caution

Use group plus label for the trusted job, default workflow permissions to read, grant only job-specific permissions, pin every third-party action to a reviewed full commit SHA, and gate sensitive environments. Fork pull-request tests use GitHub-hosted runners and never pass artifacts or caches directly into the trusted job without validation.

Why this step matters

Runner isolation is defeated if broad workflow triggers, mutable actions, excessive tokens, or fork-controlled artifacts reach it. The workflow must express the same trust boundary as the infrastructure.

What to understand

Pin action code by full commit SHA and review updates; a release tag is still mutable.

Set permissions explicitly because the default can vary by repository/organization policy and jobs should not inherit unused write access.

Do not use pull_request_target to check out and execute fork code in the privileged context. Validate artifacts before crossing trust zones.

System changes

  • Changes the workflow routing, GitHub token permissions, environment gate, action dependency pins, and executable deploy path.

Syntax explained

runs-on.group
Requires the job to use the restricted runner-group boundary.
runs-on.labels
Requires the matching ephemeral Linux capability within that group.
permissions: contents: read
Starts from least-privilege repository access.
persist-credentials: false
Prevents checkout from retaining the GitHub token in Git configuration for later steps.
File .github/workflows/trusted-deploy.yml
Configuration
Fill variables0/3 ready

Values stay on this page and are never sent or saved.

name: trusted-deploy
on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

jobs:
  untrusted-pr-test:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@{{checkoutCommit}}

  trusted-deploy:
    if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
    runs-on:
      group: {{runnerGroup}}
      labels: {{runnerLabel}}
    environment: production
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@{{checkoutCommit}}
        with:
          persist-credentials: false
      - run: ./ci/verified-deploy.sh
Example output / evidence
workflow syntax: valid
trusted job route: group=trusted-linux-deploy label=linux-x64-ephemeral
trusted trigger: push to protected main or approved workflow_dispatch
fork pull_request route: ubuntu-latest
default GITHUB_TOKEN permission: contents:read
third-party action references pinned to full commit SHA: 100%
pull_request_target executes fork-controlled code: false

Checkpoint: Verify this layer before continuing

Continue whenTrusted events reach group+label with environment approval, fork PRs use hosted isolation, permissions are minimal, all actions use full SHAs, and no untrusted artifact crosses unverified.

Stop whenStop if pull_request/fork code can reach the group, pull_request_target executes it, an action uses a tag, token writes are unnecessary, or environment gates are absent.

If this step fails

The trusted job is skipped on an approved protected push.

Likely causeThe event condition, branch protection, environment, group access, or labels do not match.

Safe checks
  • Inspect the workflow event/ref and job condition evaluation.
  • Compare group repository access, environment deployment rules, and runner labels.

ResolutionCorrect the narrow condition or policy; do not add pull_request or broaden group access as a scheduling workaround.

Security notes

  • Shell scripts in the repository are executable dependencies and require protected-branch review just like actions.

Alternatives

  • Use a reusable workflow owned in a tightly controlled repository, still pinned and permission-scoped by callers.

Stop conditions

  • Stop if pull_request/fork code can reach the group, pull_request_target executes it, an action uses a tag, token writes are unnecessary, or environment gates are absent.
08

verification

Run positive and negative routing tests before secrets exist

read-only

With no production secrets or internal write access, test a protected-branch canary, an untrusted fork PR, a mismatched label, an unauthorized repository, and a mutable-action policy violation. Confirm only the trusted canary reaches the self-hosted group and every denied case is observable.

Why this step matters

Negative tests prove that policy fails closed. Testing before secrets and production network access limits the cost of discovering a routing mistake.

What to understand

Use a benign canary repository/workflow and synthetic values. Never ask an external contributor to attempt compromise.

A queued wrong-label job is expected; cancel it after proving no matching runner appears.

Record GitHub audit/workflow evidence and runner lifecycle correlation for every denial.

System changes

  • The command documents test cases. The tests create synthetic workflow runs and one ephemeral runner job but no production deployment or credentials.

Syntax explained

trusted-push
Positive control proving intended routing and execution.
fork-pr
Negative trust test proving fork-originated code stays off the trusted infrastructure.
unauthorized-repo
Tests runner-group repository access independently of labels.
mutable-action
Tests dependency policy rejection before action execution.
Command
printf '%s\n' 'trusted-push=expected:self-hosted-one-job' 'fork-pr=expected:github-hosted' 'wrong-label=expected:queued-no-match' 'unauthorized-repo=expected:group-denied' 'mutable-action=expected:policy-fail'
Example output / evidence
trusted-push -> gha-pilot-071: passed
fork-pr -> GitHub-hosted runner: passed
fork-pr access to trusted group: denied
wrong-label -> no trusted match: passed
unauthorized repository -> group unavailable: passed
mutable action reference -> policy check failed as expected
production secrets present during tests: 0
unexpected internal destinations reached: 0

Checkpoint: Verify this layer before continuing

Continue whenOnly the protected trusted canary runs self-hosted; every untrusted/mismatched case fails closed with audit evidence and zero secrets/internal writes.

Stop whenStop and disable the group immediately if any unauthorized event/repository/action reaches the runner or denial lacks evidence.

If this step fails

The fork PR can see the trusted group but no runner is online.

Likely causeGroup repository access is broad even though current capacity masks the exposure.

Safe checks
  • Inspect organization group access and repository runner settings.
  • Review audit logs for prior routing attempts.

ResolutionRemove the repository/default access, invalidate runner registrations, and repeat the test with an online secret-free canary before production.

Security notes

  • Negative tests must not include real secrets because a routing bug is precisely what they are intended to find.

Alternatives

  • Enforce the same scenarios continuously in policy-as-code and a scheduled canary.

Stop conditions

  • Stop and disable the group immediately if any unauthorized event/repository/action reaches the runner or denial lacks evidence.
09

command

Run one job, forward diagnostics, and prove destruction

danger

Start the pilot runner under the dedicated account or a root-owned systemd unit configured for that user, run one benign trusted canary, wait for external log acknowledgment, and destroy the VM and work volume. Confirm GitHub deregistration and that a second matching job cannot land on the old identity.

Why this step matters

The real security boundary is the complete one-job lifecycle: registration, execution, external evidence, automatic deregistration, and irreversible destruction of every writable layer.

What to understand

Use timeout plus orchestrator TTL so a hostile process cannot keep the instance indefinitely.

The runner's exit does not erase data. The controller must delete the VM, local work disk, swap, snapshots, and any job-specific cache.

External log acknowledgment precedes destruction, but log loss must never cause indefinite reuse of a potentially compromised host.

System changes

  • Starts the runner and executes one workflow. The orchestration then deletes the instance, disk, registration, network identity, and ephemeral credentials.

Syntax explained

timeout --signal=TERM 45m
Bounds runner execution and requests graceful termination at the approved job TTL.
./run.sh
Starts the already registered runner as the unprivileged user.
Command
Fill variables0/2 ready

Values stay on this page and are never sent or saved.

sudo -H -u '{{runnerUser}}' -- bash -c "cd '{{runnerHome}}' && exec timeout --signal=TERM 45m ./run.sh"
Example output / evidence
Connected to GitHub
Listening for Jobs
Running job: runner-one-job-canary
Job completed: Success
Ephemeral runner removed from GitHub
External _diag/lifecycle log acknowledgment: received
Instance shutdown requested: true
Work volume deletion: confirmed
Runner record after teardown: absent
Second matching job on old runner ID: impossible

Checkpoint: Verify this layer before continuing

Continue whenOne job succeeds, external logs are acknowledged, GitHub removes the runner, all writable resources are destroyed, and a second job requires a new identity/instance.

Stop whenStop production if job count exceeds one, teardown/log acknowledgment fails, the disk persists, the runner record remains online, or old identity can reconnect.

If this step fails

The job completes but the service restarts and reconnects.

Likely causeA persistent systemd restart policy or autoscaler treats normal ephemeral exit as failure.

Safe checks
  • Disable the runner/group and inspect unit Restart policy plus controller lifecycle state.
  • Verify the runner record's ephemeral flag and instance job counter.

ResolutionDestroy the instance, correct the unit/controller to treat one-job exit as success, and repeat the negative second-job test.

Security notes

  • Assume job code can read everything reachable by the runner user, including diagnostics and other processes in the same host.

Alternatives

  • For production, use JIT configuration so the controller receives a one-job registration designed for autoscaling.

Stop conditions

  • Stop production if job count exceeds one, teardown/log acknowledgment fails, the disk persists, the runner record remains online, or old identity can reconnect.
10

verification

Validate update governance, observability, and emergency disablement

read-only

Monitor the current runner release, immutable image age/digest, online/busy/ephemeral inventory, queue time, provisioning failures, job/instance TTL, external log gaps, network denials, and teardown failures. Exercise disabling the runner group and routing an eligible nonprivileged job to the hosted fallback.

Why this step matters

A secure runner degrades quickly when releases, provisioning, logging, routing, or teardown drift. Operational metrics and an exercised kill switch keep the control effective after launch.

What to understand

GitHub can stop assigning jobs after 30 days without an update or immediately for a critical update.

Alert on any non-ephemeral runner, more than one job per instance, stale image, missing logs, unexpected repository, broad egress, or undeleted disk.

The kill switch should disable group access/capacity without deleting evidence and should not require access to the potentially compromised runner.

System changes

  • The shown output is monitoring evidence. The drill temporarily disables runner-group capacity and exercises a hosted fallback for an eligible job.

Syntax explained

runner-release-lag-days
Measures immutable image freshness against GitHub runner releases.
instances with jobs >1
Detects violation of the one-job isolation contract.
teardown/log gaps
Detects residual compromised resources or lost forensic evidence.
group-disable-test
Proves owners can stop trusted scheduling independently of runner hosts.
Command
printf '%s\n' 'runner-image-age-days=4' 'runner-release-lag-days=0' 'ephemeral-inventory=all' 'teardown-failures=0' 'external-log-gaps=0' 'group-disable-test=passed' 'hosted-fallback=passed'
Example output / evidence
runner image revision: runner-image-2026-07-28.1
runner release lag: 0 days
instances with jobs >1: 0
provision p95: 42s
queue p95: 55s
TTL violations: 0
external log gaps: 0
teardown failures: 0
organization group disabled in drill: 31s
eligible hosted fallback canary: passed

Checkpoint: Verify this layer before continuing

Continue whenAll metrics meet budget, critical alerts deliver, stale/unsafe runners are refused, the group disables promptly, and hosted fallback is understood.

Stop whenStop scheduling on stale release, lifecycle/log anomaly, unexpected repository, destructive capacity behavior, or an untested kill switch.

If this step fails

A critical runner release appears while jobs are queued.

Likely causeThe immutable image pipeline cannot replace capacity quickly enough.

Safe checks
  • Disable affected runner capacity and identify which jobs can safely use hosted fallback.
  • Build and verify a canary image from the official release/digest.

ResolutionKeep old runners disabled, deploy the verified image, run routing/lifecycle negatives, then reopen capacity gradually.

Security notes

  • Monitoring must not ingest workflow secrets, registration/JIT configuration, or complete environment dumps.

Alternatives

  • Use GitHub-hosted runners as the fail-safe for jobs that do not need private capabilities.

Stop conditions

  • Stop scheduling on stale release, lifecycle/log anomaly, unexpected repository, destructive capacity behavior, or an untested kill switch.
11

warning

Document compromise recovery and remove every persistent credential

danger

Write the response sequence for a malicious workflow, unexpected repository, exposed secret, stale runner, or failed destruction. Disable group access, stop new capacity, isolate affected instances, preserve external evidence, rotate every reachable credential, review repository/action changes, rebuild images, and reopen only after negative tests pass.

Why this step matters

Workflow code has the same opportunity as an attacker once it reaches the runner. Recovery must assume all accessible files, tokens, services, caches, networks, and image-management paths were examined.

What to understand

Preserve GitHub audit/workflow metadata and external runner/network logs; do not depend on the compromised disk as the only evidence.

Rotate environment, repository, cloud/OIDC trust, registry, package, deploy, and internal service credentials according to actual reachability.

A clean VM is insufficient if the malicious workflow or mutable action remains on a protected branch.

System changes

  • The documented incident actions disable scheduling, isolate/destroy capacity, rotate trust, rebuild images, and may interrupt deployments.

Syntax explained

disable-runner-group
Stops new trusted jobs without requiring host cooperation.
preserve-external-evidence
Retains logs and audit data outside the compromised machine.
rotate-reachable-credentials
Invalidates every secret/identity the runner could obtain, not only the registration token.
repeat-negative-tests
Requires proof that routing and one-job destruction fail closed before reopening.
Command
printf '%s\n' 'disable-runner-group' 'stop-autoscaler' 'isolate-instance' 'preserve-external-evidence' 'rotate-reachable-credentials' 'rebuild-image' 'repeat-negative-tests'
Example output / evidence
incident runbook: IR-CI-071 approved
runner-group kill switch owner: Platform Security
autoscaler stop owner: CI Operations
evidence source independent of runner: yes
credential inventory by runner class: complete
known-good image digest: recorded
reopen gate: security review plus all negative tests

Checkpoint: Verify this layer before continuing

Continue whenOwners, kill switches, credential inventory, independent evidence, known-good image, and reopen criteria are tested in a tabletop and canary drill.

Stop whenDo not reopen because the instance was deleted; stop until workflow/action provenance, credentials, image factory, routing, and negatives are trusted again.

If this step fails

The team cannot enumerate credentials reachable by the runner.

Likely causeSecrets and network identities were added organically without a per-runner-class dataflow inventory.

Safe checks
  • Review workflow permissions, environments, OIDC trust conditions, registry/package access, network policy, and service logs.
  • Assume exposure for ambiguous paths and prioritize containment/rotation.

ResolutionComplete the credential/dataflow inventory, rotate uncertain credentials, narrow future reachability, and validate with synthetic jobs.

Security notes

  • Do not collect or copy secret values while building the inventory; record identity, scope, owner, expiry, and rotation action.

Alternatives

  • Keep the trusted pool disabled and use hosted runners for safe nonprivileged work while investigation continues.

Stop conditions

  • Do not reopen because the instance was deleted; stop until workflow/action provenance, credentials, image factory, routing, and negatives are trusted again.

Finish line

Verification checklist

Identity and privilegeid '{{runnerUser}}'; sudo -l -U '{{runnerUser}}' || trueThe dedicated account has no sudo or root-equivalent group/socket and owns only disposable runner paths.
Release integrityprintf '%s %s\n' '{{runnerSha256}}' '{{runnerArchive}}' | sha256sum --check --strictThe selected current official runner archive matches GitHub's published SHA-256 and the immutable image records its version/digest.
Trusted routingprintf '%s\n' '{{runnerGroup}}' '{{runnerLabel}}' '{{githubOrg}}/{{githubRepo}}'Only the approved private repository and trusted events can target group plus label; actions are full-SHA pinned with least permissions.
Negative routingprintf '%s\n' 'fork PR denied' 'unauthorized repo denied' 'wrong label no match'Fork, unauthorized repository, and mismatched-label tests never reach trusted infrastructure and produce audit evidence.
One-job destructionprintf '%s\n' 'ephemeral deregistered' 'external logs acknowledged' 'VM and disk deleted' 'second job requires new runner'The runner executes exactly one canary, disappears from GitHub, externalizes logs, and leaves no reusable compute/storage/identity.

Recovery guidance

Common problems and safe checks

The runner remains Offline after registration.

Likely causeThe process never started, outbound HTTPS or long-poll connectivity is blocked, the registration expired, system time is wrong, or the runner registered at a different scope/group.

Safe checks
  • Inspect systemd state, the runner _diag logs, UTC/time synchronization, DNS, and the runner entry in organization settings without printing tokens.
  • Compare the approved organization URL, group, labels, and network allowlist with the effective host configuration.

ResolutionRepair the exact service, DNS, time, or egress dependency and register a fresh ephemeral instance when the old registration is uncertain. Do not expose the registration token in a connectivity test.

A job stays queued until the 24-hour timeout.

Likely causeNo online runner matches every requested label and group, the repository lacks access to the group, capacity is exhausted, or the autoscaler failed to create a JIT runner.

Safe checks
  • Compare the workflow runs-on group/labels with runner-group repository access and the online runner inventory.
  • Inspect autoscaler provisioning and JIT API status without logging encoded configuration or API credentials.

ResolutionCorrect routing policy or restore autoscaler capacity. Do not broaden the default runner group or remove labels merely to drain the queue.

A pull request from a fork is scheduled on the trusted self-hosted runner.

Likely causeThe workflow trigger/routing condition is too broad, the runner group includes a public repository, or pull_request_target checks out and executes untrusted code.

Safe checks
  • Cancel the job before execution, preserve the workflow event and routing decision, and inspect repository/group access plus the exact checked-out ref.
  • Assume the host and reachable credentials are exposed if any untrusted step began; isolate it before collecting evidence.

ResolutionRoute fork and untrusted pull-request tests to GitHub-hosted or a disposable unprivileged pool, restrict the trusted group to approved repositories/events, rotate potentially exposed credentials, and rebuild the runner.

A completed ephemeral runner remains online and accepts another job.

Likely causeIt was registered as persistent, the ephemeral flag was omitted, a service restarted stale state, or the autoscaler reused a disk/registration.

Safe checks
  • Inspect the runner record's ephemeral property, local .runner metadata, process/service state, and job count.
  • Check orchestrator teardown logs and image identity without starting the instance again.

ResolutionDisable the runner, destroy the instance and work volume, remove the stale registration, and fix the factory so every instance is one-job JIT or ephemeral before returning capacity.

The runner cannot update and GitHub stops assigning jobs.

Likely causeAutomatic updates were disabled but the immutable image was not rebuilt within 30 days, or GitHub marked a critical update as required.

Safe checks
  • Compare the installed runner version with the current supported release and GitHub release/security notices.
  • Inspect autoscaler image digest and rollout age; do not enable arbitrary network downloads on old production instances.

ResolutionBuild, verify, canary, and roll out a new immutable runner image immediately. Keep the old pool disabled; GitHub may refuse jobs for outdated runners.

A job modifies the host despite running as the runner user.

Likely causeThe account has sudo, belongs to docker/libvirt/lxd groups, has writable service/config paths, or can reach a root-equivalent management socket.

Safe checks
  • Isolate the host, inspect identity/group membership, sudo policy, capabilities, sockets, mounts, and system changes from trusted forensics.
  • Treat Docker socket access as host-root capability and review every persistent path across jobs.

ResolutionRebuild from a trusted image, remove root-equivalent memberships/sockets, use a separate isolated builder architecture when privileged build capability is required, and rotate reachable credentials.

Secrets appear in job logs or diagnostic bundles.

Likely causeA workflow echoed environment/context data, debug logging was enabled, a third-party action exfiltrated values, or masking did not recognize transformed secret content.

Safe checks
  • Restrict log access, identify the exact job/action/step and secret scope, and preserve minimal evidence without downloading logs broadly.
  • Review workflow permissions, action pins, environment approvals, shell tracing, and outbound destinations.

ResolutionRevoke and rotate exposed credentials, remove the logging/exfiltration path, minimize token permissions, and rebuild the runner. Masking is not a substitute for not emitting secrets.

An action tag resolves to unexpected code.

Likely causeThe workflow pins a mutable tag/branch rather than a full commit SHA, or repository governance allowed an unreviewed dependency update.

Safe checks
  • Resolve the action reference to a commit through GitHub and compare it with the approved dependency record.
  • Review Dependabot/update PR, publisher verification, release notes, and source diff before rerunning.

ResolutionPin the action to a reviewed full commit SHA, record the human-readable version in a comment, and require review for future pin changes.

The runner cannot reach a required internal service.

Likely causeEgress/microsegmentation intentionally excludes it, DNS or certificate trust is wrong, or the workflow was routed to the wrong trust pool.

Safe checks
  • Confirm the job's approved dataflow and runner group, then test DNS/TLS/network reachability to only the named endpoint.
  • Inspect proxy and certificate configuration without weakening verification or printing authorization headers.

ResolutionAdd the narrow reviewed service path to the correct runner class or redesign the job. Do not attach the general runner to a broad production network.

External log shipping stops for ephemeral runners.

Likely causeThe collector endpoint, credentials, buffering, or teardown order failed, and the VM was destroyed before _diag logs left the host.

Safe checks
  • Inspect collector health, last received runner ID/job correlation, queue metrics, and orchestration teardown timing.
  • Run a synthetic one-job canary that produces a known nonsecret diagnostic marker.

ResolutionBlock production autoscaling acceptance until logs are forwarded and acknowledged before destruction, with bounded local buffering and monitored loss.

Disk, inode, or work-directory usage grows across jobs.

Likely causeThe runner is persistent, teardown does not destroy the work volume, build caches are unbounded, or a post-job hook failed.

Safe checks
  • Compare instance age/job count, mount identity, filesystem use, and autoscaler deletion events.
  • Inspect a canary before and after one job; do not delete evidence on a potentially compromised runner.

ResolutionRetire the affected host and restore one-job immutable replacement behavior. Treat cleanup scripts as defense in depth, not a substitute for destruction.

The proposed rollback re-enables a shared persistent runner.

Likely causeCapacity pressure overrode the security boundary or the team lacks a known-good runner image and GitHub-hosted fallback.

Safe checks
  • Identify which repositories, secrets, and networks the persistent runner could access and whether untrusted jobs ever touched it.
  • Review the last known-good image, update status, group policy, and hosted-runner fallback.

ResolutionKeep the questionable runner disabled, restore capacity from a verified immutable image or approved hosted pool, and investigate rather than reusing cross-job state.

After the procedure

Alternatives and next steps

Consider these alternatives

  • Prefer GitHub-hosted runners for ordinary builds and all untrusted pull requests because GitHub supplies a clean managed environment per job.
  • Use Actions Runner Controller for GitHub's recommended Kubernetes autoscaling implementation when Kubernetes is already a trusted, well-operated boundary.
  • Use a separate low-trust ephemeral pool with no secrets/internal network for workloads requiring self-hosted hardware but accepting untrusted input.
  • Use a persistent runner only for a fully trusted, single-repository workload with an explicit risk acceptance, strong cleanup/reimage, and no cross-job secrets; it remains weaker than one-job destruction.

Operate it safely

  • Automate JIT creation and one-job VM destruction with signed immutable images, admission policy, capacity limits, and external event correlation.
  • Create separate runner groups for distinct trust and network boundaries rather than accumulating labels on a universal runner.
  • Add policy checks for full-SHA actions, minimal permissions, environment approvals, fork routing, artifact provenance, and secret scanning.
  • Rehearse organization-wide disablement, credential rotation, compromised-runner investigation, image rollback, and hosted-runner fallback.

Reference

Frequently asked questions

Is --ephemeral enough to clean a runner?

No. GitHub deregisters it after one job, but the infrastructure owner must destroy the machine and work storage. Cleanup scripts alone cannot prove hostile persistence is gone.

Can private-repository pull requests be trusted?

Not automatically. Anyone able to fork and open a pull request can potentially influence executed code. Route such jobs away from the trusted runner until reviewed code reaches a trusted event/ref.

Can I disable automatic runner updates?

Yes for immutable-image management, but GitHub requires updates within 30 days of a release and may block jobs immediately for a critical security update.

Why are labels not a security boundary?

Labels route jobs by declared characteristics. Runner groups and repository access policy decide who is allowed to use the infrastructure.

Recovery

Rollback

Disable the runner group and autoscaler first; do not fall back to a shared persistent runner. Restore capacity from the previous verified immutable image or GitHub-hosted runners, using the same repository/event restrictions. Any runner that executed unexpected code, missed teardown, or used a suspect image is destroyed and its reachable credentials rotated.

  1. Restrict or disable runner-group repository access and pause the autoscaler without deleting organization audit evidence.
  2. Allow queued jobs to use only an explicitly approved GitHub-hosted fallback; cancel jobs that require private capabilities.
  3. Isolate and destroy suspect instances/work disks after external evidence is preserved; remove stale runner records.
  4. Rotate registration/JIT controller credentials plus every repository, environment, registry, cloud, package, and internal identity the runner could reach.
  5. Rebuild the last known-good signed runner image with a currently supported runner release rather than restarting an old persistent host.
  6. Repeat trusted, fork, unauthorized repository, mutable action, one-job, log acknowledgment, and disk-destruction tests before reopening.

Evidence

Sources and review

Verified 2026-07-24Review due 2026-10-22
Self-hosted runners referenceofficialSecure use reference for GitHub ActionsofficialAdding self-hosted runnersofficialManaging access with self-hosted runner groupsofficialRunner groupsofficialUsing labels with self-hosted runnersofficialREST API endpoints for self-hosted runnersofficialRemoving self-hosted runnersofficialOpenID Connect referenceofficial