Skip to content

docs(runner-images): stock images work; drop the "must extend the runner base" claim - #170

Merged
luthermonson merged 2 commits into
mainfrom
docs/runner-images-stock
Aug 18, 2026
Merged

docs(runner-images): stock images work; drop the "must extend the runner base" claim#170
luthermonson merged 2 commits into
mainfrom
docs/runner-images-stock

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

docs/guides/runner-images.md:37 claimed "custom images must extend the upstream GitHub Actions runner base image", and line 12 asserted the same more quietly ("the runner binary lives inside the image").

Both are wrong, and the false premise had spawned three further errors.

What the code does — all three mechanisms verified

  1. pkg/runner extracts the runner. go:embed all:embed at runner.go:18; Manager.Extract() at :64 unpacks to <data-dir>/runners/<version>-<goos> (:43). Fetched at build time by mage, SHA-pinned — never at runtime.
  2. pkg/runtime bind-mounts it in. Per-job copy at runtime.go:952; withRunnerMount at :1514 mounts rbind,rw at /actions-runner (Linux) or an HCS mapped dir at C:\actions-runner (Windows). Entrypoint oci.WithProcessArgs("/actions-runner/run.sh", ...) at :930.
  3. The Windows default has no runner. pkg/runtime/image_windows.go:9-13 returns bare mcr.microsoft.com/windows/servercore. Every Windows job already runs the stock-image path.

The decisive detail: customImage := image != "" && !isOfficialRunnerImage(image) (runtime.go:805). Deriving from the official base is the special case that suppresses the mount. Stock is the normal path.

Errors that followed from the false premise

  • The Windows Dockerfile example and reference table cited ghcr.io/actions/actions-runner:latest-win, which does not exist. The doc had invented a Windows runner base because it believed one was required. The real base is mcr.microsoft.com/windows/servercore:ltsc2025.
  • Per-repo override example used [runner.repo_images], not in the schema. Real shape is [runner.images.<repo>] with linux/windows keys (pkg/config/config.go:1357-1365).
  • Stale images/ link pointed at a feature branch.

Real constraints, documented rather than hand-waved

Nothing runner-related, but genuine ones exist. Linux: /bin/bash (the mounted run.sh is #!/bin/bash, execved directly), and a glibc userland with libkrb5-3, zlib1g, liblttng-ust, libssl, libicu. musl/Alpine does not work — which also explains why the runner's bundled node20 will not run there. Node.js is not needed. No USER/WORKDIR is set and RUNNER_ALLOW_RUNASROOT=1, so root-by-default stock images are fine. Capability set excludes CAP_SYS_ADMIN/CAP_NET_ADMIN; /etc/hosts and /etc/resolv.conf are mounted read-only over whatever the image ships. Windows needs cmd.exe (Server Core, not Nano).

Added

golang:1.26.6-bookworm as a worked example — resolves arm64 natively from the same tag, and lets a project delete an entire run-time C-toolchain step.

Plus the gotcha: go build inside a container fails with error obtaining VCS status: exit status 128, and ${{ github.workspace }} is the host path so marking it safe does not work. git config --global --add safe.directory "*" is what works — documented with why the obvious fix fails (_work/__w remap).

Also notes that the servercore default ships no Docker CLI (image_windows.go:12 vs images/runner-ci-windows/Dockerfile:86-89), so no docker in steps, no dind, no container:.

…ner base" claim

The guide asserted that custom images must extend
ghcr.io/actions/actions-runner because "the base includes the runner binary
that ephemerd needs". The code does not require that, and the claim
discourages the simplest thing that works.

What the code actually does:

- pkg/runner embeds the Actions runner archive in the ephemerd binary
  (runner.go:18) and extracts it onto the host (runner.go:64).
- pkg/runtime bind-mounts a per-job copy into the container at
  /actions-runner (runtime.go:952, :1514) and execs
  /actions-runner/run.sh (runtime.go:860, :930).
- The Windows default image is bare Server Core with no runner in it at
  all (image_windows.go:9-13), so the "stock image" path is already what
  every Windows job takes.

Deriving from the official image is the special case, not the rule:
isOfficialRunnerImage (runtime.go:1660) matches a short ref allowlist and
is the only thing that suppresses the mount.

Changes:

- Rewrite "How it works" and the defaults table around the two real
  paths, with file:line citations.
- Add "Use a stock image" with golang:1.26.6-bookworm as the worked
  example, including native arm64 from the same tag and dropping
  run-time toolchain installs.
- Add "What the image actually has to provide": bash, glibc + the .NET
  runner's libs (musl/Alpine does not work), coreutils, CA certs; Node
  is not needed. Note the capability set and the read-only /etc mounts.
- Document the "error obtaining VCS status: exit status 128" failure and
  the safe.directory "*" fix, and why pointing safe.directory at
  ${{ github.workspace }} cannot work.
- Note that the Windows Server Core default ships no Docker CLI.
- Fix images/runner-ci-windows' base: it is Server Core, not the
  nonexistent actions-runner:latest-win.
- Fix the per-repo override example: the key is [runner.images.<repo>]
  with per-OS entries, not [runner.repo_images].
…two overreaches

Review follow-ups on the stock-image guide.

1. Missed requirement: the mounted runner dir must be writable by the
   container's uid. run.sh writes run-helper.sh into its own directory
   before the job starts, and that directory arrives owned by the ephemerd
   daemon uid. There is no Chown or Chmod in pkg/runner or pkg/runtime;
   extraction is MkdirAll(0o755) as the daemon uid (runner.go:79) and the
   per-job copy is cp -al (runtime.go:1589), so hardlinks carry the
   original ownership and mode through unchanged. Add a "Running as a
   non-root USER" subsection: root, or a uid that can write a root-owned
   0755 dir. ephemerd does grant CAP_DAC_OVERRIDE for this — the capability
   list annotates it "write to dirs owned by other users" (runtime.go:60) —
   so non-root is expected to work, but it is a dependency worth naming,
   and the Linux example's trailing `USER runner` now says so.

2. run.sh does not exec node20. The chain is run.sh -> run-helper.sh ->
   bin/Runner.Listener (.NET); runsvc.sh is the service path and ephemerd
   does not use it. The conclusions were right, the citation was not.

3. The github.workspace explanation claimed more than the source supports.
   The runner does translate paths for a container step host, and part of
   that sits behind the server-delivered flag
   DistributedTask.UseContainerPathForTemplate, so "the two never agree"
   is wrong. Reframe as observed behavior, and fix the path: on the
   mounted-runner path _work is under /actions-runner, not /home/runner.

Also: complete the official-ref list (@digest, docker.io/ prefix), add the
global [runner] default_image rung to the resolution order (runtime.go:806-811),
and scope "One Image, Every Host" to Linux jobs so it stops contradicting
the Windows notes.
@luthermonson
luthermonson merged commit 14b0a94 into main Aug 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant