@@ -5,15 +5,61 @@ package jobparser
55
66import (
77 "bytes"
8+ "errors"
89 "fmt"
10+ "slices"
911 "sort"
1012 "strings"
1113
1214 "gitea.com/gitea/runner/act/exprparser"
1315 "gitea.com/gitea/runner/act/model"
16+ "github.com/rhysd/actionlint"
1417 "go.yaml.in/yaml/v4"
1518)
1619
20+ // HasDeferredMatrix reports whether the job's matrix can only be expanded once its needs finish:
21+ // it reads the needs context and the job has needs to resolve that context against.
22+ // Parse emits such a job as a single placeholder rather than one job per combination, so every
23+ // caller that persists a job must agree with Parse on this condition.
24+ func HasDeferredMatrix (job * Job ) bool {
25+ return len (job .Needs ()) > 0 && rawMatrixReadsNeeds (& job .Strategy .RawMatrix )
26+ }
27+
28+ func rawMatrixReadsNeeds (node * yaml.Node ) bool {
29+ if node .Kind == yaml .ScalarNode {
30+ return expressionReadsNeeds (node .Value )
31+ }
32+ return slices .ContainsFunc (node .Content , rawMatrixReadsNeeds )
33+ }
34+
35+ // expressionReadsNeeds reports whether value holds a ${{ }} expression reading the needs context.
36+ // Every other context (github, vars, inputs, ...) is already available while planning, so deferring
37+ // those too would replace their combinations with one placeholder and change the commit status
38+ // contexts the run publishes, which a repository's required checks are configured against.
39+ func expressionReadsNeeds (value string ) bool {
40+ for rest := value ; ; {
41+ _ , after , found := strings .Cut (rest , "${{" )
42+ if ! found {
43+ return false
44+ }
45+ rest = after
46+ // The lexer ends the expression at its closing `}}`, so it can be handed the whole remainder.
47+ expr , err := actionlint .NewExprParser ().Parse (actionlint .NewExprLexer (rest ))
48+ if err != nil {
49+ return true // unparseable here, let the expansion report it against the real values
50+ }
51+ readsNeeds := false
52+ actionlint .VisitExprNode (expr , func (node , _ actionlint.ExprNode , entering bool ) {
53+ if variable , ok := node .(* actionlint.VariableNode ); entering && ok && strings .EqualFold (variable .Name , "needs" ) {
54+ readsNeeds = true
55+ }
56+ })
57+ if readsNeeds {
58+ return true
59+ }
60+ }
61+ }
62+
1763func Parse (content []byte , options ... ParseOption ) ([]* SingleWorkflow , error ) {
1864 origin , err := model .ReadWorkflow (bytes .NewReader (content ))
1965 if err != nil {
@@ -37,7 +83,7 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
3783 results [id ] = & JobResult {
3884 Needs : job .Needs (),
3985 Result : pc .jobResults [id ],
40- Outputs : nil , // not supported yet
86+ Outputs : nil , // resolved at expansion time, not at plan time
4187 }
4288 }
4389
@@ -52,35 +98,34 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
5298
5399 for i , id := range ids {
54100 job := jobs [i ]
55- matricxes , err := getMatrixes (origin .GetJob (id ))
56- if err != nil {
57- return nil , fmt .Errorf ("getMatrixes: %w" , err )
101+ originJob := origin .GetJob (id )
102+
103+ if originJob == nil {
104+ return nil , fmt .Errorf ("job %s not found in origin workflow" , id )
58105 }
59- for _ , matrix := range matricxes {
60- job := job .Clone ()
61- if job .Name == "" {
62- job .Name = id
63- }
64- job .Strategy .RawMatrix = encodeMatrix (matrix )
65- evaluator := NewExpressionEvaluator (NewInterpeter (id , origin .GetJob (id ), matrix , pc .gitContext , results , pc .vars , pc .inputs ))
66- job .Name = nameWithMatrix (job .Name , matrix , evaluator )
67- runsOn := origin .GetJob (id ).RunsOn ()
68- for i , v := range runsOn {
69- runsOn [i ] = evaluator .Interpolate (v )
106+
107+ var combos []* Job
108+ if HasDeferredMatrix (job ) {
109+ // The matrix reads values that do not exist yet (a needs output), so emit a single
110+ // placeholder keeping it raw. Re-parsing that placeholder's payload yields it again,
111+ // and the server expands it once the needs finish.
112+ placeholder := job .Clone ()
113+ if placeholder .Name == "" {
114+ placeholder .Name = id
70115 }
71- job .RawRunsOn = encodeRunsOn (runsOn )
72- if err := evaluator .EvaluateYamlNode (& job .RawContinueOnError ); err != nil {
73- return nil , fmt .Errorf ("evaluate continue-on-error for job %q: %w" , id , err )
116+ combos = []* Job {placeholder }
117+ } else {
118+ matricxes , err := getMatrixes (originJob )
119+ if err != nil {
120+ return nil , fmt .Errorf ("getMatrixes: %w" , err )
74121 }
75- swf := & SingleWorkflow {
76- Name : workflow .Name ,
77- RawOn : workflow .RawOn ,
78- Env : workflow .Env ,
79- Defaults : workflow .Defaults ,
80- RawPermissions : workflow .RawPermissions ,
81- RunName : workflow .RunName ,
122+ if combos , err = buildMatrixCombos (id , job , matricxes , originJob , pc .gitContext , results , pc .vars , pc .inputs ); err != nil {
123+ return nil , err
82124 }
83- if err := swf .SetJob (id , job ); err != nil {
125+ }
126+ for _ , combo := range combos {
127+ swf := workflow .cloneHeader ()
128+ if err := swf .SetJob (id , combo ); err != nil {
84129 return nil , fmt .Errorf ("SetJob: %w" , err )
85130 }
86131 ret = append (ret , swf )
@@ -89,6 +134,121 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
89134 return ret , nil
90135}
91136
137+ // cloneHeader returns a copy of w with its workflow-global fields but no jobs.
138+ func (w * SingleWorkflow ) cloneHeader () * SingleWorkflow {
139+ return & SingleWorkflow {
140+ Name : w .Name ,
141+ RawOn : w .RawOn ,
142+ Env : w .Env ,
143+ Defaults : w .Defaults ,
144+ RawPermissions : w .RawPermissions ,
145+ RunName : w .RunName ,
146+ }
147+ }
148+
149+ // ExpandMatrixWithNeeds returns one Job per combination of job's matrix, resolved against the
150+ // completed needs in results. As for EvaluateConcurrency, results must also describe jobID itself,
151+ // which is where NewInterpeter reads the job's own needs from.
152+ // maxCombinations caps how many combinations may be built: the values come from a needs output at
153+ // runtime, so the cap has to be enforced before one Job is materialized per combination.
154+ 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 ) {
155+ actJob := & model.Job {Strategy : & model.Strategy {
156+ FailFastString : job .Strategy .FailFastString ,
157+ MaxParallelString : job .Strategy .MaxParallelString ,
158+ RawMatrix : job .Strategy .RawMatrix ,
159+ }}
160+
161+ // Resolve fromJson(needs.*.outputs.*) and friends into concrete matrix values.
162+ if err := NewExpressionEvaluator (NewInterpeter (jobID , actJob , nil , gitCtx , results , vars , inputs )).
163+ EvaluateYamlNode (& actJob .Strategy .RawMatrix ); err != nil {
164+ return nil , fmt .Errorf ("evaluate matrix: %w" , err )
165+ }
166+ matrixes , err := getMatrixes (actJob )
167+ if err != nil {
168+ return nil , fmt .Errorf ("getMatrixes: %w" , err )
169+ }
170+ // act collapses a matrix that yields no combination (an empty vector or include, everything
171+ // excluded, a whole-matrix expression that is not a mapping) into one empty combination, which
172+ // would run the job once unparameterized. GitHub rejects such a matrix, so reject it too.
173+ if len (matrixes ) == 1 && len (matrixes [0 ]) == 0 {
174+ return nil , errors .New ("matrix must define at least one vector" )
175+ }
176+ if len (matrixes ) > maxCombinations {
177+ return nil , fmt .Errorf ("matrix expands to %d combinations, exceeding the limit of %d" , len (matrixes ), maxCombinations )
178+ }
179+ return buildMatrixCombos (jobID , job , matrixes , actJob , gitCtx , results , vars , inputs )
180+ }
181+
182+ // matrixesOf is this package's only entry to act's GetMatrixes, so that every caller is covered by
183+ // the filter check below. A deferred placeholder is the first thing carrying a raw matrix this far,
184+ // and the emitter reads its `if:` before expanding it.
185+ // TODO: drop the check once gitea.com/gitea/runner validates the shape itself.
186+ func matrixesOf (job * model.Job ) ([]map [string ]any , error ) {
187+ if err := validateMatrixFilters (job ); err != nil {
188+ return nil , err
189+ }
190+ matrixes , err := job .GetMatrixes ()
191+ if err != nil {
192+ return nil , fmt .Errorf ("GetMatrixes: %w" , err )
193+ }
194+ return matrixes , nil
195+ }
196+
197+ // validateMatrixFilters rejects an `include`/`exclude` that is not a list of mappings. act asserts
198+ // that shape without checking, so anything else panics there; an unevaluated ${{ }} expression, which
199+ // is still a scalar, is the usual way to reach it.
200+ func validateMatrixFilters (job * model.Job ) error {
201+ if job .Strategy == nil || job .Strategy .RawMatrix .Kind != yaml .MappingNode {
202+ return nil
203+ }
204+ content := job .Strategy .RawMatrix .Content
205+ for i := 0 ; i + 1 < len (content ); i += 2 {
206+ name , value := content [i ].Value , content [i + 1 ]
207+ if name != "include" && name != "exclude" {
208+ continue
209+ }
210+ entries := []* yaml.Node {value }
211+ if value .Kind == yaml .SequenceNode {
212+ entries = value .Content
213+ }
214+ for _ , entry := range entries {
215+ if entry .Kind == yaml .AliasNode {
216+ entry = entry .Alias
217+ }
218+ if entry .Kind != yaml .MappingNode {
219+ return fmt .Errorf ("matrix %s must be a list of mappings" , name )
220+ }
221+ }
222+ }
223+ return nil
224+ }
225+
226+ // buildMatrixCombos builds one Job per matrix combination from src, baking the combination into the
227+ // strategy and interpolating the name, runs-on and continue-on-error with it.
228+ 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 ) {
229+ srcRunsOn := src .RunsOn ()
230+ combos := make ([]* Job , 0 , len (matrixes ))
231+ for _ , matrix := range matrixes {
232+ combo := src .Clone ()
233+ if combo .Name == "" {
234+ combo .Name = jobID
235+ }
236+ combo .Strategy .RawMatrix = encodeMatrix (matrix )
237+ evaluator := NewExpressionEvaluator (NewInterpeter (jobID , actJob , matrix , gitCtx , results , vars , inputs ))
238+ combo .Name = nameWithMatrix (combo .Name , matrix , evaluator )
239+ runsOn := slices .Clone (srcRunsOn )
240+ for i := range runsOn {
241+ runsOn [i ] = evaluator .Interpolate (runsOn [i ])
242+ }
243+ combo .RawRunsOn = encodeRunsOn (runsOn )
244+ if err := evaluator .EvaluateYamlNode (& combo .RawContinueOnError ); err != nil {
245+ return nil , fmt .Errorf ("evaluate continue-on-error for job %q: %w" , jobID , err )
246+ }
247+ combos = append (combos , combo )
248+ }
249+ return combos , nil
250+ }
251+
92252func WithGitContext (context * model.GithubContext ) ParseOption {
93253 return func (c * parseContext ) {
94254 c .gitContext = context
@@ -117,9 +277,9 @@ type parseContext struct {
117277type ParseOption func (c * parseContext )
118278
119279func getMatrixes (job * model.Job ) ([]map [string ]any , error ) {
120- ret , err := job . GetMatrixes ( )
280+ ret , err := matrixesOf ( job )
121281 if err != nil {
122- return nil , fmt . Errorf ( "GetMatrixes: %w" , err )
282+ return nil , err
123283 }
124284 sort .Slice (ret , func (i , j int ) bool {
125285 return matrixName (ret [i ]) < matrixName (ret [j ])
0 commit comments