feat(actions): update actionslib, support self:, misc fixes (#39358)

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>
This commit is contained in:
silverwind
2026-09-25 00:06:42 +02:00
committed by GitHub
parent 64f31d9b70
commit f757631a47
59 changed files with 1509 additions and 2223 deletions
+28 -1
View File
@@ -7,8 +7,10 @@ import (
"context"
"fmt"
"gitea.dev/actionslib/pkg/expreval"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/modules/actions/jobparser"
)
// CancelRun cancels a run's cancellable jobs and returns the run's post-cancellation state.
@@ -26,7 +28,7 @@ func ForceCancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*a
func cancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob, force bool) (*actions_model.ActionRun, error) {
var updatedJobs []*actions_model.ActionRunJob
if err := db.WithTx(ctx, func(ctx context.Context) (err error) {
updatedJobs, err = actions_model.CancelJobs(ctx, jobs, force)
updatedJobs, err = actions_model.CancelJobs(ctx, cancellableJobs(run, jobs, force), force)
if err != nil {
return fmt.Errorf("CancelJobs: %w", err)
}
@@ -52,3 +54,28 @@ func cancelRun(ctx context.Context, run *actions_model.ActionRun, jobs []*action
}
return reloaded, nil
}
func cancellableJobs(run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob, force bool) []*actions_model.ActionRunJob {
if force || (run.Started.IsZero() && !run.Status.In(actions_model.StatusRunning, actions_model.StatusCancelling)) {
return jobs
}
toCancel := make([]*actions_model.ActionRunJob, 0, len(jobs))
for _, job := range jobs {
if !runsAfterCancellation(job) {
toCancel = append(toCancel, job)
}
}
return toCancel
}
func runsAfterCancellation(job *actions_model.ActionRunJob) bool {
if job.Status.IsDone() {
return false
}
parsed, err := job.ParseJob()
if err != nil || parsed.If.Value == "" {
return false
}
condition := jobparser.IfExpression(parsed.If.Value)
return expreval.CallsFunction(condition, "always") && !expreval.CallsFunction(condition, "cancelled")
}
+2 -2
View File
@@ -67,7 +67,7 @@ func shouldBlockJobByConcurrency(ctx context.Context, job *actions_model.ActionR
return true, nil
}
if job.ConcurrencyGroup == "" || job.ConcurrencyCancel {
if job.ConcurrencyGroup == "" {
return false, nil
}
@@ -97,7 +97,7 @@ func PrepareToStartJobWithConcurrency(ctx context.Context, job *actions_model.Ac
}
func shouldBlockRunByConcurrency(ctx context.Context, attempt *actions_model.ActionRunAttempt) (bool, error) {
if attempt.ConcurrencyGroup == "" || attempt.ConcurrencyCancel {
if attempt.ConcurrencyGroup == "" {
return false, nil
}
+33
View File
@@ -60,6 +60,31 @@ func createConflictingCancellingJob(t *testing.T, concurrencyGroup string, runIn
return job
}
func TestCancellableJobs(t *testing.T) {
jobs := []*actions_model.ActionRunJob{
{ID: 1, JobID: "always", Status: actions_model.StatusRunning, WorkflowPayload: []byte(`jobs: {always: {if: "always() && needs.build.result == 'cancelled'"}}`)},
{ID: 2, JobID: "ordinary", Status: actions_model.StatusBlocked, WorkflowPayload: []byte(`jobs: {ordinary: {}}`)},
{ID: 3, JobID: "not-cancelled", Status: actions_model.StatusBlocked, WorkflowPayload: []byte(`jobs: {not-cancelled: {if: "always() && !cancelled()"}}`)},
{ID: 4, JobID: "done", Status: actions_model.StatusSuccess, WorkflowPayload: []byte(`jobs: {done: {if: "${{ always() }}"}}`)},
}
for _, test := range []struct {
name string
started timeutil.TimeStamp
status actions_model.Status
force bool
want []*actions_model.ActionRunJob
}{
{name: "pending run", want: jobs},
{name: "started run", started: 1, want: jobs[1:]},
{name: "legacy running run", status: actions_model.StatusRunning, want: jobs[1:]},
{name: "force cancellation", started: 1, force: true, want: jobs},
} {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, cancellableJobs(&actions_model.ActionRun{Started: test.started, Status: test.status}, jobs, test.force))
})
}
}
func TestShouldBlockJobByConcurrency_CancellingJobBlocks(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
@@ -76,6 +101,10 @@ func TestShouldBlockJobByConcurrency_CancellingJobBlocks(t *testing.T) {
shouldBlock, err := shouldBlockJobByConcurrency(t.Context(), job)
require.NoError(t, err)
assert.True(t, shouldBlock)
job.ConcurrencyCancel = true
shouldBlock, err = shouldBlockJobByConcurrency(t.Context(), job)
require.NoError(t, err)
assert.True(t, shouldBlock)
}
func TestShouldBlockRunByConcurrency_CancellingJobBlocks(t *testing.T) {
@@ -92,6 +121,10 @@ func TestShouldBlockRunByConcurrency_CancellingJobBlocks(t *testing.T) {
shouldBlock, err := shouldBlockRunByConcurrency(t.Context(), attempt)
require.NoError(t, err)
assert.True(t, shouldBlock)
attempt.ConcurrencyCancel = true
shouldBlock, err = shouldBlockRunByConcurrency(t.Context(), attempt)
require.NoError(t, err)
assert.True(t, shouldBlock)
}
// TestStopEndlessTasksSkipsCancelling verifies that a task running its post-cancel cleanup is not
+1 -1
View File
@@ -236,7 +236,7 @@ func CreateSkippedCommitStatusForFilteredWorkflow(ctx context.Context, repo *rep
if job == nil {
continue
}
jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way
jobName := job.DisplayName()
ctxName := actions_module.WorkflowStatusContextName(displayName, jobName, statusEvent)
if scopedPrefix != "" {
ctxName = actions_module.ScopedWorkflowStatusContextName(scopedPrefix, displayName, jobName, statusEvent)
+2 -1
View File
@@ -11,6 +11,7 @@ import (
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"go.yaml.in/yaml/v4"
)
@@ -79,7 +80,7 @@ func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.Act
actionRunJob.ConcurrencyGroup, actionRunJob.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(&rawConcurrency, actionRunJob.JobID, workflowJob, actionsJobCtx, jobResults, vars, inputs)
if err != nil {
return fmt.Errorf("evaluate concurrency: %w", err)
return util.NewInvalidArgumentErrorf("%v", err)
}
actionRunJob.IsConcurrencyEvaluated = true
return nil
+41 -56
View File
@@ -4,10 +4,15 @@
package actions
import (
"cmp"
"context"
"fmt"
"maps"
"slices"
"strconv"
"gitea.dev/actionslib/pkg/expreval"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
@@ -96,6 +101,11 @@ func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, att
"workflow": run.WorkflowID, // string, The name of the workflow. If the workflow file doesn't specify a name, the value of this property is the full path of the workflow file in the repository.
"workspace": "", // string, The default working directory on the runner for steps, and the default location of your repository when using the checkout action.
"actor_id": strconv.FormatInt(run.TriggerUserID, 10),
"repository_id": strconv.FormatInt(run.RepoID, 10),
"repository_owner_id": strconv.FormatInt(run.Repo.OwnerID, 10),
"workflow_sha": run.WorkflowCommitSHA,
// additional contexts
"gitea_default_actions_url": setting.Actions.DefaultActionsURL.URL(),
}
@@ -164,9 +174,9 @@ type TaskNeed struct {
// FindTaskNeeds finds the `needs` for the task by the task's job.
// Lookup is scoped to the same ParentJobID.
func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*TaskNeed, error) {
func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*TaskNeed, map[string][]*actions_model.ActionRunJob, error) {
if len(job.Needs) == 0 {
return nil, nil //nolint:nilnil // return nil when the job has no needs
return nil, nil, nil
}
needs := container.SetOf(job.Needs...)
@@ -178,7 +188,7 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st
jobs, err := db.Find[actions_model.ActionRunJob](ctx, findOpts)
if err != nil {
return nil, fmt.Errorf("FindRunJobs: %w", err)
return nil, nil, fmt.Errorf("FindRunJobs: %w", err)
}
jobIDJobs := make(map[string][]*actions_model.ActionRunJob)
@@ -199,9 +209,10 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st
if !needs.Contains(jobID) {
continue
}
sortJobsByCompletion(jobsWithSameID)
var jobOutputs map[string]string
for _, candidate := range jobsWithSameID {
if !candidate.Status.IsDone() {
if !candidate.Status.IsDone() || candidate.IsReusableCaller && candidate.Status != actions_model.StatusSuccess {
continue
}
var outputs map[string]string
@@ -212,7 +223,7 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st
outputs, err = loadJobTaskOutputs(ctx, candidate)
}
if err != nil {
return nil, err
return nil, nil, err
}
if len(jobOutputs) == 0 {
jobOutputs = outputs
@@ -225,7 +236,7 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st
Result: actions_model.AggregateJobStatus(jobsWithSameID),
}
}
return ret, nil
return ret, jobIDJobs, nil
}
// computeReusableCallerOutputs returns the workflow_call outputs of a reusable caller by recursing into its child subtree.
@@ -240,7 +251,7 @@ func computeReusableCallerOutputs(ctx context.Context, caller *actions_model.Act
if err := caller.LoadRun(ctx); err != nil {
return nil, err
}
wcSpec, err := jobparser.ParseWorkflowCallSpec(caller.ReusableWorkflowContent)
wcSpec, err := jobparser.ParseWorkflowCallConfig(caller.ReusableWorkflowContent)
if err != nil {
return nil, err
}
@@ -249,7 +260,8 @@ func computeReusableCallerOutputs(ctx context.Context, caller *actions_model.Act
}
// Per-job outputs over the children of this caller.
jobOutputs := make(jobparser.JobOutputs, len(directChildren))
sortJobsByCompletion(directChildren)
jobOutputs := make(map[string]*model.WorkflowCallResult, len(directChildren))
for _, child := range directChildren {
var outs map[string]string
switch {
@@ -262,10 +274,9 @@ func computeReusableCallerOutputs(ctx context.Context, caller *actions_model.Act
return nil, err
}
if existing, ok := jobOutputs[child.JobID]; ok {
jobOutputs[child.JobID] = mergeTwoOutputs(outs, existing)
} else {
jobOutputs[child.JobID] = outs
outs = mergeTwoOutputs(outs, existing.Outputs)
}
jobOutputs[child.JobID] = &model.WorkflowCallResult{Outputs: outs}
}
// build contexts for evaluating outputs
@@ -288,7 +299,19 @@ func computeReusableCallerOutputs(ctx context.Context, caller *actions_model.Act
}
}
return jobparser.EvaluateWorkflowCallOutputs(wcSpec, gitCtx.ToGitHubContext(), vars, inputs, jobOutputs)
// See `on.workflow_call.outputs.<output_id>.value` in https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability
return expreval.New(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{
Github: gitCtx.ToGitHubContext(),
Jobs: &jobOutputs,
Vars: vars,
Inputs: inputs,
}, exprparser.Config{}).Evaluate).EvaluateWorkflowCallOutputs(wcSpec)
}
func sortJobsByCompletion(jobs []*actions_model.ActionRunJob) {
slices.SortFunc(jobs, func(left, right *actions_model.ActionRunJob) int {
return cmp.Or(cmp.Compare(left.Stopped, right.Stopped), cmp.Compare(left.ID, right.ID))
})
}
// loadJobTaskOutputs returns the task-output map of `job`.
@@ -312,56 +335,18 @@ func loadJobTaskOutputs(ctx context.Context, job *actions_model.ActionRunJob) (m
// Values with the same output name may be overridden. The user should ensure the output names are unique.
// See https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#using-job-outputs-in-a-matrix-job
func mergeTwoOutputs(o1, o2 map[string]string) map[string]string {
ret := make(map[string]string, len(o1))
ret := make(map[string]string, len(o1)+len(o2))
maps.Copy(ret, o2)
for k1, v1 := range o1 {
if len(v1) > 0 {
if len(v1) > 0 || ret[k1] == "" {
ret[k1] = v1
} else {
ret[k1] = o2[k1]
}
}
return ret
}
func contextMapValueOrDefault[T any](m map[string]any, key string, defaultValue T) T {
if value, ok := m[key]; ok {
if v, ok := value.(T); ok {
return v
}
}
return defaultValue
}
func (g *GiteaContext) ToGitHubContext() *model.GithubContext {
return &model.GithubContext{
Event: contextMapValueOrDefault(*g, "event", map[string]any(nil)),
EventPath: contextMapValueOrDefault(*g, "event_path", ""),
Workflow: contextMapValueOrDefault(*g, "workflow", ""),
RunID: contextMapValueOrDefault(*g, "run_id", ""),
RunNumber: contextMapValueOrDefault(*g, "run_number", ""),
Actor: contextMapValueOrDefault(*g, "actor", ""),
Repository: contextMapValueOrDefault(*g, "repository", ""),
EventName: contextMapValueOrDefault(*g, "event_name", ""),
Sha: contextMapValueOrDefault(*g, "sha", ""),
Ref: contextMapValueOrDefault(*g, "ref", ""),
RefName: contextMapValueOrDefault(*g, "ref_name", ""),
RefType: contextMapValueOrDefault(*g, "ref_type", ""),
HeadRef: contextMapValueOrDefault(*g, "head_ref", ""),
BaseRef: contextMapValueOrDefault(*g, "base_ref", ""),
Token: "", // deliberately omitted for security
Workspace: contextMapValueOrDefault(*g, "workspace", ""),
Action: contextMapValueOrDefault(*g, "action", ""),
ActionPath: contextMapValueOrDefault(*g, "action_path", ""),
ActionRef: contextMapValueOrDefault(*g, "action_ref", ""),
ActionRepository: contextMapValueOrDefault(*g, "action_repository", ""),
Job: contextMapValueOrDefault(*g, "job", ""),
JobName: "", // not present in GiteaContext
RepositoryOwner: contextMapValueOrDefault(*g, "repository_owner", ""),
RetentionDays: contextMapValueOrDefault(*g, "retention_days", ""),
RunnerPerflog: "", // not present in GiteaContext
RunnerTrackingID: "", // not present in GiteaContext
ServerURL: contextMapValueOrDefault(*g, "server_url", ""),
APIURL: contextMapValueOrDefault(*g, "api_url", ""),
GraphQLURL: contextMapValueOrDefault(*g, "graphql_url", ""),
}
githubCtx := model.GithubContextFromMap(*g)
githubCtx.Token = "" // deliberately omitted for security
return githubCtx
}
+10 -6
View File
@@ -293,20 +293,24 @@ func TestComputeReusableCallerOutputs(t *testing.T) {
assert.Equal(t, map[string]string{"bubbled": "bubble-value"}, out)
})
t.Run("matrix children with same JobID prefer non-empty values", func(t *testing.T) {
t.Run("matrix children combine outputs by completion order while ignoring empty values", func(t *testing.T) {
run := insertRun(t, "matrix-out.yaml")
caller := insertCaller(t, run, "caller", 0, `on:
workflow_call:
outputs:
foo:
value: ${{ jobs.matrix.outputs.foo }}
bar:
value: ${{ jobs.matrix.outputs.bar }}
`, "")
insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": ""})
insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": "filled"})
later := insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": "latest", "bar": "kept"})
earlier := insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": "earlier"})
empty := insertChildJobAndTask(t, run, "matrix", caller.ID, map[string]string{"foo": ""})
later.Stopped, earlier.Stopped, empty.Stopped = 200, 100, 300
out, err := computeReusableCallerOutputs(ctx, caller, childrenByParentOfRun(t, run.ID))
out, err := computeReusableCallerOutputs(ctx, caller, map[int64][]*actions_model.ActionRunJob{caller.ID: {later, earlier, empty}})
require.NoError(t, err)
assert.Equal(t, map[string]string{"foo": "filled"}, out)
assert.Equal(t, map[string]string{"foo": "latest", "bar": "kept"}, out)
})
}
@@ -316,7 +320,7 @@ func TestFindTaskNeeds(t *testing.T) {
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 51})
job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: task.JobID})
ret, err := FindTaskNeeds(t.Context(), job)
ret, _, err := FindTaskNeeds(t.Context(), job)
assert.NoError(t, err)
assert.Len(t, ret, 1)
assert.Contains(t, ret, "job1")
+35 -12
View File
@@ -96,18 +96,12 @@ func pullRequestTargetBaseSHA(run *actions_model.ActionRun) (string, bool) {
func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob, vars map[string]string, allNeedsSucceed bool) (bool, error) {
parsedJob, err := job.ParseJob()
if err != nil {
return false, err
return false, upsertJobErrorSummary(ctx, job, "if", err)
}
// Empty `if:` reduces to implicit `success()` - true iff every need finished as Success.
if len(parsedJob.If.Value) == 0 {
return allNeedsSucceed, nil
}
// A deferred-matrix placeholder has no combination yet, so an `if:` reading `matrix.*` can only be
// decided by the emitter's post-expansion pass, against each combination's own values.
// always()/failure()/cancelled() opt out of the needs gate this falls back to.
if job.IsMatrixDeferred && jobparser.ExpressionReadsMatrix(parsedJob.If.Value) {
return allNeedsSucceed || jobparser.ExpressionIgnoresNeedResults(parsedJob.If.Value), nil
}
jobResults, err := findJobNeedsAndFillJobResults(ctx, job)
if err != nil {
return false, err
@@ -125,24 +119,53 @@ func evaluateJobIf(ctx context.Context, run *actions_model.ActionRun, attempt *a
return false, err
}
gitCtx := GenerateGiteaContext(ctx, run, attempt, job)
return jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs, job.IsMatrixDeferred)
gitCtx["job"] = "" // github.com decides a job's `if:` before the job exists
shouldStart, err := jobparser.EvaluateJobIfExpression(job.JobID, parsedJob, gitCtx, jobResults, vars, inputs)
if err != nil {
return false, upsertJobErrorSummary(ctx, job, "if", err)
}
return shouldStart, nil
}
func upsertJobErrorSummary(ctx context.Context, job *actions_model.ActionRunJob, key string, err error) error {
content := fmt.Sprintf("Error when evaluating `%s` for job `%s`.\n\n```\n%v\n```\n", key, job.JobID, err)
return actions_model.UpsertActionRunJobSummary(ctx, job.RepoID, job.RunID, job.RunAttemptID, job.ID, 0, actions_model.JobSummaryContentTypeMarkdown, []byte(content))
}
func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.ActionRunJob) (map[string]*jobparser.JobResult, error) {
taskNeeds, err := FindTaskNeeds(ctx, job)
taskNeeds, jobsByID, err := FindTaskNeeds(ctx, job)
if err != nil {
return nil, fmt.Errorf("find task needs: %w", err)
}
jobResults := make(map[string]*jobparser.JobResult, len(taskNeeds))
jobResults := make(map[string]*jobparser.JobResult, len(taskNeeds)+1)
for jobID, taskNeed := range taskNeeds {
jobResult := &jobparser.JobResult{
jobResults[jobID] = &jobparser.JobResult{
Result: taskNeed.Result.String(),
Outputs: taskNeed.Outputs,
}
jobResults[jobID] = jobResult
}
jobResults[job.JobID] = &jobparser.JobResult{
Needs: job.Needs,
}
if len(job.Needs) == 0 {
return jobResults, nil
}
queue := append([]string(nil), job.Needs...)
for len(queue) > 0 {
jobID := queue[0]
queue = queue[1:]
if len(jobsByID[jobID]) == 0 {
continue
}
if jobResults[jobID] == nil {
jobResults[jobID] = &jobparser.JobResult{Result: actions_model.AggregateJobStatus(jobsByID[jobID]).String()}
}
if jobResults[jobID].Needs != nil {
continue
}
jobResults[jobID].Needs = jobsByID[jobID][0].Needs
queue = append(queue, jobResults[jobID].Needs...)
}
return jobResults, nil
}
-47
View File
@@ -4,7 +4,6 @@
package actions
import (
"fmt"
"testing"
actions_model "gitea.dev/models/actions"
@@ -17,52 +16,6 @@ import (
"github.com/stretchr/testify/require"
)
func TestEvaluateJobIfDefersMatrixExpression(t *testing.T) {
// A placeholder's `if:` reading `matrix.*` can only be decided per combination once the matrix is expanded.
emptyRun := &actions_model.ActionRun{} // if the gate is removed, the emptyRun will cause an error
deferredJob := func(ifExpr string) *actions_model.ActionRunJob {
return &actions_model.ActionRunJob{
ID: 1, JobID: "build", Needs: []string{"setup"}, IsMatrixDeferred: true,
WorkflowPayload: fmt.Appendf(nil, `name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
if: %s
strategy:
matrix:
value: ${{ fromJson(needs.setup.outputs.m) }}
steps:
- run: echo
`, ifExpr),
}
}
for _, tt := range []struct {
ifExpr string
// wantNeedsFailed is what the `if:` decides when the needs did not all succeed.
wantNeedsFailed bool
}{
// These hold only for a real combination, so none can be decided before the matrix expands,
// and the fallback is the needs gate: a job whose needs did not succeed is still skipped.
{ifExpr: "${{ matrix.value == 1 }}"},
{ifExpr: "matrix.value == 1"}, // an `if:` may omit the `${{ }}`
// always() asks to run whatever the needs did, so it must not fall back to their gate.
{ifExpr: "${{ always() && matrix.value == 1 }}", wantNeedsFailed: true},
} {
t.Run(tt.ifExpr, func(t *testing.T) {
got, err := evaluateJobIf(t.Context(), emptyRun, nil, deferredJob(tt.ifExpr), nil, true)
require.NoError(t, err)
assert.True(t, got, "must reach the expansion that gates each combination on its own values")
got, err = evaluateJobIf(t.Context(), emptyRun, nil, deferredJob(tt.ifExpr), nil, false)
require.NoError(t, err)
assert.Equal(t, tt.wantNeedsFailed, got)
})
}
}
func TestDispatchInputsForRunJobs(t *testing.T) {
// a child carries the callee's `on: workflow_call`, so only a top-level job answers for the run
run := &actions_model.ActionRun{Event: "workflow_dispatch", EventPayload: `{"inputs":{"deploy":"true"}}`}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"context"
"fmt"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unit"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/git"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
)
func handleInvalidWorkflows(ctx context.Context, input *notifyInput, ref git.RefName, commit *git.Commit, invalid map[string]error) {
if len(invalid) == 0 {
return
}
payload, err := json.Marshal(input.Payload)
if err != nil {
log.Error("marshal event payload: %v", err)
return
}
actionsConfig := input.Repo.MustGetUnit(ctx, unit.TypeActions).ActionsConfig()
for entryName, parseErr := range invalid {
if actionsConfig.IsWorkflowDisabled(entryName) {
continue
}
now := timeutil.TimeStampNow()
run := &actions_model.ActionRun{
Title: util.EllipsisDisplayString(commit.MessageTitle(), 255), RepoID: input.Repo.ID, Repo: input.Repo, OwnerID: input.Repo.OwnerID,
WorkflowID: entryName, TriggerUserID: input.Doer.ID, TriggerUser: input.Doer, Ref: ref.String(),
CommitSHA: commit.ID.String(), Event: input.Event, TriggerEvent: string(input.Event), EventPayload: string(payload),
WorkflowRepoID: input.Repo.ID, WorkflowCommitSHA: commit.ID.String(), Status: actions_model.StatusFailure, Started: now, Stopped: now,
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
if run.Index, err = db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID); err != nil {
return err
}
if err := db.Insert(ctx, run); err != nil {
return err
}
attempt := &actions_model.ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: run.TriggerUserID, Status: run.Status, Started: now, Stopped: now}
if err := db.Insert(ctx, attempt); err != nil {
return err
}
run.LatestAttemptID = attempt.ID
if err := actions_model.UpdateRun(ctx, run, "latest_attempt_id"); err != nil {
return err
}
content := fmt.Sprintf("**Invalid workflow file: %s**\n\n```\n%v\n```\n", entryName, parseErr)
return db.Insert(ctx, &actions_model.ActionRunJobSummary{
RepoID: run.RepoID, RunID: run.ID, RunAttemptID: attempt.ID, Content: content, ContentSize: int64(len(content)), ContentType: actions_model.JobSummaryContentTypeMarkdown,
})
}); err != nil {
log.Error("insert run for invalid workflow %q: %v", entryName, err)
continue
}
if err := createWorkflowCommitStatus(ctx, run.Repo, run.CommitSHA, entryName+" ("+run.TriggerEvent+")", run.WorkflowID,
commitstatus.CommitStatusFailure, run.Link(), "Invalid workflow file"); err != nil {
log.Error("create commit status for invalid workflow %q: %v", entryName, err)
}
NotifyWorkflowRunStatusUpdate(ctx, run)
}
}
+55 -31
View File
@@ -17,6 +17,7 @@ import (
"gitea.dev/modules/queue"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/builder"
)
@@ -281,13 +282,26 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
if err != nil {
return nil, err
}
resolver := newJobStatusResolver(jobs, vars)
var resolver *jobStatusResolver
expandedAnyCaller := false
if err = db.WithTx(ctx, func(ctx context.Context) error {
for _, job := range jobs {
job.Run = run
}
cancelledJobs, err := cancelFailedMatrixSiblings(ctx, jobs)
if err != nil {
return err
}
result.CancelledJobs = append(result.CancelledJobs, cancelledJobs...)
for _, cancelledJob := range cancelledJobs {
for _, job := range jobs {
if job.ID == cancelledJob.ID {
job.Status = cancelledJob.Status
break
}
}
}
resolver = newJobStatusResolver(jobs, vars)
updates, err := resolver.Resolve(ctx)
if err != nil {
@@ -310,6 +324,9 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
if n, uerr := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked, "is_expanded": false}, "status", "stopped"); uerr != nil {
return fmt.Errorf("mark unexpandable caller %d failed: %w", job.ID, uerr)
} else if n == 1 {
if err := upsertJobErrorSummary(ctx, job, "uses", err); err != nil {
return err
}
log.Warn("unexpandable caller %d has been marked as failed", job.ID)
result.UpdatedJobs = append(result.UpdatedJobs, job)
// Re-emit so the failed caller's dependents get resolved on the next pass.
@@ -323,10 +340,12 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
} else {
expandedAnyCaller = true
}
case actions_model.StatusSkipped:
job.Status = actions_model.StatusSkipped
if _, err := actions_model.UpdateRunJob(ctx, job, nil, "status"); err != nil {
case actions_model.StatusSkipped, actions_model.StatusFailure:
job.Status = status
if n, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": actions_model.StatusBlocked}, "status"); err != nil {
return err
} else if n == 1 {
result.UpdatedJobs = append(result.UpdatedJobs, job)
}
}
continue
@@ -352,10 +371,31 @@ func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.Action
if expandedAnyCaller || resolver.matrixChanged {
result.RunIDsToReEmit = append(result.RunIDsToReEmit, run.ID)
}
result.CancelledJobs = resolver.cancelledJobs
result.CancelledJobs = append(result.CancelledJobs, resolver.cancelledJobs...)
return result, nil
}
func cancelFailedMatrixSiblings(ctx context.Context, jobs actions_model.ActionJobList) ([]*actions_model.ActionRunJob, error) {
var toCancel []*actions_model.ActionRunJob
for _, failed := range jobs {
if failed.Status != actions_model.StatusFailure || failed.ContinueOnError {
continue
}
siblings := slices.DeleteFunc(slices.Clone(jobs), func(sibling *actions_model.ActionRunJob) bool {
return !sibling.IsMatrixSiblingOf(failed) || sibling.Status.IsDone() || slices.Contains(toCancel, sibling)
})
if len(siblings) == 0 {
continue
}
if failFast, err := failed.GetFailFast(); err != nil {
return nil, fmt.Errorf("parse failed matrix job %d: %w", failed.ID, err)
} else if failFast {
toCancel = append(toCancel, siblings...)
}
}
return actions_model.CancelJobs(ctx, toCancel, false)
}
type jobStatusResolver struct {
statuses map[int64]actions_model.Status
// sortedIDs are the keys of statuses, so blocked jobs are resolved in insertion order.
@@ -487,13 +527,9 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
continue
}
// Decide whether the job runs at all before expanding a deferred matrix: a job whose needs
// failed or were skipped has to be skipped too, not failed for a matrix those needs never
// produced the outputs for. An `if:` that reads `matrix.*` cannot be decided this early, so
// evaluateJobIf reduces it to that needs gate and the pass below decides it per combination.
// decide before expanding, so failed needs skip the job instead of failing its matrix
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
if err != nil {
// TODO: surface deterministic expression errors to users by failing the job with a message.
log.Error("evaluateJobIf failed, job will stay blocked: job: %d, err: %v", id, err)
continue
}
@@ -503,7 +539,6 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
}
// Expand a needs-dependent matrix now that its needs are done and the job is going to run.
wasDeferred := actionRunJob.IsMatrixDeferred
siblings, err := expandDeferredMatrix(ctx, actionRunJob, r.vars)
if err != nil {
// Aborting the pass is required: once the placeholder is claimed as the first combination,
@@ -525,19 +560,6 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
if len(siblings) > 0 {
r.matrixChanged, r.matrixInserted = true, true
}
if wasDeferred {
// This row is now the first combination, and the `if:` can be evaluated.
// Gate it on its own combination here, as the siblings will be on the next pass.
shouldStartJob, err := evaluateJobIf(ctx, actionRunJob.Run, nil, actionRunJob, r.vars, allSucceed)
if err != nil {
log.Error("evaluateJobIf failed after matrix expansion, job will stay blocked: job: %d, err: %v", id, err)
continue
}
if !shouldStartJob {
ret[id] = actions_model.StatusSkipped
continue
}
}
// A slot-starved job cannot start, skip the following checks.
if !slots.available(actionRunJob) {
@@ -545,11 +567,13 @@ func (r *jobStatusResolver) resolve(ctx context.Context) (map[int64]actions_mode
}
// update concurrency and check whether the job can run now
err = updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars)
if err != nil {
// The err can be caused by different cases: database error, or syntax error, or the needed jobs haven't completed
// At the moment there is no way to distinguish them.
// TODO: if workflow or concurrency expression has syntax error, there should be a user error message, need to show it to end users
if err := updateConcurrencyEvaluationForJobWithNeeds(ctx, actionRunJob, r.vars); errors.Is(err, util.ErrInvalidArgument) {
if err := upsertJobErrorSummary(ctx, actionRunJob, "concurrency", err); err != nil {
return nil, err
}
ret[id] = actions_model.StatusFailure
continue
} else if err != nil {
log.Debug("updateConcurrencyEvaluationForJobWithNeeds failed, this job will stay blocked: job: %d, err: %v", id, err)
continue
}
@@ -587,7 +611,7 @@ func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJo
}
}
if err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, attempt, actionRunJob, vars, nil); err != nil {
return fmt.Errorf("evaluate job concurrency: %w", err)
return err
}
if _, err := actions_model.UpdateRunJob(ctx, actionRunJob, nil, "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"); err != nil {
+111 -1
View File
@@ -35,6 +35,7 @@ func Test_jobStatusResolver_Resolve(t *testing.T) {
run *actions_model.ActionRun // defaults to stubRun
jobs actions_model.ActionJobList
want map[int64]actions_model.Status
note string
}{
{
name: "no blocked",
@@ -80,6 +81,15 @@ func Test_jobStatusResolver_Resolve(t *testing.T) {
3: actions_model.StatusSkipped,
},
},
{
name: "failure checks transitive needs after a skipped job",
jobs: actions_model.ActionJobList{
{ID: 1, JobID: "fail", Status: actions_model.StatusFailure},
{ID: 2, JobID: "skipped", Status: actions_model.StatusSkipped, Needs: []string{"fail"}},
{ID: 3, JobID: "recover", Status: actions_model.StatusBlocked, Needs: []string{"skipped"}, WorkflowPayload: []byte(`jobs: {recover: {if: "${{ failure() }}"}}`)},
},
want: map[int64]actions_model.Status{3: actions_model.StatusWaiting},
},
{
name: "loop need",
jobs: actions_model.ActionJobList{
@@ -145,6 +155,24 @@ jobs:
},
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
},
{
name: "invalid job `if` is skipped with an annotation",
jobs: actions_model.ActionJobList{
{ID: 1, RepoID: 1, JobID: "job1", Status: actions_model.StatusSuccess},
{ID: 2, RepoID: 1, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, WorkflowPayload: []byte("jobs: {job2: {if: '${{ fromJSON(needs.job1.outputs.x) }}'}}")},
},
want: map[int64]actions_model.Status{2: actions_model.StatusSkipped},
note: "Error when evaluating `if` for job `job2`.",
},
{
name: "invalid job `concurrency` fails the job with an annotation",
jobs: actions_model.ActionJobList{
{ID: 1, RepoID: 1, JobID: "job1", Status: actions_model.StatusSuccess},
{ID: 2, RepoID: 1, JobID: "job2", Status: actions_model.StatusBlocked, Needs: []string{"job1"}, RawConcurrency: "group: ${{ fromJSON(needs.job1.outputs.cfg).group }}", WorkflowPayload: []byte("jobs: {job2: {}}")},
},
want: map[int64]actions_model.Status{2: actions_model.StatusFailure},
note: "Error when evaluating `concurrency` for job `job2`.",
},
{
name: "max-parallel: a freed slot promotes the lowest blocked job id",
jobs: actions_model.ActionJobList{
@@ -256,13 +284,14 @@ jobs:
}
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
stubRun := &actions_model.ActionRun{TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{}}
stubRun := &actions_model.ActionRun{TriggerUser: &user_model.User{}, Repo: &repo_model.Repository{Owner: &user_model.User{}}}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Each subtest gets a unique RunID / RunAttemptID so jobs from different subtests don't bleed into each other's FindTaskNeeds queries
runID := int64(9001 + i)
attemptID := int64(9001 + i)
run := util.IfZero(tt.run, stubRun)
require.NoError(t, db.Insert(ctx, &actions_model.ActionRunAttempt{ID: attemptID, RepoID: 1, RunID: runID}))
// Insert each test job (letting the DB assign IDs) and remember the testID -> dbID mapping so we can translate the expected map.
idMap := make(map[int64]int64, len(tt.jobs))
@@ -292,6 +321,56 @@ jobs:
got, err := r.Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, want, got)
if tt.note != "" {
summaries, err := actions_model.ListActionRunJobSummaries(ctx, 1, runID, attemptID, 0)
require.NoError(t, err)
require.Len(t, summaries, 1)
assert.Contains(t, summaries[0].Content, tt.note)
}
})
}
}
func Test_cancelFailedMatrixSiblings(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
for index, test := range []struct {
name string
failFast bool
continueOnError bool
wantCancelled bool
}{
{name: "fail fast cancels a queued combination", failFast: true, wantCancelled: true},
{name: "disabled fail fast keeps a queued combination", failFast: false},
{name: "continued failure keeps a queued combination", failFast: true, continueOnError: true},
} {
t.Run(test.name, func(t *testing.T) {
ctx := t.Context()
run := &actions_model.ActionRun{RepoID: 1, OwnerID: 1, TriggerUserID: 1, WorkflowID: "matrix.yml", Index: int64(12000 + index), Status: actions_model.StatusRunning}
require.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{RepoID: 1, RunID: run.ID, Attempt: 1, Status: actions_model.StatusRunning}
require.NoError(t, db.Insert(ctx, attempt))
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", attempt.ID, run.ID)
require.NoError(t, err)
jobs := actions_model.ActionJobList{
{RunID: run.ID, RunAttemptID: attempt.ID, RepoID: 1, OwnerID: 1, JobID: "matrix", Status: actions_model.StatusFailure, ContinueOnError: test.continueOnError, WorkflowPayload: fmt.Appendf(nil, "jobs: {matrix: {strategy: {fail-fast: %t}}}", test.failFast)},
{RunID: run.ID, RunAttemptID: attempt.ID, RepoID: 1, OwnerID: 1, JobID: "matrix", Status: actions_model.StatusWaiting},
}
for _, job := range jobs {
require.NoError(t, db.Insert(ctx, job))
}
cancelled, err := cancelFailedMatrixSiblings(ctx, jobs)
require.NoError(t, err)
if test.wantCancelled {
require.Len(t, cancelled, 1)
assert.Equal(t, jobs[1].ID, cancelled[0].ID)
assert.Equal(t, actions_model.StatusCancelled, cancelled[0].Status)
run, err = actions_model.GetRunByRepoAndID(ctx, 1, run.ID)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusFailure, run.Status)
} else {
assert.Empty(t, cancelled)
assert.Equal(t, actions_model.StatusWaiting, jobs[1].Status)
}
})
}
}
@@ -525,6 +604,37 @@ func Test_checkJobsOfCurrentRunAttempt_NeedApprovalKeepsJobsBlocked(t *testing.T
assert.Equal(t, actions_model.StatusBlocked, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: job.ID}).Status)
}
func Test_checkJobsOfCurrentRunAttempt_SkippedCallerIsUpdated(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
run := &actions_model.ActionRun{
RepoID: 4, OwnerID: 1, TriggerUserID: 1,
WorkflowID: "test.yml", Index: 9914, Ref: "refs/heads/main",
Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, run))
attempt := &actions_model.ActionRunAttempt{
RepoID: 4, RunID: run.ID, Attempt: 1, Status: actions_model.StatusBlocked,
}
assert.NoError(t, db.Insert(ctx, attempt))
_, err := db.Exec(ctx, "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", attempt.ID, run.ID)
assert.NoError(t, err)
run.LatestAttemptID = attempt.ID
caller := &actions_model.ActionRunJob{
RunID: run.ID, RunAttemptID: attempt.ID, AttemptJobID: 1,
RepoID: 4, OwnerID: 1, JobID: "caller", Name: "caller", Status: actions_model.StatusBlocked,
IsReusableCaller: true, WorkflowPayload: []byte("jobs: {caller: {if: false, uses: ./.gitea/workflows/called.yml}}"),
}
assert.NoError(t, db.Insert(ctx, caller))
result, err := checkJobsOfCurrentRunAttempt(ctx, run)
assert.NoError(t, err)
require.Len(t, result.UpdatedJobs, 1)
assert.Equal(t, caller.ID, result.UpdatedJobs[0].ID)
assert.Equal(t, actions_model.StatusSkipped, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: caller.ID}).Status)
}
// Test_checkRunConcurrency_HeldGroupDoesNotWake verifies that only an unoccupied concurrency group can wake up a blocked run/job.
func Test_checkRunConcurrency_HeldGroupDoesNotWake(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
+5 -3
View File
@@ -119,9 +119,10 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
if err != nil {
return fmt.Errorf("marshal expanded job: %w", err)
}
dst.Name = util.EllipsisDisplayString(combo.Name, 255)
dst.Name = combo.DisplayName()
dst.WorkflowPayload, dst.RunsOn = payload, combo.RunsOn()
dst.ContinueOnError = combo.GetContinueOnError()
dst.MaxParallel = parseMaxParallel(job.JobID, combo.Strategy.MaxParallelString)
return nil
}
@@ -174,7 +175,7 @@ func expandDeferredMatrix(ctx context.Context, job *actions_model.ActionRunJob,
job.IsMatrixDeferred = false
affected, err := actions_model.UpdateRunJob(ctx, job,
builder.Eq{"is_matrix_deferred": true, "status": actions_model.StatusBlocked},
"name", "workflow_payload", "runs_on", "continue_on_error", "is_matrix_deferred")
"name", "workflow_payload", "runs_on", "continue_on_error", "max_parallel", "is_matrix_deferred")
if err != nil {
return nil, fmt.Errorf("claim placeholder of job %d: %w", job.ID, err)
}
@@ -208,10 +209,11 @@ func restoreDeferredMatrixPlaceholder(clone *actions_model.ActionRunJob) error {
if err != nil {
return fmt.Errorf("parse deferred matrix payload: %w", err)
}
clone.Name = util.EllipsisDisplayString(parsed.Name, 255)
clone.Name = parsed.DisplayName()
clone.WorkflowPayload = slices.Clone(clone.DeferredMatrixPayload)
clone.RunsOn = parsed.RunsOn()
clone.ContinueOnError = parsed.GetContinueOnError()
clone.MaxParallel = parseMaxParallel(clone.JobID, parsed.Strategy.MaxParallelString)
clone.IsMatrixDeferred = true
return nil
}
+20 -47
View File
@@ -23,28 +23,25 @@ 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.
// jobIf is the `build` job's `if:` expression, omitted entirely when empty.
func setupDeferredMatrixJob(t *testing.T, matrixValue, jobIf string, outputs map[string]string) *actions_model.ActionRunJob {
func setupDeferredMatrixJob(t *testing.T, matrixValue string, outputs map[string]string) *actions_model.ActionRunJob {
t.Helper()
ctx := t.Context()
ifLine := ""
if jobIf != "" {
ifLine = " if: " + jobIf + "\n"
}
// 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
%s strategy:
runs-on: ubuntu-latest
strategy:
matrix:
value: %s
steps: [{run: echo}]
`, ifLine, matrixValue))
`, matrixValue))
require.NoError(t, err)
var placeholder *jobparser.SingleWorkflow
for _, workflow := range workflows {
@@ -106,13 +103,12 @@ 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"]`})
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)
// The placeholder is reused as the first combination and stays blocked for the `if:` gate.
assert.Equal(t, "build (a)", job.Name)
assert.False(t, job.IsMatrixDeferred)
assert.Equal(t, actions_model.StatusBlocked, job.Status)
@@ -135,6 +131,11 @@ func TestExpandDeferredMatrix(t *testing.T) {
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
@@ -168,7 +169,7 @@ func TestExpandDeferredMatrix(t *testing.T) {
if outputs == nil {
outputs = map[string]string{"values": `["a","b","c"]`}
}
job := setupDeferredMatrixJob(t, tt.matrixValue, "", outputs)
job := setupDeferredMatrixJob(t, tt.matrixValue, outputs)
if tt.prepare != nil {
tt.prepare(t, job)
}
@@ -185,40 +186,14 @@ func TestExpandDeferredMatrix(t *testing.T) {
}
}
// TestDeferredMatrixResolverGating covers the resolver deciding a placeholder's fate around the
// expansion. A need that did not succeed leaves no outputs to build the matrix from, so the job is
// skipped like any other job with such a need rather than failed over a matrix it never had to
// evaluate, and no combination is inserted. A job that does run is then gated by its own
// combination: the placeholder reused as the first one is judged by `matrix.*`, not by the raw
// expression the `if:` would have seen before expansion.
func TestDeferredMatrixResolverGating(t *testing.T) {
func TestDeferredMatrixResolverSkipsWithoutSucceededNeeds(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
for _, tt := range []struct {
name string
needStatus actions_model.Status
jobIf string
outputs map[string]string
wantBuilds []string
}{
{name: "failed need", needStatus: actions_model.StatusFailure, wantBuilds: []string{"build"}},
{name: "skipped need", needStatus: actions_model.StatusSkipped, wantBuilds: []string{"build"}},
{
name: "`if:` gated per combination", needStatus: actions_model.StatusSuccess,
jobIf: "${{ matrix.value == 'b' }}", outputs: map[string]string{"values": `["a","b"]`},
wantBuilds: []string{"build (a)", "build (b)"},
},
{
// The same gate without the `${{ }}`, which must expand rather than skip the whole job.
name: "brace-less `if:` gated per combination", needStatus: actions_model.StatusSuccess,
jobIf: "matrix.value == 'b'", outputs: map[string]string{"values": `["a","b"]`},
wantBuilds: []string{"build (a)", "build (b)"},
},
} {
t.Run(tt.name, func(t *testing.T) {
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) }}", tt.jobIf, tt.outputs)
_, err := db.Exec(ctx, "UPDATE `action_run_job` SET status = ? WHERE run_id = ? AND job_id = ?", int(tt.needStatus), job.RunID, "generate")
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)
@@ -234,7 +209,7 @@ func TestDeferredMatrixResolverGating(t *testing.T) {
names = append(names, runJob.Name)
}
}
assert.ElementsMatch(t, tt.wantBuilds, names)
assert.Equal(t, []string{"build"}, names)
})
}
}
@@ -243,9 +218,7 @@ func TestDeferredMatrixResolverDefersDependents(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
// `build (a)` is skipped by the `if:` once it carries its own combination, `build (b)` runs.
build := setupDeferredMatrixJob(t, "${{ fromJson(needs.generate.outputs.values) }}",
"${{ matrix.value != 'a' }}", map[string]string{"values": `["a","b"]`})
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)
@@ -262,7 +235,7 @@ func TestDeferredMatrixResolverDefersDependents(t *testing.T) {
updates, err := newJobStatusResolver(jobs, nil).Resolve(ctx)
require.NoError(t, err)
assert.Equal(t, actions_model.StatusSkipped, updates[build.ID], "the combination the `if:` excludes")
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.
+5 -21
View File
@@ -4,36 +4,20 @@
package actions
import (
"math"
"strconv"
"strings"
"gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/modules/log"
)
// parseMaxParallel returns strategy.max-parallel for a job, 0 meaning unlimited.
// GitHub accepts any YAML number here and casts it to an int, so 1.5 truncates to 1.
// Expressions are not evaluated yet and fall back to unlimited.
func parseMaxParallel(jobID, maxParallelString string) int {
if maxParallelString == "" {
return 0
limit, err := model.Strategy{MaxParallelString: maxParallelString}.MaxParallel()
if err != nil && !strings.Contains(maxParallelString, "${{") { // a deferred matrix placeholder's expression is evaluated at expansion
log.Warn("job %s: %v, treating as unlimited", jobID, err)
}
maxParallel, err := strconv.ParseFloat(maxParallelString, 64)
if err != nil || math.IsNaN(maxParallel) {
// Both fall back to unlimited, but an expression is a gap in Gitea while a non-number is the
// author's mistake, so dropping the cap must not be reported the same way.
if strings.Contains(maxParallelString, "${{") {
// TODO: evaluate it against the contexts `if:` and the matrix already resolve, so that an
// expression can actually cap a job instead of quietly disabling the cap.
log.Debug("job %s: max-parallel %q is an expression, which is not evaluated yet: treating as unlimited", jobID, maxParallelString)
} else {
log.Warn("job %s: max-parallel %q is not a number, treating as unlimited", jobID, maxParallelString)
}
return 0
}
// a run can never hold more jobs than MaxJobNumPerRun, so clamping there keeps the cast total
return int(min(max(maxParallel, 0), actions_model.MaxJobNumPerRun))
return min(limit, actions_model.MaxJobNumPerRun)
}
// maxParallelSlots counts the jobs holding a max-parallel slot. Slots are scoped by ParentJobID as
+7 -7
View File
@@ -25,12 +25,12 @@ func TestParseMaxParallel(t *testing.T) {
{"3", 3},
{"0", 0},
{"-1", 0},
{"1.5", 1}, // GitHub casts the YAML number to int
{"-1.5", 0}, // truncates to -1, which means unlimited
{"1e3", 256}, // clamped to MaxJobNumPerRun
{"nan", 0}, // must not reach the int cast
{"${{ vars.n }}", 0}, // expressions are not evaluated yet, logged as such
{"abc", 0}, // a plain workflow error, warned about rather than hidden
{"1.5", 1}, // GitHub casts the YAML number to int
{"-1.5", 0}, // truncates to -1, which means unlimited
{"1e3", 256}, // clamped to MaxJobNumPerRun
{"nan", 0}, // must not reach the int cast
{"${{ vars.n }}", 0},
{"abc", 0}, // a plain workflow error, warned about rather than hidden
}
for _, tt := range tests {
assert.Equal(t, tt.want, parseMaxParallel("job", tt.input), "input %q", tt.input)
@@ -44,7 +44,7 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 2
max-parallel: ${{ fromJSON('2') }}
matrix:
version: [1, 2, 3, 4, 5]
steps:
+6 -2
View File
@@ -188,7 +188,7 @@ func notify(ctx context.Context, input *notifyInput) error {
var detectedWorkflows []*actions_module.DetectedWorkflow
var filteredWorkflows []*actions_module.DetectedWorkflow
actionsConfig := input.Repo.MustGetUnit(ctx, unit_model.TypeActions).ActionsConfig()
workflows, schedules, filtered, err := actions_module.DetectWorkflows(ctx, gitRepo, commit,
workflows, schedules, filtered, invalid, err := actions_module.DetectWorkflows(ctx, gitRepo, commit,
input.Event,
input.Payload,
shouldDetectSchedules,
@@ -234,7 +234,7 @@ func notify(ctx context.Context, input *notifyInput) error {
if err != nil {
return fmt.Errorf("gitRepo.GetCommit: %w", err)
}
baseWorkflows, _, baseFiltered, err := actions_module.DetectWorkflows(ctx, gitRepo, baseCommit, input.Event, input.Payload, false)
baseWorkflows, _, baseFiltered, _, err := actions_module.DetectWorkflows(ctx, gitRepo, baseCommit, input.Event, input.Payload, false)
if err != nil {
return fmt.Errorf("DetectWorkflows: %w", err)
}
@@ -268,6 +268,10 @@ func notify(ctx context.Context, input *notifyInput) error {
}
}
if input.Event == webhook_module.HookEventPush {
handleInvalidWorkflows(ctx, input, ref, commit, invalid)
}
if err := handleWorkflows(ctx, detectedWorkflows, commit, input, ref); err != nil {
return err
}
+73 -43
View File
@@ -9,11 +9,13 @@ import (
"fmt"
"strings"
"gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
perm_model "gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/container"
@@ -23,15 +25,13 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/convert"
"xorm.io/builder"
)
// MaxReusableCallLevels caps how deep a reusable workflow can nest:
// a top-level caller may have at most MaxReusableCallLevels nested callers below it.
const MaxReusableCallLevels = 9
// MaxReusableCallLevels allows nine calls across ten workflows, including the top-level workflow.
const MaxReusableCallLevels = 8
// checkRunJobLimit rejects an expansion that would push the attempt over actions_model.MaxJobNumPerRun.
// checkCallerChain bounds nesting *depth*, but a reusable graph also fans out in *breadth*: without a
@@ -49,13 +49,13 @@ func checkRunJobLimit(ctx context.Context, runID, attemptID int64, adding int) e
// loadReusableWorkflowSource resolves the workflow file referenced by a caller's `uses:` and returns its raw bytes,
// along with the (repo_id, commit_sha) the file was loaded from.
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *jobparser.UsesRef) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRun, caller *actions_model.ActionRunJob, ref *model.ReusableWorkflowUses) (content []byte, sourceRepoID int64, sourceCommitSHA string, err error) {
if err := run.LoadAttributes(ctx); err != nil {
return nil, 0, "", err
}
switch ref.Kind {
case jobparser.UsesKindLocalSameRepo:
switch {
case ref.IsLocal():
// `./` and `$/` are resolved against the workflow file containing the `uses:` - i.e. the caller's own source repo + commit.
callerRepo, err := repo_model.GetRepositoryByID(ctx, caller.WorkflowSourceRepoID)
if err != nil {
@@ -71,8 +71,12 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
}
return bytes, callerRepo.ID, resolvedSHA, nil
case jobparser.UsesKindLocalCrossRepo:
default:
unavailable := fmt.Errorf("reusable workflow repository %s/%s does not exist or is not readable", ref.Owner, ref.Repo) // the same for both, so a run cannot tell whether a private one exists
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ref.Owner, ref.Repo)
if repo_model.IsErrRepoNotExist(err) {
return nil, 0, "", unavailable
}
if err != nil {
return nil, 0, "", fmt.Errorf("look up cross-repo workflow source %q: %w", ref.Owner+"/"+ref.Repo, err)
}
@@ -81,12 +85,7 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
return nil, 0, "", err
}
if !ok {
if run.IsScopedRun {
// A scoped workflow's cross-repo "uses:" is resolved with the consuming repo's read permission,
// so the referenced repo must be readable by every consumer. Make that explicit in the failure.
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow %s/%s: a scoped workflow's cross-repo \"uses:\" is resolved with the consuming repository %q read permission", ref.Owner, ref.Repo, run.Repo.FullName())
}
return nil, 0, "", fmt.Errorf("no permission to read reusable workflow from %s/%s", ref.Owner, ref.Repo)
return nil, 0, "", unavailable
}
bytes, resolvedSHA, err := readWorkflowFromRepo(ctx, repo, ref.Ref, ref.Path)
if err != nil {
@@ -94,7 +93,6 @@ func loadReusableWorkflowSource(ctx context.Context, run *actions_model.ActionRu
}
return bytes, repo.ID, resolvedSHA, nil
}
return nil, 0, "", fmt.Errorf("unsupported uses kind %d", ref.Kind)
}
// resolveSameRepoWorkflowSourceCommit returns the commit to read a same-repo reusable workflow from.
@@ -132,16 +130,12 @@ func readWorkflowFromRepo(ctx context.Context, repo *repo_model.Repository, refO
// checkCallerChain walks `caller`'s ancestor chain (via ParentJobID) and:
// - rejects cycles (caller.CallUses appearing in any ancestor's CallUses)
// - enforces MaxReusableCallLevels on the number of ancestors above `caller`
//
// Cycle detection is intentionally *syntactic* (string equality on canonicalCallUses), not semantic.
// So `owner/repo/lib.yml@v1` and `owner/repo/lib.yml@refs/heads/v1` resolving to the same commit are NOT treated as the same node.
// Going semantic (Owner, Repo, Path, ResolvedSHA tuples) would require extra git reads.
func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) error {
if caller.ParentJobID == 0 {
return nil // top-level caller: depth 0, no ancestors to walk
}
visited := container.SetOf(canonicalCallUses(caller.CallUses))
visited := container.SetOf(canonicalCallUses(caller))
depth := 0
current := caller
@@ -155,19 +149,41 @@ func checkCallerChain(ctx context.Context, caller *actions_model.ActionRunJob) e
if depth > MaxReusableCallLevels {
return fmt.Errorf("reusable workflow call exceeds the maximum nesting level of %d at %q", MaxReusableCallLevels, caller.CallUses)
}
if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current.CallUses)) {
if current.IsReusableCaller && current.CallUses != "" && !visited.Add(canonicalCallUses(current)) {
return fmt.Errorf("reusable workflow call cycle detected: %q", current.CallUses)
}
}
return nil
}
// canonicalCallUses folds the two same-repo prefixes into one key, because `$/x.yml` and `./x.yml` name the same file.
func canonicalCallUses(uses string) string {
if ref, err := jobparser.ParseUses(uses); err == nil && ref.Kind == jobparser.UsesKindLocalSameRepo {
return "./" + ref.Path
func checkResolvedCallerCycle(ctx context.Context, caller *actions_model.ActionRunJob, sourceRepoID int64, sourceCommitSHA, path string) error {
for current := caller; current.ParentJobID != 0; {
parent, err := actions_model.GetRunJobByRunAndID(ctx, current.RunID, current.ParentJobID)
if err != nil {
return fmt.Errorf("walk caller chain: %w", err)
}
ref, err := ResolveUses(ctx, parent.CallUses)
if err != nil {
return fmt.Errorf("resolve ancestor uses %q: %w", parent.CallUses, err)
}
if current.WorkflowSourceRepoID == sourceRepoID && current.WorkflowSourceCommitSHA == sourceCommitSHA && ref.Path == path {
return fmt.Errorf("reusable workflow call cycle detected: %q", caller.CallUses)
}
current = parent
}
return uses
return nil
}
// canonicalCallUses keys a call by its parsed form, so the `$/` and `self:` spellings match the plain ones.
func canonicalCallUses(job *actions_model.ActionRunJob) string {
ref, err := model.ParseReusableWorkflowUses(job.CallUses)
if err != nil {
return job.CallUses
}
if ref.IsLocal() {
return fmt.Sprintf("./%s@%d:%s", ref.Path, job.WorkflowSourceRepoID, job.WorkflowSourceCommitSHA)
}
return ref.Owner + "/" + ref.Repo + "/" + ref.Path + "@" + ref.Ref
}
// expandReusableWorkflowCaller loads and parses the target reusable workflow and inserts the caller's direct child jobs.
@@ -204,9 +220,12 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
if err != nil {
return err
}
if err := checkResolvedCallerCycle(ctx, caller, contentSourceRepoID, contentSourceCommitSHA, ref.Path); err != nil {
return err
}
// 4. Parse the called workflow's spec (used by both secret validation and input evaluation).
wcSpec, err := jobparser.ParseWorkflowCallSpec(content)
wcSpec, err := jobparser.ParseWorkflowCallConfig(content)
if err != nil {
return fmt.Errorf("parse called workflow spec: %w", err)
}
@@ -220,7 +239,7 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
// so required-secret presence cannot be verified at expansion time and a missing required secret will surface at job runtime.
// This matches GitHub Actions' behavior.
if !inherit {
if err := jobparser.ValidateCallerSecrets(wcSpec, secretsMap); err != nil {
if err := wcSpec.ValidateSecrets(secretsMap); err != nil {
return fmt.Errorf("caller %q secrets: %w", caller.JobID, err)
}
}
@@ -238,7 +257,7 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
// 6. Evaluate caller's `with:`, then match against the callee schema.
workflowCallInputs := map[string]any{}
if len(wcSpec.Inputs) > 0 {
if len(wcSpec.Inputs) > 0 || parsedJob.With.Kind != 0 {
jobResults, err := findJobNeedsAndFillJobResults(ctx, caller)
if err != nil {
return fmt.Errorf("find caller needs: %w", err)
@@ -248,14 +267,7 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
return err
}
callerGitCtx := GenerateGiteaContext(ctx, run, attempt, caller)
evaluated, err := jobparser.EvaluateCallerWith(
caller.JobID, parsedJob,
callerGitCtx, jobResults, vars, parentInputs,
)
if err != nil {
return fmt.Errorf("evaluate caller with: %w", err)
}
workflowCallInputs, err = jobparser.MatchCallerInputsAgainstSpec(wcSpec, evaluated)
workflowCallInputs, err = jobparser.ResolveCallerInputs(caller.JobID, parsedJob, wcSpec, callerGitCtx, jobResults, vars, parentInputs)
if err != nil {
return fmt.Errorf("caller %q inputs: %w", caller.JobID, err)
}
@@ -306,6 +318,23 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
// insertCallerChildren parses the called workflow with the caller's resolved inputs and inserts each parsed job.
func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, caller *actions_model.ActionRunJob, content []byte, sourceRepoID int64, sourceCommitSHA string, vars map[string]string, inputs map[string]any) error {
callerPermissions := caller.TokenPermissions
if callerPermissions == nil {
actionsUnit, err := run.Repo.GetUnit(ctx, unit.TypeActions)
if err != nil {
return fmt.Errorf("load caller repository Actions settings: %w", err)
}
if config := actionsUnit.ActionsConfig(); config.OverrideOwnerConfig {
callerPermissions = new(config.GetDefaultTokenPermissions())
} else {
ownerConfig, err := actions_model.GetOwnerActionsConfig(ctx, run.OwnerID)
if err != nil {
return fmt.Errorf("load caller owner Actions settings: %w", err)
}
callerPermissions = new(ownerConfig.GetDefaultTokenPermissions())
}
}
// Parse the called workflow with the caller's `inputs`
gitCtx := GenerateGiteaContext(ctx, run, attempt, nil)
if event, ok := gitCtx["event"].(map[string]any); ok {
@@ -349,7 +378,7 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
return fmt.Errorf("marshal child %q under caller %d: %w", jobID, caller.ID, err)
}
parsedChild.Name = util.EllipsisDisplayString(parsedChild.Name, 255)
parsedChild.Name = parsedChild.DisplayName()
// AttemptJobID: prefer a prior-attempt match and fall back to a fresh allocator value for newly-appearing logical jobs.
var attemptJobID int64
@@ -388,7 +417,9 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
child.DeferredMatrixPayload = payload
}
if perms := ExtractJobPermissionsFromWorkflow(sw, parsedChild); perms != nil {
child.TokenPermissions = perms
child.TokenPermissions = new(repo_model.ClampActionsTokenPermissions(*perms, *callerPermissions))
} else {
child.TokenPermissions = callerPermissions
}
if parsedChild.Uses != "" {
child.IsReusableCaller = true
@@ -403,8 +434,8 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
// ResolveUses normalizes and parses a reusable workflow `uses:` value.
// It first rewrites an absolute URL pointing to this instance into the cross-repo form (rejecting external URLs),
// then validates the syntax via jobparser.ParseUses.
func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
// then validates the syntax via model.ParseReusableWorkflowUses.
func ResolveUses(ctx context.Context, uses string) (*model.ReusableWorkflowUses, error) {
// Rewrite a local-instance URL to the equivalent cross-repo form "owner/repo/.gitea/workflows/file.yml@ref".
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
// ParseGiteaSiteURL returns nil for URLs that do not belong to this instance.
@@ -415,11 +446,10 @@ func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
// RoutePath is the instance-relative path (AppSubURL already stripped), e.g. "/owner/repo/.gitea/workflows/file.yml@ref".
uses = strings.TrimPrefix(gsu.RoutePath, "/")
}
ref, err := jobparser.ParseUses(uses)
ref, err := model.ParseReusableWorkflowUses(uses)
if err != nil {
return nil, err
}
// jobparser only validates syntax; enforce the (instance-configurable) directory allowlist here.
if !actions_module.IsWorkflowOrScopedWorkflow(ref.Path) {
return nil, fmt.Errorf(`"uses:" path %q must be under a configured workflow directory (WORKFLOW_DIRS or SCOPED_WORKFLOW_DIRS)`, ref.Path)
}
+52 -4
View File
@@ -7,11 +7,11 @@ import (
"fmt"
"testing"
"gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
"gitea.dev/models/unittest"
actions_module "gitea.dev/modules/actions"
"gitea.dev/modules/actions/jobparser"
"gitea.dev/modules/json"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
@@ -55,6 +55,7 @@ func TestCheckCallerChain_Cycle(t *testing.T) {
)
err := checkCallerChain(t.Context(), chain[len(chain)-1])
assert.ErrorContains(t, err, "cycle detected")
assert.Equal(t, canonicalCallUses(&actions_model.ActionRunJob{CallUses: "owner/repo/.gitea/workflows/a.yml@v1"}), canonicalCallUses(&actions_model.ActionRunJob{CallUses: "self:owner/repo/.gitea/workflows/a.yml@v1"}))
})
t.Run("NoCycle", func(t *testing.T) {
@@ -67,6 +68,37 @@ func TestCheckCallerChain_Cycle(t *testing.T) {
)
require.NoError(t, checkCallerChain(t.Context(), chain[len(chain)-1]))
})
t.Run("SameLocalPathInOtherRepo", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
chain := buildCallerChain(t,
"./.gitea/workflows/a.yml",
"owner/lib/.gitea/workflows/lib.yml@v1",
"./.gitea/workflows/a.yml",
)
leaf := chain[len(chain)-1]
leaf.WorkflowSourceRepoID = 2
require.NoError(t, checkCallerChain(t.Context(), leaf))
})
t.Run("ResolvedIdentityCycle", func(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
chain := buildCallerChain(t,
"./.gitea/workflows/a.yml",
"owner/repo/.gitea/workflows/b.yml@v1",
"owner/repo/.gitea/workflows/a.yml@v1",
)
chain[1].WorkflowSourceRepoID = 4
chain[1].WorkflowSourceCommitSHA = "first-commit"
_, err := actions_model.UpdateRunJob(t.Context(), chain[1], nil, "workflow_source_repo_id", "workflow_source_commit_sha")
require.NoError(t, err)
chain[2].WorkflowSourceRepoID = 5
chain[2].WorkflowSourceCommitSHA = "second-commit"
require.NoError(t, checkCallerChain(t.Context(), chain[2]))
require.ErrorContains(t, checkResolvedCallerCycle(t.Context(), chain[2], 4, "first-commit", ".gitea/workflows/a.yml"), "cycle detected")
require.NoError(t, checkResolvedCallerCycle(t.Context(), chain[2], 4, "other-commit", ".gitea/workflows/a.yml"))
})
}
func TestCheckCallerChain_DepthLimit(t *testing.T) {
@@ -162,11 +194,11 @@ func TestResolveUses(t *testing.T) {
// Same-repo and cross-repo forms are not URLs and are parsed as-is.
ref, err := ResolveUses(ctx, "./.gitea/workflows/build.yml")
require.NoError(t, err)
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"}, *ref)
assert.Equal(t, model.ReusableWorkflowUses{Path: ".gitea/workflows/build.yml"}, *ref)
ref, err = ResolveUses(ctx, "owner/repo/.gitea/workflows/build.yml@v1")
require.NoError(t, err)
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/build.yml", Ref: "v1"}, *ref)
assert.Equal(t, model.ReusableWorkflowUses{Owner: "owner", Repo: "repo", Path: ".gitea/workflows/build.yml", Ref: "v1"}, *ref)
})
t.Run("DirectoryAllowlist", func(t *testing.T) {
@@ -179,11 +211,17 @@ func TestResolveUses(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
ref, err = ResolveUses(ctx, "self:owner/repo/.gitea/scoped_workflows/lib.yml@v1")
require.NoError(t, err)
assert.Equal(t, ".gitea/scoped_workflows/lib.yml", ref.Path)
// A directory that is neither WORKFLOW_DIRS nor SCOPED_WORKFLOW_DIRS parses but is rejected by the allowlist.
_, err = ResolveUses(ctx, "./not-workflows/build.yml")
require.Error(t, err)
_, err = ResolveUses(ctx, "owner/repo/lib/build.yml@v1")
require.Error(t, err)
_, err = ResolveUses(ctx, "self:owner/repo/lib/build.yml@v1")
require.Error(t, err)
})
t.Run("ConfigurableWorkflowDirs", func(t *testing.T) {
@@ -201,7 +239,7 @@ func TestResolveUses(t *testing.T) {
// An absolute URL on this instance (incl. AppSubURL) resolves to the equivalent cross-repo ref.
ref, err := ResolveUses(ctx, "https://gitea.example.com/sub/owner/repo/.gitea/workflows/ci.yml@refs/heads/main")
require.NoError(t, err)
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/ci.yml", Ref: "refs/heads/main"}, *ref)
assert.Equal(t, model.ReusableWorkflowUses{Owner: "owner", Repo: "repo", Path: ".gitea/workflows/ci.yml", Ref: "refs/heads/main"}, *ref)
})
t.Run("InvalidSyntax", func(t *testing.T) {
@@ -222,6 +260,16 @@ func TestResolveUses(t *testing.T) {
})
}
func TestLoadReusableWorkflowSourceFailsAlikeForMissingAndPrivateRepo(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
run := &actions_model.ActionRun{RepoID: 4, TriggerUserID: 1}
for _, repoName := range []string{"missing", "repo3"} {
_, _, _, err := loadReusableWorkflowSource(t.Context(), run, nil, &model.ReusableWorkflowUses{Owner: "org3", Repo: repoName, Path: ".gitea/workflows/build.yml", Ref: "main"})
assert.EqualError(t, err, "reusable workflow repository org3/"+repoName+" does not exist or is not readable")
}
}
func TestCheckRunJobLimit(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
+1 -1
View File
@@ -200,7 +200,7 @@ func insertRunJob(ctx context.Context, run *actions_model.ActionRun, runAttempt
return nil, nil, false, fmt.Errorf("alloc attempt_job_id: %w", err)
}
job.Name = util.EllipsisDisplayString(job.Name, 255)
job.Name = job.DisplayName()
runJob := &actions_model.ActionRunJob{
RunID: run.ID,
RunAttemptID: runAttempt.ID,
+1 -1
View File
@@ -180,7 +180,7 @@ func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*str
}
func findTaskNeeds(ctx context.Context, taskJob *actions_model.ActionRunJob) (map[string]*runnerv1.TaskNeed, error) {
taskNeeds, err := FindTaskNeeds(ctx, taskJob)
taskNeeds, _, err := FindTaskNeeds(ctx, taskJob)
if err != nil {
return nil, err
}
+4 -1
View File
@@ -140,9 +140,12 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
return 0, err
}
if _, err := jobparser.ValidateWorkflowStatic(content); err != nil {
return 0, util.ErrorWrapTranslatable(util.NewInvalidArgumentErrorf("invalid workflow %q: %v", workflowID, err), "actions.runs.invalid_workflow_helper", err.Error())
}
workflow, err := jobparser.ReadWorkflow(content)
if err != nil {
return 0, fmt.Errorf("failed to unmarshal workflow content: %w", err)
return 0, util.ErrorWrapTranslatable(util.NewInvalidArgumentErrorf("invalid workflow %q: %v", workflowID, err), "actions.runs.invalid_workflow_helper", err.Error())
}
// get inputs from post
workflowDispatch := workflow.WorkflowDispatchConfig()