Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion services/actions/job_emitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
}
var resolver *jobStatusResolver
expandedAnyCaller := false
callerCascaded := false
if err = db.WithTx(ctx, func(ctx context.Context) error {
for _, job := range jobs {
job.Run = run
Expand Down Expand Up @@ -355,6 +356,13 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
return fmt.Errorf("no affected for updating blocked job %v", job.ID)
}
result.UpdatedJobs = append(result.UpdatedJobs, job)
// UpdateRunJob cascades RefreshReusableCallerStatus into the DB when this job
// is a child of a reusable-workflow caller, but the in-memory resolver statuses
// still hold the pre-cascade caller. Jobs that `needs:` the caller (outside the
// called workflow) would stay Pending forever without another pass (#39587).
if job.ParentJobID > 0 {
callerCascaded = true
}
}
return nil
}); err != nil {
Expand All @@ -364,7 +372,7 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
result.UpdatedJobs = append(result.UpdatedJobs, resolver.matrixUpdatedJobs...)
// Caller and matrix expansion insert Pending or Blocked jobs and a deferred gate leaves a job Blocked, only a follow-up pass resolves them.
// Like the caller's children, matrix siblings are left out of result.Jobs and picked up there.
if expandedAnyCaller || resolver.matrixChanged || resolver.gateDeferred {
if expandedAnyCaller || resolver.matrixChanged || resolver.gateDeferred || callerCascaded {
result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID)
}
result.CancelledJobs = append(result.CancelledJobs, resolver.cancelledJobs...)
Expand Down
77 changes: 77 additions & 0 deletions services/actions/job_emitter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,83 @@
assert.Equal(t, actions_model.StatusBlocked, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
}

// Test_checkJobsOfCurrentRunAttempt_CallerCascadeReEmitsForDependants is the #39587
// regression: children of a reusable caller finishing as Skipped cascade Success onto
// the caller in the DB, but the in-memory resolver still sees the pre-cascade caller.
// Without a re-emit, a sibling job that `needs:` the caller stays Pending forever.
func Test_checkJobsOfCurrentRunAttempt_CallerCascadeReEmitsForDependants(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()

run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
WorkflowID: "stall.yml", Index: 9921, Ref: "refs/heads/main",
Status: actions_model.StatusRunning,
}
assert.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: run.ID, Attempt: 1, Status: actions_model.StatusRunning,
}
assert.NoError(t, db.Insert(ctx, attempt))
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", attempt.ID, run.ID)
assert.NoError(t, err)
run.LatestAttemptID = attempt.ID

caller := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1,
RepoID: 4, OwnerID: 1, JobID: "agent", Name: "agent",
Status: actions_model.StatusWaiting, IsReusableCaller: true, IsExpanded: true,
WorkflowPayload: []byte("jobs: {agent: {uses: ./.gitea/workflows/called.yml}}"),
}
assert.NoError(t, db.Insert(ctx, caller))

prepare := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 2,
RepoID: 4, OwnerID: 1, JobID: "prepare", Name: "prepare",
ParentJobID: caller.ID, Status: actions_model.StatusSuccess,
WorkflowPayload: minimalWorkflowPayload("prepare"),
}
assert.NoError(t, db.Insert(ctx, prepare))

sign := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 3,
RepoID: 4, OwnerID: 1, JobID: "sign", Name: "sign",
ParentJobID: caller.ID, Status: actions_model.StatusBlocked, Needs: []string{"prepare"},
WorkflowPayload: []byte(`name: called
on: workflow_call
jobs:
sign:
if: false
runs-on: ubuntu-latest
steps:
- run: echo
`),
}
assert.NoError(t, db.Insert(ctx, sign))

after := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 4,
RepoID: 4, OwnerID: 1, JobID: "after", Name: "after",
Status: actions_model.StatusPending, Needs: []string{"agent"},
WorkflowPayload: minimalWorkflowPayload("after"),
}
assert.NoError(t, db.Insert(ctx, after))

result, err := checkJobsOfCurrentRunAttempt(ctx, run)
assert.NoError(t, err)
assert.Equal(t, actions_model.StatusSkipped, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: sign.ID}).Status)
assert.Equal(t, actions_model.StatusSuccess, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: caller.ID}).Status)
assert.Equal(t, []int64{run.ID}, result.RunIDsToReEmit, "must re-emit so dependants of the cascaded caller are resolved")

// Second pass must see the cascaded caller as done so `after` leaves Pending.
// (Promotion all the way to Waiting depends on concurrency/runner fixtures; Blocked is enough to prove needs resolved.)
result, err = checkJobsOfCurrentRunAttempt(ctx, run)

Check failure on line 714 in services/actions/job_emitter_test.go

View workflow job for this annotation

GitHub Actions / lint-backend

assigned to result, but never used afterwards (wastedassign)

Check failure on line 714 in services/actions/job_emitter_test.go

View workflow job for this annotation

GitHub Actions / lint-backend

assigned to result, but never used afterwards (wastedassign)
assert.NoError(t, err)
afterStatus := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: after.ID}).Status
assert.NotEqual(t, actions_model.StatusPending, afterStatus, "after must leave Pending once the caller cascaded to Success")
assert.True(t, afterStatus.In(actions_model.StatusBlocked, actions_model.StatusWaiting), "after status=%s", afterStatus)
}

func Test_checkJobsOfCurrentRunAttempt_SkippedCallerIsUpdated(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
Expand Down
Loading