mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 14:03:24 +09:00
fix(actions): dynamic matrix expansion correctness fixes (#38690)
Follow-up to https://github.com/go-gitea/gitea/pull/36564 (dynamic matrix) and https://github.com/go-gitea/gitea/pull/36357 (max-parallel), fixing issues found reviewing the two features together. - **A placeholder could stall its run forever.** Its payload keeps the raw matrix but loses its `needs`, so `ParseJob` re-expanded it instead of reading it back — fatal for `include: ${{ fromJson(needs.*.outputs.*) }}`. - **An `if:` reading `matrix.*` skipped the whole job**, with or without the `${{ }}`. It now reduces to the needs gate, except under `always()`/`failure()`/`cancelled()`, and each combination is decided on its own values once the matrix expands. - **Dependents could be skipped before the combinations ran**, since inserted siblings are absent from the resolver's job set. The pass now stops after an insert and defers to the re-emit it schedules. - **Expansion failures stranded the placeholder.** A retryable one is returned so the queue retries it; a malformed payload fails the job instead of requeueing forever. - **Rerun could rewind a pass-through row** into a raw placeholder keeping its old terminal status, which nothing expands. Now gated on the anchor itself. Plus: `max-parallel` distinguishes an unevaluated `${{ }}` (debug) from a non-numeric literal (warn — it silently drops the cap). Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
co-authored by
Zettat123
silverwind
parent
e80a62f555
commit
11d0ed699b
@@ -26,6 +26,10 @@ import (
|
||||
// planned as a single placeholder and expanded once its dependency completes. `build` exercises the
|
||||
// expansion, `report` that a downstream job sees the combinations' outputs, `gated` and `partial` the
|
||||
// `if:` gates on either side of it, and `static` that a matrix expanded at plan time is left alone.
|
||||
// `included` covers the placeholder shapes that only survive as long as nothing re-parses their
|
||||
// payload: `include:` is still a scalar there, which act refuses to read at all.
|
||||
// `partial` and `strict` gate on `matrix.*` from either direction: neither may be decided before the
|
||||
// combinations exist, or the whole job is skipped instead of the combinations the gate excludes.
|
||||
// A full rerun then re-derives the matrix from the new attempt's outputs instead of reusing the
|
||||
// previous combinations, keeping the AttemptJobID of every combination that recurs.
|
||||
func TestDynamicMatrixEvaluation(t *testing.T) {
|
||||
@@ -47,9 +51,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set.outputs.matrix }}
|
||||
combos: ${{ steps.set.outputs.combos }}
|
||||
steps:
|
||||
- id: set
|
||||
run: echo "matrix=[1,2]" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
echo "matrix=[1,2]" >> "$GITHUB_OUTPUT"
|
||||
echo 'combos=[{"name":"x"},{"name":"y"}]' >> "$GITHUB_OUTPUT"
|
||||
build:
|
||||
needs: [generate]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -69,6 +76,14 @@ jobs:
|
||||
os: [a, b]
|
||||
steps:
|
||||
- run: echo "${{ matrix.os }}"
|
||||
included:
|
||||
needs: [generate]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include: ${{ fromJson(needs.generate.outputs.combos) }}
|
||||
steps:
|
||||
- run: echo "${{ matrix.name }}"
|
||||
gated:
|
||||
needs: [generate]
|
||||
if: false
|
||||
@@ -87,6 +102,15 @@ jobs:
|
||||
value: ${{ fromJson(needs.generate.outputs.matrix) }}
|
||||
steps:
|
||||
- run: echo "${{ matrix.value }}"
|
||||
strict:
|
||||
needs: [generate]
|
||||
if: matrix.value == 1
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
value: ${{ fromJson(needs.generate.outputs.matrix) }}
|
||||
steps:
|
||||
- run: echo "${{ matrix.value }}"
|
||||
report:
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -101,7 +125,7 @@ jobs:
|
||||
_, _, run := getTaskAndJobAndRunByTaskID(t, generateTask.Id)
|
||||
runner.execTask(t, generateTask, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
outputs: map[string]string{"matrix": "[1,2]"},
|
||||
outputs: map[string]string{"matrix": "[1,2]", "combos": `[{"name":"x"},{"name":"y"}]`},
|
||||
})
|
||||
|
||||
// execAttempt runs the attempt's remaining tasks, recording each job's name and AttemptJobID(order is not fixed).
|
||||
@@ -127,13 +151,20 @@ jobs:
|
||||
return names
|
||||
}
|
||||
|
||||
seen := execAttempt(t, 6)
|
||||
seen := execAttempt(t, 9)
|
||||
firstAttemptIDs := maps.Clone(attemptJobIDs)
|
||||
// `gated` is decided before the matrix is touched, so it never expands and is skipped as one job;
|
||||
// `partial (1)` is decided afterwards, against its own combination.
|
||||
assert.ElementsMatch(t, []string{"build (1)", "build (2)", "static (a)", "static (b)", "partial (2)", "report"}, seen)
|
||||
skipped := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "partial (1)"})
|
||||
assert.Equal(t, actions_model.StatusSkipped, skipped.Status)
|
||||
// `gated` is decided before the matrix is touched, so it never expands and is skipped as one job;
|
||||
// `partial (1)` and `strict (2)` are decided afterwards, each against its own combination.
|
||||
assert.ElementsMatch(t, []string{
|
||||
"build (1)", "build (2)", "included (x)", "included (y)",
|
||||
"static (a)", "static (b)", "partial (2)", "strict (1)", "report",
|
||||
}, seen)
|
||||
for _, name := range []string{"partial (1)", "strict (2)"} {
|
||||
skipped := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: name})
|
||||
assert.Equal(t, actions_model.StatusSkipped, skipped.Status)
|
||||
}
|
||||
// A gate reading `matrix.*` must not be decided against the unexpanded placeholder.
|
||||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "strict"})
|
||||
|
||||
buildNeed, ok := reportTask.Needs["build"]
|
||||
require.True(t, ok, "report must see the expanded combinations as its 'build' need")
|
||||
@@ -149,14 +180,16 @@ jobs:
|
||||
require.Equal(t, "generate", getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, generateTask2.Id))
|
||||
assert.Equal(t, "2", generateTask2.Context.GetFields()["run_attempt"].GetStringValue())
|
||||
runner.execTask(t, generateTask2, &mockTaskOutcome{
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
outputs: map[string]string{"matrix": "[1,2,3]"},
|
||||
result: runnerv1.Result_RESULT_SUCCESS,
|
||||
// `combos` is unchanged, so both `included` combinations recur and have to keep their AttemptJobIDs.
|
||||
outputs: map[string]string{"matrix": "[1,2,3]", "combos": `[{"name":"x"},{"name":"y"}]`},
|
||||
})
|
||||
|
||||
// The matrix now yields a third value, while `static` is cloned as-is rather than collapsed.
|
||||
seenRerun := execAttempt(t, 8)
|
||||
seenRerun := execAttempt(t, 11)
|
||||
assert.ElementsMatch(t, []string{
|
||||
"build (1)", "build (2)", "build (3)", "static (a)", "static (b)", "partial (2)", "partial (3)", "report",
|
||||
"build (1)", "build (2)", "build (3)", "included (x)", "included (y)",
|
||||
"static (a)", "static (b)", "partial (2)", "partial (3)", "strict (1)", "report",
|
||||
}, seenRerun)
|
||||
for name, firstID := range firstAttemptIDs {
|
||||
assert.Equal(t, firstID, attemptJobIDs[name], "%s keeps its AttemptJobID across attempts", name)
|
||||
|
||||
Reference in New Issue
Block a user