mirror of
https://gitea.com/gitea/runner.git
synced 2026-08-27 22:37:46 +00:00
12dc9d26a2
Fixes 51 bugs discovered via comparison with `actions/runner`. Every fix has test coverage.
### Secrets
- A short secret registered no shifted-base64 form, so `base64("user:$TOKEN")` printed in the clear
- Encoded forms came only from the whole trimmed value, missing padded and per-line spellings
- Masks split only on `\n`, so `::add-mask::a%0Db` registered neither half
- Adds XML, expression-string and quote-trimming encoders
### Workflow commands
- Split at the last `::` or `]` rather than the first, so `::add-mask::a::b` registered no mask
- A command on the last line without a newline was ignored, and `::ADD-MASK::` did nothing
- `##[...]` did not decode `%3B`/`%5D`, properties lost anything after a second `=`
- `$GITHUB_ENV` and `::set-env::` now refuse `NODE_OPTIONS`
### Status
- `continue-on-error` reported failed, a cancelled job reported success, an `if:` error reported cancelled
- File commands ran after `continue-on-error`, failing the job while the step stayed green
- A bad job output aborted the whole run instead of that job
### Steps and actions
- `${{ matrix.* }}` and `${{ strategy.* }}` were empty inside composite actions
- Composite inputs leaked into nested actions as `INPUT_*`, `with:` matched case-sensitively, `pre` failures were dropped
- Docker actions dropped `runs.env` when the caller passed `with: args:`, and caller `args`/`entrypoint` beat the manifest
- An implicit shell ran with `pipefail`, a `shell:` without `{0}` passed without running
- `container.env` overrode job env and every `$GITHUB_ENV` write, heredocs lost leading blank lines, `$GITHUB_PATH` was not BOM-decoded
Written by Claude Opus 5.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1194
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
312 lines
12 KiB
Go
312 lines
12 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.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(context.Background(), rc, rc, true)
|
|
assert.Equal(t, "cancelled", rc.Run.Job().Result)
|
|
}
|
|
|
|
// TestMainStepsExecutorRunsAlwaysStepsAfterCancel verifies that newMainStepsExecutor does
|
|
// not abandon the remaining steps when the run is cancelled mid-pipeline. The later step
|
|
// still runs (so a main-stage always() step is reached), it runs under a fresh,
|
|
// non-cancelled context, and the job is marked cancelled. The interrupt error is still
|
|
// propagated so callers up the chain see the cancellation.
|
|
func TestMainStepsExecutorRunsAlwaysStepsAfterCancel(t *testing.T) {
|
|
rc := createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
var ran []string
|
|
var laterStepCtxErr error
|
|
steps := []common.Executor{
|
|
func(_ context.Context) error {
|
|
ran = append(ran, "step1")
|
|
cancel() // server cancellation lands while step1 runs
|
|
return nil
|
|
},
|
|
func(c context.Context) error {
|
|
ran = append(ran, "always-step")
|
|
laterStepCtxErr = c.Err()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
err := newMainStepsExecutor(rc, steps)(ctx)
|
|
|
|
require.ErrorIs(t, err, context.Canceled, "interrupt error is propagated")
|
|
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after cancel")
|
|
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-cancelled context")
|
|
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
|
|
}
|
|
|
|
// TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps guards the timeout path's symmetry with the cancel path.
|
|
// When the job deadline (timeout-minutes) lands in the gap between two steps, the job must be marked as failed (not cancelled),
|
|
// so always()/failure() cleanup steps run while default success() steps skip, and so the timed-out job is not reported as success.
|
|
func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
|
|
rc := createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
|
})
|
|
|
|
ctx := newControllableDeadlineContext(context.Background())
|
|
|
|
var ran []string
|
|
var laterStepCtxErr error
|
|
steps := []common.Executor{
|
|
func(context.Context) error {
|
|
ran = append(ran, "step1")
|
|
ctx.expire()
|
|
return nil
|
|
},
|
|
func(c context.Context) error {
|
|
ran = append(ran, "always-step")
|
|
laterStepCtxErr = c.Err()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
err := newMainStepsExecutor(rc, steps)(ctx)
|
|
|
|
require.ErrorIs(t, err, context.DeadlineExceeded, "the timeout error is propagated")
|
|
assert.Equal(t, []string{"step1", "always-step"}, ran, "the always() step still runs after a timeout")
|
|
require.NoError(t, laterStepCtxErr, "remaining steps run under a fresh, non-expired context")
|
|
assert.True(t, rc.jobFailed, "a job timeout marks the job failed")
|
|
assert.False(t, rc.jobCancelled, "a timeout is not a cancellation")
|
|
|
|
// The status the real main-step `if` evaluation sees: "failure", so default success() steps skip while always()/failure() steps run.
|
|
assert.Equal(t, "failure", rc.getJobContext().Status)
|
|
}
|
|
|
|
// TestStepsExecutorRunsMainStepsAfterPreCancel verifies that a cancellation landing during the
|
|
// pre phase does not abandon the main steps: newStepsExecutor still runs the main-steps executor,
|
|
// so a main-stage always()/cancelled() step is reached (under a fresh, non-cancelled context),
|
|
// the job is marked cancelled, and the cancellation is propagated. Before the fix the `.Then(...)`
|
|
// short-circuit skipped the main steps entirely when a pre step was cancelled.
|
|
func TestStepsExecutorRunsMainStepsAfterPreCancel(t *testing.T) {
|
|
rc := createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
var ran []string
|
|
var mainStepCtxErr error
|
|
preSteps := []common.Executor{
|
|
func(_ context.Context) error {
|
|
ran = append(ran, "pre1")
|
|
cancel() // server cancellation lands during the pre phase
|
|
return nil
|
|
},
|
|
}
|
|
steps := []common.Executor{
|
|
func(c context.Context) error {
|
|
ran = append(ran, "always-step")
|
|
mainStepCtxErr = c.Err()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
err := newStepsExecutor(rc, preSteps, steps)(ctx)
|
|
|
|
require.ErrorIs(t, err, context.Canceled, "the cancellation is propagated")
|
|
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-phase cancel")
|
|
require.NoError(t, mainStepCtxErr, "the main step runs under a fresh, non-cancelled context")
|
|
assert.True(t, rc.jobCancelled, "the job is marked cancelled")
|
|
}
|
|
|
|
// TestStepsExecutorRunsMainStepsAfterPreFailure verifies that a failing pre step does not abandon
|
|
// the main steps: they still run (so a main-stage always()/failure() step is reached), and the
|
|
// pre-step error is propagated so the job is reported as failed. The main steps' own `if`
|
|
// evaluation is what skips success()-default steps, so running them here is safe.
|
|
func TestStepsExecutorRunsMainStepsAfterPreFailure(t *testing.T) {
|
|
rc := createIfTestRunContext(map[string]*model.Job{
|
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
|
})
|
|
|
|
var ran []string
|
|
preSteps := []common.Executor{
|
|
func(_ context.Context) error {
|
|
ran = append(ran, "pre1")
|
|
return assert.AnError
|
|
},
|
|
}
|
|
steps := []common.Executor{
|
|
func(_ context.Context) error {
|
|
ran = append(ran, "always-step")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
err := newStepsExecutor(rc, preSteps, steps)(context.Background())
|
|
|
|
require.ErrorIs(t, err, assert.AnError, "the pre-step error is propagated")
|
|
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-step failure")
|
|
assert.False(t, rc.jobCancelled, "a pre-step failure is not a cancellation")
|
|
}
|
|
|
|
// 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(context.Background())
|
|
|
|
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) {
|
|
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
|
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")
|
|
|
|
// 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(context.Background())
|
|
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(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)
|
|
}
|