mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-10 05:24:18 +09:00
Pairs with https://gitea.com/gitea/runner/pulls/1143. Gitea depends on `gitea.com/gitea/runner` for exactly two packages: `act/model` and `act/exprparser`, the workflow model and the expression evaluator it needs to parse workflows and to build the task payload the runner consumes. Pulling the whole runner module in for that is heavy and puts shared code in the repository of one of the two consumers. Both packages now live in `gitea.dev/actionslib` (`pkg/model`, `pkg/exprparser`), the module Gitea and the runner already share for the runner API, so the dependency on the runner repository is dropped here. ### Changes - `gitea.com/gitea/runner/act/model` -> `gitea.dev/actionslib/pkg/model`, `.../act/exprparser` -> `gitea.dev/actionslib/pkg/exprparser` (22 files, import paths only). - `routers/api/actions/runner/interceptor.go` takes the `x-runner-uuid` / `x-runner-token` names from `gitea.dev/actionslib/pkg/protocol` instead of repeating the literals the runner also has. - `go.mod`: `gitea.com/gitea/runner` removed. --------- Signed-off-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Zettat123 <zettat123@gmail.com>
575 lines
17 KiB
Go
575 lines
17 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package jobparser
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"gitea.dev/actionslib/pkg/expreval"
|
|
"gitea.dev/actionslib/pkg/exprparser"
|
|
"gitea.dev/actionslib/pkg/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) {
|
|
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
|
|
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
|
|
// `run:` step that begins with blank lines) with a wrong explicit indentation
|
|
// indicator (`run: |4`) that then fails to re-parse, which silently strands
|
|
// the job during concurrency evaluation. Keeping both encoders at indent 2
|
|
// makes the serialized single workflow round-trip.
|
|
var buf bytes.Buffer
|
|
enc := yaml.NewEncoder(&buf)
|
|
enc.SetIndent(2)
|
|
if err := enc.Encode(w); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := enc.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// GetContinueOnError decodes the continue-on-error field to a bool.
|
|
// The field may be a literal bool or an already-evaluated expression node.
|
|
func (j *Job) GetContinueOnError() bool {
|
|
if j.RawContinueOnError.Kind == 0 {
|
|
return false
|
|
}
|
|
var v bool
|
|
if err := j.RawContinueOnError.Decode(&v); err != nil {
|
|
return false
|
|
}
|
|
return v
|
|
}
|
|
|
|
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,
|
|
RawContinueOnError: j.RawContinueOnError,
|
|
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 := matrixesOf(actJob)
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
}
|
|
// 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)
|
|
return evaluator.EvalBool(job.If.Value, exprparser.DefaultStatusCheckSuccess)
|
|
}
|
|
|
|
// 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 ""
|
|
}
|