detached 8372da1

feat(workflow): add the model, the converter and its tests against the reference parser

commit
8372da1ae7704ed4ca476ecb5c1abf827c54537a
author
brice · · 2026-10-12 15:50 UTC
committer
brice · · 2026-10-12 16:17 UTC
parent
6416d3e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
168 files · +5300 -0
M template/tokens.go +5 -0
@@ -283,3 +283,8 @@ func trimDisplay(s string) string {
283283 }
284284 return s
285285 }
286+
287+// Node returns the parsed expression, or nil when the text did not parse or
288+// is empty. Callers that only inspect the tree (to look for function calls,
289+// for instance) use it with the types of package expr.
290+func (t *BasicExpression) Node() expr.Node { return t.node }
@@ -283,3 +283,8 @@ func trimDisplay(s string) string {
283 }283 }
284 return s284 return s
285 }285 }
286+
287+// Node returns the parsed expression, or nil when the text did not parse or
288+// is empty. Callers that only inspect the tree (to look for function calls,
289+// for instance) use it with the types of package expr.
290+func (t *BasicExpression) Node() expr.Node { return t.node }
A workflow/convert.go +143 -0
@@ -0,0 +1,143 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// converter turns the token tree of a workflow into the model. It is a port
10+// of the reference's converter, which reports problems by throwing an error
11+// that the caller of each conversion step catches and records; here panics of
12+// type *template.TokenError play the part of those exceptions.
13+type converter struct {
14+ ctx *template.Context
15+ schema *template.Schema
16+ w *Workflow
17+ jobIDs *idBuilder
18+}
19+
20+// handle runs fn and records a *template.TokenError panic as an error
21+// located at the token it names, like handleTemplateTokenErrors.
22+func (c *converter) handle(root template.Token, fn func()) {
23+ defer func() {
24+ if r := recover(); r != nil {
25+ te, ok := r.(*template.TokenError)
26+ if !ok {
27+ panic(r)
28+ }
29+ if te.Token != nil {
30+ c.ctx.ErrorAt(te.Token, te.Message)
31+ } else {
32+ c.ctx.ErrorAt(root, te.Message)
33+ }
34+ }
35+ }()
36+ fn()
37+}
38+
39+func throw(t template.Token, format string, a ...any) {
40+ panic(&template.TokenError{Token: t, Message: sprintf(format, a...)})
41+}
42+
43+func ck[T any](v T, err error) T {
44+ if err != nil {
45+ panic(err)
46+ }
47+ return v
48+}
49+
50+func asString(t template.Token, desc string) *template.String {
51+ return ck(template.AssertString(t, desc))
52+}
53+
54+func asBool(t template.Token, desc string) *template.Boolean {
55+ return ck(template.AssertBoolean(t, desc))
56+}
57+
58+func asSeq(t template.Token, desc string) *template.Sequence {
59+ return ck(template.AssertSequence(t, desc))
60+}
61+
62+func asMap(t template.Token, desc string) *template.Mapping {
63+ return ck(template.AssertMapping(t, desc))
64+}
65+
66+func asScalar(t template.Token, desc string) template.Token {
67+ return ck(template.AssertScalar(t, desc))
68+}
69+
70+func origin(t template.Token) template.Origin {
71+ return template.Origin{File: t.File(), Pos: t.Pos()}
72+}
73+
74+// convertRoot converts the root mapping.
75+func (c *converter) convertRoot(root template.Token) {
76+ defer func() {
77+ if r := recover(); r != nil {
78+ te, ok := r.(*template.TokenError)
79+ if !ok {
80+ panic(r)
81+ }
82+ if te.Token != nil {
83+ c.ctx.ErrorAt(te.Token, te.Message)
84+ } else {
85+ c.ctx.ErrorAt(root, te.Message)
86+ }
87+ }
88+ }()
89+ m := asMap(root, "root")
90+ for _, p := range m.Pairs {
91+ key := asString(p.Key, "root key")
92+ switch key.Value {
93+ case "on":
94+ c.handle(root, func() { c.w.On = c.convertOn(p.Value) })
95+ case "jobs":
96+ c.w.Jobs = nil
97+ c.handle(root, func() { c.w.Jobs = c.convertJobs(p.Value) })
98+ case "concurrency":
99+ c.handle(root, func() { c.convertConcurrency(p.Value) })
100+ c.w.Concurrency = p.Value
101+ case "env":
102+ if em, ok := p.Value.(*template.Mapping); ok {
103+ c.w.Env = em
104+ }
105+ case "name":
106+ if s, ok := p.Value.(*template.String); ok {
107+ c.w.Name = s.Value
108+ }
109+ case "run-name":
110+ c.w.RunName = p.Value
111+ case "defaults":
112+ c.w.Defaults = convertDefaults(p.Value)
113+ case "permissions":
114+ c.w.Permissions = convertPermissions(p.Value)
115+ }
116+ }
117+}
118+
119+func convertDefaults(t template.Token) *Defaults {
120+ m, ok := t.(*template.Mapping)
121+ if !ok {
122+ return nil
123+ }
124+ d := &Defaults{}
125+ if run, ok := m.Find("run").(*template.Mapping); ok {
126+ d.Run = &RunDefaults{Shell: run.Find("shell"), WorkingDirectory: run.Find("working-directory")}
127+ }
128+ return d
129+}
130+
131+func convertPermissions(t template.Token) *Permissions {
132+ switch t := t.(type) {
133+ case *template.String:
134+ return &Permissions{All: t.Value}
135+ case *template.Mapping:
136+ p := &Permissions{}
137+ for _, kv := range t.Pairs {
138+ p.Scopes = append(p.Scopes, PermissionScope{Scope: kv.Key.String(), Level: kv.Value.String()})
139+ }
140+ return p
141+ }
142+ return nil
143+}
@@ -0,0 +1,143 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// converter turns the token tree of a workflow into the model. It is a port
10+// of the reference's converter, which reports problems by throwing an error
11+// that the caller of each conversion step catches and records; here panics of
12+// type *template.TokenError play the part of those exceptions.
13+type converter struct {
14+ ctx *template.Context
15+ schema *template.Schema
16+ w *Workflow
17+ jobIDs *idBuilder
18+}
19+
20+// handle runs fn and records a *template.TokenError panic as an error
21+// located at the token it names, like handleTemplateTokenErrors.
22+func (c *converter) handle(root template.Token, fn func()) {
23+ defer func() {
24+ if r := recover(); r != nil {
25+ te, ok := r.(*template.TokenError)
26+ if !ok {
27+ panic(r)
28+ }
29+ if te.Token != nil {
30+ c.ctx.ErrorAt(te.Token, te.Message)
31+ } else {
32+ c.ctx.ErrorAt(root, te.Message)
33+ }
34+ }
35+ }()
36+ fn()
37+}
38+
39+func throw(t template.Token, format string, a ...any) {
40+ panic(&template.TokenError{Token: t, Message: sprintf(format, a...)})
41+}
42+
43+func ck[T any](v T, err error) T {
44+ if err != nil {
45+ panic(err)
46+ }
47+ return v
48+}
49+
50+func asString(t template.Token, desc string) *template.String {
51+ return ck(template.AssertString(t, desc))
52+}
53+
54+func asBool(t template.Token, desc string) *template.Boolean {
55+ return ck(template.AssertBoolean(t, desc))
56+}
57+
58+func asSeq(t template.Token, desc string) *template.Sequence {
59+ return ck(template.AssertSequence(t, desc))
60+}
61+
62+func asMap(t template.Token, desc string) *template.Mapping {
63+ return ck(template.AssertMapping(t, desc))
64+}
65+
66+func asScalar(t template.Token, desc string) template.Token {
67+ return ck(template.AssertScalar(t, desc))
68+}
69+
70+func origin(t template.Token) template.Origin {
71+ return template.Origin{File: t.File(), Pos: t.Pos()}
72+}
73+
74+// convertRoot converts the root mapping.
75+func (c *converter) convertRoot(root template.Token) {
76+ defer func() {
77+ if r := recover(); r != nil {
78+ te, ok := r.(*template.TokenError)
79+ if !ok {
80+ panic(r)
81+ }
82+ if te.Token != nil {
83+ c.ctx.ErrorAt(te.Token, te.Message)
84+ } else {
85+ c.ctx.ErrorAt(root, te.Message)
86+ }
87+ }
88+ }()
89+ m := asMap(root, "root")
90+ for _, p := range m.Pairs {
91+ key := asString(p.Key, "root key")
92+ switch key.Value {
93+ case "on":
94+ c.handle(root, func() { c.w.On = c.convertOn(p.Value) })
95+ case "jobs":
96+ c.w.Jobs = nil
97+ c.handle(root, func() { c.w.Jobs = c.convertJobs(p.Value) })
98+ case "concurrency":
99+ c.handle(root, func() { c.convertConcurrency(p.Value) })
100+ c.w.Concurrency = p.Value
101+ case "env":
102+ if em, ok := p.Value.(*template.Mapping); ok {
103+ c.w.Env = em
104+ }
105+ case "name":
106+ if s, ok := p.Value.(*template.String); ok {
107+ c.w.Name = s.Value
108+ }
109+ case "run-name":
110+ c.w.RunName = p.Value
111+ case "defaults":
112+ c.w.Defaults = convertDefaults(p.Value)
113+ case "permissions":
114+ c.w.Permissions = convertPermissions(p.Value)
115+ }
116+ }
117+}
118+
119+func convertDefaults(t template.Token) *Defaults {
120+ m, ok := t.(*template.Mapping)
121+ if !ok {
122+ return nil
123+ }
124+ d := &Defaults{}
125+ if run, ok := m.Find("run").(*template.Mapping); ok {
126+ d.Run = &RunDefaults{Shell: run.Find("shell"), WorkingDirectory: run.Find("working-directory")}
127+ }
128+ return d
129+}
130+
131+func convertPermissions(t template.Token) *Permissions {
132+ switch t := t.(type) {
133+ case *template.String:
134+ return &Permissions{All: t.Value}
135+ case *template.Mapping:
136+ p := &Permissions{}
137+ for _, kv := range t.Pairs {
138+ p.Scopes = append(p.Scopes, PermissionScope{Scope: kv.Key.String(), Level: kv.Value.String()})
139+ }
140+ return p
141+ }
142+ return nil
143+}
A workflow/convert_container.go +88 -0
@@ -0,0 +1,88 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// convertContainer validates a job container or, when service is set, a
10+// service container. A token that contains an expression is not checked, as
11+// in the reference.
12+func (c *converter) convertContainer(t template.Token, service bool) {
13+ if template.HasExpression(t) {
14+ return
15+ }
16+ if _, ok := t.(*template.String); ok {
17+ return
18+ }
19+ var image template.Token
20+ c.handle(t, func() {
21+ m := asMap(t, "container item")
22+ for _, p := range m.Pairs {
23+ key := asString(p.Key, "container item key")
24+ v := p.Value
25+ switch key.Value {
26+ case "image":
27+ image = asString(v, "container image")
28+ case "credentials":
29+ c.convertCredentials(v)
30+ case "env":
31+ for _, e := range asMap(v, "container env").Pairs {
32+ asString(e.Key, "container env value")
33+ }
34+ case "ports":
35+ for _, it := range asSeq(v, "container ports").Items {
36+ asString(it, "container port")
37+ }
38+ case "volumes":
39+ for _, it := range asSeq(v, "container volumes").Items {
40+ asString(it, "container volume")
41+ }
42+ case "options":
43+ asString(v, "container options")
44+ case "entrypoint":
45+ if !service {
46+ c.ctx.Errorf(key, "Unexpected container item key: %s", key.Value)
47+ continue
48+ }
49+ asString(v, "container entrypoint")
50+ case "command":
51+ if !service {
52+ c.ctx.Errorf(key, "Unexpected container item key: %s", key.Value)
53+ continue
54+ }
55+ asString(v, "container command")
56+ default:
57+ c.ctx.Errorf(key, "Unexpected container item key: %s", key.Value)
58+ }
59+ }
60+ })
61+ if image == nil {
62+ if _, isMap := t.(*template.Mapping); isMap {
63+ c.ctx.ErrorAt(t, "Container image cannot be empty")
64+ }
65+ }
66+}
67+
68+func (c *converter) convertCredentials(t template.Token) {
69+ for _, p := range asMap(t, "credentials").Pairs {
70+ key := asString(p.Key, "credentials item")
71+ switch key.Value {
72+ case "username":
73+ asString(p.Value, "credentials username")
74+ case "password":
75+ asString(p.Value, "credentials password")
76+ default:
77+ c.ctx.Errorf(key, "credentials key %s", key.Value)
78+ }
79+ }
80+}
81+
82+// convertServices validates the services mapping of a job.
83+func (c *converter) convertServices(t template.Token) {
84+ for _, p := range asMap(t, "services").Pairs {
85+ asString(p.Key, "service key")
86+ c.convertContainer(p.Value, true)
87+ }
88+}
@@ -0,0 +1,88 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// convertContainer validates a job container or, when service is set, a
10+// service container. A token that contains an expression is not checked, as
11+// in the reference.
12+func (c *converter) convertContainer(t template.Token, service bool) {
13+ if template.HasExpression(t) {
14+ return
15+ }
16+ if _, ok := t.(*template.String); ok {
17+ return
18+ }
19+ var image template.Token
20+ c.handle(t, func() {
21+ m := asMap(t, "container item")
22+ for _, p := range m.Pairs {
23+ key := asString(p.Key, "container item key")
24+ v := p.Value
25+ switch key.Value {
26+ case "image":
27+ image = asString(v, "container image")
28+ case "credentials":
29+ c.convertCredentials(v)
30+ case "env":
31+ for _, e := range asMap(v, "container env").Pairs {
32+ asString(e.Key, "container env value")
33+ }
34+ case "ports":
35+ for _, it := range asSeq(v, "container ports").Items {
36+ asString(it, "container port")
37+ }
38+ case "volumes":
39+ for _, it := range asSeq(v, "container volumes").Items {
40+ asString(it, "container volume")
41+ }
42+ case "options":
43+ asString(v, "container options")
44+ case "entrypoint":
45+ if !service {
46+ c.ctx.Errorf(key, "Unexpected container item key: %s", key.Value)
47+ continue
48+ }
49+ asString(v, "container entrypoint")
50+ case "command":
51+ if !service {
52+ c.ctx.Errorf(key, "Unexpected container item key: %s", key.Value)
53+ continue
54+ }
55+ asString(v, "container command")
56+ default:
57+ c.ctx.Errorf(key, "Unexpected container item key: %s", key.Value)
58+ }
59+ }
60+ })
61+ if image == nil {
62+ if _, isMap := t.(*template.Mapping); isMap {
63+ c.ctx.ErrorAt(t, "Container image cannot be empty")
64+ }
65+ }
66+}
67+
68+func (c *converter) convertCredentials(t template.Token) {
69+ for _, p := range asMap(t, "credentials").Pairs {
70+ key := asString(p.Key, "credentials item")
71+ switch key.Value {
72+ case "username":
73+ asString(p.Value, "credentials username")
74+ case "password":
75+ asString(p.Value, "credentials password")
76+ default:
77+ c.ctx.Errorf(key, "credentials key %s", key.Value)
78+ }
79+ }
80+}
81+
82+// convertServices validates the services mapping of a job.
83+func (c *converter) convertServices(t template.Token) {
84+ for _, p := range asMap(t, "services").Pairs {
85+ asString(p.Key, "service key")
86+ c.convertContainer(p.Value, true)
87+ }
88+}
A workflow/convert_jobs.go +184 -0
@@ -0,0 +1,184 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+type needsNode struct {
10+ name string
11+ needs []*template.String
12+}
13+
14+// convertJobs is a port of convertJobs: every job is converted on its own, a
15+// job that fails is dropped after its error is recorded, then the needs graph
16+// is checked.
17+func (c *converter) convertJobs(t template.Token) []*Job {
18+ m, ok := t.(*template.Mapping)
19+ if !ok {
20+ c.ctx.ErrorAt(t, "Invalid format for jobs")
21+ return nil
22+ }
23+ var result []*Job
24+ var satisfied, unsatisfied []*needsNode
25+ for _, p := range m.Pairs {
26+ key := asString(p.Key, "job name")
27+ var job *Job
28+ c.handle(m, func() {
29+ job = c.convertJob(key, asMap(p.Value, "job "+key.Value))
30+ })
31+ if job == nil {
32+ continue
33+ }
34+ result = append(result, job)
35+ n := &needsNode{name: job.ID, needs: append([]*template.String(nil), job.NeedTokens...)}
36+ if len(n.needs) > 0 {
37+ unsatisfied = append(unsatisfied, n)
38+ } else {
39+ satisfied = append(satisfied, n)
40+ }
41+ }
42+ c.validateNeeds(m, result, satisfied, unsatisfied)
43+ return result
44+}
45+
46+// validateNeeds reports jobs that can never start: it removes satisfied
47+// dependencies in the order the reference does and complains about what is
48+// left.
49+func (c *converter) validateNeeds(t template.Token, jobs []*Job, satisfied, unsatisfied []*needsNode) {
50+ if len(satisfied) == 0 {
51+ c.ctx.ErrorAt(t, "The workflow must contain at least one job with no dependencies.")
52+ return
53+ }
54+ for len(satisfied) > 0 {
55+ current := satisfied[0]
56+ satisfied = satisfied[1:]
57+ for i := len(unsatisfied) - 1; i >= 0; i-- {
58+ u := unsatisfied[i]
59+ for j := len(u.needs) - 1; j >= 0; j-- {
60+ if u.needs[j].Value != current.name {
61+ continue
62+ }
63+ u.needs = append(u.needs[:j], u.needs[j+1:]...)
64+ if len(u.needs) == 0 {
65+ satisfied = append(satisfied, u)
66+ unsatisfied = append(unsatisfied[:i], unsatisfied[i+1:]...)
67+ }
68+ }
69+ }
70+ }
71+ if len(unsatisfied) == 0 {
72+ return
73+ }
74+ known := map[string]bool{}
75+ for _, j := range jobs {
76+ known[j.ID] = true
77+ }
78+ for _, u := range unsatisfied {
79+ for _, need := range u.needs {
80+ if known[need.Value] {
81+ c.ctx.Errorf(need, "Job '%s' depends on job '%s' which creates a cycle in the dependency graph.", u.name, need.Value)
82+ } else {
83+ c.ctx.Errorf(need, "Job '%s' depends on unknown job '%s'.", u.name, need.Value)
84+ }
85+ }
86+ }
87+}
88+
89+// convertJob is a port of convertJob.
90+func (c *converter) convertJob(key *template.String, m *template.Mapping) *Job {
91+ if msg := c.jobIDs.tryAddKnownID(key.Value); msg != "" {
92+ c.ctx.ErrorAt(key, msg)
93+ }
94+ j := &Job{ID: key.Value, IDToken: key}
95+ var name template.Token
96+ needsSeen := false
97+ for _, p := range m.Pairs {
98+ prop := asString(p.Key, "job property name")
99+ v := p.Value
100+ switch prop.Value {
101+ case "concurrency":
102+ c.handle(v, func() { c.convertConcurrency(v) })
103+ j.Concurrency = v
104+ case "container":
105+ c.convertContainer(v, false)
106+ j.Container = v
107+ case "env":
108+ c.handle(v, func() { j.Env = asMap(v, "job env") })
109+ case "environment":
110+ c.handle(v, func() { c.convertEnvironment(v) })
111+ j.Environment = v
112+ case "if":
113+ j.If = c.convertIf(v, "job-if")
114+ case "name":
115+ name = asScalar(v, "job name")
116+ case "needs":
117+ needsSeen = true
118+ j.NeedTokens = nil
119+ if s, ok := v.(*template.String); ok {
120+ j.NeedTokens = append(j.NeedTokens, asString(s, "job needs id"))
121+ }
122+ if seq, ok := v.(*template.Sequence); ok {
123+ for _, it := range seq.Items {
124+ j.NeedTokens = append(j.NeedTokens, asString(it, "job needs id"))
125+ }
126+ }
127+ case "outputs":
128+ c.handle(v, func() { j.Outputs = asMap(v, "job outputs") })
129+ case "runs-on":
130+ c.handle(v, func() { c.convertRunsOn(v) })
131+ j.RunsOn = v
132+ case "services":
133+ c.convertServices(v)
134+ j.Services = v
135+ case "snapshot":
136+ j.Snapshot = v
137+ case "steps":
138+ j.Steps = c.convertSteps(v)
139+ case "strategy":
140+ j.Strategy = v
141+ case "uses":
142+ j.Uses = asString(v, "job uses value")
143+ case "with":
144+ c.handle(v, func() { j.With = asMap(v, "uses-with value") })
145+ case "secrets":
146+ if s, ok := v.(*template.String); ok && s.Value == "inherit" {
147+ j.InheritSecrets = true
148+ } else {
149+ c.handle(v, func() { j.Secrets = asMap(v, "uses-secrets value") })
150+ }
151+ case "timeout-minutes":
152+ j.TimeoutMinutes = v
153+ case "cancel-timeout-minutes":
154+ j.CancelTimeoutMinutes = v
155+ case "continue-on-error":
156+ j.ContinueOnError = v
157+ case "defaults":
158+ j.Defaults = convertDefaults(v)
159+ case "permissions":
160+ j.Permissions = convertPermissions(v)
161+ }
162+ }
163+ _ = needsSeen
164+ for _, n := range j.NeedTokens {
165+ j.Needs = append(j.Needs, n.Value)
166+ }
167+ if j.If == nil {
168+ j.If = c.defaultIf("job-if")
169+ }
170+ switch {
171+ case name == nil:
172+ j.Name = key
173+ case isEmptyString(name):
174+ j.Name = key
175+ default:
176+ j.Name, j.HasName = name, true
177+ }
178+ return j
179+}
180+
181+func isEmptyString(t template.Token) bool {
182+ s, ok := t.(*template.String)
183+ return ok && s.Value == ""
184+}
@@ -0,0 +1,184 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+type needsNode struct {
10+ name string
11+ needs []*template.String
12+}
13+
14+// convertJobs is a port of convertJobs: every job is converted on its own, a
15+// job that fails is dropped after its error is recorded, then the needs graph
16+// is checked.
17+func (c *converter) convertJobs(t template.Token) []*Job {
18+ m, ok := t.(*template.Mapping)
19+ if !ok {
20+ c.ctx.ErrorAt(t, "Invalid format for jobs")
21+ return nil
22+ }
23+ var result []*Job
24+ var satisfied, unsatisfied []*needsNode
25+ for _, p := range m.Pairs {
26+ key := asString(p.Key, "job name")
27+ var job *Job
28+ c.handle(m, func() {
29+ job = c.convertJob(key, asMap(p.Value, "job "+key.Value))
30+ })
31+ if job == nil {
32+ continue
33+ }
34+ result = append(result, job)
35+ n := &needsNode{name: job.ID, needs: append([]*template.String(nil), job.NeedTokens...)}
36+ if len(n.needs) > 0 {
37+ unsatisfied = append(unsatisfied, n)
38+ } else {
39+ satisfied = append(satisfied, n)
40+ }
41+ }
42+ c.validateNeeds(m, result, satisfied, unsatisfied)
43+ return result
44+}
45+
46+// validateNeeds reports jobs that can never start: it removes satisfied
47+// dependencies in the order the reference does and complains about what is
48+// left.
49+func (c *converter) validateNeeds(t template.Token, jobs []*Job, satisfied, unsatisfied []*needsNode) {
50+ if len(satisfied) == 0 {
51+ c.ctx.ErrorAt(t, "The workflow must contain at least one job with no dependencies.")
52+ return
53+ }
54+ for len(satisfied) > 0 {
55+ current := satisfied[0]
56+ satisfied = satisfied[1:]
57+ for i := len(unsatisfied) - 1; i >= 0; i-- {
58+ u := unsatisfied[i]
59+ for j := len(u.needs) - 1; j >= 0; j-- {
60+ if u.needs[j].Value != current.name {
61+ continue
62+ }
63+ u.needs = append(u.needs[:j], u.needs[j+1:]...)
64+ if len(u.needs) == 0 {
65+ satisfied = append(satisfied, u)
66+ unsatisfied = append(unsatisfied[:i], unsatisfied[i+1:]...)
67+ }
68+ }
69+ }
70+ }
71+ if len(unsatisfied) == 0 {
72+ return
73+ }
74+ known := map[string]bool{}
75+ for _, j := range jobs {
76+ known[j.ID] = true
77+ }
78+ for _, u := range unsatisfied {
79+ for _, need := range u.needs {
80+ if known[need.Value] {
81+ c.ctx.Errorf(need, "Job '%s' depends on job '%s' which creates a cycle in the dependency graph.", u.name, need.Value)
82+ } else {
83+ c.ctx.Errorf(need, "Job '%s' depends on unknown job '%s'.", u.name, need.Value)
84+ }
85+ }
86+ }
87+}
88+
89+// convertJob is a port of convertJob.
90+func (c *converter) convertJob(key *template.String, m *template.Mapping) *Job {
91+ if msg := c.jobIDs.tryAddKnownID(key.Value); msg != "" {
92+ c.ctx.ErrorAt(key, msg)
93+ }
94+ j := &Job{ID: key.Value, IDToken: key}
95+ var name template.Token
96+ needsSeen := false
97+ for _, p := range m.Pairs {
98+ prop := asString(p.Key, "job property name")
99+ v := p.Value
100+ switch prop.Value {
101+ case "concurrency":
102+ c.handle(v, func() { c.convertConcurrency(v) })
103+ j.Concurrency = v
104+ case "container":
105+ c.convertContainer(v, false)
106+ j.Container = v
107+ case "env":
108+ c.handle(v, func() { j.Env = asMap(v, "job env") })
109+ case "environment":
110+ c.handle(v, func() { c.convertEnvironment(v) })
111+ j.Environment = v
112+ case "if":
113+ j.If = c.convertIf(v, "job-if")
114+ case "name":
115+ name = asScalar(v, "job name")
116+ case "needs":
117+ needsSeen = true
118+ j.NeedTokens = nil
119+ if s, ok := v.(*template.String); ok {
120+ j.NeedTokens = append(j.NeedTokens, asString(s, "job needs id"))
121+ }
122+ if seq, ok := v.(*template.Sequence); ok {
123+ for _, it := range seq.Items {
124+ j.NeedTokens = append(j.NeedTokens, asString(it, "job needs id"))
125+ }
126+ }
127+ case "outputs":
128+ c.handle(v, func() { j.Outputs = asMap(v, "job outputs") })
129+ case "runs-on":
130+ c.handle(v, func() { c.convertRunsOn(v) })
131+ j.RunsOn = v
132+ case "services":
133+ c.convertServices(v)
134+ j.Services = v
135+ case "snapshot":
136+ j.Snapshot = v
137+ case "steps":
138+ j.Steps = c.convertSteps(v)
139+ case "strategy":
140+ j.Strategy = v
141+ case "uses":
142+ j.Uses = asString(v, "job uses value")
143+ case "with":
144+ c.handle(v, func() { j.With = asMap(v, "uses-with value") })
145+ case "secrets":
146+ if s, ok := v.(*template.String); ok && s.Value == "inherit" {
147+ j.InheritSecrets = true
148+ } else {
149+ c.handle(v, func() { j.Secrets = asMap(v, "uses-secrets value") })
150+ }
151+ case "timeout-minutes":
152+ j.TimeoutMinutes = v
153+ case "cancel-timeout-minutes":
154+ j.CancelTimeoutMinutes = v
155+ case "continue-on-error":
156+ j.ContinueOnError = v
157+ case "defaults":
158+ j.Defaults = convertDefaults(v)
159+ case "permissions":
160+ j.Permissions = convertPermissions(v)
161+ }
162+ }
163+ _ = needsSeen
164+ for _, n := range j.NeedTokens {
165+ j.Needs = append(j.Needs, n.Value)
166+ }
167+ if j.If == nil {
168+ j.If = c.defaultIf("job-if")
169+ }
170+ switch {
171+ case name == nil:
172+ j.Name = key
173+ case isEmptyString(name):
174+ j.Name = key
175+ default:
176+ j.Name, j.HasName = name, true
177+ }
178+ return j
179+}
180+
181+func isEmptyString(t template.Token) bool {
182+ s, ok := t.(*template.String)
183+ return ok && s.Value == ""
184+}
A workflow/convert_misc.go +192 -0
@@ -0,0 +1,192 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "fmt"
7+ "strings"
8+
9+ "bnema.dev/ermite-actions/expr"
10+ "bnema.dev/ermite-actions/template"
11+)
12+
13+func sprintf(format string, a ...any) string {
14+ if len(a) == 0 {
15+ return format
16+ }
17+ return fmt.Sprintf(format, a...)
18+}
19+
20+// convertConcurrency validates a concurrency token. Expressions are skipped.
21+func (c *converter) convertConcurrency(t template.Token) {
22+ if template.IsExpression(t) {
23+ return
24+ }
25+ if _, ok := t.(*template.String); ok {
26+ return
27+ }
28+ m := asMap(t, "concurrency group")
29+ for _, p := range m.Pairs {
30+ name := asString(p.Key, "concurrency group key")
31+ if template.IsExpression(p.Key) || template.IsExpression(p.Value) {
32+ continue
33+ }
34+ switch name.Value {
35+ case "group":
36+ asString(p.Value, "concurrency group")
37+ case "cancel-in-progress":
38+ asBool(p.Value, "cancel-in-progress")
39+ case "queue":
40+ asString(p.Value, "queue")
41+ default:
42+ c.ctx.Errorf(name, "Invalid property name: %s", name.Value)
43+ }
44+ }
45+}
46+
47+// convertEnvironment validates a job environment token.
48+func (c *converter) convertEnvironment(t template.Token) {
49+ if template.IsExpression(t) || template.IsScalar(t) {
50+ return
51+ }
52+ m := asMap(t, "job environment")
53+ for _, p := range m.Pairs {
54+ name := asString(p.Key, "job environment key")
55+ if template.IsExpression(p.Key) || template.IsExpression(p.Value) {
56+ continue
57+ }
58+ switch name.Value {
59+ case "name":
60+ asScalar(p.Value, "job environment name key")
61+ case "deployment":
62+ asBool(p.Value, "job environment deployment")
63+ }
64+ }
65+}
66+
67+// convertRunsOn validates a runs-on token.
68+func (c *converter) convertRunsOn(t template.Token) {
69+ labels := runsOnLabels(t)
70+ _ = labels
71+ m, ok := t.(*template.Mapping)
72+ if !ok {
73+ return
74+ }
75+ for _, p := range m.Pairs {
76+ key := asString(p.Key, "job runs-on property name")
77+ switch key.Value {
78+ case "group":
79+ if template.IsExpression(p.Value) {
80+ continue
81+ }
82+ group := asString(p.Value, "job runs-on group name").Value
83+ names := strings.Split(group, "/")
84+ switch len(names) {
85+ case 1:
86+ case 2:
87+ switch names[0] {
88+ case "org", "organization", "ent", "enterprise":
89+ default:
90+ c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group)
91+ continue
92+ }
93+ if names[1] == "" {
94+ c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s'.", group)
95+ }
96+ default:
97+ c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group)
98+ }
99+ case "labels":
100+ runsOnLabels(p.Value)
101+ }
102+ }
103+}
104+
105+// runsOnLabels collects the literal labels of a runs-on token, asserting that
106+// the items are strings.
107+func runsOnLabels(t template.Token) []string {
108+ if template.IsExpression(t) {
109+ return nil
110+ }
111+ switch t := t.(type) {
112+ case *template.String:
113+ return []string{t.Value}
114+ case *template.Sequence:
115+ var out []string
116+ for _, it := range t.Items {
117+ if template.IsExpression(it) {
118+ continue
119+ }
120+ out = append(out, asString(it, "job runs-on label sequence item").Value)
121+ }
122+ return out
123+ }
124+ return nil
125+}
126+
127+// statusFunctions are the functions that make a condition a status check.
128+var statusFunctions = map[string]bool{"success": true, "failure": true, "cancelled": true, "always": true}
129+
130+func hasStatusFunction(n expr.Node) bool {
131+ switch n := n.(type) {
132+ case *expr.FunctionCall:
133+ if statusFunctions[strings.ToLower(n.Name.Lexeme)] {
134+ return true
135+ }
136+ for _, a := range n.Args {
137+ if hasStatusFunction(a) {
138+ return true
139+ }
140+ }
141+ case *expr.Binary:
142+ return hasStatusFunction(n.Left) || hasStatusFunction(n.Right)
143+ case *expr.Unary:
144+ return hasStatusFunction(n.Expr)
145+ case *expr.Logical:
146+ for _, a := range n.Args {
147+ if hasStatusFunction(a) {
148+ return true
149+ }
150+ }
151+ case *expr.Grouping:
152+ return hasStatusFunction(n.Group)
153+ case *expr.IndexAccess:
154+ return hasStatusFunction(n.Expr) || hasStatusFunction(n.Index)
155+ }
156+ return false
157+}
158+
159+// convertIf converts an if condition: the result always contains a status
160+// function, wrapping the condition as "success() && (cond)" when it has none.
161+// A condition that does not parse is recorded as an error and returns nil.
162+func (c *converter) convertIf(t template.Token, defName string) *template.BasicExpression {
163+ scalar := asScalar(t, "if condition")
164+ def := t.Definition()
165+ if def == nil {
166+ def = c.schema.Info(defName)
167+ }
168+ var condition string
169+ switch s := scalar.(type) {
170+ case *template.BasicExpression:
171+ condition = s.Expression
172+ default:
173+ condition = strings.TrimSpace(asString(scalar, "if condition").Value)
174+ }
175+ final := "success()"
176+ if condition != "" {
177+ final = condition
178+ if node, err := expr.Parse(condition, def.NamedContexts(), def.Functions()); err == nil && !hasStatusFunction(node) {
179+ final = "success() && (" + condition + ")"
180+ }
181+ }
182+ if _, err := expr.Parse(final, def.NamedContexts(), def.Functions()); err != nil {
183+ c.ctx.ErrorAt(t, err.Error())
184+ return nil
185+ }
186+ return template.NewExpression(origin(t), final, def)
187+}
188+
189+// defaultIf is the condition of a job or step that has none.
190+func (c *converter) defaultIf(defName string) *template.BasicExpression {
191+ return template.NewExpression(template.Origin{}, "success()", c.schema.Info(defName))
192+}
@@ -0,0 +1,192 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "fmt"
7+ "strings"
8+
9+ "bnema.dev/ermite-actions/expr"
10+ "bnema.dev/ermite-actions/template"
11+)
12+
13+func sprintf(format string, a ...any) string {
14+ if len(a) == 0 {
15+ return format
16+ }
17+ return fmt.Sprintf(format, a...)
18+}
19+
20+// convertConcurrency validates a concurrency token. Expressions are skipped.
21+func (c *converter) convertConcurrency(t template.Token) {
22+ if template.IsExpression(t) {
23+ return
24+ }
25+ if _, ok := t.(*template.String); ok {
26+ return
27+ }
28+ m := asMap(t, "concurrency group")
29+ for _, p := range m.Pairs {
30+ name := asString(p.Key, "concurrency group key")
31+ if template.IsExpression(p.Key) || template.IsExpression(p.Value) {
32+ continue
33+ }
34+ switch name.Value {
35+ case "group":
36+ asString(p.Value, "concurrency group")
37+ case "cancel-in-progress":
38+ asBool(p.Value, "cancel-in-progress")
39+ case "queue":
40+ asString(p.Value, "queue")
41+ default:
42+ c.ctx.Errorf(name, "Invalid property name: %s", name.Value)
43+ }
44+ }
45+}
46+
47+// convertEnvironment validates a job environment token.
48+func (c *converter) convertEnvironment(t template.Token) {
49+ if template.IsExpression(t) || template.IsScalar(t) {
50+ return
51+ }
52+ m := asMap(t, "job environment")
53+ for _, p := range m.Pairs {
54+ name := asString(p.Key, "job environment key")
55+ if template.IsExpression(p.Key) || template.IsExpression(p.Value) {
56+ continue
57+ }
58+ switch name.Value {
59+ case "name":
60+ asScalar(p.Value, "job environment name key")
61+ case "deployment":
62+ asBool(p.Value, "job environment deployment")
63+ }
64+ }
65+}
66+
67+// convertRunsOn validates a runs-on token.
68+func (c *converter) convertRunsOn(t template.Token) {
69+ labels := runsOnLabels(t)
70+ _ = labels
71+ m, ok := t.(*template.Mapping)
72+ if !ok {
73+ return
74+ }
75+ for _, p := range m.Pairs {
76+ key := asString(p.Key, "job runs-on property name")
77+ switch key.Value {
78+ case "group":
79+ if template.IsExpression(p.Value) {
80+ continue
81+ }
82+ group := asString(p.Value, "job runs-on group name").Value
83+ names := strings.Split(group, "/")
84+ switch len(names) {
85+ case 1:
86+ case 2:
87+ switch names[0] {
88+ case "org", "organization", "ent", "enterprise":
89+ default:
90+ c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group)
91+ continue
92+ }
93+ if names[1] == "" {
94+ c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s'.", group)
95+ }
96+ default:
97+ c.ctx.Errorf(p.Value, "Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group)
98+ }
99+ case "labels":
100+ runsOnLabels(p.Value)
101+ }
102+ }
103+}
104+
105+// runsOnLabels collects the literal labels of a runs-on token, asserting that
106+// the items are strings.
107+func runsOnLabels(t template.Token) []string {
108+ if template.IsExpression(t) {
109+ return nil
110+ }
111+ switch t := t.(type) {
112+ case *template.String:
113+ return []string{t.Value}
114+ case *template.Sequence:
115+ var out []string
116+ for _, it := range t.Items {
117+ if template.IsExpression(it) {
118+ continue
119+ }
120+ out = append(out, asString(it, "job runs-on label sequence item").Value)
121+ }
122+ return out
123+ }
124+ return nil
125+}
126+
127+// statusFunctions are the functions that make a condition a status check.
128+var statusFunctions = map[string]bool{"success": true, "failure": true, "cancelled": true, "always": true}
129+
130+func hasStatusFunction(n expr.Node) bool {
131+ switch n := n.(type) {
132+ case *expr.FunctionCall:
133+ if statusFunctions[strings.ToLower(n.Name.Lexeme)] {
134+ return true
135+ }
136+ for _, a := range n.Args {
137+ if hasStatusFunction(a) {
138+ return true
139+ }
140+ }
141+ case *expr.Binary:
142+ return hasStatusFunction(n.Left) || hasStatusFunction(n.Right)
143+ case *expr.Unary:
144+ return hasStatusFunction(n.Expr)
145+ case *expr.Logical:
146+ for _, a := range n.Args {
147+ if hasStatusFunction(a) {
148+ return true
149+ }
150+ }
151+ case *expr.Grouping:
152+ return hasStatusFunction(n.Group)
153+ case *expr.IndexAccess:
154+ return hasStatusFunction(n.Expr) || hasStatusFunction(n.Index)
155+ }
156+ return false
157+}
158+
159+// convertIf converts an if condition: the result always contains a status
160+// function, wrapping the condition as "success() && (cond)" when it has none.
161+// A condition that does not parse is recorded as an error and returns nil.
162+func (c *converter) convertIf(t template.Token, defName string) *template.BasicExpression {
163+ scalar := asScalar(t, "if condition")
164+ def := t.Definition()
165+ if def == nil {
166+ def = c.schema.Info(defName)
167+ }
168+ var condition string
169+ switch s := scalar.(type) {
170+ case *template.BasicExpression:
171+ condition = s.Expression
172+ default:
173+ condition = strings.TrimSpace(asString(scalar, "if condition").Value)
174+ }
175+ final := "success()"
176+ if condition != "" {
177+ final = condition
178+ if node, err := expr.Parse(condition, def.NamedContexts(), def.Functions()); err == nil && !hasStatusFunction(node) {
179+ final = "success() && (" + condition + ")"
180+ }
181+ }
182+ if _, err := expr.Parse(final, def.NamedContexts(), def.Functions()); err != nil {
183+ c.ctx.ErrorAt(t, err.Error())
184+ return nil
185+ }
186+ return template.NewExpression(origin(t), final, def)
187+}
188+
189+// defaultIf is the condition of a job or step that has none.
190+func (c *converter) defaultIf(defName string) *template.BasicExpression {
191+ return template.NewExpression(template.Origin{}, "success()", c.schema.Info(defName))
192+}
A workflow/convert_steps.go +126 -0
@@ -0,0 +1,126 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "strings"
7+
8+ "bnema.dev/ermite-actions/template"
9+)
10+
11+// convertSteps is a port of convertSteps: steps that fail to convert are
12+// dropped, ids written in the file are checked, and the others get a
13+// generated id.
14+func (c *converter) convertSteps(t template.Token) []*Step {
15+ seq, ok := t.(*template.Sequence)
16+ if !ok {
17+ c.ctx.ErrorAt(t, "Invalid format for steps")
18+ return nil
19+ }
20+ ids := newIDBuilder()
21+ var result []*Step
22+ for _, it := range seq.Items {
23+ c.handle(seq, func() {
24+ if s := c.convertStep(ids, it); s != nil {
25+ result = append(result, s)
26+ }
27+ })
28+ }
29+ for _, s := range result {
30+ if s.ID != "" {
31+ continue
32+ }
33+ id := ""
34+ if s.Uses != nil {
35+ id = actionStepID(s.Uses.Value)
36+ }
37+ if id == "" {
38+ id = "run"
39+ }
40+ ids.appendSegment("__" + id)
41+ s.ID = ids.build()
42+ }
43+ return result
44+}
45+
46+func (c *converter) convertStep(ids *idBuilder, t template.Token) *Step {
47+ m := asMap(t, "steps item")
48+ s := &Step{Pos: t.Pos()}
49+ var uses *template.String
50+ var run template.Token
51+ for _, p := range m.Pairs {
52+ key := asString(p.Key, "steps item key")
53+ v := p.Value
54+ switch key.Value {
55+ case "id":
56+ id := asString(v, "steps item id")
57+ if msg := ids.tryAddKnownID(id.Value); msg != "" {
58+ c.ctx.ErrorAt(id, msg)
59+ }
60+ s.ID = id.Value
61+ s.Explicit = id.Value != ""
62+ case "name":
63+ s.Name = asScalar(v, "steps item name")
64+ case "run":
65+ run = asScalar(v, "steps item run")
66+ case "uses":
67+ uses = asString(v, "steps item uses")
68+ case "env":
69+ s.Env = asMap(v, "step env")
70+ case "if":
71+ s.If = c.convertIf(v, "step-if")
72+ case "continue-on-error":
73+ if !template.IsExpression(v) {
74+ asBool(v, "steps item continue-on-error")
75+ } else {
76+ asScalar(v, "steps item continue-on-error")
77+ }
78+ s.ContinueOnError = v
79+ case "shell":
80+ s.Shell = v
81+ case "working-directory":
82+ s.WorkingDirectory = v
83+ case "timeout-minutes":
84+ s.TimeoutMinutes = v
85+ case "with":
86+ if wm, ok := v.(*template.Mapping); ok {
87+ s.With = wm
88+ }
89+ }
90+ }
91+ switch {
92+ case run != nil:
93+ s.Run = run
94+ case uses != nil:
95+ s.Uses = uses
96+ default:
97+ c.ctx.ErrorAt(t, "Expected one of uses or run to be defined")
98+ return nil
99+ }
100+ if s.If == nil {
101+ s.If = c.defaultIf("step-if")
102+ }
103+ return s
104+}
105+
106+// actionStepID is the id segment generated for a uses step without an id.
107+func actionStepID(uses string) string {
108+ switch {
109+ case strings.HasPrefix(uses, "docker://"):
110+ return uses[len("docker://"):]
111+ case strings.HasPrefix(uses, "./"), strings.HasPrefix(uses, ".\\"), strings.HasPrefix(uses, "$/"):
112+ return "self"
113+ }
114+ segments := strings.Split(uses, "@")
115+ if len(segments) != 2 {
116+ return ""
117+ }
118+ var path []string
119+ for _, p := range strings.FieldsFunc(segments[0], func(r rune) bool { return r == '/' || r == '\\' }) {
120+ path = append(path, p)
121+ }
122+ if len(path) >= 2 && path[0] != "" && path[1] != "" && segments[1] != "" {
123+ return path[0] + "/" + path[1]
124+ }
125+ return ""
126+}
@@ -0,0 +1,126 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "strings"
7+
8+ "bnema.dev/ermite-actions/template"
9+)
10+
11+// convertSteps is a port of convertSteps: steps that fail to convert are
12+// dropped, ids written in the file are checked, and the others get a
13+// generated id.
14+func (c *converter) convertSteps(t template.Token) []*Step {
15+ seq, ok := t.(*template.Sequence)
16+ if !ok {
17+ c.ctx.ErrorAt(t, "Invalid format for steps")
18+ return nil
19+ }
20+ ids := newIDBuilder()
21+ var result []*Step
22+ for _, it := range seq.Items {
23+ c.handle(seq, func() {
24+ if s := c.convertStep(ids, it); s != nil {
25+ result = append(result, s)
26+ }
27+ })
28+ }
29+ for _, s := range result {
30+ if s.ID != "" {
31+ continue
32+ }
33+ id := ""
34+ if s.Uses != nil {
35+ id = actionStepID(s.Uses.Value)
36+ }
37+ if id == "" {
38+ id = "run"
39+ }
40+ ids.appendSegment("__" + id)
41+ s.ID = ids.build()
42+ }
43+ return result
44+}
45+
46+func (c *converter) convertStep(ids *idBuilder, t template.Token) *Step {
47+ m := asMap(t, "steps item")
48+ s := &Step{Pos: t.Pos()}
49+ var uses *template.String
50+ var run template.Token
51+ for _, p := range m.Pairs {
52+ key := asString(p.Key, "steps item key")
53+ v := p.Value
54+ switch key.Value {
55+ case "id":
56+ id := asString(v, "steps item id")
57+ if msg := ids.tryAddKnownID(id.Value); msg != "" {
58+ c.ctx.ErrorAt(id, msg)
59+ }
60+ s.ID = id.Value
61+ s.Explicit = id.Value != ""
62+ case "name":
63+ s.Name = asScalar(v, "steps item name")
64+ case "run":
65+ run = asScalar(v, "steps item run")
66+ case "uses":
67+ uses = asString(v, "steps item uses")
68+ case "env":
69+ s.Env = asMap(v, "step env")
70+ case "if":
71+ s.If = c.convertIf(v, "step-if")
72+ case "continue-on-error":
73+ if !template.IsExpression(v) {
74+ asBool(v, "steps item continue-on-error")
75+ } else {
76+ asScalar(v, "steps item continue-on-error")
77+ }
78+ s.ContinueOnError = v
79+ case "shell":
80+ s.Shell = v
81+ case "working-directory":
82+ s.WorkingDirectory = v
83+ case "timeout-minutes":
84+ s.TimeoutMinutes = v
85+ case "with":
86+ if wm, ok := v.(*template.Mapping); ok {
87+ s.With = wm
88+ }
89+ }
90+ }
91+ switch {
92+ case run != nil:
93+ s.Run = run
94+ case uses != nil:
95+ s.Uses = uses
96+ default:
97+ c.ctx.ErrorAt(t, "Expected one of uses or run to be defined")
98+ return nil
99+ }
100+ if s.If == nil {
101+ s.If = c.defaultIf("step-if")
102+ }
103+ return s
104+}
105+
106+// actionStepID is the id segment generated for a uses step without an id.
107+func actionStepID(uses string) string {
108+ switch {
109+ case strings.HasPrefix(uses, "docker://"):
110+ return uses[len("docker://"):]
111+ case strings.HasPrefix(uses, "./"), strings.HasPrefix(uses, ".\\"), strings.HasPrefix(uses, "$/"):
112+ return "self"
113+ }
114+ segments := strings.Split(uses, "@")
115+ if len(segments) != 2 {
116+ return ""
117+ }
118+ var path []string
119+ for _, p := range strings.FieldsFunc(segments[0], func(r rune) bool { return r == '/' || r == '\\' }) {
120+ path = append(path, p)
121+ }
122+ if len(path) >= 2 && path[0] != "" && path[1] != "" && segments[1] != "" {
123+ return path[0] + "/" + path[1]
124+ }
125+ return ""
126+}
A workflow/convert_test.go +172 -0
@@ -0,0 +1,172 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "reflect"
10+ "sort"
11+ "strings"
12+ "testing"
13+)
14+
15+// refResult is what testdata/convert.json records for one file: the output of
16+// @actions/workflow-parser 0.3.61 (see gen/convert.mjs).
17+type refResult struct {
18+ Errors []string `json:"errors"`
19+ Jobs []struct {
20+ ID string `json:"id"`
21+ Name string `json:"name"`
22+ If string `json:"if"`
23+ Needs []string `json:"needs"`
24+ Steps []struct {
25+ ID string `json:"id"`
26+ If string `json:"if"`
27+ } `json:"steps"`
28+ } `json:"jobs"`
29+ Events map[string]any `json:"events"`
30+}
31+
32+func loadRef(t *testing.T) map[string]refResult {
33+ t.Helper()
34+ data, err := os.ReadFile("testdata/convert.json")
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ var m map[string]refResult
39+ if err := json.Unmarshal(data, &m); err != nil {
40+ t.Fatal(err)
41+ }
42+ return m
43+}
44+
45+func errorTexts(errs []Error) []string {
46+ out := make([]string, len(errs))
47+ for i := range errs {
48+ out[i] = errs[i].Error()
49+ }
50+ return out
51+}
52+
53+// isYAMLError recognises the reference's YAML syntax errors, whose text is
54+// the JavaScript library's and cannot be reproduced word for word.
55+func isYAMLError(s string) bool { return strings.Contains(s, " at line ") }
56+
57+func TestConvertAgainstReference(t *testing.T) {
58+ ref := loadRef(t)
59+ files, err := filepath.Glob("testdata/convert/*.yml")
60+ if err != nil || len(files) == 0 {
61+ t.Fatalf("no corpus: %v", err)
62+ }
63+ for _, f := range files {
64+ name := filepath.Base(f)
65+ want, ok := ref[name]
66+ if !ok {
67+ t.Errorf("%s: missing from convert.json", name)
68+ continue
69+ }
70+ t.Run(strings.TrimSuffix(name, ".yml"), func(t *testing.T) {
71+ content, err := os.ReadFile(f)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ w, errs := Parse(name, content)
76+ got := errorTexts(errs)
77+ if len(want.Errors) == 1 && isYAMLError(want.Errors[0]) {
78+ // Syntax errors: the Go parser words them differently. Only
79+ // the fact that there is exactly one, on the file, is checked.
80+ if len(got) != 1 || !strings.HasPrefix(got[0], name+": ") {
81+ t.Errorf("syntax error: got %q, reference %q", got, want.Errors)
82+ }
83+ if w != nil {
84+ t.Error("workflow returned with errors")
85+ }
86+ return
87+ }
88+ if !reflect.DeepEqual(nonNil(got), nonNil(want.Errors)) {
89+ t.Errorf("errors differ\n got: %q\nwant: %q", got, want.Errors)
90+ }
91+ if len(want.Errors) > 0 {
92+ if w != nil {
93+ t.Error("workflow returned with errors")
94+ }
95+ return
96+ }
97+ if w == nil {
98+ t.Fatal("no workflow and no errors")
99+ }
100+ if len(w.Jobs) != len(want.Jobs) {
101+ t.Fatalf("jobs: got %d, reference %d", len(w.Jobs), len(want.Jobs))
102+ }
103+ for i, wj := range want.Jobs {
104+ j := w.Jobs[i]
105+ if j.ID != wj.ID || j.Name.String() != wj.Name || j.If.Expression != wj.If {
106+ t.Errorf("job %d: got id=%q name=%q if=%q, reference id=%q name=%q if=%q", i, j.ID, j.Name, j.If.Expression, wj.ID, wj.Name, wj.If)
107+ }
108+ if !reflect.DeepEqual(nonNil(j.Needs), nonNil(wj.Needs)) && !(j.IsReusable() && len(wj.Needs) == 0) {
109+ t.Errorf("job %s needs: got %v, reference %v", j.ID, j.Needs, wj.Needs)
110+ }
111+ if len(j.Steps) != len(wj.Steps) {
112+ t.Errorf("job %s: %d steps, reference %d", j.ID, len(j.Steps), len(wj.Steps))
113+ continue
114+ }
115+ for k, ws := range wj.Steps {
116+ if j.Steps[k].ID != ws.ID || j.Steps[k].If.Expression != ws.If {
117+ t.Errorf("job %s step %d: got id=%q if=%q, reference id=%q if=%q", j.ID, k, j.Steps[k].ID, j.Steps[k].If.Expression, ws.ID, ws.If)
118+ }
119+ }
120+ }
121+ checkEvents(t, w, want.Events)
122+ })
123+ }
124+}
125+
126+func nonNil(s []string) []string {
127+ if s == nil {
128+ return []string{}
129+ }
130+ return s
131+}
132+
133+// checkEvents compares the filters of the events with the reference.
134+func checkEvents(t *testing.T, w *Workflow, want map[string]any) {
135+ t.Helper()
136+ var names []string
137+ for _, e := range w.On.Events {
138+ names = append(names, e.Name)
139+ }
140+ var wnames []string
141+ for k := range want {
142+ wnames = append(wnames, k)
143+ }
144+ sort.Strings(names)
145+ sort.Strings(wnames)
146+ if !reflect.DeepEqual(names, wnames) {
147+ t.Errorf("events: got %v, reference %v", names, wnames)
148+ return
149+ }
150+ for _, e := range w.On.Events {
151+ wf, _ := want[e.Name].(map[string]any)
152+ for key, got := range map[string][]string{
153+ "branches": e.Branches, "branches-ignore": e.BranchesIgnore, "tags": e.Tags,
154+ "tags-ignore": e.TagsIgnore, "paths": e.Paths, "paths-ignore": e.PathsIgnore, "types": e.Types,
155+ } {
156+ raw, present := wf[key]
157+ if (got != nil) != present {
158+ t.Errorf("event %s filter %s: present=%v, reference present=%v", e.Name, key, got != nil, present)
159+ continue
160+ }
161+ if present {
162+ var ws []string
163+ for _, x := range raw.([]any) {
164+ ws = append(ws, x.(string))
165+ }
166+ if !reflect.DeepEqual(nonNil(got), nonNil(ws)) {
167+ t.Errorf("event %s filter %s: got %v, reference %v", e.Name, key, got, ws)
168+ }
169+ }
170+ }
171+ }
172+}
@@ -0,0 +1,172 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "reflect"
10+ "sort"
11+ "strings"
12+ "testing"
13+)
14+
15+// refResult is what testdata/convert.json records for one file: the output of
16+// @actions/workflow-parser 0.3.61 (see gen/convert.mjs).
17+type refResult struct {
18+ Errors []string `json:"errors"`
19+ Jobs []struct {
20+ ID string `json:"id"`
21+ Name string `json:"name"`
22+ If string `json:"if"`
23+ Needs []string `json:"needs"`
24+ Steps []struct {
25+ ID string `json:"id"`
26+ If string `json:"if"`
27+ } `json:"steps"`
28+ } `json:"jobs"`
29+ Events map[string]any `json:"events"`
30+}
31+
32+func loadRef(t *testing.T) map[string]refResult {
33+ t.Helper()
34+ data, err := os.ReadFile("testdata/convert.json")
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ var m map[string]refResult
39+ if err := json.Unmarshal(data, &m); err != nil {
40+ t.Fatal(err)
41+ }
42+ return m
43+}
44+
45+func errorTexts(errs []Error) []string {
46+ out := make([]string, len(errs))
47+ for i := range errs {
48+ out[i] = errs[i].Error()
49+ }
50+ return out
51+}
52+
53+// isYAMLError recognises the reference's YAML syntax errors, whose text is
54+// the JavaScript library's and cannot be reproduced word for word.
55+func isYAMLError(s string) bool { return strings.Contains(s, " at line ") }
56+
57+func TestConvertAgainstReference(t *testing.T) {
58+ ref := loadRef(t)
59+ files, err := filepath.Glob("testdata/convert/*.yml")
60+ if err != nil || len(files) == 0 {
61+ t.Fatalf("no corpus: %v", err)
62+ }
63+ for _, f := range files {
64+ name := filepath.Base(f)
65+ want, ok := ref[name]
66+ if !ok {
67+ t.Errorf("%s: missing from convert.json", name)
68+ continue
69+ }
70+ t.Run(strings.TrimSuffix(name, ".yml"), func(t *testing.T) {
71+ content, err := os.ReadFile(f)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ w, errs := Parse(name, content)
76+ got := errorTexts(errs)
77+ if len(want.Errors) == 1 && isYAMLError(want.Errors[0]) {
78+ // Syntax errors: the Go parser words them differently. Only
79+ // the fact that there is exactly one, on the file, is checked.
80+ if len(got) != 1 || !strings.HasPrefix(got[0], name+": ") {
81+ t.Errorf("syntax error: got %q, reference %q", got, want.Errors)
82+ }
83+ if w != nil {
84+ t.Error("workflow returned with errors")
85+ }
86+ return
87+ }
88+ if !reflect.DeepEqual(nonNil(got), nonNil(want.Errors)) {
89+ t.Errorf("errors differ\n got: %q\nwant: %q", got, want.Errors)
90+ }
91+ if len(want.Errors) > 0 {
92+ if w != nil {
93+ t.Error("workflow returned with errors")
94+ }
95+ return
96+ }
97+ if w == nil {
98+ t.Fatal("no workflow and no errors")
99+ }
100+ if len(w.Jobs) != len(want.Jobs) {
101+ t.Fatalf("jobs: got %d, reference %d", len(w.Jobs), len(want.Jobs))
102+ }
103+ for i, wj := range want.Jobs {
104+ j := w.Jobs[i]
105+ if j.ID != wj.ID || j.Name.String() != wj.Name || j.If.Expression != wj.If {
106+ t.Errorf("job %d: got id=%q name=%q if=%q, reference id=%q name=%q if=%q", i, j.ID, j.Name, j.If.Expression, wj.ID, wj.Name, wj.If)
107+ }
108+ if !reflect.DeepEqual(nonNil(j.Needs), nonNil(wj.Needs)) && !(j.IsReusable() && len(wj.Needs) == 0) {
109+ t.Errorf("job %s needs: got %v, reference %v", j.ID, j.Needs, wj.Needs)
110+ }
111+ if len(j.Steps) != len(wj.Steps) {
112+ t.Errorf("job %s: %d steps, reference %d", j.ID, len(j.Steps), len(wj.Steps))
113+ continue
114+ }
115+ for k, ws := range wj.Steps {
116+ if j.Steps[k].ID != ws.ID || j.Steps[k].If.Expression != ws.If {
117+ t.Errorf("job %s step %d: got id=%q if=%q, reference id=%q if=%q", j.ID, k, j.Steps[k].ID, j.Steps[k].If.Expression, ws.ID, ws.If)
118+ }
119+ }
120+ }
121+ checkEvents(t, w, want.Events)
122+ })
123+ }
124+}
125+
126+func nonNil(s []string) []string {
127+ if s == nil {
128+ return []string{}
129+ }
130+ return s
131+}
132+
133+// checkEvents compares the filters of the events with the reference.
134+func checkEvents(t *testing.T, w *Workflow, want map[string]any) {
135+ t.Helper()
136+ var names []string
137+ for _, e := range w.On.Events {
138+ names = append(names, e.Name)
139+ }
140+ var wnames []string
141+ for k := range want {
142+ wnames = append(wnames, k)
143+ }
144+ sort.Strings(names)
145+ sort.Strings(wnames)
146+ if !reflect.DeepEqual(names, wnames) {
147+ t.Errorf("events: got %v, reference %v", names, wnames)
148+ return
149+ }
150+ for _, e := range w.On.Events {
151+ wf, _ := want[e.Name].(map[string]any)
152+ for key, got := range map[string][]string{
153+ "branches": e.Branches, "branches-ignore": e.BranchesIgnore, "tags": e.Tags,
154+ "tags-ignore": e.TagsIgnore, "paths": e.Paths, "paths-ignore": e.PathsIgnore, "types": e.Types,
155+ } {
156+ raw, present := wf[key]
157+ if (got != nil) != present {
158+ t.Errorf("event %s filter %s: present=%v, reference present=%v", e.Name, key, got != nil, present)
159+ continue
160+ }
161+ if present {
162+ var ws []string
163+ for _, x := range raw.([]any) {
164+ ws = append(ws, x.(string))
165+ }
166+ if !reflect.DeepEqual(nonNil(got), nonNil(ws)) {
167+ t.Errorf("event %s filter %s: got %v, reference %v", e.Name, key, got, ws)
168+ }
169+ }
170+ }
171+ }
172+}
A workflow/cron.go +151 -0
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "math"
7+ "regexp"
8+ "strconv"
9+ "strings"
10+)
11+
12+// Cron validation, ported from converter/cron.ts.
13+
14+type cronRange struct {
15+ min, max int
16+ names map[string]int
17+}
18+
19+var (
20+ cronMinute = cronRange{0, 59, nil}
21+ cronHour = cronRange{0, 23, nil}
22+ cronDOM = cronRange{1, 31, nil}
23+ cronMonth = cronRange{1, 12, map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}}
24+ cronDOW = cronRange{0, 6, map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}}
25+)
26+
27+func isValidCron(cron string) bool {
28+ parts := splitSpaces(cron)
29+ if len(parts) != 5 {
30+ return false
31+ }
32+ return validateCronPart(parts[0], cronMinute, true) && validateCronPart(parts[1], cronHour, true) &&
33+ validateCronPart(parts[2], cronDOM, true) && validateCronPart(parts[3], cronMonth, true) &&
34+ validateCronPart(parts[4], cronDOW, true)
35+}
36+
37+// splitSpaces is JavaScript's split(/ +/): runs of spaces separate, other
38+// whitespace does not, and leading or trailing spaces give empty parts.
39+func splitSpaces(s string) []string {
40+ var parts []string
41+ start := 0
42+ i := 0
43+ for i < len(s) {
44+ if s[i] != ' ' {
45+ i++
46+ continue
47+ }
48+ parts = append(parts, s[start:i])
49+ for i < len(s) && s[i] == ' ' {
50+ i++
51+ }
52+ start = i
53+ }
54+ return append(parts, s[start:])
55+}
56+
57+var jsDecimal = regexp.MustCompile(`^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$`)
58+
59+// jsNumber is JavaScript's Number(string): surrounding white space is
60+// ignored, an empty string is 0, and decimal, hexadecimal, octal and binary
61+// literals and Infinity are numbers. ok is false for NaN.
62+func jsNumber(v string) (float64, bool) {
63+ t := strings.Trim(v, " \t\n\v\f\r\u00a0\u2028\u2029\ufeff")
64+ switch {
65+ case t == "":
66+ return 0, true
67+ case jsDecimal.MatchString(t):
68+ f, err := strconv.ParseFloat(t, 64)
69+ return f, err == nil || math.IsInf(f, 0)
70+ case t == "Infinity" || t == "+Infinity":
71+ return math.Inf(1), true
72+ case t == "-Infinity":
73+ return math.Inf(-1), true
74+ case len(t) > 2 && t[0] == '0':
75+ base := map[byte]int{'x': 16, 'X': 16, 'o': 8, 'O': 8, 'b': 2, 'B': 2}[t[1]]
76+ if base == 0 {
77+ return 0, false
78+ }
79+ n, err := strconv.ParseUint(t[2:], base, 64)
80+ if err != nil {
81+ if ne, ok := err.(*strconv.NumError); ok && ne.Err == strconv.ErrRange {
82+ return math.Inf(1), true
83+ }
84+ return 0, false
85+ }
86+ return float64(n), true
87+ }
88+ return 0, false
89+}
90+
91+func cronNumber(v string) (float64, bool) { return jsNumber(v) }
92+
93+func validateCronPart(value string, r cronRange, allowSeparators bool) bool {
94+ lower := strings.ToLower(value)
95+ if _, ok := r.names[lower]; ok {
96+ return true
97+ }
98+ if value == "*" {
99+ return true
100+ }
101+ if strings.Contains(value, ",") {
102+ if !allowSeparators {
103+ return false
104+ }
105+ for _, v := range strings.Split(value, ",") {
106+ if v == "" || !validateCronPart(v, r, true) {
107+ return false
108+ }
109+ }
110+ return true
111+ }
112+ if strings.Contains(value, "/") {
113+ if !allowSeparators {
114+ return false
115+ }
116+ parts := strings.Split(value, "/")
117+ start, step := parts[0], ""
118+ if len(parts) > 1 {
119+ step = parts[1]
120+ }
121+ n, ok := cronNumber(step)
122+ if len(parts) > 2 || !ok || n <= 0 || start == "" || step == "" {
123+ return false
124+ }
125+ return validateCronPart(start, r, true) && validateCronPart(step, r, false)
126+ }
127+ if strings.Contains(value, "-") {
128+ if !allowSeparators {
129+ return false
130+ }
131+ parts := strings.Split(value, "-")
132+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
133+ return false
134+ }
135+ s, e := cronRangeValue(parts[0], r), cronRangeValue(parts[1], r)
136+ return validateCronPart(parts[0], r, false) && validateCronPart(parts[1], r, false) && e >= s
137+ }
138+ n, ok := cronNumber(value)
139+ return ok && n >= float64(r.min) && n <= float64(r.max)
140+}
141+
142+func cronRangeValue(v string, r cronRange) float64 {
143+ if n, ok := r.names[strings.ToLower(v)]; ok {
144+ return float64(n)
145+ }
146+ n, ok := cronNumber(v)
147+ if !ok {
148+ return math.NaN()
149+ }
150+ return n
151+}
@@ -0,0 +1,151 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "math"
7+ "regexp"
8+ "strconv"
9+ "strings"
10+)
11+
12+// Cron validation, ported from converter/cron.ts.
13+
14+type cronRange struct {
15+ min, max int
16+ names map[string]int
17+}
18+
19+var (
20+ cronMinute = cronRange{0, 59, nil}
21+ cronHour = cronRange{0, 23, nil}
22+ cronDOM = cronRange{1, 31, nil}
23+ cronMonth = cronRange{1, 12, map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}}
24+ cronDOW = cronRange{0, 6, map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}}
25+)
26+
27+func isValidCron(cron string) bool {
28+ parts := splitSpaces(cron)
29+ if len(parts) != 5 {
30+ return false
31+ }
32+ return validateCronPart(parts[0], cronMinute, true) && validateCronPart(parts[1], cronHour, true) &&
33+ validateCronPart(parts[2], cronDOM, true) && validateCronPart(parts[3], cronMonth, true) &&
34+ validateCronPart(parts[4], cronDOW, true)
35+}
36+
37+// splitSpaces is JavaScript's split(/ +/): runs of spaces separate, other
38+// whitespace does not, and leading or trailing spaces give empty parts.
39+func splitSpaces(s string) []string {
40+ var parts []string
41+ start := 0
42+ i := 0
43+ for i < len(s) {
44+ if s[i] != ' ' {
45+ i++
46+ continue
47+ }
48+ parts = append(parts, s[start:i])
49+ for i < len(s) && s[i] == ' ' {
50+ i++
51+ }
52+ start = i
53+ }
54+ return append(parts, s[start:])
55+}
56+
57+var jsDecimal = regexp.MustCompile(`^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$`)
58+
59+// jsNumber is JavaScript's Number(string): surrounding white space is
60+// ignored, an empty string is 0, and decimal, hexadecimal, octal and binary
61+// literals and Infinity are numbers. ok is false for NaN.
62+func jsNumber(v string) (float64, bool) {
63+ t := strings.Trim(v, " \t\n\v\f\r\u00a0\u2028\u2029\ufeff")
64+ switch {
65+ case t == "":
66+ return 0, true
67+ case jsDecimal.MatchString(t):
68+ f, err := strconv.ParseFloat(t, 64)
69+ return f, err == nil || math.IsInf(f, 0)
70+ case t == "Infinity" || t == "+Infinity":
71+ return math.Inf(1), true
72+ case t == "-Infinity":
73+ return math.Inf(-1), true
74+ case len(t) > 2 && t[0] == '0':
75+ base := map[byte]int{'x': 16, 'X': 16, 'o': 8, 'O': 8, 'b': 2, 'B': 2}[t[1]]
76+ if base == 0 {
77+ return 0, false
78+ }
79+ n, err := strconv.ParseUint(t[2:], base, 64)
80+ if err != nil {
81+ if ne, ok := err.(*strconv.NumError); ok && ne.Err == strconv.ErrRange {
82+ return math.Inf(1), true
83+ }
84+ return 0, false
85+ }
86+ return float64(n), true
87+ }
88+ return 0, false
89+}
90+
91+func cronNumber(v string) (float64, bool) { return jsNumber(v) }
92+
93+func validateCronPart(value string, r cronRange, allowSeparators bool) bool {
94+ lower := strings.ToLower(value)
95+ if _, ok := r.names[lower]; ok {
96+ return true
97+ }
98+ if value == "*" {
99+ return true
100+ }
101+ if strings.Contains(value, ",") {
102+ if !allowSeparators {
103+ return false
104+ }
105+ for _, v := range strings.Split(value, ",") {
106+ if v == "" || !validateCronPart(v, r, true) {
107+ return false
108+ }
109+ }
110+ return true
111+ }
112+ if strings.Contains(value, "/") {
113+ if !allowSeparators {
114+ return false
115+ }
116+ parts := strings.Split(value, "/")
117+ start, step := parts[0], ""
118+ if len(parts) > 1 {
119+ step = parts[1]
120+ }
121+ n, ok := cronNumber(step)
122+ if len(parts) > 2 || !ok || n <= 0 || start == "" || step == "" {
123+ return false
124+ }
125+ return validateCronPart(start, r, true) && validateCronPart(step, r, false)
126+ }
127+ if strings.Contains(value, "-") {
128+ if !allowSeparators {
129+ return false
130+ }
131+ parts := strings.Split(value, "-")
132+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
133+ return false
134+ }
135+ s, e := cronRangeValue(parts[0], r), cronRangeValue(parts[1], r)
136+ return validateCronPart(parts[0], r, false) && validateCronPart(parts[1], r, false) && e >= s
137+ }
138+ n, ok := cronNumber(value)
139+ return ok && n >= float64(r.min) && n <= float64(r.max)
140+}
141+
142+func cronRangeValue(v string, r cronRange) float64 {
143+ if n, ok := r.names[strings.ToLower(v)]; ok {
144+ return float64(n)
145+ }
146+ n, ok := cronNumber(v)
147+ if !ok {
148+ return math.NaN()
149+ }
150+ return n
151+}
A workflow/doc.go +17 -0
@@ -0,0 +1,17 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package workflow parses GitHub Actions workflow files into a model and
4+// implements the planning rules that depend only on the file: validation,
5+// job matrices, push trigger filters and skip directives in commit messages.
6+//
7+// Parse is a port of the converter of the npm package
8+// @actions/workflow-parser 0.3.61 (MIT, GitHub, actions/languageservices) on
9+// top of package template. Values that GitHub computes later, such as
10+// runs-on, strategy, names and conditions, stay unevaluated template tokens
11+// in the model; package template evaluates them.
12+//
13+// The trigger rules (MatchPush, SkipCI) and the matrix expansion
14+// (ExpandMatrix) are written from GitHub's documentation and from the
15+// behaviour of the official runner; PORTING.md lists what to verify against
16+// recordings of github.com.
17+package workflow
@@ -0,0 +1,17 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package workflow parses GitHub Actions workflow files into a model and
4+// implements the planning rules that depend only on the file: validation,
5+// job matrices, push trigger filters and skip directives in commit messages.
6+//
7+// Parse is a port of the converter of the npm package
8+// @actions/workflow-parser 0.3.61 (MIT, GitHub, actions/languageservices) on
9+// top of package template. Values that GitHub computes later, such as
10+// runs-on, strategy, names and conditions, stay unevaluated template tokens
11+// in the model; package template evaluates them.
12+//
13+// The trigger rules (MatchPush, SkipCI) and the matrix expansion
14+// (ExpandMatrix) are written from GitHub's documentation and from the
15+// behaviour of the official runner; PORTING.md lists what to verify against
16+// recordings of github.com.
17+package workflow
A workflow/events.go +271 -0
@@ -0,0 +1,271 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// On is the "on" key: the events that trigger a workflow.
10+type On struct {
11+ // Events are the events in file order.
12+ Events []*Event
13+}
14+
15+// Event returns the configuration of the named event, or nil.
16+func (o *On) Event(name string) *Event {
17+ if o == nil {
18+ return nil
19+ }
20+ for _, e := range o.Events {
21+ if e.Name == name {
22+ return e
23+ }
24+ }
25+ return nil
26+}
27+
28+// Has reports whether the workflow is triggered by the named event.
29+func (o *On) Has(name string) bool { return o.Event(name) != nil }
30+
31+// Event is one event of the "on" key with its filters. A filter that is
32+// absent from the file is nil; one written as an empty list is empty and
33+// non-nil.
34+type Event struct {
35+ Name string
36+ // Token is the position of the event name.
37+ Token template.Token
38+
39+ Branches []string
40+ BranchesIgnore []string
41+ Tags []string
42+ TagsIgnore []string
43+ Paths []string
44+ PathsIgnore []string
45+ Types []string
46+ Versions []string
47+ Names []string
48+ Workflows []string
49+
50+ // Schedule holds the entries of the "schedule" event.
51+ Schedule []Schedule
52+ // Inputs holds the inputs of workflow_dispatch and workflow_call.
53+ Inputs []Input
54+}
55+
56+// Schedule is an entry of the "schedule" event.
57+type Schedule struct {
58+ Cron string
59+ Timezone string
60+}
61+
62+// Input is an input of workflow_dispatch or workflow_call.
63+type Input struct {
64+ Name string
65+ Type string // "string", "choice", "boolean", "number", "environment"
66+ Description string
67+ Required bool
68+ // HasDefault reports whether the file gave a default; Default is its text
69+ // (an expression keeps its ${{ }} form).
70+ HasDefault bool
71+ Default string
72+ Options []string
73+}
74+
75+// convertOn is a port of convertOn in converter/events.ts.
76+func (c *converter) convertOn(t template.Token) *On {
77+ on := &On{}
78+ switch t := t.(type) {
79+ case *template.String:
80+ on.Events = append(on.Events, &Event{Name: t.Value, Token: t})
81+ case *template.Sequence:
82+ for _, it := range t.Items {
83+ s := asString(it, "on")
84+ on.Events = append(on.Events, &Event{Name: s.Value, Token: s})
85+ }
86+ case *template.Mapping:
87+ for _, p := range t.Pairs {
88+ key := asString(p.Key, "event name")
89+ ev := &Event{Name: key.Value, Token: key}
90+ on.Events = append(on.Events, ev)
91+ if p.Value.Kind() == template.KindNull {
92+ continue
93+ }
94+ if key.Value == "schedule" {
95+ ev.Schedule = c.convertSchedule(asSeq(p.Value, "event schedule"))
96+ continue
97+ }
98+ m := asMap(p.Value, "event "+key.Value)
99+ switch key.Value {
100+ case "workflow_call":
101+ c.convertWorkflowCall(ev, m)
102+ continue
103+ case "workflow_dispatch":
104+ c.convertWorkflowDispatch(ev, m)
105+ continue
106+ }
107+ ev.Branches = patternFilter("branches", m)
108+ ev.BranchesIgnore = patternFilter("branches-ignore", m)
109+ ev.Tags = patternFilter("tags", m)
110+ ev.TagsIgnore = patternFilter("tags-ignore", m)
111+ ev.Paths = patternFilter("paths", m)
112+ ev.PathsIgnore = patternFilter("paths-ignore", m)
113+ ev.Types = patternFilter("types", m)
114+ ev.Versions = patternFilter("versions", m)
115+ ev.Names = patternFilter("names", m)
116+ ev.Workflows = patternFilter("workflows", m)
117+ }
118+ default:
119+ c.ctx.ErrorAt(t, "Invalid format for 'on'")
120+ return &On{}
121+ }
122+ return on
123+}
124+
125+// patternFilter reads a filter that is a string or a list of strings; nil
126+// when the key is absent.
127+func patternFilter(name string, m *template.Mapping) []string {
128+ var out []string
129+ for _, p := range m.Pairs {
130+ key := asString(p.Key, name+" filter key")
131+ if key.Value != name {
132+ continue
133+ }
134+ if s, ok := p.Value.(*template.String); ok {
135+ out = []string{s.Value}
136+ continue
137+ }
138+ out = []string{}
139+ for _, it := range asSeq(p.Value, name+" list").Items {
140+ out = append(out, asString(it, name+" list item").Value)
141+ }
142+ }
143+ return out
144+}
145+
146+func (c *converter) convertSchedule(seq *template.Sequence) []Schedule {
147+ var out []Schedule
148+ for _, it := range seq.Items {
149+ m := asMap(it, "event schedule")
150+ var cfg Schedule
151+ valid := true
152+ for _, e := range m.Pairs {
153+ key := asString(e.Key, "schedule key")
154+ switch key.Value {
155+ case "cron":
156+ cron := asString(e.Value, "schedule cron")
157+ if !isValidCron(cron.Value) {
158+ c.ctx.ErrorAt(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)")
159+ }
160+ cfg.Cron = cron.Value
161+ case "timezone":
162+ cfg.Timezone = asString(e.Value, "schedule timezone").Value
163+ default:
164+ c.ctx.ErrorAt(key, "Invalid schedule key")
165+ valid = false
166+ }
167+ }
168+ switch {
169+ case valid && cfg.Cron != "":
170+ out = append(out, cfg)
171+ case valid:
172+ c.ctx.ErrorAt(m, "Missing required key 'cron' in schedule entry")
173+ }
174+ }
175+ return out
176+}
177+
178+func (c *converter) convertWorkflowDispatch(ev *Event, m *template.Mapping) {
179+ for _, p := range m.Pairs {
180+ if asString(p.Key, "workflow dispatch input key").Value == "inputs" {
181+ ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), false)
182+ }
183+ }
184+}
185+
186+func (c *converter) convertWorkflowCall(ev *Event, m *template.Mapping) {
187+ for _, p := range m.Pairs {
188+ switch asString(p.Key, "workflow dispatch input key").Value {
189+ case "inputs":
190+ ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), true)
191+ case "secrets":
192+ for _, s := range asMap(p.Value, "workflow dispatch inputs").Pairs {
193+ asString(s.Key, "secret name")
194+ if sm, ok := s.Value.(*template.Mapping); ok {
195+ for _, kv := range sm.Pairs {
196+ switch asString(kv.Key, "workflow call secret key").Value {
197+ case "description":
198+ asString(kv.Value, "secret description")
199+ case "required":
200+ asBool(kv.Value, "secret required")
201+ }
202+ }
203+ }
204+ }
205+ }
206+ }
207+}
208+
209+// convertInputs ports convertWorkflowDispatchInputs and convertWorkflowInputs.
210+// The two differ only in that call inputs skip the default check when the
211+// default is an expression.
212+func (c *converter) convertInputs(m *template.Mapping, call bool) []Input {
213+ var out []Input
214+ for _, p := range m.Pairs {
215+ name := asString(p.Key, "input name")
216+ im := asMap(p.Value, "input configuration")
217+ out = append(out, c.convertInput(name.Value, im, call))
218+ }
219+ return out
220+}
221+
222+func (c *converter) convertInput(name string, m *template.Mapping, call bool) Input {
223+ in := Input{Name: name, Type: "string"}
224+ var def template.Token
225+ var options []string
226+ hasOptions := false
227+ for _, p := range m.Pairs {
228+ key := asString(p.Key, "workflow dispatch input key")
229+ switch key.Value {
230+ case "description":
231+ in.Description = asString(p.Value, "input description").Value
232+ case "required":
233+ in.Required = asBool(p.Value, "input required").Value
234+ case "default":
235+ def = asScalar(p.Value, "input default")
236+ case "type":
237+ in.Type = asString(p.Value, "input type").Value
238+ case "options":
239+ hasOptions = true
240+ options = []string{}
241+ for _, it := range asSeq(p.Value, "input options").Items {
242+ options = append(options, asString(it, "input options item").Value)
243+ }
244+ in.Options = options
245+ default:
246+ c.ctx.Errorf(p.Key, "Invalid key '%s'", key.Value)
247+ }
248+ }
249+ if def != nil {
250+ in.HasDefault = true
251+ in.Default = def.String()
252+ if !(call && template.IsExpression(def)) {
253+ c.handle(def, func() {
254+ switch in.Type {
255+ case "boolean":
256+ asBool(def, "input default")
257+ case "string", "choice", "environment":
258+ asString(def, "input default")
259+ }
260+ })
261+ }
262+ }
263+ if in.Type == "choice" {
264+ if !hasOptions || len(options) == 0 {
265+ c.ctx.ErrorAt(m, "Missing 'options' for choice input")
266+ }
267+ } else if hasOptions {
268+ c.ctx.ErrorAt(m, "Input type is not 'choice', but 'options' is defined")
269+ }
270+ return in
271+}
@@ -0,0 +1,271 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// On is the "on" key: the events that trigger a workflow.
10+type On struct {
11+ // Events are the events in file order.
12+ Events []*Event
13+}
14+
15+// Event returns the configuration of the named event, or nil.
16+func (o *On) Event(name string) *Event {
17+ if o == nil {
18+ return nil
19+ }
20+ for _, e := range o.Events {
21+ if e.Name == name {
22+ return e
23+ }
24+ }
25+ return nil
26+}
27+
28+// Has reports whether the workflow is triggered by the named event.
29+func (o *On) Has(name string) bool { return o.Event(name) != nil }
30+
31+// Event is one event of the "on" key with its filters. A filter that is
32+// absent from the file is nil; one written as an empty list is empty and
33+// non-nil.
34+type Event struct {
35+ Name string
36+ // Token is the position of the event name.
37+ Token template.Token
38+
39+ Branches []string
40+ BranchesIgnore []string
41+ Tags []string
42+ TagsIgnore []string
43+ Paths []string
44+ PathsIgnore []string
45+ Types []string
46+ Versions []string
47+ Names []string
48+ Workflows []string
49+
50+ // Schedule holds the entries of the "schedule" event.
51+ Schedule []Schedule
52+ // Inputs holds the inputs of workflow_dispatch and workflow_call.
53+ Inputs []Input
54+}
55+
56+// Schedule is an entry of the "schedule" event.
57+type Schedule struct {
58+ Cron string
59+ Timezone string
60+}
61+
62+// Input is an input of workflow_dispatch or workflow_call.
63+type Input struct {
64+ Name string
65+ Type string // "string", "choice", "boolean", "number", "environment"
66+ Description string
67+ Required bool
68+ // HasDefault reports whether the file gave a default; Default is its text
69+ // (an expression keeps its ${{ }} form).
70+ HasDefault bool
71+ Default string
72+ Options []string
73+}
74+
75+// convertOn is a port of convertOn in converter/events.ts.
76+func (c *converter) convertOn(t template.Token) *On {
77+ on := &On{}
78+ switch t := t.(type) {
79+ case *template.String:
80+ on.Events = append(on.Events, &Event{Name: t.Value, Token: t})
81+ case *template.Sequence:
82+ for _, it := range t.Items {
83+ s := asString(it, "on")
84+ on.Events = append(on.Events, &Event{Name: s.Value, Token: s})
85+ }
86+ case *template.Mapping:
87+ for _, p := range t.Pairs {
88+ key := asString(p.Key, "event name")
89+ ev := &Event{Name: key.Value, Token: key}
90+ on.Events = append(on.Events, ev)
91+ if p.Value.Kind() == template.KindNull {
92+ continue
93+ }
94+ if key.Value == "schedule" {
95+ ev.Schedule = c.convertSchedule(asSeq(p.Value, "event schedule"))
96+ continue
97+ }
98+ m := asMap(p.Value, "event "+key.Value)
99+ switch key.Value {
100+ case "workflow_call":
101+ c.convertWorkflowCall(ev, m)
102+ continue
103+ case "workflow_dispatch":
104+ c.convertWorkflowDispatch(ev, m)
105+ continue
106+ }
107+ ev.Branches = patternFilter("branches", m)
108+ ev.BranchesIgnore = patternFilter("branches-ignore", m)
109+ ev.Tags = patternFilter("tags", m)
110+ ev.TagsIgnore = patternFilter("tags-ignore", m)
111+ ev.Paths = patternFilter("paths", m)
112+ ev.PathsIgnore = patternFilter("paths-ignore", m)
113+ ev.Types = patternFilter("types", m)
114+ ev.Versions = patternFilter("versions", m)
115+ ev.Names = patternFilter("names", m)
116+ ev.Workflows = patternFilter("workflows", m)
117+ }
118+ default:
119+ c.ctx.ErrorAt(t, "Invalid format for 'on'")
120+ return &On{}
121+ }
122+ return on
123+}
124+
125+// patternFilter reads a filter that is a string or a list of strings; nil
126+// when the key is absent.
127+func patternFilter(name string, m *template.Mapping) []string {
128+ var out []string
129+ for _, p := range m.Pairs {
130+ key := asString(p.Key, name+" filter key")
131+ if key.Value != name {
132+ continue
133+ }
134+ if s, ok := p.Value.(*template.String); ok {
135+ out = []string{s.Value}
136+ continue
137+ }
138+ out = []string{}
139+ for _, it := range asSeq(p.Value, name+" list").Items {
140+ out = append(out, asString(it, name+" list item").Value)
141+ }
142+ }
143+ return out
144+}
145+
146+func (c *converter) convertSchedule(seq *template.Sequence) []Schedule {
147+ var out []Schedule
148+ for _, it := range seq.Items {
149+ m := asMap(it, "event schedule")
150+ var cfg Schedule
151+ valid := true
152+ for _, e := range m.Pairs {
153+ key := asString(e.Key, "schedule key")
154+ switch key.Value {
155+ case "cron":
156+ cron := asString(e.Value, "schedule cron")
157+ if !isValidCron(cron.Value) {
158+ c.ctx.ErrorAt(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)")
159+ }
160+ cfg.Cron = cron.Value
161+ case "timezone":
162+ cfg.Timezone = asString(e.Value, "schedule timezone").Value
163+ default:
164+ c.ctx.ErrorAt(key, "Invalid schedule key")
165+ valid = false
166+ }
167+ }
168+ switch {
169+ case valid && cfg.Cron != "":
170+ out = append(out, cfg)
171+ case valid:
172+ c.ctx.ErrorAt(m, "Missing required key 'cron' in schedule entry")
173+ }
174+ }
175+ return out
176+}
177+
178+func (c *converter) convertWorkflowDispatch(ev *Event, m *template.Mapping) {
179+ for _, p := range m.Pairs {
180+ if asString(p.Key, "workflow dispatch input key").Value == "inputs" {
181+ ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), false)
182+ }
183+ }
184+}
185+
186+func (c *converter) convertWorkflowCall(ev *Event, m *template.Mapping) {
187+ for _, p := range m.Pairs {
188+ switch asString(p.Key, "workflow dispatch input key").Value {
189+ case "inputs":
190+ ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), true)
191+ case "secrets":
192+ for _, s := range asMap(p.Value, "workflow dispatch inputs").Pairs {
193+ asString(s.Key, "secret name")
194+ if sm, ok := s.Value.(*template.Mapping); ok {
195+ for _, kv := range sm.Pairs {
196+ switch asString(kv.Key, "workflow call secret key").Value {
197+ case "description":
198+ asString(kv.Value, "secret description")
199+ case "required":
200+ asBool(kv.Value, "secret required")
201+ }
202+ }
203+ }
204+ }
205+ }
206+ }
207+}
208+
209+// convertInputs ports convertWorkflowDispatchInputs and convertWorkflowInputs.
210+// The two differ only in that call inputs skip the default check when the
211+// default is an expression.
212+func (c *converter) convertInputs(m *template.Mapping, call bool) []Input {
213+ var out []Input
214+ for _, p := range m.Pairs {
215+ name := asString(p.Key, "input name")
216+ im := asMap(p.Value, "input configuration")
217+ out = append(out, c.convertInput(name.Value, im, call))
218+ }
219+ return out
220+}
221+
222+func (c *converter) convertInput(name string, m *template.Mapping, call bool) Input {
223+ in := Input{Name: name, Type: "string"}
224+ var def template.Token
225+ var options []string
226+ hasOptions := false
227+ for _, p := range m.Pairs {
228+ key := asString(p.Key, "workflow dispatch input key")
229+ switch key.Value {
230+ case "description":
231+ in.Description = asString(p.Value, "input description").Value
232+ case "required":
233+ in.Required = asBool(p.Value, "input required").Value
234+ case "default":
235+ def = asScalar(p.Value, "input default")
236+ case "type":
237+ in.Type = asString(p.Value, "input type").Value
238+ case "options":
239+ hasOptions = true
240+ options = []string{}
241+ for _, it := range asSeq(p.Value, "input options").Items {
242+ options = append(options, asString(it, "input options item").Value)
243+ }
244+ in.Options = options
245+ default:
246+ c.ctx.Errorf(p.Key, "Invalid key '%s'", key.Value)
247+ }
248+ }
249+ if def != nil {
250+ in.HasDefault = true
251+ in.Default = def.String()
252+ if !(call && template.IsExpression(def)) {
253+ c.handle(def, func() {
254+ switch in.Type {
255+ case "boolean":
256+ asBool(def, "input default")
257+ case "string", "choice", "environment":
258+ asString(def, "input default")
259+ }
260+ })
261+ }
262+ }
263+ if in.Type == "choice" {
264+ if !hasOptions || len(options) == 0 {
265+ c.ctx.ErrorAt(m, "Missing 'options' for choice input")
266+ }
267+ } else if hasOptions {
268+ c.ctx.ErrorAt(m, "Input type is not 'choice', but 'options' is defined")
269+ }
270+ return in
271+}
A workflow/gen/convert.mjs +48 -0
@@ -0,0 +1,48 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Generates testdata/convert.json: for each workflow in testdata/convert/*.yml,
4+// the errors and the model summary that @actions/workflow-parser 0.3.61 (MIT,
5+// GitHub) produces. convert_test.go replays the files and compares.
6+//
7+// node gen/convert.mjs /path/to/@actions/workflow-parser/dist > testdata/convert.json
8+//
9+// The package imports "yaml" and "@actions/expressions"; the JSON import of the
10+// schema needs `with { type: "json" }` in dist/workflows/workflow-schema.js on
11+// recent Node versions.
12+
13+import { pathToFileURL } from "node:url";
14+import path from "node:path";
15+import fs from "node:fs";
16+
17+const dist = path.resolve(process.argv[2]);
18+const imp = p => import(pathToFileURL(path.join(dist, p)).href);
19+const { parseWorkflow } = await imp("workflows/workflow-parser.js");
20+const { NoOperationTraceWriter } = await imp("templates/trace-writer.js");
21+const { convertWorkflowTemplate } = await imp("model/convert.js");
22+
23+const dir = new URL("../testdata/convert/", import.meta.url);
24+const out = {};
25+for (const f of fs.readdirSync(dir).sort()) {
26+ if (!f.endsWith(".yml")) continue;
27+ const content = fs.readFileSync(new URL(f, dir), "utf8");
28+ const r = parseWorkflow({ name: f, content }, new NoOperationTraceWriter());
29+ const res = { errors: [] };
30+ if (!r.value) {
31+ res.errors = r.context.errors.getErrors().map(e => e.message);
32+ } else {
33+ // Default options: ReturnErrorsOnly, i.e. the converter does not run when the
34+ // schema reported errors.
35+ const t = await convertWorkflowTemplate(r.context, r.value);
36+ res.errors = (t.errors || []).map(e => e.Message);
37+ res.jobs = (t.jobs || []).map(j => ({
38+ id: j.id.value,
39+ name: String(j.name),
40+ if: j.if?.expression,
41+ needs: (j.needs || []).map(n => n.value),
42+ steps: (j.steps || []).map(s => ({ id: s.id, if: s.if?.expression })),
43+ }));
44+ res.events = t.events;
45+ }
46+ out[f] = res;
47+}
48+console.log(JSON.stringify(out, null, 1));
@@ -0,0 +1,48 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Generates testdata/convert.json: for each workflow in testdata/convert/*.yml,
4+// the errors and the model summary that @actions/workflow-parser 0.3.61 (MIT,
5+// GitHub) produces. convert_test.go replays the files and compares.
6+//
7+// node gen/convert.mjs /path/to/@actions/workflow-parser/dist > testdata/convert.json
8+//
9+// The package imports "yaml" and "@actions/expressions"; the JSON import of the
10+// schema needs `with { type: "json" }` in dist/workflows/workflow-schema.js on
11+// recent Node versions.
12+
13+import { pathToFileURL } from "node:url";
14+import path from "node:path";
15+import fs from "node:fs";
16+
17+const dist = path.resolve(process.argv[2]);
18+const imp = p => import(pathToFileURL(path.join(dist, p)).href);
19+const { parseWorkflow } = await imp("workflows/workflow-parser.js");
20+const { NoOperationTraceWriter } = await imp("templates/trace-writer.js");
21+const { convertWorkflowTemplate } = await imp("model/convert.js");
22+
23+const dir = new URL("../testdata/convert/", import.meta.url);
24+const out = {};
25+for (const f of fs.readdirSync(dir).sort()) {
26+ if (!f.endsWith(".yml")) continue;
27+ const content = fs.readFileSync(new URL(f, dir), "utf8");
28+ const r = parseWorkflow({ name: f, content }, new NoOperationTraceWriter());
29+ const res = { errors: [] };
30+ if (!r.value) {
31+ res.errors = r.context.errors.getErrors().map(e => e.message);
32+ } else {
33+ // Default options: ReturnErrorsOnly, i.e. the converter does not run when the
34+ // schema reported errors.
35+ const t = await convertWorkflowTemplate(r.context, r.value);
36+ res.errors = (t.errors || []).map(e => e.Message);
37+ res.jobs = (t.jobs || []).map(j => ({
38+ id: j.id.value,
39+ name: String(j.name),
40+ if: j.if?.expression,
41+ needs: (j.needs || []).map(n => n.value),
42+ steps: (j.steps || []).map(s => ({ id: s.id, if: s.if?.expression })),
43+ }));
44+ res.events = t.events;
45+ }
46+ out[f] = res;
47+}
48+console.log(JSON.stringify(out, null, 1));
A workflow/ids.go +104 -0
@@ -0,0 +1,104 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "fmt"
7+ "strings"
8+)
9+
10+const (
11+ idSeparator = "_"
12+ idMaxAttempts = 1000
13+ idMaxLength = 100
14+)
15+
16+// idBuilder is a port of IdBuilder in the reference: it validates the ids
17+// written in a file and makes unique ids for steps that have none.
18+type idBuilder struct {
19+ name []rune
20+ distinct map[string]bool
21+}
22+
23+func newIDBuilder() *idBuilder { return &idBuilder{distinct: map[string]bool{}} }
24+
25+func isAlpha(c rune) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
26+func isNumeric(c rune) bool { return c >= '0' && c <= '9' }
27+
28+func (b *idBuilder) appendSegment(value string) {
29+ if value == "" {
30+ return
31+ }
32+ rs := []rune(value)
33+ if len(b.name) == 0 {
34+ first := rs[0]
35+ switch {
36+ case isAlpha(first) || first == '_':
37+ case isNumeric(first) || first == '-':
38+ b.name = append(b.name, '_')
39+ }
40+ } else {
41+ b.name = append(b.name, '_')
42+ }
43+ for _, c := range rs {
44+ if isAlpha(c) || isNumeric(c) || c == '_' || c == '-' {
45+ b.name = append(b.name, c)
46+ } else {
47+ b.name = append(b.name, '_')
48+ }
49+ }
50+}
51+
52+// build returns a unique id from the appended segments, "job" when there are
53+// none. It returns "" when a thousand ids are taken.
54+func (b *idBuilder) build() string {
55+ original := "job"
56+ if len(b.name) > 0 {
57+ original = string(b.name)
58+ }
59+ for attempt := 1; attempt < idMaxAttempts; attempt++ {
60+ suffix := ""
61+ if attempt > 1 {
62+ suffix = fmt.Sprintf("_%d", attempt)
63+ }
64+ n := min(len(original), idMaxLength-len(suffix))
65+ candidate := original[:n] + suffix
66+ if !b.distinct[candidate] {
67+ b.distinct[candidate] = true
68+ b.name = nil
69+ return candidate
70+ }
71+ }
72+ return ""
73+}
74+
75+// tryAddKnownID records an id written in the file. It returns the error
76+// message when the id is invalid or already used.
77+func (b *idBuilder) tryAddKnownID(value string) string {
78+ if value == "" || !validID(value) || len(value) >= idMaxLength {
79+ return fmt.Sprintf("The identifier '%s' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than %d characters.", value, idMaxLength)
80+ }
81+ if strings.HasPrefix(value, idSeparator+idSeparator) {
82+ return fmt.Sprintf("The identifier '%s' is invalid. IDs starting with '__' are reserved.", value)
83+ }
84+ if b.distinct[value] {
85+ return fmt.Sprintf("The identifier '%s' may not be used more than once within the same scope.", value)
86+ }
87+ b.distinct[value] = true
88+ return ""
89+}
90+
91+func validID(name string) bool {
92+ for i, c := range name {
93+ if i == 0 {
94+ if !isAlpha(c) && c != '_' {
95+ return false
96+ }
97+ continue
98+ }
99+ if !isAlpha(c) && !isNumeric(c) && c != '_' && c != '-' {
100+ return false
101+ }
102+ }
103+ return true
104+}
@@ -0,0 +1,104 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "fmt"
7+ "strings"
8+)
9+
10+const (
11+ idSeparator = "_"
12+ idMaxAttempts = 1000
13+ idMaxLength = 100
14+)
15+
16+// idBuilder is a port of IdBuilder in the reference: it validates the ids
17+// written in a file and makes unique ids for steps that have none.
18+type idBuilder struct {
19+ name []rune
20+ distinct map[string]bool
21+}
22+
23+func newIDBuilder() *idBuilder { return &idBuilder{distinct: map[string]bool{}} }
24+
25+func isAlpha(c rune) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
26+func isNumeric(c rune) bool { return c >= '0' && c <= '9' }
27+
28+func (b *idBuilder) appendSegment(value string) {
29+ if value == "" {
30+ return
31+ }
32+ rs := []rune(value)
33+ if len(b.name) == 0 {
34+ first := rs[0]
35+ switch {
36+ case isAlpha(first) || first == '_':
37+ case isNumeric(first) || first == '-':
38+ b.name = append(b.name, '_')
39+ }
40+ } else {
41+ b.name = append(b.name, '_')
42+ }
43+ for _, c := range rs {
44+ if isAlpha(c) || isNumeric(c) || c == '_' || c == '-' {
45+ b.name = append(b.name, c)
46+ } else {
47+ b.name = append(b.name, '_')
48+ }
49+ }
50+}
51+
52+// build returns a unique id from the appended segments, "job" when there are
53+// none. It returns "" when a thousand ids are taken.
54+func (b *idBuilder) build() string {
55+ original := "job"
56+ if len(b.name) > 0 {
57+ original = string(b.name)
58+ }
59+ for attempt := 1; attempt < idMaxAttempts; attempt++ {
60+ suffix := ""
61+ if attempt > 1 {
62+ suffix = fmt.Sprintf("_%d", attempt)
63+ }
64+ n := min(len(original), idMaxLength-len(suffix))
65+ candidate := original[:n] + suffix
66+ if !b.distinct[candidate] {
67+ b.distinct[candidate] = true
68+ b.name = nil
69+ return candidate
70+ }
71+ }
72+ return ""
73+}
74+
75+// tryAddKnownID records an id written in the file. It returns the error
76+// message when the id is invalid or already used.
77+func (b *idBuilder) tryAddKnownID(value string) string {
78+ if value == "" || !validID(value) || len(value) >= idMaxLength {
79+ return fmt.Sprintf("The identifier '%s' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than %d characters.", value, idMaxLength)
80+ }
81+ if strings.HasPrefix(value, idSeparator+idSeparator) {
82+ return fmt.Sprintf("The identifier '%s' is invalid. IDs starting with '__' are reserved.", value)
83+ }
84+ if b.distinct[value] {
85+ return fmt.Sprintf("The identifier '%s' may not be used more than once within the same scope.", value)
86+ }
87+ b.distinct[value] = true
88+ return ""
89+}
90+
91+func validID(name string) bool {
92+ for i, c := range name {
93+ if i == 0 {
94+ if !isAlpha(c) && c != '_' {
95+ return false
96+ }
97+ continue
98+ }
99+ if !isAlpha(c) && !isNumeric(c) && c != '_' && c != '-' {
100+ return false
101+ }
102+ }
103+ return true
104+}
A workflow/model.go +161 -0
@@ -0,0 +1,161 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// Error is a problem found in a workflow file. Its message has the form
10+// "file.yml (Line: 3, Col: 5): message".
11+type Error = template.Error
12+
13+// Workflow is a parsed and validated workflow file.
14+type Workflow struct {
15+ // File is the name given to Parse.
16+ File string
17+ // Name is the "name" key, or "".
18+ Name string
19+ // RunName is the "run-name" token (an expression is allowed), or nil.
20+ RunName template.Token
21+ // On lists the events that trigger the workflow, in file order.
22+ On *On
23+ // Env is the workflow-level "env" mapping, or nil. Values may hold
24+ // expressions.
25+ Env *template.Mapping
26+ // Defaults holds the workflow-level "defaults", or nil.
27+ Defaults *Defaults
28+ // Concurrency is the "concurrency" token (a string or a mapping), or nil.
29+ Concurrency template.Token
30+ // Permissions is the "permissions" key, or nil.
31+ Permissions *Permissions
32+ // Jobs are the jobs in file order.
33+ Jobs []*Job
34+}
35+
36+// Job returns the job with the given id, or nil.
37+func (w *Workflow) Job(id string) *Job {
38+ for _, j := range w.Jobs {
39+ if j.ID == id {
40+ return j
41+ }
42+ }
43+ return nil
44+}
45+
46+// Defaults is the "defaults" key.
47+type Defaults struct {
48+ // Run holds defaults.run; nil when absent.
49+ Run *RunDefaults
50+}
51+
52+// RunDefaults is defaults.run: the shell and working directory of run steps.
53+type RunDefaults struct {
54+ Shell template.Token
55+ WorkingDirectory template.Token
56+}
57+
58+// Permissions is the "permissions" key: either all permissions read or
59+// write, or a level per scope.
60+type Permissions struct {
61+ // All is "read-all" or "write-all", or "" when Scopes is used.
62+ All string
63+ // Scopes lists the scopes in file order ("contents": "read").
64+ Scopes []PermissionScope
65+}
66+
67+// PermissionScope is one entry of a permissions mapping.
68+type PermissionScope struct {
69+ Scope string
70+ Level string // "read", "write" or "none"
71+}
72+
73+// Job is a job of a workflow.
74+type Job struct {
75+ // ID is the key of the job.
76+ ID string
77+ // IDToken is the key token, with its position.
78+ IDToken template.Token
79+ // Name is the "name" token; when the file gives none it is the id.
80+ Name template.Token
81+ // HasName reports whether the file gave a non-empty name.
82+ HasName bool
83+ // Needs are the ids of the jobs this one depends on, with their tokens.
84+ Needs []string
85+ NeedTokens []*template.String
86+ // If is the condition, always with a status function: a condition
87+ // without one is wrapped as "success() && (cond)", and a missing one is
88+ // "success()".
89+ If *template.BasicExpression
90+ // RunsOn is the "runs-on" token (string, sequence, mapping or an
91+ // expression); nil for a reusable workflow job.
92+ RunsOn template.Token
93+ // Strategy is the "strategy" token, or nil.
94+ Strategy template.Token
95+ // Env is the job's "env" mapping, or nil.
96+ Env *template.Mapping
97+ // Defaults holds the job-level "defaults", or nil.
98+ Defaults *Defaults
99+ // Steps are the steps in file order.
100+ Steps []*Step
101+ // Outputs is the "outputs" mapping, or nil.
102+ Outputs *template.Mapping
103+ // TimeoutMinutes, CancelTimeoutMinutes and ContinueOnError are tokens
104+ // (a literal or an expression), or nil.
105+ TimeoutMinutes template.Token
106+ CancelTimeoutMinutes template.Token
107+ ContinueOnError template.Token
108+ // Container, Services, Environment, Concurrency and Snapshot are
109+ // the raw tokens, or nil.
110+ Container template.Token
111+ Services template.Token
112+ Environment template.Token
113+ Concurrency template.Token
114+ Snapshot template.Token
115+ // Permissions is the job's "permissions", or nil.
116+ Permissions *Permissions
117+
118+ // Uses is set for a job that calls a reusable workflow ("uses"); such a
119+ // job has no steps and no runs-on.
120+ Uses *template.String
121+ // With and Secrets are the inputs and secrets passed to the called
122+ // workflow. InheritSecrets is "secrets: inherit".
123+ With *template.Mapping
124+ Secrets *template.Mapping
125+ InheritSecrets bool
126+}
127+
128+// IsReusable reports whether the job calls a reusable workflow.
129+func (j *Job) IsReusable() bool { return j.Uses != nil }
130+
131+// Step is a step of a job: a run step or a uses step.
132+type Step struct {
133+ // ID is the id of the step. When the file gives none, one is generated
134+ // the way the reference does: "__run", "__run_2", "__actions_checkout".
135+ ID string
136+ // Explicit reports whether the id came from the file.
137+ Explicit bool
138+ // Pos is where the step starts.
139+ Pos template.Pos
140+ // Name is the "name" token, or nil.
141+ Name template.Token
142+ // If is the condition, with a status function, as for jobs.
143+ If *template.BasicExpression
144+ // Run is the script of a run step, or nil.
145+ Run template.Token
146+ // Shell and WorkingDirectory are the run step's options, or nil.
147+ Shell template.Token
148+ WorkingDirectory template.Token
149+ // Uses is the action of a uses step, or nil.
150+ Uses *template.String
151+ // With holds the inputs of a uses step, or nil.
152+ With *template.Mapping
153+ // Env is the step's "env" mapping, or nil.
154+ Env *template.Mapping
155+ // ContinueOnError and TimeoutMinutes are tokens, or nil.
156+ ContinueOnError template.Token
157+ TimeoutMinutes template.Token
158+}
159+
160+// IsRun reports whether the step runs a script.
161+func (s *Step) IsRun() bool { return s.Run != nil }
@@ -0,0 +1,161 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// Error is a problem found in a workflow file. Its message has the form
10+// "file.yml (Line: 3, Col: 5): message".
11+type Error = template.Error
12+
13+// Workflow is a parsed and validated workflow file.
14+type Workflow struct {
15+ // File is the name given to Parse.
16+ File string
17+ // Name is the "name" key, or "".
18+ Name string
19+ // RunName is the "run-name" token (an expression is allowed), or nil.
20+ RunName template.Token
21+ // On lists the events that trigger the workflow, in file order.
22+ On *On
23+ // Env is the workflow-level "env" mapping, or nil. Values may hold
24+ // expressions.
25+ Env *template.Mapping
26+ // Defaults holds the workflow-level "defaults", or nil.
27+ Defaults *Defaults
28+ // Concurrency is the "concurrency" token (a string or a mapping), or nil.
29+ Concurrency template.Token
30+ // Permissions is the "permissions" key, or nil.
31+ Permissions *Permissions
32+ // Jobs are the jobs in file order.
33+ Jobs []*Job
34+}
35+
36+// Job returns the job with the given id, or nil.
37+func (w *Workflow) Job(id string) *Job {
38+ for _, j := range w.Jobs {
39+ if j.ID == id {
40+ return j
41+ }
42+ }
43+ return nil
44+}
45+
46+// Defaults is the "defaults" key.
47+type Defaults struct {
48+ // Run holds defaults.run; nil when absent.
49+ Run *RunDefaults
50+}
51+
52+// RunDefaults is defaults.run: the shell and working directory of run steps.
53+type RunDefaults struct {
54+ Shell template.Token
55+ WorkingDirectory template.Token
56+}
57+
58+// Permissions is the "permissions" key: either all permissions read or
59+// write, or a level per scope.
60+type Permissions struct {
61+ // All is "read-all" or "write-all", or "" when Scopes is used.
62+ All string
63+ // Scopes lists the scopes in file order ("contents": "read").
64+ Scopes []PermissionScope
65+}
66+
67+// PermissionScope is one entry of a permissions mapping.
68+type PermissionScope struct {
69+ Scope string
70+ Level string // "read", "write" or "none"
71+}
72+
73+// Job is a job of a workflow.
74+type Job struct {
75+ // ID is the key of the job.
76+ ID string
77+ // IDToken is the key token, with its position.
78+ IDToken template.Token
79+ // Name is the "name" token; when the file gives none it is the id.
80+ Name template.Token
81+ // HasName reports whether the file gave a non-empty name.
82+ HasName bool
83+ // Needs are the ids of the jobs this one depends on, with their tokens.
84+ Needs []string
85+ NeedTokens []*template.String
86+ // If is the condition, always with a status function: a condition
87+ // without one is wrapped as "success() && (cond)", and a missing one is
88+ // "success()".
89+ If *template.BasicExpression
90+ // RunsOn is the "runs-on" token (string, sequence, mapping or an
91+ // expression); nil for a reusable workflow job.
92+ RunsOn template.Token
93+ // Strategy is the "strategy" token, or nil.
94+ Strategy template.Token
95+ // Env is the job's "env" mapping, or nil.
96+ Env *template.Mapping
97+ // Defaults holds the job-level "defaults", or nil.
98+ Defaults *Defaults
99+ // Steps are the steps in file order.
100+ Steps []*Step
101+ // Outputs is the "outputs" mapping, or nil.
102+ Outputs *template.Mapping
103+ // TimeoutMinutes, CancelTimeoutMinutes and ContinueOnError are tokens
104+ // (a literal or an expression), or nil.
105+ TimeoutMinutes template.Token
106+ CancelTimeoutMinutes template.Token
107+ ContinueOnError template.Token
108+ // Container, Services, Environment, Concurrency and Snapshot are
109+ // the raw tokens, or nil.
110+ Container template.Token
111+ Services template.Token
112+ Environment template.Token
113+ Concurrency template.Token
114+ Snapshot template.Token
115+ // Permissions is the job's "permissions", or nil.
116+ Permissions *Permissions
117+
118+ // Uses is set for a job that calls a reusable workflow ("uses"); such a
119+ // job has no steps and no runs-on.
120+ Uses *template.String
121+ // With and Secrets are the inputs and secrets passed to the called
122+ // workflow. InheritSecrets is "secrets: inherit".
123+ With *template.Mapping
124+ Secrets *template.Mapping
125+ InheritSecrets bool
126+}
127+
128+// IsReusable reports whether the job calls a reusable workflow.
129+func (j *Job) IsReusable() bool { return j.Uses != nil }
130+
131+// Step is a step of a job: a run step or a uses step.
132+type Step struct {
133+ // ID is the id of the step. When the file gives none, one is generated
134+ // the way the reference does: "__run", "__run_2", "__actions_checkout".
135+ ID string
136+ // Explicit reports whether the id came from the file.
137+ Explicit bool
138+ // Pos is where the step starts.
139+ Pos template.Pos
140+ // Name is the "name" token, or nil.
141+ Name template.Token
142+ // If is the condition, with a status function, as for jobs.
143+ If *template.BasicExpression
144+ // Run is the script of a run step, or nil.
145+ Run template.Token
146+ // Shell and WorkingDirectory are the run step's options, or nil.
147+ Shell template.Token
148+ WorkingDirectory template.Token
149+ // Uses is the action of a uses step, or nil.
150+ Uses *template.String
151+ // With holds the inputs of a uses step, or nil.
152+ With *template.Mapping
153+ // Env is the step's "env" mapping, or nil.
154+ Env *template.Mapping
155+ // ContinueOnError and TimeoutMinutes are tokens, or nil.
156+ ContinueOnError template.Token
157+ TimeoutMinutes template.Token
158+}
159+
160+// IsRun reports whether the step runs a script.
161+func (s *Step) IsRun() bool { return s.Run != nil }
A workflow/parse.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// rootType is the schema definition a workflow file is read against; its
10+// "on" key is required.
11+const rootType = "workflow-root-strict"
12+
13+// Parse reads, validates and converts a workflow file. path is only used in
14+// error messages, which read "path (Line: l, Col: c): message".
15+//
16+// The errors are the reference's: syntax errors, schema violations, unknown
17+// contexts and functions in expressions, and the checks of the converter (job
18+// and step ids, needs, filters, inputs). When there is any error the workflow
19+// is nil. As in the reference, the converter only runs when the file passes
20+// the schema.
21+func Parse(path string, content []byte) (*Workflow, []Error) {
22+ schema, err := template.WorkflowSchema()
23+ if err != nil {
24+ return nil, []Error{{Raw: "internal error: " + err.Error()}}
25+ }
26+ ctx := template.NewContext(schema)
27+ root := template.ReadYAML(ctx, rootType, path, content)
28+ w := &Workflow{File: path}
29+ if root != nil && !ctx.HasErrors() {
30+ c := &converter{ctx: ctx, schema: schema, w: w, jobIDs: newIDBuilder()}
31+ c.convertRoot(root)
32+ }
33+ errs := collect(ctx)
34+ if len(errs) > 0 {
35+ return nil, errs
36+ }
37+ return w, nil
38+}
39+
40+func collect(ctx *template.Context) []Error {
41+ var out []Error
42+ for _, e := range ctx.Errors() {
43+ out = append(out, *e)
44+ }
45+ return out
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: MIT
2+
3+package workflow
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+)
8+
9+// rootType is the schema definition a workflow file is read against; its
10+// "on" key is required.
11+const rootType = "workflow-root-strict"
12+
13+// Parse reads, validates and converts a workflow file. path is only used in
14+// error messages, which read "path (Line: l, Col: c): message".
15+//
16+// The errors are the reference's: syntax errors, schema violations, unknown
17+// contexts and functions in expressions, and the checks of the converter (job
18+// and step ids, needs, filters, inputs). When there is any error the workflow
19+// is nil. As in the reference, the converter only runs when the file passes
20+// the schema.
21+func Parse(path string, content []byte) (*Workflow, []Error) {
22+ schema, err := template.WorkflowSchema()
23+ if err != nil {
24+ return nil, []Error{{Raw: "internal error: " + err.Error()}}
25+ }
26+ ctx := template.NewContext(schema)
27+ root := template.ReadYAML(ctx, rootType, path, content)
28+ w := &Workflow{File: path}
29+ if root != nil && !ctx.HasErrors() {
30+ c := &converter{ctx: ctx, schema: schema, w: w, jobIDs: newIDBuilder()}
31+ c.convertRoot(root)
32+ }
33+ errs := collect(ctx)
34+ if len(errs) > 0 {
35+ return nil, errs
36+ }
37+ return w, nil
38+}
39+
40+func collect(ctx *template.Context) []Error {
41+ var out []Error
42+ for _, e := range ctx.Errors() {
43+ out = append(out, *e)
44+ }
45+ return out
46+}
A workflow/testdata/convert.json +2445 -0 large diff (2445 lines changed): collapsed
A workflow/testdata/convert/alias.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a: &x
4+ runs-on: x
5+ steps: [{run: a}]
6+ b: *x
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a: &x
4+ runs-on: x
5+ steps: [{run: a}]
6+ b: *x
A workflow/testdata/convert/alias_scalar.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+x: &v x
3+jobs:
4+ a:
5+ runs-on: *v
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+x: &v x
3+jobs:
4+ a:
5+ runs-on: *v
6+ steps: [{run: a}]
A workflow/testdata/convert/background_steps.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: a
7+ background: true
8+ - wait: a
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: a
7+ background: true
8+ - wait: a
A workflow/testdata/convert/bad_cron.yml +20 -0
@@ -0,0 +1,20 @@
1+on:
2+ schedule:
3+ - cron: bad
4+ - cron: '61 * * * *'
5+ - cron: '*/0 * * * *'
6+ - cron: '0 0 * * 7'
7+ - cron: '0 0 * jan-mar mon'
8+ - cron: '0 0 * mar-jan mon'
9+ - cron: '* * * * * *'
10+ - cron: '1-5/2 * * * *'
11+ - cron: '*/5 */2 1,15 * *'
12+ - cron: '5 4 * * sun'
13+ - timezone: UTC
14+ - cron: '0 0 * * *'
15+ timezone: Europe/Paris
16+ extra: 1
17+jobs:
18+ a:
19+ runs-on: x
20+ steps: [{run: a}]
@@ -0,0 +1,20 @@
1+on:
2+ schedule:
3+ - cron: bad
4+ - cron: '61 * * * *'
5+ - cron: '*/0 * * * *'
6+ - cron: '0 0 * * 7'
7+ - cron: '0 0 * jan-mar mon'
8+ - cron: '0 0 * mar-jan mon'
9+ - cron: '* * * * * *'
10+ - cron: '1-5/2 * * * *'
11+ - cron: '*/5 */2 1,15 * *'
12+ - cron: '5 4 * * sun'
13+ - timezone: UTC
14+ - cron: '0 0 * * *'
15+ timezone: Europe/Paris
16+ extra: 1
17+jobs:
18+ a:
19+ runs-on: x
20+ steps: [{run: a}]
A workflow/testdata/convert/bad_if.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: github.ref == 1 &&
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: github.ref == 1 &&
6+ steps: [{run: a}]
A workflow/testdata/convert/bad_job_ids.yml +11 -0
@@ -0,0 +1,11 @@
1+on: push
2+jobs:
3+ 1a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ __b:
7+ runs-on: x
8+ steps: [{run: a}]
9+ a b:
10+ runs-on: x
11+ steps: [{run: a}]
@@ -0,0 +1,11 @@
1+on: push
2+jobs:
3+ 1a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ __b:
7+ runs-on: x
8+ steps: [{run: a}]
9+ a b:
10+ runs-on: x
11+ steps: [{run: a}]
A workflow/testdata/convert/bad_needs_cycle.yml +10 -0
@@ -0,0 +1,10 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ needs: [b]
6+ steps: [{run: a}]
7+ b:
8+ runs-on: x
9+ needs: a
10+ steps: [{run: a}]
@@ -0,0 +1,10 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ needs: [b]
6+ steps: [{run: a}]
7+ b:
8+ runs-on: x
9+ needs: a
10+ steps: [{run: a}]
A workflow/testdata/convert/bad_needs_cycle_with_root.yml +13 -0
@@ -0,0 +1,13 @@
1+on: push
2+jobs:
3+ r:
4+ runs-on: x
5+ steps: [{run: a}]
6+ a:
7+ runs-on: x
8+ needs: [b]
9+ steps: [{run: a}]
10+ b:
11+ runs-on: x
12+ needs: [a, r]
13+ steps: [{run: a}]
@@ -0,0 +1,13 @@
1+on: push
2+jobs:
3+ r:
4+ runs-on: x
5+ steps: [{run: a}]
6+ a:
7+ runs-on: x
8+ needs: [b]
9+ steps: [{run: a}]
10+ b:
11+ runs-on: x
12+ needs: [a, r]
13+ steps: [{run: a}]
A workflow/testdata/convert/bad_needs_self.yml +9 -0
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ r:
4+ runs-on: x
5+ steps: [{run: a}]
6+ a:
7+ runs-on: x
8+ needs: [a]
9+ steps: [{run: a}]
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ r:
4+ runs-on: x
5+ steps: [{run: a}]
6+ a:
7+ runs-on: x
8+ needs: [a]
9+ steps: [{run: a}]
A workflow/testdata/convert/bad_needs_unknown.yml +9 -0
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ r:
4+ runs-on: x
5+ steps: [{run: a}]
6+ a:
7+ runs-on: x
8+ needs: [zz, r]
9+ steps: [{run: a}]
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ r:
4+ runs-on: x
5+ steps: [{run: a}]
6+ a:
7+ runs-on: x
8+ needs: [zz, r]
9+ steps: [{run: a}]
A workflow/testdata/convert/bad_step_id.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, id: 1x}, {run: b, id: s},{run: b, id: s}, {run: c, id: __x}, {run: d, id: ''}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, id: 1x}, {run: b, id: s},{run: b, id: s}, {run: c, id: __x}, {run: d, id: ''}]
A workflow/testdata/convert/bad_yaml_flow.yml +1 -0
@@ -0,0 +1,1 @@
1+on: [push
@@ -0,0 +1,1 @@
1+on: [push
A workflow/testdata/convert/bad_yaml_tab.yml +3 -0
@@ -0,0 +1,3 @@
1+on: push
2+jobs:
3+ a: 1
@@ -0,0 +1,3 @@
1+on: push
2+jobs:
3+ a: 1
A workflow/testdata/convert/big_ids.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:
4+ runs-on: x
5+ steps: [{run: a}]
6+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:
7+ runs-on: x
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:
4+ runs-on: x
5+ steps: [{run: a}]
6+ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:
7+ runs-on: x
8+ steps: [{run: a}]
A workflow/testdata/convert/bool_scalars.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ continue-on-error: True
6+ timeout-minutes: 0x10
7+ steps: [{run: true}, {run: 12}, {run: 1.5}, {run: null}, {run: ~}]
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ continue-on-error: True
6+ timeout-minutes: 0x10
7+ steps: [{run: true}, {run: 12}, {run: 1.5}, {run: null}, {run: ~}]
A workflow/testdata/convert/call_inputs.yml +22 -0
@@ -0,0 +1,22 @@
1+on:
2+ workflow_call:
3+ inputs:
4+ a:
5+ type: string
6+ required: true
7+ default: ${{ github.ref }}
8+ b:
9+ type: boolean
10+ default: 1
11+ secrets:
12+ s:
13+ required: true
14+ t:
15+ outputs:
16+ o:
17+ value: ${{ jobs.a.outputs.x }}
18+jobs:
19+ a:
20+ runs-on: x
21+ outputs: {x: 1}
22+ steps: [{run: a}]
@@ -0,0 +1,22 @@
1+on:
2+ workflow_call:
3+ inputs:
4+ a:
5+ type: string
6+ required: true
7+ default: ${{ github.ref }}
8+ b:
9+ type: boolean
10+ default: 1
11+ secrets:
12+ s:
13+ required: true
14+ t:
15+ outputs:
16+ o:
17+ value: ${{ jobs.a.outputs.x }}
18+jobs:
19+ a:
20+ runs-on: x
21+ outputs: {x: 1}
22+ steps: [{run: a}]
A workflow/testdata/convert/call_inputs_expr_default.yml +13 -0
@@ -0,0 +1,13 @@
1+on:
2+ workflow_call:
3+ inputs:
4+ b:
5+ type: boolean
6+ default: ${{ github.ref }}
7+ c:
8+ type: string
9+ default: 5
10+jobs:
11+ a:
12+ runs-on: x
13+ steps: [{run: a}]
@@ -0,0 +1,13 @@
1+on:
2+ workflow_call:
3+ inputs:
4+ b:
5+ type: boolean
6+ default: ${{ github.ref }}
7+ c:
8+ type: string
9+ default: 5
10+jobs:
11+ a:
12+ runs-on: x
13+ steps: [{run: a}]
A workflow/testdata/convert/comment_doc.yml +1 -0
@@ -0,0 +1,1 @@
1+# only
@@ -0,0 +1,1 @@
1+# only
A workflow/testdata/convert/concurrency_bad.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+concurrency: {group: x, bogus: 1}
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+concurrency: {group: x, bogus: 1}
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
A workflow/testdata/convert/concurrency_converter.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: {group: x, cancel-in-progress: true, queue: single}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: {group: x, cancel-in-progress: true, queue: single}
6+ steps: [{run: a}]
A workflow/testdata/convert/concurrency_job.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: {group: x, cancel-in-progress: 1}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: {group: x, cancel-in-progress: 1}
6+ steps: [{run: a}]
A workflow/testdata/convert/concurrency_queue.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: {group: x, queue: max}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: {group: x, queue: max}
6+ steps: [{run: a}]
A workflow/testdata/convert/concurrency_queue_bad.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: {group: x, queue: other}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: {group: x, queue: other}
6+ steps: [{run: a}]
A workflow/testdata/convert/concurrency_string.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: g-${{ github.ref }}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ concurrency: g-${{ github.ref }}
6+ steps: [{run: a}]
A workflow/testdata/convert/container_bad.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: a, bogus: 1}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: a, bogus: 1}
6+ steps: [{run: a}]
A workflow/testdata/convert/container_converter.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: a, env: {A: 1}, ports: [80, '81'], volumes: [x], options: 1, credentials: {username: 1}}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: a, env: {A: 1}, ports: [80, '81'], volumes: [x], options: 1, credentials: {username: 1}}
6+ steps: [{run: a}]
A workflow/testdata/convert/container_empty_image.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: ''}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: ''}
6+ steps: [{run: a}]
A workflow/testdata/convert/container_entrypoint.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: a, entrypoint: b}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: a, entrypoint: b}
6+ steps: [{run: a}]
A workflow/testdata/convert/container_expr.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: ${{ matrix.c }}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: ${{ matrix.c }}
6+ steps: [{run: a}]
A workflow/testdata/convert/container_no_image.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {env: {A: b}}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {env: {A: b}}
6+ steps: [{run: a}]
A workflow/testdata/convert/container_string.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: node:20
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: node:20
6+ steps: [{run: a}]
A workflow/testdata/convert/context_always_job_env.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ env:
6+ A: ${{ always() }}
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ env:
6+ A: ${{ always() }}
7+ steps: [{run: a}]
A workflow/testdata/convert/context_defaults_run.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ defaults:
6+ run:
7+ working-directory: ${{ steps.x.outputs.y }}
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ defaults:
6+ run:
7+ working-directory: ${{ steps.x.outputs.y }}
8+ steps: [{run: a}]
A workflow/testdata/convert/context_env_secrets.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ env:
6+ A: ${{ secrets.x }}
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ env:
6+ A: ${{ secrets.x }}
7+ steps: [{run: a}]
A workflow/testdata/convert/context_job_name.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ name: ${{ steps.x.outputs.y }}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ name: ${{ steps.x.outputs.y }}
6+ steps: [{run: a}]
A workflow/testdata/convert/context_job_outputs.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ outputs:
6+ a: ${{ steps.x.outputs.y }}
7+ b: ${{ secrets.z }}
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ outputs:
6+ a: ${{ steps.x.outputs.y }}
7+ b: ${{ secrets.z }}
8+ steps: [{run: a}]
A workflow/testdata/convert/context_outputs_hash.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ outputs:
6+ a: ${{ hashFiles('x') }}
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ outputs:
6+ a: ${{ hashFiles('x') }}
7+ steps: [{run: a}]
A workflow/testdata/convert/context_run_name.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+run-name: ${{ matrix.x }}
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+run-name: ${{ matrix.x }}
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
A workflow/testdata/convert/context_runs_on_secrets.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ runs-on: ${{ secrets.x }}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ runs-on: ${{ secrets.x }}
6+ steps: [{run: a}]
A workflow/testdata/convert/context_step_run.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: 'echo ${{ matrix.a }} ${{ secrets.b }} ${{ job.status }} ${{ runner.os }} ${{ steps.x.outputs.y }} ${{ hashFiles("a") }} ${{ vars.q }} ${{ inputs.i }} ${{ env.x }}'}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: 'echo ${{ matrix.a }} ${{ secrets.b }} ${{ job.status }} ${{ runner.os }} ${{ steps.x.outputs.y }} ${{ hashFiles("a") }} ${{ vars.q }} ${{ inputs.i }} ${{ env.x }}'}]
A workflow/testdata/convert/context_success_in_run.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: '${{ success() }}'}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: '${{ success() }}'}]
A workflow/testdata/convert/context_unknown_func.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: '${{ nope(1) }}'}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: '${{ nope(1) }}'}]
A workflow/testdata/convert/context_workflow_env.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+env:
3+ A: ${{ matrix.x }}
4+jobs:
5+ a:
6+ runs-on: x
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on: push
2+env:
3+ A: ${{ matrix.x }}
4+jobs:
5+ a:
6+ runs-on: x
7+ steps: [{run: a}]
A workflow/testdata/convert/credentials_bad.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: a, credentials: {username: u, token: t}}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ container: {image: a, credentials: {username: u, token: t}}
6+ steps: [{run: a}]
A workflow/testdata/convert/cron_only_bad.yml +26 -0
@@ -0,0 +1,26 @@
1+on:
2+ schedule:
3+ - cron: bad
4+ - cron: '61 * * * *'
5+ - cron: '*/0 * * * *'
6+ - cron: '0 0 * * 7'
7+ - cron: '0 0 * jan-mar mon'
8+ - cron: '0 0 * mar-jan mon'
9+ - cron: '* * * * * *'
10+ - cron: '1-5/2 * * * *'
11+ - cron: '*/5 */2 1,15 * *'
12+ - cron: '5 4 * * sun'
13+ - cron: '0 0 * * *'
14+ - cron: ' 0 0 * * *'
15+ - cron: '1,,2 * * * *'
16+ - cron: '*/5/2 * * * *'
17+ - cron: '1-2-3 * * * *'
18+ - cron: '*-5 * * * *'
19+ - cron: '5-1 * * * *'
20+ - cron: '0 0 1-31/2 * *'
21+ - cron: '0 0 * * MON'
22+ - cron: '0 0 * Feb *'
23+jobs:
24+ a:
25+ runs-on: x
26+ steps: [{run: a}]
@@ -0,0 +1,26 @@
1+on:
2+ schedule:
3+ - cron: bad
4+ - cron: '61 * * * *'
5+ - cron: '*/0 * * * *'
6+ - cron: '0 0 * * 7'
7+ - cron: '0 0 * jan-mar mon'
8+ - cron: '0 0 * mar-jan mon'
9+ - cron: '* * * * * *'
10+ - cron: '1-5/2 * * * *'
11+ - cron: '*/5 */2 1,15 * *'
12+ - cron: '5 4 * * sun'
13+ - cron: '0 0 * * *'
14+ - cron: ' 0 0 * * *'
15+ - cron: '1,,2 * * * *'
16+ - cron: '*/5/2 * * * *'
17+ - cron: '1-2-3 * * * *'
18+ - cron: '*-5 * * * *'
19+ - cron: '5-1 * * * *'
20+ - cron: '0 0 1-31/2 * *'
21+ - cron: '0 0 * * MON'
22+ - cron: '0 0 * Feb *'
23+jobs:
24+ a:
25+ runs-on: x
26+ steps: [{run: a}]
A workflow/testdata/convert/description_key.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+description: hi
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+description: hi
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
A workflow/testdata/convert/dispatch_inputs.yml +23 -0
@@ -0,0 +1,23 @@
1+on:
2+ workflow_dispatch:
3+ inputs:
4+ a:
5+ type: choice
6+ b:
7+ type: string
8+ options: [x]
9+ c:
10+ type: boolean
11+ default: foo
12+ d:
13+ type: environment
14+ e:
15+ description: x
16+ required: true
17+ default: y
18+ f:
19+ type: number
20+jobs:
21+ a:
22+ runs-on: x
23+ steps: [{run: a}]
@@ -0,0 +1,23 @@
1+on:
2+ workflow_dispatch:
3+ inputs:
4+ a:
5+ type: choice
6+ b:
7+ type: string
8+ options: [x]
9+ c:
10+ type: boolean
11+ default: foo
12+ d:
13+ type: environment
14+ e:
15+ description: x
16+ required: true
17+ default: y
18+ f:
19+ type: number
20+jobs:
21+ a:
22+ runs-on: x
23+ steps: [{run: a}]
A workflow/testdata/convert/dup_job_ids_case.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ A:
7+ runs-on: x
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ A:
7+ runs-on: x
8+ steps: [{run: a}]
A workflow/testdata/convert/dup_keys.yml +9 -0
@@ -0,0 +1,9 @@
1+on: push
2+on: pull_request
3+Name: a
4+name: b
5+jobs:
6+ a:
7+ runs-on: x
8+ runs-on: y
9+ steps: [{run: a}]
@@ -0,0 +1,9 @@
1+on: push
2+on: pull_request
3+Name: a
4+name: b
5+jobs:
6+ a:
7+ runs-on: x
8+ runs-on: y
9+ steps: [{run: a}]
A workflow/testdata/convert/empty_doc.yml +0 -0

no textual change.

A workflow/testdata/convert/env_nonmap_converter.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+env: x
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+env: x
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
A workflow/testdata/convert/environment_converter.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ environment: {name: a, deployment: 1}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ environment: {name: a, deployment: 1}
6+ steps: [{run: a}]
A workflow/testdata/convert/environment_forms.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ environment: prod
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ environment: prod
6+ steps: [{run: a}]
A workflow/testdata/convert/environment_map.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ environment: {name: prod, url: 'https://x', deployment: false}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ environment: {name: prod, url: 'https://x', deployment: false}
6+ steps: [{run: a}]
A workflow/testdata/convert/environment_no_name.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ environment: {url: x}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ environment: {url: x}
6+ steps: [{run: a}]
A workflow/testdata/convert/expr_empty.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: echo ${{ }}
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: echo ${{ }}
A workflow/testdata/convert/expr_in_key_env.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ env:
6+ ${{ matrix.k }}: v
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ env:
6+ ${{ matrix.k }}: v
7+ steps: [{run: a}]
A workflow/testdata/convert/expr_insert_embedded.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ a: x ${{ insert }}
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ a: x ${{ insert }}
8+ steps: [{run: a}]
A workflow/testdata/convert/expr_insert_key.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - name: ${{ insert }}
7+ run: x
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - name: ${{ insert }}
7+ run: x
A workflow/testdata/convert/expr_insert_matrix.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ ${{ insert }}: ${{ fromJSON(needs.x.outputs.m) }}
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ ${{ insert }}: ${{ fromJSON(needs.x.outputs.m) }}
8+ steps: [{run: a}]
A workflow/testdata/convert/expr_insert_params.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ ${{ insert x }}: 1
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ ${{ insert x }}: 1
8+ steps: [{run: a}]
A workflow/testdata/convert/expr_key_not_allowed.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ outputs:
6+ ${{ github.ref }}: 1
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ outputs:
6+ ${{ github.ref }}: 1
7+ steps: [{run: a}]
A workflow/testdata/convert/expr_literal.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: "${{ '${{ x }}' }}"}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: "${{ '${{ x }}' }}"}]
A workflow/testdata/convert/expr_multi.yml +9 -0
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: |
7+ echo ${{ github.ref }}
8+ echo ${{ foo.bar }}
9+ echo ${{ 1 + }}
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: |
7+ echo ${{ github.ref }}
8+ echo ${{ foo.bar }}
9+ echo ${{ 1 + }}
A workflow/testdata/convert/expr_not_closed.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: echo ${{ 1
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: echo ${{ 1
A workflow/testdata/convert/expr_string_braces.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: "echo '${{ format('{0}}}', 1) }}'"}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: "echo '${{ format('{0}}}', 1) }}'"}]
A workflow/testdata/convert/if_bad_arity.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: c, if: failure(1)}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: c, if: failure(1)}]
A workflow/testdata/convert/if_job_ctx.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: steps.a.outputs.x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: steps.a.outputs.x
6+ steps: [{run: a}]
A workflow/testdata/convert/if_step_job_funcs.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: success(1)
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: success(1)
6+ steps: [{run: a}]
A workflow/testdata/convert/if_wrapping.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: github.ref == 'x'
6+ steps: [{run: a, if: "hashFiles('a')"}, {run: b, if: "${{ always() }}"}, {run: c, if: failure()}, {run: d, if: ''}, {run: e, if: '!cancelled()'}, {run: f, if: "contains(github.ref, 'a') || success()"}, {run: g, if: "toJSON(success())"}, {run: h, if: true}, {run: i, if: "${{ github.ref }} == 'x'"}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: github.ref == 'x'
6+ steps: [{run: a, if: "hashFiles('a')"}, {run: b, if: "${{ always() }}"}, {run: c, if: failure()}, {run: d, if: ''}, {run: e, if: '!cancelled()'}, {run: f, if: "contains(github.ref, 'a') || success()"}, {run: g, if: "toJSON(success())"}, {run: h, if: true}, {run: i, if: "${{ github.ref }} == 'x'"}]
A workflow/testdata/convert/if_wrapping_nonstring.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: 5
6+ steps: [{run: a, if: null}, {run: b, if: false}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ if: 5
6+ steps: [{run: a, if: null}, {run: b, if: false}]
A workflow/testdata/convert/inputs_converter_errors.yml +25 -0
@@ -0,0 +1,25 @@
1+on:
2+ workflow_dispatch:
3+ inputs:
4+ a:
5+ type: choice
6+ options: []
7+ b:
8+ type: string
9+ options: [x]
10+ c:
11+ type: boolean
12+ default: foo
13+ d:
14+ type: string
15+ default: 5
16+ e:
17+ type: number
18+ default: 'x'
19+ f:
20+ type: boolean
21+ default: ${{ github.ref }}
22+jobs:
23+ a:
24+ runs-on: x
25+ steps: [{run: a}]
@@ -0,0 +1,25 @@
1+on:
2+ workflow_dispatch:
3+ inputs:
4+ a:
5+ type: choice
6+ options: []
7+ b:
8+ type: string
9+ options: [x]
10+ c:
11+ type: boolean
12+ default: foo
13+ d:
14+ type: string
15+ default: 5
16+ e:
17+ type: number
18+ default: 'x'
19+ f:
20+ type: boolean
21+ default: ${{ github.ref }}
22+jobs:
23+ a:
24+ runs-on: x
25+ steps: [{run: a}]
A workflow/testdata/convert/job_env_nonmap.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ env: x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ env: x
6+ steps: [{run: a}]
A workflow/testdata/convert/job_name_empty.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ name: ''
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ name: ''
6+ steps: [{run: a}]
A workflow/testdata/convert/job_name_expr.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ name: x-${{ matrix.a }}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ name: x-${{ matrix.a }}
6+ steps: [{run: a}]
A workflow/testdata/convert/job_not_map.yml +3 -0
@@ -0,0 +1,3 @@
1+on: push
2+jobs:
3+ a: x
@@ -0,0 +1,3 @@
1+on: push
2+jobs:
3+ a: x
A workflow/testdata/convert/jobs_dropped_job.yml +14 -0
@@ -0,0 +1,14 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ runs-on: x
8+ env: x
9+ outputs: y
10+ steps: [{run: a}]
11+ c:
12+ needs: b
13+ runs-on: x
14+ steps: [{run: a}]
@@ -0,0 +1,14 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ runs-on: x
8+ env: x
9+ outputs: y
10+ steps: [{run: a}]
11+ c:
12+ needs: b
13+ runs-on: x
14+ steps: [{run: a}]
A workflow/testdata/convert/jobs_empty.yml +2 -0
@@ -0,0 +1,2 @@
1+on: push
2+jobs: {}
@@ -0,0 +1,2 @@
1+on: push
2+jobs: {}
A workflow/testdata/convert/jobs_not_map.yml +2 -0
@@ -0,0 +1,2 @@
1+on: push
2+jobs: [a]
@@ -0,0 +1,2 @@
1+on: push
2+jobs: [a]
A workflow/testdata/convert/multiline_folded.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: >-
7+ a ${{ bar }}
8+ b
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: >-
7+ a ${{ bar }}
8+ b
A workflow/testdata/convert/multiline_plain.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: a
7+ ${{ bar }}
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: a
7+ ${{ bar }}
A workflow/testdata/convert/needs_bad_type.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ needs: 3
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ needs: 3
6+ steps: [{run: a}]
A workflow/testdata/convert/needs_case.yml +9 -0
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ needs: [A]
8+ runs-on: x
9+ steps: [{run: a}]
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ needs: [A]
8+ runs-on: x
9+ steps: [{run: a}]
A workflow/testdata/convert/needs_dup.yml +9 -0
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ needs: [a, a]
8+ runs-on: x
9+ steps: [{run: a}]
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ needs: [a, a]
8+ runs-on: x
9+ steps: [{run: a}]
A workflow/testdata/convert/needs_empty.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ needs: []
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ needs: []
6+ steps: [{run: a}]
A workflow/testdata/convert/needs_string.yml +9 -0
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ needs: a
8+ runs-on: x
9+ steps: [{run: a}]
@@ -0,0 +1,9 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ needs: a
8+ runs-on: x
9+ steps: [{run: a}]
A workflow/testdata/convert/no_jobs.yml +1 -0
@@ -0,0 +1,1 @@
1+on: push
@@ -0,0 +1,1 @@
1+on: push
A workflow/testdata/convert/no_on.yml +4 -0
@@ -0,0 +1,4 @@
1+jobs:
2+ a:
3+ runs-on: x
4+ steps: [{run: a}]
@@ -0,0 +1,4 @@
1+jobs:
2+ a:
3+ runs-on: x
4+ steps: [{run: a}]
A workflow/testdata/convert/no_runs_on.yml +4 -0
@@ -0,0 +1,4 @@
1+on: push
2+jobs:
3+ a:
4+ steps: [{run: a}]
@@ -0,0 +1,4 @@
1+on: push
2+jobs:
3+ a:
4+ steps: [{run: a}]
A workflow/testdata/convert/no_steps.yml +4 -0
@@ -0,0 +1,4 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
@@ -0,0 +1,4 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
A workflow/testdata/convert/null_doc.yml +1 -0
@@ -0,0 +1,1 @@
1+~
@@ -0,0 +1,1 @@
1+~
A workflow/testdata/convert/on_bad_type.yml +5 -0
@@ -0,0 +1,5 @@
1+on: 3
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
@@ -0,0 +1,5 @@
1+on: 3
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
A workflow/testdata/convert/on_call_secrets.yml +10 -0
@@ -0,0 +1,10 @@
1+on:
2+ workflow_call:
3+ secrets:
4+ a:
5+ description: x
6+ required: 3
7+jobs:
8+ a:
9+ runs-on: x
10+ steps: [{run: a}]
@@ -0,0 +1,10 @@
1+on:
2+ workflow_call:
3+ secrets:
4+ a:
5+ description: x
6+ required: 3
7+jobs:
8+ a:
9+ runs-on: x
10+ steps: [{run: a}]
A workflow/testdata/convert/on_dispatch_input_unknown_key.yml +9 -0
@@ -0,0 +1,9 @@
1+on:
2+ workflow_dispatch:
3+ inputs:
4+ a:
5+ bogus: 1
6+jobs:
7+ a:
8+ runs-on: x
9+ steps: [{run: a}]
@@ -0,0 +1,9 @@
1+on:
2+ workflow_dispatch:
3+ inputs:
4+ a:
5+ bogus: 1
6+jobs:
7+ a:
8+ runs-on: x
9+ steps: [{run: a}]
A workflow/testdata/convert/on_event_non_mapping.yml +6 -0
@@ -0,0 +1,6 @@
1+on:
2+ push: x
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on:
2+ push: x
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
A workflow/testdata/convert/on_expression.yml +5 -0
@@ -0,0 +1,5 @@
1+on: ${{ x }}
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
@@ -0,0 +1,5 @@
1+on: ${{ x }}
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
A workflow/testdata/convert/on_filter_empty_item.yml +7 -0
@@ -0,0 +1,7 @@
1+on:
2+ push:
3+ branches: ['']
4+jobs:
5+ a:
6+ runs-on: x
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on:
2+ push:
3+ branches: ['']
4+jobs:
5+ a:
6+ runs-on: x
7+ steps: [{run: a}]
A workflow/testdata/convert/on_filter_item_types.yml +8 -0
@@ -0,0 +1,8 @@
1+on:
2+ push:
3+ branches: [1, a]
4+ paths: [[x]]
5+jobs:
6+ a:
7+ runs-on: x
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on:
2+ push:
3+ branches: [1, a]
4+ paths: [[x]]
5+jobs:
6+ a:
7+ runs-on: x
8+ steps: [{run: a}]
A workflow/testdata/convert/on_filter_types.yml +7 -0
@@ -0,0 +1,7 @@
1+on:
2+ push:
3+ branches: 1
4+jobs:
5+ a:
6+ runs-on: x
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on:
2+ push:
3+ branches: 1
4+jobs:
5+ a:
6+ runs-on: x
7+ steps: [{run: a}]
A workflow/testdata/convert/on_filters_both.yml +12 -0
@@ -0,0 +1,12 @@
1+on:
2+ push:
3+ branches: [a]
4+ branches-ignore: [b]
5+ tags: v*
6+ tags-ignore: [x]
7+ paths: ['**.go']
8+ paths-ignore: []
9+jobs:
10+ a:
11+ runs-on: x
12+ steps: [{run: a}]
@@ -0,0 +1,12 @@
1+on:
2+ push:
3+ branches: [a]
4+ branches-ignore: [b]
5+ tags: v*
6+ tags-ignore: [x]
7+ paths: ['**.go']
8+ paths-ignore: []
9+jobs:
10+ a:
11+ runs-on: x
12+ steps: [{run: a}]
A workflow/testdata/convert/on_forms.yml +9 -0
@@ -0,0 +1,9 @@
1+on:
2+ push:
3+ pull_request: {}
4+ issues:
5+ types: opened
6+jobs:
7+ a:
8+ runs-on: x
9+ steps: [{run: a}]
@@ -0,0 +1,9 @@
1+on:
2+ push:
3+ pull_request: {}
4+ issues:
5+ types: opened
6+jobs:
7+ a:
8+ runs-on: x
9+ steps: [{run: a}]
A workflow/testdata/convert/on_schedule_non_seq.yml +6 -0
@@ -0,0 +1,6 @@
1+on:
2+ schedule: x
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on:
2+ schedule: x
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
A workflow/testdata/convert/on_unknown_event.yml +5 -0
@@ -0,0 +1,5 @@
1+on: [push, bogus]
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
@@ -0,0 +1,5 @@
1+on: [push, bogus]
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
A workflow/testdata/convert/on_unknown_key.yml +7 -0
@@ -0,0 +1,7 @@
1+on:
2+ push:
3+ foo: 1
4+jobs:
5+ a:
6+ runs-on: x
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on:
2+ push:
3+ foo: 1
4+jobs:
5+ a:
6+ runs-on: x
7+ steps: [{run: a}]
A workflow/testdata/convert/outputs_nonmap.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ outputs: x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ outputs: x
6+ steps: [{run: a}]
A workflow/testdata/convert/permissions_bad.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+permissions:
3+ bogus: read
4+ contents: maybe
5+jobs:
6+ a:
7+ runs-on: x
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+permissions:
3+ bogus: read
4+ contents: maybe
5+jobs:
6+ a:
7+ runs-on: x
8+ steps: [{run: a}]
A workflow/testdata/convert/permissions_forms.yml +10 -0
@@ -0,0 +1,10 @@
1+on: push
2+permissions: read-all
3+jobs:
4+ a:
5+ runs-on: x
6+ permissions:
7+ contents: write
8+ packages: none
9+ id-token: read
10+ steps: [{run: a}]
@@ -0,0 +1,10 @@
1+on: push
2+permissions: read-all
3+jobs:
4+ a:
5+ runs-on: x
6+ permissions:
7+ contents: write
8+ packages: none
9+ id-token: read
10+ steps: [{run: a}]
A workflow/testdata/convert/quoted_multiline.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: "a\n${{ bar }}"
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: "a\n${{ bar }}"
A workflow/testdata/convert/reusable_needs.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ needs: a
8+ uses: ./x.yml
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: x
5+ steps: [{run: a}]
6+ b:
7+ needs: a
8+ uses: ./x.yml
A workflow/testdata/convert/reusable_ok.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ uses: org/repo/.github/workflows/x.yml@v1
5+ with:
6+ a: 1
7+ secrets:
8+ s: ${{ secrets.t }}
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ uses: org/repo/.github/workflows/x.yml@v1
5+ with:
6+ a: 1
7+ secrets:
8+ s: ${{ secrets.t }}
A workflow/testdata/convert/reusable_with_runs_on.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ uses: ./x.yml
5+ runs-on: x
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ uses: ./x.yml
5+ runs-on: x
A workflow/testdata/convert/reusable_with_steps.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ uses: ./x.yml
5+ steps: []
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ uses: ./x.yml
5+ steps: []
A workflow/testdata/convert/runs_on_bad.yml +11 -0
@@ -0,0 +1,11 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: [[a]]
5+ steps: [{run: a}]
6+ b:
7+ runs-on: 3
8+ steps: [{run: a}]
9+ c:
10+ runs-on: ''
11+ steps: [{run: a}]
@@ -0,0 +1,11 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: [[a]]
5+ steps: [{run: a}]
6+ b:
7+ runs-on: 3
8+ steps: [{run: a}]
9+ c:
10+ runs-on: ''
11+ steps: [{run: a}]
A workflow/testdata/convert/runs_on_converter_labels.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: {labels: [[a]]}
5+ steps: [{run: a}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: {labels: [[a]]}
5+ steps: [{run: a}]
A workflow/testdata/convert/runs_on_forms.yml +11 -0
@@ -0,0 +1,11 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: {group: g, labels: [a, b]}
5+ steps: [{run: a}]
6+ b:
7+ runs-on: {labels: c}
8+ steps: [{run: a}]
9+ c:
10+ runs-on: [a, '${{ matrix.x }}']
11+ steps: [{run: a}]
@@ -0,0 +1,11 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: {group: g, labels: [a, b]}
5+ steps: [{run: a}]
6+ b:
7+ runs-on: {labels: c}
8+ steps: [{run: a}]
9+ c:
10+ runs-on: [a, '${{ matrix.x }}']
11+ steps: [{run: a}]
A workflow/testdata/convert/runs_on_group_bad.yml +14 -0
@@ -0,0 +1,14 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: {group: foo/bar/baz}
5+ steps: [{run: a}]
6+ b:
7+ runs-on: {group: org/}
8+ steps: [{run: a}]
9+ c:
10+ runs-on: {group: bogus/x}
11+ steps: [{run: a}]
12+ d:
13+ runs-on: {group: org/x}
14+ steps: [{run: a}]
@@ -0,0 +1,14 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: {group: foo/bar/baz}
5+ steps: [{run: a}]
6+ b:
7+ runs-on: {group: org/}
8+ steps: [{run: a}]
9+ c:
10+ runs-on: {group: bogus/x}
11+ steps: [{run: a}]
12+ d:
13+ runs-on: {group: org/x}
14+ steps: [{run: a}]
A workflow/testdata/convert/scalar_doc.yml +1 -0
@@ -0,0 +1,1 @@
1+hello
@@ -0,0 +1,1 @@
1+hello
A workflow/testdata/convert/seq_doc.yml +1 -0
@@ -0,0 +1,1 @@
1+[1,2]
@@ -0,0 +1,1 @@
1+[1,2]
A workflow/testdata/convert/services_bad.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ services: {s: {image: a, bogus: 1}}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ services: {s: {image: a, bogus: 1}}
6+ steps: [{run: a}]
A workflow/testdata/convert/services_not_map.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ services: [a]
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ services: [a]
6+ steps: [{run: a}]
A workflow/testdata/convert/services_ok.yml +12 -0
@@ -0,0 +1,12 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ services:
6+ s:
7+ image: a
8+ entrypoint: b
9+ command: c
10+ options: --x
11+ volumes: [a:b]
12+ steps: [{run: a}]
@@ -0,0 +1,12 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ services:
6+ s:
7+ image: a
8+ entrypoint: b
9+ command: c
10+ options: --x
11+ volumes: [a:b]
12+ steps: [{run: a}]
A workflow/testdata/convert/snapshot.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ snapshot: img
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ snapshot: img
6+ steps: [{run: a}]
A workflow/testdata/convert/step_both.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: a
7+ uses: b
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - run: a
7+ uses: b
A workflow/testdata/convert/step_coe_bad.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, continue-on-error: yes}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, continue-on-error: yes}]
A workflow/testdata/convert/step_coe_expr.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, continue-on-error: '${{ true }}'}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, continue-on-error: '${{ true }}'}]
A workflow/testdata/convert/step_continue_on_error_num.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, continue-on-error: 1}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, continue-on-error: 1}]
A workflow/testdata/convert/step_empty_uses.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - uses: ''
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - uses: ''
A workflow/testdata/convert/step_env_nonmap.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, env: x}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, env: x}]
A workflow/testdata/convert/step_id_100.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}, {run: a, id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}, {run: a, id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}]
A workflow/testdata/convert/step_ids_only_bad.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, id: 1x}, {run: b, id: s},{run: b, id: s}, {run: c, id: __x}, {run: d, id: S}, {run: e, id: 'a b'}, {run: f, id: x.y}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, id: 1x}, {run: b, id: s},{run: b, id: s}, {run: c, id: __x}, {run: d, id: S}, {run: e, id: 'a b'}, {run: f, id: x.y}]
A workflow/testdata/convert/step_neither.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - name: a
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps:
6+ - name: a
A workflow/testdata/convert/step_run_names_collide.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, id: __run}, {run: b, id: _run}, {run: c}, {run: d}, {uses: _x/_y@v1}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, id: __run}, {run: b, id: _run}, {run: c}, {run: d}, {uses: _x/_y@v1}]
A workflow/testdata/convert/step_shell_empty.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, shell: ''}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, shell: ''}]
A workflow/testdata/convert/step_wd_empty.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, working-directory: ''}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a, working-directory: ''}]
A workflow/testdata/convert/step_with_nonstring.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{uses: a/b@v1, with: {a: 1, b: true, c: [1]}}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{uses: a/b@v1, with: {a: 1, b: true, c: [1]}}]
A workflow/testdata/convert/steps_empty_list.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: []
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: []
A workflow/testdata/convert/steps_not_seq.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: a
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: a
A workflow/testdata/convert/strategy_bad_key.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ bogus: 1
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ bogus: 1
7+ steps: [{run: a}]
A workflow/testdata/convert/strategy_ctx.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ a: ${{ steps.x.outputs.y }}
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ a: ${{ steps.x.outputs.y }}
8+ steps: [{run: a}]
A workflow/testdata/convert/strategy_matrix_bad.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ os: foo
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ os: foo
8+ steps: [{run: a}]
A workflow/testdata/convert/strategy_matrix_expr.yml +7 -0
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix: ${{ fromJSON(needs.a.outputs.m) }}
7+ steps: [{run: a}]
@@ -0,0 +1,7 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix: ${{ fromJSON(needs.a.outputs.m) }}
7+ steps: [{run: a}]
A workflow/testdata/convert/strategy_matrix_ok.yml +11 -0
@@ -0,0 +1,11 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ a: [1, 2]
8+ include:
9+ - a: 3
10+ exclude: []
11+ steps: [{run: a}]
@@ -0,0 +1,11 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ strategy:
6+ matrix:
7+ a: [1, 2]
8+ include:
9+ - a: 3
10+ exclude: []
11+ steps: [{run: a}]
A workflow/testdata/convert/timeout_expr.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ timeout-minutes: ${{ matrix.t }}
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ timeout-minutes: ${{ matrix.t }}
6+ steps: [{run: a}]
A workflow/testdata/convert/timeout_str.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ timeout-minutes: abc
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ timeout-minutes: abc
6+ steps: [{run: a}]
A workflow/testdata/convert/two_docs.yml +4 -0
@@ -0,0 +1,4 @@
1+on: push
2+jobs: {}
3+---
4+x: 1
@@ -0,0 +1,4 @@
1+on: push
2+jobs: {}
3+---
4+x: 1
A workflow/testdata/convert/unicode.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ name: héllo ${{ github.ref }} 日本
5+ runs-on: x
6+ steps: [{run: 'é${{ foo }}'}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ name: héllo ${{ github.ref }} 日本
5+ runs-on: x
6+ steps: [{run: 'é${{ foo }}'}]
A workflow/testdata/convert/unknown_job_key.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ bogus: 1
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ bogus: 1
6+ steps: [{run: a}]
A workflow/testdata/convert/unknown_top_key.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+bogus: 1
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+bogus: 1
3+jobs:
4+ a:
5+ runs-on: x
6+ steps: [{run: a}]
A workflow/testdata/convert/uses_ids.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{uses: actions/checkout@v4}, {uses: ./x}, {uses: docker://alpine:3}, {run: a}, {run: b}, {uses: actions/checkout@v4}, {uses: a}, {uses: 'a/b/c@v1'}, {uses: '$/x'}, {uses: 'a@b@c'}, {uses: '/a/b@v'}, {uses: '.\x'}, {run: 1, id: __run}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{uses: actions/checkout@v4}, {uses: ./x}, {uses: docker://alpine:3}, {run: a}, {run: b}, {uses: actions/checkout@v4}, {uses: a}, {uses: 'a/b/c@v1'}, {uses: '$/x'}, {uses: 'a@b@c'}, {uses: '/a/b@v'}, {uses: '.\x'}, {run: 1, id: __run}]
A workflow/testdata/convert/valid_full.yml +86 -0
@@ -0,0 +1,86 @@
1+name: CI
2+run-name: Run ${{ github.actor }}
3+on:
4+ push:
5+ branches: [main, 'release/**']
6+ tags: ['v*']
7+ paths: ['src/**']
8+ pull_request:
9+ branches-ignore: [wip]
10+ schedule:
11+ - cron: '30 5,17 * * 1-5'
12+ workflow_dispatch:
13+ inputs:
14+ level:
15+ type: choice
16+ options: [a, b]
17+ default: a
18+env:
19+ A: 1
20+ B: ${{ github.ref }}
21+defaults:
22+ run:
23+ shell: bash
24+ working-directory: ./x
25+permissions:
26+ contents: read
27+ id-token: write
28+concurrency:
29+ group: ${{ github.workflow }}-${{ github.ref }}
30+ cancel-in-progress: true
31+jobs:
32+ build:
33+ name: Build ${{ matrix.os }}
34+ runs-on: ${{ matrix.os }}
35+ timeout-minutes: 10
36+ strategy:
37+ fail-fast: false
38+ max-parallel: 2
39+ matrix:
40+ os: [ubuntu-latest, macos-latest]
41+ node: [18, 20]
42+ include:
43+ - os: ubuntu-latest
44+ extra: yes
45+ exclude:
46+ - os: macos-latest
47+ node: 18
48+ outputs:
49+ v: ${{ steps.s.outputs.v }}
50+ env:
51+ X: ${{ matrix.node }}
52+ steps:
53+ - uses: actions/checkout@v4
54+ with:
55+ fetch-depth: 0
56+ - id: s
57+ run: echo "v=1" >> "$GITHUB_OUTPUT"
58+ shell: bash
59+ working-directory: sub
60+ if: github.ref == 'refs/heads/main'
61+ continue-on-error: ${{ matrix.experimental }}
62+ timeout-minutes: 3
63+ env:
64+ Y: ${{ steps.s.outputs.v }}
65+ - run: echo
66+ if: always()
67+ test:
68+ needs: build
69+ runs-on: [self-hosted, linux]
70+ if: ${{ needs.build.result == 'success' }}
71+ container:
72+ image: node:20
73+ env: {A: b}
74+ ports: ['80']
75+ services:
76+ db:
77+ image: postgres
78+ credentials: {username: u, password: p}
79+ steps:
80+ - run: echo ${{ needs.build.outputs.v }}
81+ deploy:
82+ needs: [build, test]
83+ uses: ./.github/workflows/d.yml
84+ with:
85+ a: ${{ needs.build.outputs.v }}
86+ secrets: inherit
@@ -0,0 +1,86 @@
1+name: CI
2+run-name: Run ${{ github.actor }}
3+on:
4+ push:
5+ branches: [main, 'release/**']
6+ tags: ['v*']
7+ paths: ['src/**']
8+ pull_request:
9+ branches-ignore: [wip]
10+ schedule:
11+ - cron: '30 5,17 * * 1-5'
12+ workflow_dispatch:
13+ inputs:
14+ level:
15+ type: choice
16+ options: [a, b]
17+ default: a
18+env:
19+ A: 1
20+ B: ${{ github.ref }}
21+defaults:
22+ run:
23+ shell: bash
24+ working-directory: ./x
25+permissions:
26+ contents: read
27+ id-token: write
28+concurrency:
29+ group: ${{ github.workflow }}-${{ github.ref }}
30+ cancel-in-progress: true
31+jobs:
32+ build:
33+ name: Build ${{ matrix.os }}
34+ runs-on: ${{ matrix.os }}
35+ timeout-minutes: 10
36+ strategy:
37+ fail-fast: false
38+ max-parallel: 2
39+ matrix:
40+ os: [ubuntu-latest, macos-latest]
41+ node: [18, 20]
42+ include:
43+ - os: ubuntu-latest
44+ extra: yes
45+ exclude:
46+ - os: macos-latest
47+ node: 18
48+ outputs:
49+ v: ${{ steps.s.outputs.v }}
50+ env:
51+ X: ${{ matrix.node }}
52+ steps:
53+ - uses: actions/checkout@v4
54+ with:
55+ fetch-depth: 0
56+ - id: s
57+ run: echo "v=1" >> "$GITHUB_OUTPUT"
58+ shell: bash
59+ working-directory: sub
60+ if: github.ref == 'refs/heads/main'
61+ continue-on-error: ${{ matrix.experimental }}
62+ timeout-minutes: 3
63+ env:
64+ Y: ${{ steps.s.outputs.v }}
65+ - run: echo
66+ if: always()
67+ test:
68+ needs: build
69+ runs-on: [self-hosted, linux]
70+ if: ${{ needs.build.result == 'success' }}
71+ container:
72+ image: node:20
73+ env: {A: b}
74+ ports: ['80']
75+ services:
76+ db:
77+ image: postgres
78+ credentials: {username: u, password: p}
79+ steps:
80+ - run: echo ${{ needs.build.outputs.v }}
81+ deploy:
82+ needs: [build, test]
83+ uses: ./.github/workflows/d.yml
84+ with:
85+ a: ${{ needs.build.outputs.v }}
86+ secrets: inherit
A workflow/testdata/convert/valid_minimal.yml +5 -0
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a}]
@@ -0,0 +1,5 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ steps: [{run: a}]
A workflow/testdata/convert/workflow_defaults_bad.yml +8 -0
@@ -0,0 +1,8 @@
1+on: push
2+defaults:
3+ run:
4+ bogus: 1
5+jobs:
6+ a:
7+ runs-on: x
8+ steps: [{run: a}]
@@ -0,0 +1,8 @@
1+on: push
2+defaults:
3+ run:
4+ bogus: 1
5+jobs:
6+ a:
7+ runs-on: x
8+ steps: [{run: a}]
A workflow/testdata/convert/yes_no.yml +6 -0
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ continue-on-error: yes
6+ steps: [{run: a}]
@@ -0,0 +1,6 @@
1+on: push
2+jobs:
3+ a:
4+ runs-on: ubuntu-latest
5+ continue-on-error: yes
6+ steps: [{run: a}]

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close