Skip to content

Guest Contract Reference

The controller pushes a runtime payload into a booted VM over the Incus agent; the guest runs exactly one GitHub Actions job, reports its status through a well-known file and the serial console, and powers itself off. This page documents that interface — the filesystem, JSON schemas, console output, and instance metadata that every runner image must reproduce.

Filesystem contract

/run/incus-gh-runner/ is a directory on the guest's /run tmpfs, created by systemd-tmpfiles with mode 0700, owner root:root.

The controller writes two files into this directory through the Incus agent, in order:

Order File Mode Content
1 payload.json 0600 The payload, JSON-encoded
2 payload.ready 0600 Empty; its presence is the only signal that matters

A path unit watches for payload.ready and starts the one-shot guest service once it exists. The guest service requires both files to be present, parses and validates payload.json, and deletes both files before the Actions Runner process starts. If either file is missing when the service starts, the guest treats this as a fatal startup error, writes an error line to the serial console, and exits.

Machine proofs use a separate public staging directory so the root-only JIT payload boundary does not change. /run/incus-gh-runner-proof/ is root:root mode 0755; the controller writes job-proof.dsse.json first and job-proof.ready last, both root:root mode 0444. The ready marker is the commit point and a committed proof is immutable.

An unprivileged workflow retrieves the committed envelope with:

incus-gh-runner-proof --output <path> [--timeout 60s]

The helper waits for readiness, rejects a missing, empty, or malformed DSSE-shaped document, and copies the envelope to the selected path with mode 0600. It does not verify the signature; external verification uses the enrolled host public key. A timeout or malformed committed proof exits non-zero. The envelope and payload schema are documented in the job proofs reference.

Payload schema (payload.json)

{
  "version": 1,
  "jit_config": "<opaque string>"
}
Field Type Constraint
version integer Must equal 1
jit_config string Non-empty; the opaque GitHub Actions just-in-time runner registration configuration

The object must contain exactly these two keys — no more, no fewer. Any other shape, a version other than 1, or an empty or missing jit_config fails guest-side validation.

Status schema (status.json)

{
  "version": 1,
  "state": "exited",
  "exit_code": 0
}
Field Type Constraint
version integer Always 1
state string One of starting, running, exited, failed
exit_code integer Present once the runner process has exited; the process's exit status

The reference guest writes this file to a temporary path in the same directory and renames it into place, with mode 0600, so a reader never observes a partially written file. It progresses through startingrunningexited over the lifetime of one job. It never emits state: failed; a non-zero exit_code on the exited state is how a failed job is represented. failed is a valid, controller-recognized value for a custom guest that distinguishes runner-process failure from a clean exit.

Controller state mapping

The controller combines Incus and status.json with authoritative GitHub message-session events. Guest state alone never proves that a connected runner is idle:

Signal Runner state
Incus instance status is stopped or error terminal
Incus instance status is running and status.json state is running ready; job occupancy is resolved by the controller
Exact JobStarted event in the current GitHub message session busy and ineligible for scale-down
Incus instance status is running and status.json state is exited or failed terminal
Instance not yet running, or a running instance with an absent status.json or state starting provisioning, until the instance's age exceeds incus.bootstrap_timeout, then terminal

Instance age is measured from the user.incus-gh-runner.created-at metadata value (falling back to the Incus-reported creation time if that key is absent). See Configuration Reference for incus.bootstrap_timeout, and How incus-gh-runner works for the lifecycle states themselves.

Only a guest-file not-found response while the instance still exists means status.json has not appeared yet. A disappeared instance, timeout, transport or permission failure, malformed document, unsupported version, or unknown state invalidates the complete inventory refresh. The controller then retains its last observation and schedules no create or delete mutation until a fresh inventory succeeds. Each runner status read receives an independent bounded share of the overall Incus operation deadline, so one slow guest agent cannot consume the observation budget for later runners.

Only a ready runner created under the current GitHub message-session generation can be considered for idle scale-down. Before changing its Incus state, the controller removes and confirms absence of the runner's exact GitHub registration. The fenced VM remains alive until the guest runner process exits and reports a terminal state. A controller restart or GitHub message-session reconnect invalidates process-local idle knowledge; reconstructed ready runners remain capacity but are not scale-down candidates.

Serial console contract

The guest's serial console is ttyS0. It carries secret-free lifecycle lines only:

Line Emitted when
incus-gh-runner-guest state=<state> Each status.json transition (starting, running, exited)
incus-gh-runner-guest error=missing-ready-marker payload.ready is absent at guest service start
incus-gh-runner-guest error=missing-payload payload.json is absent at guest service start
incus-gh-runner-guest action=poweroff exit_code=<exit_code> grace_seconds=30 Immediately before shutdown, on every code path

After the action=poweroff line, the guest sleeps 30 seconds — a fixed diagnostic grace period — before calling systemctl poweroff. This grace period runs on every exit path, including startup validation failures.

JIT configuration stays out of guest logs, not out of job reach

The guest never writes jit_config to /dev/ttyS0 or its own systemd journal, including on error paths. It does pass the value to the stock Runner.Listener process as a command-line argument. The listener and Runner.Worker run as the same actions-runner UID, and the listener materializes JIT/session files under /opt/actions-runner owned by that user. A running job can therefore read the listener command line and those files. Deleting payload.json removes the root-owned staging copy; it does not create a secrecy boundary between a job and its own JIT registration.

This is a one-job containment boundary. The renewable controller App key or PAT remains on the Incus host and is never injected into the VM. GitHub deletes the JIT runner registration after its job, and the controller deletes the VM rather than reusing it for another job. Workloads must not rely on JIT material being hidden from the job that receives it.

Instance metadata

The controller sets these keys on every instance it creates. The first group is written with the create request; the JIT-binding group is written to the still-stopped instance during creation and re-verified before the VM starts.

With the create request:

Key Value Purpose
user.incus-gh-runner.owner The configured incus.owner value Exact-match cleanup selector; instances without a matching value are excluded from listing and refused on delete, but another project writer can forge it
user.incus-gh-runner.correlation-id Generated UUID Unique per-instance identifier, also used to derive the instance name
user.incus-gh-runner.created-at RFC3339Nano timestamp, UTC Anchor for the incus.bootstrap_timeout calculation
user.incus-gh-runner.image Resolved full image fingerprint Immutable image identity used to create the instance
user.incus-gh-runner.image-reference Configured incus.image value Operator-friendly alias or fingerprint supplied to the controller
user.incus-gh-runner.profiles JSON array of profile names and SHA-256 digests Audit identity for the effective profile configuration and devices materialized at create time
user.incus-gh-runner.launch-configuration-sha256 SHA-256 digest over the exact version 1 launch inputs Launch-identity anchor; re-checked for drift before JIT binding and recorded in the job proof as machine.launch_configuration_sha256

During JIT binding, before the VM starts:

Key Value Purpose
user.incus-gh-runner.jit-runner-id JIT runner registration identifier Binds the instance to its exact GitHub JIT registration
user.incus-gh-runner.jit-runner-name JIT runner name The runner name GitHub assigned; equals the instance name
user.incus-gh-runner.jit-scale-set-id Resolved runner scale-set identifier Records which scale set the registration belongs to
user.incus-gh-runner.instance-uuid Copy of the server-generated volatile.uuid Fences proof delivery and job-to-machine binding to this exact instance; distinct from the Incus-native volatile.uuid key it mirrors

Image aliases are resolved once during controller preflight. The controller creates each VM by the resulting fingerprint. Effective profile configuration and devices are also captured during preflight, revalidated before each create, and written directly onto the instance with an explicit empty attached-profile list. Later alias retargeting or profile edits therefore cannot change an already approved runner environment; profile drift blocks new capacity until the approved profile state is restored or the controller is deliberately restarted and preflight succeeds again.

Diagnostics capture

When incus.diagnostics_dir is configured, the controller captures the instance's serial console log during deletion, after ensuring the instance is stopped and before removing it. The read is capped at 1 MiB; oversized output is truncated within that cap and ends with an explicit marker. The controller safely creates <diagnostics_dir>/<runnerID>.console.log without replacing an existing path, mode 0600, inside a directory created with mode 0700. It retains at most 256 capture files, removing the oldest before a new capture is written. When incus.diagnostics_dir is empty, no diagnostics file is written; captured console output is discarded rather than persisted.

Guest status observations are capped at 64 KiB. Because partial JSON is not authoritative, an oversized status document is rejected instead of truncated and the inventory observation fails closed.

Console diagnostics may contain sensitive output

Captured console content may include sensitive workload output. Diagnostics files must be handled with the same care as other job-adjacent artifacts.

Shipped guest components

The repository ships the guest side of this contract as installable files under guest/. They are the canonical implementation; a custom image may install them verbatim or reimplement the same behavior.

File Installed as Role
incus-gh-runner-guest /usr/local/libexec/incus-gh-runner-guest (0755) One-shot guest entrypoint: validates and consumes the payload, runs the Actions Runner as the unprivileged runner user, publishes status, powers off
incus-gh-runner-proof /usr/local/bin/incus-gh-runner-proof (0755) Unprivileged machine-proof retrieval helper
incus-gh-runner-guest.service /usr/lib/systemd/system/incus-gh-runner-guest.service (0644) Oneshot service unit for the entrypoint (UMask=0077, runner self-update disabled)
incus-gh-runner-guest.path /usr/lib/systemd/system/incus-gh-runner-guest.path (0644) Guest bootstrap trigger, watching for payload.ready; enabled at image build time
incus-gh-runner.conf /usr/lib/tmpfiles.d/incus-gh-runner.conf (0644) Creates /run/incus-gh-runner (0700) and /run/incus-gh-runner-proof (0755) at boot

For building an image around these components, see Build a hardened runner image.

See also