feat: evaluate action job's if on the server-side when possible (#13030)

Fixes #12937.

The intent of this change is to allow Forgejo to evaluate `if` without having to send jobs to a runner.  When you send a job to a runner just for it to return "skipped!", it takes up 1 runner capacity for a `fetch_interval` period, which can be avoided if Forgejo can evaluate the `if` condition itself.

### Tests for Go changes

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/13030
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak 2026-06-16 00:02:57 +02:00 committed by Mathieu Fenniak
commit 6b3359f016
19 changed files with 3827 additions and 157 deletions

View file

@ -7,6 +7,9 @@ packages:
forgejo.org/services/auth:
config:
filename: mocks.go # make mocks public so that external packages can use
forgejo.org/services/notify:
config:
filename: mocks.go # make mocks public so that external packages can use
forgejo.org/services/authz:
config:
filename: authorization_reducer_mock.go # make mocks public so that external packages can use

View file

@ -362,42 +362,33 @@ func GetRunsNotDoneByRepoIDAndPullRequestID(ctx context.Context, repoID, pullReq
return runs, nil
}
// InsertRun inserts a run
// Inserts a run and its jobs.
// The title will be cut off at 255 characters if it's longer than 255 characters.
// We don't have to send the ActionRunNowDone notification here because there are no runs that start in a not done status.
func InsertRun(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
if err != nil {
return err
}
run.Index = index
run.Title, _ = util.SplitStringAtByteN(run.Title, 255)
if err := db.Insert(ctx, run); err != nil {
return err
}
if run.Repo == nil {
repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
func InsertRunWithoutNotification(ctx context.Context, run *ActionRun, jobs []*jobparser.SingleWorkflow) error {
return db.WithTx(ctx, func(ctx context.Context) error {
index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID)
if err != nil {
return err
}
run.Repo = repo
}
run.Index = index
run.Title, _ = util.SplitStringAtByteN(run.Title, 255)
clearRepoRunCountCache(ctx, run.Repo)
if err := db.Insert(ctx, run); err != nil {
return err
}
if err := InsertRunJobs(ctx, run, jobs); err != nil {
return err
}
if run.Repo == nil {
repo, err := repo_model.GetRepositoryByID(ctx, run.RepoID)
if err != nil {
return err
}
run.Repo = repo
}
return committer.Commit()
clearRepoRunCountCache(ctx, run.Repo)
return InsertRunJobs(ctx, run, jobs)
})
}
// Adds `ActionRunJob` instances from `SingleWorkflows` to an existing ActionRun.
@ -420,10 +411,17 @@ func InsertRunJobs(ctx context.Context, run *ActionRun, jobs []*jobparser.Single
if len(needs) > 0 || run.NeedApproval || v.IncompleteMatrix || v.IncompleteRunsOn || v.IncompleteWith {
status = StatusBlocked
} else if ifPassed, err := job.EvaluateIf(); err == nil && !ifPassed {
log.Trace("job %q skipped by server-side 'if' evaluation", id)
status = StatusSkipped
} else {
if err != nil && !errors.Is(err, jobparser.ErrCannotEvaluateInJobParser) {
return fmt.Errorf("unable to evaluate job 'if' on server-side with unexpected error: %w", err)
}
status = StatusWaiting
hasWaiting = true
}
name, _ = util.SplitStringAtByteN(job.Name, 255)
runsOn = job.RunsOn()
}
@ -571,18 +569,26 @@ func UpdateRunWithoutNotification(ctx context.Context, run *ActionRun, cols ...s
return nil
}
// Compute the Status, Started, and Stopped fields of an ActionRun based upon the current job state within the run.
// Returned is the [ActionRun] with modifications if necessary, a slice of column names that have been updated, or an
// error if the calculation failed. The caller is responsible for then invoking [actions_service.UpdateRun] for an
// update with notifications, or [actions_model.UpdateRunWithoutNotification] if notifications are already handled.
// Performs the same computation as [ComputeExistingRunStatus] from a run ID, and returning the run. The caller is
// responsible for then invoking [actions_service.UpdateRun] for an update with notifications, or
// [actions_model.UpdateRunWithoutNotification] if notifications are already handled.
func ComputeRunStatus(ctx context.Context, runID int64) (run *ActionRun, columns []string, err error) {
run, err = GetRunByID(ctx, runID)
if err != nil {
return nil, nil, err
}
jobs, err := GetRunJobsByRunID(ctx, runID)
columns, err = ComputeExistingRunStatus(ctx, run)
return run, columns, err
}
// Compute the Status, Started, and Stopped fields of an ActionRun based upon the current job state within the run. The
// provided [ActionRun] is modified in-memory, but not in the database. The caller is responsible for then invoking
// [actions_service.UpdateRun] for an update with notifications, or [actions_model.UpdateRunWithoutNotification] if
// notifications are already handled.
func ComputeExistingRunStatus(ctx context.Context, run *ActionRun) (columns []string, err error) {
jobs, err := GetRunJobsByRunID(ctx, run.ID)
if err != nil {
return nil, nil, err
return nil, err
}
newStatus := AggregateJobStatus(jobs)
@ -599,7 +605,7 @@ func ComputeRunStatus(ctx context.Context, runID int64) (run *ActionRun, columns
columns = append(columns, "stopped")
}
return run, columns, nil
return columns, nil
}
// DeleteRun removes the given run. It is the caller's responsibility to handle the run's dependencies like artifacts or

View file

@ -226,7 +226,7 @@ func TestActionRun_GetRunsNotDoneByRepoIDAndPullRequestPosterID(t *testing.T) {
PullRequestPosterID: pullRequestPosterID,
Status: StatusSuccess,
}
require.NoError(t, InsertRun(t.Context(), runDone, nil))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runDone, nil))
unrelatedUser := int64(5)
runNotByPoster := &ActionRun{
@ -235,7 +235,7 @@ func TestActionRun_GetRunsNotDoneByRepoIDAndPullRequestPosterID(t *testing.T) {
PullRequestPosterID: unrelatedUser,
Status: StatusRunning,
}
require.NoError(t, InsertRun(t.Context(), runNotByPoster, nil))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runNotByPoster, nil))
unrelatedRepository := int64(6)
runNotInTheSameRepository := &ActionRun{
@ -244,7 +244,7 @@ func TestActionRun_GetRunsNotDoneByRepoIDAndPullRequestPosterID(t *testing.T) {
PullRequestPosterID: pullRequestPosterID,
Status: StatusSuccess,
}
require.NoError(t, InsertRun(t.Context(), runNotInTheSameRepository, nil))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runNotInTheSameRepository, nil))
for _, status := range []Status{StatusUnknown, StatusWaiting, StatusRunning} {
t.Run(fmt.Sprintf("%s", status), func(t *testing.T) {
@ -254,7 +254,7 @@ func TestActionRun_GetRunsNotDoneByRepoIDAndPullRequestPosterID(t *testing.T) {
Status: status,
PullRequestPosterID: pullRequestPosterID,
}
require.NoError(t, InsertRun(t.Context(), runNotDone, nil))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runNotDone, nil))
runs, err := GetRunsNotDoneByRepoIDAndPullRequestPosterID(t.Context(), repoID, pullRequestPosterID)
require.NoError(t, err)
require.Len(t, runs, 1)
@ -277,7 +277,7 @@ func TestActionRun_NeedApproval(t *testing.T) {
PullRequestID: pullRequestID,
PullRequestPosterID: pullRequestPosterID,
}
require.NoError(t, InsertRun(t.Context(), runDoesNotNeedApproval, nil))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runDoesNotNeedApproval, nil))
unrelatedRepository := int64(6)
runNotInTheSameRepository := &ActionRun{
RepoID: unrelatedRepository,
@ -285,7 +285,7 @@ func TestActionRun_NeedApproval(t *testing.T) {
PullRequestPosterID: pullRequestPosterID,
NeedApproval: true,
}
require.NoError(t, InsertRun(t.Context(), runNotInTheSameRepository, nil))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runNotInTheSameRepository, nil))
unrelatedPullRequest := int64(3)
runNotInTheSamePullRequest := &ActionRun{
RepoID: repoID,
@ -293,7 +293,7 @@ func TestActionRun_NeedApproval(t *testing.T) {
PullRequestPosterID: pullRequestPosterID,
NeedApproval: true,
}
require.NoError(t, InsertRun(t.Context(), runNotInTheSamePullRequest, nil))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runNotInTheSamePullRequest, nil))
t.Run("HasRunThatNeedApproval is false", func(t *testing.T) {
has, err := HasRunThatNeedApproval(t.Context(), repoID, pullRequestID)
@ -307,7 +307,7 @@ func TestActionRun_NeedApproval(t *testing.T) {
PullRequestPosterID: pullRequestPosterID,
NeedApproval: true,
}
require.NoError(t, InsertRun(t.Context(), runNeedApproval, nil))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runNeedApproval, nil))
t.Run("HasRunThatNeedApproval is true", func(t *testing.T) {
has, err := HasRunThatNeedApproval(t.Context(), repoID, pullRequestID)
@ -357,7 +357,7 @@ jobs:
require.NoError(t, err)
require.True(t, workflows[0].IncompleteMatrix) // must be set for this test scenario to be valid
require.NoError(t, InsertRun(t.Context(), runDoesNotNeedApproval, workflows))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runDoesNotNeedApproval, workflows))
jobs, err := db.Find[ActionRunJob](t.Context(), FindRunJobOptions{RunID: runDoesNotNeedApproval.ID})
require.NoError(t, err)
@ -391,7 +391,7 @@ jobs:
require.NoError(t, err)
require.True(t, workflows[0].IncompleteRunsOn) // must be set for this test scenario to be valid
require.NoError(t, InsertRun(t.Context(), runDoesNotNeedApproval, workflows))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runDoesNotNeedApproval, workflows))
jobs, err := db.Find[ActionRunJob](t.Context(), FindRunJobOptions{RunID: runDoesNotNeedApproval.ID})
require.NoError(t, err)
@ -435,7 +435,7 @@ jobs:
`), nil
}))
require.NoError(t, err)
require.NoError(t, InsertRun(t.Context(), run, workflows))
require.NoError(t, InsertRunWithoutNotification(t.Context(), run, workflows))
jobs, err := db.Find[ActionRunJob](t.Context(), FindRunJobOptions{RunID: run.ID})
require.NoError(t, err)
@ -494,7 +494,7 @@ jobs:
require.NoError(t, err)
require.True(t, workflows[0].IncompleteWith) // must be set for this test scenario to be valid
require.NoError(t, InsertRun(t.Context(), runDoesNotNeedApproval, workflows))
require.NoError(t, InsertRunWithoutNotification(t.Context(), runDoesNotNeedApproval, workflows))
jobs, err := db.Find[ActionRunJob](t.Context(), FindRunJobOptions{RunID: runDoesNotNeedApproval.ID})
require.NoError(t, err)
@ -648,7 +648,7 @@ jobs:
jobs, err := jobparser.Parse(workflowRaw, false)
require.NoError(t, err)
require.NoError(t, InsertRun(t.Context(), actionRun, jobs))
require.NoError(t, InsertRunWithoutNotification(t.Context(), actionRun, jobs))
insertedJobs, err := db.Find[ActionRunJob](t.Context(), FindRunJobOptions{RunID: actionRun.ID})
require.NoError(t, err)

View file

@ -1,31 +1,3 @@
-
id: 600
run_id: 900
repo_id: 63
owner_id: 2
commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee
is_fork_pull_request: 0
name: job_1
attempt: 0
job_id: produce-artifacts
task_id: 0
status: 7 # blocked
runs_on: '["fedora"]'
started: 1683636528
needs: '["job1", "job2"]'
workflow_payload: |
"on":
push:
jobs:
produce-artifacts:
name: produce-artifacts
runs-on: docker
steps:
- run: echo "OK!"
strategy:
matrix:
color: red
-
id: 601
run_id: 901

View file

@ -14,8 +14,10 @@ import (
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
"forgejo.org/modules/graceful"
"forgejo.org/modules/json"
"forgejo.org/modules/log"
"forgejo.org/modules/queue"
"forgejo.org/modules/structs"
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
"xorm.io/builder"
@ -76,10 +78,10 @@ func checkJobsOfRun(ctx context.Context, runID int64, recursionCount int) error
updateColumns := []string{"status"}
if status.IsWaiting() {
behaviour, err := tryHandleIncompleteMatrix(ctx, job, jobs)
behaviour, err := prepareJobForEmitting(ctx, job, jobs)
switch behaviour {
case behaviourError:
return fmt.Errorf("error in tryHandleIncompleteMatrix: %w", err)
return fmt.Errorf("error in prepareJobForEmitting: %w", err)
case behaviourExecuteJob:
// Intentional blank case -- proceed with updating the status of the job to waiting.
@ -92,6 +94,10 @@ func checkJobsOfRun(ctx context.Context, runID int64, recursionCount int) error
case behaviourIgnoreAllJobsInRun:
// Stop processing any other jobs in this run.
return nil
case behaviorSkipJob:
// "status" is already a column that we're updating, so we can just switch the target status to skipped.
job.Status = actions_model.StatusSkipped
}
} else if status.IsSuccess() || status.IsFailure() || status.IsSkipped() {
// Transition to these states can be triggered by workflow call outer jobs
@ -116,7 +122,7 @@ func checkJobsOfRun(ctx context.Context, runID int64, recursionCount int) error
CreateCommitStatus(ctx, jobs...)
// tryHandleIncompleteMatrix can create new jobs in this run which may initially be persisted in the DB as blocked
// prepareJobForEmitting can create new jobs in this run which may initially be persisted in the DB as blocked
// because they have non-empty `needs`. In that case, we need to recursively run the job emitter so that new jobs
// are recognized as having their `needs` completed and be set as unblocked. Check if any new jobs were created and
// rerun the job emitter if so. The same is necessary if updates completed jobs that unblocked other jobs.
@ -176,15 +182,12 @@ func (r *jobStatusResolver) Resolve() map[int64]actions_model.Status {
if status != actions_model.StatusBlocked {
continue
}
allDone, allSucceed, allSucceedOrSkip, allSkip := true, true, true, true
allDone, allSucceedOrSkip, allSkip := true, true, true
for _, need := range r.needs[id] {
needStatus := r.statuses[need]
if !needStatus.IsDone() {
allDone = false
}
if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled, actions_model.StatusSkipped) {
allSucceed = false
}
if needStatus.In(actions_model.StatusFailure, actions_model.StatusCancelled) {
allSucceedOrSkip = false
}
@ -205,8 +208,8 @@ func (r *jobStatusResolver) Resolve() map[int64]actions_model.Status {
// inner jobs are done. But if the job is incomplete, that means that the `needs` that were
// required to define the job are done, and now the job can be expanded with the missing values
// that come from `${{ needs... }}`. By putting this job into `Waiting` state, it will go into
// `tryHandleIncompleteMatrix` to be reparsed, replaced with a full job definition, with new
// `needs` that contain its inner jobs:
// `prepareJobForEmitting` to be reparsed, replaced with a full job definition, with new `needs`
// that contain its inner jobs:
ret[id] = actions_model.StatusWaiting
} else if allSkip {
// All of the inner jobs are skipped -- this most likely occurs because an outer job's `if:`
@ -222,25 +225,10 @@ func (r *jobStatusResolver) Resolve() map[int64]actions_model.Status {
ret[id] = actions_model.StatusFailure
}
} else {
if allSucceed {
ret[id] = actions_model.StatusWaiting
} else {
// Check if the job has an "if" condition
hasIf := false
if wfJobs, _ := jobparser.Parse(r.jobMap[id].WorkflowPayload, false); len(wfJobs) == 1 {
_, wfJob := wfJobs[0].Job()
hasIf = len(wfJob.If.Value) > 0
}
if hasIf {
// act_runner will check the "if" condition
ret[id] = actions_model.StatusWaiting
} else {
// If the "if" condition is empty and not all dependent jobs completed successfully,
// the job should be skipped.
ret[id] = actions_model.StatusSkipped
}
}
// All the `needs` of this job are done. We could now go to waiting or skipped, dependent on the `if`
// condition of the new job. Don't evaluate that here -- set it to waiting, and `prepareJobForEmitting`
// will perform a full evaluation of the if clause to determine if it should be skipped.
ret[id] = actions_model.StatusWaiting
}
}
}
@ -261,11 +249,16 @@ const (
// behaviourIgnoreAllJobsInRun indicates that something went wrong and all jobs in the run should now be ignored.
behaviourIgnoreAllJobsInRun
// behaviorSkipJob indicates that the job should be marked as skipped as the 'if' evaluation returned false.
behaviorSkipJob
)
// Invoked once a job has all its `needs` parameters met and is ready to transition to waiting, this may expand the
// job's `strategy.matrix` into multiple new jobs.
func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.ActionRunJob, jobsInRun []*actions_model.ActionRunJob) (behaviour, error) {
// Invoked once a job has all its `needs` parameters met and is ready to transition to waiting. May expand the job's
// `strategy.matrix` into multiple new jobs, may compute a calculated `runs-on` field, may expand reusable workflows,
// and may evaluate `if` to see if the job should be skipped -- all things that could change based upon `${{ needs...
// }}` outputs and results, which are now available.
func prepareJobForEmitting(ctx context.Context, blockedJob *actions_model.ActionRunJob, jobsInRun []*actions_model.ActionRunJob) (behaviour, error) {
incompleteMatrix, _, err := blockedJob.HasIncompleteMatrix()
if err != nil {
return behaviourError, fmt.Errorf("job HasIncompleteMatrix: %w", err)
@ -281,26 +274,22 @@ func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.Ac
return behaviourError, fmt.Errorf("job HasIncompleteWith: %w", err)
}
if !incompleteMatrix && !incompleteRunsOn && !incompleteWith {
// Not relevant to attempt re-parsing the job if it wasn't marked as Incomplete[...] previously.
return behaviourExecuteJob, nil
}
if err := blockedJob.LoadRun(ctx); err != nil {
return behaviourError, fmt.Errorf("failure LoadRun in tryHandleIncompleteMatrix: %w", err)
return behaviourError, fmt.Errorf("failure LoadRun in prepareJobForEmitting: %w", err)
}
// Compute jobOutputs for all the other jobs required as needed by this job:
jobOutputs := make(map[string]map[string]string, len(jobsInRun))
jobResults := make(map[string]string, len(jobsInRun))
for _, job := range jobsInRun {
if !slices.Contains(blockedJob.Needs, job.JobID) {
// Only include jobs that are in the `needs` of the blocked job.
continue
} else if !job.Status.IsDone() {
// Unexpected: `job` is needed by `blockedJob` but it isn't done; `jobStatusResolver` shouldn't be calling
// `tryHandleIncompleteMatrix` in this case.
// `prepareJobForEmitting` in this case.
return behaviourError, fmt.Errorf(
"jobStatusResolver attempted to tryHandleIncompleteMatrix for a job (id=%d) with an incomplete 'needs' job (id=%d)", blockedJob.ID, job.ID)
"jobStatusResolver attempted to prepareJobForEmitting for a job (id=%d) with an incomplete 'needs' job (id=%d)", blockedJob.ID, job.ID)
}
outputs, err := actions_model.FindTaskOutputByTaskID(ctx, job.TaskID)
@ -313,6 +302,12 @@ func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.Ac
outputsMap[v.OutputKey] = v.OutputValue
}
jobOutputs[job.JobID] = outputsMap
jobResults[job.JobID] = job.Status.String()
}
vars, err := actions_model.GetVariablesOfRun(ctx, blockedJob.Run)
if err != nil {
return behaviourError, fmt.Errorf("failure GetVariablesOfRun in prepareJobForEmitting: %w", err)
}
// Re-parse the blocked job, providing all the other completed jobs' outputs, to turn this incomplete job into
@ -321,10 +316,14 @@ func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.Ac
defer expandCleanup()
newJobWorkflows, err := jobparser.Parse(blockedJob.WorkflowPayload, false,
jobparser.WithJobOutputs(jobOutputs),
jobparser.WithJobResults(jobResults),
jobparser.WithWorkflowNeeds(blockedJob.Needs),
jobparser.SupportIncompleteRunsOn(),
jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflow),
jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)),
jobparser.WithVars(vars),
jobparser.WithInputs(getRunInputs(blockedJob.Run)),
jobparser.WithGitContext(generateGiteaContextForRun(blockedJob.Run)),
)
if err != nil {
// Reparsing errors are quite rare here since we were already able to parse this workflow in the past to
@ -341,6 +340,31 @@ func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.Ac
return behaviourIgnoreAllJobsInRun, nil
}
if !incompleteMatrix && !incompleteRunsOn && !incompleteWith {
// We did not need to reparse this job in order to expand it into new workflows. But, as we've provided all the
// necessary parsing context to the job, we can use this opportunity to perform an evaluation of the job's 'if'
// clause within the server, and we might be able to skip this job.
if len(newJobWorkflows) != 1 {
// This case shouldn't happen, but we'll ignore our attempt to evaluate the if block:
return behaviourExecuteJob, nil
}
swf := newJobWorkflows[0]
_, job := swf.Job()
ifPassed, err := job.EvaluateIf()
if errors.Is(err, jobparser.ErrCannotEvaluateInJobParser) {
// Fallback to sending the job to a runner.
return behaviourExecuteJob, nil
} else if err != nil {
return behaviourError, err
} else if !ifPassed {
return behaviorSkipJob, nil
}
return behaviourExecuteJob, nil
}
// Even though every job in the `needs` list is done, perform a consistency check if the job was still unable to be
// evaluated into a fully complete job with the correct matrix and runs-on values. Evaluation errors here need to be
// reported back to the user for them to correct their workflow, so we slip this notification into
@ -392,8 +416,7 @@ func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.Ac
}
err = db.WithTx(ctx, func(ctx context.Context) error {
err := actions_model.InsertRunJobs(ctx, blockedJob.Run, newJobWorkflows)
if err != nil {
if err := actions_model.InsertRunJobs(ctx, blockedJob.Run, newJobWorkflows); err != nil {
return fmt.Errorf("failure in InsertRunJobs: %w", err)
}
@ -613,3 +636,18 @@ func tryHandleWorkflowCallOuterJob(ctx context.Context, job *actions_model.Actio
job.TaskID = actionTask.ID
return []string{"task_id", "attempt"}, nil
}
func getRunInputs(run *actions_model.ActionRun) map[string]any {
// workflow_dispatch inputs are stored in the event payload
var dispatchPayload *structs.WorkflowDispatchPayload
err := json.Unmarshal([]byte(run.EventPayload), &dispatchPayload)
if err != nil {
return nil
}
// transition from map[string]string to map[string]any...
inputs := make(map[string]any, len(dispatchPayload.Inputs))
for k, v := range dispatchPayload.Inputs {
inputs[k] = v
}
return inputs
}

View file

@ -69,8 +69,8 @@ func Test_jobStatusResolver_Resolve(t *testing.T) {
},
want: map[int64]actions_model.Status{
// Resolve() does only one update pass and does not update jobs recursively. Therefore, job 3, which
// depends on 2, is not marked as skipped. It would only be marked as skipped if it depended on job 1.
2: actions_model.StatusSkipped,
// depends on 2, is not marked as waiting. It would only be marked as waiting if it depended on job 1.
2: actions_model.StatusWaiting,
},
},
{
@ -136,7 +136,9 @@ jobs:
- run: echo "should be skipped"
`)},
},
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
// Status goes to waiting for `prepareJobForEmitting` to evaluate `if`, even in default condition, so one
// codepath always handles this consistently.
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
},
{
name: "unblocked workflow call outer job with success",
@ -379,7 +381,7 @@ func (m *mockNotifier) ActionRunNowDone(ctx context.Context, run *actions_model.
m.calls = append(m.calls, &callArgsActionRunNowDone{run, priorStatus, lastRun})
}
func Test_tryHandleIncompleteMatrix(t *testing.T) {
func Test_prepareJobForEmitting(t *testing.T) {
// Shouldn't get any decoding errors during this test -- pop them up from a log warning to a test fatal error.
defer test.MockVariableValue(&model.OnDecodeNodeError, func(node yaml.Node, out any, err error) {
t.Fatalf("Failed to decode node %v into %T: %v", node, out, err)
@ -405,10 +407,6 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) {
localReusableWorkflowCallArgs *localReusableWorkflowCallArgs
actionRunStatusChange actions_model.Status
}{
{
name: "not incomplete",
runJobID: 600,
},
{
name: "matrix expanded to 3 new jobs",
runJobID: 601,
@ -424,7 +422,7 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) {
{
name: "needs an incomplete job",
runJobID: 603,
errContains: "jobStatusResolver attempted to tryHandleIncompleteMatrix for a job (id=603) with an incomplete 'needs' job (id=604)",
errContains: "jobStatusResolver attempted to prepareJobForEmitting for a job (id=603) with an incomplete 'needs' job (id=604)",
},
{
name: "missing needs for strategy.matrix evaluation",
@ -665,7 +663,7 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer unittest.OverrideFixtures("services/actions/Test_tryHandleIncompleteMatrix")()
defer unittest.OverrideFixtures("services/actions/Test_prepareJobForEmitting")()
require.NoError(t, unittest.PrepareTestDatabase())
notifier := &mockNotifier{}
@ -706,7 +704,7 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) {
jobsInRun, err := db.Find[actions_model.ActionRunJob](t.Context(), actions_model.FindRunJobOptions{RunID: blockedJob.RunID})
require.NoError(t, err)
behaviour, err := tryHandleIncompleteMatrix(t.Context(), blockedJob, jobsInRun)
behaviour, err := prepareJobForEmitting(t.Context(), blockedJob, jobsInRun)
if tt.errContains != "" {
require.ErrorContains(t, err, tt.errContains)
@ -887,7 +885,7 @@ on:
inputs:
argument:
type: string
jobs:
reusable:
runs-on: ubuntu-latest

View file

@ -6,8 +6,10 @@ package actions
import (
"context"
"errors"
"fmt"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
issues_model "forgejo.org/models/issues"
packages_model "forgejo.org/models/packages"
perm_model "forgejo.org/models/perm"
@ -25,6 +27,7 @@ import (
"forgejo.org/services/convert"
notify_service "forgejo.org/services/notify"
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
"xorm.io/builder"
)
@ -826,6 +829,42 @@ func sendActionRunNowDoneNotificationIfNeeded(ctx context.Context, priorRun, upd
return nil
}
// Insert a new run, and all its jobs, into the database. In the event that all the `if` clauses of the jobs are
// evaluated at this stage and are `false`,
func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobparser.SingleWorkflow) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := actions_model.InsertRunWithoutNotification(ctx, run, jobs); err != nil {
return fmt.Errorf("InsertRunWithoutNotification: %w", err)
}
// Normally the status of a job is input to InsertRun as Waiting, and remains that way. But InsertRunJobs can
// evaluate the 'if' clauses of each job, and if every job is skipped then the job status needs to be updated.
// ComputeRunStatus queries for the runs that we already have in-memory, so first do a quick check, then rely on
// that reusable code if needed.
columns, err := actions_model.ComputeExistingRunStatus(ctx, run)
if err != nil {
return fmt.Errorf("compute run status: %w", err)
}
if len(columns) != 0 {
if err := UpdateRun(ctx, run, columns...); err != nil {
return fmt.Errorf("update run: %w", err)
}
}
// Some jobs might have been been immediately set to Skipped when they were inserted. Other jobs may be
// dependent on those skipped jobs. While we're still in this transaction and before these jobs are visible,
// run the job emitter which can recursively evaluate this state and update dependent runs status to either
// skipped or waiting, depending on their 'if':
if !run.NeedApproval { // don't unblock jobs if the run needs approval
if err := checkJobsOfRun(ctx, run.ID, 0); err != nil {
return fmt.Errorf("check jobs of run: %w", err)
}
}
return nil
})
}
// wrapper of UpdateRunWithoutNotification with a call to the ActionRunNowDone notification channel
func UpdateRun(ctx context.Context, run *actions_model.ActionRun, cols ...string) error {
// run.ID is the only thing that must be given

View file

@ -424,6 +424,7 @@ func handleWorkflows(
jobparser.SupportIncompleteRunsOn(),
jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflows(commit)),
jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)),
jobparser.WithGitContext(generateGiteaContextForRun(run)),
)
if err != nil {
log.Info("jobparser.Parse: invalid workflow, setting job status to failed: %v", err)
@ -449,11 +450,13 @@ func handleWorkflows(
err = db.WithTx(ctx, func(ctx context.Context) error {
// Transaction avoids any chance of a run being picked up in a Waiting state when we're about to put it into
// a PreExecutionError a millisecond later.
if err := actions_model.InsertRun(ctx, run, jobs); err != nil {
return err
if err := InsertRun(ctx, run, jobs); err != nil {
return fmt.Errorf("InsertRun: %w", err)
}
if errorCode != 0 {
return FailRunPreExecutionError(ctx, run, errorCode, errorDetails)
if err := FailRunPreExecutionError(ctx, run, errorCode, errorDetails); err != nil {
return fmt.Errorf("FailRunPreExecutionError: %w", err)
}
}
return nil
})

View file

@ -183,13 +183,14 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
jobparser.SupportIncompleteRunsOn(),
jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflow),
jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)),
jobparser.WithGitContext(generateGiteaContextForRun(run)),
)
if err != nil {
return err
}
// Insert the action run and its associated jobs into the database
if err := actions_model.InsertRun(ctx, run, workflows); err != nil {
if err := InsertRun(ctx, run, workflows); err != nil {
return err
}

View file

@ -11,8 +11,6 @@ import (
actions_model "forgejo.org/models/actions"
secret_model "forgejo.org/models/secret"
actions_module "forgejo.org/modules/actions"
"forgejo.org/modules/json"
"forgejo.org/modules/structs"
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
)
@ -116,17 +114,7 @@ func getSecretsOfInnerWorkflowCall(ctx context.Context, job *actions_model.Actio
var inputs map[string]any
if outerWorkflowCall.Run.TriggerEvent == actions_module.GithubEventWorkflowDispatch {
// workflow_dispatch inputs are stored in the event payload
var dispatchPayload *structs.WorkflowDispatchPayload
err := json.Unmarshal([]byte(outerWorkflowCall.Run.EventPayload), &dispatchPayload)
if err != nil {
return nil, fmt.Errorf("failure reading workflow dispatch payload: %w", err)
}
// transition from map[string]string to map[string]any...
inputs = make(map[string]any, len(dispatchPayload.Inputs))
for k, v := range dispatchPayload.Inputs {
inputs[k] = v
}
inputs = getRunInputs(outerWorkflowCall.Run)
}
jobSecrets := jobparser.EvaluateWorkflowCallSecrets(&jobparser.EvaluateWorkflowCallSecretsArgs{

View file

@ -28,21 +28,21 @@ func TestActionsTrust_ChangeStatus(t *testing.T) {
PullRequestPosterID: pullRequestPosterID,
Status: actions_model.StatusSuccess,
}
require.NoError(t, actions_model.InsertRun(t.Context(), runDone, nil))
require.NoError(t, actions_model.InsertRunWithoutNotification(t.Context(), runDone, nil))
runNotByPoster := &actions_model.ActionRun{
RepoID: repoID,
PullRequestPosterID: 43243,
Status: actions_model.StatusRunning,
}
require.NoError(t, actions_model.InsertRun(t.Context(), runNotByPoster, nil))
require.NoError(t, actions_model.InsertRunWithoutNotification(t.Context(), runNotByPoster, nil))
runNotInTheSameRepository := &actions_model.ActionRun{
RepoID: 5,
PullRequestPosterID: pullRequestPosterID,
Status: actions_model.StatusSuccess,
}
require.NoError(t, actions_model.InsertRun(t.Context(), runNotInTheSameRepository, nil))
require.NoError(t, actions_model.InsertRunWithoutNotification(t.Context(), runNotInTheSameRepository, nil))
t.Run("RevokeTrust", func(t *testing.T) {
singleWorkflows, err := actions_module.JobParser([]byte(`
@ -60,7 +60,7 @@ jobs:
Status: actions_model.StatusWaiting,
PullRequestPosterID: pullRequestPosterID,
}
require.NoError(t, actions_model.InsertRun(t.Context(), runNotDone, singleWorkflows))
require.NoError(t, actions_model.InsertRunWithoutNotification(t.Context(), runNotDone, singleWorkflows))
require.NoError(t, actions_model.InsertActionUser(t.Context(), &actions_model.ActionUser{
UserID: pullRequestPosterID,
RepoID: repoID,
@ -100,7 +100,7 @@ jobs:
PullRequestID: pullRequestID,
PullRequestPosterID: pullRequestPosterID,
}
require.NoError(t, actions_model.InsertRun(t.Context(), runNotApproved, singleWorkflows))
require.NoError(t, actions_model.InsertRunWithoutNotification(t.Context(), runNotApproved, singleWorkflows))
return runNotApproved
}

View file

@ -191,12 +191,13 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette
jobparser.SupportIncompleteRunsOn(),
jobparser.ExpandLocalReusableWorkflows(expandLocalReusableWorkflows(entry.Commit)),
jobparser.ExpandInstanceReusableWorkflows(expandInstanceReusableWorkflows(ctx)),
jobparser.WithGitContext(generateGiteaContextForRun(run)),
)
if err != nil {
return nil, nil, err
}
if err := actions_model.InsertRun(ctx, run, jobs); err != nil {
if err := InsertRun(ctx, run, jobs); err != nil {
return run, jobNames, err
}

2846
services/notify/mocks.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,8 @@ import (
)
// Notifier defines an interface to notify receiver
//
//mockery:generate: true
type Notifier interface {
Run()

View file

@ -142,7 +142,7 @@ jobs:
Status: actions_model.StatusWaiting,
PullRequestPosterID: pullRequestPosterID,
}
require.NoError(t, actions_model.InsertRun(t.Context(), runWaiting, singleWorkflows))
require.NoError(t, actions_model.InsertRunWithoutNotification(t.Context(), runWaiting, singleWorkflows))
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runWaiting.ID})
require.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())

View file

@ -4,11 +4,13 @@
package integration
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"reflect"
"slices"
"strconv"
"testing"
"time"
@ -22,11 +24,16 @@ import (
"forgejo.org/modules/json"
"forgejo.org/modules/setting"
api "forgejo.org/modules/structs"
"forgejo.org/modules/webhook"
actions_service "forgejo.org/services/actions"
notify_service "forgejo.org/services/notify"
"forgejo.org/tests"
runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1"
"connectrpc.com/connect"
gouuid "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@ -791,3 +798,769 @@ func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string,
}
return ""
}
func TestActionsRunsEvaluateIf(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
onApplicationRun(t, func(t *testing.T, u *url.URL) {
t.Run("skip all jobs instantly", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchSingleJob("${{ 'abc' == 'def' }}").ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
arif.assertNoRunnableJobs()
assert.Equal(t, actions_model.StatusSkipped, run.Status)
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID})
assert.Equal(t, actions_model.StatusSkipped, job.Status)
})
t.Run("skip entire run fires notifier", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
notifier := notify_service.NewMockNotifier(t)
notifier.On("Run").Return()
notifier.On("PushCommits", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return()
notifier.On("ActionRunNowDone",
mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
mock.MatchedBy(func(run *actions_model.ActionRun) bool {
return run.Title == ".forgejo/workflows/serverside_if.yml" && run.Status == actions_model.StatusSkipped
}),
actions_model.StatusWaiting, // priorStatus
mock.MatchedBy(func(lastRun *actions_model.ActionRun) bool { return lastRun == nil })).
Return()
notify_service.RegisterNotifier(notifier)
defer notify_service.UnregisterNotifier(notifier)
arif.dispatchSingleJob("${{ 'abc' == 'def' }}")
arif.assertNoRunnableJobs()
})
t.Run("skip single job instantly", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
arif.dispatchMultipleJobs("${{ 'abc' == 'abc' }}", "${{ 'abc' == 'def' }}")
task := arif.mockRunTask()
arif.assertNoRunnableJobs() // just one runnable job
actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
actionRunJob1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID})
assert.Equal(t, "test-1", actionRunJob1.Name)
assert.Equal(t, actions_model.StatusSuccess, actionRunJob1.Status)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob1.RunID})
assert.Equal(t, actions_model.StatusSuccess, run.Status)
actionRunJob2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, actionRunJob2.Status)
})
t.Run("if clause needs another job, then is skipped", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchDependentJob().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // `if` clause contains `needs`, can't be evaluated at schedule-time, still gets blocked
arif.mockRunTask()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusSuccess, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, job2.Status) // `if` clause contains `needs`, `if` is false, it is skipped
arif.assertNoRunnableJobs()
})
t.Run("if clause needs another job, then is run", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchDependentJob().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // `if` clause contains `needs`, can't be evaluated at schedule-time, still gets blocked
arif.mockRunTaskAndFail()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusFailure, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusWaiting, job2.Status) // `if` clause contains `needs`, 'if' is true, goes to waiting
arif.mockRunTask()
})
t.Run("if clause default / success() skips automatically when needed job fails", func(t *testing.T) {
test := func(t *testing.T, dispatch func(arif *ActionsRunIfTester) int64) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := dispatch(arif)
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // job is blocked by 'needs'
arif.mockRunTaskAndFail()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusFailure, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, job2.Status) // `if` defaults to success(), now gets skipped due to failure
arif.assertNoRunnableJobs()
}
t.Run("default", func(t *testing.T) {
test(t, func(arif *ActionsRunIfTester) int64 {
return arif.dispatchDependentJobDefaultIf().ID
})
})
t.Run("explicit success()", func(t *testing.T) {
test(t, func(arif *ActionsRunIfTester) int64 {
return arif.dispatchDependentJobIfSuccess().ID
})
})
})
t.Run("if failure() on successful dependency is skipped", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIfFailure().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // job is blocked by 'needs'
arif.mockRunTask()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusSuccess, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, job2.Status) // skipped because dependent job didn't fail
arif.assertNoRunnableJobs()
})
t.Run("if failure() on failed dependency is executed", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIfFailure().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // job is blocked by 'needs'
arif.mockRunTaskAndFail()
job1 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusFailure, job1.Status)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusWaiting, job2.Status) // waiting, dependent job failed
arif.mockRunTask()
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSuccess, job2.Status)
})
t.Run("if always()", func(t *testing.T) {
test := func(t *testing.T, runTask func(arif *ActionsRunIfTester)) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIfAlways().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusBlocked, job2.Status) // job is blocked by 'needs'
runTask(arif)
job2 = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusWaiting, job2.Status) // job can now be run regardless of status of test-1
arif.mockRunTask()
}
t.Run("runs on success", func(t *testing.T) {
test(t, func(arif *ActionsRunIfTester) {
arif.mockRunTask()
})
})
t.Run("runs on failure", func(t *testing.T) {
test(t, func(arif *ActionsRunIfTester) {
arif.mockRunTaskAndFail()
})
})
})
t.Run("if references env", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchSingleJob("${{ env.abc == 'def' }}").ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status) // accessing env can't be evaluated server-side, so is set to waiting
})
t.Run("if references secrets", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchSingleJob("${{ secrets.abc == 'def' }}").ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test"})
assert.Equal(t, actions_model.StatusWaiting, job1.Status) // accessing secrets isn't evaluated server-side, so is set to waiting
})
t.Run("jobs that need other jobs have their if clauses evaluated if unblocked", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIfFalseChain().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSkipped, run.Status)
job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-1"})
assert.Equal(t, actions_model.StatusSkipped, job1.Status)
job2 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-2"})
assert.Equal(t, actions_model.StatusSkipped, job2.Status)
job3 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "test-3"})
assert.Equal(t, actions_model.StatusSkipped, job3.Status)
})
t.Run("access var during evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchForgejoTesting().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
// Theoretically these are the exact right order for these to be executed, but it's possible they get
// inserted into the database with identical creation times and therefore could have indeterminate sorting.
// So the test case here is order-flexible.
expectedJobs := []string{"backend-checks", "frontend-checks", "test-unit", "test-pgsql", "test-sqlite", "security-check", "semgrep"}
for len(expectedJobs) > 0 {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
idx := slices.Index(expectedJobs, dbTask.Job.Name)
require.NotEqual(t, -1, idx, "could not find job %s in expectedJobs", dbTask.Job.Name)
expectedJobs = append(expectedJobs[:idx], expectedJobs[idx+1:]...)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("access input during evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchInputConditional().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
for _, expected := range []string{"test-1", "test-2"} {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, expected, dbTask.Job.Name)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("access forgejo context during workflow_dispatch evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchForgejoConditional().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
for _, expected := range []string{"test-1", "test-2"} {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, expected, dbTask.Job.Name)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("access forgejo context during scheduled evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.scheduleForgejoConditional().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
for _, expected := range []string{"test-1", "test-2"} {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, expected, dbTask.Job.Name)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("access forgejo context during event evaluation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.eventForgejoConditional().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusWaiting.String(), run.Status.String())
for _, expected := range []string{"test-1", "test-2"} {
task := arif.mockRunTask()
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, expected, dbTask.Job.Name)
}
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
})
}
type ActionsRunIfTester struct {
t *testing.T
user *user_model.User
session *TestSession
apiToken string
apiRepo *api.Repository
runner *mockRunner
}
func newActionsRunIfTester(t *testing.T) *ActionsRunIfTester {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
testRepository := createActionsTestRepo(t, token, fmt.Sprintf("actions-runs-on-vars-%s", gouuid.New().String()), false)
MakeRequest(t,
NewRequestWithJSON(t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/variables/ROLE", user2.Name, testRepository.Name),
api.CreateVariableOption{Value: "forgejo-coding"}).AddTokenAuth(token),
http.StatusNoContent)
runner := newMockRunner()
runner.registerAsRepoRunner(t, user2.Name, testRepository.Name, "ubuntu-runner", []string{"ubuntu"})
return &ActionsRunIfTester{
t: t,
user: user2,
session: session,
apiToken: token,
apiRepo: testRepository,
runner: runner,
}
}
func (tester *ActionsRunIfTester) dispatch(workflow string) *api.DispatchWorkflowRun {
workflowPath := ".forgejo/workflows/serverside_if.yml"
options := getWorkflowCreateFileOptions(tester.user, tester.apiRepo.DefaultBranch, fmt.Sprintf("create %s", workflowPath), workflow)
createWorkflowFile(tester.t, tester.apiToken, tester.user.Name, tester.apiRepo.Name, workflowPath, options)
dispatchRequest := NewRequestWithJSON(tester.t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/serverside_if.yml/dispatches", tester.user.Name, tester.apiRepo.Name),
&api.DispatchWorkflowOption{
Ref: tester.apiRepo.DefaultBranch,
ReturnRunInfo: true,
Inputs: map[string]string{
"input_1": "input_1 value",
},
}).
AddTokenAuth(tester.apiToken)
resp := MakeRequest(tester.t, dispatchRequest, http.StatusCreated)
run := &api.DispatchWorkflowRun{}
DecodeJSON(tester.t, resp, run)
return run
}
func (tester *ActionsRunIfTester) pushEvent(workflow string) *actions_model.ActionRun {
workflowPath := ".forgejo/workflows/serverside_if.yml"
options := getWorkflowCreateFileOptions(tester.user, tester.apiRepo.DefaultBranch, fmt.Sprintf("create %s", workflowPath), workflow)
resp := createWorkflowFile(tester.t, tester.apiToken, tester.user.Name, tester.apiRepo.Name, workflowPath, options)
return unittest.AssertExistsAndLoadBean(tester.t, &actions_model.ActionRun{CommitSHA: resp.Commit.SHA})
}
func (tester *ActionsRunIfTester) forceSchedule(workflow string) *actions_model.ActionRun {
workflowPath := ".forgejo/workflows/serverside_if.yml"
options := getWorkflowCreateFileOptions(tester.user, tester.apiRepo.DefaultBranch, fmt.Sprintf("create %s", workflowPath), workflow)
resp := createWorkflowFile(tester.t, tester.apiToken, tester.user.Name, tester.apiRepo.Name, workflowPath, options)
payload := &api.SchedulePayload{
Action: api.HookScheduleCreated,
}
p, err := json.Marshal(payload)
require.NoError(tester.t, err)
require.NoError(tester.t, actions_service.CreateScheduleTask(tester.t.Context(),
&actions_model.ActionSchedule{
RepoID: tester.apiRepo.ID,
OwnerID: tester.user.ID,
WorkflowID: "serverside_if.yml",
WorkflowDirectory: ".forgejo/workflows",
TriggerUserID: tester.user.ID,
Ref: "refs/heads/main",
CommitSHA: resp.Commit.SHA,
Event: webhook.HookEventSchedule,
EventPayload: string(p),
Content: []byte(workflow),
}))
return unittest.AssertExistsAndLoadBean(tester.t, &actions_model.ActionRun{CommitSHA: resp.Commit.SHA})
}
func (tester *ActionsRunIfTester) dispatchSingleJob(ifClause string) *api.DispatchWorkflowRun {
return tester.dispatch(fmt.Sprintf(`
on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu
if: %s
steps:
- run: echo "Job contents go here."
`, ifClause))
}
func (tester *ActionsRunIfTester) dispatchMultipleJobs(ifClause1, ifClause2 string) *api.DispatchWorkflowRun {
return tester.dispatch(fmt.Sprintf(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
if: %s
steps:
- run: echo "Job contents go here."
test-2:
runs-on: ubuntu
if: %s
steps:
- run: echo "Job contents go here."
`, ifClause1, ifClause2))
}
func (tester *ActionsRunIfTester) dispatchDependentJob() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
runs-on: ubuntu
if: ${{ needs.test-1.result == 'failure' }}
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchDependentJobDefaultIf() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchDependentJobIfSuccess() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: success()
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIfFailure() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: failure()
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIfAlways() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: always()
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIfFalseChain() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: false
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: false
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-3:
needs: [test-2]
if: false
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchForgejoTesting() *api.DispatchWorkflowRun {
// Trimmed down copy of `.forgejo/workflows/testing.yml` to create a more complex & realistic `needs` tree with `if`
// conditions on every job:
return tester.dispatch(`
name: testing
enable-email-notifications: true
on:
workflow_dispatch:
jobs:
backend-checks:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
steps:
- run: echo "backend-checks job"
frontend-checks:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
steps:
- run: echo "frontend-checks job"
test-unit:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
needs: [backend-checks, frontend-checks]
steps:
- run: echo "test-unit job"
test-pgsql:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
needs: [backend-checks, frontend-checks]
steps:
- run: echo "test-pgsql job"
test-sqlite:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
needs: [backend-checks, frontend-checks]
steps:
- run: echo "test-sqlite job"
security-check:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
needs:
- test-sqlite
- test-unit
steps:
- run: echo "security-check job"
semgrep:
if: vars.ROLE == 'forgejo-coding' || vars.ROLE == 'forgejo-testing'
runs-on: ubuntu
steps:
- run: echo "semgrep job"
`)
}
func (tester *ActionsRunIfTester) dispatchInputConditional() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
inputs:
input_1:
type: string
jobs:
test-1:
if: ${{ inputs.input_1 == 'input_1 value' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: ${{ inputs.input_1 == 'input_1 value' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchForgejoConditional() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: ${{ forgejo.event_name == 'workflow_dispatch' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: ${{ forgejo.event_name == 'workflow_dispatch' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) eventForgejoConditional() *actions_model.ActionRun {
return tester.pushEvent(`
on:
push:
jobs:
test-1:
if: ${{ forgejo.event_name == 'push' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: ${{ forgejo.event_name == 'push' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) scheduleForgejoConditional() *actions_model.ActionRun {
return tester.forceSchedule(`
on:
schedule:
jobs:
test-1:
if: ${{ forgejo.repository_owner == 'user2' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
if: ${{ forgejo.repository_owner == 'user2' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) assertNoRunnableJobs() {
assert.Nil(tester.t, tester.runner.maybeFetchTask(tester.t))
}
func (tester *ActionsRunIfTester) mockRunTask() *runnerv1.Task {
task := tester.runner.fetchTask(tester.t)
tester.runner.execTask(tester.t, task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
})
return task
}
func (tester *ActionsRunIfTester) mockRunTaskAndFail() *runnerv1.Task {
task := tester.runner.fetchTask(tester.t)
tester.runner.execTask(tester.t, task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_FAILURE,
})
return task
}

View file

@ -148,7 +148,7 @@ func actionsTrustTestRequireRun(t *testing.T, repo *repo_model.Repository, modif
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, CommitSHA: modifiedFiles.Commit.SHA})
require.Equal(t, actions_module.GithubEventPullRequest, actionRun.TriggerEvent)
require.Equal(t, actions_model.StatusWaiting.String(), actionRun.Status.String())
require.Equal(t, actions_model.StatusBlocked.String(), actionRun.Status.String())
unittest.BeanExists(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: repo.ID})
}
@ -625,7 +625,7 @@ func TestActionsPullRequestTrustPushCancel(t *testing.T) {
{
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: addFileToForkedResp.Commit.SHA})
assert.True(t, actionRun.NeedApproval)
assert.Equal(t, actions_model.StatusWaiting, actionRun.Status)
assert.Equal(t, actions_model.StatusBlocked, actionRun.Status)
actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: actionRun.ID, RepoID: baseRepo.ID})
assert.Equal(t, actions_model.StatusBlocked, actionRunJob.Status)
}
@ -645,7 +645,7 @@ func TestActionsPullRequestTrustPushCancel(t *testing.T) {
otherActionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID, CommitSHA: otherFileInForkResp.Commit.SHA})
assert.True(t, otherActionRun.NeedApproval)
assert.Equal(t, actions_model.StatusWaiting, otherActionRun.Status)
assert.Equal(t, actions_model.StatusBlocked, otherActionRun.Status)
otherActionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: otherActionRun.ID, RepoID: baseRepo.ID})
assert.Equal(t, actions_model.StatusBlocked, otherActionRunJob.Status)
}