Files
ci/.hanzo/workflows/runner.yml
zeekayandHanzo Dev 67460df3b1 The runner image is built where the runners are
This lane was written for our own pools — it names linux-amd64 and linux-arm64 —
and filed under .github/workflows, where nothing executes it. So it never ran:
the arm64 half was published by hand and the amd64 half of the current runner was
never built at all, which is what left the fleet pinned to an image that predates
the variable rename.

The entrypoint's refusal is asserted by the name it actually prints. Asserting
the old one meant the smoke test would have failed on the very image it was
meant to prove, and called it "failed for the wrong reason".

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 08:24:53 -07:00

232 lines
9.8 KiB
YAML

name: Runner image
# The machine a job runs ON, built by the pipeline it will run.
#
# Nothing built it before. oci.hanzo.ai/hanzoai/git-runner:5 — what cloud hands
# every runner Job (RUNNER_IMAGE, universe charts/app/values/hanzo/cloud.yaml) —
# was cut by hand and amd64 alone, so an arm64 job landed on spark and the
# kubelet answered `failed to load /usr/bin/tini: exec format error`. The arm64
# lane had never once started, and every multi-arch question in this estate
# bottomed out there: the builder could not build arm64 because the builder was
# not arm64.
#
# THIS LANE IS SEPARATE FROM build.yml ON PURPOSE. build.yml is the reusable
# every other repo imports; it cannot build the image it runs inside without
# depending on the artifact it is producing. So the runner image gets its own
# file, and the two never share a job.
#
# ONE ARCHITECTURE PER JOB, EACH ON ITS OWN NATIVE POOL. `--load` takes a single
# image into the local daemon and refuses a manifest list, so a job that TESTS
# what it built can only build one platform — and testing each half is the whole
# point here, since an image that starts on neither arch is the failure being
# fixed. Neither arch is emulated: the binary that ships for an arch was
# compiled and executed on that arch. `merge` joins the halves afterwards.
#
# Everything in runner/Dockerfile already branches on `dpkg --print-architecture`
# for both. Only a lane that builds both was missing.
on:
push:
branches: [main]
paths:
- runner/**
- .hanzo/workflows/runner.yml
workflow_dispatch: {}
concurrency:
group: runner-image
cancel-in-progress: false
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
pool: linux-amd64
- arch: arm64
pool: linux-arm64
runs-on: ['${{ matrix.pool }}']
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Compute this arch's names
id: meta
# A per-arch name, never a release name. A release name that resolves to
# one architecture is worse than one that does not exist yet: it pulls on
# that arch and fails everywhere else, which is precisely how :5 shipped.
# Only merge writes the release names.
run: |
set -euo pipefail
v=$(tr -d '[:space:]' < runner/VERSION)
[ -n "$v" ] || { echo "::error::runner/VERSION is empty"; exit 1; }
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "ghcr=ghcr.io/hanzoai/git-runner:sha-${GITHUB_SHA:0:7}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"
echo "oci=oci.hanzo.ai/hanzoai/git-runner:sha-${GITHUB_SHA:0:7}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
with:
driver: docker-container
driver-opts: network=host
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Sign in to oci.hanzo.ai
# NOT best-effort, unlike the mirror login in build.yml. The cluster
# pulls RUNNER_IMAGE from oci.hanzo.ai and from nowhere else, so a
# skipped push here publishes a release name that resolves at ghcr and
# 404s at the one address that matters.
uses: docker/login-action@v3
with:
registry: oci.hanzo.ai
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build ${{ matrix.arch }} (load for smoke, not yet pushed)
uses: docker/build-push-action@v6
with:
context: .
file: runner/Dockerfile
platforms: linux/${{ matrix.arch }}
load: true
push: false
provenance: false
tags: ${{ steps.meta.outputs.ghcr }}
- name: Smoke — every binary a job reaches for loads on this arch
# `exec format error` is what an image of the wrong architecture says at
# the moment something tries to run, which on :5 was the kubelet starting
# tini. So the smoke RUNS things rather than inspecting them: a foreign
# binary passes every `test -x` and fails the instant it is executed.
run: |
set -euo pipefail
img='${{ steps.meta.outputs.ghcr }}'
want='${{ matrix.arch }}'
got=$(docker image inspect -f '{{.Architecture}}' "$img")
[ "$got" = "$want" ] || { echo "::error::built $got, wanted $want"; exit 1; }
# tini is the one that faulted, and it is pid 1 for every job.
docker run --rm --entrypoint /usr/bin/tini "$img" -- /bin/echo 'tini: ok'
# The two agents, one per plane: act_runner claims a forge job,
# Runner.Listener claims a GitHub one.
docker run --rm --entrypoint sh "$img" -c 'act_runner --version'
docker run --rm --entrypoint sh "$img" -c '/opt/actions-runner/bin/Runner.Listener --version'
# The toolchain a workflow assumes is on the PATH of its first step.
docker run --rm --entrypoint sh "$img" -c 'docker --version && node --version && bun --version && git --version'
# The other arch's compiler, which a sandboxed job cannot install for
# itself. Run it, do not test -x it: a missing package fails here.
case "$want" in
amd64) cc=aarch64-linux-gnu-gcc ;;
arm64) cc=x86_64-linux-gnu-gcc ;;
esac
docker run --rm --entrypoint sh "$img" -c "$cc --version | head -1"
# The real entrypoint chain, unconfigured. runner(1) refuses with
# GIT_RUNNER_URL: the Hanzo Git URL
# which proves tini loaded, exec'd the script, and the script ran. A
# wrong-arch image cannot reach that message — it dies at `exec format
# error` instead, so the two failures are never confused.
if out=$(docker run --rm "$img" 2>&1); then
echo "::error::entrypoint exited 0 with no forge configured"; exit 1
fi
case "$out" in
*GIT_RUNNER_URL*) echo "entrypoint: refused by name — ok" ;;
*) echo "::error::entrypoint failed for the wrong reason: $out"; exit 1 ;;
esac
- name: Push this arch to both registries
run: |
set -euo pipefail
docker push '${{ steps.meta.outputs.ghcr }}'
docker tag '${{ steps.meta.outputs.ghcr }}' '${{ steps.meta.outputs.oci }}'
docker push '${{ steps.meta.outputs.oci }}'
- name: Verify both pushed names resolve
run: |
set -euo pipefail
for img in '${{ steps.meta.outputs.ghcr }}' '${{ steps.meta.outputs.oci }}'; do
ok=
for i in 1 2 3 4 5 6; do
if docker manifest inspect "$img" >/dev/null 2>&1; then ok=1; break; fi
echo "not visible yet (${i}/6) — retrying"; sleep 5
done
[ -n "$ok" ] || { echo "::error::$img not pullable after push"; exit 1; }
echo "$img is resolvable"
done
merge:
needs: build
# Registry-side only: imagetools reads the per-arch manifests and writes a
# list pointing at both. No builder and no platform of its own, so which
# pool runs this does not matter.
runs-on: [linux-amd64]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Compute the release names
id: meta
# The version is an integer counter that moves one step at a time, and it
# lives in runner/VERSION beside the Dockerfile it names rather than in
# this file — bumping the image is then an edit a reader can see, not a
# workflow change. universe pins RUNNER_IMAGE to the same integer.
run: |
set -euo pipefail
v=$(tr -d '[:space:]' < runner/VERSION)
{
echo "tags<<EOF"
for r in ghcr.io/hanzoai/git-runner oci.hanzo.ai/hanzoai/git-runner; do
echo "${r}:${v}"
echo "${r}:sha-${GITHUB_SHA:0:7}"
done
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
- uses: docker/login-action@v3
with:
registry: oci.hanzo.ai
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Join both architectures under every release name
# imagetools, not `docker manifest create`: buildx pushes a per-arch OCI
# index and `docker manifest create` rejects those outright. Each release
# name is built from the per-arch halves in ITS OWN registry, so neither
# registry's index points at blobs it does not hold.
run: |
set -euo pipefail
while IFS= read -r t; do
[ -z "$t" ] && continue
base="${t%:*}:sha-${GITHUB_SHA:0:7}"
docker buildx imagetools create -t "$t" "${base}-amd64" "${base}-arm64"
done <<< '${{ steps.meta.outputs.tags }}'
- name: Prove every release name carries BOTH architectures
# A one-entry manifest list pulls fine on the arch that built it and
# fails everywhere else — the exact state :5 was in. Counting the
# platforms is what makes "multi-arch" a fact rather than a hope.
run: |
set -euo pipefail
while IFS= read -r t; do
[ -z "$t" ] && continue
got=$(docker buildx imagetools inspect "$t" --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}')
echo "$t -> ${got}"
for want in linux/amd64 linux/arm64; do
case " $got " in
*" $want "*) ;;
*) echo "::error::$t does not carry $want"; exit 1 ;;
esac
done
done <<< '${{ steps.meta.outputs.tags }}'