diff --git a/act/runner/cancellation_test.go b/act/runner/cancellation_test.go index ec2d7afe..add340ce 100644 --- a/act/runner/cancellation_test.go +++ b/act/runner/cancellation_test.go @@ -283,3 +283,30 @@ func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) { require.NoError(t, postCtx.Err(), "post context must not carry the expired deadline") assert.ErrorIs(t, common.JobError(postCtx), assert.AnError, "the timeout job error must be preserved") } + +// reportStepError must treat a context.Canceled (e.g. a teardown-cancelled read) as an +// interruption, never a job failure. +func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) { + rc := &RunContext{} + + // stray read cancellation while the job context is live: ignored, not a failure + live := common.WithJobErrorContainer(context.Background()) + reportStepError(live, rc, context.Canceled) + require.NoError(t, common.JobError(live)) + assert.False(t, rc.jobFailed) + assert.False(t, rc.jobCancelled) + + // genuine job cancellation: recorded as cancelled, still not a failure + cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background())) + cancel() + reportStepError(cancelled, rc, context.Canceled) + require.NoError(t, common.JobError(cancelled)) + assert.False(t, rc.jobFailed) + assert.True(t, rc.jobCancelled) + + // a real error still fails the job + failed := common.WithJobErrorContainer(context.Background()) + reportStepError(failed, rc, assert.AnError) + require.ErrorIs(t, common.JobError(failed), assert.AnError) + assert.True(t, rc.jobFailed) +} diff --git a/act/runner/job_executor.go b/act/runner/job_executor.go index abd92346..ed27f1c5 100644 --- a/act/runner/job_executor.go +++ b/act/runner/job_executor.go @@ -10,6 +10,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -56,9 +57,15 @@ type jobInfo interface { result(result string) } -// reportStepError emits the GitHub Actions ##[error] annotation and records -// the error against the job so the job is reported as failed. +// reportStepError records a step error so the job is reported failed — except a +// cancellation, which is an interruption, not a failure. func reportStepError(ctx context.Context, rc *RunContext, err error) { + if errors.Is(err, context.Canceled) { + // Defer to the job context: a genuine cancel reports cancelled, a stray teardown + // cancellation on a live ctx is ignored — never a step FAILURE. + rc.markInterrupted(ctx.Err()) + return + } common.Logger(ctx).Errorf("##[error]%v", err) common.SetJobError(ctx, err) rc.markFailed()