初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// ExpressionEvaluator is copied from runner.expressionEvaluator,
|
||||
// to avoid unnecessary dependencies
|
||||
type ExpressionEvaluator struct {
|
||||
interpreter exprparser.Interpreter
|
||||
}
|
||||
|
||||
func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvaluator {
|
||||
return &ExpressionEvaluator{interpreter: interpreter}
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
||||
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
|
||||
|
||||
return evaluated, err
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
||||
var in string
|
||||
if err := node.Decode(&in); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return nil
|
||||
}
|
||||
expr, _ := rewriteSubExpression(in, false)
|
||||
res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return node.Encode(res)
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
|
||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
||||
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||
for i := 0; i < len(node.Content)/2; {
|
||||
k := node.Content[i*2]
|
||||
v := node.Content[i*2+1]
|
||||
if err := ee.EvaluateYamlNode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
var sk string
|
||||
// Merge the nested map of the insert directive
|
||||
if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
|
||||
node.Content = append(append(node.Content[:i*2], v.Content...), node.Content[(i+1)*2:]...)
|
||||
i += len(v.Content) / 2
|
||||
} else {
|
||||
if err := ee.EvaluateYamlNode(k); err != nil {
|
||||
return err
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateSequenceYamlNode(node *yaml.Node) error {
|
||||
for i := 0; i < len(node.Content); {
|
||||
v := node.Content[i]
|
||||
// Preserve nested sequences
|
||||
wasseq := v.Kind == yaml.SequenceNode
|
||||
if err := ee.EvaluateYamlNode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
// GitHub has this undocumented feature to merge sequences / arrays
|
||||
// We have a nested sequence via evaluation, merge the arrays
|
||||
if v.Kind == yaml.SequenceNode && !wasseq {
|
||||
node.Content = append(append(node.Content[:i], v.Content...), node.Content[i+1:]...)
|
||||
i += len(v.Content)
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
return ee.evaluateScalarYamlNode(node)
|
||||
case yaml.MappingNode:
|
||||
return ee.evaluateMappingYamlNode(node)
|
||||
case yaml.SequenceNode:
|
||||
return ee.evaluateSequenceYamlNode(node)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) Interpolate(in string) string {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return in
|
||||
}
|
||||
|
||||
expr, _ := rewriteSubExpression(in, true)
|
||||
evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, ok := evaluated.(string)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func escapeFormatString(in string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
||||
}
|
||||
|
||||
func rewriteSubExpression(in string, forceFormat bool) (string, error) {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return in, nil
|
||||
}
|
||||
|
||||
strPattern := regexp.MustCompile("(?:''|[^'])*'")
|
||||
pos := 0
|
||||
exprStart := -1
|
||||
strStart := -1
|
||||
var results []string
|
||||
var formatOut strings.Builder
|
||||
for pos < len(in) {
|
||||
if strStart > -1 {
|
||||
matches := strPattern.FindStringIndex(in[pos:])
|
||||
if matches == nil {
|
||||
return "", errors.New("unclosed string")
|
||||
}
|
||||
|
||||
strStart = -1
|
||||
pos += matches[1]
|
||||
} else if exprStart > -1 {
|
||||
exprEnd := strings.Index(in[pos:], "}}")
|
||||
strStart = strings.Index(in[pos:], "'")
|
||||
|
||||
if exprEnd > -1 && strStart > -1 {
|
||||
if exprEnd < strStart {
|
||||
strStart = -1
|
||||
} else {
|
||||
exprEnd = -1
|
||||
}
|
||||
}
|
||||
|
||||
if exprEnd > -1 {
|
||||
fmt.Fprintf(&formatOut, "{%d}", len(results))
|
||||
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
|
||||
pos += exprEnd + 2
|
||||
exprStart = -1
|
||||
} else if strStart > -1 {
|
||||
pos += strStart + 1
|
||||
} else {
|
||||
panic("unclosed expression.")
|
||||
}
|
||||
} else {
|
||||
exprStart = strings.Index(in[pos:], "${{")
|
||||
if exprStart != -1 {
|
||||
formatOut.WriteString(escapeFormatString(in[pos : pos+exprStart]))
|
||||
exprStart = pos + exprStart + 3
|
||||
pos = exprStart
|
||||
} else {
|
||||
formatOut.WriteString(escapeFormatString(in[pos:]))
|
||||
pos = len(in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat {
|
||||
return in, nil
|
||||
}
|
||||
|
||||
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut.String(), "'", "''"), strings.Join(results, ", "))
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// NewInterpeter returns an interpeter used in the server,
|
||||
// need github, needs, strategy, matrix, inputs context only,
|
||||
// see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability
|
||||
func NewInterpeter(
|
||||
jobID string,
|
||||
job *model.Job,
|
||||
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.FailFast
|
||||
strategy["max-parallel"] = job.Strategy.MaxParallel
|
||||
}
|
||||
|
||||
run := &model.Run{
|
||||
Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{},
|
||||
},
|
||||
JobID: jobID,
|
||||
}
|
||||
for id, result := range results {
|
||||
need := yaml.Node{}
|
||||
_ = need.Encode(result.Needs)
|
||||
run.Workflow.Jobs[id] = &model.Job{
|
||||
RawNeeds: need,
|
||||
Result: result.Result,
|
||||
Outputs: result.Outputs,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
Job: nil, // no need
|
||||
Steps: nil, // no need
|
||||
Runner: nil, // no need
|
||||
Secrets: nil, // no need
|
||||
Strategy: strategy,
|
||||
Matrix: matrix,
|
||||
Needs: using,
|
||||
Inputs: inputs,
|
||||
Vars: vars,
|
||||
}
|
||||
|
||||
config := exprparser.Config{
|
||||
Run: run,
|
||||
WorkingDir: "", // WorkingDir is used for the function hashFiles, but it's not needed in the server
|
||||
Context: "job",
|
||||
}
|
||||
|
||||
return exprparser.NewInterpeter(ee, config)
|
||||
}
|
||||
|
||||
// JobResult is the minimum requirement of job results for Interpeter
|
||||
type JobResult struct {
|
||||
Needs []string
|
||||
Result string
|
||||
Outputs map[string]string
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
origin, err := model.ReadWorkflow(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model.ReadWorkflow: %w", err)
|
||||
}
|
||||
|
||||
workflow := &SingleWorkflow{}
|
||||
if err := yaml.Unmarshal(content, workflow); err != nil {
|
||||
return nil, fmt.Errorf("yaml.Unmarshal: %w", err)
|
||||
}
|
||||
|
||||
pc := &parseContext{}
|
||||
for _, o := range options {
|
||||
o(pc)
|
||||
}
|
||||
results := map[string]*JobResult{}
|
||||
for id, job := range origin.Jobs {
|
||||
if job == nil {
|
||||
return nil, fmt.Errorf("needed job not found: %q", id)
|
||||
}
|
||||
results[id] = &JobResult{
|
||||
Needs: job.Needs(),
|
||||
Result: pc.jobResults[id],
|
||||
Outputs: nil, // not supported yet
|
||||
}
|
||||
}
|
||||
|
||||
var ret []*SingleWorkflow
|
||||
ids, jobs, err := workflow.jobs()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid jobs: %w", err)
|
||||
}
|
||||
|
||||
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}))
|
||||
workflow.RunName = evaluator.Interpolate(workflow.RunName)
|
||||
|
||||
for i, id := range ids {
|
||||
job := jobs[i]
|
||||
matricxes, err := getMatrixes(origin.GetJob(id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getMatrixes: %w", err)
|
||||
}
|
||||
for _, matrix := range matricxes {
|
||||
job := job.Clone()
|
||||
if job.Name == "" {
|
||||
job.Name = id
|
||||
}
|
||||
job.Strategy.RawMatrix = encodeMatrix(matrix)
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs))
|
||||
job.Name = nameWithMatrix(job.Name, matrix, evaluator)
|
||||
runsOn := origin.GetJob(id).RunsOn()
|
||||
for i, v := range runsOn {
|
||||
runsOn[i] = evaluator.Interpolate(v)
|
||||
}
|
||||
job.RawRunsOn = encodeRunsOn(runsOn)
|
||||
swf := &SingleWorkflow{
|
||||
Name: workflow.Name,
|
||||
RawOn: workflow.RawOn,
|
||||
Env: workflow.Env,
|
||||
Defaults: workflow.Defaults,
|
||||
RawPermissions: workflow.RawPermissions,
|
||||
RunName: workflow.RunName,
|
||||
}
|
||||
if err := swf.SetJob(id, job); err != nil {
|
||||
return nil, fmt.Errorf("SetJob: %w", err)
|
||||
}
|
||||
ret = append(ret, swf)
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func WithGitContext(context *model.GithubContext) ParseOption {
|
||||
return func(c *parseContext) {
|
||||
c.gitContext = context
|
||||
}
|
||||
}
|
||||
|
||||
func WithVars(vars map[string]string) ParseOption {
|
||||
return func(c *parseContext) {
|
||||
c.vars = vars
|
||||
}
|
||||
}
|
||||
|
||||
func WithInputs(inputs map[string]any) ParseOption {
|
||||
return func(c *parseContext) {
|
||||
c.inputs = inputs
|
||||
}
|
||||
}
|
||||
|
||||
type parseContext struct {
|
||||
jobResults map[string]string
|
||||
gitContext *model.GithubContext
|
||||
vars map[string]string
|
||||
inputs map[string]any
|
||||
}
|
||||
|
||||
type ParseOption func(c *parseContext)
|
||||
|
||||
func getMatrixes(job *model.Job) ([]map[string]any, error) {
|
||||
ret, err := job.GetMatrixes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetMatrixes: %w", 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{}
|
||||
}
|
||||
value := map[string][]any{}
|
||||
for k, v := range matrix {
|
||||
value[k] = []any{v}
|
||||
}
|
||||
node := yaml.Node{}
|
||||
_ = node.Encode(value)
|
||||
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 *ExpressionEvaluator) string {
|
||||
if len(m) == 0 {
|
||||
return name
|
||||
}
|
||||
|
||||
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
|
||||
return name + " " + matrixName(m)
|
||||
}
|
||||
|
||||
return evaluator.Interpolate(name)
|
||||
}
|
||||
|
||||
func matrixName(m map[string]any) string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
vs := make([]string, 0, len(m))
|
||||
for _, v := range ks {
|
||||
vs = append(vs, fmt.Sprint(m[v]))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("(%s)", strings.Join(vs, ", "))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options []ParseOption
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "multiple_jobs",
|
||||
options: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple_matrix",
|
||||
options: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "has_needs",
|
||||
options: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "has_with",
|
||||
options: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "has_secrets",
|
||||
options: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty_step",
|
||||
options: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "job_name_with_matrix",
|
||||
options: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "prefixed_newline",
|
||||
options: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
invalidFileTests := []struct {
|
||||
name string
|
||||
}{
|
||||
{name: "null_job_implicit"},
|
||||
{name: "null_job_explicit"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
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...)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := &strings.Builder{}
|
||||
for _, v := range got {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteString("---\n")
|
||||
}
|
||||
encoder := yaml.NewEncoder(builder)
|
||||
encoder.SetIndent(2)
|
||||
require.NoError(t, encoder.Encode(v))
|
||||
id, job := v.Job()
|
||||
assert.NotEmpty(t, id)
|
||||
assert.NotNil(t, job)
|
||||
}
|
||||
assert.Equal(t, string(want), builder.String())
|
||||
})
|
||||
}
|
||||
|
||||
for _, tt := range invalidFileTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
content := ReadTestdata(t, tt.name+".in.yaml")
|
||||
require.NotPanics(t, func() {
|
||||
_, err := Parse(content)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"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"`
|
||||
}
|
||||
|
||||
func (w *SingleWorkflow) Job() (string, *Job) {
|
||||
ids, jobs, _ := w.jobs()
|
||||
if len(ids) >= 1 {
|
||||
return ids[0], jobs[0]
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (w *SingleWorkflow) jobs() ([]string, []*Job, error) {
|
||||
ids, jobs, err := parseMappingNode[*Job](&w.RawJobs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
steps := make([]*Step, 0, len(job.Steps))
|
||||
for _, s := range job.Steps {
|
||||
if s != nil {
|
||||
steps = append(steps, s)
|
||||
}
|
||||
}
|
||||
job.Steps = steps
|
||||
}
|
||||
|
||||
return ids, jobs, nil
|
||||
}
|
||||
|
||||
func (w *SingleWorkflow) SetJob(id string, job *Job) error {
|
||||
m := map[string]*Job{
|
||||
id: job,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&buf)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(m); err != nil {
|
||||
return err
|
||||
}
|
||||
encoder.Close()
|
||||
|
||||
node := yaml.Node{}
|
||||
if err := yaml.Unmarshal(buf.Bytes(), &node); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("can not set job: %s", buf.String())
|
||||
}
|
||||
w.RawJobs = *node.Content[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *SingleWorkflow) Marshal() ([]byte, error) {
|
||||
return yaml.Marshal(w)
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (j *Job) Clone() *Job {
|
||||
if j == nil {
|
||||
return nil
|
||||
}
|
||||
return &Job{
|
||||
Name: j.Name,
|
||||
RawNeeds: j.RawNeeds,
|
||||
RawRunsOn: j.RawRunsOn,
|
||||
Env: j.Env,
|
||||
If: j.If,
|
||||
Steps: j.Steps,
|
||||
TimeoutMinutes: j.TimeoutMinutes,
|
||||
Services: j.Services,
|
||||
Strategy: j.Strategy,
|
||||
RawContainer: j.RawContainer,
|
||||
Defaults: j.Defaults,
|
||||
Outputs: j.Outputs,
|
||||
Uses: j.Uses,
|
||||
With: j.With,
|
||||
RawSecrets: j.RawSecrets,
|
||||
RawConcurrency: j.RawConcurrency,
|
||||
RawPermissions: j.RawPermissions,
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Job) Needs() []string {
|
||||
return (&model.Job{RawNeeds: j.RawNeeds}).Needs()
|
||||
}
|
||||
|
||||
func (j *Job) EraseNeeds() *Job {
|
||||
j.RawNeeds = yaml.Node{}
|
||||
return j
|
||||
}
|
||||
|
||||
func (j *Job) RunsOn() []string {
|
||||
return (&model.Job{RawRunsOn: j.RawRunsOn}).RunsOn()
|
||||
}
|
||||
|
||||
type Step struct {
|
||||
ID string `yaml:"id,omitempty"`
|
||||
If yaml.Node `yaml:"if,omitempty"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Uses string `yaml:"uses,omitempty"`
|
||||
Run string `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"`
|
||||
ContinueOnError bool `yaml:"continue-on-error,omitempty"`
|
||||
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
|
||||
}
|
||||
|
||||
// String gets the name of step
|
||||
func (s *Step) String() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return (&model.Step{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Uses: s.Uses,
|
||||
Run: s.Run,
|
||||
}).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"`
|
||||
}
|
||||
|
||||
type Defaults struct {
|
||||
Run RunDefaults `yaml:"run,omitempty"`
|
||||
}
|
||||
|
||||
type RunDefaults struct {
|
||||
Shell string `yaml:"shell,omitempty"`
|
||||
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 {
|
||||
return evt.schedules != nil
|
||||
}
|
||||
|
||||
func (evt *Event) Acts() map[string][]string {
|
||||
return evt.acts
|
||||
}
|
||||
|
||||
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 := new(model.Workflow)
|
||||
err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w)
|
||||
return w.RawConcurrency, err
|
||||
}
|
||||
|
||||
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{}
|
||||
if job != nil {
|
||||
actJob.Strategy = &model.Strategy{
|
||||
FailFastString: job.Strategy.FailFastString,
|
||||
MaxParallelString: job.Strategy.MaxParallelString,
|
||||
RawMatrix: job.Strategy.RawMatrix,
|
||||
}
|
||||
actJob.Strategy.FailFast = actJob.Strategy.GetFailFast()
|
||||
actJob.Strategy.MaxParallel = actJob.Strategy.GetMaxParallel()
|
||||
}
|
||||
|
||||
matrix := make(map[string]any)
|
||||
matrixes, err := actJob.GetMatrixes()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if len(matrixes) > 0 {
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
var node yaml.Node
|
||||
if err := node.Encode(rc); err != nil {
|
||||
return "", false, fmt.Errorf("failed to encode concurrency: %w", err)
|
||||
}
|
||||
if err := evaluator.EvaluateYamlNode(&node); err != nil {
|
||||
return "", false, fmt.Errorf("failed to evaluate concurrency: %w", err)
|
||||
}
|
||||
var evaluated model.RawConcurrency
|
||||
if err := node.Decode(&evaluated); err != nil {
|
||||
return "", false, fmt.Errorf("failed to unmarshal evaluated concurrency: %w", err)
|
||||
}
|
||||
if evaluated.RawExpression != "" {
|
||||
return evaluated.RawExpression, false, nil
|
||||
}
|
||||
return evaluated.Group, evaluated.CancelInProgress == "true", 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"
|
||||
|
||||
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
||||
switch rawOn.Kind {
|
||||
case yaml.ScalarNode:
|
||||
var val string
|
||||
err := rawOn.Decode(&val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if val == workflowCallEvent {
|
||||
return []*Event{}, nil
|
||||
}
|
||||
return []*Event{
|
||||
{Name: val},
|
||||
}, nil
|
||||
case yaml.SequenceNode:
|
||||
var val []any
|
||||
err := rawOn.Decode(&val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make([]*Event, 0, len(val))
|
||||
for _, v := range val {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t == workflowCallEvent {
|
||||
continue
|
||||
}
|
||||
res = append(res, &Event{Name: t})
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid type %T", t)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
case yaml.MappingNode:
|
||||
events, triggers, err := parseMappingNode[yaml.Node](rawOn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make([]*Event, 0, len(events))
|
||||
for i, k := range events {
|
||||
if k == workflowCallEvent {
|
||||
continue
|
||||
}
|
||||
v := triggers[i]
|
||||
switch v.Kind {
|
||||
case yaml.ScalarNode:
|
||||
res = append(res, &Event{
|
||||
Name: k,
|
||||
})
|
||||
case yaml.SequenceNode:
|
||||
var t []any
|
||||
err := v.Decode(&t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
schedules := make([]map[string]string, len(t))
|
||||
if k == "schedule" {
|
||||
for i, tt := range t {
|
||||
vv, ok := tt.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown on type(schedule): %#v", v)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(schedules) == 0 {
|
||||
schedules = nil
|
||||
}
|
||||
res = append(res, &Event{
|
||||
Name: k,
|
||||
schedules: schedules,
|
||||
})
|
||||
case yaml.MappingNode:
|
||||
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)
|
||||
}
|
||||
act = ""
|
||||
err := content.Decode(&act)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
switch content.Kind {
|
||||
case yaml.SequenceNode:
|
||||
var t []string
|
||||
err := content.Decode(&t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acts[act] = t
|
||||
case yaml.ScalarNode:
|
||||
var t string
|
||||
err := content.Decode(&t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown on type: %#v", content)
|
||||
}
|
||||
}
|
||||
expectedKey = !expectedKey
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
inputs = nil
|
||||
}
|
||||
if len(acts) == 0 {
|
||||
acts = nil
|
||||
}
|
||||
res = append(res, &Event{
|
||||
Name: k,
|
||||
acts: acts,
|
||||
inputs: inputs,
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown on type: %v", v.Kind)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown on type: %v", rawOn.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (bool, error) {
|
||||
actJob := &model.Job{
|
||||
Strategy: &model.Strategy{
|
||||
FailFastString: job.Strategy.FailFastString,
|
||||
MaxParallelString: job.Strategy.MaxParallelString,
|
||||
RawMatrix: job.Strategy.RawMatrix,
|
||||
},
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, toGitContext(gitCtx), results, vars, inputs))
|
||||
expr, err := rewriteSubExpression(job.If.Value, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(result), nil
|
||||
}
|
||||
|
||||
// parseMappingNode parse a mapping node and preserve order.
|
||||
func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return nil, nil, errors.New("input node is not a mapping node")
|
||||
}
|
||||
|
||||
var scalars []string
|
||||
var datas []T
|
||||
expectKey := true
|
||||
for _, item := range node.Content {
|
||||
if expectKey {
|
||||
if item.Kind != yaml.ScalarNode {
|
||||
return nil, nil, fmt.Errorf("not a valid scalar node: %v", item.Value)
|
||||
}
|
||||
scalars = append(scalars, item.Value)
|
||||
expectKey = false
|
||||
} else {
|
||||
var val T
|
||||
if err := item.Decode(&val); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
datas = append(datas, val)
|
||||
expectKey = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(scalars) != len(datas) {
|
||||
return nil, nil, fmt.Errorf("invalid definition of on: %v", node.Value)
|
||||
}
|
||||
|
||||
return scalars, datas, nil
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
} else if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestParseRawOn(t *testing.T) {
|
||||
kases := []struct {
|
||||
input string
|
||||
result []*Event
|
||||
}{
|
||||
{
|
||||
input: "on: issue_comment",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "issue_comment",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n push",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "push",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
input: "on:\n - push\n - pull_request",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "push",
|
||||
},
|
||||
{
|
||||
Name: "pull_request",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n push:\n branches:\n - master",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "push",
|
||||
acts: map[string][]string{
|
||||
"branches": {
|
||||
"master",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n push:\n branches: main",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "push",
|
||||
acts: map[string][]string{
|
||||
"branches": {
|
||||
"main",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n branch_protection_rule:\n types: [created, deleted]",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "branch_protection_rule",
|
||||
acts: map[string][]string{
|
||||
"types": {
|
||||
"created",
|
||||
"deleted",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "project",
|
||||
acts: map[string][]string{
|
||||
"types": {
|
||||
"created",
|
||||
"deleted",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "milestone",
|
||||
acts: map[string][]string{
|
||||
"types": {
|
||||
"opened",
|
||||
"deleted",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "pull_request",
|
||||
acts: map[string][]string{
|
||||
"types": {
|
||||
"opened",
|
||||
},
|
||||
"branches": {
|
||||
"releases/**",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "push",
|
||||
acts: map[string][]string{
|
||||
"branches": {
|
||||
"main",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pull_request",
|
||||
acts: map[string][]string{
|
||||
"types": {
|
||||
"opened",
|
||||
},
|
||||
"branches": {
|
||||
"**",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n push:\n branches:\n - 'main'\n - 'releases/**'",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "push",
|
||||
acts: map[string][]string{
|
||||
"branches": {
|
||||
"main",
|
||||
"releases/**",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n push:\n tags:\n - v1.**",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "push",
|
||||
acts: map[string][]string{
|
||||
"tags": {
|
||||
"v1.**",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on: [pull_request, workflow_dispatch]",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "pull_request",
|
||||
},
|
||||
{
|
||||
Name: "workflow_dispatch",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n schedule:\n - cron: '20 6 * * *'",
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "schedule",
|
||||
schedules: []map[string]string{
|
||||
{
|
||||
"cron": "20 6 * * *",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
required: true
|
||||
default: 'warning'
|
||||
type: choice
|
||||
options:
|
||||
- info
|
||||
- warning
|
||||
- debug
|
||||
tags:
|
||||
description: 'Test scenario tags'
|
||||
required: false
|
||||
type: boolean
|
||||
environment:
|
||||
description: 'Environment to run tests against'
|
||||
type: environment
|
||||
required: true
|
||||
push:
|
||||
`,
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// `workflow_call` is only fired by another workflow's `uses:`, so ParseRawOn intentionally excludes it from trigger detection.
|
||||
input: `on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
env:
|
||||
type: string
|
||||
required: true
|
||||
outputs:
|
||||
sha:
|
||||
value: ${{ jobs.build.outputs.commit }}
|
||||
secrets:
|
||||
DEPLOY_KEY:
|
||||
required: true
|
||||
`,
|
||||
result: []*Event{},
|
||||
},
|
||||
{
|
||||
// Mixed: a workflow that is both callable AND triggered by push. Only the "push" event surfaces.
|
||||
input: `on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
env:
|
||||
type: string
|
||||
push:
|
||||
branches: [main]
|
||||
`,
|
||||
result: []*Event{
|
||||
{
|
||||
Name: "push",
|
||||
acts: map[string][]string{"branches": {"main"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Scalar form: a purely reusable workflow has no event triggers.
|
||||
input: "on: workflow_call",
|
||||
result: []*Event{},
|
||||
},
|
||||
{
|
||||
// Sequence form: `workflow_call` is excluded while sibling events are kept.
|
||||
input: "on:\n - push\n - workflow_call\n - pull_request",
|
||||
result: []*Event{
|
||||
{Name: "push"},
|
||||
{Name: "pull_request"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.input, func(t *testing.T) {
|
||||
origin, err := model.ReadWorkflow(strings.NewReader(kase.input))
|
||||
assert.NoError(t, err)
|
||||
|
||||
events, err := ParseRawOn(&origin.RawOn)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, kase.result, events, events)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleWorkflow_SetJob(t *testing.T) {
|
||||
t.Run("erase needs", func(t *testing.T) {
|
||||
content := ReadTestdata(t, "erase_needs.in.yaml")
|
||||
want := ReadTestdata(t, "erase_needs.out.yaml")
|
||||
swf, err := Parse(content)
|
||||
require.NoError(t, err)
|
||||
builder := &strings.Builder{}
|
||||
for _, v := range swf {
|
||||
id, job := v.Job()
|
||||
require.NoError(t, v.SetJob(id, job.EraseNeeds()))
|
||||
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteString("---\n")
|
||||
}
|
||||
encoder := yaml.NewEncoder(builder)
|
||||
encoder.SetIndent(2)
|
||||
require.NoError(t, encoder.Encode(v))
|
||||
}
|
||||
assert.Equal(t, string(want), builder.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseMappingNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
scalars []string
|
||||
datas []any
|
||||
}{
|
||||
{
|
||||
input: "on:\n push:\n branches:\n - master",
|
||||
scalars: []string{"push"},
|
||||
datas: []any{
|
||||
map[string]any{
|
||||
"branches": []any{"master"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n branch_protection_rule:\n types: [created, deleted]",
|
||||
scalars: []string{"branch_protection_rule"},
|
||||
datas: []any{
|
||||
map[string]any{
|
||||
"types": []any{"created", "deleted"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n project:\n types: [created, deleted]\n milestone:\n types: [opened, deleted]",
|
||||
scalars: []string{"project", "milestone"},
|
||||
datas: []any{
|
||||
map[string]any{
|
||||
"types": []any{"created", "deleted"},
|
||||
},
|
||||
map[string]any{
|
||||
"types": []any{"opened", "deleted"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n pull_request:\n types:\n - opened\n branches:\n - 'releases/**'",
|
||||
scalars: []string{"pull_request"},
|
||||
datas: []any{
|
||||
map[string]any{
|
||||
"types": []any{"opened"},
|
||||
"branches": []any{"releases/**"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n push:\n branches:\n - main\n pull_request:\n types:\n - opened\n branches:\n - '**'",
|
||||
scalars: []string{"push", "pull_request"},
|
||||
datas: []any{
|
||||
map[string]any{
|
||||
"branches": []any{"main"},
|
||||
},
|
||||
map[string]any{
|
||||
"types": []any{"opened"},
|
||||
"branches": []any{"**"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "on:\n schedule:\n - cron: '20 6 * * *'",
|
||||
scalars: []string{"schedule"},
|
||||
datas: []any{
|
||||
[]any{map[string]any{
|
||||
"cron": "20 6 * * *",
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader(test.input))
|
||||
assert.NoError(t, err)
|
||||
|
||||
scalars, datas, err := parseMappingNode[any](&workflow.RawOn)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, test.scalars, scalars, scalars)
|
||||
assert.Equal(t, test.datas, datas, datas)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: echo job-1
|
||||
-
|
||||
@@ -0,0 +1,7 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: echo job-1
|
||||
@@ -0,0 +1,16 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
job2:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
needs: job1
|
||||
job3:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
needs: [job1, job2]
|
||||
@@ -0,0 +1,23 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job2:
|
||||
name: job2
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job3:
|
||||
name: job3
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
@@ -0,0 +1,16 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
job2:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
needs: job1
|
||||
job3:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
needs: [job1, job2]
|
||||
@@ -0,0 +1,25 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job2:
|
||||
name: job2
|
||||
needs: job1
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job3:
|
||||
name: job3
|
||||
needs: [job1, job2]
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a
|
||||
@@ -0,0 +1,14 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
runs-on: linux
|
||||
uses: .gitea/workflows/build.yml
|
||||
secrets:
|
||||
secret: hideme
|
||||
|
||||
job2:
|
||||
name: job2
|
||||
runs-on: linux
|
||||
uses: .gitea/workflows/build.yml
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,16 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
runs-on: linux
|
||||
uses: .gitea/workflows/build.yml
|
||||
secrets:
|
||||
secret: hideme
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job2:
|
||||
name: job2
|
||||
runs-on: linux
|
||||
uses: .gitea/workflows/build.yml
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,15 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
runs-on: linux
|
||||
uses: .gitea/workflows/build.yml
|
||||
with:
|
||||
package: service
|
||||
|
||||
job2:
|
||||
name: job2
|
||||
runs-on: linux
|
||||
uses: .gitea/workflows/build.yml
|
||||
with:
|
||||
package: module
|
||||
@@ -0,0 +1,17 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
runs-on: linux
|
||||
uses: .gitea/workflows/build.yml
|
||||
with:
|
||||
package: service
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job2:
|
||||
name: job2
|
||||
runs-on: linux
|
||||
uses: .gitea/workflows/build.yml
|
||||
with:
|
||||
package: module
|
||||
@@ -0,0 +1,14 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-22.04, ubuntu-20.04]
|
||||
version: [1.17, 1.18, 1.19]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: test_version_${{ matrix.version }}_on_${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
@@ -0,0 +1,101 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: test_version_1.17_on_ubuntu-20.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-20.04
|
||||
version:
|
||||
- 1.17
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: test_version_1.18_on_ubuntu-20.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-20.04
|
||||
version:
|
||||
- 1.18
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: test_version_1.19_on_ubuntu-20.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-20.04
|
||||
version:
|
||||
- 1.19
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: test_version_1.17_on_ubuntu-22.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
version:
|
||||
- 1.17
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: test_version_1.18_on_ubuntu-22.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
version:
|
||||
- 1.18
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: test_version_1.19_on_ubuntu-22.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
version:
|
||||
- 1.19
|
||||
@@ -0,0 +1,22 @@
|
||||
name: test
|
||||
jobs:
|
||||
zzz:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: echo zzz
|
||||
job1:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a && go version
|
||||
job2:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a && go version
|
||||
job3:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a && go version
|
||||
aaa:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a && go version
|
||||
@@ -0,0 +1,39 @@
|
||||
name: test
|
||||
jobs:
|
||||
zzz:
|
||||
name: zzz
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: echo zzz
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a && go version
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job2:
|
||||
name: job2
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a && go version
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job3:
|
||||
name: job3
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a && go version
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
aaa:
|
||||
name: aaa
|
||||
runs-on: linux
|
||||
steps:
|
||||
- run: uname -a && go version
|
||||
@@ -0,0 +1,13 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-22.04, ubuntu-20.04]
|
||||
version: [1.17, 1.18, 1.19]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
@@ -0,0 +1,101 @@
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1 (ubuntu-20.04, 1.17)
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-20.04
|
||||
version:
|
||||
- 1.17
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1 (ubuntu-20.04, 1.18)
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-20.04
|
||||
version:
|
||||
- 1.18
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1 (ubuntu-20.04, 1.19)
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-20.04
|
||||
version:
|
||||
- 1.19
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1 (ubuntu-22.04, 1.17)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
version:
|
||||
- 1.17
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1 (ubuntu-22.04, 1.18)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
version:
|
||||
- 1.18
|
||||
---
|
||||
name: test
|
||||
jobs:
|
||||
job1:
|
||||
name: job1 (ubuntu-22.04, 1.19)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.version }}
|
||||
- run: uname -a && go version
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
version:
|
||||
- 1.19
|
||||
@@ -0,0 +1,9 @@
|
||||
# null_job_explicit.in.yaml
|
||||
on: push
|
||||
jobs:
|
||||
empty: null
|
||||
notempty:
|
||||
needs: empty
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo ok
|
||||
@@ -0,0 +1,9 @@
|
||||
# null_job_implicit.in.yaml
|
||||
on: push
|
||||
jobs:
|
||||
empty:
|
||||
notempty:
|
||||
needs: empty
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo ok
|
||||
@@ -0,0 +1,14 @@
|
||||
name: Step with leading new line
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "\nExtract tag for variant"
|
||||
id: extract_tag
|
||||
run: |
|
||||
|
||||
echo Test
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Step with leading new line
|
||||
"on":
|
||||
push:
|
||||
jobs:
|
||||
test:
|
||||
name: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: extract_tag
|
||||
name: |2-
|
||||
|
||||
Extract tag for variant
|
||||
run: |2
|
||||
|
||||
echo Test
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var testdata embed.FS
|
||||
|
||||
func ReadTestdata(t *testing.T, name string) []byte {
|
||||
content, err := testdata.ReadFile(filepath.Join("testdata", name))
|
||||
require.NoError(t, err)
|
||||
return content
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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 "./.gitea/workflows/foo.yml" - a path inside the calling repository.
|
||||
UsesKindLocalSameRepo UsesKind = iota + 1
|
||||
// UsesKindLocalCrossRepo is "owner/repo/.gitea/workflows/foo.yml@ref" - a workflow in another repo on the same instance.
|
||||
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(`^\./\.(gitea|github)/workflows/([^@]+\.ya?ml)$`)
|
||||
reLocalCrossRepo = regexp.MustCompile(`^([-.\w]+)/([-.\w]+)/\.(gitea|github)/workflows/([^@]+\.ya?ml)@(.+)$`)
|
||||
)
|
||||
|
||||
// ParseUses parses a reusable workflow "uses:" value.
|
||||
// Only two forms are supported:
|
||||
// - "./.gitea/workflows/foo.yml" (UsesKindLocalSameRepo, no @ref)
|
||||
// - "OWNER/REPO/.gitea/workflows/foo.yml@REF" (UsesKindLocalCrossRepo)
|
||||
func ParseUses(s string) (*UsesRef, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, errors.New("empty uses value")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(s, "./") {
|
||||
m := reLocalSameRepo.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf(`invalid local "uses:" %q (expect ./.gitea/workflows/<file>.yml)`, s)
|
||||
}
|
||||
p := fmt.Sprintf(".%s/workflows/%s", m[1], m[2])
|
||||
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/.gitea/workflows/<file>.yml@ref)`, s)
|
||||
}
|
||||
p := fmt.Sprintf(".%s/workflows/%s", m[3], m[4])
|
||||
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[5],
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// 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"},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
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
|
||||
{name: "same-repo with @ref", in: "./.gitea/workflows/build.yml@v1"},
|
||||
{name: "same-repo wrong directory", in: "./not-workflows/build.yml"},
|
||||
{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"},
|
||||
{name: "same-repo no filename", in: "./.gitea/workflows/.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 workflows dir", in: "owner/repo/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,401 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"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 := yaml.Unmarshal(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(
|
||||
jobID string,
|
||||
job *Job,
|
||||
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 := actJob.GetMatrixes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get caller %q matrix: %w", jobID, err)
|
||||
}
|
||||
if len(matrixes) > 0 {
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
|
||||
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
|
||||
}
|
||||
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]
|
||||
if !ok {
|
||||
// ignore unknown "with:" keys
|
||||
continue
|
||||
}
|
||||
converted, err := parseWorkflowCallInput(k, inputSpec.Type, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
|
||||
switch typ {
|
||||
case InputTypeString:
|
||||
return toString(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)
|
||||
}
|
||||
}
|
||||
|
||||
// SecretsInherit is the literal keyword used in a caller's `secrets: inherit` directive
|
||||
const SecretsInherit = "inherit"
|
||||
|
||||
// callerSecretValueRegexp matches the `${{ secrets.NAME }}` form expected for each value in a caller's `secrets:` mapping.
|
||||
var callerSecretValueRegexp = regexp.MustCompile(`^\s*\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}\s*$`)
|
||||
|
||||
// ParseCallerSecrets decodes a caller's "secrets:" YAML node into one of two forms:
|
||||
// - inherit == true: the caller wrote `secrets: inherit`; mapping is nil
|
||||
// - inherit == false, mapping == {alias: source_name}: explicit mapping. Each value must be of the form `${{ secrets.NAME }}`.
|
||||
//
|
||||
// Both alias and source name are upper-cased: secret names are case-insensitive (matching GitHub),
|
||||
// and Gitea stores secrets upper-cased, so this keeps lookups and schema validation consistent.
|
||||
func ParseCallerSecrets(node yaml.Node) (inherit bool, mapping map[string]string, err error) {
|
||||
if node.IsZero() {
|
||||
return false, nil, nil
|
||||
}
|
||||
if node.Kind == yaml.ScalarNode && strings.TrimSpace(node.Value) == SecretsInherit {
|
||||
return true, nil, nil
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return false, nil, errors.New("invalid secrets: section, expected mapping or 'inherit'")
|
||||
}
|
||||
out := make(map[string]string, len(node.Content)/2)
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
k := node.Content[i]
|
||||
v := node.Content[i+1]
|
||||
var sv string
|
||||
if err := v.Decode(&sv); err != nil {
|
||||
return false, nil, fmt.Errorf("decode secret %q: %w", k.Value, err)
|
||||
}
|
||||
matches := callerSecretValueRegexp.FindStringSubmatch(sv)
|
||||
if len(matches) != 2 {
|
||||
return false, nil, fmt.Errorf("caller secret %q value must be of the form ${{ secrets.NAME }}", k.Value)
|
||||
}
|
||||
out[strings.ToUpper(k.Value)] = strings.ToUpper(matches[1])
|
||||
}
|
||||
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,
|
||||
}
|
||||
interpreter := exprparser.NewInterpeter(env, exprparser.Config{})
|
||||
|
||||
out := make(map[string]string, len(spec.Outputs))
|
||||
for name, o := range spec.Outputs {
|
||||
v, err := evaluateWorkflowCallOutputValue(interpreter, o.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
|
||||
}
|
||||
out[name] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func evaluateWorkflowCallOutputValue(interpreter exprparser.Interpreter, value string) (string, error) {
|
||||
if !strings.Contains(value, "${{") || !strings.Contains(value, "}}") {
|
||||
return value, nil
|
||||
}
|
||||
expr, err := rewriteSubExpression(value, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
evaluated, err := interpreter.Evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return toString(evaluated), nil
|
||||
}
|
||||
|
||||
func toString(v any) string {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return s
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"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
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseCallerSecrets(t *testing.T) {
|
||||
// secretYAMLNode unmarshals raw YAML text into a yaml.Node so tests can hand it to ParseCallerSecrets.
|
||||
secretYAMLNode := func(t *testing.T, s string) yaml.Node {
|
||||
t.Helper()
|
||||
var node yaml.Node
|
||||
require.NoError(t, yaml.Unmarshal([]byte(s), &node))
|
||||
// yaml.Unmarshal wraps content in a DocumentNode; the meaningful node is the first child.
|
||||
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
|
||||
return *node.Content[0]
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
t.Run("zero node returns no inherit, no mapping", func(t *testing.T) {
|
||||
inherit, mapping, err := ParseCallerSecrets(yaml.Node{})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, inherit)
|
||||
assert.Nil(t, mapping)
|
||||
})
|
||||
|
||||
t.Run("\"inherit\" scalar sets inherit=true", func(t *testing.T) {
|
||||
inherit, mapping, err := ParseCallerSecrets(secretYAMLNode(t, `inherit`))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, inherit)
|
||||
assert.Nil(t, mapping)
|
||||
})
|
||||
|
||||
t.Run("non-inherit scalar is rejected", func(t *testing.T) {
|
||||
_, _, err := ParseCallerSecrets(secretYAMLNode(t, `something-else`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "expected mapping or 'inherit'")
|
||||
})
|
||||
|
||||
t.Run("mapping of secrets-style references is parsed", func(t *testing.T) {
|
||||
inherit, mapping, err := ParseCallerSecrets(secretYAMLNode(t, `
|
||||
DEPLOY_KEY: ${{ secrets.GITEA_DEPLOY_KEY }}
|
||||
DB_PASS: ${{ secrets.PROD_DB_PASS }}
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, inherit)
|
||||
assert.Equal(t, map[string]string{
|
||||
"DEPLOY_KEY": "GITEA_DEPLOY_KEY",
|
||||
"DB_PASS": "PROD_DB_PASS",
|
||||
}, mapping)
|
||||
})
|
||||
|
||||
t.Run("alias and source names are upper-cased", func(t *testing.T) {
|
||||
inherit, mapping, err := ParseCallerSecrets(secretYAMLNode(t, `
|
||||
deploy_key: ${{ secrets.gitea_deploy_key }}
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, inherit)
|
||||
assert.Equal(t, map[string]string{"DEPLOY_KEY": "GITEA_DEPLOY_KEY"}, mapping)
|
||||
})
|
||||
|
||||
t.Run("mapping value not in ${{ secrets.NAME }} form is rejected", func(t *testing.T) {
|
||||
// plain string
|
||||
_, _, err := ParseCallerSecrets(secretYAMLNode(t, `KEY: not-an-expression`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `must be of the form ${{ secrets.NAME }}`)
|
||||
|
||||
// expression but referencing the wrong context (vars instead of secrets)
|
||||
_, _, err = ParseCallerSecrets(secretYAMLNode(t, `KEY: ${{ vars.NAME }}`))
|
||||
require.Error(t, err)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user