jojo/services/actions/job_emitter_test.go
Mathieu Fenniak fa5a52b983 fix: simultaneously experiencing a PreExecutionError and unblocking a different job causes error blocking job emitter queue (#10665)
In a workflow such as:
```yaml
jobs:
  define-matrix:
    runs-on: docker
    outputs:
      array-value: ${{ steps.define.outputs.array }}
    steps:
      - id: define
        run: |
          echo 'array=["value 1", "value 2"]' >> "$FORGEJO_OUTPUT"

  array-job:
    runs-on: docker
    needs: define-matrix
    strategy:
      matrix:
        array: ${{ fromJSON(needs.define-matrix.outputs.array-value-oops-i-made-an-error-here) }}
    steps: # ...

  other-job:
    runs-on: docker
    needs: define-matrix
    steps: # .... ${{ needs.define-matrix.outputs.array-value }}
```

After the job `define-matrix` is done, an error will be triggered because `array-value-oops-i-made-an-error-here` is not a valid output, and so `array-job` can't be figured out.  When the job emitter triggers that error and stores it in the database, it will mark all the jobs in the workflow as failed (`FailRunPreExecutionError()`) in order to ensure that no blocked jobs remain and appear stuck forever.

However, `other-job` is also unblocked by `job_emitter.go` because it's dependency of `define-matrix` is now complete.  After the error occurs, job emitter will attempt to unblock `other-job` and the conditional `UpdateRunJob` will fail because the condition `"status": StatusBlocked` is no longer true:

0af52cdca2/services/actions/job_emitter.go (L88-L92)

This causes an error, and that error rolls back the transaction in `checkJobsOfRun`, and that causes job emitter's queue to constantly retry the same work which has the same outcome each time.

This fix tells `checkJobsOfRun` that an error occurred that prevents all jobs in the run from progressing, and therefore no updates need to proceed.

Discovered while authoring https://code.forgejo.org/forgejo/end-to-end/pulls/1367 and causing an error unintentionally. 🤣

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

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

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10665
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
2026-01-02 17:11:12 +01:00

744 lines
25 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"
"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{
2: actions_model.StatusSkipped,
3: 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, 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,
},
},
}
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: []
`
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
}{
{
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)"},
},
{
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": nil,
"perform-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": nil,
"inner incomplete-job my-workflow-input": {"perform-workflow-call.define-runs-on"},
"perform-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",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer unittest.OverrideFixtures("services/actions/Test_tryHandleIncompleteMatrix")()
require.NoError(t, unittest.PrepareTestDatabase())
// 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)
// 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 runsOn[%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)
}
})
}
}