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

Updates actionslib to https://gitea.com/gitea/actionslib/releases/tag/v1.2.1, moves workflow
parsing into it and aligns behaviour with GitHub.

1. `uses:` supports `self:` (Gitea-only feature) and `$/` paths.
1. `strategy`, `matrix`, `max-parallel` and `fail-fast` accept
expressions, including over `needs`. A job whose `name`, `runs-on` or
`continue-on-error` reads `needs` is resolved once they finish.
1. A job `if:` may only read `github`, `needs`, `vars` and `inputs` and
is decided before the matrix, as on github.com.
1. Matrix `fail-fast` cancels the other combinations, and `always()`
jobs keep running when a run is cancelled.
1. Invalid workflow files, including a malformed `on:` and unknown or
cyclic `needs`, show up on push as failed runs with the error.
1. A job whose `if:` or `concurrency:` fails to evaluate is skipped or
failed with the error, instead of staying blocked.
1. Reusable workflows: a missing and an unreadable repository fail
alike, public callers cannot use private workflows, nested jobs cannot
exceed the caller's token permissions.
1. Runner labels match case-insensitively, and `runs-on` accepts an
array from an expression.

Runner PR: https://gitea.com/gitea/runner/pulls/1247
Docs PR: https://gitea.com/gitea/docs/pulls/553
Fixes: https://github.com/go-gitea/gitea/issues/38990
Fixes: https://github.com/go-gitea/gitea/issues/39382
Fixes: https://github.com/go-gitea/gitea/issues/32364
Fixes: https://github.com/go-gitea/gitea/issues/36077
Fixes: https://github.com/go-gitea/gitea/issues/23277
Fixes: https://github.com/go-gitea/gitea/issues/29020
Co-authored-by: Claude (Opus 5) <noreply@anthropic.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
silverwind
2026-09-25 00:06:42 +02:00
committed by GitHub
parent 64f31d9b70
commit f757631a47
59 changed files with 1509 additions and 2223 deletions
+13 -61
View File
@@ -4,6 +4,7 @@
package jobparser
import (
"bytes"
"errors"
"io"
@@ -12,12 +13,6 @@ import (
"go.yaml.in/yaml/v4"
)
// maxExpandedNodes bounds how many nodes alias expansion may create. go-yaml's own alias guard does
// not cover us: it only counts while decoding into values, and a workflow is kept as raw yaml.Nodes.
const maxExpandedNodes = 50000
var errTooManyYamlNodes = errors.New("maximum YAML nodes exceeded")
// ReadWorkflow decodes a workflow file with its aliases expanded. Callers inspect the workflow's
// raw nodes by kind, and an alias is a kind none of them expect.
func ReadWorkflow(content []byte) (*model.Workflow, error) {
@@ -33,7 +28,10 @@ func readWorkflowDoc(doc *yaml.Node) (*model.Workflow, error) {
return nil, io.EOF // what a yaml decoder reports for an empty file
}
w := new(model.Workflow)
return w, doc.Decode(w)
if err := doc.Decode(w); err != nil {
return w, err
}
return w, validateJobConditions(w)
}
// decodeResolved is yaml.Unmarshal with aliases expanded first.
@@ -54,68 +52,22 @@ func decodeYamlDoc(doc *yaml.Node, out any) error {
// resolveYamlAliases parses content and replaces every alias with a copy of the node its anchor names.
func resolveYamlAliases(content []byte) (*yaml.Node, error) {
doc := &yaml.Node{}
if err := yaml.Unmarshal(content, doc); err != nil {
doc, err := model.ReadWorkflowNode(bytes.NewReader(content))
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
budget := maxExpandedNodes
return doc, expandAliases(doc, &budget)
}
// expandAliases replaces node's alias descendants in place.
func expandAliases(node *yaml.Node, budget *int) error {
node.Anchor = "" // a name for a node, not part of the workflow: keep it out of the payloads
if err := rejectMergeKeys(node); err != nil {
return err
}
for i, child := range node.Content {
if child.Kind != yaml.AliasNode {
if err := expandAliases(child, budget); err != nil {
return err
}
continue
}
copied, err := copyExpanded(child.Alias, budget)
if err != nil {
return err
}
node.Content[i] = copied
}
return nil
}
// copyExpanded deep copies a node expandAliases already expanded and validated, since an anchor is
// declared before the alias naming it. An anchor aliased from inside itself is the exception, and
// recurses here until it exhausts budget.
func copyExpanded(node *yaml.Node, budget *int) (*yaml.Node, error) {
if *budget--; *budget < 0 {
return nil, errTooManyYamlNodes
}
if node.Kind == yaml.AliasNode {
return copyExpanded(node.Alias, budget)
}
copied := *node
copied.Content = make([]*yaml.Node, len(node.Content))
for i, child := range node.Content {
child, err := copyExpanded(child, budget)
if err != nil {
return nil, err
}
copied.Content[i] = child
}
return &copied, nil
return doc, rejectMergeKeys(doc)
}
// rejectMergeKeys refuses `<<: *anchor`, same as GitHub does
func rejectMergeKeys(node *yaml.Node) error {
if node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i < len(node.Content)-1; i += 2 {
if node.Content[i].Tag == "!!merge" {
for i, child := range node.Content {
if node.Kind == yaml.MappingNode && i%2 == 0 && child.Tag == "!!merge" {
return errors.New("merge keys (`<<`) are not supported, alias the whole value instead")
}
if err := rejectMergeKeys(child); err != nil {
return err
}
}
return nil
}
+3 -14
View File
@@ -10,10 +10,11 @@ import (
"github.com/stretchr/testify/require"
)
func TestParseResolvesAliases(t *testing.T) {
func TestParseResolvesAliasesAndTimestamps(t *testing.T) {
got, err := Parse([]byte(`on: push
env: &common_env
SHARED: "1"
DATE: 2026-03-15
jobs:
a:
runs-on: linux
@@ -31,7 +32,7 @@ jobs:
_, job := workflow.Job()
var env map[string]string
require.NoError(t, job.Env.Decode(&env))
assert.Equal(t, map[string]string{"SHARED": "1"}, env)
assert.Equal(t, map[string]string{"SHARED": "1", "DATE": "2026-03-15"}, env)
require.Len(t, job.Steps, 1)
payload, err := workflow.Marshal()
@@ -49,18 +50,6 @@ func TestParseRejectsAliases(t *testing.T) {
name, wantErr string
content []byte
}{
{
name: "nested aliases exceed the node limit",
content: []byte(`on: push
x0: &x0 [1, 2, 3, 4, 5, 6, 7, 8, 9]
x1: &x1 [*x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0]
x2: &x2 [*x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1]
x3: &x3 [*x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2]
x4: &x4 [*x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3]
jobs: {a: {runs-on: linux, steps: [{run: echo}]}}
`),
wantErr: "maximum YAML nodes exceeded",
},
{
name: "anchor aliased from inside itself",
content: job(" steps: &s [{run: echo}, *s]\n"),
+3 -24
View File
@@ -15,21 +15,13 @@ import (
// see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability
func NewInterpeter(
jobID string,
job *model.Job,
strategy *Strategy,
matrix map[string]any,
gitCtx *model.GithubContext,
results map[string]*JobResult,
vars map[string]string,
inputs map[string]any,
) exprparser.Interpreter {
strategy := make(map[string]any)
if job.Strategy != nil {
strategy["fail-fast"] = job.Strategy.GetFailFast()
if limit, declared, err := job.Strategy.ParseMaxParallel(); declared && err == nil {
strategy["max-parallel"] = limit
}
}
run := &model.Run{
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{},
@@ -46,19 +38,6 @@ func NewInterpeter(
}
}
jobs := run.Workflow.Jobs
jobNeeds := run.Job().Needs()
using := map[string]exprparser.Needs{}
for _, need := range jobNeeds {
if v, ok := jobs[need]; ok {
using[need] = exprparser.Needs{
Outputs: v.Outputs,
Result: v.Result,
}
}
}
ee := &exprparser.EvaluationEnvironment{
Github: gitCtx,
Env: nil, // no need
@@ -69,9 +48,9 @@ func NewInterpeter(
Steps: nil, // no need
Runner: nil, // no need
Secrets: nil, // no need
Strategy: strategy,
Strategy: strategy.context(),
Matrix: matrix,
Needs: using,
Needs: exprparser.NeedsContext(run),
Inputs: inputs,
Vars: vars,
}
+112 -132
View File
@@ -17,28 +17,33 @@ import (
"go.yaml.in/yaml/v4"
)
// HasDeferredMatrix reports whether the job's matrix can only be expanded once its needs finish:
// it reads the needs context and the job has needs to resolve that context against.
// Parse emits such a job as a single placeholder rather than one job per combination, so every
// caller that persists a job must agree with Parse on this condition.
// HasDeferredMatrix reports whether the job's strategy, name, runs-on or continue-on-error need outputs before they can be resolved.
func HasDeferredMatrix(job *Job) bool {
return len(job.Needs()) > 0 && rawMatrixReadsNeeds(&job.Strategy.RawMatrix)
return len(job.Needs()) > 0 && (nodeMatches(&job.Strategy.RawMatrix, expressionReadsNeeds) || expressionReadsNeeds(job.Strategy.RawExpression.Value) ||
expressionReadsNeeds(job.Strategy.MaxParallelString) || expressionReadsNeeds(job.Strategy.FailFastString) ||
expressionReadsNeeds(job.Name) || nodeMatches(&job.RawRunsOn, expressionReadsNeeds) || nodeMatches(&job.RawContinueOnError, expressionReadsNeeds))
}
func rawMatrixReadsNeeds(node *yaml.Node) bool {
func nodeMatches(node *yaml.Node, match func(string) bool) bool {
if node.Kind == yaml.ScalarNode {
return expressionReadsNeeds(node.Value)
return match(node.Value)
}
return slices.ContainsFunc(node.Content, rawMatrixReadsNeeds)
return slices.ContainsFunc(node.Content, func(child *yaml.Node) bool { return nodeMatches(child, match) })
}
// ParseRawSingleWorkflow decodes a SingleWorkflow payload into the workflow and its single job
// without expanding `strategy.matrix`.
//
// A deferred-matrix placeholder's payload still carries the raw, unevaluated matrix, which Parse
// would try to expand: depending on the matrix's shape that yields several workflows (a static
// vector crossed with the unevaluated expression) or an error (an `include`/`exclude` that is still
// a scalar), neither of which describes the one job the payload stands for.
func hasExpression(value string) bool {
return strings.Contains(value, "${{")
}
// IfExpression wraps an `if:` that omits the `${{ }}`, which github.com evaluates as one expression anyway.
func IfExpression(value string) string {
if hasExpression(value) {
return value
}
return "${{ " + value + " }}"
}
// ParseRawSingleWorkflow decodes a stored SingleWorkflow payload into the workflow and its single job as stored, without expanding or evaluating it again.
func ParseRawSingleWorkflow(payload []byte) (*SingleWorkflow, *Job, error) {
swf := &SingleWorkflow{}
if err := decodeResolved(payload, swf); err != nil {
@@ -62,29 +67,6 @@ func expressionReadsNeeds(value string) bool {
return expreval.ReadsContext(value, "needs")
}
// ExpressionReadsMatrix reports whether a job's `if:` reads the matrix context.
// A deferred-matrix placeholder has no combination yet, so such an expression cannot be decided.
func ExpressionReadsMatrix(ifValue string) bool {
return expreval.ReadsContext(asIfExpression(ifValue), "matrix")
}
// ExpressionIgnoresNeedResults reports whether a job's `if:` calls always(), failure() or cancelled(),
// the status functions that run a job whatever its needs did rather than under the implicit success().
// Keep in sync with act's exprparser, which owns the same list for the evaluation itself.
func ExpressionIgnoresNeedResults(ifValue string) bool {
return expreval.CallsFunction(asIfExpression(ifValue), "always", "failure", "cancelled")
}
// asIfExpression wraps an `if:` that omits the `${{ }}`, which GitHub evaluates as one expression anyway.
// `if:` is the only field with that exception: every other value is interpolated, so a bare matrix or
// `runs-on` is a literal there and must not be parsed as an expression.
func asIfExpression(ifValue string) string {
if ifValue == "" || strings.Contains(ifValue, "${{") {
return ifValue
}
return "${{ " + ifValue + " }}"
}
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
// The workflow is split into one document per job below, which would strand an alias whose
// anchor lands in another one.
@@ -132,28 +114,25 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
for i, id := range ids {
job := jobs[i]
originJob := origin.GetJob(id)
if originJob == nil {
return nil, fmt.Errorf("job %s not found in origin workflow", id)
}
var combos []*Job
if HasDeferredMatrix(job) {
// The matrix reads values that do not exist yet (a needs output), so emit a single
// placeholder keeping it raw. Re-parsing that placeholder's payload yields it again,
// and the server expands it once the needs finish.
if HasDeferredMatrix(job) || pc.gitContext == nil && (job.Strategy.RawExpression.Kind != 0 || nodeMatches(&job.Strategy.RawMatrix, hasExpression)) {
placeholder := job.Clone()
if placeholder.Name == "" {
placeholder.Name = id
}
combos = []*Job{placeholder}
} else {
matricxes, err := getMatrixes(originJob)
if pc.gitContext != nil { // callers without one, like commit status, only read the literal workflow
if err := job.Strategy.resolve(evaluator); err != nil {
return nil, fmt.Errorf("job %q: %w", id, err)
}
}
// Keep accepting empty exclude mappings for workflow compatibility, although GitHub rejects them.
matrixes, err := (&model.Job{Strategy: job.Strategy.actStrategy()}).GetMatrixes()
if err != nil {
return nil, fmt.Errorf("getMatrixes: %w", err)
}
if combos, err = buildMatrixCombos(id, job, matricxes, originJob, pc.gitContext, results, pc.vars, pc.inputs); err != nil {
if combos, err = buildMatrixCombos(id, job, matrixes, pc.gitContext, results, pc.vars, pc.inputs); err != nil {
return nil, err
}
}
@@ -186,99 +165,120 @@ func (w *SingleWorkflow) CloneHeader() *SingleWorkflow {
// maxCombinations caps how many combinations may be built: the values come from a needs output at
// runtime, so the cap has to be enforced before one Job is materialized per combination.
func ExpandMatrixWithNeeds(jobID string, job *Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any, maxCombinations int) ([]*Job, error) {
actJob := &model.Job{Strategy: &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
}}
// Resolve fromJson(needs.*.outputs.*) and friends into concrete matrix values.
if err := expreval.New(NewInterpeter(jobID, actJob, nil, gitCtx, results, vars, inputs).Evaluate).
EvaluateYamlNode(&actJob.Strategy.RawMatrix); err != nil {
return nil, fmt.Errorf("evaluate matrix: %w", err)
job = job.Clone()
if err := job.Strategy.resolve(expreval.New(NewInterpeter(jobID, nil, nil, gitCtx, results, vars, inputs).Evaluate)); err != nil {
return nil, err
}
matrixes, err := getMatrixes(actJob)
matrixes, err := (&model.Job{Strategy: job.Strategy.actStrategy()}).GetMatrixes()
if err != nil {
return nil, fmt.Errorf("getMatrixes: %w", err)
}
// act collapses a matrix that yields no combination (an empty vector or include, everything
// excluded, a whole-matrix expression that is not a mapping) into one empty combination, which
// would run the job once unparameterized. GitHub rejects such a matrix, so reject it too.
if len(matrixes) == 1 && len(matrixes[0]) == 0 {
return nil, errors.New("matrix must define at least one vector")
}
if len(matrixes) > maxCombinations {
return nil, fmt.Errorf("matrix expands to %d combinations, exceeding the limit of %d", len(matrixes), maxCombinations)
}
return buildMatrixCombos(jobID, job, matrixes, actJob, gitCtx, results, vars, inputs)
return buildMatrixCombos(jobID, job, matrixes, gitCtx, results, vars, inputs)
}
// matrixesOf is this package's only entry to act's GetMatrixes, so that every caller is covered by
// the filter check below. A deferred placeholder is the first thing carrying a raw matrix this far,
// and the emitter reads its `if:` before expanding it.
func matrixesOf(job *model.Job) ([]map[string]any, error) {
if err := validateMatrixFilters(job); err != nil {
return nil, err
}
matrixes, err := job.GetMatrixes()
if err != nil {
return nil, fmt.Errorf("GetMatrixes: %w", err)
}
return matrixes, nil
}
// validateMatrixFilters rejects an `include`/`exclude` that is not a list of mappings, so that the
// usual way to get there, an unevaluated ${{ }} expression that is still a scalar, is named as such
// instead of surfacing from the middle of the expansion.
func validateMatrixFilters(job *model.Job) error {
if job.Strategy == nil || job.Strategy.RawMatrix.Kind != yaml.MappingNode {
return nil
}
content := job.Strategy.RawMatrix.Content
for i := 0; i+1 < len(content); i += 2 {
name, value := content[i].Value, content[i+1]
if name != "include" && name != "exclude" {
continue
// resolve evaluates strategy values once and escapes expression-like results for the runner.
func (s *Strategy) resolve(evaluator expreval.Evaluator) error {
if s.RawExpression.Kind != 0 {
if err := model.DecodeEvaluated("strategy", s.RawExpression, evaluator.EvaluateYamlNode, s); err != nil {
return err
}
entries := []*yaml.Node{value}
if value.Kind == yaml.SequenceNode {
entries = value.Content
if s.RawExpression.Kind != 0 {
return errors.New("strategy is not a map of strategy keys to values")
}
for _, entry := range entries {
if entry.Kind != yaml.MappingNode {
return fmt.Errorf("matrix %s must be a list of mappings", name)
} else {
for _, value := range []*string{&s.FailFastString, &s.MaxParallelString} {
evaluated, err := evaluator.Interpolate(*value)
if err != nil {
return fmt.Errorf("evaluate strategy: %w", err)
}
*value = evaluated
}
if err := evaluator.EvaluateYamlNode(&s.RawMatrix); err != nil {
return fmt.Errorf("evaluate matrix: %w", err)
}
}
s.FailFastString, s.MaxParallelString = escapeExpressions(s.FailFastString), escapeExpressions(s.MaxParallelString)
return nil
}
const escapedExpression = "${{ '$' }}{{"
// escapeExpressions returns a template interpolating to value, since GitHub never evaluates the result of an evaluation again.
func escapeExpressions(value string) string {
return strings.ReplaceAll(value, "${{", escapedExpression)
}
func unescapeExpressions(value string) string {
return strings.ReplaceAll(value, escapedExpression, "${{")
}
func replaceScalars(node *yaml.Node, replace func(string) string) {
node.Value = replace(node.Value)
for _, child := range node.Content {
replaceScalars(child, replace)
}
}
// buildMatrixCombos builds one Job per matrix combination from src, baking the combination into the
// strategy and interpolating the name, runs-on and continue-on-error with it.
func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, actJob *model.Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) {
srcRunsOn := src.RunsOn()
func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) {
srcRunsOn := model.RunsOnFromNode(src.RawRunsOn)
order, names := make([]int, len(matrixes)), make([]string, len(matrixes))
for index, matrix := range matrixes {
order[index], names[index] = index, matrixName(matrix)
}
slices.SortStableFunc(order, func(a, b int) int { return strings.Compare(names[a], names[b]) })
combos := make([]*Job, 0, len(matrixes))
var err error
for _, matrix := range matrixes {
for _, index := range order {
matrix := matrixes[index]
combo := src.Clone()
if combo.Name == "" {
combo.Name = jobID
}
combo.Strategy.RawMatrix = encodeMatrix(matrix)
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, gitCtx, results, vars, inputs).Evaluate)
if combo.Name, err = nameWithMatrix(combo.Name, matrix, evaluator); err != nil {
replaceScalars(&combo.Strategy.RawMatrix, escapeExpressions)
if src.Strategy.RawMatrix.Kind != 0 {
combo.Strategy.JobIndex, combo.Strategy.JobTotal = index, len(matrixes)
}
evaluator := expreval.New(NewInterpeter(jobID, &combo.Strategy, matrix, gitCtx, results, vars, inputs).Evaluate)
if len(matrix) == 0 && gitCtx != nil {
combo.Name, err = evaluator.Interpolate(combo.Name)
combo.Name = escapeExpressions(combo.Name)
} else {
combo.Name, err = nameWithMatrix(combo.Name, matrix, evaluator)
}
if err != nil {
return nil, fmt.Errorf("interpolate name for job %q: %w", jobID, err)
}
runsOn := slices.Clone(srcRunsOn)
for i := range runsOn {
if runsOn[i], err = evaluator.Interpolate(runsOn[i]); err != nil {
if gitCtx != nil { // callers without one don't read runs-on
rawRunsOn := model.CloneYamlNode(src.RawRunsOn)
if err := evaluator.EvaluateYamlNode(&rawRunsOn); err != nil {
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", jobID, err)
}
runsOn := model.RunsOnFromNode(rawRunsOn)
if len(runsOn) == 0 && len(srcRunsOn) > 0 { // match no runner rather than every runner
runsOn = []string{""}
}
for i := range runsOn {
runsOn[i] = escapeExpressions(runsOn[i])
}
combo.RawRunsOn = model.RunsOnNode(runsOn, "")
}
combo.RawRunsOn = encodeRunsOn(runsOn)
if err := evaluator.EvaluateYamlNode(&combo.RawContinueOnError); err != nil {
return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", jobID, err)
}
if combo.RawContinueOnError.Kind != 0 {
var continueOnError bool
if err := combo.RawContinueOnError.Decode(&continueOnError); err == nil {
_ = combo.RawContinueOnError.Encode(continueOnError)
} else {
replaceScalars(&combo.RawContinueOnError, escapeExpressions)
}
}
combos = append(combos, combo)
}
return combos, nil
@@ -311,17 +311,6 @@ type parseContext struct {
type ParseOption func(c *parseContext)
func getMatrixes(job *model.Job) ([]map[string]any, error) {
ret, err := matrixesOf(job)
if err != nil {
return nil, err
}
sort.Slice(ret, func(i, j int) bool {
return matrixName(ret[i]) < matrixName(ret[j])
})
return ret, nil
}
func encodeMatrix(matrix map[string]any) yaml.Node {
if len(matrix) == 0 {
return yaml.Node{}
@@ -335,26 +324,17 @@ func encodeMatrix(matrix map[string]any) yaml.Node {
return node
}
func encodeRunsOn(runsOn []string) yaml.Node {
node := yaml.Node{}
if len(runsOn) == 1 {
_ = node.Encode(runsOn[0])
} else {
_ = node.Encode(runsOn)
}
return node
}
func nameWithMatrix(name string, m map[string]any, evaluator expreval.Evaluator) (string, error) {
if len(m) == 0 {
return name, nil
}
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
return name + " " + matrixName(m), nil
return escapeExpressions(name + " " + matrixName(m)), nil
}
return evaluator.Interpolate(name)
name, err := evaluator.Interpolate(name)
return escapeExpressions(name), err
}
func matrixName(m map[string]any) string {
+215 -142
View File
@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"gitea.dev/actionslib/pkg/expreval"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
@@ -78,7 +80,7 @@ func TestParse(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
content := ReadTestdata(t, tt.name+".in.yaml")
want := ReadTestdata(t, tt.name+".out.yaml")
got, err := Parse(content, tt.options...)
got, err := Parse(content, append([]ParseOption{WithGitContext(&model.GithubContext{})}, tt.options...)...)
if tt.wantErr {
require.Error(t, err)
}
@@ -112,8 +114,6 @@ func TestParse(t *testing.T) {
}
func TestParseDefersDynamicMatrix(t *testing.T) {
// A matrix referencing needs outputs yields one placeholder keeping the raw expression, rather
// than one job per resolvable static value. Any other matrix expands at plan time as usual.
const workflow = `
on: push
jobs:
@@ -121,32 +121,28 @@ jobs:
steps: [{run: echo}]
build:
%s
strategy:
matrix:
os: [a, b]
version: %s
%s
steps: [{run: echo}]
`
for _, tt := range []struct {
name string
needs string
version string
strategy string
deferred bool
want int
}{
{"needs outputs", "needs: setup", "${{ fromJson(needs.setup.outputs.v) }}", true, 1},
{"static", "needs: setup", "[1, 2]", false, 4},
// Without needs there is nothing to resolve the expression from later, so deferring would
// strand the job as a single combination that never expands.
{"expression without needs", "", `["${{ github.sha }}"]`, false, 2},
// A context that is already available while planning must keep expanding there, otherwise
// such a workflow would silently lose the per-combination commit statuses it used to create.
{"expression over another context", "needs: setup", `["${{ github.sha }}"]`, false, 2},
// The needs context is looked up in the parsed expression, not in the raw text.
{"needs inside a string literal", "needs: setup", `["${{ format('needs.setup.outputs.v {0}', github.sha) }}"]`, false, 2},
{"needs outputs", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.v) }}", true, 1},
{"static", "needs: setup", "strategy:\n matrix: {os: [a, b], version: [1, 2]}", false, 4},
{"max-parallel over needs outputs", "needs: setup", "strategy:\n max-parallel: ${{ needs.setup.outputs.limit }}\n matrix: {os: [a, b], version: [1, 2]}", true, 1},
{"fail-fast without matrix", "needs: setup", "strategy:\n fail-fast: ${{ needs.setup.outputs.fast }}", true, 1},
{"whole strategy without matrix", "needs: setup", "strategy: ${{ fromJSON(needs.setup.outputs.strategy) }}", true, 1},
{"expression without needs", "", "strategy:\n matrix:\n os: [a, b]\n version: [\"${{ github.sha }}\"]", false, 2},
{"expression over another context", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: [\"${{ github.sha }}\"]", false, 2},
{"needs inside a string literal", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: [\"${{ format('needs.setup.outputs.v {0}', github.sha) }}\"]", false, 2},
{"vars", "needs: setup", "strategy:\n matrix:\n os: [a, b]\n version: ${{ fromJSON(vars.VERSIONS) }}", false, 4},
} {
t.Run(tt.name, func(t *testing.T) {
result, err := Parse(fmt.Appendf(nil, workflow, tt.needs, tt.version))
result, err := Parse(fmt.Appendf(nil, workflow, tt.needs, tt.strategy), WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"VERSIONS": "[1, 2]"}))
require.NoError(t, err)
var builds []*Job
@@ -161,6 +157,93 @@ jobs:
}
}
func TestParseWholeValueExpressions(t *testing.T) {
const workflow = `
on: push
env: ${{ fromJSON(vars.ENV) }}
jobs:
setup:
runs-on: ubuntu-latest
steps: [{run: echo}]
build:
%s
runs-on: ${{ matrix.os }}
strategy: ${{ fromJSON(%s) }}
services: ${{ fromJSON(vars.SERVICES) }}
defaults:
run: ${{ fromJSON(vars.DEFAULTS) }}
steps:
- uses: actions/checkout@v4
with: ${{ fromJSON(vars.WITH) }}
`
const strategy = `{"max-parallel": 1, "matrix": {"os": ["a", "b-${{ secrets.X }}"]}}`
runnerView := func(t *testing.T, job *model.Job) []any {
t.Helper()
runner := expreval.New(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Secrets: map[string]string{"X": "leaked"}}, exprparser.Config{}).Evaluate)
require.NoError(t, runner.EvaluateYamlNode(&job.Strategy.RawMatrix))
require.NoError(t, runner.EvaluateYamlNode(&job.RawRunsOn))
matrixes, err := job.GetMatrixes()
require.NoError(t, err)
name, err := runner.Interpolate(job.Name)
require.NoError(t, err)
return []any{matrixes[0]["os"], name, job.RunsOn()}
}
payloads := func(t *testing.T, needs, source string, options ...ParseOption) []string {
t.Helper()
workflows, err := Parse(fmt.Appendf(nil, workflow, needs, source), options...)
require.NoError(t, err)
var payloads []string
for _, w := range workflows {
if id, _ := w.Job(); id == "build" {
payload, err := w.Marshal()
require.NoError(t, err)
payloads = append(payloads, string(payload))
}
}
return payloads
}
planning := []ParseOption{WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"STRATEGY": strategy})}
planned := payloads(t, "", "vars.STRATEGY", planning...)
require.Len(t, planned, 2)
for _, raw := range []string{`max-parallel: "1"`, "env: ${{ fromJSON(vars.ENV) }}", "services: ${{ fromJSON(vars.SERVICES) }}", "run: ${{ fromJSON(vars.DEFAULTS) }}", "with: ${{ fromJSON(vars.WITH) }}"} {
assert.Contains(t, planned[0], raw)
}
for index, payload := range planned {
os := []string{"a", "b-${{ secrets.X }}"}[index]
read, err := model.ReadWorkflow(strings.NewReader(payload))
require.NoError(t, err)
assert.Equal(t, []any{os, "build (" + os + ")", []string{os}}, runnerView(t, read.Jobs["build"]))
_, job, err := ParseRawSingleWorkflow([]byte(payload))
require.NoError(t, err)
group, _, err := EvaluateConcurrency(&model.RawConcurrency{Group: "${{ matrix.os }}"}, "build", job, map[string]any{}, nil, nil, nil)
require.NoError(t, err)
assert.Equal(t, []any{"build (" + os + ")", []string{os}, os}, []any{job.DisplayName(), job.RunsOn(), group})
_, err = Parse([]byte(payload))
require.NoError(t, err)
}
assert.Len(t, payloads(t, "", "vars.STRATEGY"), 1)
deferred := payloads(t, "needs: setup", "needs.setup.outputs.strategy", planning...)
require.Len(t, deferred, 1)
_, placeholder, err := ParseRawSingleWorkflow([]byte(deferred[0]))
require.NoError(t, err)
expanded, err := ExpandMatrixWithNeeds("build", placeholder, &model.GithubContext{}, map[string]*JobResult{
"build": {Needs: []string{"setup"}},
"setup": {Result: "success", Outputs: map[string]string{"strategy": strategy}},
}, nil, nil, 256)
require.NoError(t, err)
require.Len(t, expanded, 2)
assert.Equal(t, "1", expanded[0].Strategy.MaxParallelString)
assert.Equal(t, []any{"build (b-${{ secrets.X }})", []string{"b-${{ secrets.X }}"}}, []any{expanded[1].DisplayName(), expanded[1].RunsOn()})
assert.Equal(t, []any{"b-${{ secrets.X }}", "build (b-${{ secrets.X }})", []string{"b-${{ secrets.X }}"}},
runnerView(t, &model.Job{Name: expanded[1].Name, RawRunsOn: expanded[1].RawRunsOn, Strategy: expanded[1].Strategy.actStrategy()}))
_, err = Parse(fmt.Appendf(nil, workflow, "", "vars.STRATEGY"), planning[0], WithVars(map[string]string{"STRATEGY": `"a"`}))
require.ErrorContains(t, err, "is not a map of strategy keys to values")
}
func TestParseInterpolatesRunName(t *testing.T) {
workflow := func(runName string) []byte {
return []byte("name: t\nrun-name: \"" + runName + "\"\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: [{run: echo}]\n")
@@ -183,6 +266,9 @@ func TestParseInterpolatesRunName(t *testing.T) {
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, tt.want, result[0].RunName)
payload, err := result[0].Marshal()
require.NoError(t, err)
assert.NotContains(t, string(payload), "run-name")
})
}
@@ -194,7 +280,7 @@ func TestParseInterpolatesRunName(t *testing.T) {
// a malformed part must not restructure the surrounding expression
for _, runName := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 'a' }} ${{ b", "${{ 'a }}"} {
_, err := Parse(workflow(runName), WithGitContext(&model.GithubContext{EventName: "push"}))
assert.ErrorContains(t, err, "interpolate run-name")
assert.Error(t, err, runName)
}
// callers such as commit status parse without a git context, leaving `github` a nil pointer
@@ -204,6 +290,68 @@ func TestParseInterpolatesRunName(t *testing.T) {
assert.Empty(t, result[0].RunName)
}
func TestParseRunsOnFromJSONArray(t *testing.T) {
content := []byte("on: push\njobs:\n build:\n runs-on: ${{ fromJSON(vars.RUNNER) }}\n steps: [{run: echo}]\n")
_, err := Parse(content)
require.NoError(t, err)
for runner, want := range map[string][]string{`["self-hosted", "linux"]`: {"self-hosted", "linux"}, "[]": {""}} {
result, err := Parse(content, WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"RUNNER": runner}))
require.NoError(t, err)
require.Len(t, result, 1)
_, job := result[0].Job()
assert.Equal(t, want, job.RunsOn(), runner)
}
}
func TestJobFieldsWithoutMatrix(t *testing.T) {
const workflow = `on: push
jobs:
seed:
steps: [{run: echo}]
build:
name: build-${{ github.ref_name }}
continue-on-error: ${{ fromJSON(vars.CONTINUE) }}
steps: [{run: echo}]
target:
needs: seed
name: target-${{ needs.seed.outputs.runner }}
runs-on: ${{ needs.seed.outputs.runner }}
continue-on-error: ${{ needs.seed.outputs.tolerate == 'true' }}
strategy: ${{ fromJSON(needs.seed.outputs.strategy) }}
steps: [{run: echo}]
`
parse := func(t *testing.T, continueOnError string) map[string]*SingleWorkflow {
t.Helper()
parsed, err := Parse([]byte(workflow), WithGitContext(&model.GithubContext{RefName: "main"}), WithVars(map[string]string{"CONTINUE": continueOnError}))
require.NoError(t, err)
workflows := map[string]*SingleWorkflow{}
for _, parsedWorkflow := range parsed {
id, _ := parsedWorkflow.Job()
workflows[id] = parsedWorkflow
}
return workflows
}
workflows := parse(t, "true")
_, build := workflows["build"].Job()
assert.Equal(t, []any{"build-main", true}, []any{build.Name, build.GetContinueOnError()})
payload, err := parse(t, `"${{ true }}"`)["build"].Marshal()
require.NoError(t, err)
assert.Contains(t, string(payload), "${{ '$' }}{{ true }}")
_, placeholder := workflows["target"].Job()
require.True(t, HasDeferredMatrix(placeholder))
expanded, err := ExpandMatrixWithNeeds("target", placeholder, &model.GithubContext{}, map[string]*JobResult{
"target": {Needs: []string{"seed"}},
"seed": {Result: "success", Outputs: map[string]string{"runner": "ubuntu-latest", "tolerate": "true", "strategy": `{"fail-fast":false,"max-parallel":3}`}},
}, nil, nil, 256)
require.NoError(t, err)
require.Len(t, expanded, 1)
group, _, err := EvaluateConcurrency(&model.RawConcurrency{Group: "${{ strategy.job-index }}:${{ strategy.job-total }}:${{ strategy.fail-fast }}"}, "target", expanded[0], map[string]any{}, nil, nil, nil)
require.NoError(t, err)
assert.Equal(t, []any{"target-ubuntu-latest", []string{"ubuntu-latest"}, true, "3", "0:1:false"},
[]any{expanded[0].DisplayName(), expanded[0].RunsOn(), expanded[0].GetContinueOnError(), expanded[0].Strategy.MaxParallelString, group})
}
func TestExpandMatrixWithNeeds(t *testing.T) {
// matrixYAML is the YAML value of the `matrix:` key, so a case can replace the whole node.
expandMax := func(t *testing.T, matrixYAML string, maxCombinations int) ([]*Job, error) {
@@ -221,6 +369,7 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
"os": `["linux", "darwin"]`,
"include": `[{"os":"linux","fast":true},{"os":"windows","fast":false}]`,
"empty": "[]",
"limit": "3",
}},
}, nil, nil, maxCombinations)
}
@@ -230,17 +379,18 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
}
t.Run("expands the product and interpolates runs-on", func(t *testing.T) {
got, err := expand(t, "\n os: ${{ fromJson(needs.setup.outputs.os) }}\n version: ${{ fromJson(needs.setup.outputs.versions) }}\n")
got, err := expand(t, "\n os: ${{ fromJson(needs.setup.outputs.os) }}\n version: ${{ fromJson(needs.setup.outputs.versions) }}\nmax-parallel: ${{ needs.setup.outputs.limit }}\n")
require.NoError(t, err)
names := make([]string, 0, len(got))
indexes := make(map[string]int, len(got))
for _, combo := range got {
names = append(names, combo.Name)
indexes[combo.Name] = combo.Strategy.JobIndex
assert.Contains(t, []string{"linux", "darwin"}, combo.RunsOn()[0])
assert.Equal(t, "3", combo.Strategy.MaxParallelString)
assert.Equal(t, 4, combo.Strategy.JobTotal)
}
// Dimensions are appended in key order, as GitHub names multi-dimension combinations.
assert.ElementsMatch(t, []string{
"build (linux, 1.20)", "build (linux, 1.21)", "build (darwin, 1.20)", "build (darwin, 1.21)",
}, names)
assert.Equal(t, map[string]int{
"build (linux, 1.20)": 0, "build (linux, 1.21)": 1, "build (darwin, 1.20)": 2, "build (darwin, 1.21)": 3,
}, indexes)
})
t.Run("static and dynamic dimensions expand together, once", func(t *testing.T) {
@@ -255,7 +405,6 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
assert.Len(t, got, 2)
})
// GitHub rejects a matrix that yields no combinations instead of running the job unparameterized.
for _, tt := range []struct{ name, matrix, errHas string }{
{"empty vector", "\n version: ${{ fromJson(needs.setup.outputs.empty) }}\n", `Matrix vector "version" does not contain any values`},
{"empty include", "\n include: ${{ fromJson(needs.setup.outputs.empty) }}\n", "Matrix must define at least one vector"},
@@ -267,11 +416,24 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
})
}
t.Run("fully excluded matrix runs once without matrix values", func(t *testing.T) {
got, err := expand(t, "\n os: [linux]\n exclude:\n - os: linux\n")
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "build", got[0].Name)
assert.Equal(t, 1, got[0].Strategy.JobTotal)
})
t.Run("unresolved need errors", func(t *testing.T) {
_, err := expand(t, "\n v: ${{ fromJson(needs.missing.outputs.v) }}\n")
require.ErrorContains(t, err, "evaluate matrix")
})
t.Run("invalid strategy field errors", func(t *testing.T) {
_, err := expand(t, "\n os: [linux]\nmax-parallel: ${{ fromJSON('bad') }}\n")
require.ErrorContains(t, err, "evaluate strategy")
})
// The combination count comes from a runtime output, so it must be rejected before one Job per
// combination is built rather than after.
t.Run("too many combinations errors", func(t *testing.T) {
@@ -280,40 +442,34 @@ func TestExpandMatrixWithNeeds(t *testing.T) {
})
}
// evaluateJobIf builds a one-job workflow around the given `matrix:` value and `if:`, and decides it.
func evaluateJobIf(t *testing.T, matrixYAML, ifExpr string, deferred bool) (bool, error) {
t.Helper()
var strategy Strategy
require.NoError(t, yaml.Unmarshal(fmt.Appendf(nil, "matrix:\n %s\n", matrixYAML), &strategy))
job := &Job{Name: "build", Strategy: strategy}
require.NoError(t, job.If.Encode(ifExpr))
return EvaluateJobIfExpression("build", job, map[string]any{}, map[string]*JobResult{"build": {}}, nil, nil, deferred)
func TestReadWorkflowJobConditionContexts(t *testing.T) {
for condition, unavailable := range map[string]string{
"matrix.os == 'a'": "matrix",
"'${{ strategy.job-index == 0 }}'": "strategy",
"'${{ github.ref }} ${{ secrets.X }}'": "secrets",
"github.event.matrix && gitea.ref && needs.a.result && vars.X && inputs.y && always() && fromJSON('true')": "",
} {
_, err := ReadWorkflow([]byte("jobs: {build: {if: " + condition + "}}"))
if unavailable == "" {
assert.NoError(t, err, condition)
} else {
assert.ErrorContains(t, err, "Unrecognized named-value: '"+unavailable+"'", condition)
}
}
}
func TestRejectsUnevaluatedMatrixFilters(t *testing.T) {
// An unevaluated expression is still a scalar, which is not a filter act can apply.
// Every entry point into act's matrix expansion must reject it. The
// expression here reads `vars`, which is available while planning, so the job is not deferred and
// nothing will ever resolve the filter: the error is the right answer at both entry points.
// A deferred placeholder is the other case, covered by TestEvaluateJobIfExpressionLeavesRawMatrixUnavailable.
for _, filter := range []string{"include", "exclude"} {
t.Run(filter, func(t *testing.T) {
_, err := Parse(fmt.Appendf(nil,
"name: t\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}\n steps: [{run: echo}]\n", filter))
require.ErrorContains(t, err, "must be a list of mappings")
_, err = evaluateJobIf(t, fmt.Sprintf("os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}", filter), "${{ true }}", false)
require.ErrorContains(t, err, "must be a list of mappings")
"name: t\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n strategy:\n matrix:\n os: [a]\n %s: ${{ fromJson(vars.MATRIX) }}\n steps: [{run: echo}]\n", filter),
WithGitContext(&model.GithubContext{}), WithVars(map[string]string{"MATRIX": `"a"`}))
require.ErrorContains(t, err, "is not a list of maps")
})
}
}
func TestParseRawSingleWorkflowRoundTripsDeferredPlaceholder(t *testing.T) {
// The server persists a placeholder the way insertRunJob does: erase the needs, then marshal.
// Reading it back must yield that one job again. Parse cannot do it: it only keeps a matrix raw
// while the job still declares needs, so on the stored payload it falls through to expanding the
// raw matrix instead - which either fails or splits the placeholder into several workflows, and
// in both cases leaves the job unexpandable for good.
const workflow = `
on: push
jobs:
@@ -327,21 +483,13 @@ jobs:
%s
steps: [{run: echo}]
`
for _, tt := range []struct {
name string
matrix string
parseCount int // what Parse makes of the stored payload
parseErrHas string // ... or the error it fails with
}{
// The canonical GitHub dynamic-matrix idiom. validateMatrixFilters rejects the still-scalar expression outright.
{name: "include expression", matrix: "include: ${{ fromJson(needs.setup.outputs.m) }}", parseErrHas: "must be a list of mappings"},
// An unevaluated expression is a scalar where a vector is required, rather than one
// combination holding the literal `${{ }}` text.
{name: "static vector and expression", matrix: "os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.m) }}", parseErrHas: `Matrix vector "version" is not a list of values`},
{name: "single expression vector", matrix: "version: ${{ fromJson(needs.setup.outputs.m) }}", parseErrHas: `Matrix vector "version" is not a list of values`},
for name, matrix := range map[string]string{
"include expression": "include: ${{ fromJson(needs.setup.outputs.m) }}",
"static vector and expression": "os: [a, b]\n version: ${{ fromJson(needs.setup.outputs.m) }}",
"single expression vector": "version: ${{ fromJson(needs.setup.outputs.m) }}",
} {
t.Run(tt.name, func(t *testing.T) {
planned, err := Parse(fmt.Appendf(nil, workflow, tt.matrix))
t.Run(name, func(t *testing.T) {
planned, err := Parse(fmt.Appendf(nil, workflow, matrix))
require.NoError(t, err)
var payload []byte
@@ -357,90 +505,15 @@ jobs:
}
require.NotEmpty(t, payload, "no placeholder was planned for build")
// The stored payload keeps the raw matrix, but no longer the needs that made Parse defer it.
_, job, err := ParseRawSingleWorkflow(payload)
require.NoError(t, err)
assert.Equal(t, "build", job.Name)
// The needs are gone, which is exactly why Parse no longer defers this payload.
assert.Empty(t, job.Needs())
assert.False(t, HasDeferredMatrix(job))
// Guard the reason ParseRawSingleWorkflow exists, so a future Parse change cannot quietly
// make the placeholder re-expandable again without this being noticed.
reparsed, err := Parse(payload)
if tt.parseErrHas != "" {
require.ErrorContains(t, err, tt.parseErrHas)
} else {
require.NoError(t, err)
assert.Len(t, reparsed, tt.parseCount)
}
require.NoError(t, err)
assert.Len(t, reparsed, 1)
})
}
}
func TestEvaluateJobIfExpressionLeavesRawMatrixUnavailable(t *testing.T) {
// A placeholder's `if:` is read before its matrix can be resolved. `matrix.*` has to be absent
// there: binding it to the expression's own source text would decide the job against a value no
// combination ever has, and an include/exclude that is still a scalar cannot be read at all.
t.Run("include expression is not read", func(t *testing.T) {
run, err := evaluateJobIf(t, "include: ${{ fromJson(needs.setup.outputs.m) }}", "${{ true }}", true)
require.NoError(t, err)
assert.True(t, run)
})
t.Run("matrix context is null, not the raw expression", func(t *testing.T) {
const matrix = "version: ${{ fromJson(needs.setup.outputs.m) }}"
run, err := evaluateJobIf(t, matrix, "${{ matrix.version == null }}", true)
require.NoError(t, err)
assert.True(t, run)
run, err = evaluateJobIf(t, matrix, "${{ matrix.version == '${{ fromJson(needs.setup.outputs.m) }}' }}", true)
require.NoError(t, err)
assert.False(t, run)
})
t.Run("an expanded job still reads its combination", func(t *testing.T) {
run, err := evaluateJobIf(t, "version: [1]", "${{ matrix.version == 1 }}", false)
require.NoError(t, err)
assert.True(t, run)
})
}
func TestExpressionReadsMatrix(t *testing.T) {
// Erring toward true only postpones the `if:` to the pass that has the combination, which decides it correctly anyway.
for value, want := range map[string]bool{
"": false,
"true": false, // a bare literal is an expression too, it just reads nothing
"${{ always() }}": false,
"${{ needs.setup.result == 'ok' }}": false,
"${{ vars.MATRIX }}": false, // a name that merely looks like the context
"${{ matrix.os }}": true,
"${{ MATRIX.os }}": true, // contexts are case-insensitive
"${{ always() && matrix.os == 1 }}": true,
"${{ contains(matrix.tags, 'a') }}": true,
"${{ toJSON(matrix) }}": true, // the whole context, not a property of it
"${{ vars.A }}${{ matrix.os }}": true, // only the second of two expressions reads it
"${{ matrix.os == }}": true, // unparseable, postpone rather than decide it here
// An `if:` may omit the `${{ }}`, and is evaluated as one expression either way.
"matrix.os == 'a'": true,
"needs.setup.result == 'ok'": false,
} {
assert.Equal(t, want, ExpressionReadsMatrix(value), "value %q", value)
}
}
func TestExpressionIgnoresNeedResults(t *testing.T) {
for value, want := range map[string]bool{
"": false,
"${{ matrix.os == 'a' }}": false,
"${{ success() }}": false, // the implicit gate, so the fallback already matches it
"${{ always() }}": true,
"${{ ALWAYS() && matrix.os }}": true, // function names are case-insensitive
"${{ failure() }}": true,
"${{ cancelled() }}": true,
"always() && matrix.os == 'a'": true, // the brace-less form of the same gate
"${{ vars.always }}": false,
} {
assert.Equal(t, want, ExpressionIgnoresNeedResults(value), "value %q", value)
}
}
+127 -181
View File
@@ -14,18 +14,19 @@ import (
"gitea.dev/actionslib/pkg/model"
"gitea.dev/modules/util"
"github.com/robfig/cron/v3"
"go.yaml.in/yaml/v4"
)
// SingleWorkflow is a workflow with single job and single matrix
type SingleWorkflow struct {
Name string `yaml:"name,omitempty"`
RawOn yaml.Node `yaml:"on,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
RawJobs yaml.Node `yaml:"jobs,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
RunName string `yaml:"run-name,omitempty"`
Name string `yaml:"name,omitempty"`
RawOn yaml.Node `yaml:"on,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
RawJobs yaml.Node `yaml:"jobs,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
RunName string `yaml:"run-name,omitempty"`
}
func (w *SingleWorkflow) Job() (string, *Job) {
@@ -93,7 +94,9 @@ func (w *SingleWorkflow) Marshal() ([]byte, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(w); err != nil {
payload := *w
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
if err := enc.Encode(&payload); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
@@ -103,24 +106,24 @@ func (w *SingleWorkflow) Marshal() ([]byte, error) {
}
type Job struct {
Name string `yaml:"name,omitempty"`
RawNeeds yaml.Node `yaml:"needs,omitempty"`
RawRunsOn yaml.Node `yaml:"runs-on,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Steps []*Step `yaml:"steps,omitempty"`
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"`
Services map[string]*ContainerSpec `yaml:"services,omitempty"`
Strategy Strategy `yaml:"strategy,omitempty"`
RawContainer yaml.Node `yaml:"container,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
Outputs map[string]string `yaml:"outputs,omitempty"`
Uses string `yaml:"uses,omitempty"`
With map[string]any `yaml:"with,omitempty"`
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
Name string `yaml:"name,omitempty"`
RawNeeds yaml.Node `yaml:"needs,omitempty"`
RawRunsOn yaml.Node `yaml:"runs-on,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Steps []*Step `yaml:"steps,omitempty"`
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"`
Services yaml.Node `yaml:"services,omitempty"`
Strategy Strategy `yaml:"strategy,omitempty"`
RawContainer yaml.Node `yaml:"container,omitempty"`
Defaults yaml.Node `yaml:"defaults,omitempty"`
Outputs map[string]string `yaml:"outputs,omitempty"`
Uses string `yaml:"uses,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
}
// GetContinueOnError decodes the continue-on-error field to a bool.
@@ -171,8 +174,18 @@ func (j *Job) EraseNeeds() *Job {
return j
}
// RunsOn returns the labels Gitea matches runners against, unescaped like DisplayName.
func (j *Job) RunsOn() []string {
return (&model.Job{RawRunsOn: j.RawRunsOn}).RunsOn()
runsOn := model.RunsOnFromNode(j.RawRunsOn)
for i, label := range runsOn {
runsOn[i] = unescapeExpressions(label)
}
return runsOn
}
// DisplayName is the name Gitea stores, without the escaping the payload keeps for runners.
func (j *Job) DisplayName() string {
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
}
// BlockSafeString works around https://github.com/yaml/go-yaml/issues/399, quoting a value whose
@@ -187,17 +200,17 @@ func (s BlockSafeString) MarshalYAML() (any, error) {
}
type Step struct {
ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Name BlockSafeString `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"`
Run BlockSafeString `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
With map[string]string `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Name BlockSafeString `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"`
Run BlockSafeString `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
}
// UnmarshalYAML canonicalizes booleans like continue-on-error
@@ -225,20 +238,45 @@ func (s *Step) String() string {
}).String()
}
type ContainerSpec struct {
Image string `yaml:"image,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
Ports []string `yaml:"ports,omitempty"`
Volumes []string `yaml:"volumes,omitempty"`
Options string `yaml:"options,omitempty"`
Credentials map[string]string `yaml:"credentials,omitempty"`
Cmd []string `yaml:"cmd,omitempty"`
}
type Strategy struct {
FailFastString string `yaml:"fail-fast,omitempty"`
MaxParallelString string `yaml:"max-parallel,omitempty"`
RawMatrix yaml.Node `yaml:"matrix,omitempty"`
JobIndex int `yaml:"job-index,omitempty"` // set by buildMatrixCombos, read back from its payload
JobTotal int `yaml:"job-total,omitempty"`
RawExpression yaml.Node `yaml:"-"` // a whole-value `strategy: ${{ }}`, see Strategy.resolve
}
type rawStrategy Strategy
func (s *Strategy) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
*s = Strategy{RawExpression: *node}
return nil
}
return node.Decode((*rawStrategy)(s))
}
func (s Strategy) MarshalYAML() (any, error) {
if s.RawExpression.Kind != 0 {
return &s.RawExpression, nil
}
return rawStrategy(s), nil
}
func (s Strategy) actStrategy() *model.Strategy {
return &model.Strategy{FailFastString: s.FailFastString, MaxParallelString: s.MaxParallelString, RawMatrix: s.RawMatrix}
}
// context is the strategy context, combination 0 of 1 without a matrix as on GitHub, and without job-index for an unexpanded matrix.
func (s *Strategy) context() map[string]any {
switch {
case s == nil:
return exprparser.StrategyContext(nil, 0, 0)
case s.RawMatrix.Kind == 0 && s.RawExpression.Kind == 0:
return exprparser.StrategyContext(s.actStrategy(), 0, 1)
}
return exprparser.StrategyContext(s.actStrategy(), s.JobIndex, s.JobTotal)
}
type Defaults struct {
@@ -250,20 +288,10 @@ type RunDefaults struct {
WorkingDirectory string `yaml:"working-directory,omitempty"`
}
type WorkflowDispatchInput struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
Type string `yaml:"type"`
Options []string `yaml:"options"`
}
type Event struct {
Name string
acts map[string][]string
schedules []map[string]string
inputs []WorkflowDispatchInput
}
func (evt *Event) IsSchedule() bool {
@@ -278,10 +306,6 @@ func (evt *Event) Schedules() []map[string]string {
return evt.schedules
}
func (evt *Event) Inputs() []WorkflowDispatchInput {
return evt.inputs
}
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
w, err := ReadWorkflow(content)
if err != nil {
@@ -290,26 +314,30 @@ func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
return w.RawConcurrency, nil
}
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
actJob := &model.Job{}
// newJobEvaluator evaluates against a stored job's contexts, with its single matrix combination.
func newJobEvaluator(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (expreval.Evaluator, error) {
var strategy *Strategy
var matrix map[string]any
if job != nil {
actJob.Strategy = &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
strategy = &job.Strategy
rawMatrix := model.CloneYamlNode(job.Strategy.RawMatrix)
replaceScalars(&rawMatrix, unescapeExpressions)
matrixes, err := (&model.Job{Strategy: &model.Strategy{RawMatrix: rawMatrix}}).GetMatrixes()
if err != nil {
return expreval.Evaluator{}, err
}
if len(matrixes[0]) > 0 {
matrix = matrixes[0]
}
}
return expreval.New(NewInterpeter(jobID, strategy, matrix, model.GithubContextFromMap(gitCtx), results, vars, inputs).Evaluate), nil
}
matrix := make(map[string]any)
matrixes, err := matrixesOf(actJob)
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
evaluator, err := newJobEvaluator(jobID, job, gitCtx, results, vars, inputs)
if err != nil {
return "", false, err
}
if len(matrixes) > 0 {
matrix = matrixes[0]
}
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
var node yaml.Node
if err := node.Encode(rc); err != nil {
return "", false, fmt.Errorf("failed to encode concurrency: %w", err)
@@ -327,40 +355,6 @@ func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCt
return evaluated.Group, util.ParseYamlBool(evaluated.CancelInProgress), nil
}
func toGitContext(input map[string]any) *model.GithubContext {
gitContext := &model.GithubContext{
EventPath: asString(input["event_path"]),
Workflow: asString(input["workflow"]),
RunID: asString(input["run_id"]),
RunNumber: asString(input["run_number"]),
Actor: asString(input["actor"]),
Repository: asString(input["repository"]),
EventName: asString(input["event_name"]),
Sha: asString(input["sha"]),
Ref: asString(input["ref"]),
RefName: asString(input["ref_name"]),
RefType: asString(input["ref_type"]),
HeadRef: asString(input["head_ref"]),
BaseRef: asString(input["base_ref"]),
Token: asString(input["token"]),
Workspace: asString(input["workspace"]),
Action: asString(input["action"]),
ActionPath: asString(input["action_path"]),
ActionRef: asString(input["action_ref"]),
ActionRepository: asString(input["action_repository"]),
Job: asString(input["job"]),
RepositoryOwner: asString(input["repository_owner"]),
RetentionDays: asString(input["retention_days"]),
}
event, ok := input["event"].(map[string]any)
if ok {
gitContext.Event = event
}
return gitContext
}
// workflowCallEvent is only fired by another workflow's `uses:`, so it is excluded from trigger detection.
const workflowCallEvent = "workflow_call"
@@ -372,6 +366,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
if err != nil {
return nil, err
}
if rawOn.ShortTag() != "!!str" || val == "" {
return nil, fmt.Errorf("invalid event %q", val)
}
if val == workflowCallEvent {
return []*Event{}, nil
}
@@ -421,18 +418,24 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
}
schedules := make([]map[string]string, len(t))
if k == "schedule" {
if len(t) == 0 {
return nil, errors.New("schedule must contain at least one cron entry")
}
for i, tt := range t {
vv, ok := tt.(map[string]any)
if !ok {
return nil, fmt.Errorf("unknown on type(schedule): %#v", v)
return nil, errors.New("unknown on type(schedule)")
}
schedules[i] = make(map[string]string, len(vv))
for k, vvv := range vv {
var ok bool
if schedules[i][k], ok = vvv.(string); !ok {
return nil, fmt.Errorf("unknown on type(schedule): %#v", v)
return nil, errors.New("unknown on type(schedule)")
}
}
if _, err := cron.ParseStandard(schedules[i]["cron"]); err != nil {
return nil, fmt.Errorf("invalid cron %q: %w", schedules[i]["cron"], err)
}
}
}
@@ -444,14 +447,14 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
schedules: schedules,
})
case yaml.MappingNode:
// Keep combined include and ignore filters for existing Gitea workflows, although GitHub rejects them.
acts := make(map[string][]string, len(v.Content)/2)
var inputs []WorkflowDispatchInput
expectedKey := true
var act string
for _, content := range v.Content {
if expectedKey {
if content.Kind != yaml.ScalarNode {
return nil, fmt.Errorf("key type not string: %#v", content)
return nil, errors.New("key type not string")
}
act = ""
err := content.Decode(&act)
@@ -476,48 +479,23 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
acts[act] = []string{t}
case yaml.MappingNode:
if k != "workflow_dispatch" || act != "inputs" {
return nil, fmt.Errorf("map should only for workflow_dispatch but %s: %#v", act, content)
return nil, fmt.Errorf("map should only for workflow_dispatch but %s", act)
}
var key string
for i, vv := range content.Content {
if i%2 == 0 {
if vv.Kind != yaml.ScalarNode {
return nil, fmt.Errorf("key type not string: %#v", vv)
}
key = ""
if err := vv.Decode(&key); err != nil {
return nil, err
}
} else {
if vv.Kind != yaml.MappingNode {
return nil, fmt.Errorf("key type not map(%s): %#v", key, vv)
}
input := WorkflowDispatchInput{}
if err := vv.Decode(&input); err != nil {
return nil, err
}
input.Name = key
inputs = append(inputs, input)
}
if err := content.Decode(new(map[string]model.WorkflowDispatchInput)); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown on type: %#v", content)
return nil, fmt.Errorf("unknown on type for %s", act)
}
}
expectedKey = !expectedKey
}
if len(inputs) == 0 {
inputs = nil
}
if len(acts) == 0 {
acts = nil
}
res = append(res, &Event{
Name: k,
acts: acts,
inputs: inputs,
Name: k,
acts: acts,
})
default:
return nil, fmt.Errorf("unknown on type: %v", v.Kind)
@@ -529,35 +507,12 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
}
}
// EvaluateJobIfExpression evaluates a job's `if:`.
func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any, matrixDeferred bool) (bool, error) {
actJob := &model.Job{
Strategy: &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
},
// EvaluateJobIfExpression evaluates a job's `if:`, which github.com decides before the matrix, so without the matrix and strategy contexts.
func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (bool, error) {
if unavailable := unavailableContext(IfExpression(job.If.Value), jobConditionContexts); unavailable != "" { // only a job stored before its conditions were validated
return false, fmt.Errorf("job %s: Unrecognized named-value: '%s', update the workflow and trigger a new run", jobID, unavailable)
}
// Each per-matrix job carries its single matrix combination in RawMatrix so resolve it and pass it in;
// otherwise `matrix.*` references in `if:` evaluate to null.
// GetMatrixes always returns at least one element (an empty map for a job without a matrix),
// so only a non-empty combination should populate `matrix.*`, leaving it nil otherwise.
//
// A deferred-matrix placeholder is the exception: its combinations do not exist yet, and reading the
// raw matrix here would either fail outright (an `include` that is still a scalar expression) or bind
// `matrix.*` to the expression's own source text. Leaving it nil is safe: the caller checks
// ExpressionReadsMatrix first, so an `if:` that reads `matrix.*` is deferred to the post-expansion pass.
var matrix map[string]any
if !matrixDeferred {
matrixes, err := matrixesOf(actJob)
if err != nil {
return false, err
}
if len(matrixes) > 0 && len(matrixes[0]) > 0 {
matrix = matrixes[0]
}
}
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
evaluator := expreval.New(NewInterpeter(jobID, nil, nil, model.GithubContextFromMap(gitCtx), results, vars, inputs).Evaluate)
return evaluator.EvalBool(job.If.Value, exprparser.DefaultStatusCheckSuccess)
}
@@ -593,12 +548,3 @@ func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) {
return scalars, datas, nil
}
func asString(v any) string {
if v == nil {
return ""
} else if s, ok := v.(string); ok {
return s
}
return ""
}
+33 -72
View File
@@ -17,9 +17,18 @@ import (
func TestParseRawOn(t *testing.T) {
kases := []struct {
input string
result []*Event
input string
result []*Event
wantErr bool
}{
{input: "on:\n push:\n branches:\n a: b", wantErr: true},
{input: "on: [push, {pull_request: null}]", wantErr: true},
{input: "on:", wantErr: true},
{input: "on: 42", wantErr: true},
{input: "jobs: {}", wantErr: true},
{input: "on:\n schedule: []", wantErr: true},
{input: "on:\n schedule:\n - {}", wantErr: true},
{input: "on:\n schedule:\n - cron: nope", wantErr: true},
{
input: "on: issue_comment",
result: []*Event{
@@ -190,13 +199,14 @@ func TestParseRawOn(t *testing.T) {
},
},
{
input: "on:\n schedule:\n - cron: '20 6 * * *'",
input: "on:\n schedule:\n - cron: '20 6 * * *'\n timezone: UTC",
result: []*Event{
{
Name: "schedule",
schedules: []map[string]string{
{
"cron": "20 6 * * *",
"cron": "20 6 * * *",
"timezone": "UTC",
},
},
},
@@ -228,28 +238,6 @@ func TestParseRawOn(t *testing.T) {
result: []*Event{
{
Name: "workflow_dispatch",
inputs: []WorkflowDispatchInput{
{
Name: "logLevel",
Description: "Log level",
Required: true,
Default: "warning",
Type: "choice",
Options: []string{"info", "warning", "debug"},
},
{
Name: "tags",
Description: "Test scenario tags",
Required: false,
Type: "boolean",
},
{
Name: "environment",
Description: "Environment to run tests against",
Type: "environment",
Required: true,
},
},
},
{
Name: "push",
@@ -310,6 +298,10 @@ func TestParseRawOn(t *testing.T) {
assert.NoError(t, err)
events, err := ParseRawOn(&origin.RawOn)
if kase.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, kase.result, events, events)
})
@@ -467,51 +459,6 @@ func TestParseMappingNode(t *testing.T) {
}
}
func TestEvaluateJobIfExpressionMatrix(t *testing.T) {
ifExprs := []string{
`${{ contains(fromJSON('["linux","windows"]'), matrix.target) }}`,
`${{ contains('["linux","windows"]', matrix.target) }}`,
}
want := map[string]bool{
"build (linux)": true,
"build (windows)": true,
"build (macos)": false,
}
for _, ifExpr := range ifExprs {
t.Run(ifExpr, func(t *testing.T) {
content := fmt.Sprintf(`
name: test
on: push
jobs:
build:
runs-on: ubuntu-latest
if: %s
strategy:
fail-fast: false
matrix:
target: [linux, windows, macos]
steps:
- run: echo ${{ matrix.target }}
`, ifExpr)
swfs, err := Parse([]byte(content))
require.NoError(t, err)
require.Len(t, swfs, 3)
got := make(map[string]bool, len(swfs))
for _, swf := range swfs {
id, job := swf.Job()
shouldRun, err := EvaluateJobIfExpression(id, job, map[string]any{}, map[string]*JobResult{id: {}}, nil, nil, false)
require.NoError(t, err)
got[job.Name] = shouldRun
}
assert.Equal(t, want, got)
})
}
}
func TestEvaluateJobIfExpression(t *testing.T) {
kases := []struct {
name string
@@ -567,9 +514,23 @@ jobs:
"job1": {Result: kase.needResult},
"job2": {Needs: []string{"job1"}},
}
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil, false)
got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil)
require.NoError(t, err)
assert.Equal(t, kase.expected, got)
})
}
t.Run("stored unavailable context", func(t *testing.T) {
for condition, wantErr := range map[string]string{
"matrix.os == 'a'": "Unrecognized named-value: 'matrix'",
"${{ strategy.fail-fast }}": "Unrecognized named-value: 'strategy'",
"secrets.TOKEN != ''": "Unrecognized named-value: 'secrets'",
"${{ matrix.os == }}": "Unexpected end of expression",
} {
_, job, err := ParseRawSingleWorkflow(fmt.Appendf(nil, "jobs: {job2: {if: %q, strategy: {matrix: {os: [a]}}}}", condition))
require.NoError(t, err)
_, err = EvaluateJobIfExpression("job2", job, map[string]any{}, map[string]*JobResult{"job2": {}}, nil, nil)
assert.ErrorContains(t, err, wantErr, condition)
}
})
}
@@ -10,6 +10,7 @@ jobs:
matrix:
experimental:
- false
job-total: 2
---
name: test
jobs:
@@ -23,3 +24,5 @@ jobs:
matrix:
experimental:
- true
job-index: 1
job-total: 2
@@ -14,6 +14,8 @@ jobs:
- ubuntu-20.04
version:
- 1.17
job-index: 3
job-total: 6
---
name: test
jobs:
@@ -31,6 +33,8 @@ jobs:
- ubuntu-20.04
version:
- 1.18
job-index: 4
job-total: 6
---
name: test
jobs:
@@ -48,6 +52,8 @@ jobs:
- ubuntu-20.04
version:
- 1.19
job-index: 5
job-total: 6
---
name: test
jobs:
@@ -65,6 +71,7 @@ jobs:
- ubuntu-22.04
version:
- 1.17
job-total: 6
---
name: test
jobs:
@@ -82,6 +89,8 @@ jobs:
- ubuntu-22.04
version:
- 1.18
job-index: 1
job-total: 6
---
name: test
jobs:
@@ -99,3 +108,5 @@ jobs:
- ubuntu-22.04
version:
- 1.19
job-index: 2
job-total: 6
@@ -14,6 +14,8 @@ jobs:
- ubuntu-20.04
version:
- 1.17
job-index: 3
job-total: 6
---
name: test
jobs:
@@ -31,6 +33,8 @@ jobs:
- ubuntu-20.04
version:
- 1.18
job-index: 4
job-total: 6
---
name: test
jobs:
@@ -48,6 +52,8 @@ jobs:
- ubuntu-20.04
version:
- 1.19
job-index: 5
job-total: 6
---
name: test
jobs:
@@ -65,6 +71,7 @@ jobs:
- ubuntu-22.04
version:
- 1.17
job-total: 6
---
name: test
jobs:
@@ -82,6 +89,8 @@ jobs:
- ubuntu-22.04
version:
- 1.18
job-index: 1
job-total: 6
---
name: test
jobs:
@@ -99,3 +108,5 @@ jobs:
- ubuntu-22.04
version:
- 1.19
job-index: 2
job-total: 6
-79
View File
@@ -1,79 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"errors"
"fmt"
"path"
"regexp"
"strings"
)
// UsesKind enumerates the supported forms of a reusable workflow "uses:" value.
type UsesKind int
const (
// UsesKindLocalSameRepo is "./<dir>/foo.yml" or "$/<dir>/foo.yml" - a path inside the calling repository.
// For example: "./.gitea/workflows/foo.yml"
UsesKindLocalSameRepo UsesKind = iota + 1
// UsesKindLocalCrossRepo is "owner/repo/<dir>/foo.yml@ref" - a workflow in another repo on the same instance.
// For example: "owner/repo/.gitea/workflows/foo.yml@ref"
UsesKindLocalCrossRepo
)
// UsesRef is the parsed form of a reusable workflow "uses:" value.
type UsesRef struct {
Kind UsesKind
Owner string // empty for UsesKindLocalSameRepo
Repo string // empty for UsesKindLocalSameRepo
Path string // workflow file path inside the source repo
Ref string // git ref; empty for UsesKindLocalSameRepo
}
var (
reLocalSameRepo = regexp.MustCompile(`^[.$]/([^@]+\.ya?ml)$`)
reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/([^@]+\.ya?ml)@(.+)$`)
)
// ParseUses parses the SYNTAX of a reusable workflow "uses:" value into a UsesRef. Two forms are supported:
// - "./<dir>/foo.yml" or "$/<dir>/foo.yml" (UsesKindLocalSameRepo, no @ref)
// - "OWNER/REPO/<dir>/foo.yml@REF" (UsesKindLocalCrossRepo)
//
// It deliberately does NOT validate that <dir> is an allowed workflow directory: the allowed directories are instance-configurable (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS).
// The caller (services/actions.ResolveUses) enforces the directory allowlist. The returned Path is the cleaned, repo-relative file path.
func ParseUses(s string) (*UsesRef, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, errors.New("empty uses value")
}
if strings.HasPrefix(s, "./") || strings.HasPrefix(s, "$/") {
m := reLocalSameRepo.FindStringSubmatch(s)
if m == nil {
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./<dir>/<file>.yml or $/<dir>/<file>.yml)`, s)
}
p := m[1]
if path.Clean(p) != p {
return nil, fmt.Errorf("invalid workflow path %q", s)
}
return &UsesRef{Kind: UsesKindLocalSameRepo, Path: p}, nil
}
m := reLocalCrossRepo.FindStringSubmatch(s)
if m == nil {
return nil, fmt.Errorf(`invalid cross-repo "uses:" %q (expect owner/repo/<dir>/<file>.yml@ref)`, s)
}
p := m[3]
if path.Clean(p) != p {
return nil, fmt.Errorf("invalid workflow path %q", s)
}
return &UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: m[1],
Repo: m[2],
Path: p,
Ref: m[4],
}, nil
}
-195
View File
@@ -1,195 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseUses(t *testing.T) {
t.Run("LocalSameRepo", func(t *testing.T) {
cases := []struct {
name string
in string
want UsesRef
}{
{
name: "gitea dir, .yml",
in: "./.gitea/workflows/build.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"},
},
{
name: "github dir, .yml",
in: "./.github/workflows/build.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".github/workflows/build.yml"},
},
{
name: "gitea dir, .yaml",
in: "./.gitea/workflows/build.yaml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yaml"},
},
{
name: "filename containing dots is allowed",
in: "./.gitea/workflows/foo..bar.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/foo..bar.yml"},
},
{
name: "nested subdirectory",
in: "./.gitea/workflows/sub/build.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/sub/build.yml"},
},
{
// ParseUses is dir-agnostic; the allowed directories (WORKFLOW_DIRS / SCOPED_WORKFLOW_DIRS) are enforced by ResolveUses.
name: "scoped workflows dir parses",
in: "./.gitea/scoped_workflows/lib.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/scoped_workflows/lib.yml"},
},
{
name: "non-default dir parses (allowlist enforced downstream)",
in: "./.gitea/custom_workflows/x.yaml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/custom_workflows/x.yaml"},
},
{
name: "self-repo prefix",
in: "$/.gitea/workflows/build.yml",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"},
},
{
name: "leading/trailing whitespace is trimmed",
in: " ./.gitea/workflows/build.yml ",
want: UsesRef{Kind: UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := ParseUses(c.in)
require.NoError(t, err)
assert.Equal(t, c.want, *got)
})
}
})
t.Run("LocalCrossRepo", func(t *testing.T) {
cases := []struct {
name string
in string
want UsesRef
}{
{
name: "gitea dir, simple ref",
in: "owner/repo/.gitea/workflows/build.yml@v1",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/workflows/build.yml",
Ref: "v1",
},
},
{
name: "github dir, branch ref",
in: "owner/repo/.github/workflows/build.yml@main",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".github/workflows/build.yml",
Ref: "main",
},
},
{
name: ".yaml extension",
in: "owner/repo/.gitea/workflows/build.yaml@abc123",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/workflows/build.yaml",
Ref: "abc123",
},
},
{
name: "ref with slashes (refs/heads/feature)",
in: "owner/repo/.gitea/workflows/build.yml@refs/heads/feature",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/workflows/build.yml",
Ref: "refs/heads/feature",
},
},
{
name: "nested subdirectory under workflows",
in: "owner/repo/.gitea/workflows/sub/build.yml@v1",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/workflows/sub/build.yml",
Ref: "v1",
},
},
{
name: "scoped workflows dir parses (allowlist enforced by ResolveUses)",
in: "owner/repo/.gitea/scoped_workflows/lib.yml@v1",
want: UsesRef{
Kind: UsesKindLocalCrossRepo,
Owner: "owner",
Repo: "repo",
Path: ".gitea/scoped_workflows/lib.yml",
Ref: "v1",
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := ParseUses(c.in)
require.NoError(t, err)
assert.Equal(t, c.want, *got)
})
}
})
t.Run("Errors", func(t *testing.T) {
cases := []struct {
name string
in string
}{
{name: "empty string", in: ""},
{name: "whitespace only", in: " "},
// Same-repo malformed (note: a wrong *directory* parses and should be rejected by the caller)
{name: "same-repo with @ref", in: "./.gitea/workflows/build.yml@v1"},
{name: "self-repo with @ref", in: "$/.gitea/workflows/build.yml@v1"},
{name: "same-repo wrong extension", in: "./.gitea/workflows/build.txt"},
{name: "same-repo missing extension", in: "./.gitea/workflows/build"},
{name: "same-repo absolute path", in: "/.gitea/workflows/build.yml"},
{name: "same-repo path traversal", in: "./.gitea/workflows/../escape.yml"},
{name: "same-repo double slash", in: "./.gitea/workflows//build.yml"},
{name: "same-repo redundant ./", in: "./.gitea/workflows/./build.yml"},
// Cross-repo malformed
{name: "cross-repo missing @ref", in: "owner/repo/.gitea/workflows/build.yml"},
{name: "cross-repo empty ref", in: "owner/repo/.gitea/workflows/build.yml@"},
{name: "cross-repo missing owner", in: "/repo/.gitea/workflows/build.yml@v1"},
{name: "cross-repo missing repo", in: "owner//.gitea/workflows/build.yml@v1"},
{name: "cross-repo wrong extension", in: "owner/repo/.gitea/workflows/build.txt@v1"},
{name: "cross-repo path traversal", in: "owner/repo/.gitea/workflows/../escape.yml@v1"},
{name: "cross-repo double slash in path", in: "owner/repo/.gitea/workflows//build.yml@v1"},
// owner/repo with chars Gitea's name validators reject
{name: "cross-repo owner with space", in: "bad owner/repo/.gitea/workflows/build.yml@v1"},
{name: "cross-repo repo with @", in: "owner/re@po/.gitea/workflows/build.yml@v1"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ParseUses(c.in)
assert.Error(t, err)
})
}
})
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package jobparser
import (
"errors"
"fmt"
"slices"
"gitea.dev/actionslib/pkg/expreval"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
)
// jobConditionContexts are what github.com gives `jobs.<job_id>.if`, which it decides before the matrix, plus the `gitea` alias.
var jobConditionContexts = []string{"github", "gitea", "needs", "vars", "inputs"}
func ValidateWorkflowStatic(content []byte) ([]*Event, error) {
doc, err := resolveYamlAliases(content)
if err != nil {
return nil, err
}
// Keep unknown and case-distinct keys accepted for existing Gitea workflows.
workflow, err := readWorkflowDoc(doc)
if err != nil {
return nil, err
}
events, err := ParseRawOn(&workflow.RawOn)
if err != nil {
return nil, err
}
if err := validateWorkflowStructure(workflow); err != nil {
return nil, err
}
var header struct {
RunName string `yaml:"run-name"`
}
if err := decodeYamlDoc(doc, &header); err != nil {
return nil, err
}
if unavailable := unavailableContext(header.RunName, []string{"github", "gitea", "inputs", "vars"}); unavailable != "" {
return nil, fmt.Errorf("run-name: Unrecognized named-value: '%s'", unavailable)
}
return events, nil
}
func validateJobConditions(workflow *model.Workflow) error {
for id, job := range workflow.Jobs {
if job == nil {
continue
}
if unavailable := unavailableContext(IfExpression(job.If.Value), jobConditionContexts); unavailable != "" {
return fmt.Errorf("job %s: Unrecognized named-value: '%s'", id, unavailable)
}
}
return nil
}
func unavailableContext(expression string, allowed []string) string {
var unavailable string
expreval.Match(expression, func(node exprparser.ExprNode) bool {
if variable, ok := node.(*exprparser.VariableNode); ok && !slices.ContainsFunc(allowed, func(name string) bool { return exprparser.OrdinalIgnoreCaseEqual(name, variable.Name) }) {
unavailable = variable.Name
}
return unavailable != ""
})
return unavailable
}
func validateWorkflowStructure(workflow *model.Workflow) error {
if len(workflow.Jobs) == 0 {
return errors.New("the workflow must contain at least one job")
}
for id, job := range workflow.Jobs {
if job == nil {
return fmt.Errorf("job %q has no configuration", id)
}
// a job without runs-on is accepted and runs on any runner, github.com rejects it
for _, dependency := range job.Needs() {
if _, ok := workflow.Jobs[dependency]; !ok {
return fmt.Errorf("job %q needs unknown job %q", id, dependency)
}
}
}
visited := make(map[string]bool, len(workflow.Jobs))
visiting := make(map[string]bool, len(workflow.Jobs))
var visit func(string) error
visit = func(id string) error {
if visiting[id] {
return fmt.Errorf("job %q has a dependency cycle", id)
}
if visited[id] {
return nil
}
visiting[id] = true
for _, dependency := range workflow.Jobs[id].Needs() {
if err := visit(dependency); err != nil {
return err
}
}
delete(visiting, id)
visited[id] = true
return nil
}
for id := range workflow.Jobs {
if err := visit(id); err != nil {
return err
}
}
return nil
}
+36 -301
View File
@@ -9,274 +9,66 @@ import (
"regexp"
"strings"
"gitea.dev/actionslib/pkg/expreval"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
"gitea.dev/modules/container"
"gitea.dev/modules/util"
"go.yaml.in/yaml/v4"
)
// InputType enumerates the allowed types for a workflow_call input.
type InputType string
const (
InputTypeString InputType = "string"
InputTypeBoolean InputType = "boolean"
InputTypeNumber InputType = "number"
)
// InputSpec describes a single workflow_call input declaration.
type InputSpec struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default yaml.Node `yaml:"default"`
Type InputType `yaml:"type"`
}
// SecretSpec describes a single workflow_call secret declaration.
type SecretSpec struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
}
// OutputSpec describes a single workflow_call output declaration.
type OutputSpec struct {
Description string `yaml:"description"`
Value string `yaml:"value"`
}
// WorkflowCallSpec is the parsed "on.workflow_call" schema of a called workflow.
type WorkflowCallSpec struct {
Inputs map[string]InputSpec
Secrets map[string]SecretSpec
Outputs map[string]OutputSpec
}
// JobOutputs is the per-job-id outputs map used for evaluating workflow_call outputs.
type JobOutputs map[string]map[string]string
// ParseWorkflowCallSpec extracts on.workflow_call.{inputs,secrets,outputs} from a workflow YAML.
// Returns an error if the workflow does not declare on.workflow_call at all.
func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) {
var doc struct {
On yaml.Node `yaml:"on"`
}
if err := decodeResolved(content, &doc); err != nil {
return nil, fmt.Errorf("parse workflow yaml: %w", err)
}
wcNode, ok := findWorkflowCallNode(&doc.On)
if !ok {
return nil, errors.New("workflow does not declare on.workflow_call")
}
spec := &WorkflowCallSpec{
Inputs: map[string]InputSpec{},
Secrets: map[string]SecretSpec{},
Outputs: map[string]OutputSpec{},
}
if wcNode == nil || wcNode.Kind != yaml.MappingNode {
return spec, nil
}
for i := 0; i+1 < len(wcNode.Content); i += 2 {
key := wcNode.Content[i]
val := wcNode.Content[i+1]
switch key.Value {
case "inputs":
if err := decodeWorkflowCallMapping(val, spec.Inputs); err != nil {
return nil, fmt.Errorf("parse workflow_call.inputs: %w", err)
}
case "secrets":
if err := decodeWorkflowCallMapping(val, spec.Secrets); err != nil {
return nil, fmt.Errorf("parse workflow_call.secrets: %w", err)
}
case "outputs":
if err := decodeWorkflowCallMapping(val, spec.Outputs); err != nil {
return nil, fmt.Errorf("parse workflow_call.outputs: %w", err)
}
}
}
for name, in := range spec.Inputs {
if in.Type == "" {
return nil, fmt.Errorf("workflow_call input %q is missing required field \"type\"", name)
}
switch in.Type {
case InputTypeString, InputTypeBoolean, InputTypeNumber:
default:
return nil, fmt.Errorf("workflow_call input %q has unsupported type %q", name, in.Type)
}
}
return spec, nil
}
// findWorkflowCallNode walks the "on:" node and returns the value mapping (or nil) for "workflow_call".
// "ok" is true when the workflow declares workflow_call (even with an empty body).
func findWorkflowCallNode(on *yaml.Node) (val *yaml.Node, ok bool) {
if on == nil || on.Kind == 0 {
return nil, false
}
switch on.Kind {
case yaml.ScalarNode:
return nil, on.Value == "workflow_call"
case yaml.SequenceNode:
for _, item := range on.Content {
if item.Kind == yaml.ScalarNode && item.Value == "workflow_call" {
return nil, true
}
}
return nil, false
case yaml.MappingNode:
for i := 0; i+1 < len(on.Content); i += 2 {
k := on.Content[i]
v := on.Content[i+1]
if k.Value != "workflow_call" {
continue
}
if v.Kind == yaml.MappingNode {
return v, true
}
return nil, true
}
}
return nil, false
}
func decodeWorkflowCallMapping[T any](node *yaml.Node, dst map[string]T) error {
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(node.Content); i += 2 {
name := node.Content[i].Value
var v T
if err := node.Content[i+1].Decode(&v); err != nil {
return fmt.Errorf("%q: %w", name, err)
}
dst[name] = v
}
return nil
}
// EvaluateCallerWith evaluates the caller-side expressions in `job.With` against the provided contexts
func EvaluateCallerWith(
// ResolveCallerInputs evaluates the caller's `with` against its own contexts and types it by the called workflow's inputs.
func ResolveCallerInputs(
jobID string,
job *Job,
config *model.WorkflowCall,
gitCtx map[string]any,
results map[string]*JobResult,
vars map[string]string,
inputs map[string]any,
) (map[string]any, error) {
actJob := &model.Job{Strategy: &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
}}
var matrix map[string]any
matrixes, err := matrixesOf(actJob)
if job.With.Kind != 0 && job.With.Kind != yaml.MappingNode {
return nil, errors.New("caller with must be a mapping")
}
evaluator, err := newJobEvaluator(jobID, job, gitCtx, results, vars, inputs)
if err != nil {
return nil, fmt.Errorf("get caller %q matrix: %w", jobID, err)
}
if len(matrixes) > 0 {
matrix = matrixes[0]
var with map[string]any
if err := model.DecodeEvaluated("with", job.With, evaluator.EvaluateYamlNode, &with); err != nil {
return nil, err
}
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
out := make(map[string]any, len(job.With))
for k, raw := range job.With {
var evaluated any
switch v := raw.(type) {
case string:
node := yaml.Node{}
if err := node.Encode(v); err != nil {
return nil, fmt.Errorf("encode caller %q with[%q]: %w", jobID, k, err)
}
if err := evaluator.EvaluateYamlNode(&node); err != nil {
return nil, fmt.Errorf("evaluate caller %q with[%q]: %w", jobID, k, err)
}
if err := node.Decode(&evaluated); err != nil {
return nil, fmt.Errorf("decode caller %q with[%q]: %w", jobID, k, err)
}
default:
evaluated = v
}
out[k] = evaluated
declared := make(map[string]model.WorkflowCallInput, len(config.Inputs))
for name, input := range config.Inputs {
declared[strings.ToLower(name)] = input
}
return out, nil
}
// MatchCallerInputsAgainstSpec checks the caller's already-evaluated `with:` values against the callee's declared `on.workflow_call.inputs` schema
func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]any) (map[string]any, error) {
resolved := make(map[string]any, len(spec.Inputs))
// fill defaults first
for name, in := range spec.Inputs {
if in.Default.IsZero() {
continue
}
var defaultVal any
if err := in.Default.Decode(&defaultVal); err != nil {
return nil, fmt.Errorf("decode workflow_call input %q default: %w", name, err)
}
v, err := parseWorkflowCallInput(name, in.Type, defaultVal)
if err != nil {
return nil, err
}
resolved[name] = v
}
for k, raw := range evaluated {
inputSpec, ok := spec.Inputs[k]
for name, value := range with {
input, ok := declared[strings.ToLower(name)]
if !ok {
// ignore unknown "with:" keys
continue
continue // ignored as in released Gitea, github.com rejects an undeclared input
}
converted, err := parseWorkflowCallInput(k, inputSpec.Type, raw)
if err != nil {
return nil, err
switch input.Type {
case "boolean":
if _, ok := value.(bool); !ok {
return nil, fmt.Errorf("input %s: expected a boolean, got %T", name, value)
}
case "number":
switch value.(type) {
case float64, int, int64, uint64:
default:
return nil, fmt.Errorf("input %s: expected a number, got %T", name, value)
}
}
resolved[k] = converted
}
for name, in := range spec.Inputs {
if !in.Required {
continue
}
// resolved[name] is set when caller provided it OR when spec has a non-zero default - both satisfy "required".
if _, ok := resolved[name]; ok {
continue
}
return nil, fmt.Errorf("workflow_call input %q is required", name)
}
return resolved, nil
return evaluator.ResolveWorkflowCallInputs(config, with)
}
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
switch typ {
case InputTypeString:
return exprparser.CoerceToString(v), nil
case InputTypeBoolean:
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
if b, ok := v.(bool); ok {
return b, nil
}
return false, fmt.Errorf("workflow_call input %q expects boolean", name)
case InputTypeNumber:
// strict type matching: a number input rejects "123"/"3.14" strings.
if _, isString := v.(string); isString {
return 0.0, fmt.Errorf("workflow_call input %q expects number", name)
}
return util.ToFloat64(v)
default:
return nil, fmt.Errorf("workflow_call input %q has unsupported type %q", name, typ)
// ParseWorkflowCallConfig reads the workflow_call configuration of a called workflow, decoding only its `on`.
func ParseWorkflowCallConfig(content []byte) (*model.WorkflowCall, error) {
var workflow struct {
RawOn yaml.Node `yaml:"on"`
}
if err := decodeResolved(content, &workflow); err != nil {
return nil, err
}
return (&model.Workflow{RawOn: workflow.RawOn}).ParseWorkflowCallConfig()
}
// SecretsInherit is the literal keyword used in a caller's `secrets: inherit` directive
@@ -317,60 +109,3 @@ func ParseCallerSecrets(node yaml.Node) (inherit bool, mapping map[string]string
}
return false, out, nil
}
// ValidateCallerSecrets checks a caller's parsed explicit-mapping `secrets:` against the called workflow's declared `on.workflow_call.secrets` schema.
func ValidateCallerSecrets(spec *WorkflowCallSpec, mapping map[string]string) error {
if spec == nil {
return errors.New("ValidateCallerSecrets: nil workflow_call spec")
}
// Secret names are case-insensitive, so compare declared names and caller aliases upper-cased.
declaredNames := make(container.Set[string], len(spec.Secrets))
for name := range spec.Secrets {
declaredNames.Add(strings.ToUpper(name))
}
provided := make(container.Set[string], len(mapping))
for alias := range mapping {
up := strings.ToUpper(alias)
provided.Add(up)
if !declaredNames.Contains(up) {
return fmt.Errorf("caller secret %q is not declared in the called workflow's on.workflow_call.secrets", alias)
}
}
for name, sec := range spec.Secrets {
if sec.Required && !provided.Contains(strings.ToUpper(name)) {
return fmt.Errorf("required secret %q is not provided by the caller", name)
}
}
return nil
}
// EvaluateWorkflowCallOutputs evaluates a called workflow's "on.workflow_call.outputs.<name>.value" expressions against the provided contexts.
func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubContext, vars map[string]string, inputs map[string]any, jobOutputs JobOutputs) (map[string]string, error) {
if spec == nil || len(spec.Outputs) == 0 {
return map[string]string{}, nil
}
jobsCtx := make(map[string]*model.WorkflowCallResult, len(jobOutputs))
for jobID, outputs := range jobOutputs {
jobsCtx[jobID] = &model.WorkflowCallResult{Outputs: outputs}
}
// See `on.workflow_call.outputs.<output_id>.value` in https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability
env := &exprparser.EvaluationEnvironment{
Github: gitCtx,
Jobs: &jobsCtx,
Vars: vars,
Inputs: inputs,
}
evaluator := expreval.New(exprparser.NewInterpeter(env, exprparser.Config{}).Evaluate)
out := make(map[string]string, len(spec.Outputs))
for name, o := range spec.Outputs {
v, err := evaluator.Interpolate(o.Value)
if err != nil {
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
}
out[name] = v
}
return out, nil
}
+42 -385
View File
@@ -4,7 +4,6 @@
package jobparser
import (
"maps"
"testing"
"gitea.dev/actionslib/pkg/model"
@@ -14,235 +13,51 @@ import (
"go.yaml.in/yaml/v4"
)
func TestParseWorkflowCallSpec(t *testing.T) {
t.Run("malformed YAML surfaces a parse error", func(t *testing.T) {
// Mismatched flow-sequence brackets — yaml.Unmarshal must reject this.
_, err := ParseWorkflowCallSpec([]byte(`name: bad
on: [workflow_call
jobs:
noop: { }
`))
require.Error(t, err)
})
t.Run("workflow without on.workflow_call is rejected", func(t *testing.T) {
notCallable := []byte(`name: ordinary
on: push
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo
`)
_, err := ParseWorkflowCallSpec(notCallable)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not declare on.workflow_call")
})
t.Run("input missing the required type field is rejected", func(t *testing.T) {
content := callableWorkflow(t, `inputs:
x:
description: missing type
`)
_, err := ParseWorkflowCallSpec(content)
require.Error(t, err)
assert.Contains(t, err.Error(), `missing required field "type"`)
})
t.Run("inputs/secrets/outputs are decoded", func(t *testing.T) {
content := callableWorkflow(t, `inputs:
env:
type: string
required: true
secrets:
DEPLOY_KEY:
required: true
outputs:
sha:
value: ${{ jobs.build.outputs.commit }}
`)
spec, err := ParseWorkflowCallSpec(content)
require.NoError(t, err)
assert.Equal(t, InputTypeString, spec.Inputs["env"].Type)
assert.True(t, spec.Inputs["env"].Required)
assert.True(t, spec.Secrets["DEPLOY_KEY"].Required)
assert.Equal(t, "${{ jobs.build.outputs.commit }}", spec.Outputs["sha"].Value)
})
}
func TestEvaluateCallerWith(t *testing.T) {
t.Run("empty with: returns empty map", func(t *testing.T) {
out, err := EvaluateCallerWith("caller", &Job{}, nil, callerResults("caller", nil, nil), nil, nil)
require.NoError(t, err)
assert.Empty(t, out)
})
t.Run("non-string raw values pass through unchanged", func(t *testing.T) {
job := &Job{With: map[string]any{
"already_bool": true,
"already_int": 42,
"already_slice": []any{"a", "b"},
}}
out, err := EvaluateCallerWith("caller", job, nil, callerResults("caller", nil, nil), nil, nil)
require.NoError(t, err)
assert.Equal(t, true, out["already_bool"])
assert.Equal(t, 42, out["already_int"])
assert.Equal(t, []any{"a", "b"}, out["already_slice"])
})
t.Run("expressions resolve against vars/inputs/results", func(t *testing.T) {
job := &Job{With: map[string]any{
"env_name": "${{ vars.ENV }}",
"from_inputs": "${{ inputs.PARENT_VAR }}",
"from_needs": "${{ needs.upstream.outputs.commit }}",
}}
gitCtx := map[string]any{"event": map[string]any{}}
results := callerResults("caller", []string{"upstream"}, map[string]*JobResult{
"upstream": {Result: "success", Outputs: map[string]string{"commit": "abc123"}},
})
vars := map[string]string{"ENV": "staging"}
inputs := map[string]any{"PARENT_VAR": "from-parent"}
out, err := EvaluateCallerWith("caller", job, gitCtx, results, vars, inputs)
require.NoError(t, err)
assert.Equal(t, "staging", out["env_name"])
assert.Equal(t, "from-parent", out["from_inputs"])
assert.Equal(t, "abc123", out["from_needs"])
})
t.Run("matrix.X resolves to this caller row's matrix instance", func(t *testing.T) {
var rawMatrix yaml.Node
require.NoError(t, rawMatrix.Encode(map[string][]any{"target": {"staging"}}))
job := &Job{
With: map[string]any{"env": "${{ matrix.target }}"},
Strategy: Strategy{RawMatrix: rawMatrix},
}
out, err := EvaluateCallerWith("caller", job, nil, callerResults("caller", nil, nil), nil, nil)
require.NoError(t, err)
assert.Equal(t, "staging", out["env"])
})
}
func TestMatchCallerInputsAgainstSpec(t *testing.T) {
// mustParseSpec wraps ParseWorkflowCallSpec for test brevity.
mustParseSpec := func(t *testing.T, content []byte) *WorkflowCallSpec {
t.Helper()
spec, err := ParseWorkflowCallSpec(content)
require.NoError(t, err)
return spec
func TestResolveCallerInputs(t *testing.T) {
config := &model.WorkflowCall{Inputs: map[string]model.WorkflowCallInput{
"env": {Type: "string"},
"from_inputs": {Type: "string"},
"from_needs": {Type: "string"},
"count": {Type: "number"},
"index": {Type: "number"},
}}
results := map[string]*JobResult{
"caller": {Needs: []string{"upstream"}},
"upstream": {Result: "success", Outputs: map[string]string{"commit": "abc123"}},
}
var job Job
require.NoError(t, yaml.Unmarshal([]byte(`strategy: {matrix: {target: [staging]}, job-index: 1, job-total: 2}
with: {env: "${{ matrix.target }}", from_inputs: "${{ inputs.PARENT_VAR }}", from_needs: "${{ needs.upstream.outputs.commit }}", count: 42, index: "${{ strategy.job-index }}"}`), &job))
out, err := ResolveCallerInputs("caller", &job, config, map[string]any{"event": map[string]any{}}, results,
nil, map[string]any{"PARENT_VAR": "from-parent"})
require.NoError(t, err)
assert.Equal(t, map[string]any{
"env": "staging", "from_inputs": "from-parent", "from_needs": "abc123", "count": 42.0, "index": 1.0,
}, out)
t.Run("default is filled when caller does not provide the input", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
greeting:
type: string
default: hi
`))
out, err := MatchCallerInputsAgainstSpec(spec, nil)
for _, tt := range []struct {
with string
inputs map[string]model.WorkflowCallInput
want map[string]any
wantErr bool
}{
{with: "with: {EXTRA: value}", want: map[string]any{}},
{with: `with: ${{ fromJSON('{"env":"prod"}') }}`, wantErr: true},
{inputs: map[string]model.WorkflowCallInput{"required": {Type: "string", Required: true, Default: "fallback"}}, want: map[string]any{"required": "fallback"}},
{inputs: map[string]model.WorkflowCallInput{"derived": {Type: "string", Default: "${{ inputs.PARENT_VAR }}"}}, want: map[string]any{"derived": "from-parent"}},
{with: "with: {value: 'false'}", inputs: map[string]model.WorkflowCallInput{"value": {Type: "boolean"}}, wantErr: true},
{with: `with: {value: "${{ '5' }}"}`, inputs: map[string]model.WorkflowCallInput{"value": {Type: "number"}}, wantErr: true},
} {
job = Job{}
require.NoError(t, yaml.Unmarshal([]byte(tt.with), &job))
out, err := ResolveCallerInputs("caller", &job, &model.WorkflowCall{Inputs: tt.inputs}, nil, nil, nil, map[string]any{"PARENT_VAR": "from-parent"})
if tt.wantErr {
require.Error(t, err, tt.with)
continue
}
require.NoError(t, err)
assert.Equal(t, map[string]any{"greeting": "hi"}, out)
})
t.Run("caller-provided value wins over default", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
greeting:
type: string
default: hi
`))
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"greeting": "hello"})
require.NoError(t, err)
assert.Equal(t, map[string]any{"greeting": "hello"}, out)
})
t.Run("required input must be provided", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
target:
type: string
required: true
`))
_, err := MatchCallerInputsAgainstSpec(spec, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), `"target" is required`)
})
t.Run("required input is satisfied by a default value", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
target:
type: string
required: true
default: prod
`))
out, err := MatchCallerInputsAgainstSpec(spec, nil)
require.NoError(t, err)
assert.Equal(t, map[string]any{"target": "prod"}, out)
})
t.Run("boolean inputs accept native bool values and bool defaults", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
flag1:
type: boolean
flag2:
type: boolean
default: true
flag3:
type: boolean
`))
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{
"flag1": true,
"flag3": false,
})
require.NoError(t, err)
assert.Equal(t, true, out["flag1"])
assert.Equal(t, true, out["flag2"]) // from default
assert.Equal(t, false, out["flag3"])
})
t.Run("boolean input rejects strings", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
flag:
type: boolean
`))
_, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"flag": "true"})
require.Error(t, err)
assert.Contains(t, err.Error(), "expects boolean")
})
t.Run("number inputs accept native numeric values and number defaults", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
count:
type: number
ratio:
type: number
default: 0.5
`))
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"count": 42})
require.NoError(t, err)
assert.InDelta(t, 42.0, out["count"], 0)
assert.InDelta(t, 0.5, out["ratio"], 0)
})
t.Run("number input rejects strings", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
count:
type: number
`))
_, err := MatchCallerInputsAgainstSpec(spec, map[string]any{"count": "42"})
require.Error(t, err)
assert.Contains(t, err.Error(), "expects number")
})
t.Run("unknown caller-with key is silently dropped", func(t *testing.T) {
spec := mustParseSpec(t, callableWorkflow(t, `inputs:
known:
type: string
default: ok
`))
out, err := MatchCallerInputsAgainstSpec(spec, map[string]any{
"known": "yes",
"unknown": "ignored",
})
require.NoError(t, err)
assert.Equal(t, map[string]any{"known": "yes"}, out)
})
assert.Equal(t, tt.want, out)
}
}
func TestParseCallerSecrets(t *testing.T) {
@@ -312,161 +127,3 @@ deploy_key: ${{ secrets.gitea_deploy_key }}
assert.Contains(t, err.Error(), `must be of the form ${{ secrets.NAME }}`)
})
}
func TestValidateCallerSecrets(t *testing.T) {
specWith := func(secrets map[string]SecretSpec) *WorkflowCallSpec {
return &WorkflowCallSpec{Secrets: secrets}
}
t.Run("explicit mapping with all required + only declared aliases is accepted", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{
"DEPLOY_KEY": {Required: true},
"OPTIONAL": {},
})
mapping := map[string]string{
"DEPLOY_KEY": "PROD_DEPLOY_KEY",
"OPTIONAL": "SOMETHING_ELSE",
}
require.NoError(t, ValidateCallerSecrets(spec, mapping))
})
t.Run("alias not in callee schema is rejected", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{"DEPLOY_KEY": {}})
mapping := map[string]string{
"DEPLOY_KEY": "PROD_DEPLOY_KEY",
"EXTRA": "SOMETHING_NOT_DECLARED",
}
err := ValidateCallerSecrets(spec, mapping)
require.Error(t, err)
assert.Contains(t, err.Error(), `caller secret "EXTRA"`)
assert.Contains(t, err.Error(), `not declared`)
})
t.Run("missing required secret is rejected", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{
"MUST_HAVE": {Required: true},
"OPTIONAL": {},
})
mapping := map[string]string{"OPTIONAL": "X"}
err := ValidateCallerSecrets(spec, mapping)
require.Error(t, err)
assert.Contains(t, err.Error(), `required secret "MUST_HAVE"`)
assert.Contains(t, err.Error(), `not provided`)
})
t.Run("callee with no secrets schema accepts an empty mapping", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{})
require.NoError(t, ValidateCallerSecrets(spec, nil))
require.NoError(t, ValidateCallerSecrets(spec, map[string]string{}))
})
t.Run("callee with no secrets schema rejects a non-empty mapping", func(t *testing.T) {
spec := specWith(map[string]SecretSpec{})
err := ValidateCallerSecrets(spec, map[string]string{"X": "Y"})
require.Error(t, err)
assert.Contains(t, err.Error(), `caller secret "X"`)
})
t.Run("name matching is case-insensitive", func(t *testing.T) {
// declared name and caller alias differ only in case; both should match.
spec := specWith(map[string]SecretSpec{"deploy_key": {Required: true}})
mapping := map[string]string{"DEPLOY_KEY": "PROD_DEPLOY_KEY"}
require.NoError(t, ValidateCallerSecrets(spec, mapping))
})
t.Run("nil spec is rejected", func(t *testing.T) {
err := ValidateCallerSecrets(nil, map[string]string{"X": "Y"})
require.Error(t, err)
assert.Contains(t, err.Error(), "nil workflow_call spec")
})
}
func TestEvaluateWorkflowCallOutputs(t *testing.T) {
t.Run("nil spec returns empty map", func(t *testing.T) {
out, err := EvaluateWorkflowCallOutputs(nil, &model.GithubContext{}, nil, nil, nil)
require.NoError(t, err)
assert.Empty(t, out)
})
t.Run("spec with no outputs returns empty map", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{}}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil)
require.NoError(t, err)
assert.Empty(t, out)
})
t.Run("plain string value passes through unchanged", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"name": {Value: "static-value"},
}}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil)
require.NoError(t, err)
assert.Equal(t, map[string]string{"name": "static-value"}, out)
})
t.Run("output references jobs.<id>.outputs.<name>", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"sha": {Value: "${{ jobs.build.outputs.commit }}"},
}}
jobOutputs := JobOutputs{
"build": {"commit": "deadbeef"},
}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, jobOutputs)
require.NoError(t, err)
assert.Equal(t, "deadbeef", out["sha"])
})
t.Run("output references inputs.<name>", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"target": {Value: "${{ inputs.env_name }}"},
}}
inputs := map[string]any{"env_name": "staging"}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, inputs, nil)
require.NoError(t, err)
assert.Equal(t, "staging", out["target"])
})
t.Run("multiple outputs are all evaluated", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"static": {Value: "static-value"},
"dynamic": {Value: "${{ vars.SUFFIX }}"},
}}
vars := map[string]string{"SUFFIX": "abc"}
out, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, vars, nil, nil)
require.NoError(t, err)
assert.Equal(t, "static-value", out["static"])
assert.Equal(t, "abc", out["dynamic"])
})
t.Run("expression referencing an undefined symbol surfaces an error", func(t *testing.T) {
spec := &WorkflowCallSpec{Outputs: map[string]OutputSpec{
"bad": {Value: "${{ this.is.not.valid() }}"},
}}
_, err := EvaluateWorkflowCallOutputs(spec, &model.GithubContext{}, nil, nil, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), `output "bad"`)
})
}
// callableWorkflow returns a minimal valid called-workflow YAML with on.workflow_call.
func callableWorkflow(t *testing.T, body string) []byte {
t.Helper()
return []byte(`name: callable
on:
workflow_call:
` + body + `
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: "echo"
`)
}
// callerResults returns the minimum results map shape that NewInterpeter expects
func callerResults(callerJobID string, callerNeeds []string, deps map[string]*JobResult) map[string]*JobResult {
out := make(map[string]*JobResult, len(deps)+1)
maps.Copy(out, deps)
out[callerJobID] = &JobResult{Needs: callerNeeds}
return out
}
+22 -17
View File
@@ -4,6 +4,7 @@
package actions
import (
"bytes"
"context"
"fmt"
"path"
@@ -120,19 +121,19 @@ func GetContentFromEntry(ctx context.Context, gitRepo *git.Repository, entry *gi
}
func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
workflow, err := jobparser.ReadWorkflow(content)
if err != nil {
return nil, err
}
events, err := jobparser.ParseRawOn(&workflow.RawOn)
if err != nil {
return nil, err
}
if err := ValidateWorkflowContent(content); err != nil {
return nil, err
}
events, _, err := readWorkflowEvents(content)
return events, err
}
return events, nil
// readWorkflowEvents also reports whether its error needs no run-time values to find, as a parse error of a workflow without expressions does.
func readWorkflowEvents(content []byte) (events []*jobparser.Event, static bool, err error) {
if events, err = jobparser.ValidateWorkflowStatic(content); err != nil {
return nil, true, err
}
if err = ValidateWorkflowContent(content); err != nil {
return nil, !bytes.Contains(content, []byte("${{")), err
}
return events, false, nil
}
// ValidateWorkflowContent catches structural errors (e.g. blank lines in run: | blocks)
@@ -183,22 +184,26 @@ func DetectWorkflows(
triggedEvent webhook_module.HookEventType,
payload api.Payloader,
detectSchedule bool,
) (workflows, schedules, filtered []*DetectedWorkflow, err error) {
) (workflows, schedules, filtered []*DetectedWorkflow, invalid map[string]error, err error) {
_, entries, err := ListWorkflows(ctx, gitRepo, commit)
if err != nil {
return nil, nil, nil, err
return nil, nil, nil, nil, err
}
invalid = map[string]error{}
for _, entry := range entries {
content, err := GetContentFromEntry(ctx, gitRepo, entry)
if err != nil {
return nil, nil, nil, err
return nil, nil, nil, nil, err
}
// one workflow may have multiple events
events, err := GetEventsFromContent(content)
events, static, err := readWorkflowEvents(content)
if err != nil {
log.Warn("ignore invalid workflow %q: %v", entry.Name(), err)
if static {
invalid[entry.Name()] = err
}
continue
}
for _, evt := range events {
@@ -231,7 +236,7 @@ func DetectWorkflows(
}
}
return workflows, schedules, filtered, nil
return workflows, schedules, filtered, invalid, nil
}
func DetectScheduledWorkflows(ctx context.Context, gitRepo *git.Repository, commit *git.Commit) ([]*DetectedWorkflow, error) {
+20
View File
@@ -13,6 +13,7 @@ import (
webhook_module "gitea.dev/modules/webhook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func fullWorkflowContent(part string) []byte {
@@ -27,6 +28,25 @@ jobs:
`)
}
func TestReadWorkflowEventsStaticErrors(t *testing.T) {
for content, static := range map[string]bool{
"on: push\njobs: {}": true,
"on: push\njobs: {test: {needs: absent}}": true,
"on: push\njobs: {one: {needs: two}, two: {needs: one}}": true,
"on: push\njobs: {test: {strategy: {matrix: {os: []}}}}": true,
"on: push\nrun-name: ${{ secrets.TOKEN }}\njobs: {test: {}}": true,
"on: push\nrun-name: ${{ fromJSON(inputs.x) }}\njobs: {test: {steps: [{run: echo}]}}": false,
} {
_, gotStatic, err := readWorkflowEvents([]byte(content))
require.Error(t, err, content)
assert.Equal(t, static, gotStatic, content)
}
for _, content := range []string{"on: push\njobs: {test: {steps: [{run: echo}]}}", "on: push\nrun-name: ${{ github.ref }}\njobs: {test: {}}"} {
_, _, err := readWorkflowEvents([]byte(content))
assert.NoError(t, err, content)
}
}
func TestIsWorkflow(t *testing.T) {
defer test.MockVariableValue(&setting.Actions.WorkflowDirs)()