Skip to content

Commit 7a31b6a

Browse files
mfenniakearl-warren
authored andcommitted
feat: support evaluation of concurrency clauses in runner (#827)
Pre-req for support `concurrency` clauses for Gitea Actions, later added to Gitea in PR: go-gitea/gitea#32751. Unit tests added in this PR relative to upstream. Squashes PRs https://gitea.com/gitea/act/pulls/124 & https://gitea.com/gitea/act/pulls/139, as noted in https://code.forgejo.org/forgejo/runner/issues/678 Reviewed-on: https://gitea.com/gitea/act/pulls/124 Reviewed-by: Lunny Xiao <[email protected]> Co-authored-by: Zettat123 <[email protected]> Co-committed-by: Zettat123 <[email protected]> Reviewed-on: https://gitea.com/gitea/act/pulls/139 Reviewed-by: Zettat123 <[email protected]> Reviewed-by: Lunny Xiao <[email protected]> Co-authored-by: ChristopherHX <[email protected]> Co-committed-by: ChristopherHX <[email protected]> <!--start release-notes-assistant--> <!--URL:https://code.forgejo.org/forgejo/runner--> - features - [PR](https://code.forgejo.org/forgejo/runner/pulls/827): <!--number 827 --><!--line 0 --><!--description ZmVhdDogc3VwcG9ydCBldmFsdWF0aW9uIG9mIGNvbmN1cnJlbmN5IGNsYXVzZXMgaW4gcnVubmVy-->feat: support evaluation of concurrency clauses in runner<!--description--> <!--end release-notes-assistant--> Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/827 Reviewed-by: earl-warren <[email protected]> Co-authored-by: Mathieu Fenniak <[email protected]> Co-committed-by: Mathieu Fenniak <[email protected]>
1 parent 3f46873 commit 7a31b6a

12 files changed

+330
-4
lines changed

act/jobparser/interpeter.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func NewInterpeter(
1616
gitCtx *model.GithubContext,
1717
results map[string]*JobResult,
1818
vars map[string]string,
19+
inputs map[string]interface{},
1920
) exprparser.Interpreter {
2021
strategy := make(map[string]interface{})
2122
if job.Strategy != nil {
@@ -62,7 +63,7 @@ func NewInterpeter(
6263
Strategy: strategy,
6364
Matrix: matrix,
6465
Needs: using,
65-
Inputs: nil, // not supported yet
66+
Inputs: inputs,
6667
Vars: vars,
6768
}
6869

act/jobparser/jobparser.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func Parse(content []byte, validate bool, options ...ParseOption) ([]*SingleWork
4848
}
4949
for _, matrix := range matricxes {
5050
job := job.Clone()
51-
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars))
51+
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, nil))
5252
if job.Name == "" {
5353
job.Name = nameWithMatrix(id, matrix)
5454
} else {

act/jobparser/jobparser_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ func TestParse(t *testing.T) {
4242
options: nil,
4343
wantErr: false,
4444
},
45+
{
46+
name: "job_concurrency",
47+
options: nil,
48+
wantErr: false,
49+
},
50+
{
51+
name: "job_concurrency_eval",
52+
options: nil,
53+
wantErr: false,
54+
},
4555
}
4656
for _, tt := range tests {
4757
t.Run(tt.name, func(t *testing.T) {

act/jobparser/model.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package jobparser
22

33
import (
4+
"bytes"
45
"fmt"
56

67
"code.forgejo.org/forgejo/runner/v9/act/model"
@@ -82,6 +83,7 @@ type Job struct {
8283
Uses string `yaml:"uses,omitempty"`
8384
With map[string]interface{} `yaml:"with,omitempty"`
8485
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
86+
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
8587
}
8688

8789
func (j *Job) Clone() *Job {
@@ -104,6 +106,7 @@ func (j *Job) Clone() *Job {
104106
Uses: j.Uses,
105107
With: j.With,
106108
RawSecrets: j.RawSecrets,
109+
RawConcurrency: j.RawConcurrency,
107110
}
108111
}
109112

@@ -190,6 +193,85 @@ func (evt *Event) Schedules() []map[string]string {
190193
return evt.schedules
191194
}
192195

196+
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
197+
w := new(model.Workflow)
198+
err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w)
199+
return w.RawConcurrency, err
200+
}
201+
202+
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) {
203+
actJob := &model.Job{}
204+
if job != nil {
205+
actJob.Strategy = &model.Strategy{
206+
FailFastString: job.Strategy.FailFastString,
207+
MaxParallelString: job.Strategy.MaxParallelString,
208+
RawMatrix: job.Strategy.RawMatrix,
209+
}
210+
actJob.Strategy.FailFast = actJob.Strategy.GetFailFast()
211+
actJob.Strategy.MaxParallel = actJob.Strategy.GetMaxParallel()
212+
}
213+
214+
matrix := make(map[string]any)
215+
matrixes, err := actJob.GetMatrixes()
216+
if err != nil {
217+
return "", false, err
218+
}
219+
if len(matrixes) > 0 {
220+
matrix = matrixes[0]
221+
}
222+
223+
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
224+
var node yaml.Node
225+
if err := node.Encode(rc); err != nil {
226+
return "", false, fmt.Errorf("failed to encode concurrency: %w", err)
227+
}
228+
if err := evaluator.EvaluateYamlNode(&node); err != nil {
229+
return "", false, fmt.Errorf("failed to evaluate concurrency: %w", err)
230+
}
231+
var evaluated model.RawConcurrency
232+
if err := node.Decode(&evaluated); err != nil {
233+
return "", false, fmt.Errorf("failed to unmarshal evaluated concurrency: %w", err)
234+
}
235+
if evaluated.RawExpression != "" {
236+
return evaluated.RawExpression, false, nil
237+
}
238+
return evaluated.Group, evaluated.CancelInProgress == "true", nil
239+
}
240+
241+
func toGitContext(input map[string]any) *model.GithubContext {
242+
gitContext := &model.GithubContext{
243+
EventPath: asString(input["event_path"]),
244+
Workflow: asString(input["workflow"]),
245+
RunID: asString(input["run_id"]),
246+
RunNumber: asString(input["run_number"]),
247+
Actor: asString(input["actor"]),
248+
Repository: asString(input["repository"]),
249+
EventName: asString(input["event_name"]),
250+
Sha: asString(input["sha"]),
251+
Ref: asString(input["ref"]),
252+
RefName: asString(input["ref_name"]),
253+
RefType: asString(input["ref_type"]),
254+
HeadRef: asString(input["head_ref"]),
255+
BaseRef: asString(input["base_ref"]),
256+
Token: asString(input["token"]),
257+
Workspace: asString(input["workspace"]),
258+
Action: asString(input["action"]),
259+
ActionPath: asString(input["action_path"]),
260+
ActionRef: asString(input["action_ref"]),
261+
ActionRepository: asString(input["action_repository"]),
262+
Job: asString(input["job"]),
263+
RepositoryOwner: asString(input["repository_owner"]),
264+
RetentionDays: asString(input["retention_days"]),
265+
}
266+
267+
event, ok := input["event"].(map[string]any)
268+
if ok {
269+
gitContext.Event = event
270+
}
271+
272+
return gitContext
273+
}
274+
193275
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
194276
switch rawOn.Kind {
195277
case yaml.ScalarNode:
@@ -348,3 +430,12 @@ func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) {
348430

349431
return scalars, datas, nil
350432
}
433+
434+
func asString(v interface{}) string {
435+
if v == nil {
436+
return ""
437+
} else if s, ok := v.(string); ok {
438+
return s
439+
}
440+
return ""
441+
}

act/jobparser/model_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,118 @@ func TestParseMappingNode(t *testing.T) {
339339
})
340340
}
341341
}
342+
343+
func TestEvaluateConcurrency(t *testing.T) {
344+
tests := []struct {
345+
name string
346+
input model.RawConcurrency
347+
group string
348+
cancelInProgress bool
349+
}{
350+
{
351+
name: "basic",
352+
input: model.RawConcurrency{
353+
Group: "group-name",
354+
CancelInProgress: "true",
355+
},
356+
group: "group-name",
357+
cancelInProgress: true,
358+
},
359+
{
360+
name: "undefined",
361+
input: model.RawConcurrency{},
362+
group: "",
363+
cancelInProgress: false,
364+
},
365+
{
366+
name: "group-evaluation",
367+
input: model.RawConcurrency{
368+
Group: "${{ github.workflow }}-${{ github.ref }}",
369+
},
370+
group: "test_workflow-main",
371+
cancelInProgress: false,
372+
},
373+
{
374+
name: "cancel-evaluation-true",
375+
input: model.RawConcurrency{
376+
Group: "group-name",
377+
CancelInProgress: "${{ !contains(github.ref, 'release/')}}",
378+
},
379+
group: "group-name",
380+
cancelInProgress: true,
381+
},
382+
{
383+
name: "cancel-evaluation-false",
384+
input: model.RawConcurrency{
385+
Group: "group-name",
386+
CancelInProgress: "${{ contains(github.ref, 'release/')}}",
387+
},
388+
group: "group-name",
389+
cancelInProgress: false,
390+
},
391+
{
392+
name: "event-evaluation",
393+
input: model.RawConcurrency{
394+
Group: "user-${{ github.event.commits[0].author.username }}",
395+
},
396+
group: "user-someone",
397+
cancelInProgress: false,
398+
},
399+
{
400+
name: "arbitrary-var",
401+
input: model.RawConcurrency{
402+
Group: "${{ vars.eval_arbitrary_var }}",
403+
},
404+
group: "123",
405+
cancelInProgress: false,
406+
},
407+
{
408+
name: "arbitrary-input",
409+
input: model.RawConcurrency{
410+
Group: "${{ inputs.eval_arbitrary_input }}",
411+
},
412+
group: "456",
413+
cancelInProgress: false,
414+
},
415+
}
416+
417+
for _, test := range tests {
418+
t.Run(test.name, func(t *testing.T) {
419+
group, cancelInProgress, err := EvaluateConcurrency(
420+
&test.input,
421+
"job-id",
422+
nil, // job
423+
map[string]any{
424+
"workflow": "test_workflow",
425+
"ref": "main",
426+
"event": map[string]interface{}{
427+
"commits": []interface{}{
428+
map[string]interface{}{
429+
"author": map[string]interface{}{
430+
"username": "someone",
431+
},
432+
},
433+
map[string]interface{}{
434+
"author": map[string]interface{}{
435+
"username": "someone-else",
436+
},
437+
},
438+
},
439+
},
440+
}, // gitCtx
441+
map[string]*JobResult{
442+
"job-id": {},
443+
}, // results
444+
map[string]string{
445+
"eval_arbitrary_var": "123",
446+
}, // vars
447+
map[string]any{
448+
"eval_arbitrary_input": "456",
449+
}, // inputs
450+
)
451+
assert.NoError(t, err)
452+
assert.EqualValues(t, test.group, group)
453+
assert.EqualValues(t, test.cancelInProgress, cancelInProgress)
454+
})
455+
}
456+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
name: test
2+
jobs:
3+
job1:
4+
runs-on: linux
5+
concurrency:
6+
group: major-tests
7+
cancel-in-progress: true
8+
steps:
9+
- run: uname -a
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
name: test
2+
jobs:
3+
job1:
4+
name: job1
5+
runs-on: linux
6+
steps:
7+
- run: uname -a
8+
concurrency:
9+
group: major-tests
10+
cancel-in-progress: "true"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
name: test
2+
jobs:
3+
job1:
4+
runs-on: linux
5+
concurrency:
6+
group: ${{ github.workflow }}
7+
cancel-in-progress: ${{ !contains(github.ref, 'release/')}}
8+
steps:
9+
- run: uname -a
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
name: test
2+
jobs:
3+
job1:
4+
name: job1
5+
runs-on: linux
6+
steps:
7+
- run: uname -a
8+
concurrency:
9+
group: ${{ github.workflow }}
10+
cancel-in-progress: ${{ !contains(github.ref, 'release/')}}

act/model/workflow.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ type Workflow struct {
2525
Jobs map[string]*Job `yaml:"jobs"`
2626
Defaults Defaults `yaml:"defaults"`
2727

28-
RawNotifications yaml.Node `yaml:"enable-email-notifications"`
28+
RawNotifications yaml.Node `yaml:"enable-email-notifications"`
29+
RawConcurrency *RawConcurrency `yaml:"concurrency"` // For Gitea
2930
}
3031

3132
// On events for the workflow
@@ -806,3 +807,28 @@ func (w *Workflow) Notifications() (bool, error) {
806807
return false, fmt.Errorf("enable-email-notifications: unknown type: %v", w.RawNotifications.Kind)
807808
}
808809
}
810+
811+
// For Gitea
812+
// RawConcurrency represents a workflow concurrency or a job concurrency with uninterpolated options
813+
type RawConcurrency struct {
814+
Group string `yaml:"group,omitempty"`
815+
CancelInProgress string `yaml:"cancel-in-progress,omitempty"`
816+
RawExpression string `yaml:"-,omitempty"`
817+
}
818+
819+
type objectConcurrency RawConcurrency
820+
821+
func (r *RawConcurrency) UnmarshalYAML(n *yaml.Node) error {
822+
if err := n.Decode(&r.RawExpression); err == nil {
823+
return nil
824+
}
825+
return n.Decode((*objectConcurrency)(r))
826+
}
827+
828+
func (r *RawConcurrency) MarshalYAML() (interface{}, error) {
829+
if r.RawExpression != "" {
830+
return r.RawExpression, nil
831+
}
832+
833+
return (*objectConcurrency)(r), nil
834+
}

0 commit comments

Comments
 (0)