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