diff --git a/models/actions/run.go b/models/actions/run.go index a9c458c86d..fbbc5f8f01 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -255,19 +255,33 @@ func (run *ActionRun) IsDispatchedRun() bool { return run.TriggerEvent == "workflow_dispatch" } -// IsRunnable indicates whether this ActionRun can generally be run. -func (run *ActionRun) IsRunnable() bool { +// IsValid indicates whether this ActionRun is valid and can be run. +func (run *ActionRun) IsValid() bool { return run.PreExecutionErrorCode == 0 && run.PreExecutionError == "" } // CanBeRerun indicates whether this ActionRun can be rerun. func (run *ActionRun) CanBeRerun() bool { - if !run.IsRunnable() { + if !run.IsValid() { return false } return run.Status.IsDone() } +func (run *ActionRun) PrepareNextAttempt() error { + if run.Status != StatusUnknown && !run.Status.IsDone() { + return fmt.Errorf("cannot prepare next attempt because run %d is active: %s", run.ID, run.Status.String()) + } + + run.PreviousDuration = run.Duration() + + run.Status = StatusWaiting + run.Started = 0 + run.Stopped = 0 + + return nil +} + func actionsCountOpenCacheKey(repoID int64) string { return fmt.Sprintf("Actions:CountOpenActionRuns:%d", repoID) } diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 5d3a8aad80..0967ab87c3 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -141,12 +141,16 @@ func (job *ActionRunJob) PrepareNextAttempt(initialStatus Status) error { } // CanBeRerun answers whether this ActionRunJob can be rerun. Returns true if it is done and the Run it belongs to -// is runnable. Returns false in all other cases, including when Run is nil. -func (job *ActionRunJob) CanBeRerun() bool { - if job.Run == nil || !job.Run.IsRunnable() { - return false +// is valid. Returns false in all other cases. +func (job *ActionRunJob) CanBeRerun(ctx context.Context) (bool, error) { + if err := job.LoadRun(ctx); err != nil { + return false, fmt.Errorf("cannot load run %d of job %d: %w", job.RunID, job.ID, err) } - return job.Status.IsDone() + + if !job.Run.IsValid() { + return false, nil + } + return job.Status.IsDone(), nil } func GetRunJobByID(ctx context.Context, id int64) (*ActionRunJob, error) { diff --git a/models/actions/run_job_test.go b/models/actions/run_job_test.go index 6753fb0315..d9275a81ec 100644 --- a/models/actions/run_job_test.go +++ b/models/actions/run_job_test.go @@ -428,9 +428,10 @@ func TestAllNeedsExist(t *testing.T) { func TestActionRunJob_CanBeRerun(t *testing.T) { testCases := []struct { - name string - job ActionRunJob - canBeRerun bool + name string + job ActionRunJob + canBeRerun bool + expectedError string }{ { name: "job with unknown status", @@ -468,9 +469,9 @@ func TestActionRunJob_CanBeRerun(t *testing.T) { canBeRerun: false, }, { - name: "ActionRun is nil", - job: ActionRunJob{Run: nil, Status: StatusSuccess}, - canBeRerun: false, + name: "ActionRun is nil", + job: ActionRunJob{ID: 12, Run: nil, Status: StatusSuccess}, + expectedError: "cannot load run 0 of job 12", }, { name: "with busy run but completed job", @@ -489,7 +490,15 @@ func TestActionRunJob_CanBeRerun(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - assert.Equal(t, testCase.canBeRerun, testCase.job.CanBeRerun()) + result, err := testCase.job.CanBeRerun(t.Context()) + + if testCase.expectedError == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, testCase.expectedError) + } + + assert.Equal(t, testCase.canBeRerun, result) }) } } diff --git a/models/actions/run_test.go b/models/actions/run_test.go index 9a5eb99537..959753107b 100644 --- a/models/actions/run_test.go +++ b/models/actions/run_test.go @@ -96,27 +96,27 @@ func TestIsManualRun(t *testing.T) { assert.False(t, pushRun.IsDispatchedRun()) } -func TestActionRun_IsRunnable(t *testing.T) { +func TestActionRun_IsValid(t *testing.T) { testCases := []struct { - name string - run ActionRun - isRunnable bool + name string + run ActionRun + isValid bool }{ { - name: "valid run", - run: ActionRun{}, - isRunnable: true, + name: "valid run", + run: ActionRun{}, + isValid: true, }, { - name: "with pre-execution error", - run: ActionRun{PreExecutionErrorCode: ErrorCodeIncompleteRunsOnMissingOutput}, - isRunnable: false, + name: "with pre-execution error", + run: ActionRun{PreExecutionErrorCode: ErrorCodeIncompleteRunsOnMissingOutput}, + isValid: false, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - assert.Equal(t, testCase.isRunnable, testCase.run.IsRunnable()) + assert.Equal(t, testCase.isValid, testCase.run.IsValid()) }) } } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 63db9d0997..b1b7b5f2df 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -314,11 +314,16 @@ func getViewResponse(ctx *app_context.Context, req *ViewRequest, runIndex, jobIn // Ah, another job is still running. Keep the frontend polling enabled then. done = false } + canBeRerun, err := v.CanBeRerun(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return nil + } resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{ ID: v.ID, Name: v.Name, Status: v.Status.String(), - CanRerun: v.CanBeRerun() && ctx.Repo.CanWrite(unit.TypeActions), + CanRerun: canBeRerun && ctx.Repo.CanWrite(unit.TypeActions), Duration: v.Duration().String(), }) } @@ -495,120 +500,48 @@ func Rerun(ctx *app_context.Context) { ctx.Error(http.StatusInternalServerError, err.Error()) return } - if jobIndexStr == "" && !run.CanBeRerun() { - ctx.JSONError(ctx.Locale.Tr("actions.workflow.rerun_impossible")) - return - } - // can not rerun job when workflow is disabled - cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) - cfg := cfgUnit.ActionsConfig() - if cfg.IsWorkflowDisabled(run.WorkflowID) { - ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled")) - return - } - - // reset run's start and stop time when it is done - if run.Status.IsDone() { - run.PreviousDuration = run.Duration() - run.Started = 0 - run.Stopped = 0 - if err := actions_service.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + var rerunJobs []*actions_model.ActionRunJob + if jobIndexStr == "" { // Rerun the entire workflow. + rerunJobs, err = actions_service.RerunAllJobs(ctx, run) + } else { // Rerun a single job + job, _ := getRunJobs(ctx, runIndex, jobIndex) + if ctx.Written() { return } + rerunJobs, err = actions_service.RerunJob(ctx, job) } - job, jobs := getRunJobs(ctx, runIndex, jobIndex) - if ctx.Written() { - return - } - - if jobIndexStr == "" { // rerun all jobs - var redirectURL string - for _, j := range jobs { - if !j.CanBeRerun() { - ctx.JSONError(ctx.Locale.Tr("actions.workflow.job_rerun_impossible")) - return - } - - // if the job has needs, it should be set to "blocked" status to wait for other jobs - shouldBlock := len(j.Needs) > 0 - if err := rerunJob(ctx, j, shouldBlock); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - if redirectURL == "" { - redirectURL, err = j.HTMLURL(ctx) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - } + if err != nil { + if errors.Is(err, actions_service.ErrRerunWorkflowInvalid) || + errors.Is(err, actions_service.ErrRerunWorkflowStillRunning) { + ctx.JSONError(ctx.Locale.Tr("actions.workflow.rerun_impossible")) + return } - - if redirectURL != "" { - ctx.JSON(http.StatusOK, &redirectObject{Redirect: redirectURL}) - } else { - ctx.Error(http.StatusInternalServerError, "unable to determine redirectURL for job rerun") + if errors.Is(err, actions_service.ErrRerunWorkflowDisabled) { + ctx.JSONError(ctx.Locale.Tr("actions.workflow.disabled")) + return } - return - } - - rerunJobs := actions_service.GetAllRerunJobs(job, jobs) - - var redirectURL string - for _, j := range rerunJobs { - if !j.CanBeRerun() { + if errors.Is(err, actions_service.ErrRerunJobStillRunning) { ctx.JSONError(ctx.Locale.Tr("actions.workflow.job_rerun_impossible")) return } - - // jobs other than the specified one should be set to "blocked" status - shouldBlock := j.JobID != job.JobID - if err := rerunJob(ctx, j, shouldBlock); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - if j.JobID == job.JobID { - redirectURL, err = j.HTMLURL(ctx) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - } + ctx.Error(http.StatusInternalServerError, err.Error()) + return } - if redirectURL != "" { - ctx.JSON(http.StatusOK, &redirectObject{Redirect: redirectURL}) - } else { - ctx.Error(http.StatusInternalServerError, "unable to determine redirectURL for job rerun") - } -} - -func rerunJob(ctx *app_context.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { - status := job.Status - if !status.IsDone() { - return nil + if len(rerunJobs) == 0 { + ctx.Error(http.StatusInternalServerError, "no jobs were rerun") + return } - initialStatus := actions_model.StatusWaiting - if shouldBlock { - initialStatus = actions_model.StatusBlocked - } - if err := job.PrepareNextAttempt(initialStatus); err != nil { - return err + redirectURL, err := rerunJobs[0].HTMLURL(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return } - if err := db.WithTx(ctx, func(ctx context.Context) error { - _, err := actions_service.UpdateRunJob(ctx, job, builder.Eq{"status": status}, "handle", "attempt", "task_id", "status", "started", "stopped") - return err - }); err != nil { - return err - } - - actions_service.CreateCommitStatus(ctx, job) - return nil + ctx.JSON(http.StatusOK, &redirectObject{Redirect: redirectURL}) } func Logs(ctx *app_context.Context) { diff --git a/routers/web/repo/actions/view_test.go b/routers/web/repo/actions/view_test.go index 04d32f191b..0b66b7f7b8 100644 --- a/routers/web/repo/actions/view_test.go +++ b/routers/web/repo/actions/view_test.go @@ -554,7 +554,7 @@ func TestActionsRerun(t *testing.T) { runIndex: 138575, jobIndex: 1, expectedCode: 400, - expectedBody: "{\"errorMessage\":\"actions.workflow.job_rerun_impossible\",\"renderFormat\":\"html\"}\n", + expectedBody: "{\"errorMessage\":\"actions.workflow.rerun_impossible\",\"renderFormat\":\"html\"}\n", }, } for _, tt := range tests { diff --git a/services/actions/TestRerun_RerunAllJobs/action_run.yml b/services/actions/TestRerun_RerunAllJobs/action_run.yml new file mode 100644 index 0000000000..386a62d926 --- /dev/null +++ b/services/actions/TestRerun_RerunAllJobs/action_run.yml @@ -0,0 +1,82 @@ +- id: 455620 + title: Completed workflow + repo_id: 62 # test_workflows + owner_id: 2 # user2 + workflow_id: test.yaml + workflow_directory: .forgejo/workflows + index: 110 + trigger_user_id: 2 + ref: refs/heads/main + commit_sha: 1ca7f0f9-81e7-4e27-85eb-b080c33d32d3 + event: push + event_payload: "{}" + status: 1 # success + version: 4 + started: 1776279254 + stopped: 1776279265 + previous_duration: 0 + created: 1776263479 + updated: 1776279265 + +- id: 455630 + title: Running workflow + repo_id: 62 # test_workflows + owner_id: 2 # user2 + workflow_id: test.yaml + workflow_directory: .forgejo/workflows + index: 111 + trigger_user_id: 2 + ref: refs/heads/main + commit_sha: e36900942550acf94ddd8c0a3b91cc0165b4bd02 + event: push + event_payload: "{}" + status: 6 # running + version: 2 + started: 1776281360 + stopped: 0 + previous_duration: 0 + created: 1776281359 + updated: 1776281367 + +- id: 455640 + title: Invalid workflow + repo_id: 62 # test_workflows + owner_id: 2 # user2 + workflow_id: invalid.yaml + workflow_directory: .forgejo/workflows + index: 112 + trigger_user_id: 2 + ref: refs/heads/main + commit_sha: 4055b3488bf960c9c9f5c1eb2d84e346faaad7dc + event: push + event_payload: "{}" + status: 2 # failure + version: 2 + started: 0 + stopped: 0 + previous_duration: 0 + created: 1776282729 + updated: 1776282729 + pre_execution_error_code: 1 + pre_execution_error_details: + - "yaml: unmarshal errors:\n line 7: cannot unmarshal !!map into []*model.Step" + +- id: 455650 + title: Workflow with dependent jobs + repo_id: 62 # test_workflows + owner_id: 2 # user2 + workflow_id: dependent.yaml + workflow_directory: .forgejo/workflows + index: 113 + trigger_user_id: 2 + ref: refs/heads/main + commit_sha: ae36c1d75cc82cb9f9e54a86c8137ed05c3bd66e + event: push + event_payload: "{}" + status: 1 # success + version: 2 + started: 1776331635 + stopped: 1776331721 + previous_duration: 0 + created: 1776331495 + updated: 1776331721 diff --git a/services/actions/TestRerun_RerunAllJobs/action_run_job.yml b/services/actions/TestRerun_RerunAllJobs/action_run_job.yml new file mode 100644 index 0000000000..c04717ede9 --- /dev/null +++ b/services/actions/TestRerun_RerunAllJobs/action_run_job.yml @@ -0,0 +1,227 @@ +# Jobs of completed workflow run. +- id: 683880 + run_id: 455620 + repo_id: 62 + owner_id: 2 + commit_sha: e36900942550acf94ddd8c0a3b91cc0165b4bd02 + name: caller + attempt: 1 + handle: 7ecf26bd-408d-485f-b162-f349f5fe95bc + workflow_payload: | + "on": + push: + jobs: + caller: + name: caller + runs-on: [] + if: false + __metadata: + workflow_call_inputs: + greet_target: Mona the Octocat + workflow_call_id: d4f799b5a5d27e303ab97a546708c2aca6c1672b3bbc5defa0d73b251bca7955 + job_id: caller + needs: ["caller.callee"] + runs_on: [] + task_id: 0 + status: 1 # success + started: 0 + stopped: 0 + created: 1776263479 + updated: 1776279265 +- id: 683881 + run_id: 455620 + repo_id: 62 + owner_id: 2 + commit_sha: e36900942550acf94ddd8c0a3b91cc0165b4bd02 + name: callee + attempt: 1 + handle: 24920992-6701-48cc-872d-d017b33ee90e + workflow_payload: | + "on": + workflow_call: + inputs: + greet_target: + default: Mona the Octocat + type: string + jobs: + caller.callee: + name: callee + runs-on: ubuntu-latest + steps: + - run: | + echo "Hello ${{ inputs.greet_target }}" + __metadata: + workflow_call_parent: d4f799b5a5d27e303ab97a546708c2aca6c1672b3bbc5defa0d73b251bca7955 + job_id: caller.callee + needs: null + runs_on: ["ubuntu-latest"] + task_id: 989 + status: 1 # success + started: 1776279254 + stopped: 1776279264 + created: 1776263479 + updated: 1776279264 + +# Jobs of running workflow run. +- id: 683890 + run_id: 455630 + repo_id: 62 + owner_id: 2 + commit_sha: 41569413fe943e3f4e8e9625e14c1e01d85581af + name: caller + attempt: 1 + handle: 0185f661-3b6b-4f49-8a56-f6585c9c396e + workflow_payload: | + "on": + push: + jobs: + caller: + name: caller + runs-on: [] + if: false + __metadata: + workflow_call_inputs: + greet_target: Mona the Octocat + workflow_call_id: d4f799b5a5d27e303ab97a546708c2aca6c1672b3bbc5defa0d73b251bca7955 + job_id: caller + needs: ["caller.callee"] + runs_on: [] + task_id: 0 + status: 7 # blocked + started: 0 + stopped: 0 + created: 1776281359 + updated: 1776281359 +- id: 683891 + run_id: 455630 + repo_id: 62 + owner_id: 2 + commit_sha: 41569413fe943e3f4e8e9625e14c1e01d85581af + name: callee + attempt: 1 + handle: 4ee3bdf7-69f6-4174-9ffd-eb4a250188bc + workflow_payload: | + "on": + workflow_call: + inputs: + greet_target: + default: Mona the Octocat + type: string + jobs: + caller.callee: + name: callee + runs-on: ubuntu-latest + steps: + - run: | + echo "Hello ${{ inputs.greet_target }}" + __metadata: + workflow_call_parent: d4f799b5a5d27e303ab97a546708c2aca6c1672b3bbc5defa0d73b251bca7955 + job_id: caller.callee + needs: null + runs_on: ["ubuntu-latest"] + task_id: 989 + status: 6 # running + started: 1776281360 + stopped: 0 + created: 1776281359 + updated: 1776281367 + +# Jobs of invalid workflow. +- id: 683900 + run_id: 455640 + repo_id: 62 + owner_id: 2 + commit_sha: 4055b3488bf960c9c9f5c1eb2d84e346faaad7dc + name: test + attempt: 1 + handle: c60533ff-61b3-4f6f-9788-260911cda6ed + workflow_payload: "" + job_id: + needs: [] + runs_on: [] + task_id: 0 + status: 2 # failure + started: 0 + stopped: 0 + created: 1776282729 + updated: 1776282729 + +# Jobs of workflow with dependent jobs. +- id: 683910 + run_id: 455650 + repo_id: 62 + owner_id: 2 + commit_sha: ae36c1d75cc82cb9f9e54a86c8137ed05c3bd66e + name: lint + attempt: 1 + handle: 0a721b46-36d1-4540-b070-d559cf87515b + workflow_payload: | + "on": + push: + jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - run: echo "Linting" + job_id: lint + needs: null + runs_on: ["ubuntu-latest"] + task_id: 0 + status: 1 # success + started: 1776331495 + stopped: 1776331523 + created: 1776331495 + updated: 1776331523 +- id: 683911 + run_id: 455650 + repo_id: 62 + owner_id: 2 + commit_sha: ae36c1d75cc82cb9f9e54a86c8137ed05c3bd66e + name: build + attempt: 1 + handle: eec6b880-b013-4426-9361-8a4ad491ae91 + workflow_payload: | + "on": + push: + jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - run: echo "Building" + job_id: build + needs: [lint] + runs_on: ["ubuntu-latest"] + task_id: 0 + status: 1 # success + started: 1776331693 + stopped: 1776331721 + created: 1776331495 + updated: 1776331721 +- id: 683912 + run_id: 455650 + repo_id: 62 + owner_id: 2 + commit_sha: ae36c1d75cc82cb9f9e54a86c8137ed05c3bd66e + name: test + attempt: 1 + handle: b6398b81-f644-4f4b-9ed9-0c596c090b2f + workflow_payload: | + "on": + push: + jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - run: echo "Testing" + job_id: test + needs: [build] + runs_on: ["ubuntu-latest"] + task_id: 0 + status: 1 # success + started: 1776331665 + stopped: 1776331693 + created: 1776331495 + updated: 1776331693 diff --git a/services/actions/TestRerun_RerunJob/action_run.yml b/services/actions/TestRerun_RerunJob/action_run.yml new file mode 100644 index 0000000000..bac9eb9531 --- /dev/null +++ b/services/actions/TestRerun_RerunJob/action_run.yml @@ -0,0 +1,82 @@ +- id: 455620 + title: Completed workflow + repo_id: 62 # test_workflows + owner_id: 2 # user2 + workflow_id: test.yaml + workflow_directory: .forgejo/workflows + index: 110 + trigger_user_id: 2 + ref: refs/heads/main + commit_sha: 1ca7f0f9-81e7-4e27-85eb-b080c33d32d3 + event: push + event_payload: "{}" + status: 1 # success + version: 4 + started: 1776279254 + stopped: 1776279265 + previous_duration: 0 + created: 1776263479 + updated: 1776279265 + +- id: 455630 + title: Running workflow + repo_id: 62 # test_workflows + owner_id: 2 # user2 + workflow_id: test.yaml + workflow_directory: .forgejo/workflows + index: 111 + trigger_user_id: 2 + ref: refs/heads/main + commit_sha: e36900942550acf94ddd8c0a3b91cc0165b4bd02 + event: push + event_payload: "{}" + status: 6 # running + version: 2 + started: 1776281360 + stopped: 0 + previous_duration: 0 + created: 1776281359 + updated: 1776281367 + +- id: 455640 + title: Invalid workflow + repo_id: 62 # test_workflows + owner_id: 2 # user2 + workflow_id: invalid.yaml + workflow_directory: .forgejo/workflows + index: 112 + trigger_user_id: 2 + ref: refs/heads/main + commit_sha: 4055b3488bf960c9c9f5c1eb2d84e346faaad7dc + event: push + event_payload: "{}" + status: 2 # failure + version: 2 + started: 0 + stopped: 0 + previous_duration: 0 + created: 1776282729 + updated: 1776282729 + pre_execution_error_code: 1 + pre_execution_error_details: + - "yaml: unmarshal errors:\n line 7: cannot unmarshal !!map into []*model.Step" + +- id: 455650 + title: Workflow with dependent jobs + repo_id: 62 # test_workflows + owner_id: 2 # user2 + workflow_id: dependent.yaml + workflow_directory: .forgejo/workflows + index: 113 + trigger_user_id: 2 + ref: refs/heads/main + commit_sha: ae36c1d75cc82cb9f9e54a86c8137ed05c3bd66e + event: push + event_payload: "{}" + status: 6 # running + version: 2 + started: 1776331635 + stopped: 0 + previous_duration: 0 + created: 1776331495 + updated: 1776331721 diff --git a/services/actions/TestRerun_RerunJob/action_run_job.yml b/services/actions/TestRerun_RerunJob/action_run_job.yml new file mode 100644 index 0000000000..7e219e6ffe --- /dev/null +++ b/services/actions/TestRerun_RerunJob/action_run_job.yml @@ -0,0 +1,244 @@ +# Jobs of completed workflow run. +- id: 683880 + run_id: 455620 + repo_id: 62 + owner_id: 2 + commit_sha: e36900942550acf94ddd8c0a3b91cc0165b4bd02 + name: caller + attempt: 1 + handle: 7ecf26bd-408d-485f-b162-f349f5fe95bc + workflow_payload: | + "on": + push: + jobs: + caller: + name: caller + runs-on: [] + if: false + __metadata: + workflow_call_inputs: + greet_target: Mona the Octocat + workflow_call_id: d4f799b5a5d27e303ab97a546708c2aca6c1672b3bbc5defa0d73b251bca7955 + job_id: caller + needs: ["caller.callee"] + runs_on: [] + task_id: 0 + status: 1 # success + started: 0 + stopped: 0 + created: 1776263479 + updated: 1776279265 +- id: 683881 + run_id: 455620 + repo_id: 62 + owner_id: 2 + commit_sha: e36900942550acf94ddd8c0a3b91cc0165b4bd02 + name: callee + attempt: 1 + handle: 24920992-6701-48cc-872d-d017b33ee90e + workflow_payload: | + "on": + workflow_call: + inputs: + greet_target: + default: Mona the Octocat + type: string + jobs: + caller.callee: + name: callee + runs-on: ubuntu-latest + steps: + - run: | + echo "Hello ${{ inputs.greet_target }}" + __metadata: + workflow_call_parent: d4f799b5a5d27e303ab97a546708c2aca6c1672b3bbc5defa0d73b251bca7955 + job_id: caller.callee + needs: null + runs_on: ["ubuntu-latest"] + task_id: 989 + status: 1 # success + started: 1776279254 + stopped: 1776279264 + created: 1776263479 + updated: 1776279264 + +# Jobs of running workflow run. +- id: 683590 + run_id: 455630 + repo_id: 62 + owner_id: 2 + commit_sha: e36900942550acf94ddd8c0a3b91cc0165b4bd02 + name: lint + attempt: 1 + handle: d114a733-b364-4c08-b15d-77b6cc21a616 + workflow_payload: | + "on": + push: + jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - run: echo "Linting" + job_id: lint + needs: null + runs_on: ["ubuntu-latest"] + task_id: 0 + status: 1 # success + started: 1776331495 + stopped: 1776331523 + created: 1776331495 + updated: 1776331523 +- id: 683591 + run_id: 455630 + repo_id: 62 + owner_id: 2 + commit_sha: e36900942550acf94ddd8c0a3b91cc0165b4bd02 + name: build + attempt: 1 + handle: afd58a3e-76e9-467a-9a9d-9d6250fcc905 + workflow_payload: | + "on": + push: + jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - run: echo "Building" + job_id: build + needs: [lint] + runs_on: ["ubuntu-latest"] + task_id: 0 + status: 1 # success + started: 1776331693 + stopped: 1776331721 + created: 1776331495 + updated: 1776331721 +- id: 683592 + run_id: 455630 + repo_id: 62 + owner_id: 2 + commit_sha: e36900942550acf94ddd8c0a3b91cc0165b4bd02 + name: test + attempt: 1 + handle: 2d9438cb-5067-4aff-9adb-9c1265fb42d5 + workflow_payload: | + "on": + push: + jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - run: echo "Testing" + job_id: test + needs: [build] + runs_on: ["ubuntu-latest"] + task_id: 0 + status: 6 # StatusRunning + started: 1776331665 + stopped: 0 + created: 1776331495 + updated: 1776331693 + + +# Jobs of invalid workflow. +- id: 683900 + run_id: 455640 + repo_id: 62 + owner_id: 2 + commit_sha: 4055b3488bf960c9c9f5c1eb2d84e346faaad7dc + name: test + attempt: 1 + handle: c60533ff-61b3-4f6f-9788-260911cda6ed + workflow_payload: "" + job_id: + needs: [] + runs_on: [] + task_id: 0 + status: 2 # failure + started: 0 + stopped: 0 + created: 1776282729 + updated: 1776282729 + +# Jobs of successful workflow run with dependent jobs. +- id: 683910 + run_id: 455650 + repo_id: 62 + owner_id: 2 + commit_sha: ae36c1d75cc82cb9f9e54a86c8137ed05c3bd66e + name: lint + attempt: 1 + handle: 0a721b46-36d1-4540-b070-d559cf87515b + workflow_payload: | + "on": + push: + jobs: + lint: + name: lint + runs-on: ubuntu-latest + steps: + - run: echo "Linting" + job_id: lint + needs: null + runs_on: ["ubuntu-latest"] + task_id: 0 + status: 1 # success + started: 1776331495 + stopped: 1776331523 + created: 1776331495 + updated: 1776331523 +- id: 683911 + run_id: 455650 + repo_id: 62 + owner_id: 2 + commit_sha: ae36c1d75cc82cb9f9e54a86c8137ed05c3bd66e + name: build + attempt: 1 + handle: eec6b880-b013-4426-9361-8a4ad491ae91 + workflow_payload: | + "on": + push: + jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - run: echo "Building" + job_id: build + needs: [] + runs_on: ["ubuntu-latest"] + task_id: 0 + status: 1 # success + started: 1776331693 + stopped: 1776331721 + created: 1776331495 + updated: 1776331721 +- id: 683912 + run_id: 455650 + repo_id: 62 + owner_id: 2 + commit_sha: ae36c1d75cc82cb9f9e54a86c8137ed05c3bd66e + name: test + attempt: 1 + handle: b6398b81-f644-4f4b-9ed9-0c596c090b2f + workflow_payload: | + "on": + push: + jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - run: echo "Testing" + job_id: test + needs: [build] + runs_on: ["ubuntu-latest"] + task_id: 0 + status: 1 # success + started: 1776331665 + stopped: 1776331693 + created: 1776331495 + updated: 1776331693 diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 61aede5d7c..59838e5f57 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -4,10 +4,30 @@ package actions import ( + "context" + "errors" + "fmt" "slices" actions_model "forgejo.org/models/actions" + "forgejo.org/models/db" + "forgejo.org/models/unit" "forgejo.org/modules/container" + + "xorm.io/builder" +) + +var ( + // ErrRerunWorkflowInvalid signals that the workflow cannot be run because it is invalid, for example, due to syntax + // errors. + ErrRerunWorkflowInvalid = errors.New("workflow is invalid") + // ErrRerunWorkflowDisabled indicates that the workflow cannot be run because it has been disabled by the user or + // Forgejo. + ErrRerunWorkflowDisabled = errors.New("workflow is disabled") + // ErrRerunWorkflowStillRunning signals that the workflow cannot be rerun because at least one job is still running. + ErrRerunWorkflowStillRunning = errors.New("workflow is still running") + // ErrRerunJobStillRunning signals that the job cannot be rerun because it is still running. + ErrRerunJobStillRunning = errors.New("job is still running") ) // GetAllRerunJobs get all jobs that need to be rerun when job should be rerun @@ -16,22 +36,154 @@ func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.A rerunJobsIDSet := make(container.Set[string]) rerunJobsIDSet.Add(job.JobID) - for { - found := false - for _, j := range allJobs { - if rerunJobsIDSet.Contains(j.JobID) { - continue - } - if slices.ContainsFunc(j.Needs, rerunJobsIDSet.Contains) { - found = true - rerunJobs = append(rerunJobs, j) - rerunJobsIDSet.Add(j.JobID) - } + for _, j := range allJobs { + if rerunJobsIDSet.Contains(j.JobID) { + continue } - if !found { - break + if slices.ContainsFunc(j.Needs, rerunJobsIDSet.Contains) { + rerunJobs = append(rerunJobs, j) + rerunJobsIDSet.Add(j.JobID) } } return rerunJobs } + +// RerunAllJobs reruns all jobs of the given run and returns them. For it to succeed, the workflow must be valid, and the +// previous run must have completed. +func RerunAllJobs(ctx context.Context, run *actions_model.ActionRun) ([]*actions_model.ActionRunJob, error) { + if !run.IsValid() { + return nil, ErrRerunWorkflowInvalid + } + if !run.Status.IsDone() { + return nil, ErrRerunWorkflowStillRunning + } + + if err := run.LoadRepo(ctx); err != nil { + return nil, fmt.Errorf("cannot load repo of run %d: %w", run.ID, err) + } + + actionsConfig := run.Repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + if actionsConfig.IsWorkflowDisabled(run.WorkflowID) { + return nil, ErrRerunWorkflowDisabled + } + + var rerunJobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { + if run.Status != actions_model.StatusUnknown && !run.Status.IsDone() { + return fmt.Errorf("cannot prepare next attempt because run %d is active: %s", run.ID, run.Status.String()) + } + + run.PreviousDuration = run.Duration() + + run.Status = actions_model.StatusWaiting + run.Started = 0 + run.Stopped = 0 + + // The columns have to be specified here to work around a xorm quirk: It won't update columns that are set to + // their zero value without AllCols(). + if err := UpdateRun(ctx, run, "status", "started", "stopped", "previous_duration"); err != nil { + return fmt.Errorf("cannot update run %d: %w", run.ID, err) + } + + jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + if err != nil { + return fmt.Errorf("could not load jobs of run %d: %w", run.ID, err) + } + + for _, job := range jobs { + initialStatus := actions_model.StatusWaiting + if len(job.Needs) > 0 { + initialStatus = actions_model.StatusBlocked + } + + if err := rerunSingleJob(ctx, job, initialStatus); err != nil { + return fmt.Errorf("could not rerun job %d of run %d: %w", job.ID, run.ID, err) + } + + rerunJobs = append(rerunJobs, job) + } + + return nil + }); err != nil { + return nil, err + } + return rerunJobs, nil +} + +// RerunJob reruns the given job and all its dependent jobs. It returns all jobs that were rerun. For it to succeed, the +// workflow that defines this job must be valid, and the previous run must have completed. Dependent jobs that have not +// completed yet are ignored. +func RerunJob(ctx context.Context, job *actions_model.ActionRunJob) ([]*actions_model.ActionRunJob, error) { + if err := job.LoadAttributes(ctx); err != nil { + return nil, fmt.Errorf("cannot load attributes of job %d: %w", job.ID, err) + } + if !job.Run.IsValid() { + return nil, ErrRerunWorkflowInvalid + } + + actionsConfig := job.Run.Repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() + if actionsConfig.IsWorkflowDisabled(job.Run.WorkflowID) { + return nil, ErrRerunWorkflowDisabled + } + + if !job.Status.IsDone() { + return nil, ErrRerunJobStillRunning + } + + var rerunJobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { + jobs, err := actions_model.GetRunJobsByRunID(ctx, job.RunID) + if err != nil { + return fmt.Errorf("could not load jobs of run %d: %w", job.RunID, err) + } + + for _, jobToRerun := range GetAllRerunJobs(job, jobs) { + canBeRerun, err := jobToRerun.CanBeRerun(ctx) + if err != nil { + return fmt.Errorf("cannot determine whether job %d can be rerun: %w", jobToRerun.ID, err) + } + + // Skipping jobs that cannot be rerun is wrong. They should be cancelled and rerun, instead, because they + // are dependent jobs and the old results might be worthless, anyway. But we keep that behaviour for now, + // because changing it requires more rework. + if !canBeRerun { + continue + } + + // The job that should be rerun cannot be blocked, even if it has needs. + initialStatus := actions_model.StatusWaiting + if len(jobToRerun.Needs) > 0 && jobToRerun.ID != job.ID { + initialStatus = actions_model.StatusBlocked + } + + if err := rerunSingleJob(ctx, jobToRerun, initialStatus); err != nil { + return fmt.Errorf("cannot rerun job %d: %w", jobToRerun.ID, err) + } + rerunJobs = append(rerunJobs, jobToRerun) + } + return nil + }); err != nil { + return nil, err + } + + return rerunJobs, nil +} + +func rerunSingleJob(ctx context.Context, job *actions_model.ActionRunJob, initialStatus actions_model.Status) error { + oldStatus := job.Status + + if err := job.PrepareNextAttempt(initialStatus); err != nil { + return err + } + + // The columns have to be specified here to work around a xorm quirk: It won't update columns that are set to their + // zero value without AllCols(). + if _, err := UpdateRunJob(ctx, job, builder.Eq{"status": oldStatus}, "handle", "attempt", "task_id", "status", "started", "stopped"); err != nil { + return err + } + + CreateCommitStatus(ctx, job) + + return nil +} diff --git a/services/actions/rerun_test.go b/services/actions/rerun_test.go index 4b822e8da1..93a000afd1 100644 --- a/services/actions/rerun_test.go +++ b/services/actions/rerun_test.go @@ -5,13 +5,18 @@ package actions import ( "testing" + "time" actions_model "forgejo.org/models/actions" + "forgejo.org/models/unit" + "forgejo.org/models/unittest" + "forgejo.org/modules/timeutil" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestGetAllRerunJobs(t *testing.T) { +func TestRerun_GetAllRerunJobs(t *testing.T) { job1 := &actions_model.ActionRunJob{JobID: "job1"} job2 := &actions_model.ActionRunJob{JobID: "job2", Needs: []string{"job1"}} job3 := &actions_model.ActionRunJob{JobID: "job3", Needs: []string{"job2"}} @@ -46,3 +51,233 @@ func TestGetAllRerunJobs(t *testing.T) { assert.ElementsMatch(t, tc.rerunJobs, rerunJobs) } } + +func TestRerun_RerunAllJobs(t *testing.T) { + t.Run("Reruns completed workflow", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunAllJobs")() + require.NoError(t, unittest.PrepareTestDatabase()) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 455620}) + + rerunJobs, err := RerunAllJobs(t.Context(), run) + require.NoError(t, err) + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 455620}) + + assert.Equal(t, actions_model.StatusWaiting, run.Status) + assert.Equal(t, timeutil.TimeStamp(0), run.Started) + assert.Equal(t, timeutil.TimeStamp(0), run.Stopped) + assert.Equal(t, 11*time.Second, run.PreviousDuration) + + assert.Len(t, rerunJobs, 2) + assert.Equal(t, int64(683880), rerunJobs[0].ID) + assert.Equal(t, int64(2), rerunJobs[0].Attempt) + assert.Equal(t, actions_model.StatusBlocked, rerunJobs[0].Status) + assert.Equal(t, timeutil.TimeStamp(0), rerunJobs[0].Started) + assert.Equal(t, timeutil.TimeStamp(0), rerunJobs[0].Stopped) + + assert.Equal(t, int64(683881), rerunJobs[1].ID) + assert.Equal(t, int64(2), rerunJobs[1].Attempt) + assert.Equal(t, actions_model.StatusWaiting, rerunJobs[1].Status) + assert.Equal(t, timeutil.TimeStamp(0), rerunJobs[1].Started) + assert.Equal(t, timeutil.TimeStamp(0), rerunJobs[1].Stopped) + }) + + t.Run("Error if workflow running", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunAllJobs")() + require.NoError(t, unittest.PrepareTestDatabase()) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 455630}) + + rerunJobs, err := RerunAllJobs(t.Context(), run) + require.ErrorContains(t, err, "workflow is still running") + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 455630}) + + assert.Equal(t, actions_model.StatusRunning, run.Status) + assert.Equal(t, timeutil.TimeStamp(1776281360), run.Started) + assert.Equal(t, timeutil.TimeStamp(0), run.Stopped) + assert.Equal(t, time.Duration(0), run.PreviousDuration) + + assert.Empty(t, rerunJobs) + }) + + t.Run("Error if workflow invalid", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunAllJobs")() + require.NoError(t, unittest.PrepareTestDatabase()) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 455640}) + + rerunJobs, err := RerunAllJobs(t.Context(), run) + require.ErrorContains(t, err, "workflow is invalid") + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 455640}) + + assert.Equal(t, actions_model.StatusFailure, run.Status) + assert.Equal(t, timeutil.TimeStamp(0), run.Started) + assert.Equal(t, timeutil.TimeStamp(0), run.Stopped) + assert.Equal(t, time.Duration(0), run.PreviousDuration) + + assert.Empty(t, rerunJobs) + }) + + t.Run("Error if workflow disabled", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunAllJobs")() + require.NoError(t, unittest.PrepareTestDatabase()) + + run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 455620}) + + // Disable workflow + require.NoError(t, run.LoadAttributes(t.Context())) + actionsConfig := run.Repo.MustGetUnit(t.Context(), unit.TypeActions).ActionsConfig() + actionsConfig.DisableWorkflow(run.WorkflowID) + + rerunJobs, err := RerunAllJobs(t.Context(), run) + require.ErrorContains(t, err, "workflow is disabled") + + run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 455620}) + + assert.Equal(t, actions_model.StatusSuccess, run.Status) + assert.Equal(t, timeutil.TimeStamp(1776279254), run.Started) + assert.Equal(t, timeutil.TimeStamp(1776279265), run.Stopped) + assert.Equal(t, time.Duration(0), run.PreviousDuration) + + assert.Empty(t, rerunJobs) + }) +} + +func TestRerun_RerunJob(t *testing.T) { + t.Run("Rerun independent job", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunJob")() + require.NoError(t, unittest.PrepareTestDatabase()) + + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683910}) + + rerunJobs, err := RerunJob(t.Context(), job) + + require.NoError(t, err) + + assert.Len(t, rerunJobs, 1) + assert.Equal(t, job.ID, rerunJobs[0].ID) + + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683910}) + + assert.Equal(t, int64(2), job.Attempt) + assert.Equal(t, actions_model.StatusWaiting, job.Status) + assert.Equal(t, timeutil.TimeStamp(0), job.Started) + assert.Equal(t, timeutil.TimeStamp(0), job.Stopped) + }) + + t.Run("Rerun job needed by others", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunJob")() + require.NoError(t, unittest.PrepareTestDatabase()) + + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683911}) + + rerunJobs, err := RerunJob(t.Context(), job) + + require.NoError(t, err) + + assert.Len(t, rerunJobs, 2) + assert.Equal(t, int64(683911), rerunJobs[0].ID) + assert.Equal(t, int64(683912), rerunJobs[1].ID) + + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683911}) + + assert.Equal(t, int64(2), job.Attempt) + assert.Equal(t, actions_model.StatusWaiting, job.Status) + assert.Equal(t, timeutil.TimeStamp(0), job.Started) + assert.Equal(t, timeutil.TimeStamp(0), job.Stopped) + + dependentJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683912}) + + assert.Equal(t, int64(2), dependentJob.Attempt) + assert.Equal(t, actions_model.StatusBlocked, dependentJob.Status) + assert.Equal(t, timeutil.TimeStamp(0), dependentJob.Started) + assert.Equal(t, timeutil.TimeStamp(0), dependentJob.Stopped) + }) + + t.Run("Rerun job with needs", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunJob")() + require.NoError(t, unittest.PrepareTestDatabase()) + + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683912}) + + rerunJobs, err := RerunJob(t.Context(), job) + + require.NoError(t, err) + + assert.Len(t, rerunJobs, 1) + assert.Equal(t, int64(683912), rerunJobs[0].ID) + + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683912}) + + assert.Len(t, job.Needs, 1) + assert.Equal(t, int64(2), job.Attempt) + assert.Equal(t, actions_model.StatusWaiting, job.Status) + assert.Equal(t, timeutil.TimeStamp(0), job.Started) + assert.Equal(t, timeutil.TimeStamp(0), job.Stopped) + }) + + t.Run("Error if workflow invalid", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunJob")() + require.NoError(t, unittest.PrepareTestDatabase()) + + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683900}) + + rerunJobs, err := RerunJob(t.Context(), job) + + require.ErrorContains(t, err, "workflow is invalid") + assert.Empty(t, rerunJobs) + + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683900}) + + assert.Equal(t, int64(1), job.Attempt) + assert.Equal(t, actions_model.StatusFailure, job.Status) + assert.Equal(t, timeutil.TimeStamp(0), job.Started) + assert.Equal(t, timeutil.TimeStamp(0), job.Stopped) + }) + + t.Run("Error if workflow disabled", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunJob")() + require.NoError(t, unittest.PrepareTestDatabase()) + + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683881}) + + // Disable workflow + require.NoError(t, job.LoadAttributes(t.Context())) + actionsConfig := job.Run.Repo.MustGetUnit(t.Context(), unit.TypeActions).ActionsConfig() + actionsConfig.DisableWorkflow(job.Run.WorkflowID) + + rerunJobs, err := RerunJob(t.Context(), job) + + require.ErrorContains(t, err, "workflow is disabled") + assert.Empty(t, rerunJobs) + + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683881}) + + assert.Equal(t, int64(1), job.Attempt) + assert.Equal(t, actions_model.StatusSuccess, job.Status) + assert.Equal(t, timeutil.TimeStamp(1776279254), job.Started) + assert.Equal(t, timeutil.TimeStamp(1776279264), job.Stopped) + }) + + t.Run("Error if job still running", func(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestRerun_RerunJob")() + require.NoError(t, unittest.PrepareTestDatabase()) + + job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683592}) + + rerunJobs, err := RerunJob(t.Context(), job) + + require.ErrorContains(t, err, "job is still running") + assert.Empty(t, rerunJobs) + + job = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 683592}) + + assert.Equal(t, int64(1), job.Attempt) + assert.Equal(t, actions_model.StatusRunning, job.Status) + assert.Equal(t, timeutil.TimeStamp(1776331665), job.Started) + assert.Equal(t, timeutil.TimeStamp(0), job.Stopped) + }) +}