detached be87239

feat(images): preinstall Go and Node.js in the tool cache of the job image

commit
be87239e26b6225ea1960ec349d7639826e26b48
author
brice · · 2026-10-21 05:29 UTC
parent
e52d13c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +143 -2
M docs/images.md +16 -2
@@ -7,7 +7,7 @@ A job runs in the image named by `ERMITE_RUNNER_IMAGE`.
77 | `container` | OCI reference | Default `ermite-runner-ubuntu-26.04:latest` |
88 | `vm` | Directory | `vmlinux` (`Image` on arm64) and `base.ext4`. Default `<state dir>/image` |
99
10-Both come from `images/ubuntu-26.04/Dockerfile` (Ubuntu 26.04 LTS). The runner executable and Node.js are attached by the host. They are never part of the image.
10+Both come from `images/ubuntu-26.04/Dockerfile` (Ubuntu 26.04 LTS). The runner executable and action runtime Node.js are attached by the host. The image also includes system Node.js and the cached toolchains listed below.
1111
1212 ## Build
1313
@@ -26,9 +26,23 @@ scripts/build-image.sh tmp/image # builds the OCI image and tmp/image/base.ex
2626 ## What the image must contain
2727
2828 - User `runner`, uid and gid 1001, home `/home/runner`. Steps run as this user. Without it, steps run as root (test images only).
29-- `/home/runner/work` and `/opt/hostedtoolcache`, owned by `runner`.
29+- `/home/runner/work` and `/opt/hostedtoolcache`, owned by `runner`. The tool cache holds the toolchains listed in `images/ubuntu-26.04/toolcache.lock` for the image architecture.
3030 - `bash` and `sh`. `python3` and a `python` command for `shell: python`.
3131 - `dockerd` and passwordless `sudo` for `runner`, for jobs that use Docker.
3232 - Common tools: git, curl, jq, build-essential, ca-certificates, tzdata, zip and unzip.
3333
3434 `pwsh` is not in the image. A step that asks for it fails with `pwsh: command not found`.
35+
36+## Toolchains
37+
38+`images/ubuntu-26.04/toolcache.lock` pins Go and Node.js archives by version,
39+action architecture (`x64` or `arm64`), official SHA-256 and download URL.
40+To add a version, add a line for each supported architecture with the checksum
41+from go.dev's download JSON or nodejs.org's `SHASUMS256.txt`, then rebuild with
42+`scripts/build-image.sh tmp/image`. A checksum mismatch fails the build.
43+
44+The installer uses the `@actions/tool-cache` layout, including the sibling
45+`<arch>.complete` marker. `actions/setup-go` with an exact version, including
46+one read from `go.mod`, uses the cache only for an exact match. A range such as
47+`1.27` selects the highest matching cached version. Versions not in the cache
48+still download normally (and `check-latest: true` may check upstream).
@@ -7,7 +7,7 @@ A job runs in the image named by `ERMITE_RUNNER_IMAGE`.
7 | `container` | OCI reference | Default `ermite-runner-ubuntu-26.04:latest` |7 | `container` | OCI reference | Default `ermite-runner-ubuntu-26.04:latest` |
8 | `vm` | Directory | `vmlinux` (`Image` on arm64) and `base.ext4`. Default `<state dir>/image` |8 | `vm` | Directory | `vmlinux` (`Image` on arm64) and `base.ext4`. Default `<state dir>/image` |
9 9
10-Both come from `images/ubuntu-26.04/Dockerfile` (Ubuntu 26.04 LTS). The runner executable and Node.js are attached by the host. They are never part of the image.10+Both come from `images/ubuntu-26.04/Dockerfile` (Ubuntu 26.04 LTS). The runner executable and action runtime Node.js are attached by the host. The image also includes system Node.js and the cached toolchains listed below.
11 11
12 ## Build12 ## Build
13 13
@@ -26,9 +26,23 @@ scripts/build-image.sh tmp/image # builds the OCI image and tmp/image/base.ex
26 ## What the image must contain26 ## What the image must contain
27 27
28 - User `runner`, uid and gid 1001, home `/home/runner`. Steps run as this user. Without it, steps run as root (test images only).28 - User `runner`, uid and gid 1001, home `/home/runner`. Steps run as this user. Without it, steps run as root (test images only).
29-- `/home/runner/work` and `/opt/hostedtoolcache`, owned by `runner`.29+- `/home/runner/work` and `/opt/hostedtoolcache`, owned by `runner`. The tool cache holds the toolchains listed in `images/ubuntu-26.04/toolcache.lock` for the image architecture.
30 - `bash` and `sh`. `python3` and a `python` command for `shell: python`.30 - `bash` and `sh`. `python3` and a `python` command for `shell: python`.
31 - `dockerd` and passwordless `sudo` for `runner`, for jobs that use Docker.31 - `dockerd` and passwordless `sudo` for `runner`, for jobs that use Docker.
32 - Common tools: git, curl, jq, build-essential, ca-certificates, tzdata, zip and unzip.32 - Common tools: git, curl, jq, build-essential, ca-certificates, tzdata, zip and unzip.
33 33
34 `pwsh` is not in the image. A step that asks for it fails with `pwsh: command not found`.34 `pwsh` is not in the image. A step that asks for it fails with `pwsh: command not found`.
35+
36+## Toolchains
37+
38+`images/ubuntu-26.04/toolcache.lock` pins Go and Node.js archives by version,
39+action architecture (`x64` or `arm64`), official SHA-256 and download URL.
40+To add a version, add a line for each supported architecture with the checksum
41+from go.dev's download JSON or nodejs.org's `SHASUMS256.txt`, then rebuild with
42+`scripts/build-image.sh tmp/image`. A checksum mismatch fails the build.
43+
44+The installer uses the `@actions/tool-cache` layout, including the sibling
45+`<arch>.complete` marker. `actions/setup-go` with an exact version, including
46+one read from `go.mod`, uses the cache only for an exact match. A range such as
47+`1.27` selects the highest matching cached version. Versions not in the cache
48+still download normally (and `check-latest: true` may check upstream).
M images/ubuntu-26.04/Dockerfile +4 -0
@@ -78,6 +78,10 @@ RUN useradd --create-home --uid 1001 --shell /bin/bash runner \
7878 ENV RUNNER_TOOL_CACHE=/opt/hostedtoolcache \
7979 LANG=C.UTF-8
8080
81+COPY toolcache.lock install-toolcache.sh /tmp/toolcache/
82+RUN sh /tmp/toolcache/install-toolcache.sh /tmp/toolcache/toolcache.lock \
83+ && rm -rf /tmp/toolcache
84+
8185 # The runner executable is bind-mounted (container) or provided by the init
8286 # disk (vm) here; its init subcommand starts the job's own Docker daemon.
8387 ENTRYPOINT ["/opt/ermite/ermite-runner", "init"]
@@ -78,6 +78,10 @@ RUN useradd --create-home --uid 1001 --shell /bin/bash runner \
78 ENV RUNNER_TOOL_CACHE=/opt/hostedtoolcache \78 ENV RUNNER_TOOL_CACHE=/opt/hostedtoolcache \
79 LANG=C.UTF-879 LANG=C.UTF-8
80 80
81+COPY toolcache.lock install-toolcache.sh /tmp/toolcache/
82+RUN sh /tmp/toolcache/install-toolcache.sh /tmp/toolcache/toolcache.lock \
83+ && rm -rf /tmp/toolcache
84+
81 # The runner executable is bind-mounted (container) or provided by the init85 # The runner executable is bind-mounted (container) or provided by the init
82 # disk (vm) here; its init subcommand starts the job's own Docker daemon.86 # disk (vm) here; its init subcommand starts the job's own Docker daemon.
83 ENTRYPOINT ["/opt/ermite/ermite-runner", "init"]87 ENTRYPOINT ["/opt/ermite/ermite-runner", "init"]
A images/ubuntu-26.04/install-toolcache.sh +30 -0
@@ -0,0 +1,30 @@
1+#!/bin/sh
2+# SPDX-License-Identifier: MIT
3+set -eu
4+
5+case "$(dpkg --print-architecture)" in
6+ amd64) arch=x64 ;;
7+ arm64) arch=arm64 ;;
8+ *) echo 'unsupported toolcache architecture' >&2; exit 1 ;;
9+esac
10+cache=${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}
11+tmp=$(mktemp -d)
12+trap 'rm -rf "$tmp"' EXIT HUP INT TERM
13+while read -r tool version entry_arch sha url; do
14+ case "$tool" in ''|'#'*) continue ;; esac
15+ [ "$entry_arch" = "$arch" ] || continue
16+ case "$tool" in go|node) ;; *) echo "unsupported tool: $tool" >&2; exit 1 ;; esac
17+ case "$url" in
18+ *.tar.gz) compression=z ;;
19+ *.tar.xz) compression=J ;;
20+ *) echo "unsupported archive: $url" >&2; exit 1 ;;
21+ esac
22+ curl -fsSL "$url" -o "$tmp/archive"
23+ printf '%s %s\n' "$sha" "$tmp/archive" | sha256sum -c -
24+ dest="$cache/$tool/$version/$arch"
25+ mkdir -p "$dest"
26+ tar -x"$compression"f "$tmp/archive" -C "$dest" --strip-components=1 --no-same-owner
27+ touch "$dest.complete"
28+ rm "$tmp/archive"
29+done < "${1:?usage: install-toolcache.sh LOCK_FILE}"
30+chown -R runner:runner "$cache"
@@ -0,0 +1,30 @@
1+#!/bin/sh
2+# SPDX-License-Identifier: MIT
3+set -eu
4+
5+case "$(dpkg --print-architecture)" in
6+ amd64) arch=x64 ;;
7+ arm64) arch=arm64 ;;
8+ *) echo 'unsupported toolcache architecture' >&2; exit 1 ;;
9+esac
10+cache=${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}
11+tmp=$(mktemp -d)
12+trap 'rm -rf "$tmp"' EXIT HUP INT TERM
13+while read -r tool version entry_arch sha url; do
14+ case "$tool" in ''|'#'*) continue ;; esac
15+ [ "$entry_arch" = "$arch" ] || continue
16+ case "$tool" in go|node) ;; *) echo "unsupported tool: $tool" >&2; exit 1 ;; esac
17+ case "$url" in
18+ *.tar.gz) compression=z ;;
19+ *.tar.xz) compression=J ;;
20+ *) echo "unsupported archive: $url" >&2; exit 1 ;;
21+ esac
22+ curl -fsSL "$url" -o "$tmp/archive"
23+ printf '%s %s\n' "$sha" "$tmp/archive" | sha256sum -c -
24+ dest="$cache/$tool/$version/$arch"
25+ mkdir -p "$dest"
26+ tar -x"$compression"f "$tmp/archive" -C "$dest" --strip-components=1 --no-same-owner
27+ touch "$dest.complete"
28+ rm "$tmp/archive"
29+done < "${1:?usage: install-toolcache.sh LOCK_FILE}"
30+chown -R runner:runner "$cache"
A images/ubuntu-26.04/toolcache.lock +11 -0
@@ -0,0 +1,11 @@
1+# tool version arch sha256 url
2+go 1.27.3 x64 65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40 https://go.dev/dl/go1.27.3.linux-amd64.tar.gz
3+go 1.27.3 arm64 a06e99309dbcbdca1a8ccd2282c3ee146e55930886e26b4f1193269587165f49 https://go.dev/dl/go1.27.3.linux-arm64.tar.gz
4+go 1.27.1 x64 63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445 https://go.dev/dl/go1.27.1.linux-amd64.tar.gz
5+go 1.27.1 arm64 3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec https://go.dev/dl/go1.27.1.linux-arm64.tar.gz
6+go 1.26.10 x64 ccfe7ae822d2c2f41f4fe7c956199967a4dac22d0ee0d2b2bfdb440aab3b20c5 https://go.dev/dl/go1.26.10.linux-amd64.tar.gz
7+go 1.26.10 arm64 3f4227ce7b206a513e794f1393adb473a07b46e94d18bd15d1c684bbb7319797 https://go.dev/dl/go1.26.10.linux-arm64.tar.gz
8+node 24.21.0 x64 fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6 https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.xz
9+node 24.21.0 arm64 6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2 https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.xz
10+node 22.23.3 x64 df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de https://nodejs.org/dist/v22.23.3/node-v22.23.3-linux-x64.tar.xz
11+node 22.23.3 arm64 a44aeb94849a299b22df10b9e622ec2f605c2183501bc40590705131de7c740f https://nodejs.org/dist/v22.23.3/node-v22.23.3-linux-arm64.tar.xz
@@ -0,0 +1,11 @@
1+# tool version arch sha256 url
2+go 1.27.3 x64 65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40 https://go.dev/dl/go1.27.3.linux-amd64.tar.gz
3+go 1.27.3 arm64 a06e99309dbcbdca1a8ccd2282c3ee146e55930886e26b4f1193269587165f49 https://go.dev/dl/go1.27.3.linux-arm64.tar.gz
4+go 1.27.1 x64 63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445 https://go.dev/dl/go1.27.1.linux-amd64.tar.gz
5+go 1.27.1 arm64 3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec https://go.dev/dl/go1.27.1.linux-arm64.tar.gz
6+go 1.26.10 x64 ccfe7ae822d2c2f41f4fe7c956199967a4dac22d0ee0d2b2bfdb440aab3b20c5 https://go.dev/dl/go1.26.10.linux-amd64.tar.gz
7+go 1.26.10 arm64 3f4227ce7b206a513e794f1393adb473a07b46e94d18bd15d1c684bbb7319797 https://go.dev/dl/go1.26.10.linux-arm64.tar.gz
8+node 24.21.0 x64 fd8e59d5a511510f6a298afb548f18c7d2b1be404d8b4a27d94fbe49f56cb2d6 https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.xz
9+node 24.21.0 arm64 6ad1325edbdb5649c379b75a237147a666c95d4f9ae8d340fef2d1575d289ad2 https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.xz
10+node 22.23.3 x64 df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de https://nodejs.org/dist/v22.23.3/node-v22.23.3-linux-x64.tar.xz
11+node 22.23.3 arm64 a44aeb94849a299b22df10b9e622ec2f605c2183501bc40590705131de7c740f https://nodejs.org/dist/v22.23.3/node-v22.23.3-linux-arm64.tar.xz
A internal/e2e/toolcache_test.go +82 -0
@@ -0,0 +1,82 @@
1+// SPDX-License-Identifier: MIT
2+
3+package e2e
4+
5+import (
6+ "context"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite-runner/internal/execstream"
13+ "bnema.dev/ermite-runner/internal/execstream/execpb"
14+ "bnema.dev/ermite-runner/internal/sandbox"
15+)
16+
17+func TestContainerSetupGoFindsToolcache(t *testing.T) {
18+ image := needE2E(t)
19+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
20+ defer cancel()
21+ // Distinguish a missing cache from an unavailable image or Docker daemon.
22+ out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--entrypoint", "sh", image, "-c", "if test -d /opt/hostedtoolcache/go/1.27.1; then echo present; else echo absent; fi").CombinedOutput()
23+ if err != nil {
24+ t.Fatalf("probe toolcache: %v\n%s", err, out)
25+ }
26+ if strings.TrimSpace(string(out)) == "absent" {
27+ t.Skip("test image has no Go 1.27.1 toolcache")
28+ }
29+ d := newDriver(t, image)
30+ sb, err := d.New()
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ defer func() {
35+ if err := sb.Stop(context.Background()); err != nil {
36+ t.Error(err)
37+ }
38+ }()
39+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "e2e-toolcache"})
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ spec := jobSpec(`on: push
44+jobs:
45+ j:
46+ runs-on: x
47+ steps:
48+ - uses: actions/setup-go@v7
49+ with:
50+ go-version: '1.27.1'
51+ cache: false
52+ - run: go version
53+`)
54+ var logs []string
55+ var result *execpb.JobResult
56+ _, exit, err := execstream.DriveJob(conn, &execpb.Run{Job: spec}, nil, execstream.JobHandler{
57+ Resolve: func(_ context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse {
58+ resp := &execpb.ResolveResponse{}
59+ for _, ref := range r.Uses {
60+ if ref != "actions/setup-go@v7" {
61+ return &execpb.ResolveResponse{Error: "unexpected action: " + ref}
62+ }
63+ resp.Actions = append(resp.Actions, &execpb.ResolvedAction{Ref: ref, NameWithOwner: "actions/setup-go", ResolvedSha: "v7", TarballUrl: "https://codeload.github.com/actions/setup-go/tar.gz/refs/tags/v7"})
64+ }
65+ return resp
66+ },
67+ Log: func(l *execpb.Log) { logs = append(logs, l.Text) },
68+ JobResult: func(r *execpb.JobResult) { result = r },
69+ })
70+ all := strings.Join(logs, "\n")
71+ if err != nil {
72+ t.Fatalf("drive: %v\n%s", err, all)
73+ }
74+ if result == nil || result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || exit == nil || exit.Code != 0 {
75+ t.Fatalf("result %v exit %v\n%s", result, exit, all)
76+ }
77+ for _, want := range []string{"Found in cache", "go version go1.27.1"} {
78+ if !strings.Contains(all, want) {
79+ t.Errorf("log lacks %q\n%s", want, all)
80+ }
81+ }
82+}
@@ -0,0 +1,82 @@
1+// SPDX-License-Identifier: MIT
2+
3+package e2e
4+
5+import (
6+ "context"
7+ "os/exec"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/ermite-runner/internal/execstream"
13+ "bnema.dev/ermite-runner/internal/execstream/execpb"
14+ "bnema.dev/ermite-runner/internal/sandbox"
15+)
16+
17+func TestContainerSetupGoFindsToolcache(t *testing.T) {
18+ image := needE2E(t)
19+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
20+ defer cancel()
21+ // Distinguish a missing cache from an unavailable image or Docker daemon.
22+ out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--entrypoint", "sh", image, "-c", "if test -d /opt/hostedtoolcache/go/1.27.1; then echo present; else echo absent; fi").CombinedOutput()
23+ if err != nil {
24+ t.Fatalf("probe toolcache: %v\n%s", err, out)
25+ }
26+ if strings.TrimSpace(string(out)) == "absent" {
27+ t.Skip("test image has no Go 1.27.1 toolcache")
28+ }
29+ d := newDriver(t, image)
30+ sb, err := d.New()
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ defer func() {
35+ if err := sb.Stop(context.Background()); err != nil {
36+ t.Error(err)
37+ }
38+ }()
39+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "e2e-toolcache"})
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ spec := jobSpec(`on: push
44+jobs:
45+ j:
46+ runs-on: x
47+ steps:
48+ - uses: actions/setup-go@v7
49+ with:
50+ go-version: '1.27.1'
51+ cache: false
52+ - run: go version
53+`)
54+ var logs []string
55+ var result *execpb.JobResult
56+ _, exit, err := execstream.DriveJob(conn, &execpb.Run{Job: spec}, nil, execstream.JobHandler{
57+ Resolve: func(_ context.Context, r *execpb.ResolveRequest) *execpb.ResolveResponse {
58+ resp := &execpb.ResolveResponse{}
59+ for _, ref := range r.Uses {
60+ if ref != "actions/setup-go@v7" {
61+ return &execpb.ResolveResponse{Error: "unexpected action: " + ref}
62+ }
63+ resp.Actions = append(resp.Actions, &execpb.ResolvedAction{Ref: ref, NameWithOwner: "actions/setup-go", ResolvedSha: "v7", TarballUrl: "https://codeload.github.com/actions/setup-go/tar.gz/refs/tags/v7"})
64+ }
65+ return resp
66+ },
67+ Log: func(l *execpb.Log) { logs = append(logs, l.Text) },
68+ JobResult: func(r *execpb.JobResult) { result = r },
69+ })
70+ all := strings.Join(logs, "\n")
71+ if err != nil {
72+ t.Fatalf("drive: %v\n%s", err, all)
73+ }
74+ if result == nil || result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || exit == nil || exit.Code != 0 {
75+ t.Fatalf("result %v exit %v\n%s", result, exit, all)
76+ }
77+ for _, want := range []string{"Found in cache", "go version go1.27.1"} {
78+ if !strings.Contains(all, want) {
79+ t.Errorf("log lacks %q\n%s", want, all)
80+ }
81+ }
82+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close