From 46296ae8fc7f98b5da42689615485b599c792277 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 28 Oct 2026 15:04:08 +0000 Subject: [PATCH 1/5] refactor(tool-images): fetch the Node.js release without a package manager The fetch stage installed curl, tar and xz with apk, which trusts the Alpine package index on top of the release's own checksums. The base image's busybox already provides wget, tar with xz decompression and sha256sum, so the stage now installs nothing. The archive is still checked against the release's SHASUMS256.txt before it is unpacked, and the resulting tool cache layout is unchanged. --- tool-images/node/Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tool-images/node/Dockerfile b/tool-images/node/Dockerfile index 1c138aa..60c1110 100644 --- a/tool-images/node/Dockerfile +++ b/tool-images/node/Dockerfile @@ -1,17 +1,18 @@ # Tool-cache capability image for Node.js, following the tool-image contract in roles/github_runner_arc/README.md (Capabilities): /toolcache/node/// holds the official Linux build, with .complete beside it, and is named the way actions/setup-node looks it up (Node's os.arch(): x64 or arm64). A job whose setup-node node-version matches the version finds it there and skips the download. The final stage is busybox, which supplies the sh, cp, rm and chmod the role's init containers run. # +# One Dockerfile builds every Node.js version a fleet pins: the version is the NODE_VERSION build argument, whose default is the version published without an explicit one, and .github/workflows/publish-tool-images.yml names each further version in its matrix. The fetch stage installs nothing: it runs the wget, tar and sha256sum that the base image's busybox already provides, so no package manager runs at build time and no package index is trusted besides the release's own checksums. +# # Build for both architectures: docker buildx build --platform linux/amd64,linux/arm64 --build-arg NODE_VERSION= -t /: tool-images/node FROM alpine:3.22 AS fetch SHELL ["/bin/ash", "-eo", "pipefail", "-c"] ARG NODE_VERSION=24.21.0 ARG TARGETARCH -RUN apk add --no-cache curl tar xz WORKDIR /fetch # The archive is checked against the release's own SHASUMS256.txt, fetched over HTTPS from the same origin. RUN case "${TARGETARCH}" in amd64) arch=x64 ;; arm64) arch=arm64 ;; *) echo "unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; esac \ && archive="node-v${NODE_VERSION}-linux-${arch}.tar.xz" \ - && curl -fsSLO "https://nodejs.org/dist/v${NODE_VERSION}/${archive}" \ - && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" | grep " ${archive}\$" | sha256sum -c - \ + && wget -q "https://nodejs.org/dist/v${NODE_VERSION}/${archive}" \ + && wget -qO- "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" | grep " ${archive}\$" | sha256sum -c - \ && destination="/toolcache/node/${NODE_VERSION}/${arch}" \ && mkdir -p "${destination}" \ && tar -xJf "${archive}" -C "${destination}" --strip-components=1 \ From 833f1a8ebe97d37259dde4203bd068fe5096133a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 28 Oct 2026 15:04:17 +0000 Subject: [PATCH 2/5] feat(tool-images): publish Node.js 26 beside 24 from the one Dockerfile A matrix entry of the publish workflow can now name a version, which reaches the Dockerfile through its own ARG _VERSION, so one directory publishes several versions as tags of one repository. An entry without a version builds the Dockerfile's default as before. The digest artifacts carry the version, so two versions of one image do not share an artifact name or a download pattern. The matrix gains node 26.12.1. A unit test keeps the build and merge matrices equal and no two entries on the same tag, and checks that the Node.js image runs no package manager. The capabilities test builds the second version, adds it to the plain profile and checks that both versions sit in the pod's tool cache and that @actions/tool-cache finds each by its major version. --- .github/workflows/publish-tool-images.yml | 27 +++-- README.md | 2 +- roles/github_runner_arc/README.md | 4 +- tests/capabilities/run.sh | 24 ++++- tests/unit/test_tool_images_workflow.py | 120 ++++++++++++++++++++++ 5 files changed, 161 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_tool_images_workflow.py diff --git a/.github/workflows/publish-tool-images.yml b/.github/workflows/publish-tool-images.yml index 2765a72..477f098 100644 --- a/.github/workflows/publish-tool-images.yml +++ b/.github/workflows/publish-tool-images.yml @@ -1,6 +1,6 @@ name: Publish tool images -# Builds the reference images in tool-images/ (the tool-cache and system-library capability images, see the ARC role README's Capabilities section, and the build runner image, the stock runner image with a C toolchain) for linux/amd64 and linux/arm64 and pushes them to this repository owner's GitHub Container Registry namespace, as ghcr.io//github-runner-toolcache-, ghcr.io//github-runner-sysroot- and ghcr.io//github-runner-runner-, each tagged with the version its Dockerfile pins (its ARG _VERSION default; for the build runner image, the runner version it extends). A version tag that already exists is left alone, so a published version never changes under anyone pinning it and a run publishes only the images whose pinned version is new. The job summary lists each pushed image by digest, the reference to pin in a capability or as a runner image. +# Builds the reference images in tool-images/ (the tool-cache and system-library capability images, see the ARC role README's Capabilities section, and the build runner image, the stock runner image with a C toolchain) for linux/amd64 and linux/arm64 and pushes them to this repository owner's GitHub Container Registry namespace, as ghcr.io//github-runner-toolcache-, ghcr.io//github-runner-sysroot- and ghcr.io//github-runner-runner-, each tagged with the version it builds: the one its matrix entry names in `version`, built through the Dockerfile's ARG _VERSION, or, for an entry without one, the version that ARG defaults to (for the build runner image, the runner version it extends). One directory can therefore publish several versions of a tool, each its own tag of the same repository. A version tag that already exists is left alone, so a published version never changes under anyone pinning it and a run publishes only the images whose pinned version is new. The job summary lists each pushed image by digest, the reference to pin in a capability or as a runner image. # # Runs only when started by hand or by pushing a tag named tool-images-; the release workflow does not build these images. Each architecture is built natively, on GitHub's amd64 and arm64 hosted runners, and pushed by digest, and a second job joins the two into one multi-architecture tag (https://docs.docker.com/build/ci/github-actions/multi-platform/), because compiling the system libraries under emulation is far slower than natively. @@ -19,7 +19,7 @@ permissions: jobs: build: - name: Build ${{ matrix.image.kind }}-${{ matrix.image.name }} (${{ matrix.platform.arch }}) + name: Build ${{ matrix.image.kind }}-${{ matrix.image.name }} ${{ matrix.image.version }} (${{ matrix.platform.arch }}) runs-on: ${{ matrix.platform.runner }} timeout-minutes: 90 permissions: @@ -30,6 +30,7 @@ jobs: matrix: image: - {name: node, kind: toolcache} + - {name: node, kind: toolcache, version: 26.12.1} - {name: terraform, kind: toolcache} - {name: tflint, kind: toolcache} - {name: awscli, kind: toolcache} @@ -51,15 +52,19 @@ jobs: OWNER: ${{ github.repository_owner }} KIND: ${{ matrix.image.kind }} NAME: ${{ matrix.image.name }} + PINNED_VERSION: ${{ matrix.image.version }} run: | set -euo pipefail - version="$(sed -n 's/^ARG [A-Z_]*_VERSION=//p' "tool-images/${NAME}/Dockerfile")" - if [ -z "$version" ] || [ "$(wc -l <<<"$version")" != 1 ]; then + pins="$(sed -n 's/^ARG \([A-Z_]*_VERSION\)=\(.*\)/\1 \2/p' "tool-images/${NAME}/Dockerfile")" + if [ -z "$pins" ] || [ "$(wc -l <<<"$pins")" != 1 ]; then echo "tool-images/${NAME}/Dockerfile must pin exactly one ARG _VERSION" >&2 exit 1 fi + argument="${pins% *}" + version="${PINNED_VERSION:-${pins#* }}" echo "repository=ghcr.io/${OWNER,,}/github-runner-${KIND}-${NAME}" >>"$GITHUB_OUTPUT" echo "version=${version}" >>"$GITHUB_OUTPUT" + echo "argument=${argument}" >>"$GITHUB_OUTPUT" - uses: docker/login-action@v4 with: @@ -88,6 +93,8 @@ jobs: with: context: tool-images/${{ matrix.image.name }} platforms: linux/${{ matrix.platform.arch }} + build-args: | + ${{ steps.image.outputs.argument }}=${{ steps.image.outputs.version }} labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} @@ -106,13 +113,13 @@ jobs: - uses: actions/upload-artifact@v7 if: steps.published.outputs.exists == 'false' with: - name: digests-${{ matrix.image.kind }}-${{ matrix.image.name }}-${{ matrix.platform.arch }} + name: digests-${{ matrix.image.kind }}-${{ matrix.image.name }}-${{ steps.image.outputs.version }}-${{ matrix.platform.arch }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 merge: - name: Publish ${{ matrix.image.kind }}-${{ matrix.image.name }} + name: Publish ${{ matrix.image.kind }}-${{ matrix.image.name }} ${{ matrix.image.version }} needs: [build] # Each image is joined on its own, so one image's failed build does not hold back the others; the join itself refuses an image missing an architecture. if: ${{ !cancelled() }} @@ -126,6 +133,7 @@ jobs: matrix: image: - {name: node, kind: toolcache} + - {name: node, kind: toolcache, version: 26.12.1} - {name: terraform, kind: toolcache} - {name: tflint, kind: toolcache} - {name: awscli, kind: toolcache} @@ -144,16 +152,17 @@ jobs: OWNER: ${{ github.repository_owner }} KIND: ${{ matrix.image.kind }} NAME: ${{ matrix.image.name }} + PINNED_VERSION: ${{ matrix.image.version }} run: | set -euo pipefail - version="$(sed -n 's/^ARG [A-Z_]*_VERSION=//p' "tool-images/${NAME}/Dockerfile")" + default="$(sed -n 's/^ARG [A-Z_]*_VERSION=//p' "tool-images/${NAME}/Dockerfile")" echo "repository=ghcr.io/${OWNER,,}/github-runner-${KIND}-${NAME}" >>"$GITHUB_OUTPUT" - echo "version=${version}" >>"$GITHUB_OUTPUT" + echo "version=${PINNED_VERSION:-${default}}" >>"$GITHUB_OUTPUT" - uses: actions/download-artifact@v8 with: path: ${{ runner.temp }}/digests - pattern: digests-${{ matrix.image.kind }}-${{ matrix.image.name }}-* + pattern: digests-${{ matrix.image.kind }}-${{ matrix.image.name }}-${{ steps.image.outputs.version }}-* merge-multiple: true - uses: docker/login-action@v4 diff --git a/README.md b/README.md index 0e86fc9..7ee582b 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ A release: The `[skip ci]` in the release commit message is deliberate: the image build already happens in this same run using the exact version semantic-release just decided, so there is nothing useful for a second, separately-triggered run of this workflow (or of `ci.yml`) to do against a commit that only changed a version stamp and a changelog - `ci.yml`'s own lint jobs already ran against every commit before it reached `main`. -The reference capability images in `tool-images/` are not part of a release. `.github/workflows/publish-tool-images.yml` builds each one natively on amd64 and arm64 runners and pushes it to `ghcr.io//github-runner-toolcache-`, `ghcr.io//github-runner-sysroot-` or `ghcr.io//github-runner-runner-build`, tagged with the version its Dockerfile pins, when started by hand (`gh workflow run publish-tool-images.yml`) or by pushing a `tool-images-` tag. It leaves a version tag that already exists alone, and its run summary gives each pushed image's `:@sha256:` reference to pin in a capability or as a runner image. A new GitHub Container Registry package starts private; make it public in its package settings so pods can pull it without a credential. +The reference capability images in `tool-images/` are not part of a release. `.github/workflows/publish-tool-images.yml` builds each one natively on amd64 and arm64 runners and pushes it to `ghcr.io//github-runner-toolcache-`, `ghcr.io//github-runner-sysroot-` or `ghcr.io//github-runner-runner-build`, tagged with the version it builds (a matrix entry's `version`, else the default of its Dockerfile's `ARG _VERSION`; a tool kept at several versions has an entry for each), when started by hand (`gh workflow run publish-tool-images.yml`) or by pushing a `tool-images-` tag. It leaves a version tag that already exists alone, and its run summary gives each pushed image's `:@sha256:` reference to pin in a capability or as a runner image. A new GitHub Container Registry package starts private; make it public in its package settings so pods can pull it without a credential. **What the release push needs:** a deploy key with write access on `ExaDev/github-runner`, whose private key is the `RELEASE_DEPLOY_KEY` repository secret `release.yml` checks out with. If `main` gains a ruleset, add deploy keys as its bypass actor so the release commit can land. Publishing to Ansible Galaxy additionally needs a `GALAXY_API_KEY` repository secret (an API key from `https://galaxy.ansible.com/me/preferences`); without it, the publish step logs that it's skipping and the release still completes. diff --git a/roles/github_runner_arc/README.md b/roles/github_runner_arc/README.md index 700a358..4baabd8 100644 --- a/roles/github_runner_arc/README.md +++ b/roles/github_runner_arc/README.md @@ -259,6 +259,8 @@ docker buildx build --platform linux/amd64,linux/arm64 --build-arg NODE_VERSION= -t ghcr.io/example/toolcache-node:24.21.0 --push tool-images/node ``` +One Dockerfile builds every version of a tool: the version is its `ARG _VERSION`, and the image for another version is the same command with that value (the Node.js image checks each archive against the release's `SHASUMS256.txt` and installs nothing, so it runs no package manager). A fleet that needs two Node.js majors names the two tags as two capabilities (`node-24`, `node-26`): their tool caches are merged under `/node//`, and `actions/setup-node` takes whichever cached version satisfies its `node-version`, so `>=26` finds the second and `24` the first. Name `path` on one of them only if a tool no setup action looks up should get that version on `PATH`. + A capability image of your own takes a few lines. This one packages a static binary, `example-cli`, built beside the Dockerfile for each architecture, for use through `path`: ```dockerfile @@ -319,7 +321,7 @@ Then prove it the way `tests/system_libraries/run.sh` does before relying on it: ### Publishing the reference images -`.github/workflows/publish-tool-images.yml` builds every reference image in `tool-images/` natively for `linux/amd64` and `linux/arm64` and pushes it to the repository owner's GitHub Container Registry namespace, as `ghcr.io//github-runner-toolcache-`, `ghcr.io//github-runner-sysroot-` and, for the build runner image, `ghcr.io//github-runner-runner-build`, tagged with the version its Dockerfile pins (for the build runner image, the stock runner version it extends, so a new runner release is a new tag). It runs only when started by hand (`gh workflow run publish-tool-images.yml`) or by pushing a tag named `tool-images-`; the release workflow does not build these images. A version tag that already exists is left alone, so a published version never changes under anyone using it, and a run publishes only the images whose pinned version is new; to publish a new version, change the Dockerfile's version (and, where it pins one, its checksum) and run it again. The run's summary prints each pushed image as `:@sha256:`, the reference to put in a capability. A newly created GitHub Container Registry package is private until its visibility is changed to public in the package's settings, which pods without a pull credential need. +`.github/workflows/publish-tool-images.yml` builds every reference image in `tool-images/` natively for `linux/amd64` and `linux/arm64` and pushes it to the repository owner's GitHub Container Registry namespace, as `ghcr.io//github-runner-toolcache-`, `ghcr.io//github-runner-sysroot-` and, for the build runner image, `ghcr.io//github-runner-runner-build`, tagged with the version it builds: the one its matrix entry names in `version`, or, for an entry without one, the default of the Dockerfile's `ARG _VERSION` (for the build runner image, the stock runner version it extends, so a new runner release is a new tag). It runs only when started by hand (`gh workflow run publish-tool-images.yml`) or by pushing a tag named `tool-images-`; the release workflow does not build these images. A version tag that already exists is left alone, so a published version never changes under anyone using it, and a run publishes only the images whose pinned version is new; to publish a new version, change the Dockerfile's version (and, where it pins one, its checksum) and run it again. A tool kept at more than one version has an entry per version in the workflow's matrix, `{name: node, kind: toolcache, version: 26.12.1}` beside the plain `{name: node, kind: toolcache}` entry that builds the Dockerfile's default, listed in both the `build` and `merge` matrices (a unit test fails if they differ); a bump of such a version is a new `version` value there, which publishes a new tag of the same repository and leaves the old one in place for the fleets still pinning it. The Node.js image takes the new version's archive checksums from nodejs.org's `SHASUMS256.txt` at build time, so a bump needs no checksum edit; compare them with the release's signed copy (`SHASUMS256.txt.asc`) when you choose the version. The run's summary prints each pushed image as `:@sha256:`, the reference to put in a capability. A newly created GitHub Container Registry package is private until its visibility is changed to public in the package's settings, which pods without a pull credential need. ### The job-started hook diff --git a/tests/capabilities/run.sh b/tests/capabilities/run.sh index ca6c2b9..f2ba1f9 100755 --- a/tests/capabilities/run.sh +++ b/tests/capabilities/run.sh @@ -12,6 +12,7 @@ cluster=grtest-capabilities registry_container=grtest-capabilities-registry namespace=arc-runners-example tool_image=grtest/toolcache-node:test +other_tool_image=grtest/toolcache-node:other-test library_image=grtest/sysroot-libpq:test # The node cache pruner's own image, built from the autoscaler image's Dockerfile with only the files it copies, and the registry the store test's images are pushed to (Docker Hub's, a version tag: the registry's own releases are not pinned by digest anywhere else here either). pruner_image=grtest/autoscaler:test @@ -24,6 +25,8 @@ charts=oci://ghcr.io/actions/actions-runner-controller-charts chart_version="${GRTEST_SCALESET_CHART_VERSION:-}" tool_cache_package="@actions/tool-cache@4.0.0" node_version="$(sed -n 's/^ARG NODE_VERSION=//p' "$repo_root/tool-images/node/Dockerfile")" +# The first further Node.js version the publish workflow's matrix names: the same Dockerfile built for it, so the test covers a second version of the tool image beside the Dockerfile's default. +other_node_version="$(python3 -c 'import sys, yaml; print(next(entry["version"] for entry in yaml.safe_load(open(sys.argv[1]))["jobs"]["build"]["strategy"]["matrix"]["image"] if entry["name"] == "node" and "version" in entry))' "$repo_root/.github/workflows/publish-tool-images.yml")" work="$(mktemp -d "${TMPDIR:-/tmp}/grtest-capabilities.XXXXXX")" export KUBECONFIG="$work/kubeconfig" @@ -47,11 +50,14 @@ cleanup() { trap cleanup EXIT [ -n "$node_version" ] || fail "could not read NODE_VERSION from tool-images/node/Dockerfile" +[ -n "$other_node_version" ] || fail "could not read a further Node.js version from the publish workflow's matrix" version_flag=() [ -z "$chart_version" ] || version_flag=(--version "$chart_version") log "Building the reference Node.js ${node_version} tool image" docker build -q -t "$tool_image" "$repo_root/tool-images/node" >/dev/null +log "Building the same image for Node.js ${other_node_version}" +docker build -q --build-arg "NODE_VERSION=${other_node_version}" -t "$other_tool_image" "$repo_root/tool-images/node" >/dev/null log "Building the reference libpq system-library image" docker build -q -t "$library_image" "$repo_root/tool-images/libpq" >/dev/null @@ -73,7 +79,7 @@ for node in $(kind get nodes --name "$cluster"); do docker exec "$node" mkdir -p "/etc/containerd/certs.d/localhost:${registry_port}" printf '[host."http://%s:5000"]\n' "$registry_container" | docker exec -i "$node" tee "/etc/containerd/certs.d/localhost:${registry_port}/hosts.toml" >/dev/null done -kind load docker-image "$tool_image" "$library_image" "$pruner_image" --name "$cluster" >/dev/null +kind load docker-image "$tool_image" "$other_tool_image" "$library_image" "$pruner_image" --name "$cluster" >/dev/null # What the store names its entries for: each image's digest in the registry, which the push returns. push_image() { @@ -93,9 +99,9 @@ render_release() { local name=$1 store=$2 capabilities=$3 tool=$4 library=$5 log "Rendering the ${name} profile's values through the role" # The profile as arc_profiles expands it, and a catalogue with the tool image (put on PATH through its bin directory), the library image (with PGSYSCONFDIR, where libpq looks for its service file, as its extra variable) and a hook capability that records it ran. - python3 - "$repo_root/plugins/filter/arc.py" "$work/$name.extra.json" "$tool" "$node_version" "$work/$name.rendered.json" "$library" "$sysroot" "$busybox_image" "$capabilities" "$store" <<'EOF' + python3 - "$repo_root/plugins/filter/arc.py" "$work/$name.extra.json" "$tool" "$node_version" "$work/$name.rendered.json" "$library" "$sysroot" "$busybox_image" "$capabilities" "$store" "$other_tool_image" <<'EOF' import importlib.util, json, sys -plugin, extra, image, version, output, library_image, sysroot, busybox_image, capabilities, store = sys.argv[1:] +plugin, extra, image, version, output, library_image, sysroot, busybox_image, capabilities, store, other_image = sys.argv[1:] spec = importlib.util.spec_from_file_location("arc", plugin) arc = importlib.util.module_from_spec(spec) spec.loader.exec_module(arc) @@ -103,6 +109,7 @@ result = arc.arc_profiles([{"name": "Example", "image": "unused:1", "scale_set_p assert result["errors"] == [], result["errors"] catalogue = { "node": {"image": image, "path": f"node/{version}/bin"}, + "node-other": {"image": other_image}, "libpq": {"sysroot_image": library_image, "env": {"PGSYSCONFDIR": f"{sysroot}/etc"}}, "pnpm-store": {"node_cache": {"init_image": busybox_image, "dirs": {"pnpm_config_store_dir": "pnpm"}}}, "marker": {"job_started_hook": 'echo "CAPABILITY_HOOK_RAN=yes" >> "$GITHUB_ENV"\n'}, @@ -132,7 +139,7 @@ yaml.safe_dump(sets[0], open(sys.argv[2], "w")) EOF } -render_release plain false node,libpq,marker,pnpm-store "$tool_image" "$library_image" +render_release plain false node,node-other,libpq,marker,pnpm-store "$tool_image" "$library_image" render_release store true node,libpq,marker "$tool_reference" "$library_reference" log "Checking the AutoscalingRunnerSets against the ARC CRDs" @@ -171,6 +178,11 @@ grep -qx CAPABILITY_HOOK_RAN=yes "\$GITHUB_ENV" echo "hook capability ran" echo "node on PATH: \$(command -v node) \$(node --version)" [ "\$(node --version)" = "v${node_version}" ] +other_arch_dirs="\$(find "\$RUNNER_TOOL_CACHE/node/${other_node_version}" -mindepth 1 -maxdepth 1 -type d)" +[ "\$(printf '%s\\n' "\$other_arch_dirs" | wc -l)" = 1 ] +[ -e "\$other_arch_dirs.complete" ] +[ "\$("\$other_arch_dirs/bin/node" --version)" = "v${other_node_version}" ] +echo "node ${other_node_version} sits in the tool cache beside it: \$other_arch_dirs" echo "psql on PATH: \$(command -v psql) \$(psql --version)" [ "\$(command -v psql)" = "${sysroot}/bin/psql" ] [ "\$(pg_config --includedir)" = "${sysroot}/include" ] @@ -180,7 +192,7 @@ echo "system library ran from ${sysroot}" $(cat "$work/cache-step.sh") cd "\$(mktemp -d)" npm install --silent --no-audit --no-fund "${tool_cache_package}" >/dev/null -node --input-type=module -e "import * as tc from '@actions/tool-cache'; const found = tc.find('node', '${node_version%%.*}', process.arch); if (!found) { console.error('not found in ' + process.env.RUNNER_TOOL_CACHE); process.exit(1); } console.log('tool-cache find: ' + found);" +node --input-type=module -e "import * as tc from '@actions/tool-cache'; const found = tc.find('node', '${node_version%%.*}', process.arch); if (!found) { console.error('not found in ' + process.env.RUNNER_TOOL_CACHE); process.exit(1); } console.log('tool-cache find: ' + found); const other = tc.find('node', '${other_node_version%%.*}', process.arch); if (!other || !other.includes('/${other_node_version}/')) { console.error('node ${other_node_version} not found in ' + process.env.RUNNER_TOOL_CACHE + ': ' + other); process.exit(1); } console.log('tool-cache find: ' + other);" echo CAPABILITIES-OK EOF cat >"$work/make-pod.py" <<'EOF' @@ -246,6 +258,8 @@ wait_for_pod runner kubectl -n "$namespace" logs runner -c runner | grep -qx CAPABILITIES-OK || fail "the step did not reach its end" init_state="$(kubectl -n "$namespace" get pod runner -o jsonpath='{.status.initContainerStatuses[?(@.name=="capability-node")].state.terminated.exitCode}')" [ "$init_state" = 0 ] || fail "the capability-node init container did not succeed (exit code '${init_state}')" +init_state="$(kubectl -n "$namespace" get pod runner -o jsonpath='{.status.initContainerStatuses[?(@.name=="capability-node-other")].state.terminated.exitCode}')" +[ "$init_state" = 0 ] || fail "the capability-node-other init container did not succeed (exit code '${init_state}')" init_state="$(kubectl -n "$namespace" get pod runner -o jsonpath='{.status.initContainerStatuses[?(@.name=="capability-libpq")].state.terminated.exitCode}')" [ "$init_state" = 0 ] || fail "the capability-libpq init container did not succeed (exit code '${init_state}')" init_state="$(kubectl -n "$namespace" get pod runner -o jsonpath='{.status.initContainerStatuses[?(@.name=="capability-pnpm-store")].state.terminated.exitCode}')" diff --git a/tests/unit/test_tool_images_workflow.py b/tests/unit/test_tool_images_workflow.py new file mode 100644 index 0000000..6ef5b87 --- /dev/null +++ b/tests/unit/test_tool_images_workflow.py @@ -0,0 +1,120 @@ +"""Tests that the publish workflow can publish several versions of one tool image without them colliding, and that the Node.js reference image installs nothing at build time.""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github" / "workflows" / "publish-tool-images.yml" +TOOL_IMAGES = ROOT / "tool-images" +KINDS = {"toolcache", "sysroot", "runner"} +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") +VERSION_ARGUMENT = re.compile(r"^ARG ([A-Z_]*_VERSION)=(.+)$", flags=re.MULTILINE) +PACKAGE_MANAGERS = re.compile(r"\b(apk|apt|apt-get|dpkg|npm|npx|pnpm|yarn|pip|pip3|pipx|gem|cargo|go install)\b") + + +def default_version(name: str) -> str: + """The version a tool image's Dockerfile builds when no explicit one is named.""" + pins = VERSION_ARGUMENT.findall((TOOL_IMAGES / name / "Dockerfile").read_text()) + assert len(pins) == 1, (name, pins) + return pins[0][1] + + +class MatrixTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.workflow: dict[str, Any] = yaml.safe_load(WORKFLOW.read_text()) + cls.build: list[dict[str, Any]] = cls.workflow["jobs"]["build"]["strategy"]["matrix"]["image"] + cls.merge: list[dict[str, Any]] = cls.workflow["jobs"]["merge"]["strategy"]["matrix"]["image"] + + def test_the_merge_job_joins_exactly_the_images_the_build_job_builds(self) -> None: + self.assertEqual(self.build, self.merge) + + def test_every_entry_names_a_kind_and_a_directory_with_a_dockerfile(self) -> None: + for entry in self.build: + with self.subTest(entry=entry): + self.assertIn(entry["kind"], KINDS) + self.assertTrue((TOOL_IMAGES / entry["name"] / "Dockerfile").is_file()) + + def test_an_explicit_version_is_a_string_of_three_numbers(self) -> None: + for entry in self.build: + if "version" in entry: + with self.subTest(entry=entry): + self.assertIsInstance(entry["version"], str) + self.assertRegex(entry["version"], SEMVER) + + def test_no_two_entries_publish_the_same_tag_of_the_same_repository(self) -> None: + tags = [(entry["kind"], entry["name"], entry.get("version", default_version(entry["name"]))) for entry in self.build] + self.assertEqual(len(tags), len(set(tags)), tags) + + def test_a_tool_with_an_explicit_version_also_has_the_entry_for_its_default(self) -> None: + # The default entry keeps the version the Dockerfile and its local builds name published; dropping it would strand every pin of it. + for entry in self.build: + if "version" in entry: + with self.subTest(entry=entry): + self.assertIn({"name": entry["name"], "kind": entry["kind"]}, self.build) + + +class StepsTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + workflow: dict[str, Any] = yaml.safe_load(WORKFLOW.read_text()) + cls.build_steps: list[dict[str, Any]] = workflow["jobs"]["build"]["steps"] + cls.merge_steps: list[dict[str, Any]] = workflow["jobs"]["merge"]["steps"] + + @staticmethod + def uses(steps: list[dict[str, Any]], action: str) -> dict[str, Any]: + return next(step for step in steps if step.get("uses", "").startswith(action)) + + def test_the_version_a_matrix_entry_names_reaches_the_build_through_the_dockerfiles_own_argument(self) -> None: + build = self.uses(self.build_steps, "docker/build-push-action") + self.assertEqual(build["with"]["build-args"].strip(), "${{ steps.image.outputs.argument }}=${{ steps.image.outputs.version }}") + naming = next(step for step in self.build_steps if step.get("id") == "image") + self.assertEqual(naming["env"]["PINNED_VERSION"], "${{ matrix.image.version }}") + + def test_both_jobs_resolve_the_version_from_the_entry_before_the_dockerfile(self) -> None: + for steps in (self.build_steps, self.merge_steps): + naming = next(step for step in steps if step.get("id") == "image") + self.assertEqual(naming["env"]["PINNED_VERSION"], "${{ matrix.image.version }}") + self.assertIn("${PINNED_VERSION:-", naming["run"]) + + def test_the_digest_artifacts_of_two_versions_of_one_image_do_not_share_a_name_or_a_download_pattern(self) -> None: + upload = self.uses(self.build_steps, "actions/upload-artifact") + download = self.uses(self.merge_steps, "actions/download-artifact") + stem = "digests-${{ matrix.image.kind }}-${{ matrix.image.name }}-${{ steps.image.outputs.version }}-" + self.assertEqual(upload["with"]["name"], stem + "${{ matrix.platform.arch }}") + self.assertEqual(download["with"]["pattern"], stem + "*") + + +class NodeImageTest(unittest.TestCase): + dockerfile = (TOOL_IMAGES / "node" / "Dockerfile").read_text() + + def instructions(self) -> list[str]: + """The Dockerfile's instructions with continuation lines joined and comments dropped.""" + joined = re.sub(r"\\\n\s*", " ", self.dockerfile) + return [line for line in joined.splitlines() if line.strip() and not line.lstrip().startswith("#")] + + def test_no_build_step_runs_a_package_manager(self) -> None: + for instruction in self.instructions(): + if instruction.startswith("RUN "): + with self.subTest(instruction=instruction): + self.assertIsNone(PACKAGE_MANAGERS.search(instruction)) + + def test_the_archive_is_checked_against_the_releases_own_checksums_before_it_is_unpacked(self) -> None: + run = next(instruction for instruction in self.instructions() if "SHASUMS256.txt" in instruction) + self.assertLess(run.index("sha256sum -c"), run.index("tar -x")) + + def test_both_architectures_are_laid_out_under_the_names_setup_node_looks_up(self) -> None: + self.assertIn("amd64) arch=x64", self.dockerfile) + self.assertIn("arm64) arch=arm64", self.dockerfile) + self.assertIn('destination="/toolcache/node/${NODE_VERSION}/${arch}"', self.dockerfile) + self.assertIn('touch "${destination}.complete"', self.dockerfile) + + +if __name__ == "__main__": + unittest.main() From bcb465f0822a9bd2e5b0815ee4075f14d5d5a1cc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 28 Oct 2026 19:09:28 +0000 Subject: [PATCH 3/5] refactor(tool-images): write the image step outputs in one redirect actionlint's shellcheck run reports SC2129 for the three consecutive appends to GITHUB_OUTPUT the multi-version change added. --- .github/workflows/publish-tool-images.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-tool-images.yml b/.github/workflows/publish-tool-images.yml index 477f098..154cd19 100644 --- a/.github/workflows/publish-tool-images.yml +++ b/.github/workflows/publish-tool-images.yml @@ -62,9 +62,11 @@ jobs: fi argument="${pins% *}" version="${PINNED_VERSION:-${pins#* }}" - echo "repository=ghcr.io/${OWNER,,}/github-runner-${KIND}-${NAME}" >>"$GITHUB_OUTPUT" - echo "version=${version}" >>"$GITHUB_OUTPUT" - echo "argument=${argument}" >>"$GITHUB_OUTPUT" + { + echo "repository=ghcr.io/${OWNER,,}/github-runner-${KIND}-${NAME}" + echo "version=${version}" + echo "argument=${argument}" + } >>"$GITHUB_OUTPUT" - uses: docker/login-action@v4 with: From 48d31ed095f7144310604d7420284e5091a2e0e9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 28 Oct 2026 19:09:32 +0000 Subject: [PATCH 4/5] test(capabilities): check the further Node.js version without running it Node.js 26 links libatomic, which the stock runner image the test uses does not carry, so running its node there fails for a reason unrelated to the capability. The test now checks that the version's directory, its .complete marker and an executable node are in the pod's tool cache, and that @actions/tool-cache finds it by its major version. --- tests/capabilities/run.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/capabilities/run.sh b/tests/capabilities/run.sh index f2ba1f9..61f248d 100755 --- a/tests/capabilities/run.sh +++ b/tests/capabilities/run.sh @@ -178,10 +178,11 @@ grep -qx CAPABILITY_HOOK_RAN=yes "\$GITHUB_ENV" echo "hook capability ran" echo "node on PATH: \$(command -v node) \$(node --version)" [ "\$(node --version)" = "v${node_version}" ] +# The further version's node is not run: Node.js 26 links libatomic, which the stock runner image lacks (a fleet's own runner image carries it), so it would fail here for a reason that has nothing to do with the capability. other_arch_dirs="\$(find "\$RUNNER_TOOL_CACHE/node/${other_node_version}" -mindepth 1 -maxdepth 1 -type d)" [ "\$(printf '%s\\n' "\$other_arch_dirs" | wc -l)" = 1 ] [ -e "\$other_arch_dirs.complete" ] -[ "\$("\$other_arch_dirs/bin/node" --version)" = "v${other_node_version}" ] +[ -x "\$other_arch_dirs/bin/node" ] echo "node ${other_node_version} sits in the tool cache beside it: \$other_arch_dirs" echo "psql on PATH: \$(command -v psql) \$(psql --version)" [ "\$(command -v psql)" = "${sysroot}/bin/psql" ] From 236e11768ba9fa14268e15b4d251e9fe3461eae2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 28 Oct 2026 19:14:43 +0000 Subject: [PATCH 5/5] fix(node-recovery): load a built stand-in image into kind, not a pulled index GitHub's ubuntu-latest moved to 26.04, whose Docker keeps pulled images in the containerd store as multi-platform indexes. kind load saves only the pulled platform's content, so ctr images import --all-platforms then fails on the other platforms' digests with "content digest ... not found". The test now builds its stand-in pod image from the base image, a single platform image of the kind the watcher image already is. --- tests/node_recovery/run.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/node_recovery/run.sh b/tests/node_recovery/run.sh index 366a935..7e4243d 100755 --- a/tests/node_recovery/run.sh +++ b/tests/node_recovery/run.sh @@ -12,7 +12,9 @@ cluster=grtest-recovery platform_namespace=github-runner-platform namespace=grtest-listeners image=grtest/node-recovery:test -pod_image=busybox:1.36 +# The stand-in's base image. The test loads a copy built from it, not the pulled image: with Docker's containerd image store a pulled multi-platform image is an index whose other platforms' content is not in the store, and kind's `ctr images import --all-platforms` then fails with "content digest ... not found". +base_image=busybox:1.36 +pod_image=grtest/node-recovery-standin:test watcher_node="${cluster}-worker" doomed_node="${cluster}-worker2" voter_node="${cluster}-worker3" @@ -155,7 +157,7 @@ doomed_uid="$(kubectl get node "$doomed_node" -o jsonpath='{.metadata.uid}')" log "Building and loading the watcher image and the stand-in's image" docker build -q -f "$repo_root/node-recovery/Dockerfile" -t "$image" "$repo_root" >/dev/null -docker pull -q "$pod_image" >/dev/null +printf 'FROM %s\n' "$base_image" | docker build -q -t "$pod_image" - >/dev/null kind load docker-image "$image" "$pod_image" --name "$cluster" >/dev/null # Renders the role's manifests into $work/recovery.yaml; extra arguments are further -e settings, such as the prune threshold.