CHROOT.DEV BLOG2026-10-19CHROOT.DEV BLOG

Run your tests on OpenBSD -current from GitHub Actions

One step in a workflow copies a prepared OpenBSD VM, runs go test on it and removes it. The whole job took 11 s.

chroot-dev/example has a Go test that only passes on OpenBSD: it calls pledge(2) and unveil(2) and checks that they hold. GitHub's runners are Linux, so the workflow sends the test to an OpenBSD -current VM on chroot.dev with chroot-action. In one run, the VM answered ssh 1.6 s after the copy started, go test took 4 s, and the whole job 11 s, VM removed included.

gh workflow run, then gh run watch until success

This post sets that up for your own repository: the workflow, a key that can only do CI, and a VM prepared once so each job does not install Go again.

Restrict leaves one directory readable and hides the rest of the file system:

func Restrict(dir string) error {
	if err := unix.Unveil(dir, "r"); err != nil {
		return err
	}
	if err := unix.UnveilBlock(); err != nil {
		return err
	}
	return unix.Pledge("stdio rpath", "")
}

pledge and unveil last for the whole process, so the test runs itself again as a child, restricts the child, and checks there that a file inside the directory opens and /etc/passwd does not exist. On Linux it is skipped:

$ go test -v ./...
=== RUN   TestRestrict
    sandbox_test.go:17: pledge and unveil are OpenBSD only
--- SKIP: TestRestrict (0.00s)

jobs:
  test:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false

      - uses: chroot-dev/chroot-action@v1
        with:
          ssh-key: ${{ secrets.CHROOT_SSH_KEY }}
          from: ci-go

      - shell: chroot {0}
        run: go test -v ./...

The action creates a VM for the job, here by copying ci-go, and removes it when the job ends, whether it passed or not. Every step with shell: chroot {0} runs on the VM as puffy, in ~/work, after the workspace is copied there. The output streams back to the job log, and the step fails with the command's exit status:

Copying ci-go to gh-38595228229-1-d1bdf6
gh-38595228229-1-d1bdf6 ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
ssh in 669 ms
=== RUN   TestRestrict
--- PASS: TestRestrict (0.01s)
PASS
ok  	github.com/chroot-dev/example	0.021s
Removing gh-38595228229-1-d1bdf6

The secret is an ssh private key, so give CI one of its own, restricted with --ci:

$ ssh-keygen -t ed25519 -N '' -C ci-example -f chroot-ci
$ ssh chroot.dev ssh-key add --ci --from ci-go < chroot-ci.pub
SHA256:n+bDQ658HDqAL6b/QiDRf0HIsCbKqNXZ+4jM7oSd8us: not added; to add it, sign a challenge with its private key within 15 minutes: echo NONCE | ssh-keygen -Y sign -n add-key@chroot.dev -f PRIVATE_KEY | ssh chroot.dev ssh-key add
$ echo NONCE | ssh-keygen -Y sign -n add-key@chroot.dev -f chroot-ci |
    ssh chroot.dev ssh-key add
SHA256:n+bDQ658HDqAL6b/QiDRf0HIsCbKqNXZ+4jM7oSd8us added
$ gh secret set CHROOT_SSH_KEY < chroot-ci
$ rm chroot-ci
$ ssh chroot.dev ssh-key ls
...
SHA256:n+bDQ658HDqAL6b/QiDRf0HIsCbKqNXZ+4jM7oSd8us ci-example (ci: VMs it created, cp from ci-go)

With that key, CI can create VMs, copy ci-go, and reach, inspect and remove the VMs it created. It cannot see or touch the rest of the account. A key's restriction cannot be changed later: add a new key and remove the old one.

Installing Go and building the standard library on every run is slow: the first go test on a fresh VM took 3 min 42 s with 1 cpu and the default 128 MiB, most of it building and swapping. So ci-go is a VM with room to build, 2 cpus and 1 GiB, that has done that once:

$ ssh chroot.dev new -n ci-go -c 2 -m 1G -w -s /dev/stdin <<'EOF'
doas pkg_add -I go
EOF

Then run the tests on it once, so the module and build caches are warm, and leave it running. cp(1) copies a running VM with its memory, so each job's copy starts where ci-go was, caches included. The second run on this repository copied it in 1.6 s; the first copy of a new template took 6 s.

When the dependencies change, update ci-go (or make ci-go-2 and point from: at it) and the next job copies the new one.

GitHub does not give secrets to workflows of pull requests from forks, so the action fails there. Do not work around it with pull_request_target: it runs the fork's code with your secrets. The example runs on pushes to main and by hand only.

Each job's VM counts against the account's plan while it exists, and so does ci-go, running or stopped. A copy keeps the template's size, so ci-go plus one job takes 4 cpus and 2 GiB, all of p2. On p1 (2 cpus, 1 GiB), make the template 1 cpu and 512 MiB; jobs that run in parallel need a plan with room for them. Traffic between the runner and the VM counts too.

Other limits: the action needs a Linux runner, and with sync: both the files that come back from the VM are output of the VM, to be treated as such by later steps. The README has every input.

Invites: invite@chroot.dev. Questions: contact@chroot.dev.

Murilo Ijanc

chroot.devOctober 19, 2026CHROOT.DEV BLOG