feat(actions): support referencing ${{ needs... }} variables in runs-on (#10308)

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.<job-id>.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/<pull request number>.md` to be be used for the release notes instead of the title.

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Features
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/10308): <!--number 10308 --><!--line 0 --><!--description ZmVhdChhY3Rpb25zKTogc3VwcG9ydCByZWZlcmVuY2luZyBgJHt7IG5lZWRzLi4uIH19YCB2YXJpYWJsZXMgaW4gYHJ1bnMtb25g-->feat(actions): support referencing `${{ needs... }}` variables in `runs-on`<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10308
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak 2025-12-05 18:14:43 +01:00 committed by Mathieu Fenniak
parent 0ecc6ef632
commit ffbd500600
21 changed files with 958 additions and 53 deletions

View file

@ -18,9 +18,14 @@ type PreExecutionError int64
const ( const (
ErrorCodeEventDetectionError PreExecutionError = iota + 1 ErrorCodeEventDetectionError PreExecutionError = iota + 1
ErrorCodeJobParsingError ErrorCodeJobParsingError
ErrorCodePersistentIncompleteMatrix ErrorCodePersistentIncompleteMatrix // obsolete
ErrorCodeIncompleteMatrixMissingJob ErrorCodeIncompleteMatrixMissingJob
ErrorCodeIncompleteMatrixMissingOutput ErrorCodeIncompleteMatrixMissingOutput
ErrorCodeIncompleteMatrixUnknownCause
ErrorCodeIncompleteRunsOnMissingJob
ErrorCodeIncompleteRunsOnMissingOutput
ErrorCodeIncompleteRunsOnMissingMatrixDimension
ErrorCodeIncompleteRunsOnUnknownCause
) )
func TranslatePreExecutionError(lang translation.Locale, run *ActionRun) string { 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...) return lang.TrString("actions.workflow.incomplete_matrix_missing_job", run.PreExecutionErrorDetails...)
case ErrorCodeIncompleteMatrixMissingOutput: case ErrorCodeIncompleteMatrixMissingOutput:
return lang.TrString("actions.workflow.incomplete_matrix_missing_output", run.PreExecutionErrorDetails...) 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("<unsupported error: code=%v details=%#v", run.PreExecutionErrorCode, run.PreExecutionErrorDetails) return fmt.Sprintf("<unsupported error: code=%v details=%#v", run.PreExecutionErrorCode, run.PreExecutionErrorDetails)
} }

View file

@ -70,6 +70,46 @@ func TestTranslatePreExecutionError(t *testing.T) {
}, },
expected: "Unable to evaluate `strategy.matrix` of job blocked_job: job other_job is not in the `needs` list of job blocked_job (needs-1, needs-2).", expected: "Unable to evaluate `strategy.matrix` of job blocked_job: job other_job is not in the `needs` list of job blocked_job (needs-1, needs-2).",
}, },
{
name: "ErrorCodeIncompleteMatrixUnknownCause",
run: &ActionRun{
PreExecutionErrorCode: ErrorCodeIncompleteMatrixUnknownCause,
PreExecutionErrorDetails: []any{"blocked_job"},
},
expected: "Unable to evaluate `strategy.matrix` of job blocked_job: unknown error.",
},
{
name: "ErrorCodeIncompleteRunsOnMissingOutput",
run: &ActionRun{
PreExecutionErrorCode: ErrorCodeIncompleteRunsOnMissingOutput,
PreExecutionErrorDetails: []any{"blocked_job", "other_job", "some_output"},
},
expected: "Unable to evaluate `runs-on` of job blocked_job: job other_job does not have an output some_output.",
},
{
name: "ErrorCodeIncompleteRunsOnMissingJob",
run: &ActionRun{
PreExecutionErrorCode: ErrorCodeIncompleteRunsOnMissingJob,
PreExecutionErrorDetails: []any{"blocked_job", "other_job", "needs-1, needs-2"},
},
expected: "Unable to evaluate `runs-on` of job blocked_job: job other_job is not in the `needs` list of job blocked_job (needs-1, needs-2).",
},
{
name: "ErrorCodeIncompleteRunsOnMissingMatrixDimension",
run: &ActionRun{
PreExecutionErrorCode: ErrorCodeIncompleteRunsOnMissingMatrixDimension,
PreExecutionErrorDetails: []any{"blocked_job", "platfurm"},
},
expected: "Unable to evaluate `runs-on` of job blocked_job: matrix dimension platfurm does not exist.",
},
{
name: "ErrorCodeIncompleteRunsOnUnknownCause",
run: &ActionRun{
PreExecutionErrorCode: ErrorCodeIncompleteRunsOnUnknownCause,
PreExecutionErrorDetails: []any{"blocked_job"},
},
expected: "Unable to evaluate `runs-on` of job blocked_job: unknown error.",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -341,7 +341,7 @@ func InsertRunJobs(ctx context.Context, run *ActionRun, jobs []*jobparser.Single
} }
payload, _ = v.Marshal() payload, _ = v.Marshal()
if len(needs) > 0 || run.NeedApproval || v.IncompleteMatrix { if len(needs) > 0 || run.NeedApproval || v.IncompleteMatrix || v.IncompleteRunsOn {
status = StatusBlocked status = StatusBlocked
} else { } else {
status = StatusWaiting status = StatusWaiting

View file

@ -289,3 +289,13 @@ func (job *ActionRunJob) IsIncompleteMatrix() (bool, *jobparser.IncompleteNeeds,
} }
return jobWorkflow.IncompleteMatrix, jobWorkflow.IncompleteMatrixNeeds, nil 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
}

View file

@ -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)
}
})
}
}

View file

@ -238,3 +238,37 @@ jobs:
// Expect job with an incomplete matrix to be StatusBlocked: // Expect job with an incomplete matrix to be StatusBlocked:
assert.Equal(t, StatusBlocked, job.Status) 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)
}

View file

@ -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.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_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_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.", "actions.workflow.pre_execution_error": "Workflow was not executed due to an error that blocked the execution attempt.",
"pulse.n_active_issues": { "pulse.n_active_issues": {
"one": "%s active issue", "one": "%s active issue",

View file

@ -52,3 +52,39 @@
updated: 1683636626 updated: 1683636626
need_approval: 0 need_approval: 0
approved_by: 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

View file

@ -111,3 +111,64 @@
task_id: 100 task_id: 100
status: 1 # success status: 1 # success
runs_on: '["fedora"]' 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

View file

@ -169,3 +169,117 @@
need_approval: 0 need_approval: 0
approved_by: 0 approved_by: 0
concurrency_group: abc123 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

View file

@ -359,3 +359,240 @@
task_id: 100 task_id: 100
status: 1 # success status: 1 # success
runs_on: '["fedora"]' 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"]'

View file

@ -33,3 +33,18 @@
task_id: 104 task_id: 104
output_key: scalar-value output_key: scalar-value
output_value: just some 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

View file

@ -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 // 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. // job's `strategy.matrix` into multiple new jobs.
func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.ActionRunJob, jobsInRun []*actions_model.ActionRunJob) (bool, error) { 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) 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 return false, nil
} }
@ -218,63 +226,34 @@ func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.Ac
jobOutputs[job.JobID] = outputsMap 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: // one-or-more new jobs:
newJobWorkflows, err := jobparser.Parse(blockedJob.WorkflowPayload, false, newJobWorkflows, err := jobparser.Parse(blockedJob.WorkflowPayload, false,
jobparser.WithJobOutputs(jobOutputs), jobparser.WithJobOutputs(jobOutputs),
jobparser.WithWorkflowNeeds(blockedJob.Needs), jobparser.WithWorkflowNeeds(blockedJob.Needs),
jobparser.SupportIncompleteRunsOn(),
) )
if err != nil { if err != nil {
return false, fmt.Errorf("failure re-parsing SingleWorkflow: %w", err) 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 { for _, swf := range newJobWorkflows {
if swf.IncompleteMatrix { if swf.IncompleteMatrix {
// Even though every job in the `needs` list is done, this job came back as `IncompleteMatrix`. This could errorCode, errorDetails := persistentIncompleteMatrixError(blockedJob, swf.IncompleteMatrixNeeds)
// happen if the job referenced `needs.some-job` in the `strategy.matrix`, but the job didn't have `needs: if err := FailRunPreExecutionError(ctx, blockedJob.Run, errorCode, errorDetails); err != nil {
// some-job`, or it could happen if it references an output that doesn't exist on that job. We don't have return false, fmt.Errorf("failure when marking run with error: %w", err)
// enough information from the jobparser to determine what failed specifically. }
// // Return `true` to skip running this job in this invalid state
// This is an error that needs to be reported back to the user for them to correct their workflow, so we return true, nil
// slip this notification into PreExecutionError. } else if swf.IncompleteRunsOn {
errorCode, errorDetails := persistentIncompleteRunsOnError(blockedJob, swf.IncompleteRunsOnNeeds, swf.IncompleteRunsOnMatrix)
run := blockedJob.Run if err := FailRunPreExecutionError(ctx, blockedJob.Run, errorCode, errorDetails); err != nil {
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 {
return false, fmt.Errorf("failure when marking run with error: %w", err) 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` to skip running this job in this invalid state
return true, nil return true, nil
} }
@ -301,3 +280,78 @@ func tryHandleIncompleteMatrix(ctx context.Context, blockedJob *actions_model.Ac
} }
return true, nil 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
}

View file

@ -10,9 +10,12 @@ import (
actions_model "forgejo.org/models/actions" actions_model "forgejo.org/models/actions"
"forgejo.org/models/db" "forgejo.org/models/db"
"forgejo.org/models/unittest" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.yaml.in/yaml/v3"
) )
func Test_jobStatusResolver_Resolve(t *testing.T) { func Test_jobStatusResolver_Resolve(t *testing.T) {
@ -140,6 +143,11 @@ jobs:
} }
func Test_tryHandleIncompleteMatrix(t *testing.T) { 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 { tests := []struct {
name string name string
runJobID int64 runJobID int64
@ -148,9 +156,10 @@ func Test_tryHandleIncompleteMatrix(t *testing.T) {
runJobNames []string runJobNames []string
preExecutionError actions_model.PreExecutionError preExecutionError actions_model.PreExecutionError
preExecutionErrorDetails []any preExecutionErrorDetails []any
runsOn map[string][]string
}{ }{
{ {
name: "not incomplete_matrix", name: "not incomplete",
runJobID: 600, 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, runJobID: 615,
preExecutionError: actions_model.ErrorCodeIncompleteMatrixMissingOutput, preExecutionError: actions_model.ErrorCodeIncompleteMatrixMissingOutput,
preExecutionErrorDetails: []any{"job_1", "define-matrix-1", "colours-intentional-mistake"}, 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 // expectations are that the ActionRun has an empty PreExecutionError
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: blockedJob.RunID}) 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: // 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}) 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) slices.Sort(allJobNames)
assert.Equal(t, tt.runJobNames, 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 { } else if tt.preExecutionError != 0 {
// expectations are that the ActionRun has a populated PreExecutionError, is marked as failed // expectations are that the ActionRun has a populated PreExecutionError, is marked as failed
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: blockedJob.RunID}) 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.preExecutionError, actionRun.PreExecutionErrorCode)
assert.Equal(t, tt.preExecutionErrorDetails, actionRun.PreExecutionErrorDetails) assert.Equal(t, tt.preExecutionErrorDetails, actionRun.PreExecutionErrorDetails)
assert.Equal(t, actions_model.StatusFailure, actionRun.Status) assert.Equal(t, actions_model.StatusFailure, actionRun.Status)

View file

@ -418,6 +418,7 @@ func handleWorkflows(
// We don't have any job outputs yet, but `WithJobOutputs(...)` triggers JobParser to supporting its // 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. // `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs.
jobparser.WithJobOutputs(map[string]map[string]string{}), jobparser.WithJobOutputs(map[string]map[string]string{}),
jobparser.SupportIncompleteRunsOn(),
) )
if err != nil { if err != nil {
log.Info("jobparser.Parse: invalid workflow, setting job status to failed: %v", err) log.Info("jobparser.Parse: invalid workflow, setting job status to failed: %v", err)

View file

@ -298,3 +298,31 @@ func TestActionsNotifier_DynamicMatrix(t *testing.T) {
// first inserted it is tagged w/ incomplete_matrix // first inserted it is tagged w/ incomplete_matrix
assert.Contains(t, string(job.WorkflowPayload), "incomplete_matrix: true") 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")
}

View file

@ -107,6 +107,11 @@ func consistencyCheckRun(ctx context.Context, run *actions_model.ActionRun) erro
} else if stop { } else if stop {
break break
} }
if stop, err := checkJobRunsOnStaticMatrixError(ctx, job); err != nil {
return err
} else if stop {
break
}
} }
return nil return nil
} }
@ -156,3 +161,39 @@ func checkJobWillRevisit(ctx context.Context, job *actions_model.ActionRunJob) (
return true, nil 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
}

View file

@ -127,6 +127,16 @@ func TestActions_consistencyCheckRun(t *testing.T) {
preExecutionError: actions_model.ErrorCodeIncompleteMatrixMissingJob, preExecutionError: actions_model.ErrorCodeIncompleteMatrixMissingJob,
preExecutionErrorDetails: []any{"job_1", "oops-something-wrong-here", "define-matrix"}, 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

View file

@ -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 // 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. // `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs.
jobparser.WithJobOutputs(map[string]map[string]string{}), jobparser.WithJobOutputs(map[string]map[string]string{}),
jobparser.SupportIncompleteRunsOn(),
) )
if err != nil { if err != nil {
return err return err

View file

@ -326,3 +326,62 @@ jobs:
// first inserted it is tagged w/ incomplete_matrix // first inserted it is tagged w/ incomplete_matrix
assert.Contains(t, string(job.WorkflowPayload), "incomplete_matrix: true") 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")
}

View file

@ -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 // 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. // `IncompleteMatrix` tagging for any jobs that require the inputs of other jobs.
jobparser.WithJobOutputs(map[string]map[string]string{}), jobparser.WithJobOutputs(map[string]map[string]string{}),
jobparser.SupportIncompleteRunsOn(),
) )
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err