fix(actions): explain why a blocked or waiting job has not started (#38476)

When an Actions job is blocked or waiting, the job view only shows the
generic **Blocked** / **Waiting** label. Users have no way to tell *why*
a job is stuck — whether it's waiting on dependencies, waiting for a
runner that doesn't exist, waiting for a runner whose labels don't
match, or simply queued behind busy runners.

## Change

The current-job detail line now surfaces the actual cause:

- **Blocked** → lists the dependency jobs (`needs`) that haven't
finished yet, e.g. *"Waiting for the following jobs to complete:
build."*
- **Waiting**, no online runner → *"No runner is online to pick up this
job."*
- **Waiting**, online runners but none match `runs-on` → *"No matching
online runner with label: X"* (reuses the existing string)
- **Waiting**, a matching runner exists but hasn't claimed the job →
*"Waiting for a matching runner to become available."*

The runner lookup reuses the same available-online-runner query the run
list already performs, and only runs while a selected job is actually
pending. Dependency resolution is scoped to the same parent job and
treats matrix expansions of a `need` as pending until all of them
complete.
This commit is contained in:
bircni
2026-07-16 23:10:43 +02:00
committed by GitHub
parent 2e0aa7ec74
commit 9d3f04eb7e
3 changed files with 123 additions and 0 deletions

View File

@@ -3783,6 +3783,9 @@
"actions.runs.pushed_by": "pushed by",
"actions.runs.invalid_workflow_helper": "Workflow config file is invalid. Please check your config file: %s",
"actions.runs.no_matching_online_runner_helper": "No matching online runner with label: %s",
"actions.runs.no_runner_online": "No runner is online to pick up this job.",
"actions.runs.waiting_for_available_runner": "Waiting for a matching runner to become available.",
"actions.runs.waiting_for_dependent_jobs": "Waiting for the following jobs to complete: %s",
"actions.runs.no_job_without_needs": "The workflow must contain at least one job without dependencies.",
"actions.runs.no_job": "The workflow must contain at least one job",
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",

View File

@@ -32,6 +32,7 @@ import (
"gitea.dev/modules/httplib"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/storage"
api "gitea.dev/modules/structs"
"gitea.dev/modules/templates"
@@ -753,6 +754,8 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
resp.State.CurrentJob.Detail = current.Status.LocaleString(ctx.Locale)
if run.NeedApproval {
resp.State.CurrentJob.Detail = ctx.Locale.TrString("actions.need_approval_desc")
} else if detail := describePendingJobDetail(ctx, current, jobs); detail != "" {
resp.State.CurrentJob.Detail = detail
}
resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json
resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json
@@ -767,6 +770,78 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon
}
}
// describePendingJobDetail explains why a blocked or waiting job has not started
// yet, so the user can tell whether it is waiting on its dependencies or on an
// available runner. It returns an empty string when the job is not pending or the
// cause can't be determined (the caller keeps the generic status label then).
func describePendingJobDetail(ctx *context_module.Context, current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) string {
switch {
case current.Status.IsBlocked():
// A blocked job is held back by the jobs listed in its `needs`.
if pending := pendingNeeds(current, jobs); len(pending) > 0 {
return ctx.Locale.TrString("actions.runs.waiting_for_dependent_jobs", strings.Join(pending, ", "))
}
case current.Status.IsWaiting():
// A waiting job has no runner to pick it up yet. A busy runner is still
// "online", so distinguish three cases: no runner online at all, online
// runners but none match the labels, and a matching runner that is busy.
runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{
RepoID: current.RepoID,
IsOnline: optional.Some(true),
WithAvailable: true,
})
if err != nil {
log.Error("FindRunners for job %d: %v", current.ID, err)
return ""
}
hasOnlineRunner, hasMatchingRunner := false, false
for _, runner := range runners {
if runner.IsDisabled {
continue
}
hasOnlineRunner = true
if runner.CanMatchLabels(current.RunsOn) {
hasMatchingRunner = true
break
}
}
switch {
case !hasOnlineRunner:
return ctx.Locale.TrString("actions.runs.no_runner_online")
case !hasMatchingRunner:
return ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(current.RunsOn, ", "))
default:
// A matching runner exists but hasn't claimed the job, so it is busy.
return ctx.Locale.TrString("actions.runs.waiting_for_available_runner")
}
}
return ""
}
// pendingNeeds returns the `needs` keys of jobs the given job depends on that
// have not finished yet, scoped to the same parent job (matrix expansions of a
// need are all required to be done). Unresolved needs are treated as pending.
func pendingNeeds(current *actions_model.ActionRunJob, jobs []*actions_model.ActionRunJob) []string {
var pending []string
for _, need := range current.Needs {
found, allDone := false, true
for _, job := range jobs {
if job.ParentJobID != current.ParentJobID || job.JobID != need {
continue
}
found = true
if !job.Status.IsDone() {
allDone = false
break
}
}
if !found || !allDone {
pending = append(pending, need)
}
}
return pending
}
func convertToViewModel(ctx context.Context, locale translation.Locale, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) {
var viewJobs []*ViewJobStep
var logs []*ViewStepLog

View File

@@ -138,3 +138,48 @@ func TestConvertToViewModelCancellingTaskDoesNotRenderRunningSteps(t *testing.T)
}
assert.Equal(t, expectedViewJobs, viewJobSteps)
}
func TestPendingNeeds(t *testing.T) {
current := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build", "test"}}
jobs := []*actions_model.ActionRunJob{
current,
{JobID: "build", Status: actions_model.StatusSuccess},
{JobID: "test", Status: actions_model.StatusRunning},
}
// "test" is not done yet, "build" succeeded, so only "test" blocks.
assert.Equal(t, []string{"test"}, pendingNeeds(current, jobs))
t.Run("all needs done", func(t *testing.T) {
done := []*actions_model.ActionRunJob{
current,
{JobID: "build", Status: actions_model.StatusSuccess},
{JobID: "test", Status: actions_model.StatusSkipped},
}
assert.Empty(t, pendingNeeds(current, done))
})
t.Run("matrix expansion all required", func(t *testing.T) {
matrix := []*actions_model.ActionRunJob{
current,
{JobID: "build", Status: actions_model.StatusSuccess},
{JobID: "build", Status: actions_model.StatusRunning},
{JobID: "test", Status: actions_model.StatusSuccess},
}
assert.Equal(t, []string{"build"}, pendingNeeds(current, matrix))
})
t.Run("unresolved need treated as pending", func(t *testing.T) {
missing := []*actions_model.ActionRunJob{current}
assert.Equal(t, []string{"build", "test"}, pendingNeeds(current, missing))
})
t.Run("parent job scope", func(t *testing.T) {
// a same-named job under a different parent must not satisfy the need
scoped := &actions_model.ActionRunJob{JobID: "deploy", Needs: []string{"build"}, ParentJobID: 5}
jobs := []*actions_model.ActionRunJob{
scoped,
{JobID: "build", Status: actions_model.StatusSuccess, ParentJobID: 0},
}
assert.Equal(t, []string{"build"}, pendingNeeds(scoped, jobs))
})
}