This Action runs your CI inside a fresh Containarium box that you can SSH into when it fails. This doc explains the end-to-end flow and why each non-obvious step is the way it is — so future changes don't reintroduce bugs we've already paid for.
If you just want to use the Action, see the README. This is the "how it's wired and why" companion.
Every job runs these steps (see action.yml):
- Install the CLI — used only for
expose-port(PR previews). Create / delete / push talk to the REST API directly (see below). - Parse
containarium.yml— image,setup,test, optionalserve. Parsed withpython3 + PyYAML(present on every Linux runner) rather thanyq(not preinstalled on self-hosted runners). - Mint a throwaway SSH keypair — a per-run
ed25519pair under$RUNNER_TEMP. The job never carries a long-lived key; GHA discards this one at job end. - Create the box —
POST /v1/containersover curl/HTTP1.1 (not the CLI; see "Why curl" below). Async: returns immediately with the box in aCREATING/PENDINGstate. 4b. Start the TTL lease — stamp a short birth TTL immediately and launch a background heartbeat that renews it every half-window while the job runs. This is the blanket leak guard (see "The lease" below): the box is always reaped within one window once the heartbeat stops. - Wait until ready — poll the cloud for
RUNNING, then probe SSH until it answers. Create is async, so this avoids racing a half-provisioned box. - Push the working tree —
containarium syncinto~/work(see "Source transfer"). - Write CI context —
~/work/.containarium/ci-context.json(repo, PR, commit, run URL) so an agent attached to a failing box has context. - Run
setupthentest— over SSH,cd ~/work && <cmd>. - Expose (only if
serve:is defined) — wire a public preview hostname. 9b. Stop the TTL lease heartbeat (always()) — so the final-state TTL below wins instead of being renewed back down to the lease window. - Teardown on success —
DELETE /v1/containers/<box>. - On failure (PR +
keep-on-failure) — replace the lease with the boundeddebug-ttlwindow, refresh the CI context with the failing test- log tail, post a PR comment with the SSH/MCP debug command, and report the debug box to the cloud dashboard.
The leak this Action used to have: a box only got a TTL on the failure path. A job that was cancelled (neither success nor failure) or whose runner died mid-run reached neither the success teardown nor the failure TTL — so the box ran forever. Push-event failures and the create→ready window (image pull) leaked too.
The fix (#526) is a lease, not a one-shot:
- Birth TTL — right after create, stamp a short
lease-ttl(default 20m). Even if everything after this dies, the box self-reaps within the window. - Heartbeat — a background loop renews the lease every
lease-ttl/2while the job runs, so a legitimately long job never gets reaped mid-run. It persists across steps (the runner doesn't kill background processes until job cleanup) and dies with the job / runner. always()stop — before the final-state TTL, the heartbeat is killed so the success-delete / failure-debug-ttlis the last word.
Result: success → deleted immediately; failure → kept for debug-ttl
(default 1h, bounded — never infinite) then self-reaps; cancel / runner
death → no more renews, reaped within one lease window. No path leaks.
Renew uses SetContainerTTL (POST /ttl), whose semantics are
ttl_expires_at = now() + duration — i.e. renew-from-now, exactly a lease.
The create request also carries ttlSeconds (the lease window) directly, so
on a cloud/daemon at OSS v0.24.0+ the box is born with its TTL atomically
— there is no create→stamp window at all (FootprintAI/Containarium#523). On an
older deployment that field is an unknown the create decoder drops, and the
synchronous stamp in the lease step one RPC later covers it — so a box is never
without a TTL regardless of the deployed version. Either way the heartbeat then
renews from there.
This is the part that's caused the most bugs, so internalize it:
ssh <ssh-user>@<ssh-host>
│ └── the regional sentinel's public hostname
└── the box's cloud-assigned username, "cld-<short-id>"
ssh-useriscld-<id>— the username the cloud assigns the box, NOT the name you sent at create. The jump server (sshpiper) routes by this username, and the cloud addresses the box by it onGET/DELETE. The name you pass at create is only the box's human handle.ssh-hostcomes from the create response (.container.sshHost) — the client cannot derive it: the cloud's region code is not the public DNS label, so only the cloud knows which sentinel fronts the box. The Action readsssh-user+ssh-hoststraight from the create response and dials${ssh-user}@${ssh-host}. (There's anssh-hostinput as a stopgap for a cloud not yet returningsshHost.)
Never reconstruct the SSH host from the box name or region — that's the bug
that produced ssh: Could not resolve hostname <box-name>.
At create we send the public key's contents in sshKeys. The daemon bakes
that key into the box and registers the box's jump-server account, so sshpiper
authenticates the runner's private key and proxies into the box. The login is a
non-root box user, so we work under its home (~/work), not at the
filesystem root — mkdir /workspace fails with Permission denied.
⚠️ Send the key contents, not the file path.sshKeys: [<path-to>.pub]stores the literal path as the "key"; the daemon then rejects every create withssh_keys[0]: not a valid SSH public key: ssh: no key found. Alwayscatthe.pubfile into the request.
We push the working tree with a tar stream over ssh into the login user's
~/work:
tar -czf - . | ssh $SSHOPTS "$SSH_USER@$SSH_HOST" "mkdir -p ~/work && tar -xzf - -C ~/work"~ expands to the cld-<id> login user's writable home, and combining
mkdir + extract in one remote command makes it cwd-independent. Then
setup/test run with cd ~/work.
Why tar, and not the obvious alternatives:
- Not
rsync. Base box images shiptarbut notrsync, so an rsync push dies withrsync: command not found→ protocol error code 12. - Not
/workspace. The box logs us in as a non-root user, somkdir /workspace→Permission denied.~/workis the login user's home, writable whatever user the box assigns. - Not
containarium sync(yet). It's the right tool in principle — the platform's own primitive (the CLI behind the MCPsync/pushtools), tar-based, with diffing +--delete. But its first step reads a remote manifest (find+sha256sumat the remote path), and that fails in the cloud box's ssh session with an opaqueread remote manifest: ssh manifest: exit status 1— even though the identical script runs fine viasu - <cld-user>on the box, and there's no flag to skip the manifest read. Filed upstream; tar only needsmkdir+ extract, so it sidesteps it. Reach back forsynconce the session-specific manifest failure is resolved. - Not
containarium push. Realgit push, and it refuses a detached HEAD (detached HEAD; pass --branch); CI's checkout is a shallow, detached merge ref.
The Go CLI's REST client auto-negotiates HTTP/2, and a long-running create carried over HTTP/2 through the fronting TLS edge intermittently resets the response even though the box provisioned. curl defaults to HTTP/1.1 and sidesteps it. Create is also made idempotent: on a non-2xx we check whether the box exists before retrying, and fail fast on a permanent 4xx.
Create returns before the box exists; the cloud may also queue the
actuation under burst (many parallel jobs). The "wait until ready" step is what
makes this transparent — it polls the cloud for RUNNING (keyed by cld-<id>)
and then probes SSH. If a box never reaches RUNNING within the timeout, the
step fails loudly rather than letting a later step hit a phantom host.
- Self-hosted runners are minimal. Don't assume
gh,yq, orrsync. We use curl +python3+tar, which are universally present, and fall back to GitHub's REST API whenghis missing (PR comments). - The debug-box SSH the PR comment prints needs a key the box trusts. The CI keypair is throwaway (gone at job end), so a human debugger must be a registered collaborator with their own key on the box.