mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 22:13:26 +09:00
feat: Add support for dynamic matrix evaluation in Gitea Actions workflows (#36564)
Adds dynamic matrix evaluation to Gitea Actions: a job's
`strategy.matrix` can be built from the outputs of the jobs it needs.
```yaml
jobs:
generate:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.result }}
steps:
- id: set
run: echo "result=[1,2,3]" >> $GITHUB_OUTPUT
build:
needs: [generate]
runs-on: ubuntu-latest
strategy:
matrix:
version: ${{ fromJson(needs.generate.outputs.matrix) }}
steps:
- run: echo "building ${{ matrix.version }}"
```
Such a matrix cannot be expanded at planning time, so the job is planned
as a single placeholder and expanded by the job emitter once its needs
finish. Each combination is then gated by `if:` and concurrency as
usual.
- A matrix that resolves to no combination fails the job, as on GitHub.
- Expansion is capped at `MaxJobNumPerRun`.
- Workflows without a needs-dependent matrix are unaffected.
Fixes https://github.com/go-gitea/gitea/issues/25179
---------
Signed-off-by: Pascal Zimmermann <pascal.zimmermann@theiotstudio.com>
Signed-off-by: ZPascal <pascal.zimmermann@theiotstudio.com>
Co-authored-by: Claude <claude-sonnet-4-5@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.8) <noreply@anthropic.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
This commit is contained in:
co-authored by
Claude
silverwind
Claude
bircni
Zettat123
parent
717db275d5
commit
5672b1c4cf
@@ -5,15 +5,61 @@ package jobparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/rhysd/actionlint"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// HasDeferredMatrix reports whether the job's matrix can only be expanded once its needs finish:
|
||||
// it reads the needs context and the job has needs to resolve that context against.
|
||||
// Parse emits such a job as a single placeholder rather than one job per combination, so every
|
||||
// caller that persists a job must agree with Parse on this condition.
|
||||
func HasDeferredMatrix(job *Job) bool {
|
||||
return len(job.Needs()) > 0 && rawMatrixReadsNeeds(&job.Strategy.RawMatrix)
|
||||
}
|
||||
|
||||
func rawMatrixReadsNeeds(node *yaml.Node) bool {
|
||||
if node.Kind == yaml.ScalarNode {
|
||||
return expressionReadsNeeds(node.Value)
|
||||
}
|
||||
return slices.ContainsFunc(node.Content, rawMatrixReadsNeeds)
|
||||
}
|
||||
|
||||
// expressionReadsNeeds reports whether value holds a ${{ }} expression reading the needs context.
|
||||
// Every other context (github, vars, inputs, ...) is already available while planning, so deferring
|
||||
// those too would replace their combinations with one placeholder and change the commit status
|
||||
// contexts the run publishes, which a repository's required checks are configured against.
|
||||
func expressionReadsNeeds(value string) bool {
|
||||
for rest := value; ; {
|
||||
_, after, found := strings.Cut(rest, "${{")
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
rest = after
|
||||
// The lexer ends the expression at its closing `}}`, so it can be handed the whole remainder.
|
||||
expr, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(rest))
|
||||
if err != nil {
|
||||
return true // unparseable here, let the expansion report it against the real values
|
||||
}
|
||||
readsNeeds := false
|
||||
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
|
||||
if variable, ok := node.(*actionlint.VariableNode); entering && ok && strings.EqualFold(variable.Name, "needs") {
|
||||
readsNeeds = true
|
||||
}
|
||||
})
|
||||
if readsNeeds {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
origin, err := model.ReadWorkflow(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
@@ -37,7 +83,7 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
results[id] = &JobResult{
|
||||
Needs: job.Needs(),
|
||||
Result: pc.jobResults[id],
|
||||
Outputs: nil, // not supported yet
|
||||
Outputs: nil, // resolved at expansion time, not at plan time
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,35 +98,34 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
|
||||
for i, id := range ids {
|
||||
job := jobs[i]
|
||||
matricxes, err := getMatrixes(origin.GetJob(id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getMatrixes: %w", err)
|
||||
originJob := origin.GetJob(id)
|
||||
|
||||
if originJob == nil {
|
||||
return nil, fmt.Errorf("job %s not found in origin workflow", id)
|
||||
}
|
||||
for _, matrix := range matricxes {
|
||||
job := job.Clone()
|
||||
if job.Name == "" {
|
||||
job.Name = id
|
||||
|
||||
var combos []*Job
|
||||
if HasDeferredMatrix(job) {
|
||||
// The matrix reads values that do not exist yet (a needs output), so emit a single
|
||||
// placeholder keeping it raw. Re-parsing that placeholder's payload yields it again,
|
||||
// and the server expands it once the needs finish.
|
||||
placeholder := job.Clone()
|
||||
if placeholder.Name == "" {
|
||||
placeholder.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)
|
||||
combos = []*Job{placeholder}
|
||||
} else {
|
||||
matricxes, err := getMatrixes(originJob)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getMatrixes: %w", err)
|
||||
}
|
||||
job.RawRunsOn = encodeRunsOn(runsOn)
|
||||
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
|
||||
return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", id, err)
|
||||
if combos, err = buildMatrixCombos(id, job, matricxes, originJob, pc.gitContext, results, pc.vars, pc.inputs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
}
|
||||
for _, combo := range combos {
|
||||
swf := workflow.cloneHeader()
|
||||
if err := swf.SetJob(id, combo); err != nil {
|
||||
return nil, fmt.Errorf("SetJob: %w", err)
|
||||
}
|
||||
ret = append(ret, swf)
|
||||
@@ -89,6 +134,121 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// cloneHeader returns a copy of w with its workflow-global fields but no jobs.
|
||||
func (w *SingleWorkflow) cloneHeader() *SingleWorkflow {
|
||||
return &SingleWorkflow{
|
||||
Name: w.Name,
|
||||
RawOn: w.RawOn,
|
||||
Env: w.Env,
|
||||
Defaults: w.Defaults,
|
||||
RawPermissions: w.RawPermissions,
|
||||
RunName: w.RunName,
|
||||
}
|
||||
}
|
||||
|
||||
// ExpandMatrixWithNeeds returns one Job per combination of job's matrix, resolved against the
|
||||
// completed needs in results. As for EvaluateConcurrency, results must also describe jobID itself,
|
||||
// which is where NewInterpeter reads the job's own needs from.
|
||||
// maxCombinations caps how many combinations may be built: the values come from a needs output at
|
||||
// runtime, so the cap has to be enforced before one Job is materialized per combination.
|
||||
func ExpandMatrixWithNeeds(jobID string, job *Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any, maxCombinations int) ([]*Job, error) {
|
||||
actJob := &model.Job{Strategy: &model.Strategy{
|
||||
FailFastString: job.Strategy.FailFastString,
|
||||
MaxParallelString: job.Strategy.MaxParallelString,
|
||||
RawMatrix: job.Strategy.RawMatrix,
|
||||
}}
|
||||
|
||||
// Resolve fromJson(needs.*.outputs.*) and friends into concrete matrix values.
|
||||
if err := NewExpressionEvaluator(NewInterpeter(jobID, actJob, nil, gitCtx, results, vars, inputs)).
|
||||
EvaluateYamlNode(&actJob.Strategy.RawMatrix); err != nil {
|
||||
return nil, fmt.Errorf("evaluate matrix: %w", err)
|
||||
}
|
||||
matrixes, err := getMatrixes(actJob)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getMatrixes: %w", err)
|
||||
}
|
||||
// act collapses a matrix that yields no combination (an empty vector or include, everything
|
||||
// excluded, a whole-matrix expression that is not a mapping) into one empty combination, which
|
||||
// would run the job once unparameterized. GitHub rejects such a matrix, so reject it too.
|
||||
if len(matrixes) == 1 && len(matrixes[0]) == 0 {
|
||||
return nil, errors.New("matrix must define at least one vector")
|
||||
}
|
||||
if len(matrixes) > maxCombinations {
|
||||
return nil, fmt.Errorf("matrix expands to %d combinations, exceeding the limit of %d", len(matrixes), maxCombinations)
|
||||
}
|
||||
return buildMatrixCombos(jobID, job, matrixes, actJob, gitCtx, results, vars, inputs)
|
||||
}
|
||||
|
||||
// matrixesOf is this package's only entry to act's GetMatrixes, so that every caller is covered by
|
||||
// the filter check below. A deferred placeholder is the first thing carrying a raw matrix this far,
|
||||
// and the emitter reads its `if:` before expanding it.
|
||||
// TODO: drop the check once gitea.com/gitea/runner validates the shape itself.
|
||||
func matrixesOf(job *model.Job) ([]map[string]any, error) {
|
||||
if err := validateMatrixFilters(job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matrixes, err := job.GetMatrixes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetMatrixes: %w", err)
|
||||
}
|
||||
return matrixes, nil
|
||||
}
|
||||
|
||||
// validateMatrixFilters rejects an `include`/`exclude` that is not a list of mappings. act asserts
|
||||
// that shape without checking, so anything else panics there; an unevaluated ${{ }} expression, which
|
||||
// is still a scalar, is the usual way to reach it.
|
||||
func validateMatrixFilters(job *model.Job) error {
|
||||
if job.Strategy == nil || job.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
content := job.Strategy.RawMatrix.Content
|
||||
for i := 0; i+1 < len(content); i += 2 {
|
||||
name, value := content[i].Value, content[i+1]
|
||||
if name != "include" && name != "exclude" {
|
||||
continue
|
||||
}
|
||||
entries := []*yaml.Node{value}
|
||||
if value.Kind == yaml.SequenceNode {
|
||||
entries = value.Content
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Kind == yaml.AliasNode {
|
||||
entry = entry.Alias
|
||||
}
|
||||
if entry.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("matrix %s must be a list of mappings", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMatrixCombos builds one Job per matrix combination from src, baking the combination into the
|
||||
// strategy and interpolating the name, runs-on and continue-on-error with it.
|
||||
func buildMatrixCombos(jobID string, src *Job, matrixes []map[string]any, actJob *model.Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) {
|
||||
srcRunsOn := src.RunsOn()
|
||||
combos := make([]*Job, 0, len(matrixes))
|
||||
for _, matrix := range matrixes {
|
||||
combo := src.Clone()
|
||||
if combo.Name == "" {
|
||||
combo.Name = jobID
|
||||
}
|
||||
combo.Strategy.RawMatrix = encodeMatrix(matrix)
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, gitCtx, results, vars, inputs))
|
||||
combo.Name = nameWithMatrix(combo.Name, matrix, evaluator)
|
||||
runsOn := slices.Clone(srcRunsOn)
|
||||
for i := range runsOn {
|
||||
runsOn[i] = evaluator.Interpolate(runsOn[i])
|
||||
}
|
||||
combo.RawRunsOn = encodeRunsOn(runsOn)
|
||||
if err := evaluator.EvaluateYamlNode(&combo.RawContinueOnError); err != nil {
|
||||
return nil, fmt.Errorf("evaluate continue-on-error for job %q: %w", jobID, err)
|
||||
}
|
||||
combos = append(combos, combo)
|
||||
}
|
||||
return combos, nil
|
||||
}
|
||||
|
||||
func WithGitContext(context *model.GithubContext) ParseOption {
|
||||
return func(c *parseContext) {
|
||||
c.gitContext = context
|
||||
@@ -117,9 +277,9 @@ type parseContext struct {
|
||||
type ParseOption func(c *parseContext)
|
||||
|
||||
func getMatrixes(job *model.Job) ([]map[string]any, error) {
|
||||
ret, err := job.GetMatrixes()
|
||||
ret, err := matrixesOf(job)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetMatrixes: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(ret, func(i, j int) bool {
|
||||
return matrixName(ret[i]) < matrixName(ret[j])
|
||||
|
||||
Reference in New Issue
Block a user