mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-31 20:37:43 +08:00
Contains a number of small bug fixes where jobs behaved differently from GitHub or failed silently.
1. Hooks fail on a refused `::set-env::` or `::add-path::` and no longer inherit the last step's identity
1. Docker build, exec and wait errors are returned instead of swallowed, token-only registry credentials work, logs of `--tty` services decode
1. Env files split lines like GitHub on each OS, so a carriage return is kept on Linux and CRLF ends a line on Windows, empty heredoc names or delimiters are rejected
1. Post steps run after their pre step even when main was skipped, composites see undeclared inputs, `runs.args` wins without `with.args` being parsed
1. Docker actions skip evaluating defaults of inputs given in `with` and read `entryPoint` and `args` in any case, a lowercase `dockerfile` is found, undeclared inputs get GitHub's warning
1. A user-set `CI` is kept, `GITHUB_REPOSITORY_ID` and the other missing `GITHUB_*` variables are exported
1. `timeout-minutes` can read the step's own `env`, a step-level `ACTIONS_ALLOW_UNSECURE_COMMANDS: false` beats a job-level `true` while the runner's own opt-in still wins, `pwsh` quotes the script path
1. A step `env:` given as one expression such as `${{ fromJSON(matrix.env) }}` is applied, `hashFiles` fails with GitHub's messages on script errors and after 120 seconds, a job image without node still gets an empty hash
1. A step whose command files cannot be reset fails instead of importing stale ones, a timeout between steps fails the job instead of reporting success
1. One named volume can be mounted at several paths, host-mode PTY steps keep their last output when they fail, host scratch directories are removed when job setup fails
1. Cancelling a step right after it starts kills its whole process tree
1. Cache prefix restores pick the newest entry created within the same second, also on S3, cache commits with a wrong size are rejected
1. Artifact chunks are written at their offset, download URLs are escaped, listing a run without artifacts no longer crashes
1. Poll backoff reaches `fetch_interval_max`, proxy passwords are masked in more forms, secrets are also masked in step names and in their quoted form in the runner log, a failed final report is logged
1. On Windows hosts a differently cased unsecure-commands opt-in or proxy variable counts, and an early cancel no longer leaks a job object handle
1. Interactive `register` prompts for a missing token and stops on SIGTERM, `exec --list --detect-event` no longer panics without events, `config set` works on `cache.s3` keys
Reviewed-on: https://gitea.com/gitea/runner/pulls/1338
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
267 lines
9.8 KiB
Go
267 lines
9.8 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
|
|
"gitea.dev/actionslib/pkg/exprparser"
|
|
"gitea.dev/actionslib/pkg/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
// TestCancelledJobStatusEnablesAlwaysAndCancelledSteps verifies that once a job is
|
|
// cancelled, getJobContext reports the "cancelled" status so the step `if` functions
|
|
// evaluate the way GitHub Actions does: cancelled()/always() are true, success()/failure()
|
|
// are false. A step that defaults to success() is therefore skipped while an always() step
|
|
// still runs. Before the fix the status could only ever be success/failure, so cancelled()
|
|
// was structurally impossible and cancel-only cleanup steps never ran.
|
|
func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
|
|
rc := createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
|
})
|
|
rc.markCancelled()
|
|
|
|
// The core fix: the job status context now reports "cancelled" instead of being
|
|
// pinned to success/failure.
|
|
jobCtx := rc.getJobContext()
|
|
require.Equal(t, "cancelled", jobCtx.Status)
|
|
|
|
// Feed that status through the step-context expression functions, which is what a
|
|
// step `if` evaluates. On a cancelled job only always()/cancelled() are true.
|
|
interp := exprparser.NewInterpeter(
|
|
&exprparser.EvaluationEnvironment{Job: jobCtx},
|
|
exprparser.Config{Context: "step"},
|
|
)
|
|
for expr, want := range map[string]bool{
|
|
"cancelled()": true,
|
|
"always()": true,
|
|
"success()": false,
|
|
"failure()": false,
|
|
"!cancelled()": false,
|
|
} {
|
|
got, err := interp.Evaluate(expr, exprparser.DefaultStatusCheckNone)
|
|
require.NoErrorf(t, err, "Evaluate(%q)", expr)
|
|
assert.Equalf(t, want, got, "Evaluate(%q) on a cancelled job", expr)
|
|
}
|
|
|
|
// A step without an `if` defaults to success() and must be skipped on cancel,
|
|
// while an `if: always()` step must still run.
|
|
disabled, err := interp.Evaluate("", exprparser.DefaultStatusCheckSuccess)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, false, disabled, "default-success step must be skipped on a cancelled job")
|
|
|
|
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
|
setJobResult(t.Context(), rc, rc, true)
|
|
assert.Equal(t, "cancelled", rc.Run.Job().Result)
|
|
}
|
|
|
|
func TestMainStepsExecutorRunsAlwaysStepsAfterInterruption(t *testing.T) {
|
|
for name, wantError := range map[string]error{"cancel": context.Canceled, "timeout": context.DeadlineExceeded} {
|
|
t.Run(name, func(t *testing.T) {
|
|
rc := createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
|
})
|
|
var ctx context.Context
|
|
var interrupt func()
|
|
if wantError == context.Canceled {
|
|
ctx, interrupt = context.WithCancel(t.Context())
|
|
defer interrupt()
|
|
} else {
|
|
deadline := newControllableDeadlineContext(t.Context())
|
|
ctx, interrupt = deadline, deadline.expire
|
|
}
|
|
|
|
var ran []string
|
|
var laterStepCtxErr error
|
|
err := newMainStepsExecutor(rc, []common.Executor{
|
|
func(_ctx context.Context) error {
|
|
ran = append(ran, "step1")
|
|
interrupt()
|
|
return nil
|
|
},
|
|
func(ctx context.Context) error {
|
|
ran = append(ran, "always-step")
|
|
laterStepCtxErr = ctx.Err()
|
|
return nil
|
|
},
|
|
})(ctx)
|
|
|
|
require.ErrorIs(t, err, wantError)
|
|
assert.Equal(t, []string{"step1", "always-step"}, ran)
|
|
require.NoError(t, laterStepCtxErr)
|
|
assert.Equal(t, wantError == context.Canceled, rc.jobCancelled)
|
|
setJobResult(t.Context(), rc, rc, true)
|
|
if wantError == context.DeadlineExceeded {
|
|
assert.True(t, rc.jobFailed)
|
|
assert.Equal(t, "failure", rc.getJobContext().Status)
|
|
assert.Equal(t, "failure", rc.Run.Job().Result)
|
|
} else {
|
|
assert.Equal(t, "cancelled", rc.Run.Job().Result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStepsExecutorRunsMainStepsAfterPreInterruption(t *testing.T) {
|
|
for name, tt := range map[string]struct {
|
|
cancel bool
|
|
preErr error
|
|
wantError error
|
|
}{
|
|
"cancel": {cancel: true, wantError: context.Canceled},
|
|
"failure": {preErr: assert.AnError, wantError: assert.AnError},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
rc := createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
|
})
|
|
ctx := t.Context()
|
|
var cancel context.CancelFunc
|
|
if tt.cancel {
|
|
ctx, cancel = context.WithCancel(ctx)
|
|
defer cancel()
|
|
}
|
|
|
|
var ran []string
|
|
var mainStepCtxErr error
|
|
err := newStepsExecutor(rc, []common.Executor{
|
|
func(_ctx context.Context) error {
|
|
ran = append(ran, "pre1")
|
|
if tt.cancel {
|
|
cancel()
|
|
}
|
|
return tt.preErr
|
|
},
|
|
}, []common.Executor{
|
|
func(ctx context.Context) error {
|
|
ran = append(ran, "always-step")
|
|
mainStepCtxErr = ctx.Err()
|
|
return nil
|
|
},
|
|
})(ctx)
|
|
|
|
require.ErrorIs(t, err, tt.wantError)
|
|
assert.Equal(t, []string{"pre1", "always-step"}, ran)
|
|
require.NoError(t, mainStepCtxErr)
|
|
assert.Equal(t, tt.cancel, rc.jobCancelled)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPreStepFailureAffectsMainStepIfStatus verifies the status path used by real
|
|
// main-step `if` evaluation. A pre-step failure is not present in StepResults, so
|
|
// recording only the context job error is not enough: getJobContext must also report
|
|
// failure so success()-default main steps skip and failure() steps run.
|
|
func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
|
|
rc := createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
|
})
|
|
ctx := common.WithJobErrorContainer(t.Context())
|
|
|
|
reportStepError(ctx, rc, assert.AnError)
|
|
|
|
assert.Equal(t, "failure", rc.getJobContext().Status)
|
|
require.ErrorIs(t, common.JobError(ctx), assert.AnError)
|
|
|
|
defaultStep := &stepRun{
|
|
RunContext: rc,
|
|
Step: &model.Step{ID: "default-step"},
|
|
env: map[string]string{},
|
|
}
|
|
defaultEnabled, err := isStepEnabled(ctx, defaultStep.getIfExpression(ctx, stepStageMain), defaultStep, stepStageMain)
|
|
require.NoError(t, err)
|
|
assert.False(t, defaultEnabled, "default success() main step must skip after a pre-step failure")
|
|
|
|
failureStep := &stepRun{
|
|
RunContext: rc,
|
|
Step: &model.Step{
|
|
ID: "failure-step",
|
|
If: yaml.Node{Value: "failure()"},
|
|
},
|
|
env: map[string]string{},
|
|
}
|
|
failureEnabled, err := isStepEnabled(ctx, failureStep.getIfExpression(ctx, stepStageMain), failureStep, stepStageMain)
|
|
require.NoError(t, err)
|
|
assert.True(t, failureEnabled, "failure() main step must run after a pre-step failure")
|
|
}
|
|
|
|
// TestPostStepsContextCancelledIsUsableForFailingStep guards against a panic: post/cleanup
|
|
// steps run on a context derived from the cancelled job context, and a failing post step
|
|
// records its error via common.SetJobError. If that derived context lacks a job-error container,
|
|
// SetJobError dereferences a nil map and panics. The post context must therefore be detached
|
|
// from cancellation (so the steps run) yet still carry a usable error container.
|
|
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
|
jobSpan := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID{1}, SpanID: trace.SpanID{1}})
|
|
cancelled, cancel := context.WithCancel(trace.ContextWithSpanContext(common.WithJobErrorContainer(t.Context()), jobSpan))
|
|
cancel()
|
|
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
|
|
|
postCtx, done := postStepsContext(cancelled)
|
|
defer done()
|
|
|
|
// Detached from cancellation, so the post steps actually run.
|
|
require.NoError(t, postCtx.Err(), "post context must not be cancelled")
|
|
assert.Equal(t, jobSpan, trace.SpanContextFromContext(postCtx), "post steps must stay in the job's trace")
|
|
|
|
// A failing post step records its error instead of panicking.
|
|
require.NotPanics(t, func() {
|
|
common.SetJobError(postCtx, assert.AnError)
|
|
}, "a failing post step must not panic on the cancel path")
|
|
assert.ErrorIs(t, common.JobError(postCtx), assert.AnError)
|
|
}
|
|
|
|
// TestPostStepsContextDeadlinePreservesJobError verifies the job-timeout path keeps the original
|
|
// job-error container (via context.WithoutCancel), so the timeout failure and any post-step error
|
|
// survive into the post phase and the job is still reported as failed.
|
|
func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
|
|
base := common.WithJobErrorContainer(t.Context())
|
|
common.SetJobError(base, assert.AnError)
|
|
expired, cancel := context.WithDeadline(base, time.Now().Add(-time.Hour))
|
|
defer cancel()
|
|
require.ErrorIs(t, expired.Err(), context.DeadlineExceeded)
|
|
|
|
postCtx, done := postStepsContext(expired)
|
|
defer done()
|
|
|
|
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(t.Context())
|
|
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(t.Context()))
|
|
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(t.Context())
|
|
reportStepError(failed, rc, assert.AnError)
|
|
require.ErrorIs(t, common.JobError(failed), assert.AnError)
|
|
assert.True(t, rc.jobFailed)
|
|
}
|