Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 90 additions & 5 deletions e2e/harness/act.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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 <image> node --version` reports >= actRunnerNodeMajorMin,
// then record the resolved digest here
// (`docker inspect --format '{{index .RepoDigests 0}}' <image>`).
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
Expand All @@ -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
Expand Down Expand Up @@ -107,10 +132,10 @@ func NewActRunner(ctx context.Context, giteaURL, giteaToken, networkName string,
// Network override is passed on the CLI as `--network=<name>`. 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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
133 changes: 133 additions & 0 deletions e2e/harness/act_node_version_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading