mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 06:33:42 +09:00
Updates actionslib to https://gitea.com/gitea/actionslib/releases/tag/v1.2.1, moves workflow parsing into it and aligns behaviour with GitHub. 1. `uses:` supports `self:` (Gitea-only feature) and `$/` paths. 1. `strategy`, `matrix`, `max-parallel` and `fail-fast` accept expressions, including over `needs`. A job whose `name`, `runs-on` or `continue-on-error` reads `needs` is resolved once they finish. 1. A job `if:` may only read `github`, `needs`, `vars` and `inputs` and is decided before the matrix, as on github.com. 1. Matrix `fail-fast` cancels the other combinations, and `always()` jobs keep running when a run is cancelled. 1. Invalid workflow files, including a malformed `on:` and unknown or cyclic `needs`, show up on push as failed runs with the error. 1. A job whose `if:` or `concurrency:` fails to evaluate is skipped or failed with the error, instead of staying blocked. 1. Reusable workflows: a missing and an unreadable repository fail alike, public callers cannot use private workflows, nested jobs cannot exceed the caller's token permissions. 1. Runner labels match case-insensitively, and `runs-on` accepts an array from an expression. Runner PR: https://gitea.com/gitea/runner/pulls/1247 Docs PR: https://gitea.com/gitea/docs/pulls/553 Fixes: https://github.com/go-gitea/gitea/issues/38990 Fixes: https://github.com/go-gitea/gitea/issues/39382 Fixes: https://github.com/go-gitea/gitea/issues/32364 Fixes: https://github.com/go-gitea/gitea/issues/36077 Fixes: https://github.com/go-gitea/gitea/issues/23277 Fixes: https://github.com/go-gitea/gitea/issues/29020 Co-authored-by: Claude (Opus 5) <noreply@anthropic.com> Co-authored-by: Zettat123 <zettat123@gmail.com>
245 lines
10 KiB
Go
245 lines
10 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package actions
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
actions_model "gitea.dev/models/actions"
|
|
"gitea.dev/models/db"
|
|
"gitea.dev/models/unittest"
|
|
"gitea.dev/modules/actions/jobparser"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// testRunIndex hands out the per-repo run index, which is unique per action_run row.
|
|
var testRunIndex int64 = 9100
|
|
|
|
// setupDeferredMatrixJob plants a completed `generate` job exposing outputs and the blocked `build`
|
|
// placeholder that depends on them, and returns the placeholder. Both are children of a reusable
|
|
// workflow caller, the case where a sibling losing ParentJobID would break needs resolution.
|
|
func setupDeferredMatrixJob(t *testing.T, matrixValue string, outputs map[string]string) *actions_model.ActionRunJob {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
|
|
// The `build` job takes its matrix from `generate`'s outputs, so Parse defers it.
|
|
workflows, err := jobparser.Parse(fmt.Appendf(nil, `
|
|
on: push
|
|
jobs:
|
|
generate:
|
|
runs-on: ubuntu-latest
|
|
steps: [{run: echo}]
|
|
build:
|
|
needs: generate
|
|
runs-on: ubuntu-latest
|
|
strategy:
|
|
matrix:
|
|
value: %s
|
|
steps: [{run: echo}]
|
|
`, matrixValue))
|
|
require.NoError(t, err)
|
|
var placeholder *jobparser.SingleWorkflow
|
|
for _, workflow := range workflows {
|
|
if id, _ := workflow.Job(); id == "build" {
|
|
placeholder = workflow
|
|
}
|
|
}
|
|
require.NotNil(t, placeholder, "the build job must be planned as a single deferred placeholder")
|
|
id, job := placeholder.Job()
|
|
require.NoError(t, placeholder.SetJob(id, job.EraseNeeds()))
|
|
payload, err := placeholder.Marshal()
|
|
require.NoError(t, err)
|
|
|
|
testRunIndex++
|
|
run := &actions_model.ActionRun{
|
|
RepoID: 4, OwnerID: 1, TriggerUserID: 1, Index: testRunIndex,
|
|
WorkflowID: "dynamic.yml", Ref: "refs/heads/main", Status: actions_model.StatusRunning,
|
|
}
|
|
require.NoError(t, db.Insert(ctx, run))
|
|
attempt := &actions_model.ActionRunAttempt{RepoID: 4, RunID: run.ID, Attempt: 1, Status: actions_model.StatusRunning}
|
|
require.NoError(t, db.Insert(ctx, attempt))
|
|
run.LatestAttemptID = attempt.ID
|
|
require.NoError(t, actions_model.UpdateRun(ctx, run, "latest_attempt_id"))
|
|
|
|
// Needs outputs are read straight from action_task_output, so no action_task row is required;
|
|
// the run's id doubles as a task id that is unique across subtests.
|
|
taskID := run.ID
|
|
for key, value := range outputs {
|
|
require.NoError(t, db.Insert(ctx, &actions_model.ActionTaskOutput{TaskID: taskID, OutputKey: key, OutputValue: value}))
|
|
}
|
|
|
|
newJob := func(jobID string) *actions_model.ActionRunJob {
|
|
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, run.ID)
|
|
require.NoError(t, err)
|
|
return &actions_model.ActionRunJob{
|
|
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: attemptJobID, RepoID: 4, OwnerID: 1,
|
|
JobID: jobID, Name: jobID, Status: actions_model.StatusRunning,
|
|
}
|
|
}
|
|
caller := newJob("call")
|
|
caller.IsReusableCaller, caller.IsExpanded = true, true
|
|
require.NoError(t, db.Insert(ctx, caller))
|
|
|
|
generate := newJob("generate")
|
|
generate.ParentJobID, generate.Status, generate.TaskID = caller.ID, actions_model.StatusSuccess, taskID
|
|
require.NoError(t, db.Insert(ctx, generate))
|
|
|
|
build := newJob("build")
|
|
build.ParentJobID, build.Status = caller.ID, actions_model.StatusBlocked
|
|
build.Needs, build.WorkflowPayload, build.IsMatrixDeferred = []string{"generate"}, payload, true
|
|
build.DeferredMatrixPayload = payload
|
|
// Values a sibling must inherit rather than silently reset.
|
|
build.WorkflowSourceRepoID, build.WorkflowSourceCommitSHA = 42, "abc123"
|
|
require.NoError(t, db.Insert(ctx, build))
|
|
return build
|
|
}
|
|
|
|
func TestExpandDeferredMatrix(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
t.Run("expands into siblings", func(t *testing.T) {
|
|
job := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}", map[string]string{"values": `["a","b","c"]`})
|
|
|
|
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, siblings, 2)
|
|
|
|
assert.Equal(t, "build (a)", job.Name)
|
|
assert.False(t, job.IsMatrixDeferred)
|
|
assert.Equal(t, actions_model.StatusBlocked, job.Status)
|
|
|
|
names := []string{job.Name}
|
|
for _, sibling := range siblings {
|
|
names = append(names, sibling.Name)
|
|
assert.Equal(t, actions_model.StatusBlocked, sibling.Status)
|
|
assert.False(t, sibling.IsMatrixDeferred, "a sibling must not be expanded again")
|
|
assert.Equal(t, []string{"generate"}, sibling.Needs)
|
|
assert.Equal(t, job.ParentJobID, sibling.ParentJobID, "needs resolution is scoped by ParentJobID")
|
|
assert.Equal(t, int64(42), sibling.WorkflowSourceRepoID)
|
|
assert.Equal(t, "abc123", sibling.WorkflowSourceCommitSHA)
|
|
assert.Greater(t, sibling.AttemptJobID, job.AttemptJobID, "siblings take fresh ids from the run-wide counter")
|
|
assert.Empty(t, sibling.DeferredMatrixPayload, "only the placeholder anchors the group with the raw payload")
|
|
}
|
|
assert.ElementsMatch(t, []string{"build (a)", "build (b)", "build (c)"}, names)
|
|
|
|
reloaded := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID})
|
|
assert.Equal(t, "build (a)", reloaded.Name)
|
|
assert.False(t, reloaded.IsMatrixDeferred)
|
|
assert.NotEmpty(t, reloaded.DeferredMatrixPayload, "the claim must not erase the raw payload")
|
|
reloaded.MaxParallel = 3
|
|
require.NoError(t, restoreDeferredMatrixPlaceholder(reloaded))
|
|
assert.Equal(t, "build", reloaded.Name)
|
|
assert.Zero(t, reloaded.MaxParallel)
|
|
assert.True(t, reloaded.IsMatrixDeferred)
|
|
})
|
|
|
|
// A matrix that can never produce runnable combinations fails the job instead of rolling the
|
|
// emitter pass back, so it is not retried forever.
|
|
for _, tt := range []struct {
|
|
name, matrixValue string
|
|
outputs map[string]string
|
|
prepare func(t *testing.T, job *actions_model.ActionRunJob)
|
|
}{
|
|
{name: "unresolvable matrix", matrixValue: "${{ fromJson(needs.generate.outputs.missing) }}"},
|
|
{
|
|
name: "expansion exceeding the per-attempt job limit",
|
|
matrixValue: "${{ fromJson(needs.generate.outputs.many) }}",
|
|
// One combination more than the attempt has room for: the cap is MaxJobNumPerRun less
|
|
// the rows the setup plants, plus the placeholder the first combination reuses.
|
|
outputs: map[string]string{"many": "[" + strings.Repeat("0,", actions_model.MaxJobNumPerRun-2) + "0]"},
|
|
},
|
|
{
|
|
// A malformed payload fails the same way on every pass, so returning it would requeue the
|
|
// run forever instead of ever reaching a terminal status.
|
|
name: "unreadable caller inputs",
|
|
matrixValue: "${{ fromJson(needs.generate.outputs.values) }}",
|
|
prepare: func(t *testing.T, job *actions_model.ActionRunJob) {
|
|
_, err := db.Exec(t.Context(), "UPDATE `action_run_job` SET call_payload = ? WHERE id = ?", "{", job.ParentJobID)
|
|
require.NoError(t, err)
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tt.name+" fails the job", func(t *testing.T) {
|
|
outputs := tt.outputs
|
|
if outputs == nil {
|
|
outputs = map[string]string{"values": `["a","b","c"]`}
|
|
}
|
|
job := setupDeferredMatrixJob(t, tt.matrixValue, outputs)
|
|
if tt.prepare != nil {
|
|
tt.prepare(t, job)
|
|
}
|
|
|
|
siblings, err := expandDeferredMatrix(t.Context(), job, nil)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, siblings)
|
|
assert.Equal(t, actions_model.StatusFailure, job.Status)
|
|
assert.True(t, job.IsMatrixDeferred, "the flag survives, marking the payload as still unexpanded for reruns")
|
|
assert.NotZero(t, job.Stopped)
|
|
// A rejected expansion must not leave the first combination's name behind.
|
|
assert.Equal(t, "build", unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Name)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeferredMatrixResolverSkipsWithoutSucceededNeeds(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
for _, needStatus := range []actions_model.Status{actions_model.StatusFailure, actions_model.StatusSkipped} {
|
|
t.Run(needStatus.String(), func(t *testing.T) {
|
|
ctx := t.Context()
|
|
job := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}", nil)
|
|
_, err := db.Exec(ctx, "UPDATE `action_run_job` SET status = ? WHERE run_id = ? AND job_id = ?", int(needStatus), job.RunID, "generate")
|
|
require.NoError(t, err)
|
|
|
|
jobs := runJobs(t, job.RunID, job.RunAttemptID)
|
|
require.NoError(t, jobs.LoadRuns(ctx, false))
|
|
|
|
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, actions_model.StatusSkipped, updates[job.ID])
|
|
|
|
var names []string
|
|
for _, runJob := range runJobs(t, job.RunID, job.RunAttemptID) {
|
|
if runJob.JobID == "build" {
|
|
names = append(names, runJob.Name)
|
|
}
|
|
}
|
|
assert.Equal(t, []string{"build"}, names)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeferredMatrixResolverDefersDependents(t *testing.T) {
|
|
require.NoError(t, unittest.PrepareTestDatabase())
|
|
ctx := t.Context()
|
|
|
|
build := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}", map[string]string{"values": `["a","b"]`})
|
|
|
|
attemptJobID, err := actions_model.GetNextAttemptJobID(ctx, build.RunID)
|
|
require.NoError(t, err)
|
|
report := &actions_model.ActionRunJob{
|
|
RunID: build.RunID, RunAttemptID: build.RunAttemptID, AttemptJobID: attemptJobID,
|
|
RepoID: build.RepoID, OwnerID: build.OwnerID, ParentJobID: build.ParentJobID,
|
|
JobID: "report", Name: "report", Status: actions_model.StatusBlocked,
|
|
Needs: []string{"build"}, WorkflowPayload: minimalWorkflowPayload("report"),
|
|
}
|
|
require.NoError(t, db.Insert(ctx, report))
|
|
|
|
jobs := runJobs(t, build.RunID, build.RunAttemptID)
|
|
require.NoError(t, jobs.LoadRuns(ctx, false))
|
|
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, actions_model.StatusWaiting, updates[build.ID], "the placeholder runs as `build (a)`")
|
|
assert.NotContains(t, updates, report.ID, "report must wait for the re-emit, which sees `build (b)` too")
|
|
|
|
// The sibling the pass would otherwise have resolved report against is there, and still to run.
|
|
sibling := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: build.RunID, Name: "build (b)"})
|
|
assert.Equal(t, actions_model.StatusBlocked, sibling.Status)
|
|
}
|