diff --git a/modules/actions/jobparser/jobparser.go b/modules/actions/jobparser/jobparser.go index 88b2f09b10..b1dcf728b4 100644 --- a/modules/actions/jobparser/jobparser.go +++ b/modules/actions/jobparser/jobparser.go @@ -92,7 +92,7 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) { } // Clone + pre-evaluate only when the matrix has a ${{ }} expression; static matrices use - // the origin job as-is. Unresolved expressions defer to ReEvaluateMatrixForJobWithNeeds. + // the origin job as-is. evaluatedJob := originJob if originJob.Strategy != nil && rawMatrixHasExpression(&originJob.Strategy.RawMatrix) { jobCopy := *originJob @@ -102,7 +102,21 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) { evaluatedJob = &jobCopy matrixEvaluator := NewExpressionEvaluator(NewInterpeter(id, evaluatedJob, nil, pc.gitContext, results, pc.vars, pc.inputs)) if err := matrixEvaluator.EvaluateYamlNode(&evaluatedJob.Strategy.RawMatrix); err != nil { - log.Debug("matrix evaluation deferred for job %s (unresolved expression): %v", id, err) + // Matrix references needs.*.outputs.* (unavailable now). Emit one placeholder + // keeping the raw strategy; the server expands it once the needs finish. + if len(originJob.Needs()) > 0 { + placeholder := job.Clone() + if placeholder.Name == "" { + placeholder.Name = id + } + swf := newSingleWorkflow(workflow) + if err := swf.SetJob(id, placeholder); err != nil { + return nil, fmt.Errorf("SetJob: %w", err) + } + ret = append(ret, swf) + continue + } + log.Debug("matrix evaluation for job %s left unresolved (no needs): %v", id, err) } } @@ -111,27 +125,8 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) { 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, evaluatedJob, matrix, pc.gitContext, results, pc.vars, pc.inputs)) - job.Name = nameWithMatrix(job.Name, matrix, evaluator) - runsOn := evaluatedJob.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 { + swf := newSingleWorkflow(workflow) + if err := swf.SetJob(id, expandJobCombo(id, job, matrix, evaluatedJob, pc.gitContext, results, pc.vars, pc.inputs)); err != nil { return nil, fmt.Errorf("SetJob: %w", err) } ret = append(ret, swf) @@ -140,6 +135,77 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) { return ret, nil } +// newSingleWorkflow returns a SingleWorkflow carrying w's global fields and no job. +func newSingleWorkflow(w *SingleWorkflow) *SingleWorkflow { + return &SingleWorkflow{ + Name: w.Name, + RawOn: w.RawOn, + Env: w.Env, + Defaults: w.Defaults, + RawPermissions: w.RawPermissions, + RunName: w.RunName, + } +} + +// expandJobCombo builds the Job for one matrix combination: it bakes the matrix into the strategy +// and interpolates the name and runs-on. actJob drives the interpreter (it supplies the job's +// needs and strategy contexts) and may differ from src (an act model.Job vs the source *Job). +func expandJobCombo(jobID string, src *Job, matrix map[string]any, actJob *model.Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) *Job { + 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 := combo.RunsOn() + for i := range runsOn { + runsOn[i] = evaluator.Interpolate(runsOn[i]) + } + combo.RawRunsOn = encodeRunsOn(runsOn) + return combo +} + +// RawMatrixHasExpression reports whether the job's matrix contains a ${{ }} expression. +// With needs present, this marks a matrix whose expansion is deferred until they complete. +func RawMatrixHasExpression(job *Job) bool { + return rawMatrixHasExpression(&job.Strategy.RawMatrix) +} + +// ExpandMatrixWithNeeds expands job's matrix once its needs complete, returning one Job per +// combination. Like EvaluateConcurrency it evaluates against the needs context via NewInterpeter, +// with no workflow re-parse or stub jobs. job must carry its raw matrix and needs (RawNeeds). +func ExpandMatrixWithNeeds(jobID string, job *Job, gitCtx *model.GithubContext, results map[string]*JobResult, vars map[string]string, inputs map[string]any) ([]*Job, error) { + var rawNeeds yaml.Node + if err := rawNeeds.Encode(job.Needs()); err != nil { + return nil, fmt.Errorf("encode needs: %w", err) + } + actJob := &model.Job{ + RawNeeds: rawNeeds, + Strategy: &model.Strategy{ + FailFastString: job.Strategy.FailFastString, + MaxParallelString: job.Strategy.MaxParallelString, + RawMatrix: *deepCopyYamlNode(&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) + } + + expanded := make([]*Job, 0, len(matrixes)) + for _, matrix := range matrixes { + expanded = append(expanded, expandJobCombo(jobID, job, matrix, actJob, gitCtx, results, vars, inputs)) + } + return expanded, nil +} + func WithJobResults(results map[string]string) ParseOption { return func(c *parseContext) { c.jobResults = results diff --git a/modules/actions/jobparser/jobparser_test.go b/modules/actions/jobparser/jobparser_test.go index e60cd5cb73..d638a50a85 100644 --- a/modules/actions/jobparser/jobparser_test.go +++ b/modules/actions/jobparser/jobparser_test.go @@ -533,3 +533,91 @@ jobs: } }) } + +func TestParseDefersDynamicMatrix(t *testing.T) { + // A matrix referencing needs outputs is emitted as a single placeholder retaining the raw + // expression, rather than being expanded or split per static value. + workflowYAML := ` +name: test-defer +on: push + +jobs: + setup: + runs-on: ubuntu-latest + steps: + - run: echo setup + + build: + needs: setup + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + version: ${{ fromJson(needs.setup.outputs.versions) }} + steps: + - run: echo build +` + result, err := Parse([]byte(workflowYAML)) + require.NoError(t, err) + + var buildJobs []*Job + for _, w := range result { + if id, j := w.Job(); id == "build" { + buildJobs = append(buildJobs, j) + } + } + require.Len(t, buildJobs, 1, "deferred matrix must yield exactly one placeholder") + assert.True(t, RawMatrixHasExpression(buildJobs[0]), "placeholder must keep the raw matrix expression") +} + +func TestExpandMatrixWithNeeds(t *testing.T) { + buildJob := func(t *testing.T, matrixYAML, runsOn string, needs []string) *Job { + t.Helper() + var strat Strategy + require.NoError(t, yaml.Unmarshal([]byte(matrixYAML), &strat)) + job := &Job{Name: "build", Strategy: strat} + require.NoError(t, job.RawRunsOn.Encode(runsOn)) + require.NoError(t, job.RawNeeds.Encode(needs)) + return job + } + + results := map[string]*JobResult{ + "setup": {Result: "success", Outputs: map[string]string{ + "versions": "[1.20, 1.21]", + "platforms": `["linux", "darwin"]`, + }}, + } + + t.Run("single dynamic dimension", func(t *testing.T) { + job := buildJob(t, "matrix:\n version: ${{ fromJson(needs.setup.outputs.versions) }}\n", "ubuntu-latest", []string{"setup"}) + got, err := ExpandMatrixWithNeeds("build", job, &model.GithubContext{}, results, nil, nil) + require.NoError(t, err) + assert.Len(t, got, 2) + }) + + t.Run("static and dynamic expand to product once", func(t *testing.T) { + // 2 static os * 2 dynamic versions = 4, expanded once (regression: not split then re-expanded). + job := buildJob(t, "matrix:\n os: [ubuntu-latest, windows-latest]\n version: ${{ fromJson(needs.setup.outputs.versions) }}\n", "${{ matrix.os }}", []string{"setup"}) + got, err := ExpandMatrixWithNeeds("build", job, &model.GithubContext{}, results, nil, nil) + require.NoError(t, err) + assert.Len(t, got, 4) + for _, combo := range got { + runsOn := combo.RunsOn() + require.Len(t, runsOn, 1) + assert.Contains(t, []string{"ubuntu-latest", "windows-latest"}, runsOn[0], "runs-on must be interpolated from matrix.os") + } + }) + + t.Run("multiple dynamic dimensions", func(t *testing.T) { + job := buildJob(t, "matrix:\n version: ${{ fromJson(needs.setup.outputs.versions) }}\n platform: ${{ fromJson(needs.setup.outputs.platforms) }}\n", "ubuntu-latest", []string{"setup"}) + got, err := ExpandMatrixWithNeeds("build", job, &model.GithubContext{}, results, nil, nil) + require.NoError(t, err) + assert.Len(t, got, 4) + }) + + t.Run("unresolved needs output errors", func(t *testing.T) { + job := buildJob(t, "matrix:\n version: ${{ fromJson(needs.missing.outputs.versions) }}\n", "ubuntu-latest", []string{"missing"}) + _, err := ExpandMatrixWithNeeds("build", job, &model.GithubContext{}, map[string]*JobResult{}, nil, nil) + require.Error(t, err) + }) +} diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index 1e60b5506a..91449a1340 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -46,6 +46,11 @@ func CreateCommitStatusForRunJobs(ctx context.Context, run *actions_model.Action } for _, job := range jobs { + // A deferred-matrix placeholder's name changes when it expands, so a status created now + // would be orphaned; the expanded combos get theirs at expansion time. + if job.RawStrategy != "" && !job.IsMatrixEvaluated { + continue + } if err = createCommitStatus(ctx, run.Repo, event, commitID, run, job); err != nil { log.Error("Failed to create commit status for job %d: %v", job.ID, err) } diff --git a/services/actions/matrix.go b/services/actions/matrix.go index 6df1bba131..40eea01b87 100644 --- a/services/actions/matrix.go +++ b/services/actions/matrix.go @@ -7,10 +7,6 @@ import ( "context" "errors" "fmt" - "maps" - "slices" - "sort" - "strings" actions_model "gitea.dev/models/actions" "gitea.dev/models/db" @@ -60,128 +56,9 @@ func checkTaskNeedsReady(ctx context.Context, job *actions_model.ActionRunJob) ( return taskNeeds, true, nil } -// ExtractRawStrategies extracts strategy definitions from the raw workflow content. -// Returns a map of jobID to strategy YAML for jobs that have matrix dependencies. -func ExtractRawStrategies(content []byte) (map[string]string, error) { - var workflowDef struct { - Jobs map[string]struct { - Strategy any `yaml:"strategy"` - Needs any `yaml:"needs"` - } `yaml:"jobs"` - } - - if err := yaml.Unmarshal(content, &workflowDef); err != nil { - return nil, err - } - - strategies := make(map[string]string) - for jobID, jobDef := range workflowDef.Jobs { - if jobDef.Strategy == nil { - continue - } - - var needsList []string - switch needs := jobDef.Needs.(type) { - case string: - needsList = append(needsList, needs) - case []any: - for _, need := range needs { - if needStr, ok := need.(string); ok { - needsList = append(needsList, needStr) - } - } - } - - if len(needsList) > 0 { - if strategyBytes, err := yaml.Marshal(jobDef.Strategy); err == nil { - strategies[jobID] = string(strategyBytes) - } - } - } - - return strategies, nil -} - -// HasMatrixWithNeeds reports whether rawStrategy contains a matrix value whose -// expression tree references needs..outputs.. -// It walks the parsed YAML tree to avoid false positives from values such as -// "os: [needs.review-runner]" that merely contain the substring "needs.". -func HasMatrixWithNeeds(rawStrategy string) bool { - if rawStrategy == "" { - return false - } - - var root yaml.Node - if err := yaml.Unmarshal([]byte(rawStrategy), &root); err != nil { - return false - } - - // The top-level document node wraps a single mapping node. - doc := &root - if doc.Kind == yaml.DocumentNode && len(doc.Content) == 1 { - doc = doc.Content[0] - } - - // Find the "matrix" key inside the strategy mapping. - var matrixNode *yaml.Node - if doc.Kind == yaml.MappingNode { - for i := 0; i+1 < len(doc.Content); i += 2 { - if doc.Content[i].Value == "matrix" { - matrixNode = doc.Content[i+1] - break - } - } - } - if matrixNode == nil { - return false - } - - return yamlNodeContainsNeedsOutputsExpr(matrixNode) -} - -// yamlNodeContainsNeedsOutputsExpr recursively inspects a yaml.Node and -// returns true if any scalar value contains a GitHub Actions expression of -// the form ${{ ... needs..outputs. ... }}. -func yamlNodeContainsNeedsOutputsExpr(node *yaml.Node) bool { - if node == nil { - return false - } - if node.Kind == yaml.ScalarNode { - return containsNeedsOutputsExpr(node.Value) - } - return slices.ContainsFunc(node.Content, yamlNodeContainsNeedsOutputsExpr) -} - -// containsNeedsOutputsExpr returns true when s contains an Actions expression -// (${{ ... }}) that references needs..outputs.. -// A bare "needs." substring outside an expression block is not a match. -func containsNeedsOutputsExpr(s string) bool { - if !strings.Contains(s, "${{") { - return false - } - for i := 0; i < len(s); { - start := strings.Index(s[i:], "${{") - if start == -1 { - break - } - start += i - end := strings.Index(s[start:], "}}") - if end == -1 { - break - } - end += start - expr := s[start : end+2] - if strings.Contains(expr, "needs.") && strings.Contains(expr, ".outputs.") { - return true - } - i = end + 2 - } - return false -} - -// ReEvaluateMatrixForJobWithNeeds re-evaluates the matrix strategy of a job once all its -// dependent jobs are done. It expands the matrix using job outputs and inserts the resulting -// ActionRunJobs. The original placeholder job is marked as evaluated and skipped. +// ReEvaluateMatrixForJobWithNeeds expands the matrix strategy of a job once all its dependent +// jobs are done, using their outputs, and inserts the resulting ActionRunJobs. The original +// placeholder job is reused as the first combination. // Returns nil, nil if the job is not ready yet or has nothing to do. func ReEvaluateMatrixForJobWithNeeds(ctx context.Context, job *actions_model.ActionRunJob, vars map[string]string) ([]*actions_model.ActionRunJob, error) { if job.IsMatrixEvaluated || job.RawStrategy == "" { @@ -191,8 +68,8 @@ func ReEvaluateMatrixForJobWithNeeds(ctx context.Context, job *actions_model.Act log.Debug("Starting matrix re-evaluation for job %d (JobID: %s)", job.ID, job.JobID) // skipWithError marks the job as evaluated+skipped and wraps any secondary error. - skipWithError := func(reason string, origErr error) ([]*actions_model.ActionRunJob, error) { - if markErr := markMatrixAsEvaluatedAndSkip(ctx, job, reason); markErr != nil { + skipWithError := func(origErr error) ([]*actions_model.ActionRunJob, error) { + if markErr := markMatrixAsEvaluatedAndSkip(ctx, job, origErr.Error()); markErr != nil { return nil, fmt.Errorf("%w; additionally failed to mark as evaluated: %v", origErr, markErr) } return nil, origErr @@ -201,7 +78,7 @@ func ReEvaluateMatrixForJobWithNeeds(ctx context.Context, job *actions_model.Act taskNeeds, allDone, err := checkTaskNeedsReady(ctx, job) if err != nil { log.Error("Matrix re-evaluation error for job %d: check task needs: %v", job.ID, err) - return skipWithError(fmt.Sprintf("task needs check failed: %v", err), fmt.Errorf("check task needs: %w", err)) + return skipWithError(fmt.Errorf("check task needs: %w", err)) } if !allDone { return nil, nil @@ -221,30 +98,36 @@ func ReEvaluateMatrixForJobWithNeeds(ctx context.Context, job *actions_model.Act giteaCtx := GenerateGiteaContext(ctx, job.Run, nil, job) - jobOutputs := make(map[string]map[string]string, len(taskNeeds)) - jobResults := make(map[string]string, len(taskNeeds)) - for jobID, need := range taskNeeds { - jobOutputs[jobID] = need.Outputs - jobResults[jobID] = need.Result.String() + results := make(map[string]*jobparser.JobResult, len(taskNeeds)) + for needID, need := range taskNeeds { + results[needID] = &jobparser.JobResult{Result: need.Result.String(), Outputs: need.Outputs} } - workflowYAML, err := constructWorkflowWithNeeds(job, taskNeeds) + // Rebuild the job from its own payload + stored raw strategy and re-attach needs (erased from + // the payload) so needs.*.outputs.* resolves. No synthetic workflow, no stub jobs, no re-parse. + var baseSWF jobparser.SingleWorkflow + if err := yaml.Unmarshal(job.WorkflowPayload, &baseSWF); err != nil { + return skipWithError(fmt.Errorf("unmarshal payload: %w", err)) + } + _, parsedJob := baseSWF.Job() + if parsedJob == nil { + return skipWithError(errors.New("payload contains no job")) + } + var rawStrategy jobparser.Strategy + if err := yaml.Unmarshal([]byte(job.RawStrategy), &rawStrategy); err != nil { + return skipWithError(fmt.Errorf("unmarshal raw strategy: %w", err)) + } + parsedJob.Strategy = rawStrategy + if err := parsedJob.RawNeeds.Encode(job.Needs); err != nil { + return skipWithError(fmt.Errorf("encode needs: %w", err)) + } + + expandedJobs, err := jobparser.ExpandMatrixWithNeeds(job.JobID, parsedJob, giteaCtx.ToGitHubContext(), results, vars, nil) if err != nil { - return skipWithError(fmt.Sprintf("workflow construction failed: %v", err), fmt.Errorf("construct workflow: %w", err)) + return nil, markMatrixAsEvaluatedAndSkip(ctx, job, fmt.Sprintf("matrix expansion failed: %v", err)) } - parsedJobs, err := jobparser.Parse( - workflowYAML, - jobparser.WithVars(vars), - jobparser.WithGitContext(giteaCtx.ToGitHubContext()), - jobparser.WithJobOutputs(jobOutputs), - jobparser.WithJobResults(jobResults), - ) - if err != nil { - return nil, markMatrixAsEvaluatedAndSkip(ctx, job, fmt.Sprintf("parse failed: %v", err)) - } - - // One parsed workflow per combination; needs are kept on the model and erased from the + // One workflow payload per combination; needs are kept on the model and erased from the // payload, as at initial planning time. type matrixCombo struct { name string @@ -252,18 +135,15 @@ func ReEvaluateMatrixForJobWithNeeds(ctx context.Context, job *actions_model.Act runsOn []string needs []string } - var combos []matrixCombo - for _, sw := range parsedJobs { - id, jobDef := sw.Job() - if jobDef == nil || id != job.JobID { - continue + combos := make([]matrixCombo, 0, len(expandedJobs)) + for _, expanded := range expandedJobs { + combo := matrixCombo{name: expanded.Name, runsOn: expanded.RunsOn(), needs: expanded.Needs()} + swf := baseSWF + if err := swf.SetJob(job.JobID, expanded.EraseNeeds()); err != nil { + return nil, fmt.Errorf("set expanded job %s: %w", job.JobID, err) } - combo := matrixCombo{name: jobDef.Name, runsOn: jobDef.RunsOn(), needs: jobDef.Needs()} - if err := sw.SetJob(id, jobDef.EraseNeeds()); err != nil { - return nil, fmt.Errorf("erase needs for job %s: %w", id, err) - } - if combo.payload, err = sw.Marshal(); err != nil { - return nil, fmt.Errorf("marshal expanded job %s: %w", id, err) + if combo.payload, err = swf.Marshal(); err != nil { + return nil, fmt.Errorf("marshal expanded job %s: %w", job.JobID, err) } combos = append(combos, combo) } @@ -323,65 +203,3 @@ func ReEvaluateMatrixForJobWithNeeds(ctx context.Context, job *actions_model.Act return children, nil } - -// constructWorkflowWithNeeds creates a workflow YAML that includes the target job -// and stub definitions for its dependencies so the jobparser can resolve needs.*.outputs expressions. -func constructWorkflowWithNeeds(job *actions_model.ActionRunJob, taskNeeds map[string]*TaskNeed) ([]byte, error) { - var jobWorkflow map[string]any - if err := yaml.Unmarshal(job.WorkflowPayload, &jobWorkflow); err != nil { - return nil, fmt.Errorf("unmarshal job workflow: %w", err) - } - - jobsSection, ok := jobWorkflow["jobs"].(map[string]any) - if !ok { - return nil, errors.New("invalid jobs section in workflow") - } - - newJobs := make(map[string]any) - - for needJobID, taskNeed := range taskNeeds { - stubJob := map[string]any{ - "runs-on": "ubuntu-latest", - "outputs": taskNeed.Outputs, - "steps": []any{}, - } - newJobs[needJobID] = stubJob - } - - maps.Copy(newJobs, jobsSection) - - // The WorkflowPayload may contain a normalised/wrapped matrix (e.g. - // version: ["${{ fromJson(...) }}"]). Restore the original scalar expression - // from RawStrategy so jobparser.Parse() can expand it correctly with job outputs. - // Also drop the pre-baked "name" so jobparser regenerates it per matrix combination - // (e.g. "build (1)", "build (2)", …) instead of "build (Array)". - // Critically, re-add "needs" because EraseNeeds() removed them from WorkflowPayload: - // without needs, NewInterpeter builds an empty Needs context and - // "needs.generate.outputs.*" expressions can never be evaluated. - if targetJobDef, ok := newJobs[job.JobID]; ok { - if targetJobMap, ok := targetJobDef.(map[string]any); ok { - delete(targetJobMap, "name") - needsKeys := make([]string, 0, len(taskNeeds)) - for needJobID := range taskNeeds { - needsKeys = append(needsKeys, needJobID) - } - sort.Strings(needsKeys) - targetJobMap["needs"] = needsKeys - if job.RawStrategy != "" { - var rawStrategyMap map[string]any - if err := yaml.Unmarshal([]byte(job.RawStrategy), &rawStrategyMap); err == nil { - targetJobMap["strategy"] = rawStrategyMap - } - } - newJobs[job.JobID] = targetJobMap - } - } - - workflow := map[string]any{ - "name": "matrix-expansion", - "on": "push", - "jobs": newJobs, - } - - return yaml.Marshal(workflow) -} diff --git a/services/actions/matrix_test.go b/services/actions/matrix_test.go deleted file mode 100644 index 45fba1a80c..0000000000 --- a/services/actions/matrix_test.go +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package actions - -import ( - "testing" - - actions_model "gitea.dev/models/actions" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.yaml.in/yaml/v4" -) - -func TestHasMatrixWithNeeds(t *testing.T) { - tests := []struct { - name string - strategy string - want bool - }{ - { - name: "dynamic matrix referencing job output", - strategy: ` -matrix: - version: ${{ fromJson(needs.generate.outputs.matrix) }} -`, - want: true, - }, - { - name: "static matrix — no expression", - strategy: ` -matrix: - os: [ubuntu-latest, windows-latest] -`, - want: false, - }, - { - name: "value contains needs. but not inside expression", - strategy: ` -matrix: - os: [needs.review-runner] -`, - want: false, - }, - { - name: "needs. outside expression block", - strategy: ` -matrix: - runner: needs.something-but-no-braces -`, - want: false, - }, - { - name: "expression with needs but no .outputs.", - strategy: ` -matrix: - version: ${{ needs.job1 }} -`, - want: false, - }, - { - name: "empty strategy", - strategy: "", - want: false, - }, - { - name: "strategy without matrix key", - strategy: ` -fail-fast: false -`, - want: false, - }, - { - name: "multi-dimension dynamic matrix", - strategy: ` -matrix: - os: [ubuntu-latest, windows-latest] - version: ${{ fromJson(needs.setup.outputs.versions) }} -`, - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, HasMatrixWithNeeds(tt.strategy)) - }) - } -} - -func TestConstructWorkflowWithNeeds(t *testing.T) { - // Minimal WorkflowPayload with a strategy referencing a needs output. - payload, err := yaml.Marshal(map[string]any{ - "jobs": map[string]any{ - "build": map[string]any{ - "runs-on": "ubuntu-latest", - "strategy": map[string]any{ - "matrix": map[string]any{ - "version": `${{ fromJson(needs.setup.outputs.versions) }}`, - }, - }, - "steps": []any{}, - }, - }, - }) - require.NoError(t, err) - - job := &actions_model.ActionRunJob{ - JobID: "build", - WorkflowPayload: payload, - RawStrategy: ` -matrix: - version: ${{ fromJson(needs.setup.outputs.versions) }} -`, - Needs: []string{"setup"}, - } - needs := map[string]*TaskNeed{ - "setup": { - Result: actions_model.StatusSuccess, - Outputs: map[string]string{"versions": `["1.20","1.21"]`}, - }, - } - - out, err := constructWorkflowWithNeeds(job, needs) - require.NoError(t, err) - - // The resulting YAML should contain both the build job and a stub for setup. - var wf map[string]any - require.NoError(t, yaml.Unmarshal(out, &wf)) - - jobs, ok := wf["jobs"].(map[string]any) - require.True(t, ok) - assert.Contains(t, jobs, "build") - assert.Contains(t, jobs, "setup", "stub for needs dependency must be present") - - // build job needs must list the dependency - buildJob, ok := jobs["build"].(map[string]any) - require.True(t, ok) - assert.Contains(t, buildJob, "needs") - - // RawStrategy must be re-injected (not the pre-baked array form) - strategy, ok := buildJob["strategy"].(map[string]any) - require.True(t, ok) - matrix, ok := strategy["matrix"].(map[string]any) - require.True(t, ok) - versionExpr, ok := matrix["version"].(string) - require.True(t, ok) - assert.Contains(t, versionExpr, "fromJson") -} - -func TestConstructWorkflowWithNeeds_InvalidPayload(t *testing.T) { - job := &actions_model.ActionRunJob{ - JobID: "build", - WorkflowPayload: []byte("not: valid: yaml: ["), - } - _, err := constructWorkflowWithNeeds(job, nil) - assert.Error(t, err) -} - -func TestConstructWorkflowWithNeeds_MissingJobsSection(t *testing.T) { - payload, err := yaml.Marshal(map[string]any{"name": "test"}) - require.NoError(t, err) - - job := &actions_model.ActionRunJob{ - JobID: "build", - WorkflowPayload: payload, - } - _, err = constructWorkflowWithNeeds(job, nil) - assert.Error(t, err) -} diff --git a/services/actions/run.go b/services/actions/run.go index c89d24f590..3b7c26484b 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -10,7 +10,6 @@ import ( actions_model "gitea.dev/models/actions" "gitea.dev/models/db" "gitea.dev/modules/actions/jobparser" - "gitea.dev/modules/log" "gitea.dev/modules/util" act_model "gitea.com/gitea/runner/act/model" @@ -129,15 +128,10 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs)) var hasWaitingJobs bool - rawStrategies, err := ExtractRawStrategies(content) - if err != nil { - log.Warn("Failed to extract raw strategies for run %d: %v", run.ID, err) - rawStrategies = nil - } - for i, v := range jobs { id, job := v.Job() needs := job.Needs() + isDeferredMatrix := len(needs) > 0 && jobparser.RawMatrixHasExpression(job) if err := v.SetJob(id, job.EraseNeeds()); err != nil { return err } @@ -167,11 +161,14 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte runJob.TokenPermissions = perms } - // Store the raw strategy for jobs whose matrix references needs outputs. - // ReEvaluateMatrixForJobWithNeeds uses this to re-expand the matrix once - // the dependency jobs have completed and their outputs are available. - if rawStrategy, ok := rawStrategies[id]; ok && HasMatrixWithNeeds(rawStrategy) { - runJob.RawStrategy = rawStrategy + // Matrix references needs outputs: jobparser emitted a placeholder. Store the raw + // strategy for ReEvaluateMatrixForJobWithNeeds to expand once the needs finish. + if isDeferredMatrix { + rawStrategy, err := yaml.Marshal(&job.Strategy) + if err != nil { + return fmt.Errorf("marshal raw strategy for job %s: %w", id, err) + } + runJob.RawStrategy = string(rawStrategy) } // check job concurrency