mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-25 14:13:40 +09:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}}`}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: 'unknown'", "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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user