Files
gitocean/services/actions/job_emitter_test.go
Andreas AhlenstorfandMathieu Fenniak 42618ba771 fix: update statuses in phases to prevent out of order updates from stalling workflows (#12685)
During workflow expansion, jobs are replaced or added. That means that their execution order does not necessarily match the order of their numeric IDs. For example, job 129 might depend on job 130. Unfortunately, Forgejo doesn't take this possibility into account and always examines and updates jobs by ascending ID. That means that while examining job 129, job 130 has still its old status, and, as a result, Forgejo won't schedule job 129 for execution because it's still waiting for job 130 to complete. This can lead to workflows getting stuck:

```
...tions/job_emitter.go:48:jobEmitterQueueHandler() [E] checkJobsOfRun failed for RunID = 49: error in tryHandleIncompleteMatrix: jobStatusResolver attempted to tryHandleIncompleteMatrix for a job (id=129) with an incomplete 'needs' job (id=130)
```

This is caused by calculating all status changes recursively in memory before writing them to the database. For example, job A that was completed would unblock job B that depended on it. In that case, Forgejo would simultaneously mark job A as completed and B as waiting. However, that would not work if B had a lower ID than A. That is remedied by writing updates to the database before entering the next recursion. So, continuing the example, job A is marked as completed in the database before the next iteration detects that B is no longer blocked and writes that to the database.

Resolves https://codeberg.org/forgejo/forgejo/issues/12641.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). 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 for Go changes

(can be removed for JavaScript changes)

- 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 ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

(can be removed for Go changes)

- 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.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

*The decision if the pull request will be shown in the release notes is up to the mergers / release team.*

The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12685
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
2026-05-25 05:20:23 +02:00

963 lines
33 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"errors"
"slices"
"testing"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
"forgejo.org/modules/test"
notify_service "forgejo.org/services/notify"
"code.forgejo.org/forgejo/runner/v12/act/jobparser"
"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) {
tests := []struct {
name string
jobs actions_model.ActionJobList
want map[int64]actions_model.Status
}{
{
name: "no blocked",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "1", Status: actions_model.StatusWaiting, Needs: []string{}},
{ID: 2, JobID: "2", Status: actions_model.StatusWaiting, Needs: []string{}},
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}},
},
want: map[int64]actions_model.Status{},
},
{
name: "single blocked",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusWaiting, Needs: []string{}},
},
want: map[int64]actions_model.Status{
2: actions_model.StatusWaiting,
},
},
{
name: "multiple blocked",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
},
want: map[int64]actions_model.Status{
2: actions_model.StatusWaiting,
3: actions_model.StatusWaiting,
},
},
{
name: "chain blocked",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "1", Status: actions_model.StatusFailure, Needs: []string{}},
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
},
want: map[int64]actions_model.Status{
// Resolve() does only one update pass and does not update jobs recursively. Therefore, job 3, which
// depends on 2, is not marked as skipped. It would only be marked as skipped if it depended on job 1.
2: actions_model.StatusSkipped,
},
},
{
name: "loop need",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "1", Status: actions_model.StatusBlocked, Needs: []string{"3"}},
{ID: 2, JobID: "2", Status: actions_model.StatusBlocked, Needs: []string{"1"}},
{ID: 3, JobID: "3", Status: actions_model.StatusBlocked, Needs: []string{"2"}},
},
want: map[int64]actions_model.Status{},
},
{
name: "`if` is not empty and all jobs in `needs` completed successfully",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
runs-on: ubuntu-latest
needs: job1
if: ${{ always() && needs.job1.result == 'success' }}
steps:
- run: echo "will be checked by act_runner"
`)},
},
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
},
{
name: "`if` is not empty and not all jobs in `needs` completed successfully",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
runs-on: ubuntu-latest
needs: job1
if: ${{ always() && needs.job1.result == 'failure' }}
steps:
- run: echo "will be checked by act_runner"
`)},
},
want: map[int64]actions_model.Status{2: actions_model.StatusWaiting},
},
{
name: "`if` is empty and not all jobs in `needs` completed successfully",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1", Status: actions_model.StatusFailure, Needs: []string{}},
{ID: 2, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
runs-on: ubuntu-latest
needs: job1
steps:
- run: echo "should be skipped"
`)},
},
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
},
{
name: "unblocked workflow call outer job with success",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1.innerjob1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job1.innerjob2", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 3, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job1.innerjob1", "job1.innerjob2"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
if: false
uses: ./.forgejo/workflows/reusable.yml
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
`)},
},
want: map[int64]actions_model.Status{
3: actions_model.StatusSuccess,
},
},
{
name: "unblocked workflow call outer job with success and skip",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1.innerjob1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job1.innerjob2", Status: actions_model.StatusSkipped, Needs: []string{}},
{ID: 3, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job1.innerjob1", "job1.innerjob2"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
if: false
uses: ./.forgejo/workflows/reusable.yml
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
`)},
},
want: map[int64]actions_model.Status{
3: actions_model.StatusSuccess,
},
},
{
name: "unblocked workflow call outer job with only skip",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1.innerjob1", Status: actions_model.StatusSkipped, Needs: []string{}},
{ID: 2, JobID: "job1.innerjob2", Status: actions_model.StatusSkipped, Needs: []string{}},
{ID: 3, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job1.innerjob1", "job1.innerjob2"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
if: false
uses: ./.forgejo/workflows/reusable.yml
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
`)},
},
want: map[int64]actions_model.Status{
3: actions_model.StatusSkipped,
},
},
{
name: "unblocked workflow call outer job, incomplete `with`",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job0", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job0"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
if: false
uses: ./.forgejo/workflows/reusable.yml
with:
something: ${{ needs.job0.outputs.something }}
incomplete_with: true
incomplete_with_needs:
job: job0
output: something
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
`)},
},
want: map[int64]actions_model.Status{
2: actions_model.StatusWaiting,
},
},
{
name: "unblocked workflow call outer job, incomplete `strategy.matrix`",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job0", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job0"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
if: false
uses: ./.forgejo/workflows/reusable.yml
strategy:
matrix: ${{ fromJSON(needs.job0.outputs.something) }}
incomplete_matrix: true
incomplete_matrix_needs:
job: job0
output: something
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
`)},
},
want: map[int64]actions_model.Status{
2: actions_model.StatusWaiting,
},
},
{
name: "unblocked workflow call outer job with internal failure",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1.innerjob1", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "job1.innerjob2", Status: actions_model.StatusFailure, Needs: []string{}},
{ID: 3, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job1.innerjob1", "job1.innerjob2"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
if: false
uses: ./.forgejo/workflows/reusable.yml
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
`)},
},
want: map[int64]actions_model.Status{
3: actions_model.StatusFailure,
},
},
{
name: "unblocked workflow call outer job with internal failure",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "job1.innerjob1", Status: actions_model.StatusSkipped, Needs: []string{}},
{ID: 2, JobID: "job1.innerjob2", Status: actions_model.StatusFailure, Needs: []string{}},
{ID: 3, JobID: "job1", Status: actions_model.StatusBlocked, Needs: []string{"job1.innerjob1", "job1.innerjob2"}, WorkflowPayload: []byte(
`
name: test
on: push
jobs:
job2:
if: false
uses: ./.forgejo/workflows/reusable.yml
__metadata:
workflow_call_id: b5a9f46f1f2513d7777fde50b169d323a6519e349cc175484c947ac315a209ed
`)},
},
want: map[int64]actions_model.Status{
3: actions_model.StatusFailure,
},
},
{
name: "blocked if needs are unknown",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "build", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 2, JobID: "test", Status: actions_model.StatusBlocked, Needs: []string{"build", "unknown"}},
},
want: map[int64]actions_model.Status{},
},
{
name: "blocked if needs are unknown despite always()",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "build", Status: actions_model.StatusSuccess, Needs: []string{}},
{ID: 45, JobID: "test", Needs: []string{"build", "unknown"}, Status: actions_model.StatusBlocked, WorkflowPayload: []byte(`
on: push
jobs:
test:
runs-on: ubuntu-latest
needs: [build, unknown]
if: always()
steps: []
`)},
},
want: map[int64]actions_model.Status{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := newJobStatusResolver(tt.jobs)
assert.Equal(t, tt.want, r.Resolve())
})
}
}
const testWorkflowCallSimpleExpansion = `
on:
workflow_call:
inputs:
workflow_input:
type: string
jobs:
inner_job:
name: "inner ${{ inputs.workflow_input }}"
runs-on: debian-latest
steps:
- run: echo ${{ inputs.workflow_input }}
`
const testWorkflowCallMoreIncompleteExpansion = `
on:
workflow_call:
inputs:
workflow_input:
type: string
jobs:
define-runs-on:
name: "inner define-runs-on ${{ inputs.workflow_input }}"
runs-on: docker
outputs:
scalar-value: ${{ steps.define.outputs.scalar }}
steps:
- id: define
run: |
echo 'scalar=scalar value' >> "$FORGEJO_OUTPUT"
scalar-job:
name: "inner incomplete-job ${{ inputs.workflow_input }}"
runs-on: ${{ needs.define-runs-on.outputs.scalar-value }}
needs: define-runs-on
steps: []
`
type callArgsActionRunNowDone struct {
run *actions_model.ActionRun
priorStatus actions_model.Status
lastRun *actions_model.ActionRun
}
type mockNotifier struct {
notify_service.NullNotifier
calls []*callArgsActionRunNowDone
}
func (m *mockNotifier) ActionRunNowDone(ctx context.Context, run *actions_model.ActionRun, priorStatus actions_model.Status, lastRun *actions_model.ActionRun) {
m.calls = append(m.calls, &callArgsActionRunNowDone{run, priorStatus, lastRun})
}
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)
})()
type localReusableWorkflowCallArgs struct {
repoID int64
commitSHA string
path string
}
tests := []struct {
name string
runJobID int64
errContains string
consumed bool
runJobNames []string
preExecutionError actions_model.PreExecutionError
preExecutionErrorDetails []any
runsOn map[string][]string
needs map[string][]string
expectIncompleteJob []string
localReusableWorkflowCallArgs *localReusableWorkflowCallArgs
actionRunStatusChange actions_model.Status
}{
{
name: "not incomplete",
runJobID: 600,
},
{
name: "matrix expanded to 3 new jobs",
runJobID: 601,
consumed: true,
runJobNames: []string{"define-matrix", "produce-artifacts (blue)", "produce-artifacts (green)", "produce-artifacts (red)"},
needs: map[string][]string{
"define-matrix": nil,
"produce-artifacts (blue)": {"define-matrix"},
"produce-artifacts (green)": {"define-matrix"},
"produce-artifacts (red)": {"define-matrix"},
},
},
{
name: "needs an incomplete job",
runJobID: 603,
errContains: "jobStatusResolver attempted to tryHandleIncompleteMatrix for a job (id=603) with an incomplete 'needs' job (id=604)",
},
{
name: "missing needs for strategy.matrix evaluation",
runJobID: 605,
preExecutionError: actions_model.ErrorCodeIncompleteMatrixMissingJob,
preExecutionErrorDetails: []any{"produce-artifacts", "define-matrix-2", "define-matrix-1"},
},
{
name: "matrix expanded to 0 jobs",
runJobID: 607,
consumed: true,
runJobNames: []string{"define-matrix"},
},
{
name: "matrix multiple dimensions from separate outputs",
runJobID: 609,
consumed: true,
runJobNames: []string{
"define-matrix",
"run-tests (site-a, 12.x, 17)",
"run-tests (site-a, 12.x, 18)",
"run-tests (site-a, 14.x, 17)",
"run-tests (site-a, 14.x, 18)",
"run-tests (site-b, 12.x, 17)",
"run-tests (site-b, 12.x, 18)",
"run-tests (site-b, 14.x, 17)",
"run-tests (site-b, 14.x, 18)",
},
},
{
name: "matrix multiple dimensions from one output",
runJobID: 611,
consumed: true,
runJobNames: []string{
"define-matrix",
"run-tests (site-a, 12.x, 17)",
"run-tests (site-a, 12.x, 18)",
"run-tests (site-a, 14.x, 17)",
"run-tests (site-a, 14.x, 18)",
"run-tests (site-b, 12.x, 17)",
"run-tests (site-b, 12.x, 18)",
"run-tests (site-b, 14.x, 17)",
"run-tests (site-b, 14.x, 18)",
},
},
{
// This test case also includes `on: [push]` in the workflow_payload, which appears to trigger a regression
// in go.yaml.in/yaml/v4 v4.0.0-rc.2 (which I had accidentally referenced in job_emitter.go), and so serves
// as a regression prevention test for this case...
//
// unmarshal WorkflowPayload to SingleWorkflow failed: yaml: unmarshal errors: line 1: cannot unmarshal
// !!seq into yaml.Node
name: "scalar expansion into matrix",
runJobID: 613,
consumed: true,
runJobNames: []string{
"define-matrix",
"scalar-job (hard-coded value)",
"scalar-job (just some value)",
},
},
{
name: "missing needs output for strategy.matrix evaluation",
runJobID: 615,
preExecutionError: actions_model.ErrorCodeIncompleteMatrixMissingOutput,
preExecutionErrorDetails: []any{"produce-artifacts", "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"},
},
{
name: "workflow call remote reference unavailable",
runJobID: 629,
preExecutionError: actions_model.ErrorCodeJobParsingError,
preExecutionErrorDetails: []any{"unable to read instance workflow \"some-repo/some-org/.forgejo/workflows/reusable.yml@non-existent-reference\": someone deleted that reference maybe"},
},
{
name: "workflow call with needs expansion",
runJobID: 630,
consumed: true,
runJobNames: []string{
"define-workflow-call",
"inner my-workflow-input",
"perform-workflow-call",
},
needs: map[string][]string{
"define-workflow-call": nil,
"inner my-workflow-input": {"define-workflow-call"},
"perform-workflow-call": {"define-workflow-call", "perform-workflow-call.inner_job"},
},
},
// Before reusable workflow expansion, there weren't any cases where evaluating a job in the job emitter could
// result in more incomplete jobs being generated (other than errors). This is the first such case -- run job
// ID 632 references reusable workflow "more-incomplete" which generates more incomplete jobs.
{
name: "workflow call generates more incomplete jobs",
runJobID: 632,
consumed: true,
runJobNames: []string{
"define-workflow-call",
"inner define-runs-on my-workflow-input",
"inner incomplete-job my-workflow-input",
"perform-workflow-call",
},
runsOn: map[string][]string{
"define-workflow-call": {"fedora"},
"perform-workflow-call": {},
"inner define-runs-on my-workflow-input": {"docker"},
"inner incomplete-job my-workflow-input": {"${{ needs[format('{0}.{1}', 'perform-workflow-call', 'define-runs-on')].outputs.scalar-value }}"},
},
needs: map[string][]string{
"define-workflow-call": nil,
"inner define-runs-on my-workflow-input": {"define-workflow-call"},
"inner incomplete-job my-workflow-input": {"define-workflow-call", "perform-workflow-call.define-runs-on"},
"perform-workflow-call": {
"define-workflow-call",
"perform-workflow-call.define-runs-on",
"perform-workflow-call.scalar-job",
},
},
expectIncompleteJob: []string{"inner incomplete-job my-workflow-input"},
},
{
name: "missing needs job for workflow call evaluation",
runJobID: 634,
preExecutionError: actions_model.ErrorCodeIncompleteWithMissingJob,
preExecutionErrorDetails: []any{"perform-workflow-call", "oops-i-misspelt-the-job-id", "define-workflow-call"},
},
{
name: "missing needs output for workflow call evaluation",
runJobID: 636,
preExecutionError: actions_model.ErrorCodeIncompleteWithMissingOutput,
preExecutionErrorDetails: []any{"perform-workflow-call", "define-workflow-call", "output-doesnt-exist"},
},
{
name: "missing matrix dimension for workflow call evaluation",
runJobID: 638,
preExecutionError: actions_model.ErrorCodeIncompleteWithMissingMatrixDimension,
preExecutionErrorDetails: []any{"perform-workflow-call", "dimension-oops-error"},
},
{
name: "local workflow call with needs expansion",
runJobID: 640,
consumed: true,
runJobNames: []string{
"define-workflow-call",
"inner my-workflow-input",
"perform-workflow-call",
},
localReusableWorkflowCallArgs: &localReusableWorkflowCallArgs{
repoID: 63,
commitSHA: "97f29ee599c373c729132a5c46a046978311e0ee",
path: "./.forgejo/workflows/reusable.yml",
},
},
{
name: "action run completed after expansion",
runJobID: 642,
consumed: true,
runJobNames: []string{
"job1",
// job2 which expanded into an empty matrix is gone
},
actionRunStatusChange: actions_model.StatusSuccess,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer unittest.OverrideFixtures("services/actions/Test_tryHandleIncompleteMatrix")()
require.NoError(t, unittest.PrepareTestDatabase())
notifier := &mockNotifier{}
notify_service.RegisterNotifier(notifier)
defer notify_service.UnregisterNotifier(notifier)
// Mock access to reusable workflows, both local and remote
var localReusableCalled []*localReusableWorkflowCallArgs
var cleanupCallCount int
defer test.MockVariableValue(&lazyRepoExpandLocalReusableWorkflow,
func(ctx context.Context, repoID int64, commitSHA string) (jobparser.LocalWorkflowFetcher, CleanupFunc) {
fetcher := func(job *jobparser.Job, path string) ([]byte, error) {
localReusableCalled = append(localReusableCalled, &localReusableWorkflowCallArgs{repoID, commitSHA, path})
return []byte(testWorkflowCallSimpleExpansion), nil
}
cleanup := func() {
cleanupCallCount++
}
return fetcher, cleanup
})()
defer test.MockVariableValue(&expandInstanceReusableWorkflows,
func(ctx context.Context) jobparser.InstanceWorkflowFetcher {
return func(job *jobparser.Job, ref *model.NonLocalReusableWorkflowReference) ([]byte, error) {
switch ref.Ref {
case "non-existent-reference":
return nil, errors.New("someone deleted that reference maybe")
case "simple":
return []byte(testWorkflowCallSimpleExpansion), nil
case "more-incomplete":
return []byte(testWorkflowCallMoreIncompleteExpansion), nil
}
return nil, errors.New("unknown workflow reference")
}
})()
blockedJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: tt.runJobID})
jobsInRun, err := db.Find[actions_model.ActionRunJob](t.Context(), actions_model.FindRunJobOptions{RunID: blockedJob.RunID})
require.NoError(t, err)
behaviour, err := tryHandleIncompleteMatrix(t.Context(), blockedJob, jobsInRun)
if tt.errContains != "" {
require.ErrorContains(t, err, tt.errContains)
} else {
require.NoError(t, err)
if tt.consumed {
assert.Equal(t, behaviourIgnoreJob, behaviour)
// blockedJob should no longer exist in the database
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{ID: tt.runJobID})
// expectations are that the ActionRun has an empty PreExecutionError
actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: blockedJob.RunID})
assert.EqualValues(t, 0, actionRun.PreExecutionErrorCode, "PreExecutionError Details: %#v", actionRun.PreExecutionErrorDetails)
if tt.actionRunStatusChange != 0 {
assert.Equal(t, tt.actionRunStatusChange, actionRun.Status)
require.Len(t, notifier.calls, 1)
call := notifier.calls[0]
assert.Equal(t, actionRun.ID, call.run.ID)
assert.Nil(t, call.lastRun)
assert.Equal(t, actions_model.StatusRunning, call.priorStatus)
assert.Equal(t, tt.actionRunStatusChange, call.run.Status)
}
// 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})
require.NoError(t, err)
allJobNames := []string{}
for _, j := range allJobsInRun {
allJobNames = append(allJobNames, j.Name)
}
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)
}
}
}
if tt.needs != nil {
for _, j := range allJobsInRun {
expected, ok := tt.needs[j.Name]
if assert.Truef(t, ok, "unable to find needs[%q] in test case", j.Name) {
slices.Sort(j.Needs)
slices.Sort(expected)
assert.Equalf(t, expected, j.Needs, "comparing needs expectations for job %q", j.Name)
}
}
}
if tt.expectIncompleteJob != nil {
for _, j := range allJobsInRun {
if slices.Contains(tt.expectIncompleteJob, j.Name) {
m, _, err := j.HasIncompleteMatrix()
require.NoError(t, err)
r, _, _, err := j.HasIncompleteRunsOn()
require.NoError(t, err)
w, _, _, err := j.HasIncompleteWith()
require.NoError(t, err)
assert.True(t, m || r || w, "job %s was expected to still be marked as incomplete", j.Name)
}
}
}
if tt.localReusableWorkflowCallArgs != nil {
require.Len(t, localReusableCalled, 1)
assert.Equal(t, tt.localReusableWorkflowCallArgs, localReusableCalled[0])
assert.Equal(t, 1, cleanupCallCount)
}
} 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.Equal(t, tt.preExecutionError, actionRun.PreExecutionErrorCode)
assert.Equal(t, tt.preExecutionErrorDetails, actionRun.PreExecutionErrorDetails)
assert.Equal(t, actions_model.StatusFailure, actionRun.Status)
// ActionRunJob is marked as failed
blockedJobReloaded := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: tt.runJobID})
assert.Equal(t, actions_model.StatusFailure, blockedJobReloaded.Status)
// ensure all other jobs in this run are ignored
assert.Equal(t, behaviourIgnoreAllJobsInRun, behaviour)
} else {
assert.Equal(t, behaviourExecuteJob, behaviour)
}
}
})
}
}
func Test_tryHandleWorkflowCallOuterJob(t *testing.T) {
tests := []struct {
name string
runJobID int64
updateFields []string
outputs map[string]string
expectedAttempt int
}{
{
name: "not workflow call outer job",
runJobID: 600,
},
{
name: "outputs for every context",
runJobID: 601,
updateFields: []string{"task_id", "attempt"},
outputs: map[string]string{
"from_inner_job": "abcdefghijklmnopqrstuvwxyz",
"from_inner_job_result": "success",
"from_forgejo_ctx": "refs/heads/main",
"from_input_ctx": "hello, world!",
"from_vars_repo": "this is a repo variable",
"from_vars_org": "this is an org variable",
"from_vars_global": "this is a global variable",
},
expectedAttempt: 1,
},
{
name: "attempt 2 rerun task",
runJobID: 603,
updateFields: []string{"task_id", "attempt"},
outputs: map[string]string{
"from_inner_job": "abcdefghijklmnopqrstuvwxyz",
"from_inner_job_result": "success",
"from_forgejo_ctx": "refs/heads/main",
"from_input_ctx": "hello, world!",
"from_vars_repo": "this is a repo variable",
"from_vars_org": "this is an org variable",
"from_vars_global": "this is a global variable",
},
expectedAttempt: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer unittest.OverrideFixtures("services/actions/Test_tryHandleWorkflowCallOuterJob")()
require.NoError(t, unittest.PrepareTestDatabase())
outerJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: tt.runJobID})
require.EqualValues(t, 0, outerJob.TaskID)
updateFields, err := tryHandleWorkflowCallOuterJob(t.Context(), outerJob)
require.NoError(t, err)
assert.Equal(t, tt.updateFields, updateFields)
if tt.updateFields != nil {
assert.EqualValues(t, tt.expectedAttempt, outerJob.Attempt)
// TaskID expected to be set by tryHandleWorkflowCallOuterJob
require.NotEqualValues(t, 0, outerJob.TaskID)
taskOutputs, err := actions_model.FindTaskOutputByTaskID(t.Context(), outerJob.TaskID)
require.NoError(t, err)
outputMap := map[string]string{}
for _, to := range taskOutputs {
outputMap[to.OutputKey] = to.OutputValue
}
assert.Equal(t, tt.outputs, outputMap)
}
})
}
}
func Test_checkJobsOfRun(t *testing.T) {
defer unittest.OverrideFixtures("services/actions/Test_checkJobsOfRun")()
require.NoError(t, unittest.PrepareTestDatabase())
reusableWorkflow := `
on:
workflow_call:
inputs:
argument:
type: string
jobs:
reusable:
runs-on: ubuntu-latest
steps:
- run: |
echo "Argument: ${{ inputs.argument }}"
`
defer test.MockVariableValue(&lazyRepoExpandLocalReusableWorkflow,
func(ctx context.Context, repoID int64, commitSHA string) (jobparser.LocalWorkflowFetcher, CleanupFunc) {
fetcher := func(job *jobparser.Job, path string) ([]byte, error) {
return []byte(reusableWorkflow), nil
}
cleanup := func() {
}
return fetcher, cleanup
})()
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), 901)
require.NoError(t, err)
require.Len(t, jobs, 4)
require.NoError(t, checkJobsOfRun(t.Context(), 901, 0))
jobs, err = actions_model.GetRunJobsByRunID(t.Context(), 901)
require.NoError(t, err)
assert.Len(t, jobs, 5)
assert.Equal(t, "a", jobs[0].JobID)
assert.Equal(t, actions_model.StatusSuccess, jobs[0].Status)
assert.Equal(t, "b", jobs[1].JobID)
assert.Equal(t, actions_model.StatusSuccess, jobs[1].Status)
assert.Equal(t, "b.reusable", jobs[2].JobID)
assert.Equal(t, actions_model.StatusSuccess, jobs[2].Status)
assert.Equal(t, "c", jobs[3].JobID)
assert.Equal(t, actions_model.StatusBlocked, jobs[3].Status)
assert.Equal(t, "c.reusable", jobs[4].JobID)
assert.Equal(t, actions_model.StatusWaiting, jobs[4].Status)
}
func Test_checkJobsOfRun_ExpandsMatrixWithCorrectOutputJobStatuses(t *testing.T) {
defer unittest.OverrideFixtures("services/actions/Test_checkJobsOfRun")()
require.NoError(t, unittest.PrepareTestDatabase())
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), 900)
require.NoError(t, err)
require.Len(t, jobs, 2)
require.NoError(t, checkJobsOfRun(t.Context(), 900, 0))
jobs, err = actions_model.GetRunJobsByRunID(t.Context(), 900)
require.NoError(t, err)
assert.Len(t, jobs, 4)
for _, job := range jobs {
switch job.Name {
case "define-matrix":
assert.Equal(t, actions_model.StatusSuccess, job.Status)
case "produce-artifacts (blue)":
fallthrough
case "produce-artifacts (green)":
fallthrough
case "produce-artifacts (red)":
assert.Equal(t, actions_model.StatusWaiting, job.Status)
default:
assert.Fail(t, "unexpected job name")
}
}
}