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:
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)¶
| 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)¶
| 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 starting → running → exited 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¶
- Configuration Reference —
incus.owner,incus.image,incus.bootstrap_timeout,incus.diagnostics_dir - Job Proofs Reference — the DSSE envelope and payload schema behind
job-proof.dsse.json - Build a hardened runner image — building and boot-testing an image against this contract
- How incus-gh-runner works — runner lifecycle states and the cleanup boundary