enhance: report the steps of a task as they run (#1293)

Gitea derives the steps of a job from the workflow file, so the `pre` and `post` steps of actions only show up inside "Set up job" and "Complete job", see https://github.com/go-gitea/gitea/issues/24604.

The runner now also reports the steps as it runs them (https://gitea.com/gitea/actions-proto-def/pulls/19): "Set up job", a "Pre" and "Post" step for each action that has one, the workflow steps with their evaluated names, and "Complete job", as actions/runner shows them. The per-step state Gitea reads today is unchanged, and the job log only moves "Complete job name" and "Matrix:" ahead of the pre steps, so older Gitea versions keep showing everything as before. Job hooks and containers stay part of "Set up job" and "Complete job".

The `replace` of `gitea.dev/actionslib` is swapped for the release once the proto change is merged.

Reviewed-on: https://gitea.com/gitea/runner/pulls/1293
Reviewed-by: Zettat123 <[email protected]>
Co-authored-by: bircni <[email protected]>
This commit is contained in:
bircni authored and Lunny Xiao committed 2026-10-12 20:35:02 +00:00
1 parent 20c8b4abc4
commit 160e18e705
11 files changed
+497 -47

No files matched your search

+1 -1
View File
@@ -49,7 +49,7 @@ func TestJobLog_MirrorsUploadedRows(t *testing.T) {
reporter := NewReporter(ctx, cancel, client, task, cfg)
require.NotNil(t, reporter.jobLog)
reporter.RunDaemon()
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
fire := func(message string) {
require.NoError(t, fire(reporter, logEntry{
+173 -3
View File
@@ -4,6 +4,7 @@
package report
import (
"cmp"
"context"
"errors"
"fmt"
@@ -21,6 +22,7 @@ import (
"gitea.com/gitea/runner/internal/pkg/metrics"
"connectrpc.com/connect"
"gitea.dev/actionslib/pkg/model"
runnerv1 "gitea.dev/actionslib/runner/v1"
"github.com/avast/retry-go/v5"
"google.golang.org/protobuf/proto"
@@ -62,6 +64,11 @@ type Reporter struct {
state *runnerv1.TaskState
stateChanged bool
// The steps in state.ReportedSteps by stage and workflow step number, the step the next log row
// without a step of its own belongs to, and the workflow steps. Guarded by stateMu.
reportedSteps map[reportedStepKey]*runnerv1.StepState
currentReportedStep *runnerv1.StepState
stepModels []*model.Step
// reportFailing keeps an outage to one log line at each end.
reportFailing map[string]bool
// serverResult is what the server decided, e.g. the zombie reaper failing
@@ -157,14 +164,152 @@ func (r *Reporter) Result() runnerv1.Result {
return r.state.Result
}
func (r *Reporter) ResetSteps(l int) {
// ResetSteps sets the steps of the workflow job. They are reported both by their index in the
// workflow file, for Gitea versions deriving the steps from the file, and as the steps the runner
// runs, together with the setup, pre, post and cleanup steps, for Gitea versions that show those.
func (r *Reporter) ResetSteps(steps []*model.Step) {
r.stateMu.Lock()
defer r.stateMu.Unlock()
for i := range l {
for i := range steps {
r.state.Steps = append(r.state.Steps, &runnerv1.StepState{
Id: int64(i),
})
}
if len(steps) == 0 {
return // a reusable workflow caller, whose called jobs log their own steps through it
}
r.stepModels = steps
r.reportedSteps = map[reportedStepKey]*runnerv1.StepState{}
setup := r.addReportedStep(len(r.state.ReportedSteps), runnerv1.StepStage_STEP_STAGE_SETUP, 0, "Set up job")
setup.StartedAt = cmp.Or(r.state.StartedAt, timestamppb.Now())
setup.LogLength = int64(r.logOffset + len(r.logRows)) // the runner's own lines logged before the job was parsed
r.currentReportedStep = setup
for i, step := range steps {
r.addReportedStep(len(r.state.ReportedSteps), runnerv1.StepStage_STEP_STAGE_MAIN, i, r.mask(stepDisplayName(step, "")))
}
r.addReportedStep(len(r.state.ReportedSteps), runnerv1.StepStage_STEP_STAGE_CLEANUP, 0, "Complete job")
}
type reportedStepKey struct {
stage runnerv1.StepStage
number int
}
// stepDisplayName names a workflow step like Gitea and GitHub do, prefixing an unnamed one with "Run".
// evaluated is step.String() with its expressions evaluated, empty while the step has not run yet.
func stepDisplayName(step *model.Step, evaluated string) string {
if step.Name != "" {
return cmp.Or(evaluated, step.Name)
}
first, _, _ := strings.Cut(strings.TrimSpace(cmp.Or(evaluated, step.String())), "\n")
return "Run " + cmp.Or(strings.TrimSpace(first), step.String())
}
// addReportedStep inserts a step at pos of the reported steps. Caller holds stateMu.
func (r *Reporter) addReportedStep(pos int, stage runnerv1.StepStage, number int, name string) *runnerv1.StepState {
step := &runnerv1.StepState{Name: name, Stage: stage, Number: int64(number)}
r.state.ReportedSteps = slices.Insert(r.state.ReportedSteps, pos, step)
for i, v := range r.state.ReportedSteps[pos:] {
v.Id = int64(pos + i)
}
r.reportedSteps[reportedStepKey{stage, number}] = step
return step
}
// reportedStepOf returns the reported step an entry belongs to, adding a pre or post step the
// first time it runs, and whether the entry names that step itself. Caller holds stateMu.
func (r *Reporter) reportedStepOf(entry logging.Record) (*runnerv1.StepState, bool) {
if r.reportedSteps == nil {
return nil, false
}
stage, _ := recordAttr[string](entry, "stage")
number, ok := recordAttr[int](entry, "stepNumber")
if stage == runner.StageComplete {
return r.reportedSteps[reportedStepKey{stage: runnerv1.StepStage_STEP_STAGE_CLEANUP}], false
}
if !ok || number < 0 || number >= len(r.stepModels) {
return r.currentReportedStep, false
}
var key reportedStepKey
switch stage {
case runner.StagePre:
key = reportedStepKey{runnerv1.StepStage_STEP_STAGE_PRE, number}
case runner.StageMain:
key = reportedStepKey{runnerv1.StepStage_STEP_STAGE_MAIN, number}
case runner.StagePost:
key = reportedStepKey{runnerv1.StepStage_STEP_STAGE_POST, number}
default:
return r.currentReportedStep, false
}
main, ok := r.reportedSteps[reportedStepKey{runnerv1.StepStage_STEP_STAGE_MAIN, number}]
if !ok {
return r.currentReportedStep, false
}
if evaluated, _ := recordAttr[string](entry, "step"); evaluated != "" {
// evaluated as GitHub shows it, and masked as an expression can name a secret
main.Name = r.mask(stepDisplayName(r.stepModels[number], evaluated))
}
if step, ok := r.reportedSteps[key]; ok {
return step, true
}
if started, _ := recordAttr[bool](entry, runner.StepStartedField); !started {
return r.currentReportedStep, false // a pre or post step that does not run is not shown
}
mainName := main.Name
if stage == runner.StagePre {
// pre steps run in the order of the workflow steps, so each goes right before the main steps
pos := slices.IndexFunc(r.state.ReportedSteps, func(v *runnerv1.StepState) bool { return v.Stage == runnerv1.StepStage_STEP_STAGE_MAIN })
return r.addReportedStep(pos, key.stage, number, "Pre "+mainName), true
}
return r.addReportedStep(len(r.state.ReportedSteps)-1, key.stage, number, "Post "+mainName), true
}
// recordAttr returns the attribute key of rec if it is a T.
func recordAttr[T any](rec logging.Record, key string) (T, bool) {
v, _ := rec.Attr(key)
t, ok := v.(T)
return t, ok
}
// enterReportedStep makes step the one log rows without a step of their own belong to, finishing
// the setup step once another step starts. It reports whether the step started. Caller holds stateMu.
func (r *Reporter) enterReportedStep(step *runnerv1.StepState, timestamp time.Time) bool {
if prev := r.currentReportedStep; step != prev && prev.Stage == runnerv1.StepStage_STEP_STAGE_SETUP && prev.StoppedAt == nil {
if prev.Result == runnerv1.Result_RESULT_UNSPECIFIED {
prev.Result = runnerv1.Result_RESULT_SUCCESS
}
prev.StoppedAt = timestamppb.New(timestamp)
}
r.currentReportedStep = step
if step.StartedAt != nil {
return false
}
step.StartedAt = timestamppb.New(timestamp)
return true
}
// finishReportedSteps gives a result to the reported steps that have none when the job ends:
// the job's result to a step that was running, except the cleanup that ran in full. A running
// cleanup is left running unless closing, as it still logs after the job result. Caller holds stateMu.
func (r *Reporter) finishReportedSteps(jobResult runnerv1.Result, timestamp time.Time, closing bool) {
for _, v := range r.state.ReportedSteps {
if v.Result != runnerv1.Result_RESULT_UNSPECIFIED || !closing && v.StartedAt != nil && v.Stage == runnerv1.StepStage_STEP_STAGE_CLEANUP {
continue
}
switch {
case v.StartedAt == nil && jobResult == runnerv1.Result_RESULT_CANCELLED:
v.Result = runnerv1.Result_RESULT_CANCELLED
case v.StartedAt == nil:
v.Result = runnerv1.Result_RESULT_SKIPPED // never reached, such as the steps after a failed setup
case v.Stage == runnerv1.StepStage_STEP_STAGE_CLEANUP:
v.Result = runnerv1.Result_RESULT_SUCCESS
default:
v.Result = jobResult
}
if v.StartedAt != nil {
v.StoppedAt = timestamppb.New(timestamp)
}
}
}
// Enabled makes the Reporter see every record, the job logger's level filter runs before it.
@@ -200,6 +345,12 @@ func (r *Reporter) appendLogRow(row *runnerv1.LogRow) {
return
}
row.Content = r.mask(row.Content)
if step := r.currentReportedStep; step != nil {
if step.LogLength == 0 {
step.LogIndex = int64(r.logOffset + len(r.logRows))
}
step.LogLength++
}
r.logRows = append(r.logRows, row)
r.jobLog.write(row.Time.AsTime(), row.Content)
}
@@ -266,9 +417,26 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
r.state.StartedAt = timestamppb.New(timestamp)
}
if reported, own := r.reportedStepOf(entry); reported != nil {
if r.enterReportedStep(reported, timestamp) {
urgentState = true
}
if !own && entry.Level >= logging.LevelError && reported.Result == runnerv1.Result_RESULT_UNSPECIFIED {
reported.Result = runnerv1.Result_RESULT_FAILURE // a job level error, such as an action that cannot be downloaded
urgentState = true
}
if v, ok := entry.Attr("stepResult"); ok && own && isJobStepEntry(entry) {
if stepResult, ok := r.parseResult(v); ok {
reported.Result = stepResult
reported.StoppedAt = timestamppb.New(timestamp)
urgentState = true
}
}
}
stage, _ := entry.Attr("stage")
if stage != "Main" {
if stage != runner.StageMain {
if v, ok := entry.Attr("jobResult"); ok {
if jobResult, ok := r.parseResult(v); ok {
// We need to ensure log upload before this upload
@@ -282,6 +450,7 @@ func (r *Reporter) Handle(_ context.Context, entry logging.Record) error {
}
}
}
r.finishReportedSteps(jobResult, timestamp, false)
urgentState = true
}
}
@@ -549,6 +718,7 @@ func (r *Reporter) Close(lastWords string) error {
Content: lastWords,
})
}
r.finishReportedSteps(r.state.Result, time.Now(), true)
r.stateMu.Unlock()
// Wake up the daemon loop so it detects closed promptly.
+225 -17
View File
@@ -255,7 +255,7 @@ func TestReporter_Handle(t *testing.T) {
defer func() {
require.NoError(t, reporter.Close(""))
}()
reporter.ResetSteps(5)
reporter.ResetSteps(newTestSteps(5))
dataStep0 := map[string]any{
"stage": "Main",
@@ -314,7 +314,7 @@ func TestReporter_LogLevelFiltering(t *testing.T) {
defer func() {
require.NoError(t, reporter.Close(""))
}()
reporter.ResetSteps(2)
reporter.ResetSteps(newTestSteps(2))
dataStep0 := map[string]any{"stage": "Main", "stepNumber": 0, "raw_output": true}
dataStep0Internal := map[string]any{"stage": "Main", "stepNumber": 0}
@@ -380,7 +380,7 @@ func TestReporter_EphemeralRunnerDeletion(t *testing.T) {
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
// Fire a log entry to create pending data
require.NoError(t, fire(reporter, logEntry{
@@ -446,7 +446,7 @@ func TestReporter_RunDaemonClose_Race(t *testing.T) {
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{
Context: taskCtx,
}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
// Start the daemon loop — RunDaemon spawns a goroutine internally.
reporter.RunDaemon()
@@ -493,7 +493,7 @@ func TestReporter_MaxLatencyTimer(t *testing.T) {
cfg.Runner.LogReportBatchSize = 1000 // won't trigger batch flush
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
reporter.RunDaemon()
defer func() {
_ = reporter.Close("")
@@ -544,7 +544,7 @@ func TestReporter_BatchSizeFlush(t *testing.T) {
cfg.Runner.LogReportBatchSize = 5
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
reporter.RunDaemon()
defer func() {
_ = reporter.Close("")
@@ -591,7 +591,7 @@ func TestReporter_StateChangedNotLostDuringReport(t *testing.T) {
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(2)
reporter.ResetSteps(newTestSteps(2))
// Mark stateChanged=true so the first ReportState proceeds to UpdateTask.
reporter.stateMu.Lock()
@@ -652,7 +652,7 @@ func TestReporter_StateChangedRestoredOnError(t *testing.T) {
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
reporter.stateMu.Lock()
reporter.stateChanged = true
@@ -702,7 +702,7 @@ func TestReporter_StateNotifyFlush(t *testing.T) {
cfg.Runner.LogReportInterval = 10 * time.Second
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
reporter.RunDaemon()
defer func() {
_ = reporter.Close("")
@@ -761,7 +761,7 @@ func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
cfg.Runner.LogReportBatchSize = 1000
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
reporter.RunDaemon()
// Simulate a successful job whose log buffer was already drained by the
@@ -806,7 +806,7 @@ func TestReporter_StateHeartbeat(t *testing.T) {
cfg, _ := config.LoadDefault("")
cfg.Runner.StateReportInterval = 50 * time.Millisecond
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
// First call has no prior report — sends to seed lastReportedAt.
reporter.stateMu.Lock()
@@ -877,7 +877,7 @@ func TestReporter_ServerCancelStillFlushesFinal(t *testing.T) {
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
// Force the first ReportState to actually call UpdateTask.
reporter.stateMu.Lock()
@@ -945,7 +945,7 @@ func TestReporter_CloseReportsCancelledOnCanceledCtx(t *testing.T) {
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
// Simulate the cancellation path: r.ctx is cancelled before Close() runs.
cancel()
@@ -1010,7 +1010,7 @@ func TestReporter_StopHeartbeats(t *testing.T) {
cfg.Runner.LogReportInterval = time.Hour
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
reporter.RunDaemon()
reporter.stateMu.Lock()
@@ -1216,7 +1216,7 @@ func TestReporter_AckIndexBeyondBuffer(t *testing.T) {
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
require.NoError(t, fire(reporter, logEntry{
Message: "hello",
@@ -1256,7 +1256,7 @@ func TestReporter_CloseWithStuckDaemon(t *testing.T) {
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.daemonWait = time.Millisecond
reporter.RunDaemon()
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
require.NoError(t, fire(reporter, logEntry{
Message: "hello",
@@ -1434,7 +1434,7 @@ func TestReporter_StepRangeCoversAckedRows(t *testing.T) {
cfg.Runner.LogReportBatchSize = 5
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx}, cfg)
reporter.ResetSteps(1)
reporter.ResetSteps(newTestSteps(1))
reporter.RunDaemon()
defer func() {
_ = reporter.Close("")
@@ -1489,6 +1489,214 @@ func TestReporter_FlushWithoutAckedRowsSkipsState(t *testing.T) {
reporter.reportLogWithState()
}
func newTestSteps(n int) []*model.Step {
steps := make([]*model.Step, n)
for i := range steps {
steps[i] = &model.Step{Run: fmt.Sprintf("echo %d", i)}
}
return steps
}
func TestReporter_ReportedSteps(t *testing.T) {
newReporter := func(t *testing.T, steps []*model.Step) (*Reporter, context.CancelFunc) {
client := mocks.NewClient(t)
client.On("UpdateLog", mock.Anything, mock.Anything).Maybe().Return(func(_ context.Context, req *connect_go.Request[runnerv1.UpdateLogRequest]) (*connect_go.Response[runnerv1.UpdateLogResponse], error) {
return connect_go.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil
})
client.On("UpdateTask", mock.Anything, mock.Anything).Maybe().Return(connect_go.NewResponse(&runnerv1.UpdateTaskResponse{}), nil)
taskCtx, err := structpb.NewStruct(map[string]any{})
require.NoError(t, err)
cfg, _ := config.LoadDefault("")
ctx, cancel := context.WithCancel(t.Context())
reporter := NewReporter(ctx, cancel, client, &runnerv1.Task{Context: taskCtx, Secrets: map[string]string{"TOKEN": "s3cr3t"}}, cfg)
reporter.RunDaemon()
reporter.Logf("runner version")
reporter.ResetSteps(steps)
return reporter, cancel
}
send := func(t *testing.T, reporter *Reporter, level logging.Level, msg string, data map[string]any) {
t.Helper()
require.NoError(t, fire(reporter, logEntry{Message: msg, Data: data, Level: level}))
}
type reported struct {
stage runnerv1.StepStage
number int64
name string
result runnerv1.Result
logIndex, logLen int64
}
reportedOf := func(t *testing.T, reporter *Reporter) []reported {
reporter.stateMu.RLock()
defer reporter.stateMu.RUnlock()
var ret []reported
for i, v := range reporter.state.ReportedSteps {
assert.EqualValues(t, i, v.Id)
ret = append(ret, reported{v.Stage, v.Number, v.Name, v.Result, v.LogIndex, v.LogLength})
}
return ret
}
const (
setup = runnerv1.StepStage_STEP_STAGE_SETUP
pre = runnerv1.StepStage_STEP_STAGE_PRE
main = runnerv1.StepStage_STEP_STAGE_MAIN
post = runnerv1.StepStage_STEP_STAGE_POST
cleanup = runnerv1.StepStage_STEP_STAGE_CLEANUP
unspecified = runnerv1.Result_RESULT_UNSPECIFIED
success = runnerv1.Result_RESULT_SUCCESS
failure = runnerv1.Result_RESULT_FAILURE
cancelled = runnerv1.Result_RESULT_CANCELLED
skipped = runnerv1.Result_RESULT_SKIPPED
)
t.Run("steps as run", func(t *testing.T) {
steps := newTestSteps(2)
steps[0].Name = "Echo ${{ matrix.n }}"
reporter, _ := newReporter(t, steps)
reporter.Logf("workflow prepared")
send(t, reporter, logging.LevelInfo, "Complete job name: test", map[string]any{"raw_output": true})
send(t, reporter, logging.LevelDebug, "evaluating expression 'success()'", map[string]any{"stage": runner.StagePre, "stepNumber": 0})
send(t, reporter, logging.LevelInfo, "Run Pre echo 0", map[string]any{"stage": runner.StagePre, "stepNumber": 0, "step": "Echo 1", runner.StepStartedField: true})
send(t, reporter, logging.LevelInfo, "pre output", map[string]any{"stage": runner.StagePre, "stepNumber": 0, "raw_output": true})
send(t, reporter, logging.LevelInfo, "Success - Pre echo 0", map[string]any{"stage": runner.StagePre, "stepNumber": 0, "stepResult": model.StepStatusSuccess})
send(t, reporter, logging.LevelDebug, "evaluating expression 'false'", map[string]any{"stage": runner.StagePre, "stepNumber": 1})
send(t, reporter, logging.LevelDebug, "Skipping step", map[string]any{"stage": runner.StagePre, "stepNumber": 1, "stepResult": model.StepStatusSkipped})
send(t, reporter, logging.LevelInfo, "main output", map[string]any{"stage": runner.StageMain, "stepNumber": 0, "raw_output": true})
send(t, reporter, logging.LevelInfo, "main output", map[string]any{"stage": runner.StageMain, "stepNumber": 0, "raw_output": true})
send(t, reporter, logging.LevelInfo, "Success - Main echo 0", map[string]any{"stage": runner.StageMain, "stepNumber": 0, "stepResult": model.StepStatusSuccess})
send(t, reporter, logging.LevelDebug, "Skipping step", map[string]any{"stage": runner.StageMain, "stepNumber": 1, "stepResult": model.StepStatusSkipped})
send(t, reporter, logging.LevelInfo, "Run Post echo 0", map[string]any{"stage": runner.StagePost, "stepNumber": 0, runner.StepStartedField: true})
send(t, reporter, logging.LevelInfo, "post output", map[string]any{"stage": runner.StagePost, "stepNumber": 0, "raw_output": true})
send(t, reporter, logging.LevelInfo, "Success - Post echo 0", map[string]any{"stage": runner.StagePost, "stepNumber": 0, "stepResult": model.StepStatusSuccess})
send(t, reporter, logging.LevelInfo, "Cleaning up container", map[string]any{"stage": runner.StageComplete})
send(t, reporter, logging.LevelInfo, "Job succeeded", map[string]any{"stage": runner.StageComplete, "jobResult": model.StepStatusSuccess})
want := []reported{
{setup, 0, "Set up job", success, 0, 3},
{pre, 0, "Pre Echo 1", success, 3, 3},
{main, 0, "Echo 1", success, 6, 2},
{main, 1, "Run echo 1", skipped, 0, 0},
{post, 0, "Post Echo 1", success, 8, 3},
{cleanup, 0, "Complete job", unspecified, 11, 2}, // still logging after the job result
}
assert.Equal(t, want, reportedOf(t, reporter))
send(t, reporter, logging.LevelInfo, "Removing images", map[string]any{"stage": runner.StageComplete})
require.NoError(t, reporter.Close(""))
want[5].result, want[5].logLen = success, 3
assert.Equal(t, want, reportedOf(t, reporter))
// the workflow steps keep what Gitea versions deriving them from the workflow file expect
require.Len(t, reporter.state.Steps, 2)
assert.EqualValues(t, 6, reporter.state.Steps[0].LogIndex)
assert.EqualValues(t, 2, reporter.state.Steps[0].LogLength)
assert.Equal(t, success, reporter.state.Steps[0].Result)
assert.Equal(t, skipped, reporter.state.Steps[1].Result)
})
t.Run("failures and cancellation", func(t *testing.T) {
reporter, cancel := newReporter(t, newTestSteps(3))
started := func(stage string, n int) map[string]any {
return map[string]any{"stage": stage, "stepNumber": n, runner.StepStartedField: true}
}
result := func(stage string, n int, result model.StepStatus, stepID ...string) map[string]any {
fields := map[string]any{"stage": stage, "stepNumber": n, "stepResult": result}
if len(stepID) > 0 {
fields["stepID"] = stepID
}
return fields
}
send(t, reporter, logging.LevelInfo, "Run Pre echo 0", started(runner.StagePre, 0))
send(t, reporter, logging.LevelInfo, "Success - Pre echo 0", result(runner.StagePre, 0, model.StepStatusSuccess))
send(t, reporter, logging.LevelDebug, "Skipping step", result(runner.StagePre, 1, model.StepStatusSkipped))
send(t, reporter, logging.LevelInfo, "Run Pre echo 2", started(runner.StagePre, 2))
send(t, reporter, logging.LevelInfo, "Success - Pre echo 2", result(runner.StagePre, 2, model.StepStatusSuccess))
send(t, reporter, logging.LevelInfo, "composite step", result(runner.StageMain, 0, model.StepStatusSuccess, "0", "inner"))
send(t, reporter, logging.LevelInfo, "Failure - Main echo 0", result(runner.StageMain, 0, model.StepStatusFailure))
send(t, reporter, logging.LevelInfo, "main output", map[string]any{"stage": runner.StageMain, "stepNumber": 1, "raw_output": true})
send(t, reporter, logging.LevelInfo, "Run Post echo 0", started(runner.StagePost, 0))
send(t, reporter, logging.LevelInfo, "Failure - Post echo 0", result(runner.StagePost, 0, model.StepStatusFailure))
cancel()
require.NoError(t, reporter.Close(""))
results := make(map[string]runnerv1.Result)
for _, v := range reportedOf(t, reporter) {
results[v.name] = v.result
}
assert.Equal(t, map[string]runnerv1.Result{
"Set up job": success,
"Pre Run echo 0": success,
"Pre Run echo 2": success,
"Run echo 0": failure, // not the result of its composite step
"Run echo 1": cancelled,
"Run echo 2": cancelled,
"Post Run echo 0": failure,
"Complete job": cancelled,
}, results)
names := make([]string, 0, len(results))
for _, v := range reportedOf(t, reporter) {
names = append(names, v.name)
}
assert.Equal(t, []string{"Set up job", "Pre Run echo 0", "Pre Run echo 2", "Run echo 0", "Run echo 1", "Run echo 2", "Post Run echo 0", "Complete job"}, names)
})
t.Run("job failing during setup", func(t *testing.T) {
reporter, _ := newReporter(t, newTestSteps(1))
reporter.Logf("workflow prepared")
send(t, reporter, logging.LevelInfo, "Job failed", map[string]any{"jobResult": model.StepStatusFailure})
require.NoError(t, reporter.Close(""))
assert.Equal(t, []reported{
{setup, 0, "Set up job", failure, 0, 3},
{main, 0, "Run echo 0", skipped, 0, 0},
{cleanup, 0, "Complete job", skipped, 0, 0},
}, reportedOf(t, reporter))
})
t.Run("action failing to download", func(t *testing.T) {
reporter, _ := newReporter(t, newTestSteps(1))
send(t, reporter, logging.LevelInfo, "Prepare all required actions", map[string]any{"raw_output": true})
send(t, reporter, logging.LevelError, "##[error]unable to clone 'invalid-owner/invalid-action'", nil)
send(t, reporter, logging.LevelInfo, "Cleaning up container", map[string]any{"stage": runner.StageComplete})
send(t, reporter, logging.LevelInfo, "Job failed", map[string]any{"stage": runner.StageComplete, "jobResult": model.StepStatusFailure})
require.NoError(t, reporter.Close(""))
got := reportedOf(t, reporter)
assert.Equal(t, []runnerv1.Result{failure, skipped, success}, []runnerv1.Result{got[0].result, got[1].result, got[2].result})
})
t.Run("step not reached in a succeeding job", func(t *testing.T) {
reporter, _ := newReporter(t, newTestSteps(1))
send(t, reporter, logging.LevelInfo, "Job succeeded", map[string]any{"stage": runner.StageComplete, "jobResult": model.StepStatusSuccess})
require.NoError(t, reporter.Close(""))
assert.Equal(t, skipped, reportedOf(t, reporter)[1].result)
})
t.Run("evaluated and masked names", func(t *testing.T) {
steps := newTestSteps(2)
steps[0].Run = "echo ${{ matrix.os }}\necho more"
steps[1].Name = "Deploy with ${{ secrets.TOKEN }}"
reporter, _ := newReporter(t, steps)
send(t, reporter, logging.LevelDebug, "Run Main", map[string]any{"stage": runner.StageMain, "stepNumber": 0, "step": "echo linux\necho more"})
send(t, reporter, logging.LevelDebug, "Run Main", map[string]any{"stage": runner.StageMain, "stepNumber": 1, "step": "Deploy with s3cr3t"})
require.NoError(t, reporter.Close(""))
names := []string{}
for _, v := range reportedOf(t, reporter) {
names = append(names, v.name)
}
assert.Equal(t, []string{"Set up job", "Run echo linux", "Deploy with ***", "Complete job"}, names)
})
t.Run("reusable workflow caller", func(t *testing.T) {
reporter, _ := newReporter(t, nil)
send(t, reporter, logging.LevelInfo, "Cleaning up container", map[string]any{"stage": runner.StageComplete})
send(t, reporter, logging.LevelInfo, "Job succeeded", map[string]any{"stage": runner.StageComplete, "jobResult": model.StepStatusSuccess})
require.NoError(t, reporter.Close(""))
assert.Empty(t, reportedOf(t, reporter))
})
}
// logEntry is the record a job logger hands the Reporter, with the fields it accumulated as Data.
type logEntry struct {
Message string