feat(actions): implement jobs.<job_id>.continue-on-error (#38100)

Support `continue-on-error` for workflow jobs when aggregating an
Actions workflow run status.

Previously, `continue-on-error` was parsed from workflow YAML but was
not persisted or used when calculating the overall run result. As a
result, a failed job could incorrectly fail the entire workflow even
when the workflow explicitly allowed that job to fail.

This PR stores the parsed `continue-on-error` value on each action run
job and treats failed jobs with `continue-on-error: true` as successful
when computing the workflow run status, matching GitHub Actions
behavior.

## Changes

- Add `ContinueOnError` to `jobparser.Job`.
- Add `continue_on_error` to `ActionRunJob` with a `NOT NULL DEFAULT
FALSE` migration.
- Populate `ActionRunJob.ContinueOnError` when creating workflow run
jobs.
- Update workflow status aggregation so failed `continue-on-error` jobs
do not fail the overall run.
- Leave `resolveCheckNeeds` unchanged so dependent jobs still see the
job result as `failure` and are skipped by default.

## Compatibility

This is backward compatible.

If only the runner or only the server is updated, `continue-on-error`
continues to degrade to the previous behavior and is effectively ignored
until both sides support it.

Related runner PR: https://gitea.com/gitea/runner/pulls/1032

---------

Signed-off-by: bircni <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
bircni
2026-06-22 06:51:16 +02:00
committed by GitHub
parent 2c2611eab9
commit 7684221ed4
16 changed files with 268 additions and 36 deletions
+5
View File
@@ -380,6 +380,11 @@ func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed boo
if !needStatus.IsDone() {
allDone = false
}
// A failed need with continue-on-error:true is treated as success, matching AggregateJobStatus,
// so a downstream job with an implicit `success()` is not skipped.
if needJob := r.jobMap[need]; needJob != nil && needJob.ContinueOnError && needStatus == actions_model.StatusFailure {
continue
}
if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped) {
allSucceed = false
}
+18
View File
@@ -131,6 +131,24 @@ jobs:
},
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
},
{
name: "`if` is empty and a failed need has continue-on-error",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, ContinueOnError: true, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
runs-on: ubuntu-latest
needs: job1
steps:
- run: echo "should run, job1 failure is masked by continue-on-error"
`)},
},
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
},
}
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
+1
View File
@@ -506,6 +506,7 @@ func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *act
AttemptJobID: templateJob.AttemptJobID,
Needs: slices.Clone(templateJob.Needs),
RunsOn: slices.Clone(templateJob.RunsOn),
ContinueOnError: templateJob.ContinueOnError,
Status: templateJob.Status,
RawConcurrency: templateJob.RawConcurrency,
IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated,
+16
View File
@@ -80,6 +80,22 @@ func TestGetFailedJobsForRerun(t *testing.T) {
})
}
func TestCloneRunJobForAttempt(t *testing.T) {
attempt := &actions_model.ActionRunAttempt{ID: 42, Attempt: 2}
t.Run("preserves continue-on-error", func(t *testing.T) {
template := &actions_model.ActionRunJob{ContinueOnError: true, Status: actions_model.StatusFailure}
clone := cloneRunJobForAttempt(template, attempt)
assert.True(t, clone.ContinueOnError)
})
t.Run("defaults to false when template has it unset", func(t *testing.T) {
template := &actions_model.ActionRunJob{ContinueOnError: false}
clone := cloneRunJobForAttempt(template, attempt)
assert.False(t, clone.ContinueOnError)
})
}
func TestRerunValidation(t *testing.T) {
runningRun := &actions_model.ActionRun{Status: actions_model.StatusRunning}
+1
View File
@@ -325,6 +325,7 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
AttemptJobID: attemptJobID,
Needs: needs,
RunsOn: parsedChild.RunsOn(),
ContinueOnError: parsedChild.GetContinueOnError(),
Status: actions_model.StatusBlocked,
ParentJobID: caller.ID,
WorkflowSourceRepoID: sourceRepoID,
+1
View File
@@ -164,6 +164,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
WorkflowSourceRepoID: run.RepoID,
WorkflowSourceCommitSHA: run.CommitSHA,
ContinueOnError: job.GetContinueOnError(),
}
// Parse workflow/job permissions (no clamping here)
if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {