mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-23 04:40:40 +00:00
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:
@@ -108,6 +108,10 @@ type ActionRunJob struct {
|
|||||||
// ParentJobID scopes `Needs` resolution: name lookups happen only among rows sharing the same ParentJobID. 0 for top-level rows.
|
// ParentJobID scopes `Needs` resolution: name lookups happen only among rows sharing the same ParentJobID. 0 for top-level rows.
|
||||||
ParentJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
ParentJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||||
|
|
||||||
|
// ContinueOnError mirrors the job-level continue-on-error field from the workflow YAML.
|
||||||
|
// When true, a failure of this job does not fail the overall workflow run.
|
||||||
|
ContinueOnError bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||||
|
|
||||||
Started timeutil.TimeStamp
|
Started timeutil.TimeStamp
|
||||||
Stopped timeutil.TimeStamp
|
Stopped timeutil.TimeStamp
|
||||||
Created timeutil.TimeStamp `xorm:"created"`
|
Created timeutil.TimeStamp `xorm:"created"`
|
||||||
@@ -500,9 +504,12 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
|
|||||||
allSkipped := len(jobs) != 0
|
allSkipped := len(jobs) != 0
|
||||||
var hasFailure, hasCancelled, hasCancelling, hasWaiting, hasRunning, hasBlocked bool
|
var hasFailure, hasCancelled, hasCancelling, hasWaiting, hasRunning, hasBlocked bool
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
allSuccessOrSkipped = allSuccessOrSkipped && (job.Status == StatusSuccess || job.Status == StatusSkipped)
|
// A failed job with continue-on-error:true does not fail the workflow run.
|
||||||
|
// It counts as a "continued failure" and is treated like success for aggregation.
|
||||||
|
isContinuedFailure := job.ContinueOnError && job.Status == StatusFailure
|
||||||
|
allSuccessOrSkipped = allSuccessOrSkipped && (job.Status == StatusSuccess || job.Status == StatusSkipped || isContinuedFailure)
|
||||||
allSkipped = allSkipped && job.Status == StatusSkipped
|
allSkipped = allSkipped && job.Status == StatusSkipped
|
||||||
hasFailure = hasFailure || job.Status == StatusFailure
|
hasFailure = hasFailure || (job.Status == StatusFailure && !job.ContinueOnError)
|
||||||
hasCancelled = hasCancelled || job.Status == StatusCancelled
|
hasCancelled = hasCancelled || job.Status == StatusCancelled
|
||||||
hasCancelling = hasCancelling || job.Status == StatusCancelling
|
hasCancelling = hasCancelling || job.Status == StatusCancelling
|
||||||
hasWaiting = hasWaiting || job.Status == StatusWaiting
|
hasWaiting = hasWaiting || job.Status == StatusWaiting
|
||||||
|
|||||||
@@ -48,3 +48,57 @@ func TestStatusFromResult(t *testing.T) {
|
|||||||
assert.Equal(t, tt.want, StatusFromResult(tt.result), "result=%s", tt.result)
|
assert.Equal(t, tt.want, StatusFromResult(tt.result), "result=%s", tt.result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newJob(status Status, continueOnError bool) *ActionRunJob {
|
||||||
|
return &ActionRunJob{Status: status, ContinueOnError: continueOnError}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateJobStatusContinueOnError(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
jobs []*ActionRunJob
|
||||||
|
want Status
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all success",
|
||||||
|
jobs: []*ActionRunJob{newJob(StatusSuccess, false), newJob(StatusSuccess, false)},
|
||||||
|
want: StatusSuccess,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one failure without continue-on-error",
|
||||||
|
jobs: []*ActionRunJob{newJob(StatusSuccess, false), newJob(StatusFailure, false)},
|
||||||
|
want: StatusFailure,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one failure with continue-on-error",
|
||||||
|
jobs: []*ActionRunJob{newJob(StatusSuccess, false), newJob(StatusFailure, true)},
|
||||||
|
want: StatusSuccess,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only continued-failure",
|
||||||
|
jobs: []*ActionRunJob{newJob(StatusFailure, true)},
|
||||||
|
want: StatusSuccess,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "continued-failure plus real failure",
|
||||||
|
jobs: []*ActionRunJob{newJob(StatusFailure, true), newJob(StatusFailure, false)},
|
||||||
|
want: StatusFailure,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all skipped",
|
||||||
|
jobs: []*ActionRunJob{newJob(StatusSkipped, false), newJob(StatusSkipped, false)},
|
||||||
|
want: StatusSkipped,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "continued-failure plus skipped counts as success",
|
||||||
|
jobs: []*ActionRunJob{newJob(StatusFailure, true), newJob(StatusSkipped, false)},
|
||||||
|
want: StatusSuccess,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, AggregateJobStatus(tt.jobs))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam),
|
newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam),
|
||||||
newMigration(338, "Expand legacy MSSQL issue/comment long-text columns", v1_27.ExpandIssueAndCommentLongTextFieldsForMSSQL),
|
newMigration(338, "Expand legacy MSSQL issue/comment long-text columns", v1_27.ExpandIssueAndCommentLongTextFieldsForMSSQL),
|
||||||
newMigration(339, "Extend action c_u index to include created_unix for faster dashboard feed queries", v1_27.AddCreatedUnixToActionUserIsDeletedIndex),
|
newMigration(339, "Extend action c_u index to include created_unix for faster dashboard feed queries", v1_27.AddCreatedUnixToActionUserIsDeletedIndex),
|
||||||
|
newMigration(340, "Add ContinueOnError column to ActionRunJob", v1_27.AddContinueOnErrorToActionRunJob),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.dev/models/db"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddContinueOnErrorToActionRunJob adds the ContinueOnError column to ActionRunJob,
|
||||||
|
// storing the job-level continue-on-error value from the workflow YAML.
|
||||||
|
func AddContinueOnErrorToActionRunJob(x db.EngineMigration) error {
|
||||||
|
type ActionRunJob struct {
|
||||||
|
ContinueOnError bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||||
|
IgnoreDropIndices: true,
|
||||||
|
IgnoreConstrains: true,
|
||||||
|
}, new(ActionRunJob))
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
|||||||
runsOn[i] = evaluator.Interpolate(v)
|
runsOn[i] = evaluator.Interpolate(v)
|
||||||
}
|
}
|
||||||
job.RawRunsOn = encodeRunsOn(runsOn)
|
job.RawRunsOn = encodeRunsOn(runsOn)
|
||||||
|
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
|
||||||
|
return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", id, err)
|
||||||
|
}
|
||||||
swf := &SingleWorkflow{
|
swf := &SingleWorkflow{
|
||||||
Name: workflow.Name,
|
Name: workflow.Name,
|
||||||
RawOn: workflow.RawOn,
|
RawOn: workflow.RawOn,
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ func TestParse(t *testing.T) {
|
|||||||
options: nil,
|
options: nil,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "continue_on_error_expr",
|
||||||
|
options: nil,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
invalidFileTests := []struct {
|
invalidFileTests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -79,23 +79,37 @@ func (w *SingleWorkflow) Marshal() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
Name string `yaml:"name,omitempty"`
|
Name string `yaml:"name,omitempty"`
|
||||||
RawNeeds yaml.Node `yaml:"needs,omitempty"`
|
RawNeeds yaml.Node `yaml:"needs,omitempty"`
|
||||||
RawRunsOn yaml.Node `yaml:"runs-on,omitempty"`
|
RawRunsOn yaml.Node `yaml:"runs-on,omitempty"`
|
||||||
Env yaml.Node `yaml:"env,omitempty"`
|
Env yaml.Node `yaml:"env,omitempty"`
|
||||||
If yaml.Node `yaml:"if,omitempty"`
|
If yaml.Node `yaml:"if,omitempty"`
|
||||||
Steps []*Step `yaml:"steps,omitempty"`
|
Steps []*Step `yaml:"steps,omitempty"`
|
||||||
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
|
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
|
||||||
Services map[string]*ContainerSpec `yaml:"services,omitempty"`
|
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"`
|
||||||
Strategy Strategy `yaml:"strategy,omitempty"`
|
Services map[string]*ContainerSpec `yaml:"services,omitempty"`
|
||||||
RawContainer yaml.Node `yaml:"container,omitempty"`
|
Strategy Strategy `yaml:"strategy,omitempty"`
|
||||||
Defaults Defaults `yaml:"defaults,omitempty"`
|
RawContainer yaml.Node `yaml:"container,omitempty"`
|
||||||
Outputs map[string]string `yaml:"outputs,omitempty"`
|
Defaults Defaults `yaml:"defaults,omitempty"`
|
||||||
Uses string `yaml:"uses,omitempty"`
|
Outputs map[string]string `yaml:"outputs,omitempty"`
|
||||||
With map[string]any `yaml:"with,omitempty"`
|
Uses string `yaml:"uses,omitempty"`
|
||||||
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
|
With map[string]any `yaml:"with,omitempty"`
|
||||||
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
|
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
|
||||||
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
|
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
|
||||||
|
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContinueOnError decodes the continue-on-error field to a bool.
|
||||||
|
// The field may be a literal bool or an already-evaluated expression node.
|
||||||
|
func (j *Job) GetContinueOnError() bool {
|
||||||
|
if j.RawContinueOnError.Kind == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var v bool
|
||||||
|
if err := j.RawContinueOnError.Decode(&v); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *Job) Clone() *Job {
|
func (j *Job) Clone() *Job {
|
||||||
@@ -103,23 +117,24 @@ func (j *Job) Clone() *Job {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &Job{
|
return &Job{
|
||||||
Name: j.Name,
|
Name: j.Name,
|
||||||
RawNeeds: j.RawNeeds,
|
RawNeeds: j.RawNeeds,
|
||||||
RawRunsOn: j.RawRunsOn,
|
RawRunsOn: j.RawRunsOn,
|
||||||
Env: j.Env,
|
Env: j.Env,
|
||||||
If: j.If,
|
If: j.If,
|
||||||
Steps: j.Steps,
|
Steps: j.Steps,
|
||||||
TimeoutMinutes: j.TimeoutMinutes,
|
TimeoutMinutes: j.TimeoutMinutes,
|
||||||
Services: j.Services,
|
RawContinueOnError: j.RawContinueOnError,
|
||||||
Strategy: j.Strategy,
|
Services: j.Services,
|
||||||
RawContainer: j.RawContainer,
|
Strategy: j.Strategy,
|
||||||
Defaults: j.Defaults,
|
RawContainer: j.RawContainer,
|
||||||
Outputs: j.Outputs,
|
Defaults: j.Defaults,
|
||||||
Uses: j.Uses,
|
Outputs: j.Outputs,
|
||||||
With: j.With,
|
Uses: j.Uses,
|
||||||
RawSecrets: j.RawSecrets,
|
With: j.With,
|
||||||
RawConcurrency: j.RawConcurrency,
|
RawSecrets: j.RawSecrets,
|
||||||
RawPermissions: j.RawPermissions,
|
RawConcurrency: j.RawConcurrency,
|
||||||
|
RawPermissions: j.RawPermissions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -336,6 +336,52 @@ func TestSingleWorkflow_SetJob(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetContinueOnError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "absent",
|
||||||
|
yaml: "name: test\non: push\njobs:\n job1:\n runs-on: ubuntu-22.04\n steps:\n - run: echo hi\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "static true",
|
||||||
|
yaml: "name: test\non: push\njobs:\n job1:\n runs-on: ubuntu-22.04\n continue-on-error: true\n steps:\n - run: echo hi\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "static false",
|
||||||
|
yaml: "name: test\non: push\njobs:\n job1:\n runs-on: ubuntu-22.04\n continue-on-error: false\n steps:\n - run: echo hi\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := Parse([]byte(tt.yaml))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
_, job := got[0].Job()
|
||||||
|
assert.Equal(t, tt.want, job.GetContinueOnError())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expression case: ${{ matrix.experimental }} must resolve per matrix variant.
|
||||||
|
t.Run("matrix expression", func(t *testing.T) {
|
||||||
|
content := ReadTestdata(t, "continue_on_error_expr.in.yaml")
|
||||||
|
got, err := Parse(content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
// sorted by matrix name: (false) before (true)
|
||||||
|
_, jobFalse := got[0].Job()
|
||||||
|
_, jobTrue := got[1].Job()
|
||||||
|
assert.False(t, jobFalse.GetContinueOnError())
|
||||||
|
assert.True(t, jobTrue.GetContinueOnError())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseMappingNode(t *testing.T) {
|
func TestParseMappingNode(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
name: test
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
experimental: [false, true]
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
continue-on-error: ${{ matrix.experimental }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: test
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
name: job1 (false)
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
continue-on-error: false
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
experimental:
|
||||||
|
- false
|
||||||
|
---
|
||||||
|
name: test
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
name: job1 (true)
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
continue-on-error: true
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
experimental:
|
||||||
|
- true
|
||||||
@@ -380,6 +380,11 @@ func (r *jobStatusResolver) resolveCheckNeeds(id int64) (allDone, allSucceed boo
|
|||||||
if !needStatus.IsDone() {
|
if !needStatus.IsDone() {
|
||||||
allDone = false
|
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) {
|
if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped) {
|
||||||
allSucceed = false
|
allSucceed = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,24 @@ jobs:
|
|||||||
},
|
},
|
||||||
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
|
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())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *act
|
|||||||
AttemptJobID: templateJob.AttemptJobID,
|
AttemptJobID: templateJob.AttemptJobID,
|
||||||
Needs: slices.Clone(templateJob.Needs),
|
Needs: slices.Clone(templateJob.Needs),
|
||||||
RunsOn: slices.Clone(templateJob.RunsOn),
|
RunsOn: slices.Clone(templateJob.RunsOn),
|
||||||
|
ContinueOnError: templateJob.ContinueOnError,
|
||||||
Status: templateJob.Status,
|
Status: templateJob.Status,
|
||||||
RawConcurrency: templateJob.RawConcurrency,
|
RawConcurrency: templateJob.RawConcurrency,
|
||||||
IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated,
|
IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated,
|
||||||
|
|||||||
@@ -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) {
|
func TestRerunValidation(t *testing.T) {
|
||||||
runningRun := &actions_model.ActionRun{Status: actions_model.StatusRunning}
|
runningRun := &actions_model.ActionRun{Status: actions_model.StatusRunning}
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
|||||||
AttemptJobID: attemptJobID,
|
AttemptJobID: attemptJobID,
|
||||||
Needs: needs,
|
Needs: needs,
|
||||||
RunsOn: parsedChild.RunsOn(),
|
RunsOn: parsedChild.RunsOn(),
|
||||||
|
ContinueOnError: parsedChild.GetContinueOnError(),
|
||||||
Status: actions_model.StatusBlocked,
|
Status: actions_model.StatusBlocked,
|
||||||
ParentJobID: caller.ID,
|
ParentJobID: caller.ID,
|
||||||
WorkflowSourceRepoID: sourceRepoID,
|
WorkflowSourceRepoID: sourceRepoID,
|
||||||
|
|||||||
@@ -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),
|
Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting),
|
||||||
WorkflowSourceRepoID: run.RepoID,
|
WorkflowSourceRepoID: run.RepoID,
|
||||||
WorkflowSourceCommitSHA: run.CommitSHA,
|
WorkflowSourceCommitSHA: run.CommitSHA,
|
||||||
|
ContinueOnError: job.GetContinueOnError(),
|
||||||
}
|
}
|
||||||
// Parse workflow/job permissions (no clamping here)
|
// Parse workflow/job permissions (no clamping here)
|
||||||
if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {
|
if perms := ExtractJobPermissionsFromWorkflow(v, job); perms != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user