From f09ba0c72e03ab9d5c4dd140775941ff4f64059d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 30 Jun 2026 21:56:25 -0400 Subject: [PATCH] ci(e2e): add scheduled act runner image repin Re-resolve the catthehacker/ubuntu act-latest digest on a schedule and open a repin pull request when it moves, so the e2e runner pin keeps tracking a live, tag-referenced digest ahead of any upstream garbage-collection window. The job refuses to repin below the Node floor, mirroring the harness startup preflight. Signed-off-by: Joshua Temple --- .github/workflows/act-image-repin.yml | 142 ++++++++++++++++++++++++++ e2e/harness/act.go | 8 +- 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/act-image-repin.yml diff --git a/.github/workflows/act-image-repin.yml b/.github/workflows/act-image-repin.yml new file mode 100644 index 0000000..6862442 --- /dev/null +++ b/.github/workflows/act-image-repin.yml @@ -0,0 +1,142 @@ +# Keeps the e2e act runner image pin fresh so it never ages into an upstream +# garbage-collection window. +# +# e2e/harness/act.go pins the catthehacker/ubuntu image by digest for a +# deterministic runtime. That digest is a bare upstream reference: if +# catthehacker retags act-latest and later garbage-collects the old untagged +# digest, every e2e run fails to pull it. This job re-resolves the current +# act-latest digest on a schedule, confirms it still carries the required Node +# major, and opens a repin pull request when the digest has moved. The pin +# therefore keeps tracking a live, tag-referenced digest instead of drifting +# toward a collectable one. +# +# The pull request is reviewer-gated like any other change: it swaps the e2e +# runtime, so a human confirms the suite stays green before it merges. The job +# refuses to repin to a digest below the Node floor, mirroring the harness +# startup preflight, so a bad upstream image cannot land unnoticed. +# +# The pull request is authored with the default token, so it does not itself +# fire the pull_request checks; add it to the merge queue to run the e2e suite +# before merging. +name: Act Runner Image Repin + +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + repin: + name: Re-resolve act runner digest + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + env: + IMAGE_REPO: ghcr.io/catthehacker/ubuntu + IMAGE_TAG: act-latest + NODE_MAJOR_MIN: '24' + ACT_GO: e2e/harness/act.go + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Resolve current upstream digest + id: resolve + run: | + set -euo pipefail + ref="${IMAGE_REPO}:${IMAGE_TAG}" + docker pull "$ref" + + node_version="$(docker run --rm "$ref" node --version)" + node_major="${node_version#v}" + node_major="${node_major%%.*}" + if [ "$node_major" -lt "$NODE_MAJOR_MIN" ]; then + echo "::error::${ref} provides Node ${node_version}; the e2e suite requires Node >= ${NODE_MAJOR_MIN}. Refusing to repin." + exit 1 + fi + + new_ref="$(docker inspect --format '{{index .RepoDigests 0}}' "$ref")" + new_digest="${new_ref#*@}" + case "$new_digest" in + sha256:*) ;; + *) echo "::error::could not resolve a sha256 digest from ${new_ref}"; exit 1 ;; + esac + + current_ref="$(grep -oE 'ghcr\.io/catthehacker/ubuntu@sha256:[0-9a-f]+' "$ACT_GO" | head -n1)" + current_digest="${current_ref#*@}" + if [ -z "$current_digest" ]; then + echo "::error::could not read the current digest from ${ACT_GO}" + exit 1 + fi + + { + echo "current=${current_digest}" + echo "resolved=${new_digest}" + } >> "$GITHUB_OUTPUT" + if [ "$current_digest" = "$new_digest" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Digest unchanged (${current_digest}); nothing to repin." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Digest moved: ${current_digest} -> ${new_digest}" + fi + + - name: Apply repin + if: steps.resolve.outputs.changed == 'true' + env: + CURRENT: ${{ steps.resolve.outputs.current }} + RESOLVED: ${{ steps.resolve.outputs.resolved }} + run: | + set -euo pipefail + sed -i "s#@${CURRENT}#@${RESOLVED}#g" "$ACT_GO" + if ! grep -q "@${RESOLVED}" "$ACT_GO"; then + echo "::error::failed to apply the digest repin to ${ACT_GO}" + exit 1 + fi + + - name: Set up Go + if: steps.resolve.outputs.changed == 'true' + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.25" + + - name: Verify e2e module builds + if: steps.resolve.outputs.changed == 'true' + working-directory: e2e + run: go build ./... + + - name: Open repin pull request + if: steps.resolve.outputs.changed == 'true' + env: + CURRENT: ${{ steps.resolve.outputs.current }} + RESOLVED: ${{ steps.resolve.outputs.resolved }} + run: | + set -euo pipefail + short="${RESOLVED#sha256:}" + short="${short:0:12}" + branch="ci/act-repin-${short}" + + if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + echo "Branch ${branch} already exists; a repin pull request is already open." + exit 0 + fi + + git config user.name "cascade-bot" + git config user.email "cascade-bot@users.noreply.github.com" + git checkout -b "$branch" + git add "$ACT_GO" + git commit -s -m "ci(e2e): repin act runner image to current upstream digest" + git push origin "$branch" + + body="$(printf 'The e2e act runner image pin was re-resolved to the current %s:%s digest so it stays ahead of any upstream garbage-collection window.\n\nWas: %s\nNow: %s\n\nThe resolver confirmed the new image still carries Node >= %s and that the e2e module builds against it. Add this to the merge queue so the e2e suite runs before merge.\n' "$IMAGE_REPO" "$IMAGE_TAG" "$CURRENT" "$RESOLVED" "$NODE_MAJOR_MIN")" + + gh pr create \ + --base main \ + --head "$branch" \ + --title "ci(e2e): repin act runner image to current upstream digest" \ + --body "$body" diff --git a/e2e/harness/act.go b/e2e/harness/act.go index a1905e8..a006e7f 100644 --- a/e2e/harness/act.go +++ b/e2e/harness/act.go @@ -62,7 +62,13 @@ const actStartupPollInterval = 2 * time.Second // publishing dated act-latest-YYYYMMDD tags in 2023 (all predate Node 24), so a // digest is the only deterministic reference that reaches a Node 24 runtime. // -// To repin: pull ghcr.io/catthehacker/ubuntu:act-latest, confirm +// The scheduled Act Runner Image Repin workflow +// (.github/workflows/act-image-repin.yml) re-resolves act-latest to its current +// digest and opens a repin pull request whenever it moves, so this pin keeps +// tracking a live, tag-referenced digest ahead of any upstream +// garbage-collection window. The manual recipe below stays the fallback. +// +// To repin by hand: pull ghcr.io/catthehacker/ubuntu:act-latest, confirm // `docker run --rm node --version` reports >= actRunnerNodeMajorMin, // then record the resolved digest here // (`docker inspect --format '{{index .RepoDigests 0}}' `).