mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-06-22 10:02:15 +00:00
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:
parent
b29e21a90c
commit
6b3359f016
19 changed files with 3827 additions and 157 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
2846
services/notify/mocks.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,8 @@ import (
|
|||
)
|
||||
|
||||
// Notifier defines an interface to notify receiver
|
||||
//
|
||||
//mockery:generate: true
|
||||
type Notifier interface {
|
||||
Run()
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue