forgejo/models/actions/run_job_test.go
Mathieu Fenniak ffbd500600 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>
2025-12-05 18:14:43 +01:00

239 lines
7.3 KiB
Go

// SPDX-License-Identifier: MIT
package actions
import (
"fmt"
"html/template"
"testing"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/translation"
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionRunJob_ItRunsOn(t *testing.T) {
actionJob := ActionRunJob{RunsOn: []string{"ubuntu"}}
agentLabels := []string{"ubuntu", "node-20"}
assert.True(t, actionJob.ItRunsOn(agentLabels))
assert.False(t, actionJob.ItRunsOn([]string{}))
actionJob.RunsOn = append(actionJob.RunsOn, "node-20")
assert.True(t, actionJob.ItRunsOn(agentLabels))
agentLabels = []string{"ubuntu"}
assert.False(t, actionJob.ItRunsOn(agentLabels))
actionJob.RunsOn = []string{}
assert.False(t, actionJob.ItRunsOn(agentLabels))
}
func TestActionRunJob_HTMLURL(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
tests := []struct {
id int64
expected string
}{
{
id: 192,
expected: "https://try.gitea.io/user5/repo4/actions/runs/187/jobs/0/attempt/1",
},
{
id: 393,
expected: "https://try.gitea.io/user2/repo1/actions/runs/187/jobs/1/attempt/1",
},
{
id: 394,
expected: "https://try.gitea.io/user2/repo1/actions/runs/187/jobs/2/attempt/2",
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("id=%d", tt.id), func(t *testing.T) {
var job ActionRunJob
has, err := db.GetEngine(t.Context()).Where("id=?", tt.id).Get(&job)
require.NoError(t, err)
require.True(t, has, "load ActionRunJob from fixture")
err = job.LoadAttributes(t.Context())
require.NoError(t, err)
url, err := job.HTMLURL(t.Context())
require.NoError(t, err)
assert.Equal(t, tt.expected, url)
})
}
}
func TestActionRunJob_StatusDiagnostics(t *testing.T) {
translation.InitLocales(t.Context())
english := translation.NewLocale("en-US")
tests := []struct {
name string
job ActionRunJob
expected []template.HTML
}{
{
name: "Unknown status",
job: ActionRunJob{RunsOn: []string{"windows"}, Status: StatusUnknown, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Unknown"},
},
{
name: "Waiting without labels",
job: ActionRunJob{RunsOn: []string{}, Status: StatusWaiting, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Waiting for a runner with the following labels: "},
},
{
name: "Waiting with one label",
job: ActionRunJob{RunsOn: []string{"freebsd"}, Status: StatusWaiting, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Waiting for a runner with the following label: freebsd"},
},
{
name: "Waiting with labels, no approval",
job: ActionRunJob{RunsOn: []string{"docker", "ubuntu"}, Status: StatusWaiting, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Waiting for a runner with the following labels: docker, ubuntu"},
},
{
name: "Waiting with labels, approval",
job: ActionRunJob{RunsOn: []string{"docker", "ubuntu"}, Status: StatusWaiting, Run: &ActionRun{NeedApproval: true}},
expected: []template.HTML{
"Waiting for a runner with the following labels: docker, ubuntu",
"Need approval to run workflows for fork pull request.",
},
},
{
name: "Running",
job: ActionRunJob{RunsOn: []string{"debian"}, Status: StatusRunning, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Running"},
},
{
name: "Success",
job: ActionRunJob{RunsOn: []string{"debian"}, Status: StatusSuccess, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Success"},
},
{
name: "Failure",
job: ActionRunJob{RunsOn: []string{"debian"}, Status: StatusFailure, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Failure"},
},
{
name: "Cancelled",
job: ActionRunJob{RunsOn: []string{"debian"}, Status: StatusCancelled, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Canceled"},
},
{
name: "Skipped",
job: ActionRunJob{RunsOn: []string{"debian"}, Status: StatusSkipped, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Skipped"},
},
{
name: "Blocked",
job: ActionRunJob{RunsOn: []string{"debian"}, Status: StatusBlocked, Run: &ActionRun{NeedApproval: false}},
expected: []template.HTML{"Blocked"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.job.StatusDiagnostics(english))
})
}
}
func TestActionRunJob_IsIncompleteMatrix(t *testing.T) {
tests := []struct {
name string
job ActionRunJob
isIncomplete bool
needs *jobparser.IncompleteNeeds
errContains string
}{
{
name: "normal workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow")},
isIncomplete: false,
},
{
name: "incomplete_matrix workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: workflow\nincomplete_matrix: true\nincomplete_matrix_needs: { job: abc }")},
needs: &jobparser.IncompleteNeeds{Job: "abc"},
isIncomplete: true,
},
{
name: "unparseable workflow",
job: ActionRunJob{WorkflowPayload: []byte("name: []\nincomplete_matrix: true")},
errContains: "failure unmarshaling WorkflowPayload to SingleWorkflow: yaml: unmarshal errors",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isIncomplete, needs, err := tt.job.IsIncompleteMatrix()
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)
}
})
}
}
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)
}
})
}
}