From 0bbfa3e2fa952223792e5601e4bc2cce4f6b43c8 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 27 Jun 2026 13:08:32 -0400 Subject: [PATCH] fix(e2e): pin act runner image to a Node 24 digest The harness ran generated workflows on the rolling ghcr.io/catthehacker/ubuntu:act-latest tag, a nondeterministic reference whose Node runtime can change under us. Generated workflows run checkout through act, and the upcoming Node 24 actions need a Node 24 runtime in the job container. Pin the image to a specific digest known to carry Node 24, defined once as actRunnerImage and reused in all three spots (container image, actrc -P mapping, and pre-pull). Add a startup preflight that execs node --version and fails fast with a repin pointer if the major is below 24, so a future image regression is a clear red rather than an obscure action crash. Signed-off-by: Joshua Temple --- e2e/harness/act.go | 95 ++++++++++++++++++- e2e/harness/act_node_version_test.go | 133 +++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 e2e/harness/act_node_version_test.go diff --git a/e2e/harness/act.go b/e2e/harness/act.go index e635b49..a1905e8 100644 --- a/e2e/harness/act.go +++ b/e2e/harness/act.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "strconv" "strings" "time" @@ -50,6 +51,30 @@ const actStartupTimeout = 5 * time.Minute // completes, without hammering the daemon. const actStartupPollInterval = 2 * time.Second +// actRunnerImage pins the catthehacker/ubuntu image used BOTH as the act +// orchestrator container and as the job container act spawns for each generated +// workflow. It is a DIGEST pin, not the rolling :act-latest tag, so the e2e +// suite runs on a deterministic runtime instead of whatever the tag happens to +// point at on a given day. The digest must carry Node >= actRunnerNodeMajorMin +// because generated workflows run checkout (a Node 24 action) and act executes +// every JavaScript action with the image's single node binary; +// assertNodeMajorAtLeast enforces this at startup. catthehacker stopped +// 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 +// `docker run --rm node --version` reports >= actRunnerNodeMajorMin, +// then record the resolved digest here +// (`docker inspect --format '{{index .RepoDigests 0}}' `). +const actRunnerImage = "ghcr.io/catthehacker/ubuntu@sha256:2f22a801c486881e278401813586faf73fac7dd1d016cbe9e01122ef14a57850" + +// actRunnerNodeMajorMin is the minimum Node major version the pinned image must +// provide. checkout v7, github-script v9, and download-artifact v8 are Node 24 +// actions, and act runs every JavaScript action with the image's single node +// binary, so the image's node must be at least this major or those live +// scenarios crash with an obscure runtime error instead of a clear signal. +const actRunnerNodeMajorMin = 24 + // NewActRunner starts a new act container func NewActRunner(ctx context.Context, giteaURL, giteaToken, networkName string, net *testcontainers.DockerNetwork) (*ActRunner, error) { var networks []string @@ -63,7 +88,7 @@ func NewActRunner(ctx context.Context, giteaURL, giteaToken, networkName string, // network alias directly. Job containers are configured separately // in actrc below. req := testcontainers.ContainerRequest{ - Image: "ghcr.io/catthehacker/ubuntu:act-latest", + Image: actRunnerImage, Cmd: []string{"sleep", "infinity"}, // Keep container running Networks: networks, // The readiness exec ("echo ready") only proves the container is up; it @@ -107,10 +132,10 @@ func NewActRunner(ctx context.Context, giteaURL, giteaToken, networkName string, // Network override is passed on the CLI as `--network=`. Act's // dedicated flag drives ContainerNetworkMode; --container-options is // appended after docker create and cannot override the network mode. - actrc := `mkdir -p /root/.config/act && cat > /root/.config/act/actrc <<'EOF' --P ubuntu-latest=catthehacker/ubuntu:act-latest + actrc := fmt.Sprintf(`mkdir -p /root/.config/act && cat > /root/.config/act/actrc <<'EOF' +-P ubuntu-latest=%s --pull=false -EOF` +EOF`, actRunnerImage) _, _, err = container.Exec(ctx, []string{"bash", "-c", actrc}) if err != nil { _ = container.Terminate(ctx) // Best-effort cleanup @@ -126,9 +151,19 @@ EOF` // proxy hiccup), we proceed; act will retry on its own when needed. _, _, _ = container.Exec(ctx, []string{ "bash", "-c", - `docker pull catthehacker/ubuntu:act-latest >/dev/null 2>&1 || true`, + fmt.Sprintf(`docker pull %s >/dev/null 2>&1 || true`, actRunnerImage), }) + // Fail fast if the pinned image's Node runtime regressed below the minimum + // the generated workflows require. The orchestrator container and the job + // containers act spawns share actRunnerImage, so one cheap exec here proves + // the job runtime too. A stale or mis-pinned image surfaces as a clear error + // pointing at the pin constant, not as a downstream checkout/action crash. + if err := assertNodeMajorAtLeast(ctx, container, actRunnerNodeMajorMin); err != nil { + _ = container.Terminate(ctx) // Best-effort cleanup + return nil, err + } + return &ActRunner{ container: container, giteaURL: giteaURL, @@ -211,6 +246,56 @@ func installActWithVerify(ctx context.Context, exec containerExecer, maxAttempts return fmt.Errorf("failed to install act after %d attempt(s): %w", maxAttempts, lastErr) } +// assertNodeMajorAtLeast execs `node --version` inside the act container and +// errors if the reported Node major is below minMajor. The act orchestrator +// container and the job containers act spawns share actRunnerImage, so checking +// the orchestrator's node proves the job runtime too with a single cheap exec +// and no extra container. The reader is requested multiplexed so Docker's +// per-frame stream headers are stripped (a TTY-less exec on CI Linux would +// otherwise prefix the version string with header bytes and break parsing). +func assertNodeMajorAtLeast(ctx context.Context, exec containerExecer, minMajor int) error { + code, reader, err := exec.Exec(ctx, []string{"node", "--version"}, tcexec.Multiplexed()) + if err != nil { + return fmt.Errorf("act image node version check exec failed: %w", err) + } + out := readExecOutput(reader) + if code != 0 { + return fmt.Errorf("act image node version check exited %d: %s", code, out) + } + + major, err := parseNodeMajor(out) + if err != nil { + return fmt.Errorf("act image %s: %w", actRunnerImage, err) + } + if major < minMajor { + return fmt.Errorf( + "act image %s provides Node %d (%q); the e2e suite requires Node >= %d because generated workflows run Node 24 actions. Repin actRunnerImage to a newer catthehacker digest carrying Node >= %d", + actRunnerImage, major, out, minMajor, minMajor, + ) + } + return nil +} + +// parseNodeMajor extracts the major version from a `node --version` string such +// as "v24.17.0" (the leading "v" is optional and trailing patch/build metadata +// is ignored). It returns an error for empty or non-numeric input so a garbled +// exec result fails loudly rather than silently reading as major 0. +func parseNodeMajor(version string) (int, error) { + v := strings.TrimPrefix(strings.TrimSpace(version), "v") + if v == "" { + return 0, fmt.Errorf("empty node version output") + } + majorStr := v + if i := strings.IndexByte(v, '.'); i >= 0 { + majorStr = v[:i] + } + major, err := strconv.Atoi(majorStr) + if err != nil { + return 0, fmt.Errorf("unparseable node version %q: %w", version, err) + } + return major, nil +} + // readExecOutput drains an exec output reader into a trimmed string for error // messages, tolerating a nil reader. Output here is short (a curl error line or // a `command -v` result), so reading it whole is fine. diff --git a/e2e/harness/act_node_version_test.go b/e2e/harness/act_node_version_test.go new file mode 100644 index 0000000..7b87643 --- /dev/null +++ b/e2e/harness/act_node_version_test.go @@ -0,0 +1,133 @@ +package harness + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tcexec "github.com/testcontainers/testcontainers-go/exec" +) + +// nodeVersionExecer is a containerExecer that scripts a single `node --version` +// response without Docker, so the act-image Node-floor preflight is exercisable +// in a plain unit test. It records the command it was handed so a test can +// confirm the preflight asks for the node version. +type nodeVersionExecer struct { + code int + output string + err error + gotCmd []string + gotMulti bool +} + +func (n *nodeVersionExecer) Exec(_ context.Context, cmd []string, opts ...tcexec.ProcessOption) (int, io.Reader, error) { + n.gotCmd = cmd + n.gotMulti = len(opts) > 0 + if n.err != nil { + return 0, nil, n.err + } + return n.code, strings.NewReader(n.output), nil +} + +// TestParseNodeMajor covers the version-string shapes `node --version` can +// produce, plus the malformed inputs that must fail loudly rather than read as +// major 0. +func TestParseNodeMajor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantMajor int + wantErr bool + }{ + {name: "standard with v prefix", input: "v24.17.0", wantMajor: 24}, + {name: "no v prefix", input: "24.17.0", wantMajor: 24}, + {name: "older major", input: "v20.11.1", wantMajor: 20}, + {name: "major only", input: "v24", wantMajor: 24}, + {name: "surrounding whitespace", input: " v18.0.0\n", wantMajor: 18}, + {name: "two digit major", input: "v100.0.0", wantMajor: 100}, + {name: "empty", input: "", wantErr: true}, + {name: "whitespace only", input: " \n", wantErr: true}, + {name: "v only", input: "v", wantErr: true}, + {name: "non numeric", input: "vabc.1.2", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseNodeMajor(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantMajor, got) + }) + } +} + +// TestAssertNodeMajorAtLeast_MeetsFloor proves the preflight passes when the +// image's Node major is at or above the required floor, and that it asks the +// container for the node version over a multiplexed (header-stripped) stream. +func TestAssertNodeMajorAtLeast_MeetsFloor(t *testing.T) { + t.Parallel() + + exec := &nodeVersionExecer{code: 0, output: "v24.17.0\n"} + err := assertNodeMajorAtLeast(context.Background(), exec, 24) + require.NoError(t, err) + assert.Equal(t, []string{"node", "--version"}, exec.gotCmd) + assert.True(t, exec.gotMulti, "version check must request a multiplexed reader so Docker frame headers are stripped") +} + +// TestAssertNodeMajorAtLeast_BelowFloor proves a stale image whose Node major is +// below the floor fails with a message that names the deficient version and +// tells the reader to repin, instead of letting a Node 24 action crash later. +func TestAssertNodeMajorAtLeast_BelowFloor(t *testing.T) { + t.Parallel() + + exec := &nodeVersionExecer{code: 0, output: "v20.11.1\n"} + err := assertNodeMajorAtLeast(context.Background(), exec, 24) + require.Error(t, err) + assert.Contains(t, err.Error(), "Node 20") + assert.Contains(t, err.Error(), "Repin") + assert.Contains(t, err.Error(), actRunnerImage) +} + +// TestAssertNodeMajorAtLeast_NonZeroExit surfaces a node binary that exists but +// exits non-zero (e.g. a broken install) rather than treating it as a pass. +func TestAssertNodeMajorAtLeast_NonZeroExit(t *testing.T) { + t.Parallel() + + exec := &nodeVersionExecer{code: 127, output: "node: command not found"} + err := assertNodeMajorAtLeast(context.Background(), exec, 24) + require.Error(t, err) + assert.Contains(t, err.Error(), "exited 127") +} + +// TestAssertNodeMajorAtLeast_ExecError surfaces a Docker transport failure on +// the version check as an error rather than a silent pass. +func TestAssertNodeMajorAtLeast_ExecError(t *testing.T) { + t.Parallel() + + exec := &nodeVersionExecer{err: errors.New("docker daemon gone")} + err := assertNodeMajorAtLeast(context.Background(), exec, 24) + require.Error(t, err) + assert.Contains(t, err.Error(), "exec failed") +} + +// TestAssertNodeMajorAtLeast_GarbledOutput proves an unparseable version string +// fails the preflight (with the image named) instead of reading as major 0 and +// tripping a confusing below-floor message. +func TestAssertNodeMajorAtLeast_GarbledOutput(t *testing.T) { + t.Parallel() + + exec := &nodeVersionExecer{code: 0, output: "not-a-version"} + err := assertNodeMajorAtLeast(context.Background(), exec, 24) + require.Error(t, err) + assert.Contains(t, err.Error(), actRunnerImage) +}