mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-26 14:43:39 +09:00
feat(actions): update actionslib, support self:, misc fixes (#39358)
Updates actionslib to https://gitea.com/gitea/actionslib/releases/tag/v1.2.1, moves workflow parsing into it and aligns behaviour with GitHub. 1. `uses:` supports `self:` (Gitea-only feature) and `$/` paths. 1. `strategy`, `matrix`, `max-parallel` and `fail-fast` accept expressions, including over `needs`. A job whose `name`, `runs-on` or `continue-on-error` reads `needs` is resolved once they finish. 1. A job `if:` may only read `github`, `needs`, `vars` and `inputs` and is decided before the matrix, as on github.com. 1. Matrix `fail-fast` cancels the other combinations, and `always()` jobs keep running when a run is cancelled. 1. Invalid workflow files, including a malformed `on:` and unknown or cyclic `needs`, show up on push as failed runs with the error. 1. A job whose `if:` or `concurrency:` fails to evaluate is skipped or failed with the error, instead of staying blocked. 1. Reusable workflows: a missing and an unreadable repository fail alike, public callers cannot use private workflows, nested jobs cannot exceed the caller's token permissions. 1. Runner labels match case-insensitively, and `runs-on` accepts an array from an expression. Runner PR: https://gitea.com/gitea/runner/pulls/1247 Docs PR: https://gitea.com/gitea/docs/pulls/553 Fixes: https://github.com/go-gitea/gitea/issues/38990 Fixes: https://github.com/go-gitea/gitea/issues/39382 Fixes: https://github.com/go-gitea/gitea/issues/32364 Fixes: https://github.com/go-gitea/gitea/issues/36077 Fixes: https://github.com/go-gitea/gitea/issues/23277 Fixes: https://github.com/go-gitea/gitea/issues/29020 Co-authored-by: Claude (Opus 5) <noreply@anthropic.com> Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
@@ -9,274 +9,66 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/actionslib/pkg/expreval"
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// InputType enumerates the allowed types for a workflow_call input.
|
||||
type InputType string
|
||||
|
||||
const (
|
||||
InputTypeString InputType = "string"
|
||||
InputTypeBoolean InputType = "boolean"
|
||||
InputTypeNumber InputType = "number"
|
||||
)
|
||||
|
||||
// InputSpec describes a single workflow_call input declaration.
|
||||
type InputSpec struct {
|
||||
Description string `yaml:"description"`
|
||||
Required bool `yaml:"required"`
|
||||
Default yaml.Node `yaml:"default"`
|
||||
Type InputType `yaml:"type"`
|
||||
}
|
||||
|
||||
// SecretSpec describes a single workflow_call secret declaration.
|
||||
type SecretSpec struct {
|
||||
Description string `yaml:"description"`
|
||||
Required bool `yaml:"required"`
|
||||
}
|
||||
|
||||
// OutputSpec describes a single workflow_call output declaration.
|
||||
type OutputSpec struct {
|
||||
Description string `yaml:"description"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
// WorkflowCallSpec is the parsed "on.workflow_call" schema of a called workflow.
|
||||
type WorkflowCallSpec struct {
|
||||
Inputs map[string]InputSpec
|
||||
Secrets map[string]SecretSpec
|
||||
Outputs map[string]OutputSpec
|
||||
}
|
||||
|
||||
// JobOutputs is the per-job-id outputs map used for evaluating workflow_call outputs.
|
||||
type JobOutputs map[string]map[string]string
|
||||
|
||||
// ParseWorkflowCallSpec extracts on.workflow_call.{inputs,secrets,outputs} from a workflow YAML.
|
||||
// Returns an error if the workflow does not declare on.workflow_call at all.
|
||||
func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) {
|
||||
var doc struct {
|
||||
On yaml.Node `yaml:"on"`
|
||||
}
|
||||
if err := decodeResolved(content, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse workflow yaml: %w", err)
|
||||
}
|
||||
|
||||
wcNode, ok := findWorkflowCallNode(&doc.On)
|
||||
if !ok {
|
||||
return nil, errors.New("workflow does not declare on.workflow_call")
|
||||
}
|
||||
|
||||
spec := &WorkflowCallSpec{
|
||||
Inputs: map[string]InputSpec{},
|
||||
Secrets: map[string]SecretSpec{},
|
||||
Outputs: map[string]OutputSpec{},
|
||||
}
|
||||
|
||||
if wcNode == nil || wcNode.Kind != yaml.MappingNode {
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
for i := 0; i+1 < len(wcNode.Content); i += 2 {
|
||||
key := wcNode.Content[i]
|
||||
val := wcNode.Content[i+1]
|
||||
switch key.Value {
|
||||
case "inputs":
|
||||
if err := decodeWorkflowCallMapping(val, spec.Inputs); err != nil {
|
||||
return nil, fmt.Errorf("parse workflow_call.inputs: %w", err)
|
||||
}
|
||||
case "secrets":
|
||||
if err := decodeWorkflowCallMapping(val, spec.Secrets); err != nil {
|
||||
return nil, fmt.Errorf("parse workflow_call.secrets: %w", err)
|
||||
}
|
||||
case "outputs":
|
||||
if err := decodeWorkflowCallMapping(val, spec.Outputs); err != nil {
|
||||
return nil, fmt.Errorf("parse workflow_call.outputs: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name, in := range spec.Inputs {
|
||||
if in.Type == "" {
|
||||
return nil, fmt.Errorf("workflow_call input %q is missing required field \"type\"", name)
|
||||
}
|
||||
switch in.Type {
|
||||
case InputTypeString, InputTypeBoolean, InputTypeNumber:
|
||||
default:
|
||||
return nil, fmt.Errorf("workflow_call input %q has unsupported type %q", name, in.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// findWorkflowCallNode walks the "on:" node and returns the value mapping (or nil) for "workflow_call".
|
||||
// "ok" is true when the workflow declares workflow_call (even with an empty body).
|
||||
func findWorkflowCallNode(on *yaml.Node) (val *yaml.Node, ok bool) {
|
||||
if on == nil || on.Kind == 0 {
|
||||
return nil, false
|
||||
}
|
||||
switch on.Kind {
|
||||
case yaml.ScalarNode:
|
||||
return nil, on.Value == "workflow_call"
|
||||
case yaml.SequenceNode:
|
||||
for _, item := range on.Content {
|
||||
if item.Kind == yaml.ScalarNode && item.Value == "workflow_call" {
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(on.Content); i += 2 {
|
||||
k := on.Content[i]
|
||||
v := on.Content[i+1]
|
||||
if k.Value != "workflow_call" {
|
||||
continue
|
||||
}
|
||||
if v.Kind == yaml.MappingNode {
|
||||
return v, true
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func decodeWorkflowCallMapping[T any](node *yaml.Node, dst map[string]T) error {
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
name := node.Content[i].Value
|
||||
var v T
|
||||
if err := node.Content[i+1].Decode(&v); err != nil {
|
||||
return fmt.Errorf("%q: %w", name, err)
|
||||
}
|
||||
dst[name] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EvaluateCallerWith evaluates the caller-side expressions in `job.With` against the provided contexts
|
||||
func EvaluateCallerWith(
|
||||
// ResolveCallerInputs evaluates the caller's `with` against its own contexts and types it by the called workflow's inputs.
|
||||
func ResolveCallerInputs(
|
||||
jobID string,
|
||||
job *Job,
|
||||
config *model.WorkflowCall,
|
||||
gitCtx map[string]any,
|
||||
results map[string]*JobResult,
|
||||
vars map[string]string,
|
||||
inputs map[string]any,
|
||||
) (map[string]any, error) {
|
||||
actJob := &model.Job{Strategy: &model.Strategy{
|
||||
FailFastString: job.Strategy.FailFastString,
|
||||
MaxParallelString: job.Strategy.MaxParallelString,
|
||||
RawMatrix: job.Strategy.RawMatrix,
|
||||
}}
|
||||
|
||||
var matrix map[string]any
|
||||
matrixes, err := matrixesOf(actJob)
|
||||
if job.With.Kind != 0 && job.With.Kind != yaml.MappingNode {
|
||||
return nil, errors.New("caller with must be a mapping")
|
||||
}
|
||||
evaluator, err := newJobEvaluator(jobID, job, gitCtx, results, vars, inputs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get caller %q matrix: %w", jobID, err)
|
||||
}
|
||||
if len(matrixes) > 0 {
|
||||
matrix = matrixes[0]
|
||||
var with map[string]any
|
||||
if err := model.DecodeEvaluated("with", job.With, evaluator.EvaluateYamlNode, &with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
|
||||
|
||||
out := make(map[string]any, len(job.With))
|
||||
for k, raw := range job.With {
|
||||
var evaluated any
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
node := yaml.Node{}
|
||||
if err := node.Encode(v); err != nil {
|
||||
return nil, fmt.Errorf("encode caller %q with[%q]: %w", jobID, k, err)
|
||||
}
|
||||
if err := evaluator.EvaluateYamlNode(&node); err != nil {
|
||||
return nil, fmt.Errorf("evaluate caller %q with[%q]: %w", jobID, k, err)
|
||||
}
|
||||
if err := node.Decode(&evaluated); err != nil {
|
||||
return nil, fmt.Errorf("decode caller %q with[%q]: %w", jobID, k, err)
|
||||
}
|
||||
default:
|
||||
evaluated = v
|
||||
}
|
||||
out[k] = evaluated
|
||||
declared := make(map[string]model.WorkflowCallInput, len(config.Inputs))
|
||||
for name, input := range config.Inputs {
|
||||
declared[strings.ToLower(name)] = input
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MatchCallerInputsAgainstSpec checks the caller's already-evaluated `with:` values against the callee's declared `on.workflow_call.inputs` schema
|
||||
func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]any) (map[string]any, error) {
|
||||
resolved := make(map[string]any, len(spec.Inputs))
|
||||
|
||||
// fill defaults first
|
||||
for name, in := range spec.Inputs {
|
||||
if in.Default.IsZero() {
|
||||
continue
|
||||
}
|
||||
var defaultVal any
|
||||
if err := in.Default.Decode(&defaultVal); err != nil {
|
||||
return nil, fmt.Errorf("decode workflow_call input %q default: %w", name, err)
|
||||
}
|
||||
v, err := parseWorkflowCallInput(name, in.Type, defaultVal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved[name] = v
|
||||
}
|
||||
|
||||
for k, raw := range evaluated {
|
||||
inputSpec, ok := spec.Inputs[k]
|
||||
for name, value := range with {
|
||||
input, ok := declared[strings.ToLower(name)]
|
||||
if !ok {
|
||||
// ignore unknown "with:" keys
|
||||
continue
|
||||
continue // ignored as in released Gitea, github.com rejects an undeclared input
|
||||
}
|
||||
converted, err := parseWorkflowCallInput(k, inputSpec.Type, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
switch input.Type {
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return nil, fmt.Errorf("input %s: expected a boolean, got %T", name, value)
|
||||
}
|
||||
case "number":
|
||||
switch value.(type) {
|
||||
case float64, int, int64, uint64:
|
||||
default:
|
||||
return nil, fmt.Errorf("input %s: expected a number, got %T", name, value)
|
||||
}
|
||||
}
|
||||
resolved[k] = converted
|
||||
}
|
||||
|
||||
for name, in := range spec.Inputs {
|
||||
if !in.Required {
|
||||
continue
|
||||
}
|
||||
// resolved[name] is set when caller provided it OR when spec has a non-zero default - both satisfy "required".
|
||||
if _, ok := resolved[name]; ok {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("workflow_call input %q is required", name)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
return evaluator.ResolveWorkflowCallInputs(config, with)
|
||||
}
|
||||
|
||||
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
|
||||
switch typ {
|
||||
case InputTypeString:
|
||||
return exprparser.CoerceToString(v), nil
|
||||
case InputTypeBoolean:
|
||||
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
|
||||
if b, ok := v.(bool); ok {
|
||||
return b, nil
|
||||
}
|
||||
return false, fmt.Errorf("workflow_call input %q expects boolean", name)
|
||||
case InputTypeNumber:
|
||||
// strict type matching: a number input rejects "123"/"3.14" strings.
|
||||
if _, isString := v.(string); isString {
|
||||
return 0.0, fmt.Errorf("workflow_call input %q expects number", name)
|
||||
}
|
||||
return util.ToFloat64(v)
|
||||
default:
|
||||
return nil, fmt.Errorf("workflow_call input %q has unsupported type %q", name, typ)
|
||||
// ParseWorkflowCallConfig reads the workflow_call configuration of a called workflow, decoding only its `on`.
|
||||
func ParseWorkflowCallConfig(content []byte) (*model.WorkflowCall, error) {
|
||||
var workflow struct {
|
||||
RawOn yaml.Node `yaml:"on"`
|
||||
}
|
||||
if err := decodeResolved(content, &workflow); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return (&model.Workflow{RawOn: workflow.RawOn}).ParseWorkflowCallConfig()
|
||||
}
|
||||
|
||||
// SecretsInherit is the literal keyword used in a caller's `secrets: inherit` directive
|
||||
@@ -317,60 +109,3 @@ func ParseCallerSecrets(node yaml.Node) (inherit bool, mapping map[string]string
|
||||
}
|
||||
return false, out, nil
|
||||
}
|
||||
|
||||
// ValidateCallerSecrets checks a caller's parsed explicit-mapping `secrets:` against the called workflow's declared `on.workflow_call.secrets` schema.
|
||||
func ValidateCallerSecrets(spec *WorkflowCallSpec, mapping map[string]string) error {
|
||||
if spec == nil {
|
||||
return errors.New("ValidateCallerSecrets: nil workflow_call spec")
|
||||
}
|
||||
// Secret names are case-insensitive, so compare declared names and caller aliases upper-cased.
|
||||
declaredNames := make(container.Set[string], len(spec.Secrets))
|
||||
for name := range spec.Secrets {
|
||||
declaredNames.Add(strings.ToUpper(name))
|
||||
}
|
||||
provided := make(container.Set[string], len(mapping))
|
||||
for alias := range mapping {
|
||||
up := strings.ToUpper(alias)
|
||||
provided.Add(up)
|
||||
if !declaredNames.Contains(up) {
|
||||
return fmt.Errorf("caller secret %q is not declared in the called workflow's on.workflow_call.secrets", alias)
|
||||
}
|
||||
}
|
||||
for name, sec := range spec.Secrets {
|
||||
if sec.Required && !provided.Contains(strings.ToUpper(name)) {
|
||||
return fmt.Errorf("required secret %q is not provided by the caller", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EvaluateWorkflowCallOutputs evaluates a called workflow's "on.workflow_call.outputs.<name>.value" expressions against the provided contexts.
|
||||
func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubContext, vars map[string]string, inputs map[string]any, jobOutputs JobOutputs) (map[string]string, error) {
|
||||
if spec == nil || len(spec.Outputs) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
|
||||
jobsCtx := make(map[string]*model.WorkflowCallResult, len(jobOutputs))
|
||||
for jobID, outputs := range jobOutputs {
|
||||
jobsCtx[jobID] = &model.WorkflowCallResult{Outputs: outputs}
|
||||
}
|
||||
|
||||
// See `on.workflow_call.outputs.<output_id>.value` in https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability
|
||||
env := &exprparser.EvaluationEnvironment{
|
||||
Github: gitCtx,
|
||||
Jobs: &jobsCtx,
|
||||
Vars: vars,
|
||||
Inputs: inputs,
|
||||
}
|
||||
evaluator := expreval.New(exprparser.NewInterpeter(env, exprparser.Config{}).Evaluate)
|
||||
|
||||
out := make(map[string]string, len(spec.Outputs))
|
||||
for name, o := range spec.Outputs {
|
||||
v, err := evaluator.Interpolate(o.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
|
||||
}
|
||||
out[name] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user