mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 16:04:43 +02:00
fix(actions): evaluate job-level if: before concurrency check (#39437)
Gitea doesn't evaluate a job's `if:` before checking the job's concurrency group, which causes a job that should have been skipped to incorrectly cancel other jobs in the same concurrency group. This PR makes Gitea decide `if:` for every job before it becomes waiting, including jobs without `needs` at insertion, on approval and on rerun. A skipped job therefore no longer takes part in job concurrency or holds a max-parallel slot, and a reusable caller whose `if:` is false is no longer expanded on approval or rerun. An invalid `if:` skips the job with an error summary. After this PR, Gitea decides all jobs' `if:` expressions and sends `if: always()` to the runner, so the runner no longer needs to evaluate a job's `if:` again ([gitea/runner `run_context.go`](https://gitea.com/gitea/runner/src/commit/81add274599355ec1838b6ebe45804890d40bab9/act/runner/run_context.go#L1195)). --------- Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -163,6 +163,10 @@ func UpdateRunAttempt(ctx context.Context, attempt *ActionRunAttempt, cols ...st
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
cols = append(cols, "started")
|
||||
}
|
||||
if slices.Contains(cols, "status") && attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
||||
attempt.Stopped = timeutil.TimeStampNow()
|
||||
cols = append(cols, "stopped")
|
||||
}
|
||||
if slices.Contains(cols, "status") && !attempt.Stopped.IsZero() && !attempt.Status.IsDone() {
|
||||
attempt.Stopped = 0
|
||||
cols = append(cols, "stopped")
|
||||
|
||||
@@ -545,13 +545,7 @@ func refreshRunStatus(ctx context.Context, repoID, runID, runAttemptID int64, no
|
||||
if len(jobs) == 0 {
|
||||
attempt.Status = noJobsStatus
|
||||
}
|
||||
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
||||
attempt.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
|
||||
if err := UpdateRunAttempt(ctx, attempt, "status"); err != nil {
|
||||
return fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
+30
-14
@@ -21,8 +21,7 @@ import (
|
||||
func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) ([]*actions_model.ActionRun, error) {
|
||||
updatedJobs := make([]*actions_model.ActionRunJob, 0)
|
||||
cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0)
|
||||
// Track runs whose reusable callers were just expanded so we can re-emit after the tx commits.
|
||||
expandedCallerRunIDs := make(container.Set[int64])
|
||||
runIDsToEmit := make(container.Set[int64])
|
||||
|
||||
err := db.WithTx(ctx, func(ctx context.Context) (err error) {
|
||||
for _, runID := range runIDs {
|
||||
@@ -43,6 +42,11 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
|
||||
return err
|
||||
}
|
||||
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// approval unblocks every job at once, so max-parallel has to cap them here too
|
||||
slots := maxParallelSlots{}
|
||||
for _, job := range jobs {
|
||||
@@ -58,6 +62,25 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
|
||||
// Only a job this approval unblocks competes for a slot, one that is already
|
||||
// active was counted by the seeding loop above and must not take a second.
|
||||
isUnblocking := job.Status == actions_model.StatusBlocked
|
||||
// a skipped job must neither cancel its group peers nor take a slot
|
||||
if isUnblocking {
|
||||
shouldStart, err := evaluateJobIf(ctx, run, nil, job, vars, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate job %d if on approval: %w", job.ID, err)
|
||||
}
|
||||
if !shouldStart {
|
||||
job.Status = actions_model.StatusSkipped
|
||||
n, err := actions_model.UpdateRunJob(ctx, job, nil, "status")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
updatedJobs = append(updatedJobs, job)
|
||||
runIDsToEmit.Add(run.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
// A slot-starved job cannot start, skip the following checks.
|
||||
if isUnblocking && !slots.available(job) {
|
||||
continue
|
||||
@@ -92,17 +115,10 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
|
||||
if !has {
|
||||
return errors.New("run has no attempt")
|
||||
}
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, run)
|
||||
if err != nil {
|
||||
if err := expandInlineReusableCaller(ctx, run, attempt, job, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := expandReusableWorkflowCaller(ctx, run, attempt, job, vars); err != nil {
|
||||
return fmt.Errorf("expand caller %d on approval: %w", job.ID, err)
|
||||
}
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, job); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status after approval-time expansion: %w", job.ID, err)
|
||||
}
|
||||
expandedCallerRunIDs.Add(run.ID)
|
||||
runIDsToEmit.Add(run.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,10 +128,10 @@ func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_mo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-emit AFTER the tx commits so the newly inserted callee rows transition Blocked -> Waiting.
|
||||
for runID := range expandedCallerRunIDs {
|
||||
// Re-emit AFTER the tx commits so callee rows and dependents of skipped jobs get resolved.
|
||||
for runID := range runIDsToEmit {
|
||||
if err := EmitJobsIfReadyByRun(runID); err != nil {
|
||||
log.Error("emit run %d after approval-time caller expansion: %v", runID, err)
|
||||
log.Error("emit run %d after approval: %v", runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -37,6 +38,7 @@ func TestApproveRuns(t *testing.T) {
|
||||
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
|
||||
Name: "job1", Attempt: 1, JobID: "job1", Status: status,
|
||||
RunsOn: []string{"ubuntu-latest"}, Needs: needs,
|
||||
WorkflowPayload: minimalWorkflowPayload("job1"),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
return job
|
||||
@@ -55,6 +57,20 @@ func TestApproveRuns(t *testing.T) {
|
||||
assert.Equal(t, actions_model.StatusWaiting, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
|
||||
})
|
||||
|
||||
t.Run("approve skips a job whose if is false", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(int64) error { return nil })()
|
||||
run := insertRun(1006, actions_model.StatusBlocked, true, 0)
|
||||
job := insertJob(run, actions_model.StatusBlocked)
|
||||
job.WorkflowPayload = []byte("jobs:\n job1:\n if: false\n")
|
||||
_, err := actions_model.UpdateRunJob(t.Context(), job, nil, "workflow_payload")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ApproveRuns(t.Context(), repo, doer, []int64{run.ID})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, actions_model.StatusSkipped, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
|
||||
})
|
||||
|
||||
t.Run("a job with unmet dependencies stays blocked", func(t *testing.T) {
|
||||
run := insertRun(1002, actions_model.StatusBlocked, true, 0)
|
||||
job := insertJob(run, actions_model.StatusBlocked, "some-other-job")
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
actions_module "gitea.dev/modules/actions"
|
||||
"gitea.dev/modules/json"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -97,6 +98,47 @@ jobs:
|
||||
assert.NotEmpty(t, persisted.RawConcurrency)
|
||||
}
|
||||
|
||||
func TestPrepareRunAndInsert_JobIf(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(int64) error { return nil })()
|
||||
|
||||
run := insertMaxParallelRun(t, `on: push
|
||||
jobs:
|
||||
start:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
skip:
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: skip
|
||||
steps:
|
||||
- run: echo
|
||||
skip-caller:
|
||||
if: false
|
||||
uses: ./.gitea/workflows/callee.yml
|
||||
invalid:
|
||||
if: fromJSON('{')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`, false)
|
||||
|
||||
jobs := map[string]*actions_model.ActionRunJob{}
|
||||
for _, job := range runJobs(t, run.ID, run.LatestAttemptID) {
|
||||
jobs[job.JobID] = job
|
||||
}
|
||||
assert.Equal(t, actions_model.StatusWaiting, jobs["start"].Status)
|
||||
assert.Equal(t, actions_model.StatusSkipped, jobs["skip"].Status)
|
||||
assert.False(t, jobs["skip"].IsConcurrencyEvaluated)
|
||||
assert.Equal(t, actions_model.StatusSkipped, jobs["skip-caller"].Status)
|
||||
assert.Equal(t, actions_model.StatusSkipped, jobs["invalid"].Status)
|
||||
summary, err := actions_model.GetActionRunJobSummary(t.Context(), run.RepoID, run.ID, run.LatestAttemptID, jobs["invalid"].ID, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, summary.Content, "Error when evaluating `if` for job `invalid`")
|
||||
}
|
||||
|
||||
func TestComputeReusableCallerOutputs(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
@@ -5,6 +5,7 @@ package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
@@ -92,11 +93,35 @@ func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) {
|
||||
return payload.PullRequest.Base.Sha, true
|
||||
}
|
||||
|
||||
// evaluateJobIf evaluates a job's `if:`
|
||||
// evaluateJobIf evaluates a job's `if:`. An invalid `if:` skips the job and is reported in its summary.
|
||||
func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) {
|
||||
shouldStart, err := resolveJobIf(ctx, run, attempt, job, vars, allNeedsSucceed)
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
return false, upsertJobErrorSummary(ctx, job, "if", err)
|
||||
}
|
||||
return shouldStart, err
|
||||
}
|
||||
|
||||
// decideJobIf skips a waiting job whose `if:` is false before its insertion, an invalid `if:` is returned for the job's summary.
|
||||
func decideJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string) (invalidIf, err error) {
|
||||
if job.Status != actions_model.StatusWaiting {
|
||||
return nil, nil
|
||||
}
|
||||
shouldStart, err := resolveJobIf(ctx, run, attempt, job, vars, true)
|
||||
if errors.Is(err, util.ErrInvalidArgument) {
|
||||
invalidIf, err = err, nil
|
||||
}
|
||||
if !shouldStart {
|
||||
job.Status = actions_model.StatusSkipped
|
||||
}
|
||||
return invalidIf, err
|
||||
}
|
||||
|
||||
// resolveJobIf evaluates a job's `if:` and returns an invalid `if:` as util.ErrInvalidArgument.
|
||||
func resolveJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) {
|
||||
parsedJob, err := job.ParseJob()
|
||||
if err != nil {
|
||||
return false, upsertJobErrorSummary(ctx, job, "if", err)
|
||||
return false, util.NewInvalidArgumentErrorf("%v", err)
|
||||
}
|
||||
// Empty `if:` reduces to implicit `success()` - true iff every need finished as Success.
|
||||
if len(parsedJob.If.Value) == 0 {
|
||||
@@ -122,7 +147,7 @@ func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *a
|
||||
gitCtx["job"] = "" // github.com decides a job's `if:` before the job exists
|
||||
shouldStart, err := jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs)
|
||||
if err != nil {
|
||||
return false, upsertJobErrorSummary(ctx, job, "if", err)
|
||||
return false, util.NewInvalidArgumentErrorf("%v", err)
|
||||
}
|
||||
return shouldStart, nil
|
||||
}
|
||||
|
||||
+22
-21
@@ -190,7 +190,7 @@ func buildRerunPlan(ctx context.Context, run *actions_model.ActionRun, triggerUs
|
||||
// Inside a single database transaction it then inserts the new attempt, clones all template jobs, evaluates job-level concurrency for rerun jobs,
|
||||
// and updates the run's latest_attempt_id.
|
||||
// Jobs not in the rerun set are cloned as pass-through: their status is preserved and SourceTaskID points to the original task so the UI can still display their results.
|
||||
// The attempt's final status is derived only from the rerun jobs, not the pass-through jobs.
|
||||
// The attempt's status aggregates all its jobs, pass-through ones included, as rerun jobs skipped by `if:` get no later update.
|
||||
// Notifications and commit statuses are sent after the transaction commits.
|
||||
func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionRunAttempt, error) {
|
||||
vars, err := actions_model.GetVariablesOfRun(ctx, plan.run)
|
||||
@@ -222,7 +222,6 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
||||
|
||||
var newJobs, newJobsToRerun actions_model.ActionJobList
|
||||
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||
var hasWaitingCallerJobs bool
|
||||
|
||||
err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||
newAttemptStatus, jobsToCancel, err := PrepareToStartRunWithConcurrency(ctx, newAttempt)
|
||||
@@ -281,10 +280,10 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
||||
newJob.ParentJobID = newParentID
|
||||
}
|
||||
|
||||
var invalidIf error
|
||||
if plan.rerunAttemptJobIDs.Contains(templateJob.AttemptJobID) {
|
||||
// A deferred-matrix placeholder must go through the emitter, which is the only place
|
||||
// that expands it: dispatching it directly would hand the runner the raw payload.
|
||||
shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob) || newJob.IsMatrixDeferred
|
||||
// the emitter decides `if:` once all needs have results, and is the only place expanding a deferred matrix
|
||||
shouldBlockJob := shouldBlock || len(newJob.Needs) > 0 || newJob.IsMatrixDeferred
|
||||
|
||||
newJob.Status = util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting)
|
||||
newJob.TaskID = 0
|
||||
@@ -300,8 +299,13 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
||||
newJob.CallPayload = ""
|
||||
}
|
||||
|
||||
invalidIf, err = decideJobIf(ctx, plan.run, newAttempt, newJob, vars)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate job if: %w", err)
|
||||
}
|
||||
|
||||
// A slot-starved job must not cancel its group peers.
|
||||
if newJob.RawConcurrency != "" && !shouldBlockJob && slots.available(newJob) {
|
||||
if newJob.RawConcurrency != "" && newJob.Status == actions_model.StatusWaiting && slots.available(newJob) {
|
||||
if err := EvaluateJobConcurrencyFillModel(ctx, plan.run, newAttempt, newJob, vars, nil); err != nil {
|
||||
return fmt.Errorf("evaluate job concurrency: %w", err)
|
||||
}
|
||||
@@ -327,17 +331,17 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
||||
return err
|
||||
}
|
||||
templateIDToNewID[templateJob.ID] = newJob.ID
|
||||
if invalidIf != nil {
|
||||
if err := upsertJobErrorSummary(ctx, newJob, "if", invalidIf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// expand reusable caller
|
||||
if newJob.IsReusableCaller && newJob.Status == actions_model.StatusWaiting && !newJob.IsExpanded {
|
||||
if err := expandReusableWorkflowCaller(ctx, plan.run, newAttempt, newJob, vars); err != nil {
|
||||
return fmt.Errorf("inline trigger caller %d ready: %w", newJob.ID, err)
|
||||
if err := expandInlineReusableCaller(ctx, plan.run, newAttempt, newJob, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
// refresh the caller status
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, newJob); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status: %w", newJob.ID, err)
|
||||
}
|
||||
hasWaitingCallerJobs = true
|
||||
}
|
||||
|
||||
// A reusable caller is never dispatched to a runner, so it must not drive the task-version bump.
|
||||
@@ -356,7 +360,7 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
||||
}
|
||||
}
|
||||
|
||||
newAttempt.Status = actions_model.AggregateJobStatus(newJobsToRerun)
|
||||
newAttempt.Status = actions_model.AggregateJobStatus(newJobs)
|
||||
if err := actions_model.UpdateRunAttempt(ctx, newAttempt, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -383,12 +387,9 @@ func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionR
|
||||
CreateCommitStatusForRunJobs(ctx, plan.run, newJobs...)
|
||||
NotifyWorkflowJobsAndRunsStatusUpdate(ctx, newJobsToRerun)
|
||||
|
||||
// Post-commit kick for expanded callers and restored matrix placeholders: let job_emitter
|
||||
// resolve child jobs, and re-expand a placeholder whose needs may all be pass-through and done.
|
||||
if hasWaitingCallerJobs || len(plan.matrixPlaceholderTemplateIDs) > 0 {
|
||||
if err := EmitJobsIfReadyByRun(plan.run.ID); err != nil {
|
||||
log.Error("emit run %d after rerun: %v", plan.run.ID, err)
|
||||
}
|
||||
// Post-commit kick: resolve rerun jobs with needs, children of expanded callers and dependents of skipped jobs.
|
||||
if err := EmitJobsIfReadyByRun(plan.run.ID); err != nil {
|
||||
log.Error("emit run %d after rerun: %v", plan.run.ID, err)
|
||||
}
|
||||
|
||||
return newAttempt, nil
|
||||
@@ -487,7 +488,7 @@ func (p *rerunPlan) expandRerunJobIDs(jobsToRerun []*actions_model.ActionRunJob)
|
||||
|
||||
// hasRerunDependency reports whether `job` has a needs-reference that points to a job which is itself being rerun (in rerunAttemptJobIDs)
|
||||
// or is an ancestor caller whose subtree is being rerun (in ancestorAttemptJobIDs).
|
||||
// Either case means `job` should start in Blocked status.
|
||||
// Either case means the needs of `job` may produce different results or outputs in the new attempt.
|
||||
func (p *rerunPlan) hasRerunDependency(job *actions_model.ActionRunJob) bool {
|
||||
if len(job.Needs) == 0 {
|
||||
return false
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"testing"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -453,3 +456,52 @@ func TestCollectMatrixCollapse(t *testing.T) {
|
||||
assert.Empty(t, plan.matrixSiblingSkipTemplateIDs)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRerunDecidesJobIf(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
defer test.MockVariableValue(&EmitJobsIfReadyByRun, func(int64) error { return nil })()
|
||||
ctx := t.Context()
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
variable, err := actions_model.InsertVariable(ctx, 0, repo.ID, "DEPLOY", "yes", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
run := insertMaxParallelRun(t, `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
deploy:
|
||||
if: vars.DEPLOY == 'yes'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
`, false)
|
||||
jobs := map[string]*actions_model.ActionRunJob{}
|
||||
for _, job := range runJobs(t, run.ID, run.LatestAttemptID) {
|
||||
require.Equal(t, actions_model.StatusWaiting, job.Status)
|
||||
job.Status = actions_model.StatusSuccess
|
||||
_, err = actions_model.UpdateRunJob(ctx, job, nil, "status")
|
||||
require.NoError(t, err)
|
||||
jobs[job.JobID] = job
|
||||
}
|
||||
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID})
|
||||
|
||||
variable.Data = "no"
|
||||
_, err = actions_model.UpdateVariableCols(ctx, variable, "data")
|
||||
require.NoError(t, err)
|
||||
attempt, err := RerunWorkflowRunJobs(ctx, repo, run, &user_model.User{ID: 1}, []*actions_model.ActionRunJob{jobs["deploy"]})
|
||||
require.NoError(t, err)
|
||||
rerunJobs := map[string]*actions_model.ActionRunJob{}
|
||||
for _, job := range runJobs(t, run.ID, attempt.ID) {
|
||||
rerunJobs[job.JobID] = job
|
||||
}
|
||||
assert.Equal(t, actions_model.StatusSuccess, rerunJobs["build"].Status)
|
||||
assert.Equal(t, actions_model.StatusSkipped, rerunJobs["deploy"].Status)
|
||||
|
||||
attempt = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: attempt.ID})
|
||||
assert.Equal(t, actions_model.StatusSuccess, attempt.Status)
|
||||
assert.NotZero(t, attempt.Stopped)
|
||||
assert.Equal(t, actions_model.StatusSuccess, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}).Status)
|
||||
}
|
||||
|
||||
+24
-37
@@ -178,11 +178,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertRunJob builds a single run job from a parsed workflow job, evaluates its
|
||||
// job-level concurrency, inserts it, and — for a ready no-needs reusable caller —
|
||||
// inline-expands (or skips) it. It returns the inserted job, any jobs cancelled by
|
||||
// job concurrency, and whether a post-commit emitter pass is needed to resolve the
|
||||
// caller's dependents.
|
||||
// insertRunJob returns the inserted job, the jobs its concurrency cancelled, and whether a post-commit emitter pass is needed.
|
||||
func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, workflowJob *jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any, slots maxParallelSlots) (*actions_model.ActionRunJob, []*actions_model.ActionRunJob, bool, error) {
|
||||
id, job := workflowJob.Job()
|
||||
needs := job.Needs()
|
||||
@@ -236,6 +232,12 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
|
||||
runJob.CallUses = job.Uses
|
||||
}
|
||||
|
||||
// a skipped job must neither cancel its group peers nor take a slot
|
||||
invalidIf, err := decideJobIf(ctx, run, runAttempt, runJob, vars)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("evaluate job if: %w", err)
|
||||
}
|
||||
|
||||
var cancelledConcurrencyJobs []*actions_model.ActionRunJob
|
||||
// check job concurrency
|
||||
if job.RawConcurrency != nil {
|
||||
@@ -245,8 +247,8 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
|
||||
}
|
||||
runJob.RawConcurrency = string(rawConcurrency)
|
||||
|
||||
// do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter
|
||||
if len(needs) == 0 {
|
||||
// the job emitter evaluates it for jobs with `needs`, a skipped job never takes part
|
||||
if len(needs) == 0 && runJob.Status != actions_model.StatusSkipped {
|
||||
if err := EvaluateJobConcurrencyFillModel(ctx, run, runAttempt, runJob, vars, inputs); err != nil {
|
||||
return nil, nil, false, fmt.Errorf("evaluate job concurrency: %w", err)
|
||||
}
|
||||
@@ -270,43 +272,28 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
|
||||
if err := db.Insert(ctx, runJob); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
// expand reusable caller
|
||||
var needPostCommitEmit bool
|
||||
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
|
||||
if err := processInlineReusableCaller(ctx, run, runAttempt, runJob, vars); err != nil {
|
||||
if invalidIf != nil {
|
||||
if err := upsertJobErrorSummary(ctx, runJob, "if", invalidIf); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
// A processed caller always needs a resolver pass:
|
||||
// - if the caller is expanded, resolve its children jobs;
|
||||
// - if the caller is skipped, propagate its state to its dependents
|
||||
needPostCommitEmit = true
|
||||
}
|
||||
|
||||
return runJob, cancelledConcurrencyJobs, needPostCommitEmit, nil
|
||||
if isReusableWorkflowCaller && runJob.Status == actions_model.StatusWaiting {
|
||||
if err := expandInlineReusableCaller(ctx, run, runAttempt, runJob, vars); err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
// the emitter resolves an expanded caller's children and a skipped job's dependents
|
||||
return runJob, cancelledConcurrencyJobs, runJob.IsExpanded || runJob.Status == actions_model.StatusSkipped, nil
|
||||
}
|
||||
|
||||
// processInlineReusableCaller evaluates a no-needs reusable caller's own `if:` and
|
||||
// either inline-expands it into child jobs or marks it skipped.
|
||||
// (A caller with needs is Blocked and gets its `if:` evaluated by the job emitter instead.)
|
||||
func processInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
|
||||
shouldStart, err := evaluateJobIf(ctx, run, runAttempt, caller, vars, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evaluate caller %d if: %w", caller.ID, err)
|
||||
func expandInlineReusableCaller(ctx context.Context, run *actions_model.ActionRun, runAttempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, vars map[string]string) error {
|
||||
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, caller, vars); err != nil {
|
||||
return fmt.Errorf("inline trigger caller %d ready: %w", caller.ID, err)
|
||||
}
|
||||
if shouldStart {
|
||||
if err := expandReusableWorkflowCaller(ctx, run, runAttempt, caller, vars); err != nil {
|
||||
return fmt.Errorf("inline trigger caller %d ready: %w", caller.ID, err)
|
||||
}
|
||||
// refresh the caller status
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, caller); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status: %w", caller.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
caller.Status = actions_model.StatusSkipped
|
||||
if _, err := actions_model.UpdateRunJob(ctx, caller, nil, "status"); err != nil {
|
||||
return fmt.Errorf("skip caller %d: %w", caller.ID, err)
|
||||
if err := actions_model.RefreshReusableCallerStatus(ctx, caller); err != nil {
|
||||
return fmt.Errorf("refresh caller %d status: %w", caller.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
secret_model "gitea.dev/models/secret"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
@@ -156,9 +158,14 @@ func buildRunnerTask(ctx context.Context, t *actions_model.ActionTask) (*runnerv
|
||||
return nil, nil, fmt.Errorf("generateTaskContext: %w", err)
|
||||
}
|
||||
|
||||
payload, err := runnerWorkflowPayload(job)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("runnerWorkflowPayload: %w", err)
|
||||
}
|
||||
|
||||
return &runnerv1.Task{
|
||||
Id: t.ID,
|
||||
WorkflowPayload: t.Job.WorkflowPayload,
|
||||
WorkflowPayload: payload,
|
||||
Context: taskContext,
|
||||
Secrets: secrets,
|
||||
Vars: vars,
|
||||
@@ -166,6 +173,19 @@ func buildRunnerTask(ctx context.Context, t *actions_model.ActionTask) (*runnerv
|
||||
}, job, nil
|
||||
}
|
||||
|
||||
// runnerWorkflowPayload sets the job `if:` to `always()`, as Gitea has decided it and a runner must not re-evaluate it.
|
||||
func runnerWorkflowPayload(job *actions_model.ActionRunJob) ([]byte, error) {
|
||||
swf, parsedJob, err := jobparser.ParseRawSingleWorkflow(job.WorkflowPayload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsedJob.If = yaml.Node{Kind: yaml.ScalarNode, Value: "always()"}
|
||||
if err := swf.SetJob(job.JobID, parsedJob); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return swf.Marshal()
|
||||
}
|
||||
|
||||
func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*structpb.Struct, error) {
|
||||
giteaRuntimeToken, err := CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -72,3 +73,17 @@ func TestReleaseTaskForRunnerCleanup(t *testing.T) {
|
||||
assert.Zero(t, released.TaskID)
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionTask{ID: task.ID})
|
||||
}
|
||||
|
||||
func TestRunnerWorkflowPayload(t *testing.T) {
|
||||
payload, err := runnerWorkflowPayload(&actions_model.ActionRunJob{
|
||||
JobID: "build",
|
||||
WorkflowPayload: []byte("name: ci\njobs:\n build:\n if: false\n steps:\n - run: echo\n"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
swf, parsedJob, err := jobparser.ParseRawSingleWorkflow(payload)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ci", swf.Name)
|
||||
assert.Equal(t, "always()", parsedJob.If.Value)
|
||||
assert.Len(t, parsedJob.Steps, 1)
|
||||
}
|
||||
|
||||
@@ -208,6 +208,13 @@ func TestAPIActionsRerunWorkflowRun(t *testing.T) {
|
||||
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
||||
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
||||
|
||||
for _, jobID := range []int64{198, 199} {
|
||||
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: jobID})
|
||||
job.WorkflowPayload = minimalConcurrentWorkflowPayload(job.JobID)
|
||||
_, err := actions_model.UpdateRunJob(t.Context(), job, nil, "workflow_payload")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
t.Run("RunLogs", func(t *testing.T) {
|
||||
// run 795 (workflow "test.yaml") has job 198 "job_1" on task 53 and job 199 "job_2" on task 54
|
||||
seedTaskLogs(t, 53, "hello from job_1")
|
||||
@@ -522,6 +529,7 @@ func testAPIActionsApproveWorkflowRun(t *testing.T) {
|
||||
require.NoError(t, db.Insert(t.Context(), &actions_model.ActionRunJob{
|
||||
RunID: run.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA,
|
||||
Name: "job1", Attempt: 1, JobID: "job1", Status: actions_model.StatusBlocked, RunsOn: []string{"ubuntu-latest"},
|
||||
WorkflowPayload: minimalConcurrentWorkflowPayload("job1"),
|
||||
}))
|
||||
return run
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user