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
+1 -1
View File
@@ -12,7 +12,7 @@ require (
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
gitea.dev/actionslib v1.0.0
gitea.dev/actionslib v1.2.1
gitea.dev/sdk v1.2.0
github.com/42wim/httpsig v1.2.4
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432
+2 -2
View File
@@ -22,8 +22,8 @@ gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHq
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
gitea.dev/actionslib v1.0.0 h1:l0oFJP+P4Ds1rlCI5zk618dYkuBc2mU7Gz5wPeG0lZY=
gitea.dev/actionslib v1.0.0/go.mod h1:6O8YHkqVTKSR0LL2e5VhIDePYzGTZCbfmSVqJWEhk9g=
gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0=
gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo=
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
+33 -20
View File
@@ -10,6 +10,7 @@ import (
"slices"
"time"
act_model "gitea.dev/actionslib/pkg/model"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/actions/jobparser"
@@ -192,32 +193,26 @@ func (job *ActionRunJob) LoadAttributes(ctx context.Context) error {
// ParseJob parses the job structure from the ActionRunJob.WorkflowPayload
func (job *ActionRunJob) ParseJob() (*jobparser.Job, error) {
if job.IsMatrixDeferred {
// The needs were erased before the placeholder was persisted, so jobparser.Parse no longer
// recognises the raw matrix it still carries and would re-expand it: see ParseRawSingleWorkflow.
_, workflowJob, err := jobparser.ParseRawSingleWorkflow(job.WorkflowPayload)
if err != nil {
return nil, fmt.Errorf("job %d deferred matrix placeholder: unable to parse: %w", job.ID, err)
}
return workflowJob, nil
}
// job.WorkflowPayload is a SingleWorkflow created from an ActionRun's workflow, which exactly contains this job's YAML definition.
// Ideally it shouldn't be called "Workflow", it is just a job with global workflow fields + trigger
parsedWorkflows, err := jobparser.Parse(job.WorkflowPayload)
// read as stored, jobparser.Parse would evaluate the payload again and reset its strategy.job-index
_, workflowJob, err := jobparser.ParseRawSingleWorkflow(job.WorkflowPayload)
if err != nil {
return nil, fmt.Errorf("job %d single workflow: unable to parse: %w", job.ID, err)
} else if len(parsedWorkflows) != 1 {
return nil, fmt.Errorf("job %d single workflow: not single workflow", job.ID)
}
_, workflowJob := parsedWorkflows[0].Job()
if workflowJob == nil {
// it shouldn't happen, and since the callers don't check nil, so return an error instead of nil
return nil, util.ErrorWrap(util.ErrNotExist, "job %d single workflow: payload doesn't contain a job", job.ID)
}
return workflowJob, nil
}
func (job *ActionRunJob) IsMatrixSiblingOf(other *ActionRunJob) bool {
return job.JobID == other.JobID && job.ParentJobID == other.ParentJobID && job.ID != other.ID
}
func (job *ActionRunJob) GetFailFast() (bool, error) {
parsed, err := job.ParseJob()
if err != nil {
return false, err
}
return (&act_model.Strategy{FailFastString: parsed.Strategy.FailFastString}).GetFailFast(), nil
}
func GetRunJobByRepoAndID(ctx context.Context, repoID, jobID int64) (*ActionRunJob, error) {
var job ActionRunJob
has, err := db.GetEngine(ctx).Where("id=? AND repo_id=?", jobID, repoID).Get(&job)
@@ -663,6 +658,9 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
// statuses like cancelled/failure when no job is waiting or running.
return StatusBlocked
case hasCancelled:
if hasFailure && hasFailFastMatrixFailure(jobs) {
return StatusFailure
}
return StatusCancelled
case hasFailure:
return StatusFailure
@@ -671,6 +669,21 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
}
}
func hasFailFastMatrixFailure(jobs []*ActionRunJob) bool {
for _, failed := range jobs {
if failed.Status != StatusFailure || failed.ContinueOnError || !slices.ContainsFunc(jobs, func(sibling *ActionRunJob) bool {
return sibling.IsMatrixSiblingOf(failed) && sibling.Status == StatusCancelled
}) {
continue
}
failFast, err := failed.GetFailFast()
if err == nil && failFast {
return true
}
}
return false
}
// CancelPreviousJobs cancels all previous jobs of the same repository, reference, workflow, and event.
// It's useful when a new run is triggered, and all previous runs needn't be continued anymore.
func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) ([]*ActionRunJob, error) {
-1
View File
@@ -51,7 +51,6 @@ func TestAggregateJobStatus(t *testing.T) {
{[]Status{StatusSuccess, StatusRunning}, StatusRunning},
{[]Status{StatusSuccess, StatusBlocked}, StatusBlocked},
// any cancelled, then cancelled
{[]Status{StatusCancelled}, StatusCancelled},
{[]Status{StatusCancelled, StatusSuccess}, StatusCancelled},
{[]Status{StatusCancelled, StatusSkipped}, StatusCancelled},
+3 -7
View File
@@ -308,8 +308,6 @@ jobs:
`, matrix)
}
// The shape Parse happens to survive, so only the name tells the two paths apart. What Parse makes
// of every other shape is asserted in TestParseRawSingleWorkflowRoundTripsDeferredPlaceholder.
t.Run("a placeholder is read back, not re-expanded", func(t *testing.T) {
job := &ActionRunJob{ID: 1, JobID: "build", IsMatrixDeferred: true, WorkflowPayload: payload("version: ${{ fromJson(needs.setup.outputs.m) }}")}
parsed, err := job.ParseJob()
@@ -318,13 +316,11 @@ jobs:
assert.Equal(t, "build", parsed.Name)
})
t.Run("an expanded job still goes through the full parse", func(t *testing.T) {
job := &ActionRunJob{ID: 1, JobID: "build", WorkflowPayload: payload("version: [1]")}
t.Run("an expanded job keeps its stored job-index", func(t *testing.T) {
job := &ActionRunJob{ID: 1, JobID: "build", WorkflowPayload: payload("version: [1]\n job-index: 1\n job-total: 2")}
parsed, err := job.ParseJob()
require.NoError(t, err)
require.NotNil(t, parsed)
// Parse bakes the combination into the name, ParseRawSingleWorkflow would not.
assert.Equal(t, "build (1)", parsed.Name)
assert.Equal(t, []any{"build", 1, 2}, []any{parsed.Name, parsed.Strategy.JobIndex, parsed.Strategy.JobTotal})
})
}
+2 -3
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
@@ -15,7 +16,6 @@ import (
repo_model "gitea.dev/models/repo"
"gitea.dev/models/shared/types"
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
@@ -200,8 +200,7 @@ func (r *ActionRunner) GenerateAndFillToken() {
// CanMatchLabels checks whether the runner's labels can match a job's "runs-on"
// See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idruns-on
func (r *ActionRunner) CanMatchLabels(jobRunsOn []string) bool {
runnerLabelSet := container.SetOf(r.AgentLabels...)
return runnerLabelSet.Contains(jobRunsOn...) // match all labels
return !slices.ContainsFunc(jobRunsOn, func(label string) bool { return !util.SliceContainsString(r.AgentLabels, label, true) })
}
func init() {
+6
View File
@@ -81,3 +81,9 @@ func TestShouldPersistLastActive(t *testing.T) {
})
}
}
func TestCanMatchLabelsCaseInsensitive(t *testing.T) {
runner := &ActionRunner{AgentLabels: []string{"self-hosted", "Linux", "X64"}}
assert.True(t, runner.CanMatchLabels([]string{"SELF-HOSTED", "linux"}))
assert.False(t, runner.CanMatchLabels([]string{"linux", "arm64"}))
}
@@ -93,6 +93,17 @@ func TestGetActionsUserRepoPermission(t *testing.T) {
// Fork PR never gets cross-repo access to other private repos
assert.False(t, perm.CanRead(unit.TypeCode))
publicCaller := *repo2
publicCaller.IsPrivate = false
run := &actions_model.ActionRun{RepoID: repo2.ID, Repo: &publicCaller}
allowed, err := CanReadWorkflowCrossRepo(ctx, repo15, run)
require.NoError(t, err)
assert.False(t, allowed)
run.Repo = repo2
allowed, err = CanReadWorkflowCrossRepo(ctx, repo15, run)
require.NoError(t, err)
assert.True(t, allowed)
})
t.Run("CollaborativeOwner_ForkPR_Denied", func(t *testing.T) {
+5 -7
View File
@@ -686,18 +686,16 @@ func CanReadWorkflowCrossRepo(ctx context.Context, targetRepo *repo_model.Reposi
if err := run.LoadRepo(ctx); err != nil {
return false, err
}
if targetRepo.IsPrivate && !run.Repo.IsPrivate {
return false, nil
}
// (1) Same owner: always allowed (fork-PR scrubbing handled inside).
// (1) Same owner: allowed by owner policy (fork-PR scrubbing handled inside).
if checkSameOwnerCrossRepoAccess(ctx, run.Repo, targetRepo, run.IsForkPullRequest) {
return true, nil
}
// (2) Cross-owner: respect the target repo's collaborative-owner allowlist on its Actions unit.
// The caller (run.Repo) must itself be private. The collaborative-owner grant is owner-level, so without this
// guard a public caller owned by a grantee could pull a private reusable workflow and expose its definition and
// logs in a publicly visible run; requiring a private caller keeps private content flowing private -> private.
// This is intentionally stricter than GitHub, which gates on the target repo's access setting (introduced in #32562):
// https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository
// (2) Cross-owner access requires a private caller and the target's collaborative-owner grant.
if run.Repo.IsPrivate && !run.IsForkPullRequest {
if actionsUnit, err := targetRepo.GetUnit(ctx, unit.TypeActions); err == nil {
if actionsUnit.ActionsConfig().IsCollaborativeOwner(run.Repo.OwnerID) {
+13 -61
View File
@@ -4,6 +4,7 @@
package jobparser
import (
"bytes"
"errors"
"io"
@@ -12,12 +13,6 @@ import (
"go.yaml.in/yaml/v4"
)
// maxExpandedNodes bounds how many nodes alias expansion may create. go-yaml's own alias guard does
// not cover us: it only counts while decoding into values, and a workflow is kept as raw yaml.Nodes.
const maxExpandedNodes = 50000
var errTooManyYamlNodes = errors.New("maximum YAML nodes exceeded")
// ReadWorkflow decodes a workflow file with its aliases expanded. Callers inspect the workflow's
// raw nodes by kind, and an alias is a kind none of them expect.
func ReadWorkflow(content []byte) (*model.Workflow, error) {
@@ -33,7 +28,10 @@ func readWorkflowDoc(doc *yaml.Node) (*model.Workflow, error) {
return nil, io.EOF // what a yaml decoder reports for an empty file
}
w := new(model.Workflow)
return w, doc.Decode(w)
if err := doc.Decode(w); err != nil {
return w, err
}
return w, validateJobConditions(w)
}
// decodeResolved is yaml.Unmarshal with aliases expanded first.
@@ -54,68 +52,22 @@ func decodeYamlDoc(doc *yaml.Node, out any) error {
// resolveYamlAliases parses content and replaces every alias with a copy of the node its anchor names.
func resolveYamlAliases(content []byte) (*yaml.Node, error) {
doc := &yaml.Node{}
if err := yaml.Unmarshal(content, doc); err != nil {
doc, err := model.ReadWorkflowNode(bytes.NewReader(content))
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
budget := maxExpandedNodes
return doc, expandAliases(doc, &budget)
}
// expandAliases replaces node's alias descendants in place.
func expandAliases(node *yaml.Node, budget *int) error {
node.Anchor = "" // a name for a node, not part of the workflow: keep it out of the payloads
if err := rejectMergeKeys(node); err != nil {
return err
}
for i, child := range node.Content {
if child.Kind != yaml.AliasNode {
if err := expandAliases(child, budget); err != nil {
return err
}
continue
}
copied, err := copyExpanded(child.Alias, budget)
if err != nil {
return err
}
node.Content[i] = copied
}
return nil
}
// copyExpanded deep copies a node expandAliases already expanded and validated, since an anchor is
// declared before the alias naming it. An anchor aliased from inside itself is the exception, and
// recurses here until it exhausts budget.
func copyExpanded(node *yaml.Node, budget *int) (*yaml.Node, error) {
if *budget--; *budget < 0 {
return nil, errTooManyYamlNodes
}
if node.Kind == yaml.AliasNode {
return copyExpanded(node.Alias, budget)
}
copied := *node
copied.Content = make([]*yaml.Node, len(node.Content))
for i, child := range node.Content {
child, err := copyExpanded(child, budget)
if err != nil {
return nil, err
}
copied.Content[i] = child
}
return &copied, nil
return doc, rejectMergeKeys(doc)
}
// rejectMergeKeys refuses `<<: *anchor`, same as GitHub does
func rejectMergeKeys(node *yaml.Node) error {
if node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i < len(node.Content)-1; i += 2 {
if node.Content[i].Tag == "!!merge" {
for i, child := range node.Content {
if node.Kind == yaml.MappingNode && i%2 == 0 && child.Tag == "!!merge" {
return errors.New("merge keys (`<<`) are not supported, alias the whole value instead")
}
if err := rejectMergeKeys(child); err != nil {
return err
}
}
return nil
}
+3 -14
View File
@@ -10,10 +10,11 @@ import (
"github.com/stretchr/testify/require"
)
func TestParseResolvesAliases(t *testing.T) {
func TestParseResolvesAliasesAndTimestamps(t *testing.T) {
got, err := Parse([]byte(`on: push
env: &common_env
SHARED: "1"
DATE: 2026-03-15
jobs:
a:
runs-on: linux
@@ -31,7 +32,7 @@ jobs:
_, job := workflow.Job()
var env map[string]string
require.NoError(t, job.Env.Decode(&env))
assert.Equal(t, map[string]string{"SHARED": "1"}, env)
assert.Equal(t, map[string]string{"SHARED": "1", "DATE": "2026-03-15"}, env)
require.Len(t, job.Steps, 1)
payload, err := workflow.Marshal()
@@ -49,18 +50,6 @@ func TestParseRejectsAliases(t *testing.T) {
name, wantErr string
content []byte
}{
{
name: "nested aliases exceed the node limit",
content: []byte(`on: push
x0: &x0 [1, 2, 3, 4, 5, 6, 7, 8, 9]
x1: &x1 [*x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0]
x2: &x2 [*x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1]
x3: &x3 [*x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2]
x4: &x4 [*x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3]
jobs: {a: {runs-on: linux, steps: [{run: echo}]}}
`),
wantErr: "maximum YAML nodes exceeded",
},
{
name: "anchor aliased from inside itself",
content: job(" steps: &s [{run: echo}, *s]\n"),
+3 -24
View File
@@ -15,21 +15,13 @@ import (
// see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability
func NewInterpeter(
jobID string,
job *model.Job,
strategy *Strategy,
matrix map[string]any,
gitCtx *model.GithubContext,
results map[string]*JobResult,
vars map[string]string,
inputs map[string]any,
) exprparser.Interpreter {
strategy := make(map[string]any)
if job.Strategy != nil {
strategy["fail-fast"] = job.Strategy.GetFailFast()
if limit, declared, err := job.Strategy.ParseMaxParallel(); declared && err == nil {
strategy["max-parallel"] = limit
}
}
run := &model.Run{
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{},
@@ -46,19 +38,6 @@ func NewInterpeter(
}
}
jobs := run.Workflow.Jobs
jobNeeds := run.Job().Needs()
using := map[string]exprparser.Needs{}
for _, need := range jobNeeds {
if v, ok := jobs[need]; ok {
using[need] = exprparser.Needs{
Outputs: v.Outputs,
Result: v.Result,
}
}
}
ee := &exprparser.EvaluationEnvironment{
Github: gitCtx,
Env: nil, // no need
@@ -69,9 +48,9 @@ func NewInterpeter(
Steps: nil, // no need
Runner: nil, // no need
Secrets: nil, // no need
Strategy: strategy,
Strategy: strategy.context(),
Matrix: matrix,
Needs: using,
Needs: exprparser.NeedsContext(run),
Inputs: inputs,
Vars: vars,
}
+112 -132
View File
@@ -17,28 +17,33 @@ import (
"go.yaml.in/yaml/v4"
)
// HasDeferredMatrix reports whether the job's matrix can only be expanded once its needs finish:
// it reads the needs context and the job has needs to resolve that context against.
// Parse emits such a job as a single placeholder rather than one job per combination, so every
// caller that persists a job must agree with Parse on this condition.
// HasDeferredMatrix reports whether the job's strategy, name, runs-on or continue-on-error need outputs before they can be resolved.
func HasDeferredMatrix(job *Job) bool {
return len(job.Needs()) > 0 && rawMatrixReadsNeeds(&job.Strategy.RawMatrix)
return len(job.Needs()) > 0 && (nodeMatches(&job.Strategy.RawMatrix, expressionReadsNeeds) || expressionReadsNeeds(job.Strategy.RawExpression.Value) ||
expressionReadsNeeds(job.Strategy.MaxParallelString) || expressionReadsNeeds(job.Strategy.FailFastString) ||
expressionReadsNeeds(job.Name) || nodeMatches(&job.RawRunsOn, expressionReadsNeeds) || nodeMatches(&job.RawContinueOnError, expressionReadsNeeds))
}
func rawMatrixReadsNeeds(node *yaml.Node) bool {
func nodeMatches(node *yaml.Node, match func(string) bool) bool {
if node.Kind == yaml.ScalarNode {
return expressionReadsNeeds(node.Value)
return match(node.Value)
}
return slices.ContainsFunc(node.Content, rawMatrixReadsNeeds)
return slices.ContainsFunc(node.Content, func(child *yaml.Node) bool { return nodeMatches(child, match) })
}
// ParseRawSingleWorkflow decodes a SingleWorkflow payload into the workflow and its single job
// without expanding `strategy.matrix`.
//
// A deferred-matrix placeholder's payload still carries the raw, unevaluated matrix, which Parse
// would try to expand: depending on the matrix's shape that yields several workflows (a static
// vector crossed with the unevaluated expression) or an error (an `include`/`exclude` that is still
// a scalar), neither of which describes the one job the payload stands for.
func hasExpression(value string) bool {
return strings.Contains(value, "${{")
}
// IfExpression wraps an `if:` that omits the `${{ }}`, which github.com evaluates as one expression anyway.
func IfExpression(value string) string {
if hasExpression(value) {
return value
}
return "${{ " + value + " }}"
}
// ParseRawSingleWorkflow decodes a stored SingleWorkflow payload into the workflow and its single job as stored, without expanding or evaluating it again.
func ParseRawSingleWorkflow(payload []byte) (*SingleWorkflow, *Job, error) {
swf := &SingleWorkflow{}
if err := decodeResolved(payload, swf); err != nil {
@@ -62,29 +67,6 @@ func expressionReadsNeeds(value string) bool {
return expreval.ReadsContext(value, "needs")
}
// ExpressionReadsMatrix reports whether a job's `if:` reads the matrix context.
// A deferred-matrix placeholder has no combination yet, so such an expression cannot be decided.
func ExpressionReadsMatrix(ifValue string) bool {
return expreval.ReadsContext(asIfExpression(ifValue), "matrix")
}
// ExpressionIgnoresNeedResults reports whether a job's `if:` calls always(), failure() or cancelled(),
// the status functions that run a job whatever its needs did rather than under the implicit success().
// Keep in sync with act's exprparser, which owns the same list for the evaluation itself.
func ExpressionIgnoresNeedResults(ifValue string) bool {
return expreval.CallsFunction(asIfExpression(ifValue), "always", "failure", "cancelled")
}
// asIfExpression wraps an `if:` that omits the `${{ }}`, which GitHub evaluates as one expression anyway.
// `if:` is the only field with that exception: every other value is interpolated, so a bare matrix or
// `runs-on` is a literal there and must not be parsed as an expression.
func asIfExpression(ifValue string) string {
if ifValue == "" || strings.Contains(ifValue, "${{") {
return ifValue
}
return "${{ " + ifValue + " }}"
}
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
// The workflow is split into one document per job below, which would strand an alias whose
// anchor lands in another one.
@@ -132,28 +114,25 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
for i, id := range ids {
job := jobs[i]
originJob := origin.GetJob(id)
if originJob == nil {
return nil, fmt.Errorf("job %s not found in origin workflow", id)
}
var combos []*Job
if HasDeferredMatrix(job) {
// The matrix reads values that do not exist yet (a needs output), so emit a single
// placeholder keeping it raw. Re-parsing that placeholder's payload yields it again,
// and the server expands it once the needs finish.
if HasDeferredMatrix(job) || pc.gitContext == nil && (job.Strategy.RawExpression.Kind != 0 || nodeMatches(&job.Strategy.RawMatrix, hasExpression)) {
placeholder := job.Clone()
if placeholder.Name == "" {
placeholder.Name = id
}
combos = []*Job{placeholder}
} else {
matricxes, err := getMatrixes(originJob)
if pc.gitContext != nil { // callers without one, like commit status, only read the literal workflow
if err := job.Strategy.resolve(evaluator); err != nil {
return nil, fmt.Errorf("job %q: %w", id, err)
}
}
// Keep accepting empty exclude mappings for workflow compatibility, although GitHub rejects them.
matrixes, err := (&model.Job{Strategy: job.Strategy.actStrategy()}).GetMatrixes()
if err != nil {
return nil, fmt.Errorf("getMatrixes: %w", err)
}
if combos, err = buildMatrixCombos(id, job, matricxes, originJob, pc.gitContext, results, pc.vars, pc.inputs); err != nil {
if combos, err = buildMatrixCombos(id, job, matrixes, pc.gitContext, results, pc.vars, pc.inputs); err != nil {
return nil, err
}
}
@@ -186,99 +165,120 @@ func (w *SingleWorkflow) CloneHeader() *SingleWorkflow {
// maxCombinations caps how many combinations may be built: the values come from a needs output at
// runtime, so the cap has to be enforced before one Job is materialized per combination.
func ExpandMatrixWithNeeds(jobID string, job *Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any, maxCombinations int) ([]*Job, error) {
actJob := &model.Job{Strategy: &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
}}
// Resolve fromJson(needs.*.outputs.*) and friends into concrete matrix values.
if err := expreval.New(NewInterpeter(jobID, actJob, nil, gitCtx, results, vars, inputs).Evaluate).
EvaluateYamlNode(&actJob.Strategy.RawMatrix); err != nil {
return nil, fmt.Errorf("evaluate matrix: %w", err)
job = job.Clone()
if err := job.Strategy.resolve(expreval.New(NewInterpeter(jobID, nil, nil, gitCtx, results, vars, inputs).Evaluate)); err != nil {
return nil, err
}
matrixes, err := getMatrixes(actJob)
matrixes, err := (&model.Job{Strategy: job.Strategy.actStrategy()}).GetMatrixes()
if err != nil {
return nil, fmt.Errorf("getMatrixes: %w", err)
}
// act collapses a matrix that yields no combination (an empty vector or include, everything
// excluded, a whole-matrix expression that is not a mapping) into one empty combination, which
// would run the job once unparameterized. GitHub rejects such a matrix, so reject it too.
if len(matrixes) == 1 && len(matrixes[0]) == 0 {
return nil, errors.New("matrix must define at least one vector")
}
if len(matrixes) > maxCombinations {
return nil, fmt.Errorf("matrix expands to %d combinations, exceeding the limit of %d", len(matrixes), maxCombinations)
}
return buildMatrixCombos(jobID, job, matrixes, actJob, gitCtx, results, vars, inputs)
return buildMatrixCombos(jobID, job, matrixes, gitCtx, results, vars, inputs)
}
// matrixesOf is this package's only entry to act's GetMatrixes, so that every caller is covered by
// the filter check below. A deferred placeholder is the first thing carrying a raw matrix this far,
// and the emitter reads its `if:` before expanding it.
func matrixesOf(job *model.Job) ([]map[string]any, error) {
if err := validateMatrixFilters(job); err != nil {
return nil, err
}
matrixes, err := job.GetMatrixes()
if err != nil {
return nil, fmt.Errorf("GetMatrixes: %w", err)
}
return matrixes, nil
}
// validateMatrixFilters rejects an `include`/`exclude` that is not a list of mappings, so that the
// usual way to get there, an unevaluated ${{ }} expression that is still a scalar, is named as such
// instead of surfacing from the middle of the expansion.
func validateMatrixFilters(job *model.Job) error {
if job.Strategy == nil || job.Strategy.RawMatrix.Kind != yaml.MappingNode {
return nil
}
content := job.Strategy.RawMatrix.Content
for i := 0; i+1 < len(content); i += 2 {
name, value := content[i].Value, content[i+1]
if name != "include" && name != "exclude" {
continue
// resolve evaluates strategy values once and escapes expression-like results for the runner.
func (s *Strategy) resolve(evaluator expreval.Evaluator) error {
if s.RawExpression.Kind != 0 {
if err := model.DecodeEvaluated("strategy", s.RawExpression, evaluator.EvaluateYamlNode, s); err != nil {
return err
}
entries := []*yaml.Node{value}
if value.Kind == yaml.SequenceNode {
entries = value.Content
if s.RawExpression.Kind != 0 {
return errors.New("strategy is not a map of strategy keys to values")
}
for _, entry := range entries {
if entry.Kind != yaml.MappingNode {
return fmt.Errorf("matrix %s must be a list of mappings", name)
} else {
for _, value := range []*string{&s.FailFastString, &s.MaxParallelString} {
evaluated, err := evaluator.Interpolate(*value)
if err != nil {
return fmt.Errorf("evaluate strategy: %w", err)
}
*value = evaluated
}
if err := evaluator.EvaluateYamlNode(&s.RawMatrix); err != nil {
return fmt.Errorf("evaluate matrix: %w", err)
}
}
s.FailFastString, s.MaxParallelString = escapeExpressions(s.FailFastString), escapeExpressions(s.MaxParallelString)
return nil
}
const escapedExpression = "${{ '$' }}{{"
// escapeExpressions returns a template interpolating to value, since GitHub never evaluates the result of an evaluation again.
func escapeExpressions(value string) string {
return strings.ReplaceAll(value, "${{", escapedExpression)
}
func unescapeExpressions(value string) string {
return strings.ReplaceAll(value, escapedExpression, "${{")
}
func replaceScalars(node *yaml.Node, replace func(string) string) {
node.Value = replace(node.Value)
for _, child := range node.Content {
replaceScalars(child, replace)
}
}
// buildMatrixCombos builds one Job per matrix combination from src, baking the combination into the
// strategy and interpolating the name, runs-on and continue-on-error with it.
func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, actJob *model.Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) {
srcRunsOn := src.RunsOn()
func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) {
srcRunsOn := model.RunsOnFromNode(src.RawRunsOn)
order, names := make([]int, len(matrixes)), make([]string, len(matrixes))
for index, matrix := range matrixes {
order[index], names[index] = index, matrixName(matrix)
}
slices.SortStableFunc(order, func(a, b int) int { return strings.Compare(names[a], names[b]) })
combos := make([]*Job, 0, len(matrixes))
var err error
for _, matrix := range matrixes {
for _, index := range order {
matrix := matrixes[index]
combo := src.Clone()
if combo.Name == "" {
combo.Name = jobID
}
combo.Strategy.RawMatrix = encodeMatrix(matrix)
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, gitCtx, results, vars, inputs).Evaluate)
if combo.Name, err = nameWithMatrix(combo.Name, matrix, evaluator); err != nil {
replaceScalars(&combo.Strategy.RawMatrix, escapeExpressions)
if src.Strategy.RawMatrix.Kind != 0 {
combo.Strategy.JobIndex, combo.Strategy.JobTotal = index, len(matrixes)
}
evaluator := expreval.New(NewInterpeter(jobID, &combo.Strategy, matrix, gitCtx, results, vars, inputs).Evaluate)
if len(matrix) == 0 && gitCtx != nil {
combo.Name, err = evaluator.Interpolate(combo.Name)
combo.Name = escapeExpressions(combo.Name)
} else {
combo.Name, err = nameWithMatrix(combo.Name, matrix, evaluator)
}
if err != nil {
return nil, fmt.Errorf("interpolate name for job %q: %w", jobID, err)
}
runsOn := slices.Clone(srcRunsOn)
for i := range runsOn {
if runsOn[i], err = evaluator.Interpolate(runsOn[i]); err != nil {
if gitCtx != nil { // callers without one don't read runs-on
rawRunsOn := model.CloneYamlNode(src.RawRunsOn)
if err := evaluator.EvaluateYamlNode(&rawRunsOn); err != nil {
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", jobID, err)
}
runsOn := model.RunsOnFromNode(rawRunsOn)
if len(runsOn) == 0 && len(srcRunsOn) > 0 { // match no runner rather than every runner
runsOn = []string{""}
}
for i := range runsOn {
runsOn[i] = escapeExpressions(runsOn[i])
}
combo.RawRunsOn = model.RunsOnNode(runsOn, "")
}
combo.RawRunsOn = encodeRunsOn(runsOn)
if err := evaluator.EvaluateYamlNode(&combo.RawContinueOnError); err != nil {
return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", jobID, err)
}
if combo.RawContinueOnError.Kind != 0 {
var continueOnError bool
if err := combo.RawContinueOnError.Decode(&continueOnError); err == nil {
_ = combo.RawContinueOnError.Encode(continueOnError)
} else {
replaceScalars(&combo.RawContinueOnError, escapeExpressions)
}
}
combos = append(combos, combo)
}
return combos, nil
@@ -311,17 +311,6 @@ type parseContext struct {
type ParseOption func(c *parseContext)
func getMatrixes(job *model.Job) ([]map[string]any, error) {
ret, err := matrixesOf(job)
if err != nil {
return nil, err
}
sort.Slice(ret, func(i, j int) bool {
return matrixName(ret[i]) < matrixName(ret[j])
})
return ret, nil
}
func encodeMatrix(matrix map[string]any) yaml.Node {
if len(matrix) == 0 {
return yaml.Node{}
@@ -335,26 +324,17 @@ func encodeMatrix(matrix map[string]any) yaml.Node {
return node
}
func encodeRunsOn(runsOn []string) yaml.Node {
node := yaml.Node{}
if len(runsOn) == 1 {
_ = node.Encode(runsOn[0])
} else {
_ = node.Encode(runsOn)
}
return node
}
func nameWithMatrix(name string, m map[string]any, evaluator expreval.Evaluator) (string, error) {
if len(m) == 0 {
return name, nil
}
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
return name + " " + matrixName(m), nil
return escapeExpressions(name + " " + matrixName(m)), nil
}
return evaluator.Interpolate(name)
name, err := evaluator.Interpolate(name)
return escapeExpressions(name), err
}
func matrixName(m map[string]any) string {
+215 -142
View File
@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"gitea.dev/actionslib/pkg/expreval"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
@@ -78,7 +80,7 @@ func TestParse(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
content := ReadTestdata(t, tt.name+".in.yaml")
want := ReadTestdata(t, tt.name+".out.yaml")
got, err := Parse(content, tt.options...)
got, err := Parse(content, append([]ParseOption{WithGitContext(&model.GithubContext{})}, tt.options...)...)
if tt.wantErr {
require.Error(t, err)
}
@@ -112,8 +114,6 @@ func TestParse(t *testing.T) {
}
func TestParseDefersDynamicMatrix(t *testing.T) {
// A matrix referencing needs outputs yields one placeholder keeping the raw expression, rather
// than one job per resolvable static value. Any other matrix expands at plan time as usual.
const workflow = `
on: push
jobs:
@@ -121,32 +121,28 @@ jobs:
steps: [{run: echo}]
build:
%s
strategy:
matrix:
os: [a, b]
version: %s
%s
steps: [{run: echo}]
`
for _, tt := range []struct {
name string
needs string
version string
strategy string
deferred bool
want int
}{
{"needs outputs", "needs: setup", "${{ fromJson(needs.setup.outputs.v) }}", true, 1},
{"static", "needs: setup", "[1, 2]", false, 4},
// Without needs there is nothing to resolve the expression from later, so deferring would
// strand the job as a single combination that never expands.
{"expression without needs", "", `["${{ github.sha }}"]`, false, 2},
// A context that is already available while planning must keep expanding there, otherwise
// such a workflow would silently lose the per-combination commit statuses it used to create.
{"expression over another context", "needs: setup", `["${{ github.sha }}"]`, false, 2},
// The needs context is looked up in the parsed expression, not in the raw text.
{"needs inside a string literal", "needs: setup", `["${{ format('needs.setup.outputs.v {0}', github.sha) }}"]`, false, 2},
{"needs outputs", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.v) }}", true, 1},
{"static", "needs: setup", "strategy:\n matrix: {os: [a, b], version: [1, 2]}", false, 4},
{"max-parallel over needs outputs", "needs: setup", "strategy:\n max-parallel: ${{ needs.setup.outputs.limit }}\n matrix: {os: [a, b], version: [1, 2]}", true, 1},
{"fail-fast without matrix", "needs: setup", "strategy:\n fail-fast: ${{ needs.setup.outputs.fast }}", true, 1},
{"whole strategy without matrix", "needs: setup", "strategy: ${{ fromJSON(needs.setup.outputs.strategy) }}", true, 1},
{"expression without needs", "", "strategy:\n matrix:\n os: [a, b]\n version: [\"${{ github.sha }}\"]", false, 2},
{"expression over another context", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: [\"${{ github.sha }}\"]", false, 2},
{"needs inside a string literal", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: [\"${{ format('needs.setup.outputs.v {0}', github.sha) }}\"]", false, 2},
{"vars", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: ${{ fromJSON(vars.VERSIONS) }}", false, 4},
} {
t.Run(tt.name, func(t *testing.T) {
result, err := Parse(fmt.Appendf(nil, workflow, tt.needs, tt.version))
result, err := Parse(fmt.Appendf(nil, workflow, tt.needs, tt.strategy), WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"VERSIONS": "[1, 2]"}))
require.NoError(t, err)
var builds []*Job
@@ -161,6 +157,93 @@ jobs:
}
}
func TestParseWholeValueExpressions(t *testing.T) {
const workflow = `
on: push
env: ${{ fromJSON(vars.ENV) }}
jobs:
setup:
runs-on: ubuntu-latest
steps: [{run: echo}]
build:
%s
runs-on: ${{ matrix.os }}
strategy: ${{ fromJSON(%s) }}
services: ${{ fromJSON(vars.SERVICES) }}
defaults:
run: ${{ fromJSON(vars.DEFAULTS) }}
steps:
- uses: actions/checkout@v4
with: ${{ fromJSON(vars.WITH) }}
`
const strategy = `{"max-parallel": 1, "matrix": {"os": ["a", "b-${{ secrets.X }}"]}}`
runnerView := func(t *testing.T, job *model.Job) []any {
t.Helper()
runner := expreval.New(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Secrets: map[string]string{"X": "leaked"}}, exprparser.Config{}).Evaluate)
require.NoError(t, runner.EvaluateYamlNode(&job.Strategy.RawMatrix))
require.NoError(t, runner.EvaluateYamlNode(&job.RawRunsOn))
matrixes, err := job.GetMatrixes()
require.NoError(t, err)
name, err := runner.Interpolate(job.Name)
require.NoError(t, err)
return []any{matrixes[0]["os"], name, job.RunsOn()}
}
payloads := func(t *testing.T, needs, source string, options ...ParseOption) []string {
t.Helper()
workflows, err := Parse(fmt.Appendf(nil, workflow, needs, source), options...)
require.NoError(t, err)
var payloads []string
for _, w := range workflows {
if id, _ := w.Job(); id == "build" {
payload, err := w.Marshal()
require.NoError(t, err)
payloads = append(payloads, string(payload))
}
}
return payloads
}
planning := []ParseOption{WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"STRATEGY": strategy})}
planned := payloads(t, "", "vars.STRATEGY", planning...)
require.Len(t, planned, 2)
for _, raw := range []string{`max-parallel: "1"`, "env: ${{ fromJSON(vars.ENV) }}", "services: ${{ fromJSON(vars.SERVICES) }}", "run: ${{ fromJSON(vars.DEFAULTS) }}", "with: ${{ fromJSON(vars.WITH) }}"} {
assert.Contains(t, planned[0], raw)
}
for index, payload := range planned {
os := []string{"a", "b-${{ secrets.X }}"}[index]
read, err := model.ReadWorkflow(strings.NewReader(payload))
require.NoError(t, err)
assert.Equal(t, []any{os, "build (" + os + ")", []string{os}}, runnerView(t, read.Jobs["build"]))
_, job, err := ParseRawSingleWorkflow([]byte(payload))
require.NoError(t, err)
group, _, err := EvaluateConcurrency(&model.RawConcurrency{Group: "${{ matrix.os }}"}, "build", job, map[string]any{}, nil, nil, nil)
require.NoError(t, err)
assert.Equal(t, []any{"build (" + os + ")", []string{os}, os}, []any{job.DisplayName(), job.RunsOn(), group})
_, err = Parse([]byte(payload))
require.NoError(t, err)
}
assert.Len(t, payloads(t, "", "vars.STRATEGY"), 1)
deferred := payloads(t, "needs: setup", "needs.setup.outputs.strategy", planning...)
require.Len(t, deferred, 1)
_, placeholder, err := ParseRawSingleWorkflow([]byte(deferred[0]))
require.NoError(t, err)
expanded, err := ExpandMatrixWithNeeds("build", placeholder, &model.GithubContext{}, map[string]*JobResult{
"build": {Needs: []string{"setup"}},
"setup": {Result: "success", Outputs: map[string]string{"strategy": strategy}},
}, nil, nil, 256)
require.NoError(t, err)
require.Len(t, expanded, 2)
assert.Equal(t, "1", expanded[0].Strategy.MaxParallelString)
assert.Equal(t, []any{"build (b-${{ secrets.X }})", []string{"b-${{ secrets.X }}"}}, []any{expanded[1].DisplayName(), expanded[1].RunsOn()})
assert.Equal(t, []any{"b-${{ secrets.X }}", "build (b-${{ secrets.X }})", []string{"b-${{ secrets.X }}"}},
runnerView(t, &model.Job{Name: expanded[1].Name, RawRunsOn: expanded[1].RawRunsOn, Strategy: expanded[1].Strategy.actStrategy()}))
_, err = Parse(fmt.Appendf(nil, workflow, "", "vars.STRATEGY"), planning[0], WithVars(map[string]string{"STRATEGY": `"a"`}))
require.ErrorContains(t, err, "is not a map of strategy keys to values")
}
func TestParseInterpolatesRunName(t *testing.T) {
workflow := func(runName string) []byte {
return []byte("name: t\nrun-name: \"" + runName + "\"\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: [{run: echo}]\n")
@@ -183,6 +266,9 @@ func TestParseInterpolatesRunName(t *testing.T) {
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, tt.want, result[0].RunName)
payload, err := result[0].Marshal()
require.NoError(t, err)
assert.NotContains(t, string(payload), "run-name")
})
}
@@ -194,7 +280,7 @@ func TestParseInterpolatesRunName(t *testing.T) {
// a malformed part must not restructure the surrounding expression
for _, runName := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 'a' }} ${{ b", "${{ 'a }}"} {
_, err := Parse(workflow(runName), WithGitContext(&model.GithubContext{EventName: "push"}))
assert.ErrorContains(t, err, "interpolate run-name")
assert.Error(t, err, runName)
}
// callers such as commit status parse without a git context, leaving `github` a nil pointer
@@ -204,6 +290,68 @@ func TestParseInterpolatesRunName(t *testing.T) {
assert.Empty(t, result[0].RunName)
}
func TestParseRunsOnFromJSONArray(t *testing.T) {
content := []byte("on: push\njobs:\n build:\n runs-on: ${{ fromJSON(vars.RUNNER) }}\n steps: [{run: echo}]\n")
_, err := Parse(content)
require.NoError(t, err)
for runner, want := range map[string][]string{`["self-hosted", "linux"]`: {"self-hosted", "linux"}, "[]": {""}} {
result, err := Parse(content, WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"RUNNER": runner}))
require.NoError(t, err)
require.Len(t, result, 1)
_, job := result[0].Job()
assert.Equal(t, want, job.RunsOn(), runner)
}
}
func TestJobFieldsWithoutMatrix(t *testing.T) {
const workflow = `on: push
jobs:
seed:
steps: [{run: echo}]
build:
name: build-${{ github.ref_name }}
continue-on-error: ${{ fromJSON(vars.CONTINUE) }}
steps: [{run: echo}]
target:
needs: seed
name: target-${{ needs.seed.outputs.runner }}
runs-on: ${{ needs.seed.outputs.runner }}
continue-on-error: ${{ needs.seed.outputs.tolerate == 'true' }}
strategy: ${{ fromJSON(needs.seed.outputs.strategy) }}
steps: [{run: echo}]
`
parse := func(t *testing.T, continueOnError string) map[string]*SingleWorkflow {
t.Helper()
parsed, err := Parse([]byte(workflow), WithGitContext(&model.GithubContext{RefName: "main"}), WithVars(map[string]string{"CONTINUE": continueOnError}))
require.NoError(t, err)
workflows := map[string]*SingleWorkflow{}
for _, parsedWorkflow := range parsed {
id, _ := parsedWorkflow.Job()
workflows[id] = parsedWorkflow
}
return workflows
}
workflows := parse(t, "true")
_, build := workflows["build"].Job()
assert.Equal(t, []any{"build-main", true}, []any{build.Name, build.GetContinueOnError()})
payload, err := parse(t, `"${{ true }}"`)["build"].Marshal()
require.NoError(t, err)
assert.Contains(t, string(payload), "${{ '$' }}{{ true }}")
_, placeholder := workflows["target"].Job()
require.True(t, HasDeferredMatrix(placeholder))
expanded, err := ExpandMatrixWithNeeds("target", placeholder, &model.GithubContext{}, map[string]*JobResult{
"target": {Needs: []string{"seed"}},
"seed": {Result: "success", Outputs: map[string]string{"runner": "ubuntu-latest", "tolerate": "true", "strategy": `{"fail-fast":false,"max-parallel":3}`}},
}, nil, nil, 256)
require.NoError(t, err)
require.Len(t, expanded, 1)
group, _, err := EvaluateConcurrency(&model.RawConcurrency{Group: "${{ strategy.job-index }}:${{ strategy.job-total }}:${{ strategy.fail-fast }}"}, "target", expanded[0], map[string]any{}, nil, nil, nil)
require.NoError(t, err)
assert.Equal(t, []any{"target-ubuntu-latest", []string{"ubuntu-latest"}, true, "3", "0:1:false"},
[]any{expanded[0].DisplayName(), expanded[0].RunsOn(), expanded[0].GetContinueOnError(), expanded[0].Strategy.MaxParallelString, group})
}
func TestExpandMatrixWithNeeds(t *testing.T) {
// matrixYAML is the YAML value of the `matrix:` key, so a case can replace the whole node.
expandMax := func(t *testing.T, matrixYAML string, maxCombinations int) ([]*Job, error) {
@@ -221,6 +369,7 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
"os": `["linux", "darwin"]`,
"include": `[{"os":"linux","fast":true},{"os":"windows","fast":false}]`,
"empty": "[]",
"limit": "3",
}},
}, nil, nil, maxCombinations)
}
@@ -230,17 +379,18 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
}
t.Run("expands the product and interpolates runs-on", func(t *testing.T) {
got, err := expand(t, "\n os: ${{ fromJson(needs.setup.outputs.os) }}\n version: ${{ fromJson(needs.setup.outputs.versions) }}\n")
got, err := expand(t, "\n os: ${{ fromJson(needs.setup.outputs.os) }}\n version: ${{ fromJson(needs.setup.outputs.versions) }}\nmax-parallel: ${{ needs.setup.outputs.limit }}\n")
require.NoError(t, err)
names := make([]string, 0, len(got))
indexes := make(map[string]int, len(got))
for _, combo := range got {
names = append(names, combo.Name)
indexes[combo.Name] = combo.Strategy.JobIndex
assert.Contains(t, []string{"linux", "darwin"}, combo.RunsOn()[0])
assert.Equal(t, "3", combo.Strategy.MaxParallelString)
assert.Equal(t, 4, combo.Strategy.JobTotal)
}
// Dimensions are appended in key order, as GitHub names multi-dimension combinations.
assert.ElementsMatch(t, []string{
"build (linux, 1.20)", "build (linux, 1.21)", "build (darwin, 1.20)", "build (darwin, 1.21)",
}, names)
assert.Equal(t, map[string]int{
"build (linux, 1.20)": 0, "build (linux, 1.21)": 1, "build (darwin, 1.20)": 2, "build (darwin, 1.21)": 3,
}, indexes)
})
t.Run("static and dynamic dimensions expand together, once", func(t *testing.T) {
@@ -255,7 +405,6 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
assert.Len(t, got, 2)
})
// GitHub rejects a matrix that yields no combinations instead of running the job unparameterized.
for _, tt := range []struct{ name, matrix, errHas string }{
{"empty vector", "\n version: ${{ fromJson(needs.setup.outputs.empty) }}\n", `Matrix vector "version" does not contain any values`},
{"empty include", "\n include: ${{ fromJson(needs.setup.outputs.empty) }}\n", "Matrix must define at least one vector"},
@@ -267,11 +416,24 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
})
}
t.Run("fully excluded matrix runs once without matrix values", func(t *testing.T) {
got, err := expand(t, "\n os: [linux]\n exclude:\n - os: linux\n")
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "build", got[0].Name)
assert.Equal(t, 1, got[0].Strategy.JobTotal)
})
t.Run("unresolved need errors", func(t *testing.T) {
_, err := expand(t, "\n v: ${{ fromJson(needs.missing.outputs.v) }}\n")
require.ErrorContains(t, err, "evaluate matrix")
})
t.Run("invalid strategy field errors", func(t *testing.T) {
_, err := expand(t, "\n os: [linux]\nmax-parallel: ${{ fromJSON('bad') }}\n")
require.ErrorContains(t, err, "evaluate strategy")
})
// The combination count comes from a runtime output, so it must be rejected before one Job per
// combination is built rather than after.
t.Run("too many combinations errors", func(t *testing.T) {
@@ -280,40 +442,34 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
})
}
// evaluateJobIf builds a one-job workflow around the given `matrix:` value and `if:`, and decides it.
func evaluateJobIf(t *testing.T, matrixYAML, ifExpr string, deferred bool) (bool, error) {
t.Helper()
var strategy Strategy
require.NoError(t, yaml.Unmarshal(fmt.Appendf(nil, "matrix:\n %s\n", matrixYAML), &strategy))
job := &Job{Name: "build", Strategy: strategy}
require.NoError(t, job.If.Encode(ifExpr))
return EvaluateJobIfExpression("build", job, map[string]any{}, map[string]*JobResult{"build": {}}, nil, nil, deferred)
func TestReadWorkflowJobConditionContexts(t *testing.T) {
for condition, unavailable := range map[string]string{
"matrix.os == 'a'": "matrix",
"'${{ strategy.job-index == 0 }}'": "strategy",
"'${{ github.ref }} ${{ secrets.X }}'": "secrets",
"github.event.matrix && gitea.ref && needs.a.result && vars.X && inputs.y && always() && fromJSON('true')": "",
} {
_, err := ReadWorkflow([]byte("jobs: {build: {if: " + condition + "}}"))
if unavailable == "" {
assert.NoError(t, err, condition)
} else {
assert.ErrorContains(t, err, "Unrecognized named-value: '"+unavailable+"'", condition)
}
}
}
func TestRejectsUnevaluatedMatrixFilters(t *testing.T) {
// An unevaluated expression is still a scalar, which is not a filter act can apply.
// Every entry point into act's matrix expansion must reject it. The
// expression here reads `vars`, which is available while planning, so the job is not deferred and
// nothing will ever resolve the filter: the error is the right answer at both entry points.
// A deferred placeholder is the other case, covered by TestEvaluateJobIfExpressionLeavesRawMatrixUnavailable.
for _, filter := range []string{"include", "exclude"} {
t.Run(filter, func(t *testing.T) {
_, err := Parse(fmt.Appendf(nil,
"name: t\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}\n steps: [{run: echo}]\n", filter))
require.ErrorContains(t, err, "must be a list of mappings")
_, err = evaluateJobIf(t, fmt.Sprintf("os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}", filter), "${{ true }}", false)
require.ErrorContains(t, err, "must be a list of mappings")
"name: t\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}\n steps: [{run: echo}]\n", filter),
WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"MATRIX": `"a"`}))
require.ErrorContains(t, err, "is not a list of maps")
})
}
}
func TestParseRawSingleWorkflowRoundTripsDeferredPlaceholder(t *testing.T) {
// The server persists a placeholder the way insertRunJob does: erase the needs, then marshal.
// Reading it back must yield that one job again. Parse cannot do it: it only keeps a matrix raw
// while the job still declares needs, so on the stored payload it falls through to expanding the
// raw matrix instead - which either fails or splits the placeholder into several workflows, and
// in both cases leaves the job unexpandable for good.
const workflow = `
on: push
jobs:
@@ -327,21 +483,13 @@ jobs:
%s
steps: [{run: echo}]
`
for _, tt := range []struct {
name string
matrix string
parseCount int // what Parse makes of the stored payload
parseErrHas string // ... or the error it fails with
}{
// The canonical GitHub dynamic-matrix idiom. validateMatrixFilters rejects the still-scalar expression outright.
{name: "include expression", matrix: "include: ${{ fromJson(needs.setup.outputs.m) }}", parseErrHas: "must be a list of mappings"},
// An unevaluated expression is a scalar where a vector is required, rather than one
// combination holding the literal `${{ }}` text.
{name: "static vector and expression", matrix: "os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.m) }}", parseErrHas: `Matrix vector "version" is not a list of values`},
{name: "single expression vector", matrix: "version: ${{ fromJson(needs.setup.outputs.m) }}", parseErrHas: `Matrix vector "version" is not a list of values`},
for name, matrix := range map[string]string{
"include expression": "include: ${{ fromJson(needs.setup.outputs.m) }}",
"static vector and expression": "os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.m) }}",
"single expression vector": "version: ${{ fromJson(needs.setup.outputs.m) }}",
} {
t.Run(tt.name, func(t *testing.T) {
planned, err := Parse(fmt.Appendf(nil, workflow, tt.matrix))
t.Run(name, func(t *testing.T) {
planned, err := Parse(fmt.Appendf(nil, workflow, matrix))
require.NoError(t, err)
var payload []byte
@@ -357,90 +505,15 @@ jobs:
}
require.NotEmpty(t, payload, "no placeholder was planned for build")
// The stored payload keeps the raw matrix, but no longer the needs that made Parse defer it.
_, job, err := ParseRawSingleWorkflow(payload)
require.NoError(t, err)
assert.Equal(t, "build", job.Name)
// The needs are gone, which is exactly why Parse no longer defers this payload.
assert.Empty(t, job.Needs())
assert.False(t, HasDeferredMatrix(job))
// Guard the reason ParseRawSingleWorkflow exists, so a future Parse change cannot quietly
// make the placeholder re-expandable again without this being noticed.
reparsed, err := Parse(payload)
if tt.parseErrHas != "" {
require.ErrorContains(t, err, tt.parseErrHas)
} else {
require.NoError(t, err)
assert.Len(t, reparsed, tt.parseCount)
}
require.NoError(t, err)
assert.Len(t, reparsed, 1)
})
}
}
func TestEvaluateJobIfExpressionLeavesRawMatrixUnavailable(t *testing.T) {
// A placeholder's `if:` is read before its matrix can be resolved. `matrix.*` has to be absent
// there: binding it to the expression's own source text would decide the job against a value no
// combination ever has, and an include/exclude that is still a scalar cannot be read at all.
t.Run("include expression is not read", func(t *testing.T) {
run, err := evaluateJobIf(t, "include: ${{ fromJson(needs.setup.outputs.m) }}", "${{ true }}", true)
require.NoError(t, err)
assert.True(t, run)
})
t.Run("matrix context is null, not the raw expression", func(t *testing.T) {
const matrix = "version: ${{ fromJson(needs.setup.outputs.m) }}"
run, err := evaluateJobIf(t, matrix, "${{ matrix.version == null }}", true)
require.NoError(t, err)
assert.True(t, run)
run, err = evaluateJobIf(t, matrix, "${{ matrix.version == '${{ fromJson(needs.setup.outputs.m) }}' }}", true)
require.NoError(t, err)
assert.False(t, run)
})
t.Run("an expanded job still reads its combination", func(t *testing.T) {
run, err := evaluateJobIf(t, "version: [1]", "${{ matrix.version == 1 }}", false)
require.NoError(t, err)
assert.True(t, run)
})
}
func TestExpressionReadsMatrix(t *testing.T) {
// Erring toward true only postpones the `if:` to the pass that has the combination, which decides it correctly anyway.
for value, want := range map[string]bool{
"": false,
"true": false, // a bare literal is an expression too, it just reads nothing
"${{ always() }}": false,
"${{ needs.setup.result == 'ok' }}": false,
"${{ vars.MATRIX }}": false, // a name that merely looks like the context
"${{ matrix.os }}": true,
"${{ MATRIX.os }}": true, // contexts are case-insensitive
"${{ always() && matrix.os == 1 }}": true,
"${{ contains(matrix.tags, 'a') }}": true,
"${{ toJSON(matrix) }}": true, // the whole context, not a property of it
"${{ vars.A }}${{ matrix.os }}": true, // only the second of two expressions reads it
"${{ matrix.os == }}": true, // unparseable, postpone rather than decide it here
// An `if:` may omit the `${{ }}`, and is evaluated as one expression either way.
"matrix.os == 'a'": true,
"needs.setup.result == 'ok'": false,
} {
assert.Equal(t, want, ExpressionReadsMatrix(value), "value %q", value)
}
}
func TestExpressionIgnoresNeedResults(t *testing.T) {
for value, want := range map[string]bool{
"": false,
"${{ matrix.os == 'a' }}": false,
"${{ success() }}": false, // the implicit gate, so the fallback already matches it
"${{ always() }}": true,
"${{ ALWAYS() && matrix.os }}": true, // function names are case-insensitive
"${{ failure() }}": true,
"${{ cancelled() }}": true,
"always() && matrix.os == 'a'": true, // the brace-less form of the same gate
"${{ vars.always }}": false,
} {
assert.Equal(t, want, ExpressionIgnoresNeedResults(value), "value %q", value)
}
}
+127 -181
View File
@@ -14,18 +14,19 @@ import (
"gitea.dev/actionslib/pkg/model"
"gitea.dev/modules/util"
"github.com/robfig/cron/v3"
"go.yaml.in/yaml/v4"
)
// SingleWorkflow is a workflow with single job and single matrix
type SingleWorkflow struct {
Name string `yaml:"name,omitempty"`
RawOn yaml.Node `yaml:"on,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
RawJobs yaml.Node `yaml:"jobs,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
RunName string `yaml:"run-name,omitempty"`
Name string `yaml:"name,omitempty"`
RawOn yaml.Node `yaml:"on,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
RawJobs yaml.Node `yaml:"jobs,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
RunName string `yaml:"run-name,omitempty"`
}
func (w *SingleWorkflow) Job() (string, *Job) {
@@ -93,7 +94,9 @@ func (w *SingleWorkflow) Marshal() ([]byte, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(w); err != nil {
payload := *w
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
if err := enc.Encode(&payload); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
@@ -103,24 +106,24 @@ func (w *SingleWorkflow) Marshal() ([]byte, error) {
}
type Job struct {
Name string `yaml:"name,omitempty"`
RawNeeds yaml.Node `yaml:"needs,omitempty"`
RawRunsOn yaml.Node `yaml:"runs-on,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Steps []*Step `yaml:"steps,omitempty"`
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"`
Services map[string]*ContainerSpec `yaml:"services,omitempty"`
Strategy Strategy `yaml:"strategy,omitempty"`
RawContainer yaml.Node `yaml:"container,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
Outputs map[string]string `yaml:"outputs,omitempty"`
Uses string `yaml:"uses,omitempty"`
With map[string]any `yaml:"with,omitempty"`
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
Name string `yaml:"name,omitempty"`
RawNeeds yaml.Node `yaml:"needs,omitempty"`
RawRunsOn yaml.Node `yaml:"runs-on,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Steps []*Step `yaml:"steps,omitempty"`
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"`
Services yaml.Node `yaml:"services,omitempty"`
Strategy Strategy `yaml:"strategy,omitempty"`
RawContainer yaml.Node `yaml:"container,omitempty"`
Defaults yaml.Node `yaml:"defaults,omitempty"`
Outputs map[string]string `yaml:"outputs,omitempty"`
Uses string `yaml:"uses,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
}
// GetContinueOnError decodes the continue-on-error field to a bool.
@@ -171,8 +174,18 @@ func (j *Job) EraseNeeds() *Job {
return j
}
// RunsOn returns the labels Gitea matches runners against, unescaped like DisplayName.
func (j *Job) RunsOn() []string {
return (&model.Job{RawRunsOn: j.RawRunsOn}).RunsOn()
runsOn := model.RunsOnFromNode(j.RawRunsOn)
for i, label := range runsOn {
runsOn[i] = unescapeExpressions(label)
}
return runsOn
}
// DisplayName is the name Gitea stores, without the escaping the payload keeps for runners.
func (j *Job) DisplayName() string {
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
}
// BlockSafeString works around https://github.com/yaml/go-yaml/issues/399, quoting a value whose
@@ -187,17 +200,17 @@ func (s BlockSafeString) MarshalYAML() (any, error) {
}
type Step struct {
ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Name BlockSafeString `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"`
Run BlockSafeString `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
With map[string]string `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Name BlockSafeString `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"`
Run BlockSafeString `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
}
// UnmarshalYAML canonicalizes booleans like continue-on-error
@@ -225,20 +238,45 @@ func (s *Step) String() string {
}).String()
}
type ContainerSpec struct {
Image string `yaml:"image,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
Ports []string `yaml:"ports,omitempty"`
Volumes []string `yaml:"volumes,omitempty"`
Options string `yaml:"options,omitempty"`
Credentials map[string]string `yaml:"credentials,omitempty"`
Cmd []string `yaml:"cmd,omitempty"`
}
type Strategy struct {
FailFastString string `yaml:"fail-fast,omitempty"`
MaxParallelString string `yaml:"max-parallel,omitempty"`
RawMatrix yaml.Node `yaml:"matrix,omitempty"`
JobIndex int `yaml:"job-index,omitempty"` // set by buildMatrixCombos, read back from its payload
JobTotal int `yaml:"job-total,omitempty"`
RawExpression yaml.Node `yaml:"-"` // a whole-value `strategy: ${{ }}`, see Strategy.resolve
}
type rawStrategy Strategy
func (s *Strategy) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
*s = Strategy{RawExpression: *node}
return nil
}
return node.Decode((*rawStrategy)(s))
}
func (s Strategy) MarshalYAML() (any, error) {
if s.RawExpression.Kind != 0 {
return &s.RawExpression, nil
}
return rawStrategy(s), nil
}
func (s Strategy) actStrategy() *model.Strategy {
return &model.Strategy{FailFastString: s.FailFastString, MaxParallelString: s.MaxParallelString, RawMatrix: s.RawMatrix}
}
// context is the strategy context, combination 0 of 1 without a matrix as on GitHub, and without job-index for an unexpanded matrix.
func (s *Strategy) context() map[string]any {
switch {
case s == nil:
return exprparser.StrategyContext(nil, 0, 0)
case s.RawMatrix.Kind == 0 && s.RawExpression.Kind == 0:
return exprparser.StrategyContext(s.actStrategy(), 0, 1)
}
return exprparser.StrategyContext(s.actStrategy(), s.JobIndex, s.JobTotal)
}
type Defaults struct {
@@ -250,20 +288,10 @@ type RunDefaults struct {
WorkingDirectory string `yaml:"working-directory,omitempty"`
}
type WorkflowDispatchInput struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
Type string `yaml:"type"`
Options []string `yaml:"options"`
}
type Event struct {
Name string
acts map[string][]string
schedules []map[string]string
inputs []WorkflowDispatchInput
}
func (evt *Event) IsSchedule() bool {
@@ -278,10 +306,6 @@ func (evt *Event) Schedules() []map[string]string {
return evt.schedules
}
func (evt *Event) Inputs() []WorkflowDispatchInput {
return evt.inputs
}
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
w, err := ReadWorkflow(content)
if err != nil {
@@ -290,26 +314,30 @@ func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
return w.RawConcurrency, nil
}
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
actJob := &model.Job{}
// newJobEvaluator evaluates against a stored job's contexts, with its single matrix combination.
func newJobEvaluator(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (expreval.Evaluator, error) {
var strategy *Strategy
var matrix map[string]any
if job != nil {
actJob.Strategy = &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
strategy = &job.Strategy
rawMatrix := model.CloneYamlNode(job.Strategy.RawMatrix)
replaceScalars(&rawMatrix, unescapeExpressions)
matrixes, err := (&model.Job{Strategy: &model.Strategy{RawMatrix: rawMatrix}}).GetMatrixes()
if err != nil {
return expreval.Evaluator{}, err
}
if len(matrixes[0]) > 0 {
matrix = matrixes[0]
}
}
return expreval.New(NewInterpeter(jobID, strategy, matrix, model.GithubContextFromMap(gitCtx), results, vars, inputs).Evaluate), nil
}
matrix := make(map[string]any)
matrixes, err := matrixesOf(actJob)
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
evaluator, err := newJobEvaluator(jobID, job, gitCtx, results, vars, inputs)
if err != nil {
return "", false, err
}
if len(matrixes) > 0 {
matrix = matrixes[0]
}
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
var node yaml.Node
if err := node.Encode(rc); err != nil {
return "", false, fmt.Errorf("failed to encode concurrency: %w", err)
@@ -327,40 +355,6 @@ func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCt
return evaluated.Group, util.ParseYamlBool(evaluated.CancelInProgress), nil
}
func toGitContext(input map[string]any) *model.GithubContext {
gitContext := &model.GithubContext{
EventPath: asString(input["event_path"]),
Workflow: asString(input["workflow"]),
RunID: asString(input["run_id"]),
RunNumber: asString(input["run_number"]),
Actor: asString(input["actor"]),
Repository: asString(input["repository"]),
EventName: asString(input["event_name"]),
Sha: asString(input["sha"]),
Ref: asString(input["ref"]),
RefName: asString(input["ref_name"]),
RefType: asString(input["ref_type"]),
HeadRef: asString(input["head_ref"]),
BaseRef: asString(input["base_ref"]),
Token: asString(input["token"]),
Workspace: asString(input["workspace"]),
Action: asString(input["action"]),
ActionPath: asString(input["action_path"]),
ActionRef: asString(input["action_ref"]),
ActionRepository: asString(input["action_repository"]),
Job: asString(input["job"]),
RepositoryOwner: asString(input["repository_owner"]),
RetentionDays: asString(input["retention_days"]),
}
event, ok := input["event"].(map[string]any)
if ok {
gitContext.Event = event
}
return gitContext
}
// workflowCallEvent is only fired by another workflow's `uses:`, so it is excluded from trigger detection.
const workflowCallEvent = "workflow_call"
@@ -372,6 +366,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
if err != nil {
return nil, err
}
if rawOn.ShortTag() != "!!str" || val == "" {
return nil, fmt.Errorf("invalid event %q", val)
}
if val == workflowCallEvent {
return []*Event{}, nil
}
@@ -421,18 +418,24 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
}
schedules := make([]map[string]string, len(t))
if k == "schedule" {
if len(t) == 0 {
return nil, errors.New("schedule must contain at least one cron entry")
}
for i, tt := range t {
vv, ok := tt.(map[string]any)
if !ok {
return nil, fmt.Errorf("unknown on type(schedule): %#v", v)
return nil, errors.New("unknown on type(schedule)")
}
schedules[i] = make(map[string]string, len(vv))
for k, vvv := range vv {
var ok bool
if schedules[i][k], ok = vvv.(string); !ok {
return nil, fmt.Errorf("unknown on type(schedule): %#v", v)
return nil, errors.New("unknown on type(schedule)")
}
}
if _, err := cron.ParseStandard(schedules[i]["cron"]); err != nil {
return nil, fmt.Errorf("invalid cron %q: %w", schedules[i]["cron"], err)
}
}
}
@@ -444,14 +447,14 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
schedules: schedules,
})
case yaml.MappingNode:
// Keep combined include and ignore filters for existing Gitea workflows, although GitHub rejects them.
acts := make(map[string][]string, len(v.Content)/2)
var inputs []WorkflowDispatchInput
expectedKey := true
var act string
for _, content := range v.Content {
if expectedKey {
if content.Kind != yaml.ScalarNode {
return nil, fmt.Errorf("key type not string: %#v", content)
return nil, errors.New("key type not string")
}
act = ""
err := content.Decode(&act)
@@ -476,48 +479,23 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
acts[act] = []string{t}
case yaml.MappingNode:
if k != "workflow_dispatch" || act != "inputs" {
return nil, fmt.Errorf("map should only for workflow_dispatch but %s: %#v", act, content)
return nil, fmt.Errorf("map should only for workflow_dispatch but %s", act)
}
var key string
for i, vv := range content.Content {
if i%2 == 0 {
if vv.Kind != yaml.ScalarNode {
return nil, fmt.Errorf("key type not string: %#v", vv)
}
key = ""
if err := vv.Decode(&key); err != nil {
return nil, err
}
} else {
if vv.Kind != yaml.MappingNode {
return nil, fmt.Errorf("key type not map(%s): %#v", key, vv)
}
input := WorkflowDispatchInput{}
if err := vv.Decode(&input); err != nil {
return nil, err
}
input.Name = key
inputs = append(inputs, input)
}
if err := content.Decode(new(map[string]model.WorkflowDispatchInput)); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown on type: %#v", content)
return nil, fmt.Errorf("unknown on type for %s", act)
}
}
expectedKey = !expectedKey
}
if len(inputs) == 0 {
inputs = nil
}
if len(acts) == 0 {
acts = nil
}
res = append(res, &Event{
Name: k,
acts: acts,
inputs: inputs,
Name: k,
acts: acts,
})
default:
return nil, fmt.Errorf("unknown on type: %v", v.Kind)
@@ -529,35 +507,12 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
}
}
// EvaluateJobIfExpression evaluates a job's `if:`.
func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any, matrixDeferred bool) (bool, error) {
actJob := &model.Job{
Strategy: &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
},
// EvaluateJobIfExpression evaluates a job's `if:`, which github.com decides before the matrix, so without the matrix and strategy contexts.
func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (bool, error) {
if unavailable := unavailableContext(IfExpression(job.If.Value), jobConditionContexts); unavailable != "" { // only a job stored before its conditions were validated
return false, fmt.Errorf("job %s: Unrecognized named-value: '%s', update the workflow and trigger a new run", jobID, unavailable)
}
// Each per-matrix job carries its single matrix combination in RawMatrix so resolve it and pass it in;
// otherwise `matrix.*` references in `if:` evaluate to null.
// GetMatrixes always returns at least one element (an empty map for a job without a matrix),
// so only a non-empty combination should populate `matrix.*`, leaving it nil otherwise.
//
// A deferred-matrix placeholder is the exception: its combinations do not exist yet, and reading the
// raw matrix here would either fail outright (an `include` that is still a scalar expression) or bind
// `matrix.*` to the expression's own source text. Leaving it nil is safe: the caller checks
// ExpressionReadsMatrix first, so an `if:` that reads `matrix.*` is deferred to the post-expansion pass.
var matrix map[string]any
if !matrixDeferred {
matrixes, err := matrixesOf(actJob)
if err != nil {
return false, err
}
if len(matrixes) > 0 && len(matrixes[0]) > 0 {
matrix = matrixes[0]
}
}
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
evaluator := expreval.New(NewInterpeter(jobID, nil, nil, model.GithubContextFromMap(gitCtx), results, vars, inputs).Evaluate)
return evaluator.EvalBool(job.If.Value, exprparser.DefaultStatusCheckSuccess)
}
@@ -593,12 +548,3 @@ func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) {
return scalars, datas, nil
}
func asString(v any) string {
if v == nil {
return ""
} else if s, ok := v.(string); ok {
return s
}
return ""
}
+33 -72
View File
@@ -17,9 +17,18 @@ import (
func TestParseRawOn(t *testing.T) {
kases := []struct {
input string
result []*Event
input string
result []*Event
wantErr bool
}{
{input: "on:\n push:\n branches:\n a: b", wantErr: true},
{input: "on: [push, {pull_request: null}]", wantErr: true},
{input: "on:", wantErr: true},
{input: "on: 42", wantErr: true},
{input: "jobs: {}", wantErr: true},
{input: "on:\n schedule: []", wantErr: true},
{input: "on:\n schedule:\n - {}", wantErr: true},
{input: "on:\n schedule:\n - cron: nope", wantErr: true},
{
input: "on: issue_comment",
result: []*Event{
@@ -190,13 +199,14 @@ func TestParseRawOn(t *testing.T) {
},
},
{
input: "on:\n schedule:\n - cron: '20 6 * * *'",
input: "on:\n schedule:\n - cron: '20 6 * * *'\n timezone: UTC",
result: []*Event{
{
Name: "schedule",
schedules: []map[string]string{
{
"cron": "20 6 * * *",
"cron": "20 6 * * *",
"timezone": "UTC",
},
},
},
@@ -228,28 +238,6 @@ func TestParseRawOn(t *testing.T) {
result: []*Event{
{
Name: "workflow_dispatch",
inputs: []WorkflowDispatchInput{
{
Name: "logLevel",
Description: "Log level",
Required: true,
Default: "warning",
Type: "choice",
Options: []string{"info", "warning", "debug"},
},
{
Name: "tags",
Description: "Test scenario tags",
Required: false,
Type: "boolean",
},
{
Name: "environment",
Description: "Environment to run tests against",
Type: "environment",
Required: true,
},
},
},
{
Name: "push",
@@ -310,6 +298,10 @@ func TestParseRawOn(t *testing.T) {
assert.NoError(t, err)
events, err := ParseRawOn(&origin.RawOn)
if kase.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, kase.result, events, events)
})
@@ -467,51 +459,6 @@ func TestParseMappingNode(t *testing.T) {
}
}
func TestEvaluateJobIfExpressionMatrix(t *testing.T) {
ifExprs := []string{
`${{ contains(fromJSON('["linux","windows"]'), matrix.target) }}`,
`${{ contains('["linux","windows"]', matrix.target) }}`,
}
want := map[string]bool{
"build (linux)": true,
"build (windows)": true,
"build (macos)": false,
}
for _, ifExpr := range ifExprs {
t.Run(ifExpr, func(t *testing.T) {
content := fmt.Sprintf(`
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
if: %s
strategy:
fail-fast: false
matrix:
target: [linux, windows, macos]
steps:
- run: echo ${{ matrix.target }}
`, ifExpr)
swfs, err := Parse([]byte(content))
require.NoError(t, err)
require.Len(t, swfs, 3)
got := make(map[string]bool, len(swfs))
for _, swf := range swfs {
id, job := swf.Job()
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil, false)
require.NoError(t, err)
got[job.Name] = shouldRun
}
assert.Equal(t, want, got)
})
}
}
func TestEvaluateJobIfExpression(t *testing.T) {
kases := []struct {
name string
@@ -567,9 +514,23 @@ jobs:
"job1": {Result: kase.needResult},
"job2": {Needs: []string{"job1"}},
}
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil, false)
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil)
require.NoError(t, err)
assert.Equal(t, kase.expected, got)
})
}
t.Run("stored unavailable context", func(t *testing.T) {
for condition, wantErr := range map[string]string{
"matrix.os == 'a'": "Unrecognized named-value: 'matrix'",
"${{ strategy.fail-fast }}": "Unrecognized named-value: 'strategy'",
"secrets.TOKEN != ''": "Unrecognized named-value: 'secrets'",
"${{ matrix.os == }}": "Unexpected end of expression",
} {
_, job, err := ParseRawSingleWorkflow(fmt.Appendf(nil, "jobs: {job2: {if: %q, strategy: {matrix: {os: [a]}}}}", condition))
require.NoError(t, err)
_, err = EvaluateJobIfExpression("job2", job, map[string]any{}, map[string]*JobResult{"job2": {}}, nil, nil)
assert.ErrorContains(t, err, wantErr, condition)
}
})
}
@@ -10,6 +10,7 @@ jobs:
matrix:
experimental:
- false
job-total: 2
---
name: test
jobs:
@@ -23,3 +24,5 @@ jobs:
matrix:
experimental:
- true
job-index: 1
job-total: 2
@@ -14,6 +14,8 @@ jobs:
- ubuntu-20.04
version:
- 1.17
job-index: 3
job-total: 6
---
name: test
jobs:
@@ -31,6 +33,8 @@ jobs:
- ubuntu-20.04
version:
- 1.18
job-index: 4
job-total: 6
---
name: test
jobs:
@@ -48,6 +52,8 @@ jobs:
- ubuntu-20.04
version:
- 1.19
job-index: 5
job-total: 6
---
name: test
jobs:
@@ -65,6 +71,7 @@ jobs:
- ubuntu-22.04
version:
- 1.17
job-total: 6
---
name: test
jobs:
@@ -82,6 +89,8 @@ jobs:
- ubuntu-22.04
version:
- 1.18
job-index: 1
job-total: 6
---
name: test
jobs:
@@ -99,3 +108,5 @@ jobs:
- ubuntu-22.04
version:
- 1.19
job-index: 2
job-total: 6
@@ -14,6 +14,8 @@ jobs:
- ubuntu-20.04
version:
- 1.17
job-index: 3
job-total: 6
---
name: test
jobs:
@@ -31,6 +33,8 @@ jobs:
- ubuntu-20.04
version:
- 1.18
job-index: 4
job-total: 6
---
name: test
jobs:
@@ -48,6 +52,8 @@ jobs:
- ubuntu-20.04
version:
- 1.19
job-index: 5
job-total: 6
---
name: test
jobs:
@@ -65,6 +71,7 @@ jobs:
- ubuntu-22.04
version:
- 1.17
job-total: 6
---
name: test
jobs:
@@ -82,6 +89,8 @@ jobs:
- ubuntu-22.04
version:
- 1.18
job-index: 1
job-total: 6
---
name: test
jobs:
@@ -99,3 +108,5 @@ jobs:
- ubuntu-22.04
version:
- 1.19
job-index: 2
job-total: 6
-79
View File
@@ -1,79 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"errors"
"fmt"
"path"
"regexp"
"strings"
)
// UsesKind enumerates the supported forms of a reusable workflow "uses:" value.
type UsesKind int
const (
// UsesKindLocalSameRepo is "./<dir>/foo.yml" or "$/<dir>/foo.yml" - a path inside the calling repository.
// For example: "./.gitea/workflows/foo.yml"
UsesKindLocalSameRepo UsesKind = iota + 1
// UsesKindLocalCrossRepo is "owner/repo/<dir>/foo.yml@ref" - a workflow in another repo on the same instance.
// For example: "owner/repo/.gitea/workflows/foo.yml@ref"
UsesKindLocalCrossRepo
)
// UsesRef is the parsed form of a reusable workflow "uses:" value.
type UsesRef struct {
Kind UsesKind
Owner string // empty for UsesKindLocalSameRepo
Repo string // empty for UsesKindLocalSameRepo
Path string // workflow file path inside the source repo
Ref string // git ref; empty for UsesKindLocalSameRepo
}
var (
reLocalSameRepo = regexp.MustCompile(`^[.$]/([^@]+\.ya?ml)$`)
reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/([^@]+\.ya?ml)@(.+)$`)
)
// ParseUses parses the SYNTAX of a reusable workflow "uses:" value into a UsesRef. Two forms are supported:
// - "./<dir>/foo.yml" or "$/<dir>/foo.yml" (UsesKindLocalSameRepo, no @ref)
// - "OWNER/REPO/<dir>/foo.yml@REF" (UsesKindLocalCrossRepo)
//
// It deliberately does NOT validate that <dir> is an allowed workflow directory: the allowed directories are instance-configurable (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS).
// The caller (services/actions.ResolveUses) enforces the directory allowlist. The returned Path is the cleaned, repo-relative file path.
func ParseUses(s string) (*UsesRef, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, errors.New("empty uses value")
}
if strings.HasPrefix(s, "./") || strings.HasPrefix(s, "$/") {
m := reLocalSameRepo.FindStringSubmatch(s)
if m == nil {
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./<dir>/<file>.yml or $/<dir>/<file>.yml)`, s)
}
p := m[1]
if path.Clean(p) != p {
return nil, fmt.Errorf("invalid workflow path %q", s)
}
return &UsesRef{Kind: UsesKindLocalSameRepo, Path: p}, nil
}
m := reLocalCrossRepo.FindStringSubmatch(s)
if m == nil {
return nil, fmt.Errorf(`invalid cross-repo "uses:" %q (expect owner/repo/<dir>/<file>.yml@ref)`, s)
}
p := m[3]
if path.Clean(p) != p {
return nil, fmt.Errorf("invalid workflow path %q", s)
}
return &UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: m[1],
Repo: m[2],
Path: p,
Ref: m[4],
}, nil
}
-195
View File
@@ -1,195 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseUses(t *testing.T) {
t.Run("LocalSameRepo", func(t *testing.T) {
cases := []struct {
name string
in string
want UsesRef
}{
{
name: "gitea dir, .yml",
in: "./.gitea/workflows/build.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"},
},
{
name: "github dir, .yml",
in: "./.github/workflows/build.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".github/workflows/build.yml"},
},
{
name: "gitea dir, .yaml",
in: "./.gitea/workflows/build.yaml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yaml"},
},
{
name: "filename containing dots is allowed",
in: "./.gitea/workflows/foo..bar.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/foo..bar.yml"},
},
{
name: "nested subdirectory",
in: "./.gitea/workflows/sub/build.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/sub/build.yml"},
},
{
// ParseUses is dir-agnostic; the allowed directories (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS) are enforced by ResolveUses.
name: "scoped workflows dir parses",
in: "./.gitea/scoped_workflows/lib.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/scoped_workflows/lib.yml"},
},
{
name: "non-default dir parses (allowlist enforced downstream)",
in: "./.gitea/custom_workflows/x.yaml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/custom_workflows/x.yaml"},
},
{
name: "self-repo prefix",
in: "$/.gitea/workflows/build.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"},
},
{
name: "leading/trailing whitespace is trimmed",
in: " ./.gitea/workflows/build.yml ",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := ParseUses(c.in)
require.NoError(t, err)
assert.Equal(t, c.want, *got)
})
}
})
t.Run("LocalCrossRepo", func(t *testing.T) {
cases := []struct {
name string
in string
want UsesRef
}{
{
name: "gitea dir, simple ref",
in: "owner/repo/.gitea/workflows/build.yml@v1",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/workflows/build.yml",
Ref: "v1",
},
},
{
name: "github dir, branch ref",
in: "owner/repo/.github/workflows/build.yml@main",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".github/workflows/build.yml",
Ref: "main",
},
},
{
name: ".yaml extension",
in: "owner/repo/.gitea/workflows/build.yaml@abc123",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/workflows/build.yaml",
Ref: "abc123",
},
},
{
name: "ref with slashes (refs/heads/feature)",
in: "owner/repo/.gitea/workflows/build.yml@refs/heads/feature",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/workflows/build.yml",
Ref: "refs/heads/feature",
},
},
{
name: "nested subdirectory under workflows",
in: "owner/repo/.gitea/workflows/sub/build.yml@v1",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/workflows/sub/build.yml",
Ref: "v1",
},
},
{
name: "scoped workflows dir parses (allowlist enforced by ResolveUses)",
in: "owner/repo/.gitea/scoped_workflows/lib.yml@v1",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/scoped_workflows/lib.yml",
Ref: "v1",
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := ParseUses(c.in)
require.NoError(t, err)
assert.Equal(t, c.want, *got)
})
}
})
t.Run("Errors", func(t *testing.T) {
cases := []struct {
name string
in string
}{
{name: "empty string", in: ""},
{name: "whitespace only", in: " "},
// Same-repo malformed (note: a wrong *directory* parses and should be rejected by the caller)
{name: "same-repo with @ref", in: "./.gitea/workflows/build.yml@v1"},
{name: "self-repo with @ref", in: "$/.gitea/workflows/build.yml@v1"},
{name: "same-repo wrong extension", in: "./.gitea/workflows/build.txt"},
{name: "same-repo missing extension", in: "./.gitea/workflows/build"},
{name: "same-repo absolute path", in: "/.gitea/workflows/build.yml"},
{name: "same-repo path traversal", in: "./.gitea/workflows/../escape.yml"},
{name: "same-repo double slash", in: "./.gitea/workflows//build.yml"},
{name: "same-repo redundant ./", in: "./.gitea/workflows/./build.yml"},
// Cross-repo malformed
{name: "cross-repo missing @ref", in: "owner/repo/.gitea/workflows/build.yml"},
{name: "cross-repo empty ref", in: "owner/repo/.gitea/workflows/build.yml@"},
{name: "cross-repo missing owner", in: "/repo/.gitea/workflows/build.yml@v1"},
{name: "cross-repo missing repo", in: "owner//.gitea/workflows/build.yml@v1"},
{name: "cross-repo wrong extension", in: "owner/repo/.gitea/workflows/build.txt@v1"},
{name: "cross-repo path traversal", in: "owner/repo/.gitea/workflows/../escape.yml@v1"},
{name: "cross-repo double slash in path", in: "owner/repo/.gitea/workflows//build.yml@v1"},
// owner/repo with chars Gitea's name validators reject
{name: "cross-repo owner with space", in: "bad owner/repo/.gitea/workflows/build.yml@v1"},
{name: "cross-repo repo with @", in: "owner/re@po/.gitea/workflows/build.yml@v1"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ParseUses(c.in)
assert.Error(t, err)
})
}
})
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"errors"
"fmt"
"slices"
"gitea.dev/actionslib/pkg/expreval"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
)
// jobConditionContexts are what github.com gives `jobs.<job_id>.if`, which it decides before the matrix, plus the `gitea` alias.
var jobConditionContexts = []string{"github", "gitea", "needs", "vars", "inputs"}
func ValidateWorkflowStatic(content []byte) ([]*Event, error) {
doc, err := resolveYamlAliases(content)
if err != nil {
return nil, err
}
// Keep unknown and case-distinct keys accepted for existing Gitea workflows.
workflow, err := readWorkflowDoc(doc)
if err != nil {
return nil, err
}
events, err := ParseRawOn(&workflow.RawOn)
if err != nil {
return nil, err
}
if err := validateWorkflowStructure(workflow); err != nil {
return nil, err
}
var header struct {
RunName string `yaml:"run-name"`
}
if err := decodeYamlDoc(doc, &header); err != nil {
return nil, err
}
if unavailable := unavailableContext(header.RunName, []string{"github", "gitea", "inputs", "vars"}); unavailable != "" {
return nil, fmt.Errorf("run-name: Unrecognized named-value: '%s'", unavailable)
}
return events, nil
}
func validateJobConditions(workflow *model.Workflow) error {
for id, job := range workflow.Jobs {
if job == nil {
continue
}
if unavailable := unavailableContext(IfExpression(job.If.Value), jobConditionContexts); unavailable != "" {
return fmt.Errorf("job %s: Unrecognized named-value: '%s'", id, unavailable)
}
}
return nil
}
func unavailableContext(expression string, allowed []string) string {
var unavailable string
expreval.Match(expression, func(node exprparser.ExprNode) bool {
if variable, ok := node.(*exprparser.VariableNode); ok && !slices.ContainsFunc(allowed, func(name string) bool { return exprparser.OrdinalIgnoreCaseEqual(name, variable.Name) }) {
unavailable = variable.Name
}
return unavailable != ""
})
return unavailable
}
func validateWorkflowStructure(workflow *model.Workflow) error {
if len(workflow.Jobs) == 0 {
return errors.New("the workflow must contain at least one job")
}
for id, job := range workflow.Jobs {
if job == nil {
return fmt.Errorf("job %q has no configuration", id)
}
// a job without runs-on is accepted and runs on any runner, github.com rejects it
for _, dependency := range job.Needs() {
if _, ok := workflow.Jobs[dependency]; !ok {
return fmt.Errorf("job %q needs unknown job %q", id, dependency)
}
}
}
visited := make(map[string]bool, len(workflow.Jobs))
visiting := make(map[string]bool, len(workflow.Jobs))
var visit func(string) error
visit = func(id string) error {
if visiting[id] {
return fmt.Errorf("job %q has a dependency cycle", id)
}
if visited[id] {
return nil
}
visiting[id] = true
for _, dependency := range workflow.Jobs[id].Needs() {
if err := visit(dependency); err != nil {
return err
}
}
delete(visiting, id)
visited[id] = true
return nil
}
for id := range workflow.Jobs {
if err := visit(id); err != nil {
return err
}
}
return nil
}
+36 -301
View File
@@ -9,274 +9,66 @@ import (
"regexp"
"strings"
"gitea.dev/actionslib/pkg/expreval"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
"gitea.dev/modules/container"
"gitea.dev/modules/util"
"go.yaml.in/yaml/v4"
)
// InputType enumerates the allowed types for a workflow_call input.
type InputType string
const (
InputTypeString InputType = "string"
InputTypeBoolean InputType = "boolean"
InputTypeNumber InputType = "number"
)
// InputSpec describes a single workflow_call input declaration.
type InputSpec struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default yaml.Node `yaml:"default"`
Type InputType `yaml:"type"`
}
// SecretSpec describes a single workflow_call secret declaration.
type SecretSpec struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
}
// OutputSpec describes a single workflow_call output declaration.
type OutputSpec struct {
Description string `yaml:"description"`
Value string `yaml:"value"`
}
// WorkflowCallSpec is the parsed "on.workflow_call" schema of a called workflow.
type WorkflowCallSpec struct {
Inputs map[string]InputSpec
Secrets map[string]SecretSpec
Outputs map[string]OutputSpec
}
// JobOutputs is the per-job-id outputs map used for evaluating workflow_call outputs.
type JobOutputs map[string]map[string]string
// ParseWorkflowCallSpec extracts on.workflow_call.{inputs,secrets,outputs} from a workflow YAML.
// Returns an error if the workflow does not declare on.workflow_call at all.
func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) {
var doc struct {
On yaml.Node `yaml:"on"`
}
if err := decodeResolved(content, &doc); err != nil {
return nil, fmt.Errorf("parse workflow yaml: %w", err)
}
wcNode, ok := findWorkflowCallNode(&doc.On)
if !ok {
return nil, errors.New("workflow does not declare on.workflow_call")
}
spec := &WorkflowCallSpec{
Inputs: map[string]InputSpec{},
Secrets: map[string]SecretSpec{},
Outputs: map[string]OutputSpec{},
}
if wcNode == nil || wcNode.Kind != yaml.MappingNode {
return spec, nil
}
for i := 0; i+1 < len(wcNode.Content); i += 2 {
key := wcNode.Content[i]
val := wcNode.Content[i+1]
switch key.Value {
case "inputs":
if err := decodeWorkflowCallMapping(val, spec.Inputs); err != nil {
return nil, fmt.Errorf("parse workflow_call.inputs: %w", err)
}
case "secrets":
if err := decodeWorkflowCallMapping(val, spec.Secrets); err != nil {
return nil, fmt.Errorf("parse workflow_call.secrets: %w", err)
}
case "outputs":
if err := decodeWorkflowCallMapping(val, spec.Outputs); err != nil {
return nil, fmt.Errorf("parse workflow_call.outputs: %w", err)
}
}
}
for name, in := range spec.Inputs {
if in.Type == "" {
return nil, fmt.Errorf("workflow_call input %q is missing required field \"type\"", name)
}
switch in.Type {
case InputTypeString, InputTypeBoolean, InputTypeNumber:
default:
return nil, fmt.Errorf("workflow_call input %q has unsupported type %q", name, in.Type)
}
}
return spec, nil
}
// findWorkflowCallNode walks the "on:" node and returns the value mapping (or nil) for "workflow_call".
// "ok" is true when the workflow declares workflow_call (even with an empty body).
func findWorkflowCallNode(on *yaml.Node) (val *yaml.Node, ok bool) {
if on == nil || on.Kind == 0 {
return nil, false
}
switch on.Kind {
case yaml.ScalarNode:
return nil, on.Value == "workflow_call"
case yaml.SequenceNode:
for _, item := range on.Content {
if item.Kind == yaml.ScalarNode && item.Value == "workflow_call" {
return nil, true
}
}
return nil, false
case yaml.MappingNode:
for i := 0; i+1 < len(on.Content); i += 2 {
k := on.Content[i]
v := on.Content[i+1]
if k.Value != "workflow_call" {
continue
}
if v.Kind == yaml.MappingNode {
return v, true
}
return nil, true
}
}
return nil, false
}
func decodeWorkflowCallMapping[T any](node *yaml.Node, dst map[string]T) error {
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(node.Content); i += 2 {
name := node.Content[i].Value
var v T
if err := node.Content[i+1].Decode(&v); err != nil {
return fmt.Errorf("%q: %w", name, err)
}
dst[name] = v
}
return nil
}
// EvaluateCallerWith evaluates the caller-side expressions in `job.With` against the provided contexts
func EvaluateCallerWith(
// ResolveCallerInputs evaluates the caller's `with` against its own contexts and types it by the called workflow's inputs.
func ResolveCallerInputs(
jobID string,
job *Job,
config *model.WorkflowCall,
gitCtx map[string]any,
results map[string]*JobResult,
vars map[string]string,
inputs map[string]any,
) (map[string]any, error) {
actJob := &model.Job{Strategy: &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
}}
var matrix map[string]any
matrixes, err := matrixesOf(actJob)
if job.With.Kind != 0 && job.With.Kind != yaml.MappingNode {
return nil, errors.New("caller with must be a mapping")
}
evaluator, err := newJobEvaluator(jobID, job, gitCtx, results, vars, inputs)
if err != nil {
return nil, fmt.Errorf("get caller %q matrix: %w", jobID, err)
}
if len(matrixes) > 0 {
matrix = matrixes[0]
var with map[string]any
if err := model.DecodeEvaluated("with", job.With, evaluator.EvaluateYamlNode, &with); err != nil {
return nil, err
}
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
out := make(map[string]any, len(job.With))
for k, raw := range job.With {
var evaluated any
switch v := raw.(type) {
case string:
node := yaml.Node{}
if err := node.Encode(v); err != nil {
return nil, fmt.Errorf("encode caller %q with[%q]: %w", jobID, k, err)
}
if err := evaluator.EvaluateYamlNode(&node); err != nil {
return nil, fmt.Errorf("evaluate caller %q with[%q]: %w", jobID, k, err)
}
if err := node.Decode(&evaluated); err != nil {
return nil, fmt.Errorf("decode caller %q with[%q]: %w", jobID, k, err)
}
default:
evaluated = v
}
out[k] = evaluated
declared := make(map[string]model.WorkflowCallInput, len(config.Inputs))
for name, input := range config.Inputs {
declared[strings.ToLower(name)] = input
}
return out, nil
}
// MatchCallerInputsAgainstSpec checks the caller's already-evaluated `with:` values against the callee's declared `on.workflow_call.inputs` schema
func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]any) (map[string]any, error) {
resolved := make(map[string]any, len(spec.Inputs))
// fill defaults first
for name, in := range spec.Inputs {
if in.Default.IsZero() {
continue
}
var defaultVal any
if err := in.Default.Decode(&defaultVal); err != nil {
return nil, fmt.Errorf("decode workflow_call input %q default: %w", name, err)
}
v, err := parseWorkflowCallInput(name, in.Type, defaultVal)
if err != nil {
return nil, err
}
resolved[name] = v
}
for k, raw := range evaluated {
inputSpec, ok := spec.Inputs[k]
for name, value := range with {
input, ok := declared[strings.ToLower(name)]
if !ok {
// ignore unknown "with:" keys
continue
continue // ignored as in released Gitea, github.com rejects an undeclared input
}
converted, err := parseWorkflowCallInput(k, inputSpec.Type, raw)
if err != nil {
return nil, err
switch input.Type {
case "boolean":
if _, ok := value.(bool); !ok {
return nil, fmt.Errorf("input %s: expected a boolean, got %T", name, value)
}
case "number":
switch value.(type) {
case float64, int, int64, uint64:
default:
return nil, fmt.Errorf("input %s: expected a number, got %T", name, value)
}
}
resolved[k] = converted
}
for name, in := range spec.Inputs {
if !in.Required {
continue
}
// resolved[name] is set when caller provided it OR when spec has a non-zero default - both satisfy "required".
if _, ok := resolved[name]; ok {
continue
}
return nil, fmt.Errorf("workflow_call input %q is required", name)
}
return resolved, nil
return evaluator.ResolveWorkflowCallInputs(config, with)
}
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
switch typ {
case InputTypeString:
return exprparser.CoerceToString(v), nil
case InputTypeBoolean:
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
if b, ok := v.(bool); ok {
return b, nil
}
return false, fmt.Errorf("workflow_call input %q expects boolean", name)
case InputTypeNumber:
// strict type matching: a number input rejects "123"/"3.14" strings.
if _, isString := v.(string); isString {
return 0.0, fmt.Errorf("workflow_call input %q expects number", name)
}
return util.ToFloat64(v)
default:
return nil, fmt.Errorf("workflow_call input %q has unsupported type %q", name, typ)
// ParseWorkflowCallConfig reads the workflow_call configuration of a called workflow, decoding only its `on`.
func ParseWorkflowCallConfig(content []byte) (*model.WorkflowCall, error) {
var workflow struct {
RawOn yaml.Node `yaml:"on"`
}
if err := decodeResolved(content, &workflow); err != nil {
return nil, err
}
return (&model.Workflow{RawOn: workflow.RawOn}).ParseWorkflowCallConfig()
}
// SecretsInherit is the literal keyword used in a caller's `secrets: inherit` directive
@@ -317,60 +109,3 @@ func ParseCallerSecrets(node yaml.Node) (inherit bool, mapping map[string]string
}
return false, out, nil
}
// ValidateCallerSecrets checks a caller's parsed explicit-mapping `secrets:` against the called workflow's declared `on.workflow_call.secrets` schema.
func ValidateCallerSecrets(spec *WorkflowCallSpec, mapping map[string]string) error {
if spec == nil {
return errors.New("ValidateCallerSecrets: nil workflow_call spec")
}
// Secret names are case-insensitive, so compare declared names and caller aliases upper-cased.
declaredNames := make(container.Set[string], len(spec.Secrets))
for name := range spec.Secrets {
declaredNames.Add(strings.ToUpper(name))
}
provided := make(container.Set[string], len(mapping))
for alias := range mapping {
up := strings.ToUpper(alias)
provided.Add(up)
if !declaredNames.Contains(up) {
return fmt.Errorf("caller secret %q is not declared in the called workflow's on.workflow_call.secrets", alias)
}
}
for name, sec := range spec.Secrets {
if sec.Required && !provided.Contains(strings.ToUpper(name)) {
return fmt.Errorf("required secret %q is not provided by the caller", name)
}
}
return nil
}
// EvaluateWorkflowCallOutputs evaluates a called workflow's "on.workflow_call.outputs.<name>.value" expressions against the provided contexts.
func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubContext, vars map[string]string, inputs map[string]any, jobOutputs JobOutputs) (map[string]string, error) {
if spec == nil || len(spec.Outputs) == 0 {
return map[string]string{}, nil
}
jobsCtx := make(map[string]*model.WorkflowCallResult, len(jobOutputs))
for jobID, outputs := range jobOutputs {
jobsCtx[jobID] = &model.WorkflowCallResult{Outputs: outputs}
}
// See `on.workflow_call.outputs.<output_id>.value` in https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability
env := &exprparser.EvaluationEnvironment{
Github: gitCtx,
Jobs: &jobsCtx,
Vars: vars,
Inputs: inputs,
}
evaluator := expreval.New(exprparser.NewInterpeter(env, exprparser.Config{}).Evaluate)
out := make(map[string]string, len(spec.Outputs))
for name, o := range spec.Outputs {
v, err := evaluator.Interpolate(o.Value)
if err != nil {
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
}
out[name] = v
}
return out, nil
}
+42 -385
View File
@@ -4,7 +4,6 @@
package jobparser
import (
"maps"
"testing"
"gitea.dev/actionslib/pkg/model"
@@ -14,235 +13,51 @@ import (
"go.yaml.in/yaml/v4"
)
func TestParseWorkflowCallSpec(t *testing.T) {
t.Run("malformed YAML surfaces a parse error", func(t *testing.T) {
// Mismatched flow-sequence brackets — yaml.Unmarshal must reject this.
_, err := ParseWorkflowCallSpec([]byte(`name: bad
on: [workflow_call
jobs:
noop: { }
`))
require.Error(t, err)
})
t.Run("workflow without on.workflow_call is rejected", func(t *testing.T) {
notCallable := []byte(`name: ordinary
on: push
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo
`)
_, err := ParseWorkflowCallSpec(notCallable)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not declare on.workflow_call")
})
t.Run("input missing the required type field is rejected", func(t *testing.T) {
content := callableWorkflow(t, `inputs:
x:
description: missing type
`)
_, err := ParseWorkflowCallSpec(content)
require.Error(t, err)
assert.Contains(t, err.Error(), `missing required field "type"`)
})
t.Run("inputs/secrets/outputs are decoded", func(t *testing.T) {
content := callableWorkflow(t, `inputs:
env:
type: string
required: true
secrets:
DEPLOY_KEY:
required: true
outputs:
sha:
value: ${{ jobs.build.outputs.commit }}
`)
spec, err := ParseWorkflowCallSpec(content)
require.NoError(t, err)
assert.Equal(t, InputTypeString, spec.Inputs["env"].Type)
assert.True(t, spec.Inputs["env"].Required)
assert.True(t, spec.Secrets["DEPLOY_KEY"].Required)
assert.Equal(t, "${{ jobs.build.outputs.commit }}", spec.Outputs["sha"].Value)
})
}
func TestEvaluateCallerWith(t *testing.T) {
t.Run("empty with: returns empty map", func(t *testing.T) {
out, err := EvaluateCallerWith("caller", &Job{}, nil, callerResults("caller", nil, nil), nil, nil)
require.NoError(t, err)
assert.Empty(t, out)
})
t.Run("non-string raw values pass through unchanged", func(t *testing.T) {
job := &Job{With: map[string]any{
"already_bool": true,
"already_int": 42,
"already_slice": []any{"a", "b"},
}}
out, err := EvaluateCallerWith("caller", job, nil, callerResults("caller", nil, nil), nil, nil)
require.NoError(t, err)
assert.Equal(t, true, out["already_bool"])
assert.Equal(t, 42, out["already_int"])
assert.Equal(t, []any{"a", "b"}, out["already_slice"])
})
t.Run("expressions resolve against vars/inputs/results", func(t *testing.T) {
job := &Job{With: map[string]any{
"env_name": "${{ vars.ENV }}",
"from_inputs": "${{ inputs.PARENT_VAR }}",
"from_needs": "${{ needs.upstream.outputs.commit }}",
}}
gitCtx := map[string]any{"event": map[string]any{}}
results := callerResults("caller", []string{"upstream"}, map[string]*JobResult{
"upstream": {Result: "success", Outputs: map[string]string{"commit": "abc123"}},
})
vars := map[string]string{"ENV": "staging"}
inputs := map[string]any{"PARENT_VAR": "from-parent"}
out, err := EvaluateCallerWith("caller", job, gitCtx, results, vars, inputs)
require.NoError(t, err)
assert.Equal(t, "staging", out["env_name"])
assert.Equal(t, "from-parent", out["from_inputs"])
assert.Equal(t, "abc123", out["from_needs"])
})
t.Run("matrix.X resolves to this caller row's matrix instance", func(t *testing.T) {
var rawMatrix yaml.Node
require.NoError(t, rawMatrix.Encode(map[string][]any{"target": {"staging"}}))
job := &Job{
With: map[string]any{"env": "${{ matrix.target }}"},
Strategy: Strategy{RawMatrix: rawMatrix},
}
out, err := EvaluateCallerWith("caller", job, nil, callerResults("caller", nil, nil), nil, nil)
require.NoError(t, err)
assert.Equal(t, "staging", out["env"])
})
}
func TestMatchCallerInputsAgainstSpec(t *testing.T) {
// mustParseSpec wraps ParseWorkflowCallSpec for test brevity.
mustParseSpec := func(t *testing.T, content []byte) *WorkflowCallSpec {
t.Helper()
spec, err := ParseWorkflowCallSpec(content)
require.NoError(t, err)
return spec
func TestResolveCallerInputs(t *testing.T) {
config := &model.WorkflowCall{Inputs: map[string]model.WorkflowCallInput{
"env": {Type: "string"},
"from_inputs": {Type: "string"},
"from_needs": {Type: "string"},
"count": {Type: "number"},
"index": {Type: "number"},
}}
results := map[string]*JobResult{
"caller": {Needs: []string{"upstream"}},
"upstream": {Result: "success", Outputs: map[string]string{"commit": "abc123"}},
}
var job Job
require.NoError(t, yaml.Unmarshal([]byte(`strategy: {matrix: {target: [staging]}, job-index: 1, job-total: 2}
with: {env: "${{ matrix.target }}", from_inputs: "${{ inputs.PARENT_VAR }}", from_needs: "${{ needs.upstream.outputs.commit }}", count: 42, index: "${{ strategy.job-index }}"}`), &job))
out, err := ResolveCallerInputs("caller", &job, config, map[string]any{"event": map[string]any{}}, results,
nil, map[string]any{"PARENT_VAR": "from-parent"})
require.NoError(t, err)
assert.Equal(t, map[string]any{
"env": "staging", "from_inputs": "from-parent", "from_needs": "abc123", "count": 42.0, "index": 1.0,
}, out)
t.Run("default is filled when caller does not provide the input", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
greeting:
type: string
default: hi
`))
out, err := MatchCallerInputsAgainstSpec(spec, nil)
for _, tt := range []struct {
with string
inputs map[string]model.WorkflowCallInput
want map[string]any
wantErr bool
}{
{with: "with: {EXTRA: value}", want: map[string]any{}},
{with: `with: ${{ fromJSON('{"env":"prod"}') }}`, wantErr: true},
{inputs: map[string]model.WorkflowCallInput{"required": {Type: "string", Required: true, Default: "fallback"}}, want: map[string]any{"required": "fallback"}},
{inputs: map[string]model.WorkflowCallInput{"derived": {Type: "string", Default: "${{ inputs.PARENT_VAR }}"}}, want: map[string]any{"derived": "from-parent"}},
{with: "with: {value: 'false'}", inputs: map[string]model.WorkflowCallInput{"value": {Type: "boolean"}}, wantErr: true},
{with: `with: {value: "${{ '5' }}"}`, inputs: map[string]model.WorkflowCallInput{"value": {Type: "number"}}, wantErr: true},
} {
job = Job{}
require.NoError(t, yaml.Unmarshal([]byte(tt.with), &job))
out, err := ResolveCallerInputs("caller", &job, &model.WorkflowCall{Inputs: tt.inputs}, nil, nil, nil, map[string]any{"PARENT_VAR": "from-parent"})
if tt.wantErr {
require.Error(t, err, tt.with)
continue
}
require.NoError(t, err)
assert.Equal(t, map[string]any{"greeting": "hi"}, out)
})
t.Run("caller-provided value wins over default", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
greeting:
type: string
default: hi
`))
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"greeting": "hello"})
require.NoError(t, err)
assert.Equal(t, map[string]any{"greeting": "hello"}, out)
})
t.Run("required input must be provided", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
target:
type: string
required: true
`))
_, err := MatchCallerInputsAgainstSpec(spec, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), `"target" is required`)
})
t.Run("required input is satisfied by a default value", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
target:
type: string
required: true
default: prod
`))
out, err := MatchCallerInputsAgainstSpec(spec, nil)
require.NoError(t, err)
assert.Equal(t, map[string]any{"target": "prod"}, out)
})
t.Run("boolean inputs accept native bool values and bool defaults", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
flag1:
type: boolean
flag2:
type: boolean
default: true
flag3:
type: boolean
`))
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{
"flag1": true,
"flag3": false,
})
require.NoError(t, err)
assert.Equal(t, true, out["flag1"])
assert.Equal(t, true, out["flag2"]) // from default
assert.Equal(t, false, out["flag3"])
})
t.Run("boolean input rejects strings", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
flag:
type: boolean
`))
_, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"flag": "true"})
require.Error(t, err)
assert.Contains(t, err.Error(), "expects boolean")
})
t.Run("number inputs accept native numeric values and number defaults", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
count:
type: number
ratio:
type: number
default: 0.5
`))
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"count": 42})
require.NoError(t, err)
assert.InDelta(t, 42.0, out["count"], 0)
assert.InDelta(t, 0.5, out["ratio"], 0)
})
t.Run("number input rejects strings", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
count:
type: number
`))
_, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"count": "42"})
require.Error(t, err)
assert.Contains(t, err.Error(), "expects number")
})
t.Run("unknown caller-with key is silently dropped", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
known:
type: string
default: ok
`))
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{
"known": "yes",
"unknown": "ignored",
})
require.NoError(t, err)
assert.Equal(t, map[string]any{"known": "yes"}, out)
})
assert.Equal(t, tt.want, out)
}
}
func TestParseCallerSecrets(t *testing.T) {
@@ -312,161 +127,3 @@ deploy_key: ${{ secrets.gitea_deploy_key }}
assert.Contains(t, err.Error(), `must be of the form ${{ secrets.NAME }}`)
})
}
func TestValidateCallerSecrets(t *testing.T) {
specWith := func(secrets map[string]SecretSpec) *WorkflowCallSpec {
return &WorkflowCallSpec{Secrets: secrets}
}
t.Run("explicit mapping with all required + only declared aliases is accepted", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{
"DEPLOY_KEY": {Required: true},
"OPTIONAL": {},
})
mapping := map[string]string{
"DEPLOY_KEY": "PROD_DEPLOY_KEY",
"OPTIONAL": "SOMETHING_ELSE",
}
require.NoError(t, ValidateCallerSecrets(spec, mapping))
})
t.Run("alias not in callee schema is rejected", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{"DEPLOY_KEY": {}})
mapping := map[string]string{
"DEPLOY_KEY": "PROD_DEPLOY_KEY",
"EXTRA": "SOMETHING_NOT_DECLARED",
}
err := ValidateCallerSecrets(spec, mapping)
require.Error(t, err)
assert.Contains(t, err.Error(), `caller secret "EXTRA"`)
assert.Contains(t, err.Error(), `not declared`)
})
t.Run("missing required secret is rejected", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{
"MUST_HAVE": {Required: true},
"OPTIONAL": {},
})
mapping := map[string]string{"OPTIONAL": "X"}
err := ValidateCallerSecrets(spec, mapping)
require.Error(t, err)
assert.Contains(t, err.Error(), `required secret "MUST_HAVE"`)
assert.Contains(t, err.Error(), `not provided`)
})
t.Run("callee with no secrets schema accepts an empty mapping", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{})
require.NoError(t, ValidateCallerSecrets(spec, nil))
require.NoError(t, ValidateCallerSecrets(spec, map[string]string{}))
})
t.Run("callee with no secrets schema rejects a non-empty mapping", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{})
err := ValidateCallerSecrets(spec, map[string]string{"X": "Y"})
require.Error(t, err)
assert.Contains(t, err.Error(), `caller secret "X"`)
})
t.Run("name matching is case-insensitive", func(t *testing.T) {
// declared name and caller alias differ only in case; both should match.
spec := specWith(map[string]SecretSpec{"deploy_key": {Required: true}})
mapping := map[string]string{"DEPLOY_KEY": "PROD_DEPLOY_KEY"}
require.NoError(t, ValidateCallerSecrets(spec, mapping))
})
t.Run("nil spec is rejected", func(t *testing.T) {
err := ValidateCallerSecrets(nil, map[string]string{"X": "Y"})
require.Error(t, err)
assert.Contains(t, err.Error(), "nil workflow_call spec")
})
}
func TestEvaluateWorkflowCallOutputs(t *testing.T) {
t.Run("nil spec returns empty map", func(t *testing.T) {
out, err := EvaluateWorkflowCallOutputs(nil, &model.GithubContext{}, nil, nil, nil)
require.NoError(t, err)
assert.Empty(t, out)
})
t.Run("spec with no outputs returns empty map", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{}}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil)
require.NoError(t, err)
assert.Empty(t, out)
})
t.Run("plain string value passes through unchanged", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"name": {Value: "static-value"},
}}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil)
require.NoError(t, err)
assert.Equal(t, map[string]string{"name": "static-value"}, out)
})
t.Run("output references jobs.<id>.outputs.<name>", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"sha": {Value: "${{ jobs.build.outputs.commit }}"},
}}
jobOutputs := JobOutputs{
"build": {"commit": "deadbeef"},
}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, jobOutputs)
require.NoError(t, err)
assert.Equal(t, "deadbeef", out["sha"])
})
t.Run("output references inputs.<name>", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"target": {Value: "${{ inputs.env_name }}"},
}}
inputs := map[string]any{"env_name": "staging"}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, inputs, nil)
require.NoError(t, err)
assert.Equal(t, "staging", out["target"])
})
t.Run("multiple outputs are all evaluated", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"static": {Value: "static-value"},
"dynamic": {Value: "${{ vars.SUFFIX }}"},
}}
vars := map[string]string{"SUFFIX": "abc"}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, vars, nil, nil)
require.NoError(t, err)
assert.Equal(t, "static-value", out["static"])
assert.Equal(t, "abc", out["dynamic"])
})
t.Run("expression referencing an undefined symbol surfaces an error", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"bad": {Value: "${{ this.is.not.valid() }}"},
}}
_, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), `output "bad"`)
})
}
// callableWorkflow returns a minimal valid called-workflow YAML with on.workflow_call.
func callableWorkflow(t *testing.T, body string) []byte {
t.Helper()
return []byte(`name: callable
on:
workflow_call:
` + body + `
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: "echo"
`)
}
// callerResults returns the minimum results map shape that NewInterpeter expects
func callerResults(callerJobID string, callerNeeds []string, deps map[string]*JobResult) map[string]*JobResult {
out := make(map[string]*JobResult, len(deps)+1)
maps.Copy(out, deps)
out[callerJobID] = &JobResult{Needs: callerNeeds}
return out
}
+22 -17
View File
@@ -4,6 +4,7 @@
package actions
import (
"bytes"
"context"
"fmt"
"path"
@@ -120,19 +121,19 @@ func GetContentFromEntry(ctx context.Context, gitRepo *git.Repository, entry *gi
}
func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
workflow, err := jobparser.ReadWorkflow(content)
if err != nil {
return nil, err
}
events, err := jobparser.ParseRawOn(&workflow.RawOn)
if err != nil {
return nil, err
}
if err := ValidateWorkflowContent(content); err != nil {
return nil, err
}
events, _, err := readWorkflowEvents(content)
return events, err
}
return events, nil
// readWorkflowEvents also reports whether its error needs no run-time values to find, as a parse error of a workflow without expressions does.
func readWorkflowEvents(content []byte) (events []*jobparser.Event, static bool, err error) {
if events, err = jobparser.ValidateWorkflowStatic(content); err != nil {
return nil, true, err
}
if err = ValidateWorkflowContent(content); err != nil {
return nil, !bytes.Contains(content, []byte("${{")), err
}
return events, false, nil
}
// ValidateWorkflowContent catches structural errors (e.g. blank lines in run: | blocks)
@@ -183,22 +184,26 @@ func DetectWorkflows(
triggedEvent webhook_module.HookEventType,
payload api.Payloader,
detectSchedule bool,
) (workflows, schedules, filtered []*DetectedWorkflow, err error) {
) (workflows, schedules, filtered []*DetectedWorkflow, invalid map[string]error, err error) {
_, entries, err := ListWorkflows(ctx, gitRepo, commit)
if err != nil {
return nil, nil, nil, err
return nil, nil, nil, nil, err
}
invalid = map[string]error{}
for _, entry := range entries {
content, err := GetContentFromEntry(ctx, gitRepo, entry)
if err != nil {
return nil, nil, nil, err
return nil, nil, nil, nil, err
}
// one workflow may have multiple events
events, err := GetEventsFromContent(content)
events, static, err := readWorkflowEvents(content)
if err != nil {
log.Warn("ignore invalid workflow %q: %v", entry.Name(), err)
if static {
invalid[entry.Name()] = err
}
continue
}
for _, evt := range events {
@@ -231,7 +236,7 @@ func DetectWorkflows(
}
}
return workflows, schedules, filtered, nil
return workflows, schedules, filtered, invalid, nil
}
func DetectScheduledWorkflows(ctx context.Context, gitRepo *git.Repository, commit *git.Commit) ([]*DetectedWorkflow, error) {
+20
View File
@@ -13,6 +13,7 @@ import (
webhook_module "gitea.dev/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func fullWorkflowContent(part string) []byte {
@@ -27,6 +28,25 @@ jobs:
`)
}
func TestReadWorkflowEventsStaticErrors(t *testing.T) {
for content, static := range map[string]bool{
"on: push\njobs: {}": true,
"on: push\njobs: {test: {needs: absent}}": true,
"on: push\njobs: {one: {needs: two}, two: {needs: one}}": true,
"on: push\njobs: {test: {strategy: {matrix: {os: []}}}}": true,
"on: push\nrun-name: ${{ secrets.TOKEN }}\njobs: {test: {}}": true,
"on: push\nrun-name: ${{ fromJSON(inputs.x) }}\njobs: {test: {steps: [{run: echo}]}}": false,
} {
_, gotStatic, err := readWorkflowEvents([]byte(content))
require.Error(t, err, content)
assert.Equal(t, static, gotStatic, content)
}
for _, content := range []string{"on: push\njobs: {test: {steps: [{run: echo}]}}", "on: push\nrun-name: ${{ github.ref }}\njobs: {test: {}}"} {
_, _, err := readWorkflowEvents([]byte(content))
assert.NoError(t, err, content)
}
}
func TestIsWorkflow(t *testing.T) {
defer test.MockVariableValue(&setting.Actions.WorkflowDirs)()
+3 -89
View File
@@ -33,8 +33,6 @@ import (
actions_service "gitea.dev/services/actions"
"gitea.dev/services/context"
"gitea.dev/services/convert"
"go.yaml.in/yaml/v4"
)
const (
@@ -226,7 +224,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
workflows = append(workflows, workflow)
continue
}
if err := actions.ValidateWorkflowContent(content); err != nil {
if _, err := actions.GetEventsFromContent(content); err != nil {
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
workflows = append(workflows, workflow)
continue
@@ -441,12 +439,13 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf
}
ctx.Data["CurWorkflowExists"] = true
curWfDispatchCfg := workflowDispatchConfig(curWorkflow)
curWfDispatchCfg := curWorkflow.WorkflowDispatchConfig()
if curWfDispatchCfg == nil {
return
}
ctx.Data["WorkflowDispatchConfig"] = curWfDispatchCfg
ctx.Data["WorkflowDispatchInputs"] = curWorkflow.WorkflowDispatchInputs()
branchOpts := git_model.FindBranchOptions{
RepoID: ctx.Repo.Repository.ID,
@@ -750,91 +749,6 @@ func loadIsRefDeleted(ctx stdCtx.Context, repoID int64, runs actions_model.RunLi
return nil
}
type WorkflowDispatchInput struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
Type string `yaml:"type"`
Options []string `yaml:"options"`
}
func (i WorkflowDispatchInput) IsDefaultTrue() bool {
return util.ParseYamlBool(i.Default)
}
type WorkflowDispatch struct {
Inputs []WorkflowDispatchInput
}
func workflowDispatchConfig(w *act_model.Workflow) *WorkflowDispatch {
switch w.RawOn.Kind {
case yaml.ScalarNode:
var val string
if !decodeNode(w.RawOn, &val) {
return nil
}
if val == "workflow_dispatch" {
return &WorkflowDispatch{}
}
case yaml.SequenceNode:
var val []string
if !decodeNode(w.RawOn, &val) {
return nil
}
if slices.Contains(val, "workflow_dispatch") {
return &WorkflowDispatch{}
}
case yaml.MappingNode:
var val map[string]yaml.Node
if !decodeNode(w.RawOn, &val) {
return nil
}
workflowDispatchNode, found := val["workflow_dispatch"]
if !found {
return nil
}
var workflowDispatch WorkflowDispatch
var workflowDispatchVal map[string]yaml.Node
if !decodeNode(workflowDispatchNode, &workflowDispatchVal) {
return &workflowDispatch
}
inputsNode, found := workflowDispatchVal["inputs"]
if !found || inputsNode.Kind != yaml.MappingNode {
return &workflowDispatch
}
i := 0
for {
if i+1 >= len(inputsNode.Content) {
break
}
var input WorkflowDispatchInput
if decodeNode(*inputsNode.Content[i+1], &input) {
input.Name = inputsNode.Content[i].Value
workflowDispatch.Inputs = append(workflowDispatch.Inputs, input)
}
i += 2
}
return &workflowDispatch
default:
return nil
}
return nil
}
func decodeNode(node yaml.Node, out any) bool {
if err := node.Decode(out); err != nil {
log.Warn("Failed to decode node %v into %T: %v", node, out, err)
return false
}
return true
}
func actionsListRedirectURL(repoLink, workflow, scopedWorkflowSourceRepoID, actor, status, branch string) string {
return fmt.Sprintf("%s/actions?workflow=%s&scoped_workflow_source_repo_id=%s&actor=%s&status=%s&branch=%s",
repoLink,
-146
View File
@@ -6,10 +6,8 @@ package actions
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
act_model "gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
@@ -21,150 +19,6 @@ import (
"github.com/stretchr/testify/assert"
)
func TestReadWorkflow_WorkflowDispatchConfig(t *testing.T) {
yaml := `
name: local-action-docker-url
`
workflow, err := act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch := workflowDispatchConfig(workflow)
assert.Nil(t, workflowDispatch)
yaml = `
name: local-action-docker-url
on: push
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.Nil(t, workflowDispatch)
yaml = `
name: local-action-docker-url
on: workflow_dispatch
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Nil(t, workflowDispatch.Inputs)
yaml = `
name: local-action-docker-url
on: [push, pull_request]
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.Nil(t, workflowDispatch)
yaml = `
name: local-action-docker-url
on:
push:
pull_request:
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.Nil(t, workflowDispatch)
yaml = `
name: local-action-docker-url
on: [push, workflow_dispatch]
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Nil(t, workflowDispatch.Inputs)
yaml = `
name: local-action-docker-url
on:
- push
- workflow_dispatch
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Nil(t, workflowDispatch.Inputs)
yaml = `
name: local-action-docker-url
on:
push:
pull_request:
workflow_dispatch:
inputs:
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Nil(t, workflowDispatch.Inputs)
yaml = `
name: local-action-docker-url
on:
push:
pull_request:
workflow_dispatch:
inputs:
logLevel:
description: 'Log level'
required: true
default: 'warning'
type: choice
options:
- info
- warning
- debug
boolean_default_true:
description: 'Test scenario tags'
required: true
type: boolean
default: true
boolean_default_false:
description: 'Test scenario tags'
required: true
type: boolean
default: false
`
workflow, err = act_model.ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
workflowDispatch = workflowDispatchConfig(workflow)
assert.NotNil(t, workflowDispatch)
assert.Equal(t, WorkflowDispatchInput{
Name: "logLevel",
Default: "warning",
Description: "Log level",
Options: []string{
"info",
"warning",
"debug",
},
Required: true,
Type: "choice",
}, workflowDispatch.Inputs[0])
assert.Equal(t, WorkflowDispatchInput{
Name: "boolean_default_true",
Default: "true",
Description: "Test scenario tags",
Required: true,
Type: "boolean",
}, workflowDispatch.Inputs[1])
assert.Equal(t, WorkflowDispatchInput{
Name: "boolean_default_false",
Default: "false",
Description: "Test scenario tags",
Required: true,
Type: "boolean",
}, workflowDispatch.Inputs[2])
}
func Test_loadIsRefDeleted(t *testing.T) {
unittest.PrepareTestEnv(t)
+7 -7
View File
@@ -594,7 +594,7 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
// Hide the Cancel button once a cancel is already in cancelling progress
resp.State.Run.CanCancel = isLatestAttempt && !resp.State.Run.Done && !effectiveStatus.IsCancelling() && ctx.Repo.Permission.CanWrite(unit.TypeActions)
resp.State.Run.CanApprove = isLatestAttempt && run.NeedApproval && ctx.Repo.Permission.CanWrite(unit.TypeActions)
resp.State.Run.CanRerun = isLatestAttempt && resp.State.Run.Done && ctx.Repo.Permission.CanWrite(unit.TypeActions)
resp.State.Run.CanRerun = isLatestAttempt && resp.State.Run.Done && len(jobs) > 0 && ctx.Repo.Permission.CanWrite(unit.TypeActions)
resp.State.Run.CanDeleteArtifact = resp.State.Run.Done && ctx.Repo.Permission.CanWrite(unit.TypeActions)
if resp.State.Run.CanRerun {
for _, job := range jobs {
@@ -682,7 +682,7 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse,
return
}
if len(summaries) > 0 {
jobNameByID := make(map[int64]string, len(jobs))
jobNameByID := map[int64]string{0: run.WorkflowID} // a workflow-level summary, such as an invalid workflow file
for _, j := range jobs {
jobNameByID[j.ID] = j.Name
}
@@ -964,6 +964,10 @@ func Rerun(ctx *context_module.Context) {
if !checkRunRerunAllowed(ctx, run) {
return
}
if len(jobs) == 0 {
ctx.JSONError(ctx.Locale.Tr("actions.runs.no_job"))
return
}
currentJob, hasPathParam := findCurrentJobByPathParam(ctx, jobs)
if hasPathParam && currentJob == nil {
@@ -1097,7 +1101,7 @@ func getRunViewLink(run *actions_model.ActionRun, attempt *actions_model.ActionR
}
// getCurrentRunJobsByPathParam resolves the current run view context from path parameters, including the run, optional attempt, and jobs to render.
// Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil.
// Any error will be written to the ctx, then the return values are all nil.
func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, *actions_model.ActionRunAttempt, []*actions_model.ActionRunJob) {
run := getCurrentRunByPathParam(ctx)
if ctx.Written() {
@@ -1164,10 +1168,6 @@ func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.A
ctx.ServerError("get current jobs", err)
return nil, nil, nil
}
if len(jobs) == 0 {
ctx.NotFound(nil)
return nil, nil, nil
}
jobs.SortMatrixGroupsByName()
for _, job := range jobs {
+1 -1
View File
@@ -186,7 +186,7 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) {
if err != nil {
log.Error("actions.GetContentFromEntry: %v", err)
}
if workFlowErr := actions.ValidateWorkflowContent(content); workFlowErr != nil {
if _, workFlowErr := actions.GetEventsFromContent(content); workFlowErr != nil {
ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error())
}
} else if issue_service.IsCodeOwnerFile(ctx.Repo.TreePath) {
@@ -181,7 +181,7 @@ func deriveScopedStatusContexts(prefix, displayName string, content []byte, even
if job == nil {
continue
}
jobName := util.EllipsisDisplayString(job.Name, 255) // run creation truncates job names the same way
jobName := job.DisplayName()
for _, ev := range eventNames {
ctxName := actions_module.ScopedWorkflowStatusContextName(prefix, displayName, jobName, ev)
if seen.Contains(ctxName) {
@@ -13,7 +13,7 @@ import (
)
func TestDeriveScopedStatusContexts(t *testing.T) {
t.Run("jobs x events; job name is its name: or its id", func(t *testing.T) {
t.Run("jobs x events; job name is its unescaped name: or its id", func(t *testing.T) {
content := []byte(`name: CI
on: [push, pull_request]
jobs:
@@ -26,6 +26,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- run: echo
test:
name: "${{ matrix.os }} ${{ '${{' }}"
strategy:
matrix:
os: [linux]
runs-on: ubuntu-latest
steps:
- run: echo
`)
events, err := actions_module.GetEventsFromContent(content)
require.NoError(t, err)
@@ -35,6 +43,8 @@ jobs:
"org/src: CI / lint (pull_request)",
"org/src: CI / Build It (push)",
"org/src: CI / Build It (pull_request)",
"org/src: CI / linux ${{ (push)",
"org/src: CI / linux ${{ (pull_request)",
}, got)
})
+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()
@@ -8,7 +8,7 @@
{{end}}
</div>
{{else}}
{{range $item := .WorkflowDispatchConfig.Inputs}}
{{range $item := .WorkflowDispatchInputs}}
<div class="ui field {{if .Required}}required{{end}}">
{{if eq .Type "choice"}}
<label>{{or .Description .Name}}:</label>
@@ -19,12 +19,12 @@
</select>
{{else if eq .Type "boolean"}}
<label class="tw-flex flex-text-inline">
<input type="checkbox" name="{{.Name}}" {{if .IsDefaultTrue}}checked{{end}}>
<input type="checkbox" name="{{.Name}}" {{if eq .Default "true" "True" "TRUE"}}checked{{end}}>
{{or .Description .Name}}
</label>
{{else if eq .Type "number"}}
<label>{{or .Description .Name}}:</label>
<input name="{{.Name}}" value="{{.Default}}" {{if .Required}}required{{end}}>
<input type="number" step="any" name="{{.Name}}" value="{{.Default}}" {{if .Required}}required{{end}}>
{{else}}
<label>{{or .Description .Name}}:</label>
<input name="{{.Name}}" value="{{.Default}}" {{if .Required}}required{{end}}>
@@ -22,16 +22,6 @@ import (
"github.com/stretchr/testify/require"
)
// TestDynamicMatrixEvaluation covers a job whose matrix references ${{ needs.*.outputs.* }}: it is
// 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) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
@@ -93,24 +83,6 @@ jobs:
value: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- run: echo "${{ matrix.value }}"
partial:
needs: [generate]
if: ${{ matrix.value != 1 }}
runs-on: ubuntu-latest
strategy:
matrix:
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
@@ -151,20 +123,12 @@ jobs:
return names
}
seen := execAttempt(t, 9)
seen := execAttempt(t, 7)
firstAttemptIDs := maps.Clone(attemptJobIDs)
// `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",
"build (1)", "build (2)", "included (x)", "included (y)", "static (a)", "static (b)", "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"})
assert.Equal(t, actions_model.StatusSkipped, unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: run.ID, Name: "gated"}).Status)
buildNeed, ok := reportTask.Needs["build"]
require.True(t, ok, "report must see the expanded combinations as its 'build' need")
@@ -186,10 +150,9 @@ jobs:
})
// The matrix now yields a third value, while `static` is cloned as-is rather than collapsed.
seenRerun := execAttempt(t, 11)
seenRerun := execAttempt(t, 8)
assert.ElementsMatch(t, []string{
"build (1)", "build (2)", "build (3)", "included (x)", "included (y)",
"static (a)", "static (b)", "partial (2)", "partial (3)", "strict (1)", "report",
"build (1)", "build (2)", "build (3)", "included (x)", "included (y)", "static (a)", "static (b)", "report",
}, seenRerun)
for name, firstID := range firstAttemptIDs {
assert.Equal(t, firstID, attemptJobIDs[name], "%s keeps its AttemptJobID across attempts", name)
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"testing"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/commitstatus"
"gitea.dev/modules/json"
"gitea.dev/routers/web/repo/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestActionsInvalidWorkflowPush(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
for _, testCase := range []struct {
name string
content string
wantErrors []string
}{
{"expression", "on: push\nrun-name: '${{ github.ref'\njobs: {check: {if: unknown.x}}\n", []string{"Unrecognized named-value: &#39;unknown&#39;", "unclosed expression"}},
{"trigger", "on:\njobs: {check: {runs-on: ubuntu-latest, steps: [{run: echo hello}]}}\n", []string{"invalid event"}},
} {
t.Run(testCase.name, func(t *testing.T) {
repo := createActionsTestRepo(t, token, "invalid-workflow-"+testCase.name, false)
createWorkflowFile(t, token, user.Name, repo.Name, ".gitea/workflows/invalid.yml",
getWorkflowCreateFileOptions(user, repo.DefaultBranch, "invalid workflow", testCase.content))
runs, err := db.Find[actions_model.ActionRun](t.Context(), actions_model.FindRunOptions{RepoID: repo.ID})
require.NoError(t, err)
require.Len(t, runs, 1)
run := runs[0]
assert.Equal(t, actions_model.StatusFailure, run.Status)
statuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, run.CommitSHA, db.ListOptionsAll)
require.NoError(t, err)
require.Len(t, statuses, 1)
assert.Equal(t, commitstatus.CommitStatusFailure, statuses[0].State)
view := session.MakeRequest(t, NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d", user.Name, repo.Name, run.ID)), http.StatusOK)
var viewResponse actions.ViewResponse
require.NoError(t, json.Unmarshal(view.Body.Bytes(), &viewResponse))
assert.Empty(t, viewResponse.State.Run.Jobs)
require.Len(t, viewResponse.State.Run.JobSummaries, 1)
assert.Equal(t, "invalid.yml", viewResponse.State.Run.JobSummaries[0].JobName)
assert.Contains(t, string(viewResponse.State.Run.JobSummaries[0].SummaryHTML), "Invalid workflow file: invalid.yml")
actionsPage := session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions", user.Name, repo.Name)), http.StatusOK)
filePage := session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/%s/%s/src/branch/%s/.gitea/workflows/invalid.yml", user.Name, repo.Name, repo.DefaultBranch)), http.StatusOK)
for _, wantError := range testCase.wantErrors {
assert.Contains(t, string(viewResponse.State.Run.JobSummaries[0].SummaryHTML), wantError)
assert.Contains(t, actionsPage.Body.String(), wantError)
assert.Contains(t, filePage.Body.String(), wantError)
}
session.MakeRequest(t, NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", user.Name, repo.Name, run.ID)), http.StatusBadRequest)
})
}
})
}
+1
View File
@@ -260,6 +260,7 @@ jobs:
output_2: ${{ steps.gen_output.outputs.output_2 }}
output_3: ${{ steps.gen_output.outputs.output_3 }}
strategy:
fail-fast: false
matrix:
version: [1, 2, 3]
steps:
@@ -14,7 +14,9 @@ import (
runnerv1 "gitea.dev/actionslib/runner/v1"
actions_model "gitea.dev/models/actions"
auth_model "gitea.dev/models/auth"
perm_model "gitea.dev/models/perm"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
actions_module "gitea.dev/modules/actions"
@@ -81,6 +83,8 @@ on:
jobs:
reusable1_job1:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- run: echo 'reusable1_job1'
@@ -135,6 +139,8 @@ jobs:
caller_job2:
needs: [caller_job1]
permissions:
contents: read
uses: './.gitea/workflows/reusable1.yaml'
with:
str_input: 'from_caller_job2'
@@ -204,6 +210,8 @@ jobs:
_, r1Job1, _ := getTaskAndJobAndRunByTaskID(t, r1Job1Task.Id)
assert.Equal(t, "reusable1_job1", r1Job1.JobID)
assert.Equal(t, callerJob2ID, r1Job1.ParentJobID)
require.NotNil(t, r1Job1.TokenPermissions)
assert.Equal(t, perm_model.AccessModeRead, r1Job1.TokenPermissions.UnitAccessModes[unit.TypeCode])
payload := getWorkflowCallPayloadFromTask(t, r1Job1Task)
if assert.Len(t, payload.Inputs, 5) {
assert.Equal(t, "from_caller_job2", payload.Inputs["str_input"])
@@ -253,6 +261,8 @@ jobs:
r1Job3AttemptJobID = r1Job3.AttemptJobID
r2Job1 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{RunID: runID, JobID: "reusable2_job1"})
assert.Equal(t, r1Job3ID, r2Job1.ParentJobID)
require.NotNil(t, r2Job1.TokenPermissions)
assert.Equal(t, perm_model.AccessModeRead, r2Job1.TokenPermissions.UnitAccessModes[unit.TypeCode])
r2Job1AttemptJobID = r2Job1.AttemptJobID
r2Job1Task := defaultRunner.fetchTask(t) // for reusable2_job1
@@ -575,7 +585,7 @@ jobs:
assert.Equal(t, 0, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
})
t.Run("Nested caller with missing callee fails instead of blocking", func(t *testing.T) {
t.Run("Nested caller with missing callee fails with the error as summary instead of blocking", func(t *testing.T) {
// When the expansion hits a terminal error (e.g. missing callee), the emitter must fail the caller and let the run finish as failed, not retry the expansion forever.
apiRepo := createActionsTestRepo(t, user2Token, "nested-caller-missing-callee", false)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
@@ -616,6 +626,9 @@ jobs:
assert.Equal(t, actions_model.StatusFailure, finalRun.Status)
runner.fetchNoTask(t) // no task scheduled for the failed caller; the run is not stuck
summary, err := actions_model.GetActionRunJobSummary(t.Context(), repo.ID, run.ID, badCaller.RunAttemptID, badCaller.ID, 0)
require.NoError(t, err)
assert.Contains(t, summary.Content, "does-not-exist.yml")
})
t.Run("Fork PR with secrets: inherit does not leak base repo secrets", func(t *testing.T) {