From ffbd500600d45fd86805004694086faaf68ecbdb Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Fri, 5 Dec 2025 18:14:43 +0100 Subject: [PATCH] feat(actions): support referencing `${{ needs... }}` variables in `runs-on` (#10308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows referencing the outputs of previously executed jobs in the `runs-on` field directly by a `${{ needs.some-job.outputs.some-output }}`, and also *indirectly* through the job's `strategy.matrix`. At its most complicated, supports a workflow with dynamic matrices like this: ```yaml jobs: define-matrix: runs-on: docker outputs: array-value: ${{ steps.define.outputs.array }} steps: - id: define run: | echo 'array=["debian-bookworm", "debian-trixie"]' >> "$FORGEJO_OUTPUT" runs-on-dynamic-matrix: needs: define-matrix strategy: matrix: my-runners: ${{ fromJSON(needs.define-matrix.outputs.array-value) }} runs-on: ${{ matrix.my-runners }} steps: - run: uname -a ``` ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### 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. - Documentation already (incorrectly) states that `jobs..runs-on` can access the `needs` context. 😛 https://forgejo.org/docs/latest/user/actions/reference/#availability ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. ## Release notes - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/10308): feat(actions): support referencing `${{ needs... }}` variables in `runs-on` Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10308 Reviewed-by: Earl Warren Co-authored-by: Mathieu Fenniak Co-committed-by: Mathieu Fenniak --- models/actions/pre_execution_errors.go | 17 +- models/actions/pre_execution_errors_test.go | 40 +++ models/actions/run.go | 2 +- models/actions/run_job.go | 10 + models/actions/run_job_test.go | 48 ++++ models/actions/run_test.go | 34 +++ options/locale_next/locale_en-US.json | 5 + .../action_run.yml | 36 +++ .../action_run_job.yml | 61 +++++ .../action_run.yml | 114 +++++++++ .../action_run_job.yml | 237 ++++++++++++++++++ .../action_task_output.yml | 15 ++ services/actions/job_emitter.go | 148 +++++++---- services/actions/job_emitter_test.go | 103 +++++++- services/actions/notifier_helper.go | 1 + services/actions/notifier_helper_test.go | 28 +++ services/actions/run.go | 41 +++ services/actions/run_test.go | 10 + services/actions/schedule_tasks.go | 1 + services/actions/schedule_tasks_test.go | 59 +++++ services/actions/workflows.go | 1 + 21 files changed, 958 insertions(+), 53 deletions(-) diff --git a/models/actions/pre_execution_errors.go b/models/actions/pre_execution_errors.go index 59976cde6e..28cd589ef9 100644 --- a/models/actions/pre_execution_errors.go +++ b/models/actions/pre_execution_errors.go @@ -18,9 +18,14 @@ type PreExecutionError int64 const ( ErrorCodeEventDetectionError PreExecutionError = iota + 1 ErrorCodeJobParsingError - ErrorCodePersistentIncompleteMatrix + ErrorCodePersistentIncompleteMatrix // obsolete ErrorCodeIncompleteMatrixMissingJob ErrorCodeIncompleteMatrixMissingOutput + ErrorCodeIncompleteMatrixUnknownCause + ErrorCodeIncompleteRunsOnMissingJob + ErrorCodeIncompleteRunsOnMissingOutput + ErrorCodeIncompleteRunsOnMissingMatrixDimension + ErrorCodeIncompleteRunsOnUnknownCause ) func TranslatePreExecutionError(lang translation.Locale, run *ActionRun) string { @@ -42,6 +47,16 @@ func TranslatePreExecutionError(lang translation.Locale, run *ActionRun) string return lang.TrString("actions.workflow.incomplete_matrix_missing_job", run.PreExecutionErrorDetails...) case ErrorCodeIncompleteMatrixMissingOutput: return lang.TrString("actions.workflow.incomplete_matrix_missing_output", run.PreExecutionErrorDetails...) + case ErrorCodeIncompleteMatrixUnknownCause: + return lang.TrString("actions.workflow.incomplete_matrix_unknown_cause", run.PreExecutionErrorDetails...) + case ErrorCodeIncompleteRunsOnMissingJob: + return lang.TrString("actions.workflow.incomplete_runson_missing_job", run.PreExecutionErrorDetails...) + case ErrorCodeIncompleteRunsOnMissingOutput: + return lang.TrString("actions.workflow.incomplete_runson_missing_output", run.PreExecutionErrorDetails...) + case ErrorCodeIncompleteRunsOnMissingMatrixDimension: + return lang.TrString("actions.workflow.incomplete_runson_missing_matrix_dimension", run.PreExecutionErrorDetails...) + case ErrorCodeIncompleteRunsOnUnknownCause: + return lang.TrString("actions.workflow.incomplete_runson_unknown_cause", run.PreExecutionErrorDetails...) } return fmt.Sprintf(" 0 || run.NeedApproval || v.IncompleteMatrix { + if len(needs) > 0 || run.NeedApproval || v.IncompleteMatrix || v.IncompleteRunsOn { status = StatusBlocked } else { status = StatusWaiting diff --git a/models/actions/run_job.go b/models/actions/run_job.go index 8a926e7577..31f5f44a14 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -289,3 +289,13 @@ func (job *ActionRunJob) IsIncompleteMatrix() (bool, *jobparser.IncompleteNeeds, } return jobWorkflow.IncompleteMatrix, jobWorkflow.IncompleteMatrixNeeds, nil } + +// Checks whether the target job has a `runs-on` field with an expression that requires an input from another job. The +// job will be blocked until the other job is complete, and then regenerated and deleted. +func (job *ActionRunJob) IsIncompleteRunsOn() (bool, *jobparser.IncompleteNeeds, *jobparser.IncompleteMatrix, error) { + jobWorkflow, err := job.decodeWorkflowPayload() + if err != nil { + return false, nil, nil, fmt.Errorf("failure decoding workflow payload: %w", err) + } + return jobWorkflow.IncompleteRunsOn, jobWorkflow.IncompleteRunsOnNeeds, jobWorkflow.IncompleteRunsOnMatrix, nil +} diff --git a/models/actions/run_job_test.go b/models/actions/run_job_test.go index 9988e4f621..acab662e60 100644 --- a/models/actions/run_job_test.go +++ b/models/actions/run_job_test.go @@ -189,3 +189,51 @@ func TestActionRunJob_IsIncompleteMatrix(t *testing.T) { }) } } + +func TestActionRunJob_IsIncompleteRunsOn(t *testing.T) { + tests := []struct { + name string + job ActionRunJob + isIncomplete bool + needs *jobparser.IncompleteNeeds + matrix *jobparser.IncompleteMatrix + errContains string + }{ + { + name: "normal workflow", + job: ActionRunJob{WorkflowPayload: []byte("name: workflow")}, + isIncomplete: false, + }, + { + name: "nincomplete_runs_on workflow", + job: ActionRunJob{WorkflowPayload: []byte("name: workflow\nincomplete_runs_on: true\nincomplete_runs_on_needs: { job: abc }")}, + needs: &jobparser.IncompleteNeeds{Job: "abc"}, + isIncomplete: true, + }, + { + name: "nincomplete_runs_on workflow", + job: ActionRunJob{WorkflowPayload: []byte("name: workflow\nincomplete_runs_on: true\nincomplete_runs_on_matrix: { dimension: abc }")}, + matrix: &jobparser.IncompleteMatrix{Dimension: "abc"}, + isIncomplete: true, + }, + { + name: "unparseable workflow", + job: ActionRunJob{WorkflowPayload: []byte("name: []\nincomplete_runs_on: true")}, + errContains: "failure unmarshaling WorkflowPayload to SingleWorkflow: yaml: unmarshal errors", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isIncomplete, needs, matrix, err := tt.job.IsIncompleteRunsOn() + if tt.errContains != "" { + assert.ErrorContains(t, err, tt.errContains) + } else { + require.NoError(t, err) + assert.Equal(t, tt.isIncomplete, isIncomplete) + assert.Equal(t, tt.needs, needs) + assert.Equal(t, tt.matrix, matrix) + } + }) + } +} diff --git a/models/actions/run_test.go b/models/actions/run_test.go index ebc7d3b475..3a169dd56a 100644 --- a/models/actions/run_test.go +++ b/models/actions/run_test.go @@ -238,3 +238,37 @@ jobs: // Expect job with an incomplete matrix to be StatusBlocked: assert.Equal(t, StatusBlocked, job.Status) } + +func TestActionRun_IncompleteRunsOn(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + pullRequestPosterID := int64(4) + repoID := int64(10) + pullRequestID := int64(2) + runDoesNotNeedApproval := &ActionRun{ + RepoID: repoID, + PullRequestID: pullRequestID, + PullRequestPosterID: pullRequestPosterID, + } + + workflowRaw := []byte(` +jobs: + job2: + runs-on: ${{ needs.other-job.outputs.some-output }} + steps: + - run: true +`) + workflows, err := jobparser.Parse(workflowRaw, false, jobparser.WithJobOutputs(map[string]map[string]string{}), jobparser.SupportIncompleteRunsOn()) + 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)) + + jobs, err := db.Find[ActionRunJob](t.Context(), FindRunJobOptions{RunID: runDoesNotNeedApproval.ID}) + require.NoError(t, err) + require.Len(t, jobs, 1) + job := jobs[0] + + // Expect job with an incomplete runs-on to be StatusBlocked: + assert.Equal(t, StatusBlocked, job.Status) +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 4b535077cd..13f2ed6f55 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -217,6 +217,11 @@ "actions.workflow.persistent_incomplete_matrix": "Unable to evaluate `strategy.matrix` of job %[1]s due to a `needs` expression that was invalid. It may reference a job that is not in it's 'needs' list (%[2]s), or an output that doesn't exist on one of those jobs.", "actions.workflow.incomplete_matrix_missing_job": "Unable to evaluate `strategy.matrix` of job %[1]s: job %[2]s is not in the `needs` list of job %[1]s (%[3]s).", "actions.workflow.incomplete_matrix_missing_output": "Unable to evaluate `strategy.matrix` of job %[1]s: job %[2]s does not have an output %[3]s.", + "actions.workflow.incomplete_matrix_unknown_cause": "Unable to evaluate `strategy.matrix` of job %[1]s: unknown error.", + "actions.workflow.incomplete_runson_missing_job": "Unable to evaluate `runs-on` of job %[1]s: job %[2]s is not in the `needs` list of job %[1]s (%[3]s).", + "actions.workflow.incomplete_runson_missing_output": "Unable to evaluate `runs-on` of job %[1]s: job %[2]s does not have an output %[3]s.", + "actions.workflow.incomplete_runson_missing_matrix_dimension": "Unable to evaluate `runs-on` of job %[1]s: matrix dimension %[2]s does not exist.", + "actions.workflow.incomplete_runson_unknown_cause": "Unable to evaluate `runs-on` of job %[1]s: unknown error.", "actions.workflow.pre_execution_error": "Workflow was not executed due to an error that blocked the execution attempt.", "pulse.n_active_issues": { "one": "%s active issue", diff --git a/services/actions/TestActions_consistencyCheckRun/action_run.yml b/services/actions/TestActions_consistencyCheckRun/action_run.yml index 5762595905..321600adf9 100644 --- a/services/actions/TestActions_consistencyCheckRun/action_run.yml +++ b/services/actions/TestActions_consistencyCheckRun/action_run.yml @@ -52,3 +52,39 @@ updated: 1683636626 need_approval: 0 approved_by: 0 +- + id: 903 + title: "running workflow_dispatch run" + repo_id: 63 + owner_id: 2 + workflow_id: "running.yaml" + index: 7 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "workflow_dispatch" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 +- + id: 904 + title: "running workflow_dispatch run" + repo_id: 63 + owner_id: 2 + workflow_id: "running.yaml" + index: 8 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "workflow_dispatch" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 diff --git a/services/actions/TestActions_consistencyCheckRun/action_run_job.yml b/services/actions/TestActions_consistencyCheckRun/action_run_job.yml index ffef2816be..8d0adf3f58 100644 --- a/services/actions/TestActions_consistencyCheckRun/action_run_job.yml +++ b/services/actions/TestActions_consistencyCheckRun/action_run_job.yml @@ -111,3 +111,64 @@ task_id: 100 status: 1 # success runs_on: '["fedora"]' + +- + id: 605 + run_id: 903 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: job_1 + attempt: 0 + job_id: job_1 + task_id: 0 + status: 7 # blocked + runs_on: '["fedora"]' + needs: '[]' + workflow_payload: | + "on": + push: + jobs: + produce-artifacts: + name: produce-artifacts + runs-on: ${{ matrix.platform-oops-wrong-dimension }} + steps: + - run: echo "OK!" + strategy: + matrix: + os: [ nixos, ubuntu ] + incomplete_runs_on: true + incomplete_runs_on_matrix: + dimension: platform-oops-wrong-dimension + +- + id: 606 + run_id: 904 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: job_1 + attempt: 0 + job_id: job_1 + task_id: 0 + status: 7 # blocked + runs_on: '["fedora"]' + needs: '[]' + workflow_payload: | + "on": + push: + jobs: + produce-artifacts: + name: produce-artifacts + runs-on: ${{ matrix.platform-oops-wrong-dimension }} + steps: + - run: echo "OK!" + strategy: + matrix: + os: [ "${{ needs.some-job.outputs.some-output }}", ubuntu ] + incomplete_runs_on: true + incomplete_runs_on_matrix: + dimension: platform-oops-wrong-dimension + incomplete_matrix: true diff --git a/services/actions/Test_tryHandleIncompleteMatrix/action_run.yml b/services/actions/Test_tryHandleIncompleteMatrix/action_run.yml index 8ed7d27714..2bbd44b405 100644 --- a/services/actions/Test_tryHandleIncompleteMatrix/action_run.yml +++ b/services/actions/Test_tryHandleIncompleteMatrix/action_run.yml @@ -169,3 +169,117 @@ need_approval: 0 approved_by: 0 concurrency_group: abc123 +- + id: 909 + title: "running workflow_dispatch run" + repo_id: 63 + owner_id: 2 + workflow_id: "running.yaml" + index: 13 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "workflow_dispatch" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 + concurrency_group: abc123 +- + id: 910 + title: "running workflow_dispatch run" + repo_id: 63 + owner_id: 2 + workflow_id: "running.yaml" + index: 14 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "workflow_dispatch" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 + concurrency_group: abc123 +- + id: 911 + title: "running workflow_dispatch run" + repo_id: 63 + owner_id: 2 + workflow_id: "running.yaml" + index: 15 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "workflow_dispatch" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 + concurrency_group: abc123 +- + id: 912 + title: "running workflow_dispatch run" + repo_id: 63 + owner_id: 2 + workflow_id: "running.yaml" + index: 16 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "workflow_dispatch" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 + concurrency_group: abc123 +- + id: 913 + title: "running workflow_dispatch run" + repo_id: 63 + owner_id: 2 + workflow_id: "running.yaml" + index: 17 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "workflow_dispatch" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 + concurrency_group: abc123 +- + id: 914 + title: "running workflow_dispatch run" + repo_id: 63 + owner_id: 2 + workflow_id: "running.yaml" + index: 18 + trigger_user_id: 2 + ref: "refs/heads/main" + commit_sha: "97f29ee599c373c729132a5c46a046978311e0ee" + trigger_event: "workflow_dispatch" + is_fork_pull_request: 0 + status: 6 # running + started: 1683636528 + created: 1683636108 + updated: 1683636626 + need_approval: 0 + approved_by: 0 + concurrency_group: abc123 diff --git a/services/actions/Test_tryHandleIncompleteMatrix/action_run_job.yml b/services/actions/Test_tryHandleIncompleteMatrix/action_run_job.yml index 8334b47002..a352f54ee6 100644 --- a/services/actions/Test_tryHandleIncompleteMatrix/action_run_job.yml +++ b/services/actions/Test_tryHandleIncompleteMatrix/action_run_job.yml @@ -359,3 +359,240 @@ task_id: 100 status: 1 # success runs_on: '["fedora"]' + +- + id: 617 + run_id: 909 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: consume-runs-on + attempt: 0 + job_id: consume-runs-on + task_id: 0 + status: 7 # blocked + runs_on: '["fedora"]' + needs: '["define-runs-on"]' + workflow_payload: | + "on": + push: + jobs: + consume-runs-on: + name: consume-runs-on + runs-on: ${{ needs.define-runs-on.outputs.run-on-this }} + steps: + - run: echo "OK!" + incomplete_runs_on: true +- + id: 618 + run_id: 909 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: define-runs-on + attempt: 0 + job_id: define-runs-on + task_id: 105 + status: 1 # success + runs_on: '["fedora"]' + +- + id: 619 + run_id: 910 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: consume-runs-on + attempt: 0 + job_id: consume-runs-on + task_id: 0 + status: 7 # blocked + runs_on: '["fedora"]' + needs: '["define-matrix"]' + workflow_payload: | + "on": + push: + jobs: + consume-runs-on: + name: consume-runs-on (incomplete matrix) + strategy: + matrix: ${{ fromJSON(needs.define-matrix.outputs.entire-matrix) }} + runs-on: node-${{ matrix.node }} + steps: + - run: echo "OK!" + incomplete_matrix: true + incomplete_runs_on: true +- + id: 620 + run_id: 910 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: define-matrix + attempt: 0 + job_id: define-matrix + task_id: 106 + status: 1 # success + runs_on: '["fedora"]' + +- + id: 621 + run_id: 911 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: consume-runs-on + attempt: 0 + job_id: consume-runs-on + task_id: 0 + status: 7 # blocked + runs_on: '["fedora"]' + needs: '["define-runs-on"]' + workflow_payload: | + "on": + push: + jobs: + consume-runs-on: + name: consume-runs-on + runs-on: + - datacenter-alpha + - ${{ needs.define-runs-on.outputs.run-on-this }} + - node-27.x + steps: + - run: echo "OK!" + incomplete_runs_on: true +- + id: 622 + run_id: 911 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: define-runs-on + attempt: 0 + job_id: define-runs-on + task_id: 107 + status: 1 # success + runs_on: '["fedora"]' + +- + id: 623 + run_id: 912 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: consume-runs-on + attempt: 0 + job_id: consume-runs-on + task_id: 0 + status: 7 # blocked + runs_on: '["fedora"]' + needs: '["define-runs-on", "another-needs"]' + workflow_payload: | + "on": + push: + jobs: + consume-runs-on: + name: consume-runs-on + runs-on: ${{ needs.oops-i-misspelt-the-job-id.outputs.run-on-this }} + steps: + - run: echo "OK!" + incomplete_runs_on: true +- + id: 624 + run_id: 912 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: define-runs-on + attempt: 0 + job_id: define-runs-on + task_id: 107 + status: 1 # success + runs_on: '["fedora"]' + +- + id: 625 + run_id: 913 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: consume-runs-on + attempt: 0 + job_id: consume-runs-on + task_id: 0 + status: 7 # blocked + runs_on: '["fedora"]' + needs: '["define-runs-on"]' + workflow_payload: | + "on": + push: + jobs: + consume-runs-on: + name: consume-runs-on + runs-on: ${{ needs.define-runs-on.outputs.output-doesnt-exist }} + steps: + - run: echo "OK!" + incomplete_runs_on: true +- + id: 626 + run_id: 913 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: define-runs-on + attempt: 0 + job_id: define-runs-on + task_id: 107 + status: 1 # success + runs_on: '["fedora"]' + +- + id: 627 + run_id: 914 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: consume-runs-on + attempt: 0 + job_id: consume-runs-on + task_id: 0 + status: 7 # blocked + runs_on: '["fedora"]' + needs: '["define-runs-on"]' + workflow_payload: | + "on": + push: + jobs: + consume-runs-on: + name: consume-runs-on + runs-on: ${{ matrix.dimension-oops-error }} + steps: + - run: echo "OK!" + strategy: + matrix: + dimension1: [ abc, def ] + incomplete_runs_on: true +- + id: 628 + run_id: 914 + repo_id: 63 + owner_id: 2 + commit_sha: 97f29ee599c373c729132a5c46a046978311e0ee + is_fork_pull_request: 0 + name: define-runs-on + attempt: 0 + job_id: define-runs-on + task_id: 107 + status: 1 # success + runs_on: '["fedora"]' diff --git a/services/actions/Test_tryHandleIncompleteMatrix/action_task_output.yml b/services/actions/Test_tryHandleIncompleteMatrix/action_task_output.yml index a2083d010d..6633ee72ee 100644 --- a/services/actions/Test_tryHandleIncompleteMatrix/action_task_output.yml +++ b/services/actions/Test_tryHandleIncompleteMatrix/action_task_output.yml @@ -33,3 +33,18 @@ task_id: 104 output_key: scalar-value output_value: just some value +- + id: 107 + task_id: 105 + output_key: run-on-this + output_value: nixos-25.11 +- + id: 108 + task_id: 106 + output_key: entire-matrix + output_value: '{"datacenter": ["site-a", "site-b"], "node": ["12.x", "14.x"], "pg": [17, 18]}' +- + id: 109 + task_id: 107 + output_key: run-on-this + output_value: nixos-25.11 diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index 616632f9aa..9484f7aed7 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -182,10 +182,18 @@ func (r *jobStatusResolver) resolve() map[int64]actions_model.Status { // 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) (bool, error) { - if incompleteMatrix, _, err := blockedJob.IsIncompleteMatrix(); err != nil { + incompleteMatrix, _, err := blockedJob.IsIncompleteMatrix() + if err != nil { return false, fmt.Errorf("job IsIncompleteMatrix: %w", err) - } else if !incompleteMatrix { - // Not relevant to attempt expansion if it wasn't marked IncompleteMatrix previously. + } + + incompleteRunsOn, _, _, err := blockedJob.IsIncompleteRunsOn() + if err != nil { + return false, fmt.Errorf("job IsIncompleteRunsOn: %w", err) + } + + if !incompleteMatrix && !incompleteRunsOn { + // Not relevant to attempt re-parsing the job if it wasn't marked as Incomplete[...] previously. return false, nil } @@ -218,63 +226,34 @@ func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.Ac jobOutputs[job.JobID] = outputsMap } - // Re-parse the blocked job, providing all the other completed jobs' outputs, to turn this incomplete matrix into + // Re-parse the blocked job, providing all the other completed jobs' outputs, to turn this incomplete job into // one-or-more new jobs: newJobWorkflows, err := jobparser.Parse(blockedJob.WorkflowPayload, false, jobparser.WithJobOutputs(jobOutputs), jobparser.WithWorkflowNeeds(blockedJob.Needs), + jobparser.SupportIncompleteRunsOn(), ) if err != nil { return false, fmt.Errorf("failure re-parsing SingleWorkflow: %w", err) } - // Sanity check that the expanded jobs are !IncompleteMatrix: + // 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 + // PreExecutionError. for _, swf := range newJobWorkflows { if swf.IncompleteMatrix { - // Even though every job in the `needs` list is done, this job came back as `IncompleteMatrix`. This could - // happen if the job referenced `needs.some-job` in the `strategy.matrix`, but the job didn't have `needs: - // some-job`, or it could happen if it references an output that doesn't exist on that job. We don't have - // enough information from the jobparser to determine what failed specifically. - // - // This is an error that needs to be reported back to the user for them to correct their workflow, so we - // slip this notification into PreExecutionError. - - run := blockedJob.Run - - var errorCode actions_model.PreExecutionError - var errorDetails []any - - // `IncompleteMatrixNeeds` tells us which output was accessed that was missing - if swf.IncompleteMatrixNeeds != nil { - jobRef := swf.IncompleteMatrixNeeds.Job // always provided - outputRef := swf.IncompleteMatrixNeeds.Output // missing if the entire job wasn't present - if outputRef != "" { - errorCode = actions_model.ErrorCodeIncompleteMatrixMissingOutput - errorDetails = []any{ - blockedJob.JobID, - jobRef, - outputRef, - } - } else { - errorCode = actions_model.ErrorCodeIncompleteMatrixMissingJob - errorDetails = []any{ - blockedJob.JobID, - jobRef, - strings.Join(blockedJob.Needs, ", "), - } - } - } else { - errorCode = actions_model.ErrorCodePersistentIncompleteMatrix - errorDetails = []any{ - blockedJob.JobID, - strings.Join(blockedJob.Needs, ", "), - } - } - - if err := FailRunPreExecutionError(ctx, run, errorCode, errorDetails); err != nil { + errorCode, errorDetails := persistentIncompleteMatrixError(blockedJob, swf.IncompleteMatrixNeeds) + if err := FailRunPreExecutionError(ctx, blockedJob.Run, errorCode, errorDetails); err != nil { + return false, fmt.Errorf("failure when marking run with error: %w", err) + } + // Return `true` to skip running this job in this invalid state + return true, nil + } else if swf.IncompleteRunsOn { + errorCode, errorDetails := persistentIncompleteRunsOnError(blockedJob, swf.IncompleteRunsOnNeeds, swf.IncompleteRunsOnMatrix) + if err := FailRunPreExecutionError(ctx, blockedJob.Run, errorCode, errorDetails); err != nil { return false, fmt.Errorf("failure when marking run with error: %w", err) } - // Return `true` to skip running this job in this invalid state return true, nil } @@ -301,3 +280,78 @@ func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.Ac } return true, nil } + +func persistentIncompleteMatrixError(job *actions_model.ActionRunJob, incompleteNeeds *jobparser.IncompleteNeeds) (actions_model.PreExecutionError, []any) { + var errorCode actions_model.PreExecutionError + var errorDetails []any + + // `incompleteNeeds` tells us what part of a `${{ needs... }}` expression was missing + if incompleteNeeds != nil { + jobRef := incompleteNeeds.Job // always provided + outputRef := incompleteNeeds.Output // missing if the entire job wasn't present + if outputRef != "" { + errorCode = actions_model.ErrorCodeIncompleteMatrixMissingOutput + errorDetails = []any{ + job.JobID, + jobRef, + outputRef, + } + } else { + errorCode = actions_model.ErrorCodeIncompleteMatrixMissingJob + errorDetails = []any{ + job.JobID, + jobRef, + strings.Join(job.Needs, ", "), + } + } + return errorCode, errorDetails + } + + // Not sure why we ended up in `IncompleteMatrix` when nothing was marked as incomplete + errorCode = actions_model.ErrorCodeIncompleteMatrixUnknownCause + errorDetails = []any{job.JobID} + return errorCode, errorDetails +} + +func persistentIncompleteRunsOnError(job *actions_model.ActionRunJob, incompleteNeeds *jobparser.IncompleteNeeds, incompleteMatrix *jobparser.IncompleteMatrix) (actions_model.PreExecutionError, []any) { + var errorCode actions_model.PreExecutionError + var errorDetails []any + + // `incompleteMatrix` tells us which dimension of a matrix was accessed that was missing + if incompleteMatrix != nil { + dimension := incompleteMatrix.Dimension + errorCode = actions_model.ErrorCodeIncompleteRunsOnMissingMatrixDimension + errorDetails = []any{ + job.JobID, + dimension, + } + return errorCode, errorDetails + } + + // `incompleteNeeds` tells us what part of a `${{ needs... }}` expression was missing + if incompleteNeeds != nil { + jobRef := incompleteNeeds.Job // always provided + outputRef := incompleteNeeds.Output // missing if the entire job wasn't present + if outputRef != "" { + errorCode = actions_model.ErrorCodeIncompleteRunsOnMissingOutput + errorDetails = []any{ + job.JobID, + jobRef, + outputRef, + } + } else { + errorCode = actions_model.ErrorCodeIncompleteRunsOnMissingJob + errorDetails = []any{ + job.JobID, + jobRef, + strings.Join(job.Needs, ", "), + } + } + return errorCode, errorDetails + } + + // Not sure why we ended up in `IncompleteRunsOn` when nothing was marked as incomplete + errorCode = actions_model.ErrorCodeIncompleteRunsOnUnknownCause + errorDetails = []any{job.JobID} + return errorCode, errorDetails +} diff --git a/services/actions/job_emitter_test.go b/services/actions/job_emitter_test.go index 93ed3d5158..0125e924d1 100644 --- a/services/actions/job_emitter_test.go +++ b/services/actions/job_emitter_test.go @@ -10,9 +10,12 @@ import ( actions_model "forgejo.org/models/actions" "forgejo.org/models/db" "forgejo.org/models/unittest" + "forgejo.org/modules/test" + "code.forgejo.org/forgejo/runner/v12/act/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" ) func Test_jobStatusResolver_Resolve(t *testing.T) { @@ -140,6 +143,11 @@ jobs: } func Test_tryHandleIncompleteMatrix(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) + })() + tests := []struct { name string runJobID int64 @@ -148,9 +156,10 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) { runJobNames []string preExecutionError actions_model.PreExecutionError preExecutionErrorDetails []any + runsOn map[string][]string }{ { - name: "not incomplete_matrix", + name: "not incomplete", runJobID: 600, }, { @@ -225,11 +234,86 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) { }, }, { - name: "missing needs for strategy.matrix evaluation", + name: "missing needs output for strategy.matrix evaluation", runJobID: 615, preExecutionError: actions_model.ErrorCodeIncompleteMatrixMissingOutput, preExecutionErrorDetails: []any{"job_1", "define-matrix-1", "colours-intentional-mistake"}, }, + { + name: "runs-on evaluation with needs", + runJobID: 617, + consumed: true, + runJobNames: []string{ + "consume-runs-on", + "define-runs-on", + }, + runsOn: map[string][]string{ + "define-runs-on": {"fedora"}, + "consume-runs-on": {"nixos-25.11"}, + }, + }, + { + name: "runs-on evaluation with needs dynamic matrix", + runJobID: 619, + consumed: true, + runJobNames: []string{ + "consume-runs-on (site-a, 12.x, 17)", + "consume-runs-on (site-a, 12.x, 18)", + "consume-runs-on (site-a, 14.x, 17)", + "consume-runs-on (site-a, 14.x, 18)", + "consume-runs-on (site-b, 12.x, 17)", + "consume-runs-on (site-b, 12.x, 18)", + "consume-runs-on (site-b, 14.x, 17)", + "consume-runs-on (site-b, 14.x, 18)", + "define-matrix", + }, + runsOn: map[string][]string{ + "consume-runs-on (site-a, 12.x, 17)": {"node-12.x"}, + "consume-runs-on (site-a, 12.x, 18)": {"node-12.x"}, + "consume-runs-on (site-a, 14.x, 17)": {"node-14.x"}, + "consume-runs-on (site-a, 14.x, 18)": {"node-14.x"}, + "consume-runs-on (site-b, 12.x, 17)": {"node-12.x"}, + "consume-runs-on (site-b, 12.x, 18)": {"node-12.x"}, + "consume-runs-on (site-b, 14.x, 17)": {"node-14.x"}, + "consume-runs-on (site-b, 14.x, 18)": {"node-14.x"}, + "define-matrix": {"fedora"}, + }, + }, + { + name: "runs-on evaluation to part of array", + runJobID: 621, + consumed: true, + runJobNames: []string{ + "consume-runs-on", + "define-runs-on", + }, + runsOn: map[string][]string{ + "define-runs-on": {"fedora"}, + "consume-runs-on": { + "datacenter-alpha", + "nixos-25.11", + "node-27.x", + }, + }, + }, + { + name: "missing needs job for runs-on evaluation", + runJobID: 623, + preExecutionError: actions_model.ErrorCodeIncompleteRunsOnMissingJob, + preExecutionErrorDetails: []any{"consume-runs-on", "oops-i-misspelt-the-job-id", "define-runs-on, another-needs"}, + }, + { + name: "missing needs output for runs-on evaluation", + runJobID: 625, + preExecutionError: actions_model.ErrorCodeIncompleteRunsOnMissingOutput, + preExecutionErrorDetails: []any{"consume-runs-on", "define-runs-on", "output-doesnt-exist"}, + }, + { + name: "missing matrix dimension for runs-on evaluation", + runJobID: 627, + preExecutionError: actions_model.ErrorCodeIncompleteRunsOnMissingMatrixDimension, + preExecutionErrorDetails: []any{"consume-runs-on", "dimension-oops-error"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -255,7 +339,7 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) { // expectations are that the ActionRun has an empty PreExecutionError actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: blockedJob.RunID}) - assert.Empty(t, actionRun.PreExecutionError) + assert.EqualValues(t, 0, actionRun.PreExecutionErrorCode) // compare jobs that exist with `runJobNames` to ensure new jobs are inserted: allJobsInRun, err := db.Find[actions_model.ActionRunJob](t.Context(), actions_model.FindRunJobOptions{RunID: blockedJob.RunID}) @@ -266,10 +350,21 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) { } slices.Sort(allJobNames) assert.Equal(t, tt.runJobNames, allJobNames) + + // Check the runs-on of all jobs + if tt.runsOn != nil { + for _, j := range allJobsInRun { + expected, ok := tt.runsOn[j.Name] + if assert.Truef(t, ok, "unable to find runsOn[%q] in test case", j.Name) { + slices.Sort(j.RunsOn) + slices.Sort(expected) + assert.Equalf(t, expected, j.RunsOn, "comparing runsOn expectations for job %q", j.Name) + } + } + } } else if tt.preExecutionError != 0 { // expectations are that the ActionRun has a populated PreExecutionError, is marked as failed actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: blockedJob.RunID}) - assert.Empty(t, actionRun.PreExecutionError) assert.Equal(t, tt.preExecutionError, actionRun.PreExecutionErrorCode) assert.Equal(t, tt.preExecutionErrorDetails, actionRun.PreExecutionErrorDetails) assert.Equal(t, actions_model.StatusFailure, actionRun.Status) diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go index 38970ef86c..b4087d6c6f 100644 --- a/services/actions/notifier_helper.go +++ b/services/actions/notifier_helper.go @@ -418,6 +418,7 @@ func handleWorkflows( // We don't have any job outputs yet, but `WithJobOutputs(...)` triggers JobParser to supporting its // `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs. jobparser.WithJobOutputs(map[string]map[string]string{}), + jobparser.SupportIncompleteRunsOn(), ) if err != nil { log.Info("jobparser.Parse: invalid workflow, setting job status to failed: %v", err) diff --git a/services/actions/notifier_helper_test.go b/services/actions/notifier_helper_test.go index d9e0847056..a9fc64248d 100644 --- a/services/actions/notifier_helper_test.go +++ b/services/actions/notifier_helper_test.go @@ -298,3 +298,31 @@ func TestActionsNotifier_DynamicMatrix(t *testing.T) { // first inserted it is tagged w/ incomplete_matrix assert.Contains(t, string(job.WorkflowPayload), "incomplete_matrix: true") } + +func TestActionsNotifier_RunsOnNeeds(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3}) + + dw := &actions_module.DetectedWorkflow{ + Content: []byte("{ on: pull_request, jobs: { j1: { runs-on: \"${{ needs.other-job.outputs.some-output }}\" } } }"), + } + testActionsNotifierPullRequest(t, repo, pr, dw, webhook_module.HookEventPullRequestSync) + + runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{ + RepoID: repo.ID, + }) + require.NoError(t, err) + require.Len(t, runs, 1) + run := runs[0] + + jobs, err := db.Find[actions_model.ActionRunJob](t.Context(), actions_model.FindRunJobOptions{RunID: run.ID}) + require.NoError(t, err) + require.Len(t, jobs, 1) + job := jobs[0] + + // With a runs-on that contains ${{ needs ... }} references, the only requirement to work is that when the job is + // first inserted it is tagged w/ incomplete_runs_on. + assert.Contains(t, string(job.WorkflowPayload), "incomplete_runs_on: true") +} diff --git a/services/actions/run.go b/services/actions/run.go index 55f1e0b4c6..9bc93bc68e 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -107,6 +107,11 @@ func consistencyCheckRun(ctx context.Context, run *actions_model.ActionRun) erro } else if stop { break } + if stop, err := checkJobRunsOnStaticMatrixError(ctx, job); err != nil { + return err + } else if stop { + break + } } return nil } @@ -156,3 +161,39 @@ func checkJobWillRevisit(ctx context.Context, job *actions_model.ActionRunJob) ( return true, nil } + +func checkJobRunsOnStaticMatrixError(ctx context.Context, job *actions_model.ActionRunJob) (bool, error) { + // If a job has a `runs-on` field that references a matrix dimension like `runs-on: ${{ matrix.platorm }}`, and + // `platform` is not part of the job's matrix at all, then it will be tagged as `IsIncompleteRunsOn` and will be + // blocked forever. This only applies if the matrix is static -- that is, the job isn't also tagged + // `IsIncompleteMatrix` and the matrix is yet to be fully defined. + + isIncompleteRunsOn, _, matrixReference, err := job.IsIncompleteRunsOn() + if err != nil { + return false, err + } else if !isIncompleteRunsOn || matrixReference == nil { + // Not incomplete, or, it's incomplete but not because of a matrix reference error. + return false, nil + } + + isIncompleteMatrix, _, err := job.IsIncompleteMatrix() + if err != nil { + return false, err + } else if isIncompleteMatrix { + // Not a static matrix, so this might be resolved later when the job is expanded. + return false, nil + } + + // Job doesn't seem like it can proceed; mark the run with an error. + if err := job.LoadRun(ctx); err != nil { + return false, err + } + if err := FailRunPreExecutionError(ctx, job.Run, actions_model.ErrorCodeIncompleteRunsOnMissingMatrixDimension, []any{ + job.JobID, + matrixReference.Dimension, + }); err != nil { + return false, err + } + + return true, nil +} diff --git a/services/actions/run_test.go b/services/actions/run_test.go index b6d8b36a8c..e8a198c446 100644 --- a/services/actions/run_test.go +++ b/services/actions/run_test.go @@ -127,6 +127,16 @@ func TestActions_consistencyCheckRun(t *testing.T) { preExecutionError: actions_model.ErrorCodeIncompleteMatrixMissingJob, preExecutionErrorDetails: []any{"job_1", "oops-something-wrong-here", "define-matrix"}, }, + { + name: "inconsistent: static matrix missing dimension", + runID: 903, + preExecutionError: actions_model.ErrorCodeIncompleteRunsOnMissingMatrixDimension, + preExecutionErrorDetails: []any{"job_1", "platform-oops-wrong-dimension"}, + }, + { + name: "consistent: matrix missing dimension but matrix is dynamic", + runID: 904, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index 480897750b..f30bb530bd 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -174,6 +174,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) // We don't have any job outputs yet, but `WithJobOutputs(...)` triggers JobParser to supporting its // `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs. jobparser.WithJobOutputs(map[string]map[string]string{}), + jobparser.SupportIncompleteRunsOn(), ) if err != nil { return err diff --git a/services/actions/schedule_tasks_test.go b/services/actions/schedule_tasks_test.go index 286ae6d4f8..c848d9d9c7 100644 --- a/services/actions/schedule_tasks_test.go +++ b/services/actions/schedule_tasks_test.go @@ -326,3 +326,62 @@ jobs: // first inserted it is tagged w/ incomplete_matrix assert.Contains(t, string(job.WorkflowPayload), "incomplete_matrix: true") } + +func TestServiceActions_RunsOnNeeds(t *testing.T) { + defer unittest.OverrideFixtures("services/actions/TestServiceActions_startTask")() + require.NoError(t, unittest.PrepareTestDatabase()) + + // Load fixtures that are corrupted and create one valid scheduled workflow + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + + workflowID := "some.yml" + schedules := []*actions_model.ActionSchedule{ + { + Title: "scheduletitle1", + RepoID: repo.ID, + OwnerID: repo.OwnerID, + WorkflowID: workflowID, + TriggerUserID: repo.OwnerID, + Ref: "branch", + CommitSHA: "fakeSHA", + Event: webhook_module.HookEventSchedule, + EventPayload: "fakepayload", + Specs: []string{"* * * * *"}, + Content: []byte( + ` +jobs: + job2: + runs-on: "${{ fromJSON(needs.other-job.outputs.some-output) }}" + steps: + - run: true +`), + }, + } + + require.Equal(t, 2, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) + require.NoError(t, actions_model.CreateScheduleTask(t.Context(), schedules)) + require.Equal(t, 3, unittest.GetCount(t, actions_model.ActionScheduleSpec{})) + _, err := db.GetEngine(db.DefaultContext).Exec("UPDATE `action_schedule_spec` SET next = 1") + require.NoError(t, err) + + // After running startTasks an ActionRun row is created for the valid scheduled workflow + require.Empty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) + require.NoError(t, startTasks(t.Context())) + require.NotEmpty(t, unittest.GetCount(t, actions_model.ActionRun{WorkflowID: workflowID})) + + runs, err := db.Find[actions_model.ActionRun](db.DefaultContext, actions_model.FindRunOptions{ + WorkflowID: workflowID, + }) + require.NoError(t, err) + require.Len(t, runs, 1) + run := runs[0] + + jobs, err := db.Find[actions_model.ActionRunJob](t.Context(), actions_model.FindRunJobOptions{RunID: run.ID}) + require.NoError(t, err) + require.Len(t, jobs, 1) + job := jobs[0] + + // With a runs-on that contains ${{ needs ... }} references, the only requirement to work is that when the job is + // first inserted it is tagged w/ incomplete_runs_on + assert.Contains(t, string(job.WorkflowPayload), "incomplete_runs_on: true") +} diff --git a/services/actions/workflows.go b/services/actions/workflows.go index 2c2e439373..da006b755a 100644 --- a/services/actions/workflows.go +++ b/services/actions/workflows.go @@ -174,6 +174,7 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette // We don't have any job outputs yet, but `WithJobOutputs(...)` triggers JobParser to supporting its // `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs. jobparser.WithJobOutputs(map[string]map[string]string{}), + jobparser.SupportIncompleteRunsOn(), ) if err != nil { return nil, nil, err