fix: jobs with ${{ needs.x.[...] }} inherit the skip state of job x (#13174)

In cases where a job references `${{ needs.x.[...]}}` in a critical section relevant for dispatching a job -- `strategy.matrix`, `runs-on`, or `with` -- referencing a skipped job as `x` would cause an error that the job is missing the required output.  With this change, the referencing job is now automatically skipped.

Fixes #13165.

I've performed manual testing on my dev instance and verified that the end-to-end multiarch test, identified as the problem in #13165, now successfully gets skipped as would be expected (and, not in the screenshot, that the matrix expands correctly when the `if` condition is true and real jobs are run).

![image](/attachments/96042c48-189f-48c5-a6b2-a48c6660fa1c)

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

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

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

- [ ] 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.
- [x] 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.
    - Fixes regression from #13030 which is unreleased.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/13174
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak 2026-06-23 21:19:11 +02:00 committed by Mathieu Fenniak
commit 3ea1f3f784
2 changed files with 213 additions and 8 deletions

View file

@ -1168,6 +1168,73 @@ func TestActionsRunsEvaluateIf(t *testing.T) {
run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
})
t.Run("matrix requires skipped job", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIncompleteMatrix().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSkipped.String(), run.Status.String())
})
t.Run("runs-on requires skipped job", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIncompleteRunsOn().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSkipped.String(), run.Status.String())
})
t.Run("with requires skipped job", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIncompleteWith().ID
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSkipped.String(), run.Status.String())
})
// If a `strategy.matrix` is used to expand a dependant job into multiple jobs, and, that expansion also
// requires an input from another job, and, that other job has a server-side evaluated `if` that returns
// false...
t.Run("matrix expansion and incomplete job simultaneously job", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
arif := newActionsRunIfTester(t)
runID := arif.dispatchIncompleteRunsOnWithMatrix().ID
// Provide three values to the 'dim1' output which will become a matrix dimension.
task := arif.mockRunTaskWithOutputs(map[string]string{"dim1": "[\"abc\",\"def\",\"ghj\"]"})
dbTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id})
require.NoError(t, dbTask.LoadJob(t.Context()))
assert.Equal(t, "matrix-output", dbTask.Job.Name)
// runs-on-output will be server-side skipped, and dependent-job will have a cascade skip because the
// 'runs-on' value refers to a skipped job.
arif.assertNoRunnableJobs()
run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: runID})
assert.Equal(t, actions_model.StatusSuccess.String(), run.Status.String())
jobs, err := actions_model.GetRunJobsByRunID(t.Context(), run.ID)
require.NoError(t, err)
// Currently this behaviour doesn't expand the jobs, it goes straight to skipping them. That's reasonable
// behaviour at the moment. In the future, it would also be reasonable for it to expand-then-skip -- both
// choices have the same functional outputs. The expand-then-skip option may become the behaviour if we add
// `matrix` context access to the `if` block in the future, since the evaluation order would have to change
// a bit.
expected := []string{"matrix-output", "runs-on-output", "dependent-job (incomplete matrix)"}
for _, j := range jobs {
if assert.Contains(t, expected, j.Name) {
expected = slices.DeleteFunc(expected, func(s string) bool { return s == j.Name })
}
}
})
})
}
@ -1206,11 +1273,14 @@ func newActionsRunIfTester(t *testing.T) *ActionsRunIfTester {
}
}
func (tester *ActionsRunIfTester) addWorkflowFile(filename, workflow string) *api.FileResponse {
options := getWorkflowCreateFileOptions(tester.user, tester.apiRepo.DefaultBranch, fmt.Sprintf("create %s", filename), workflow)
return createWorkflowFile(tester.t, tester.apiToken, tester.user.Name, tester.apiRepo.Name, filename, options)
}
func (tester *ActionsRunIfTester) dispatch(workflow string) *api.DispatchWorkflowRun {
workflowPath := ".forgejo/workflows/serverside_if.yml"
options := getWorkflowCreateFileOptions(tester.user, tester.apiRepo.DefaultBranch, fmt.Sprintf("create %s", workflowPath), workflow)
createWorkflowFile(tester.t, tester.apiToken, tester.user.Name, tester.apiRepo.Name, workflowPath, options)
tester.addWorkflowFile(workflowPath, workflow)
dispatchRequest := NewRequestWithJSON(tester.t, "POST",
fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/serverside_if.yml/dispatches", tester.user.Name, tester.apiRepo.Name),
@ -1230,15 +1300,13 @@ func (tester *ActionsRunIfTester) dispatch(workflow string) *api.DispatchWorkflo
func (tester *ActionsRunIfTester) pushEvent(workflow string) *actions_model.ActionRun {
workflowPath := ".forgejo/workflows/serverside_if.yml"
options := getWorkflowCreateFileOptions(tester.user, tester.apiRepo.DefaultBranch, fmt.Sprintf("create %s", workflowPath), workflow)
resp := createWorkflowFile(tester.t, tester.apiToken, tester.user.Name, tester.apiRepo.Name, workflowPath, options)
resp := tester.addWorkflowFile(workflowPath, workflow)
return unittest.AssertExistsAndLoadBean(tester.t, &actions_model.ActionRun{CommitSHA: resp.Commit.SHA})
}
func (tester *ActionsRunIfTester) forceSchedule(workflow string) *actions_model.ActionRun {
workflowPath := ".forgejo/workflows/serverside_if.yml"
options := getWorkflowCreateFileOptions(tester.user, tester.apiRepo.DefaultBranch, fmt.Sprintf("create %s", workflowPath), workflow)
resp := createWorkflowFile(tester.t, tester.apiToken, tester.user.Name, tester.apiRepo.Name, workflowPath, options)
resp := tester.addWorkflowFile(workflowPath, workflow)
payload := &api.SchedulePayload{
Action: api.HookScheduleCreated,
}
@ -1545,14 +1613,116 @@ jobs:
`)
}
func (tester *ActionsRunIfTester) dispatchIncompleteMatrix() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: ${{ '123' == 'abc' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
strategy:
matrix:
dim1: ${{ needs.test-1.outputs.output-not-existing }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIncompleteRunsOn() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: ${{ '123' == 'abc' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
runs-on: ${{ needs.test-1.outputs.output-not-existing }}
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIncompleteRunsOnWithMatrix() *api.DispatchWorkflowRun {
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
# This job will provide an output...
matrix-output:
runs-on: ubuntu
steps:
- run: echo "Job contents go here." # (output would be here, but runner will be mocked)
# This job will be relied upon for an output, but will be skipped.
runs-on-output:
if: ${{ '123' == 'abc' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here." # (output would be here, but runner will be mocked)
dependent-job:
needs: [matrix-output, runs-on-output]
strategy:
matrix:
dim1: ${{ fromJSON(needs.matrix-output.outputs.dim1) }}
runs-on: ${{ needs.runs-on-output.outputs.output-not-existing }}
steps:
- run: echo "Job contents go here."
`)
}
func (tester *ActionsRunIfTester) dispatchIncompleteWith() *api.DispatchWorkflowRun {
tester.addWorkflowFile(".forgejo/workflows/reusable.yml", `
on:
workflow_call:
inputs:
my_input:
type: string
jobs:
inner-job:
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
`)
return tester.dispatch(`
on:
workflow_dispatch:
jobs:
test-1:
if: ${{ '123' == 'abc' }}
runs-on: ubuntu
steps:
- run: echo "Job contents go here."
test-2:
needs: [test-1]
uses: ./.forgejo/workflows/reusable.yml
with:
my_input: ${{ needs.test-1.outputs.output-not-existing }}
`)
}
func (tester *ActionsRunIfTester) assertNoRunnableJobs() {
assert.Nil(tester.t, tester.runner.maybeFetchTask(tester.t))
}
func (tester *ActionsRunIfTester) mockRunTask() *runnerv1.Task {
return tester.mockRunTaskWithOutputs(nil)
}
func (tester *ActionsRunIfTester) mockRunTaskWithOutputs(outputs map[string]string) *runnerv1.Task {
task := tester.runner.fetchTask(tester.t)
tester.runner.execTask(tester.t, task, &mockTaskOutcome{
result: runnerv1.Result_RESULT_SUCCESS,
result: runnerv1.Result_RESULT_SUCCESS,
outputs: outputs,
})
return task
}