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

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

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

Runner PR: https://gitea.com/gitea/runner/pulls/1247
Docs PR: https://gitea.com/gitea/docs/pulls/553
Fixes: https://github.com/go-gitea/gitea/issues/38990
Fixes: https://github.com/go-gitea/gitea/issues/39382
Fixes: https://github.com/go-gitea/gitea/issues/32364
Fixes: https://github.com/go-gitea/gitea/issues/36077
Fixes: https://github.com/go-gitea/gitea/issues/23277
Fixes: https://github.com/go-gitea/gitea/issues/29020
Co-authored-by: Claude (Opus 5) <noreply@anthropic.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
silverwind
2026-09-25 00:06:42 +02:00
committed by GitHub
parent 64f31d9b70
commit f757631a47
59 changed files with 1509 additions and 2223 deletions
+127 -181
View File
@@ -14,18 +14,19 @@ import (
"gitea.dev/actionslib/pkg/model"
"gitea.dev/modules/util"
"github.com/robfig/cron/v3"
"go.yaml.in/yaml/v4"
)
// SingleWorkflow is a workflow with single job and single matrix
type SingleWorkflow struct {
Name string `yaml:"name,omitempty"`
RawOn yaml.Node `yaml:"on,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
RawJobs yaml.Node `yaml:"jobs,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
RunName string `yaml:"run-name,omitempty"`
Name string `yaml:"name,omitempty"`
RawOn yaml.Node `yaml:"on,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
RawJobs yaml.Node `yaml:"jobs,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
RunName string `yaml:"run-name,omitempty"`
}
func (w *SingleWorkflow) Job() (string, *Job) {
@@ -93,7 +94,9 @@ func (w *SingleWorkflow) Marshal() ([]byte, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(w); err != nil {
payload := *w
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
if err := enc.Encode(&payload); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
@@ -103,24 +106,24 @@ func (w *SingleWorkflow) Marshal() ([]byte, error) {
}
type Job struct {
Name string `yaml:"name,omitempty"`
RawNeeds yaml.Node `yaml:"needs,omitempty"`
RawRunsOn yaml.Node `yaml:"runs-on,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Steps []*Step `yaml:"steps,omitempty"`
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"`
Services map[string]*ContainerSpec `yaml:"services,omitempty"`
Strategy Strategy `yaml:"strategy,omitempty"`
RawContainer yaml.Node `yaml:"container,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
Outputs map[string]string `yaml:"outputs,omitempty"`
Uses string `yaml:"uses,omitempty"`
With map[string]any `yaml:"with,omitempty"`
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
Name string `yaml:"name,omitempty"`
RawNeeds yaml.Node `yaml:"needs,omitempty"`
RawRunsOn yaml.Node `yaml:"runs-on,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Steps []*Step `yaml:"steps,omitempty"`
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"`
Services yaml.Node `yaml:"services,omitempty"`
Strategy Strategy `yaml:"strategy,omitempty"`
RawContainer yaml.Node `yaml:"container,omitempty"`
Defaults yaml.Node `yaml:"defaults,omitempty"`
Outputs map[string]string `yaml:"outputs,omitempty"`
Uses string `yaml:"uses,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
}
// GetContinueOnError decodes the continue-on-error field to a bool.
@@ -171,8 +174,18 @@ func (j *Job) EraseNeeds() *Job {
return j
}
// RunsOn returns the labels Gitea matches runners against, unescaped like DisplayName.
func (j *Job) RunsOn() []string {
return (&model.Job{RawRunsOn: j.RawRunsOn}).RunsOn()
runsOn := model.RunsOnFromNode(j.RawRunsOn)
for i, label := range runsOn {
runsOn[i] = unescapeExpressions(label)
}
return runsOn
}
// DisplayName is the name Gitea stores, without the escaping the payload keeps for runners.
func (j *Job) DisplayName() string {
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
}
// BlockSafeString works around https://github.com/yaml/go-yaml/issues/399, quoting a value whose
@@ -187,17 +200,17 @@ func (s BlockSafeString) MarshalYAML() (any, error) {
}
type Step struct {
ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Name BlockSafeString `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"`
Run BlockSafeString `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
With map[string]string `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
ID string `yaml:"id,omitempty"`
If yaml.Node `yaml:"if,omitempty"`
Name BlockSafeString `yaml:"name,omitempty"`
Uses string `yaml:"uses,omitempty"`
Run BlockSafeString `yaml:"run,omitempty"`
WorkingDirectory string `yaml:"working-directory,omitempty"`
Shell string `yaml:"shell,omitempty"`
Env yaml.Node `yaml:"env,omitempty"`
With yaml.Node `yaml:"with,omitempty"`
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
}
// UnmarshalYAML canonicalizes booleans like continue-on-error
@@ -225,20 +238,45 @@ func (s *Step) String() string {
}).String()
}
type ContainerSpec struct {
Image string `yaml:"image,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
Ports []string `yaml:"ports,omitempty"`
Volumes []string `yaml:"volumes,omitempty"`
Options string `yaml:"options,omitempty"`
Credentials map[string]string `yaml:"credentials,omitempty"`
Cmd []string `yaml:"cmd,omitempty"`
}
type Strategy struct {
FailFastString string `yaml:"fail-fast,omitempty"`
MaxParallelString string `yaml:"max-parallel,omitempty"`
RawMatrix yaml.Node `yaml:"matrix,omitempty"`
JobIndex int `yaml:"job-index,omitempty"` // set by buildMatrixCombos, read back from its payload
JobTotal int `yaml:"job-total,omitempty"`
RawExpression yaml.Node `yaml:"-"` // a whole-value `strategy: ${{ }}`, see Strategy.resolve
}
type rawStrategy Strategy
func (s *Strategy) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
*s = Strategy{RawExpression: *node}
return nil
}
return node.Decode((*rawStrategy)(s))
}
func (s Strategy) MarshalYAML() (any, error) {
if s.RawExpression.Kind != 0 {
return &s.RawExpression, nil
}
return rawStrategy(s), nil
}
func (s Strategy) actStrategy() *model.Strategy {
return &model.Strategy{FailFastString: s.FailFastString, MaxParallelString: s.MaxParallelString, RawMatrix: s.RawMatrix}
}
// context is the strategy context, combination 0 of 1 without a matrix as on GitHub, and without job-index for an unexpanded matrix.
func (s *Strategy) context() map[string]any {
switch {
case s == nil:
return exprparser.StrategyContext(nil, 0, 0)
case s.RawMatrix.Kind == 0 && s.RawExpression.Kind == 0:
return exprparser.StrategyContext(s.actStrategy(), 0, 1)
}
return exprparser.StrategyContext(s.actStrategy(), s.JobIndex, s.JobTotal)
}
type Defaults struct {
@@ -250,20 +288,10 @@ type RunDefaults struct {
WorkingDirectory string `yaml:"working-directory,omitempty"`
}
type WorkflowDispatchInput struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
Type string `yaml:"type"`
Options []string `yaml:"options"`
}
type Event struct {
Name string
acts map[string][]string
schedules []map[string]string
inputs []WorkflowDispatchInput
}
func (evt *Event) IsSchedule() bool {
@@ -278,10 +306,6 @@ func (evt *Event) Schedules() []map[string]string {
return evt.schedules
}
func (evt *Event) Inputs() []WorkflowDispatchInput {
return evt.inputs
}
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
w, err := ReadWorkflow(content)
if err != nil {
@@ -290,26 +314,30 @@ func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
return w.RawConcurrency, nil
}
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
actJob := &model.Job{}
// newJobEvaluator evaluates against a stored job's contexts, with its single matrix combination.
func newJobEvaluator(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (expreval.Evaluator, error) {
var strategy *Strategy
var matrix map[string]any
if job != nil {
actJob.Strategy = &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
strategy = &job.Strategy
rawMatrix := model.CloneYamlNode(job.Strategy.RawMatrix)
replaceScalars(&rawMatrix, unescapeExpressions)
matrixes, err := (&model.Job{Strategy: &model.Strategy{RawMatrix: rawMatrix}}).GetMatrixes()
if err != nil {
return expreval.Evaluator{}, err
}
if len(matrixes[0]) > 0 {
matrix = matrixes[0]
}
}
return expreval.New(NewInterpeter(jobID, strategy, matrix, model.GithubContextFromMap(gitCtx), results, vars, inputs).Evaluate), nil
}
matrix := make(map[string]any)
matrixes, err := matrixesOf(actJob)
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
evaluator, err := newJobEvaluator(jobID, job, gitCtx, results, vars, inputs)
if err != nil {
return "", false, err
}
if len(matrixes) > 0 {
matrix = matrixes[0]
}
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
var node yaml.Node
if err := node.Encode(rc); err != nil {
return "", false, fmt.Errorf("failed to encode concurrency: %w", err)
@@ -327,40 +355,6 @@ func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCt
return evaluated.Group, util.ParseYamlBool(evaluated.CancelInProgress), nil
}
func toGitContext(input map[string]any) *model.GithubContext {
gitContext := &model.GithubContext{
EventPath: asString(input["event_path"]),
Workflow: asString(input["workflow"]),
RunID: asString(input["run_id"]),
RunNumber: asString(input["run_number"]),
Actor: asString(input["actor"]),
Repository: asString(input["repository"]),
EventName: asString(input["event_name"]),
Sha: asString(input["sha"]),
Ref: asString(input["ref"]),
RefName: asString(input["ref_name"]),
RefType: asString(input["ref_type"]),
HeadRef: asString(input["head_ref"]),
BaseRef: asString(input["base_ref"]),
Token: asString(input["token"]),
Workspace: asString(input["workspace"]),
Action: asString(input["action"]),
ActionPath: asString(input["action_path"]),
ActionRef: asString(input["action_ref"]),
ActionRepository: asString(input["action_repository"]),
Job: asString(input["job"]),
RepositoryOwner: asString(input["repository_owner"]),
RetentionDays: asString(input["retention_days"]),
}
event, ok := input["event"].(map[string]any)
if ok {
gitContext.Event = event
}
return gitContext
}
// workflowCallEvent is only fired by another workflow's `uses:`, so it is excluded from trigger detection.
const workflowCallEvent = "workflow_call"
@@ -372,6 +366,9 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
if err != nil {
return nil, err
}
if rawOn.ShortTag() != "!!str" || val == "" {
return nil, fmt.Errorf("invalid event %q", val)
}
if val == workflowCallEvent {
return []*Event{}, nil
}
@@ -421,18 +418,24 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
}
schedules := make([]map[string]string, len(t))
if k == "schedule" {
if len(t) == 0 {
return nil, errors.New("schedule must contain at least one cron entry")
}
for i, tt := range t {
vv, ok := tt.(map[string]any)
if !ok {
return nil, fmt.Errorf("unknown on type(schedule): %#v", v)
return nil, errors.New("unknown on type(schedule)")
}
schedules[i] = make(map[string]string, len(vv))
for k, vvv := range vv {
var ok bool
if schedules[i][k], ok = vvv.(string); !ok {
return nil, fmt.Errorf("unknown on type(schedule): %#v", v)
return nil, errors.New("unknown on type(schedule)")
}
}
if _, err := cron.ParseStandard(schedules[i]["cron"]); err != nil {
return nil, fmt.Errorf("invalid cron %q: %w", schedules[i]["cron"], err)
}
}
}
@@ -444,14 +447,14 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
schedules: schedules,
})
case yaml.MappingNode:
// Keep combined include and ignore filters for existing Gitea workflows, although GitHub rejects them.
acts := make(map[string][]string, len(v.Content)/2)
var inputs []WorkflowDispatchInput
expectedKey := true
var act string
for _, content := range v.Content {
if expectedKey {
if content.Kind != yaml.ScalarNode {
return nil, fmt.Errorf("key type not string: %#v", content)
return nil, errors.New("key type not string")
}
act = ""
err := content.Decode(&act)
@@ -476,48 +479,23 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
acts[act] = []string{t}
case yaml.MappingNode:
if k != "workflow_dispatch" || act != "inputs" {
return nil, fmt.Errorf("map should only for workflow_dispatch but %s: %#v", act, content)
return nil, fmt.Errorf("map should only for workflow_dispatch but %s", act)
}
var key string
for i, vv := range content.Content {
if i%2 == 0 {
if vv.Kind != yaml.ScalarNode {
return nil, fmt.Errorf("key type not string: %#v", vv)
}
key = ""
if err := vv.Decode(&key); err != nil {
return nil, err
}
} else {
if vv.Kind != yaml.MappingNode {
return nil, fmt.Errorf("key type not map(%s): %#v", key, vv)
}
input := WorkflowDispatchInput{}
if err := vv.Decode(&input); err != nil {
return nil, err
}
input.Name = key
inputs = append(inputs, input)
}
if err := content.Decode(new(map[string]model.WorkflowDispatchInput)); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown on type: %#v", content)
return nil, fmt.Errorf("unknown on type for %s", act)
}
}
expectedKey = !expectedKey
}
if len(inputs) == 0 {
inputs = nil
}
if len(acts) == 0 {
acts = nil
}
res = append(res, &Event{
Name: k,
acts: acts,
inputs: inputs,
Name: k,
acts: acts,
})
default:
return nil, fmt.Errorf("unknown on type: %v", v.Kind)
@@ -529,35 +507,12 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
}
}
// EvaluateJobIfExpression evaluates a job's `if:`.
func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any, matrixDeferred bool) (bool, error) {
actJob := &model.Job{
Strategy: &model.Strategy{
FailFastString: job.Strategy.FailFastString,
MaxParallelString: job.Strategy.MaxParallelString,
RawMatrix: job.Strategy.RawMatrix,
},
// EvaluateJobIfExpression evaluates a job's `if:`, which github.com decides before the matrix, so without the matrix and strategy contexts.
func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (bool, error) {
if unavailable := unavailableContext(IfExpression(job.If.Value), jobConditionContexts); unavailable != "" { // only a job stored before its conditions were validated
return false, fmt.Errorf("job %s: Unrecognized named-value: '%s', update the workflow and trigger a new run", jobID, unavailable)
}
// Each per-matrix job carries its single matrix combination in RawMatrix so resolve it and pass it in;
// otherwise `matrix.*` references in `if:` evaluate to null.
// GetMatrixes always returns at least one element (an empty map for a job without a matrix),
// so only a non-empty combination should populate `matrix.*`, leaving it nil otherwise.
//
// A deferred-matrix placeholder is the exception: its combinations do not exist yet, and reading the
// raw matrix here would either fail outright (an `include` that is still a scalar expression) or bind
// `matrix.*` to the expression's own source text. Leaving it nil is safe: the caller checks
// ExpressionReadsMatrix first, so an `if:` that reads `matrix.*` is deferred to the post-expansion pass.
var matrix map[string]any
if !matrixDeferred {
matrixes, err := matrixesOf(actJob)
if err != nil {
return false, err
}
if len(matrixes) > 0 && len(matrixes[0]) > 0 {
matrix = matrixes[0]
}
}
evaluator := expreval.New(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs).Evaluate)
evaluator := expreval.New(NewInterpeter(jobID, nil, nil, model.GithubContextFromMap(gitCtx), results, vars, inputs).Evaluate)
return evaluator.EvalBool(job.If.Value, exprparser.DefaultStatusCheckSuccess)
}
@@ -593,12 +548,3 @@ func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) {
return scalars, datas, nil
}
func asString(v any) string {
if v == nil {
return ""
} else if s, ok := v.(string); ok {
return s
}
return ""
}