初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+189
View File
@@ -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
}
+87
View File
@@ -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
}
+174
View File
@@ -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, ", "))
}
+104
View File
@@ -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)
})
})
}
}
+530
View File
@@ -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 ""
}
+420
View File
@@ -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)
})
}
}
+8
View File
@@ -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
+16
View File
@@ -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]
+23
View File
@@ -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
+16
View File
@@ -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]
+25
View File
@@ -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
+14
View File
@@ -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
+16
View File
@@ -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
+15
View File
@@ -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
+17
View File
@@ -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
}
+75
View File
@@ -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
}
+170
View File
@@ -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)
})
}
})
}
+401
View File
@@ -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
}