diff --git a/act/container/docker_run_test.go b/act/container/docker_run_test.go index 02a335bf..9917918a 100644 --- a/act/container/docker_run_test.go +++ b/act/container/docker_run_test.go @@ -16,7 +16,6 @@ import ( "path/filepath" "strings" "testing" - "time" "gitea.com/gitea/runner/act/common" @@ -148,12 +147,17 @@ func (m *mockDockerClient) NetworkRemove(ctx context.Context, id string, opts mo return args.Get(0).(mobyclient.NetworkRemoveResult), args.Error(1) } -type endlessReader struct { - io.Reader +type interruptReader struct { + started chan struct{} + interrupted chan struct{} + stopped chan struct{} } -func (r endlessReader) Read(_ []byte) (n int, err error) { - return 1, nil +func (r *interruptReader) Read(_ []byte) (int, error) { + close(r.started) + <-r.interrupted + close(r.stopped) + return 0, io.EOF } type mockConn struct { @@ -173,15 +177,18 @@ func (m *mockConn) Close() (err error) { func TestDockerExecAbort(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) + reader := &interruptReader{started: make(chan struct{}), interrupted: make(chan struct{}), stopped: make(chan struct{})} conn := &mockConn{} - conn.On("Write", mock.AnythingOfType("[]uint8")).Return(1, nil) + conn.On("Write", []byte{3}). + Run(func(mock.Arguments) { close(reader.interrupted) }). + Return(1, nil) client := &mockDockerClient{} client.On("ExecCreate", ctx, "123", mock.AnythingOfType("client.ExecCreateOptions")).Return(mobyclient.ExecCreateResult{ID: "id"}, nil) client.On("ExecAttach", ctx, "id", mock.AnythingOfType("client.ExecAttachOptions")).Return(mobyclient.ExecAttachResult{ HijackedResponse: mobyclient.HijackedResponse{ Conn: conn, - Reader: bufio.NewReader(endlessReader{}), + Reader: bufio.NewReader(reader), }, }, nil) @@ -199,11 +206,11 @@ func TestDockerExecAbort(t *testing.T) { channel <- cr.exec([]string{""}, map[string]string{}, "user", "workdir")(ctx) }() - time.Sleep(500 * time.Millisecond) - + <-reader.started cancel() err := <-channel + <-reader.stopped assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // pre-existing issue from nektos/act conn.AssertExpectations(t) diff --git a/act/runner/cancellation_test.go b/act/runner/cancellation_test.go index 3af3f5ed..77920003 100644 --- a/act/runner/cancellation_test.go +++ b/act/runner/cancellation_test.go @@ -107,17 +107,14 @@ func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) { "job1": createJob(t, `runs-on: ubuntu-latest`, ""), }) - // A short deadline that we let elapse between steps, so no step records the error itself. - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) - defer cancel() + ctx := newControllableDeadlineContext(context.Background()) var ran []string var laterStepCtxErr error steps := []common.Executor{ - func(c context.Context) error { + func(context.Context) error { ran = append(ran, "step1") - // Block until the job deadline elapses, then return cleanly: the interrupt lands in the loop's between-steps check, not inside a step. - <-c.Done() + ctx.expire() return nil }, func(c context.Context) error { diff --git a/act/runner/job_executor_test.go b/act/runner/job_executor_test.go index 1a841bd6..ec3240a0 100644 --- a/act/runner/job_executor_test.go +++ b/act/runner/job_executor_test.go @@ -528,18 +528,39 @@ func TestNewJobExecutor(t *testing.T) { } } +type controllableDeadlineContext struct { + context.Context + done chan struct{} +} + +func newControllableDeadlineContext(parent context.Context) *controllableDeadlineContext { + return &controllableDeadlineContext{Context: parent, done: make(chan struct{})} +} + +func (ctx *controllableDeadlineContext) Done() <-chan struct{} { + return ctx.done +} + +func (ctx *controllableDeadlineContext) Err() error { + select { + case <-ctx.done: + return context.DeadlineExceeded + default: + return nil + } +} + +func (ctx *controllableDeadlineContext) expire() { + close(ctx.done) +} + // TestNewJobExecutorRunsPostStepsAfterTimeout guards the timeout-minutes cleanup // path: when a job exceeds its timeout the job context is DeadlineExceeded, but // the post steps (cleanup hooks like actions/checkout post and cache save) must // still run against a fresh, non-expired context, and the job must still be // reported as failed. func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) { - ctx := common.WithJobErrorContainer(context.Background()) - // The timeout is generous so the main step (which blocks on ctx.Done below) is - // always reached before the deadline fires; otherwise the pipeline would - // short-circuit before the step runs and the job error would never be set. - ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) - defer cancel() + ctx := newControllableDeadlineContext(common.WithJobErrorContainer(context.Background())) jim := &jobInfoMock{} sfm := &stepFactoryMock{} @@ -570,11 +591,9 @@ func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) { sm := &stepMock{} sfm.On("newStep", stepModel, rc).Return(sm, nil) sm.On("pre").Return(func(ctx context.Context) error { return nil }) - // The main step runs past the job timeout: it blocks until the job context is - // done, mirroring a step that overruns timeout-minutes. - sm.On("main").Return(func(ctx context.Context) error { - <-ctx.Done() - return ctx.Err() + sm.On("main").Return(func(stepCtx context.Context) error { + ctx.expire() + return stepCtx.Err() }) var postRan bool diff --git a/act/runner/runner_test.go b/act/runner/runner_test.go index 79e5c280..1f33a032 100644 --- a/act/runner/runner_test.go +++ b/act/runner/runner_test.go @@ -14,6 +14,7 @@ import ( "runtime" "slices" "strings" + "sync" "testing" "time" @@ -171,6 +172,23 @@ func TestGraphEvent(t *testing.T) { assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act assert.NotNil(t, plan) assert.Empty(t, plan.Stages) + + for _, workflowPath := range []string{ + "testdata/workflow_dispatch_no_inputs_mapping/workflow_dispatch.yml", + "testdata/workflow_dispatch-scalar/workflow_dispatch.yml", + } { + planner, err := model.NewWorkflowPlanner(workflowPath, true) + if !assert.NoError(t, err, workflowPath) { //nolint:testifylint // pre-existing issue from nektos/act + continue + } + plan, err := planner.PlanEvent("workflow_dispatch") + if !assert.NoError(t, err, workflowPath) || !assert.NotNil(t, plan, workflowPath) { //nolint:testifylint // pre-existing issue from nektos/act + continue + } + if assert.Len(t, plan.Stages, 1, workflowPath) { + assert.Len(t, plan.Stages[0].Runs, 1, workflowPath) + } + } } // these two build the same action Dockerfiles into one image tag, so they cannot overlap @@ -276,7 +294,6 @@ func TestRunEvent(t *testing.T) { {workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets}, {workdir, "checkout", "push", "", platforms, secrets}, {workdir, "job-container", "push", "", platforms, secrets}, - {workdir, "job-container-non-root", "push", "", platforms, secrets}, {workdir, "job-container-invalid-credentials", "push", "failed to handle credentials: failed to interpolate container.credentials.password", platforms, secrets}, {workdir, "container-hostname", "push", "", platforms, secrets}, {workdir, "matrix", "push", "", platforms, secrets}, @@ -286,17 +303,14 @@ func TestRunEvent(t *testing.T) { {workdir, "defaults-run", "push", "", platforms, secrets}, {workdir, "composite-fail-with-output", "push", "", platforms, secrets}, {workdir, "issue-597", "push", "", platforms, secrets}, - {workdir, "issue-598", "push", "", platforms, secrets}, {workdir, "if-env-act", "push", "", platforms, secrets}, - {workdir, "env-and-path", "push", "", platforms, secrets}, {workdir, "environment-files", "push", "", platforms, secrets}, {workdir, "GITHUB_STATE", "push", "", platforms, secrets}, {workdir, "environment-files-parser-bug", "push", "", platforms, secrets}, {workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets}, {workdir, "outputs", "push", "", platforms, secrets}, {workdir, "networking", "push", "", platforms, secrets}, - {workdir, "steps-context/conclusion", "push", "", platforms, secrets}, - {workdir, "steps-context/outcome", "push", "", platforms, secrets}, + {workdir, "steps-context", "push", "", platforms, secrets}, {workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets}, {workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets}, {workdir, "actions-environment-and-context-tests", "push", "", platforms, secrets}, @@ -306,8 +320,6 @@ func TestRunEvent(t *testing.T) { {workdir, "ensure-post-steps", "push", "Job 'second-post-step-should-fail' failed", platforms, secrets}, {workdir, "workflow_call_inputs", "workflow_call", "", platforms, secrets}, {workdir, "workflow_dispatch", "workflow_dispatch", "", platforms, secrets}, - {workdir, "workflow_dispatch_no_inputs_mapping", "workflow_dispatch", "", platforms, secrets}, - {workdir, "workflow_dispatch-scalar", "workflow_dispatch", "", platforms, secrets}, {workdir, "workflow_dispatch-scalar-composite-action", "workflow_dispatch", "", platforms, secrets}, {workdir, "job-needs-context-contains-result", "push", "", platforms, secrets}, {workdir, "container-volumes", "push", "", platforms, secrets}, @@ -323,14 +335,17 @@ func TestRunEvent(t *testing.T) { {workdir, "services-empty-image", "push", "", platforms, secrets}, } + var sharedImageMu sync.Mutex for _, table := range tables { t.Run(table.workflowPath, func(t *testing.T) { if table.workflowPath == "container-volumes" { // host /proc bind mounts are Linux-Docker-only requireLinuxDocker(t) } - if !slices.Contains(sharedImageWorkflows, table.workflowPath) { - t.Parallel() + t.Parallel() + if slices.Contains(sharedImageWorkflows, table.workflowPath) { + sharedImageMu.Lock() + defer sharedImageMu.Unlock() } config := &Config{ @@ -386,20 +401,17 @@ func TestRunEventHostEnvironment(t *testing.T) { {workdir, "evalmatrix-merge-map", "push", "", platforms, secrets}, {workdir, "evalmatrix-merge-array", "push", "", platforms, secrets}, - {workdir, "fail", "push", "exit with `FAILURE`: 1", platforms, secrets}, {workdir, "checkout", "push", "", platforms, secrets}, {workdir, "matrix", "push", "", platforms, secrets}, {workdir, "commands", "push", "", platforms, secrets}, {workdir, "defaults-run", "push", "", platforms, secrets}, {workdir, "composite-fail-with-output", "push", "", platforms, secrets}, {workdir, "issue-597", "push", "", platforms, secrets}, - {workdir, "issue-598", "push", "", platforms, secrets}, {workdir, "if-env-act", "push", "", platforms, secrets}, {workdir, "env-and-path", "push", "", platforms, secrets}, {workdir, "non-existent-action", "push", "Job 'nopanic' failed", platforms, secrets}, {workdir, "outputs", "push", "", platforms, secrets}, - {workdir, "steps-context/conclusion", "push", "", platforms, secrets}, - {workdir, "steps-context/outcome", "push", "", platforms, secrets}, + {workdir, "steps-context", "push", "", platforms, secrets}, {workdir, "job-status-check", "push", "job 'fail' failed", platforms, secrets}, {workdir, "if-expressions", "push", "Job 'mytest' failed", platforms, secrets}, {workdir, "evalenv", "push", "", platforms, secrets}, @@ -432,6 +444,7 @@ func TestRunEventHostEnvironment(t *testing.T) { }...) } + hostPlanSlots := make(chan struct{}, 2) for _, table := range tables { t.Run(table.workflowPath, func(t *testing.T) { switch table.workflowPath { @@ -440,6 +453,9 @@ func TestRunEventHostEnvironment(t *testing.T) { case "nix-prepend-path": requireHostTools(t, "nix") } + t.Parallel() + hostPlanSlots <- struct{}{} + defer func() { <-hostPlanSlots }() table.runTest(ctx, t, &Config{}) }) } @@ -544,41 +560,6 @@ func TestRunEventSecrets(t *testing.T) { tjfi.runTest(context.Background(), t, &Config{Secrets: secrets, Env: env}) } -func TestRunWithService(t *testing.T) { - requireDocker(t) - - log.SetLevel(log.DebugLevel) - ctx := context.Background() - - platforms := map[string]string{ - "ubuntu-latest": "node:24-bookworm-slim", - } - - workflowPath := "services" - eventName := "push" - - workdir, err := filepath.Abs("testdata") - assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act - - runnerConfig := &Config{ - Workdir: workdir, - EventName: eventName, - PlatformPicker: mapPlatformPicker(platforms), - ContainerMaxLifetime: time.Hour, // otherwise the job container is `sleep 0` and exits at once - } - runner, err := New(runnerConfig) - assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act - - planner, err := model.NewWorkflowPlanner("testdata/"+workflowPath, true) - assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act - - plan, err := planner.PlanEvent(eventName) - assert.NoError(t, err, workflowPath) //nolint:testifylint // pre-existing issue from nektos/act - - err = runner.NewPlanExecutor(plan)(ctx) - assert.NoError(t, err, workflowPath) -} - func TestRunEventPullRequest(t *testing.T) { t.Parallel() requireDocker(t) diff --git a/act/runner/testdata/issue-597/spelling.yaml b/act/runner/testdata/issue-597/spelling.yaml index db20afa1..3aca234f 100644 --- a/act/runner/testdata/issue-597/spelling.yaml +++ b/act/runner/testdata/issue-597/spelling.yaml @@ -1,4 +1,4 @@ -name: issue-597 +name: issues-597-598 on: push @@ -13,6 +13,9 @@ jobs: - name: My first true step if: ${{endsWith('Hello world', 'ld')}} run: echo "Renst the Octocat" + - name: My second true step + if: "!endsWith('Hello world', 'od')" + run: echo "Renst the Octocat" - name: My second false step if: "endsWith('Should not evaluate', 'o2')" run: exit 1 diff --git a/act/runner/testdata/issue-598/spelling.yml b/act/runner/testdata/issue-598/spelling.yml deleted file mode 100644 index ea920491..00000000 --- a/act/runner/testdata/issue-598/spelling.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: issue-598 -on: push - - -jobs: - my_first_job: - - runs-on: ubuntu-latest - steps: - - name: My first false step - if: "endsWith('Hello world', 'o1')" - run: exit 1 - - name: My first true step - if: "!endsWith('Hello world', 'od')" - run: echo "Renst the Octocat" - - name: My second false step - if: "endsWith('Hello world', 'o2')" - run: exit 1 - - name: My third false step - if: "endsWith('Hello world', 'o2')" - run: exit 1 diff --git a/act/runner/testdata/job-container-non-root/push.yml b/act/runner/testdata/job-container-non-root/push.yml deleted file mode 100644 index 68a9f392..00000000 --- a/act/runner/testdata/job-container-non-root/push.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: job-container -on: push - -jobs: - test: - runs-on: ubuntu-latest - container: - image: node:24-bookworm-slim - options: --user 1000 - steps: - - run: echo PASS diff --git a/act/runner/testdata/matrix/push.yml b/act/runner/testdata/matrix/push.yml index a975c1eb..b5725154 100644 --- a/act/runner/testdata/matrix/push.yml +++ b/act/runner/testdata/matrix/push.yml @@ -12,13 +12,13 @@ jobs: strategy: matrix: os: [ubuntu-18.04, macos-latest] - node: [4, 6, 8, 10] + node: [4, 10] test: runs-on: ubuntu-latest strategy: matrix: - node: [8.x, 10.x, 12.x, 13.x] + node: [8.x, 13.x] steps: - run: echo ${NODE_VERSION} | grep ${{ matrix.node }} env: diff --git a/act/runner/testdata/networking/push.yml b/act/runner/testdata/networking/push.yml index 1e02ba71..81271a3b 100644 --- a/act/runner/testdata/networking/push.yml +++ b/act/runner/testdata/networking/push.yml @@ -4,11 +4,5 @@ jobs: test: runs-on: ubuntu-latest steps: - - name: Install tools - run: | - apt update - apt install -y iputils-ping - - name: Run hostname test - run: | - hostname -f - ping -c 4 $(hostname -f) + - name: Resolve the container hostname + run: getent hosts "$(hostname -f)" diff --git a/act/runner/testdata/outputs/push.yml b/act/runner/testdata/outputs/push.yml index fc9317aa..82f29b65 100644 --- a/act/runner/testdata/outputs/push.yml +++ b/act/runner/testdata/outputs/push.yml @@ -12,32 +12,17 @@ jobs: - id: set_2 run: | echo "::set-output name=var_3::$(echo var3)" - - id: set_3 - run: | - echo "::set-output name=var_4::$(echo var4)" outputs: variable_1: ${{ steps.set_1.outputs.var_1 }} variable_2: ${{ steps.set_1.outputs.var_2 }} variable_3: ${{ steps.set_2.outputs.var_3 }} - variable_4: ${{ steps.set_3.outputs.var_4 }} build: needs: build_output runs-on: ubuntu-latest steps: - - name: Check set_1 var1 + - name: Check outputs run: | - echo "${{ needs.build_output.outputs.variable_1 }}" - echo "${{ needs.build_output.outputs.variable_1 }}" | grep 'var1' || exit 1 - - name: Check set_1 var2 - run: | - echo "${{ needs.build_output.outputs.variable_2 }}" - echo "${{ needs.build_output.outputs.variable_2 }}" | grep 'var2' || exit 1 - - name: Check set_2 var3 - run: | - echo "${{ needs.build_output.outputs.variable_3 }}" - echo "${{ needs.build_output.outputs.variable_3 }}" | grep 'var3' || exit 1 - - name: Check set_3 var4 - run: | - echo "${{ needs.build_output.outputs.variable_4 }}" - echo "${{ needs.build_output.outputs.variable_4 }}" | grep 'var4' || exit 1 + test "${{ needs.build_output.outputs.variable_1 }}" = var1 + test "${{ needs.build_output.outputs.variable_2 }}" = var2 + test "${{ needs.build_output.outputs.variable_3 }}" = var3 diff --git a/act/runner/testdata/services/push.yaml b/act/runner/testdata/services/push.yaml index 2ebce33b..65a98c87 100644 --- a/act/runner/testdata/services/push.yaml +++ b/act/runner/testdata/services/push.yaml @@ -10,14 +10,10 @@ jobs: ports: - 80 steps: - - name: Echo the Postgres service ID / Network / Ports - run: | - echo "id: ${{ job.services.postgres.id }}" - echo "network: ${{ job.services.postgres.network }}" - echo "ports: ${{ job.services.postgres.ports }}" - name: The job context describes the started containers run: | test -n "${{ job.container.id }}" test -n "${{ job.services.postgres.id }}" + test -n "${{ job.services.postgres.ports }}" test -n "${{ job.services.postgres.ports['80'] }}" test "${{ job.services.postgres.network }}" = "${{ job.container.network }}" diff --git a/act/runner/testdata/shells/bash/push.yml b/act/runner/testdata/shells/bash/push.yml index f177d684..c8ba9dc5 100644 --- a/act/runner/testdata/shells/bash/push.yml +++ b/act/runner/testdata/shells/bash/push.yml @@ -4,6 +4,9 @@ env: jobs: check: runs-on: ubuntu-latest + defaults: + run: + shell: ${{ env.MY_SHELL }} steps: - shell: ${{ env.MY_SHELL }} run: | @@ -12,6 +15,12 @@ jobs: else exit 1 fi + - run: | + if [[ -n "$BASH" ]]; then + echo "I'm $BASH!" + else + exit 1 + fi check-container: runs-on: ubuntu-latest container: node:24-bookworm-slim @@ -23,15 +32,3 @@ jobs: else exit 1 fi - check-job-default: - runs-on: ubuntu-latest - defaults: - run: - shell: ${{ env.MY_SHELL }} - steps: - - run: | - if [[ -n "$BASH" ]]; then - echo "I'm $BASH!" - else - exit 1 - fi diff --git a/act/runner/testdata/shells/pwsh/push.yml b/act/runner/testdata/shells/pwsh/push.yml index 47385aaf..27541330 100644 --- a/act/runner/testdata/shells/pwsh/push.yml +++ b/act/runner/testdata/shells/pwsh/push.yml @@ -3,16 +3,13 @@ env: MY_SHELL: pwsh jobs: check: - runs-on: ubuntu-latest - steps: - - shell: ${{ env.MY_SHELL }} - run: | - $PSVersionTable - check-job-default: runs-on: ubuntu-latest defaults: run: shell: ${{ env.MY_SHELL }} steps: + - shell: ${{ env.MY_SHELL }} + run: | + $PSVersionTable - run: | $PSVersionTable diff --git a/act/runner/testdata/shells/sh/push.yml b/act/runner/testdata/shells/sh/push.yml index 0914ca2f..54850a0d 100644 --- a/act/runner/testdata/shells/sh/push.yml +++ b/act/runner/testdata/shells/sh/push.yml @@ -4,6 +4,9 @@ env: jobs: check: runs-on: ubuntu-latest + defaults: + run: + shell: ${{ env.MY_SHELL }} steps: - shell: ${{ env.MY_SHELL }} run: | @@ -12,6 +15,12 @@ jobs: else exit 1 fi + - run: | + if [ -z ${BASH+x} ]; then + echo "I'm sh!" + else + exit 1 + fi check-container: runs-on: ubuntu-latest container: alpine:latest @@ -23,15 +32,3 @@ jobs: else exit 1 fi - check-job-default: - runs-on: ubuntu-latest - defaults: - run: - shell: ${{ env.MY_SHELL }} - steps: - - run: | - if [ -z ${BASH+x} ]; then - echo "I'm sh!" - else - exit 1 - fi diff --git a/act/runner/testdata/steps-context/conclusion/push.yml b/act/runner/testdata/steps-context/conclusion/push.yml deleted file mode 100644 index aff6bb4f..00000000 --- a/act/runner/testdata/steps-context/conclusion/push.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: conclusion -on: push - -jobs: - check: - runs-on: ubuntu-latest - steps: - - id: first - run: exit 0 - - id: second - continue-on-error: true - run: exit 1 - - run: echo '${{ steps.first.conclusion }}' | grep 'success' - - run: echo '${{ steps.second.conclusion }}' | grep 'success' diff --git a/act/runner/testdata/steps-context/outcome/push.yml b/act/runner/testdata/steps-context/outcome/push.yml deleted file mode 100644 index 20f8e811..00000000 --- a/act/runner/testdata/steps-context/outcome/push.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: outcome -on: push - -jobs: - check: - runs-on: ubuntu-latest - steps: - - id: first - run: exit 0 - - id: second - continue-on-error: true - run: exit 1 - - run: echo '${{ steps.first.outcome }}' | grep 'success' - - run: echo '${{ steps.second.outcome }}' | grep 'failure' diff --git a/act/runner/testdata/steps-context/push.yml b/act/runner/testdata/steps-context/push.yml new file mode 100644 index 00000000..7e08c51f --- /dev/null +++ b/act/runner/testdata/steps-context/push.yml @@ -0,0 +1,17 @@ +name: steps context +on: push + +jobs: + check: + runs-on: ubuntu-latest + steps: + - id: first + run: exit 0 + - id: second + continue-on-error: true + run: exit 1 + - run: | + test '${{ steps.first.conclusion }}' = success + test '${{ steps.second.conclusion }}' = success + test '${{ steps.first.outcome }}' = success + test '${{ steps.second.outcome }}' = failure