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 }
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+}
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+}
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+}
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+}
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+}
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+}
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+}
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
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+}
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));
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+}
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 }
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+}
A workflow/testdata/convert.json +2445 -0
@@ -0,0 +1,2445 @@
1+{
2+ "alias.yml": {
3+ "errors": [],
4+ "jobs": [
5+ {
6+ "id": "a",
7+ "name": "a",
8+ "if": "success()",
9+ "needs": [],
10+ "steps": [
11+ {
12+ "id": "__run",
13+ "if": "success()"
14+ }
15+ ]
16+ },
17+ {
18+ "id": "b",
19+ "name": "b",
20+ "if": "success()",
21+ "needs": [],
22+ "steps": [
23+ {
24+ "id": "__run",
25+ "if": "success()"
26+ }
27+ ]
28+ }
29+ ],
30+ "events": {
31+ "push": {}
32+ }
33+ },
34+ "alias_scalar.yml": {
35+ "errors": [
36+ "alias_scalar.yml (Line: 2, Col: 1): Unexpected value 'x'"
37+ ],
38+ "jobs": []
39+ },
40+ "background_steps.yml": {
41+ "errors": [
42+ "background_steps.yml (Line: 7, Col: 9): Unexpected value 'background'",
43+ "background_steps.yml (Line: 8, Col: 9): Unexpected value 'wait'"
44+ ],
45+ "jobs": []
46+ },
47+ "bad_cron.yml": {
48+ "errors": [
49+ "bad_cron.yml (Line: 13, Col: 7): Required property is missing: cron",
50+ "bad_cron.yml (Line: 16, Col: 7): Unexpected value 'extra'"
51+ ],
52+ "jobs": []
53+ },
54+ "bad_if.yml": {
55+ "errors": [
56+ "bad_if.yml (Line: 5, Col: 9): Unexpected end of expression: '&&'"
57+ ],
58+ "jobs": [
59+ {
60+ "id": "a",
61+ "name": "a",
62+ "if": "success()",
63+ "needs": [],
64+ "steps": [
65+ {
66+ "id": "__run",
67+ "if": "success()"
68+ }
69+ ]
70+ }
71+ ],
72+ "events": {
73+ "push": {}
74+ }
75+ },
76+ "bad_job_ids.yml": {
77+ "errors": [
78+ "bad_job_ids.yml (Line: 3, Col: 3): The identifier '1a' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters.",
79+ "bad_job_ids.yml (Line: 6, Col: 3): The identifier '__b' is invalid. IDs starting with '__' are reserved.",
80+ "bad_job_ids.yml (Line: 9, Col: 3): The identifier 'a b' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters."
81+ ],
82+ "jobs": [
83+ {
84+ "id": "1a",
85+ "name": "1a",
86+ "if": "success()",
87+ "needs": [],
88+ "steps": [
89+ {
90+ "id": "__run",
91+ "if": "success()"
92+ }
93+ ]
94+ },
95+ {
96+ "id": "__b",
97+ "name": "__b",
98+ "if": "success()",
99+ "needs": [],
100+ "steps": [
101+ {
102+ "id": "__run",
103+ "if": "success()"
104+ }
105+ ]
106+ },
107+ {
108+ "id": "a b",
109+ "name": "a b",
110+ "if": "success()",
111+ "needs": [],
112+ "steps": [
113+ {
114+ "id": "__run",
115+ "if": "success()"
116+ }
117+ ]
118+ }
119+ ],
120+ "events": {
121+ "push": {}
122+ }
123+ },
124+ "bad_needs_cycle.yml": {
125+ "errors": [
126+ "bad_needs_cycle.yml (Line: 3, Col: 3): The workflow must contain at least one job with no dependencies."
127+ ],
128+ "jobs": [
129+ {
130+ "id": "a",
131+ "name": "a",
132+ "if": "success()",
133+ "needs": [
134+ "b"
135+ ],
136+ "steps": [
137+ {
138+ "id": "__run",
139+ "if": "success()"
140+ }
141+ ]
142+ },
143+ {
144+ "id": "b",
145+ "name": "b",
146+ "if": "success()",
147+ "needs": [
148+ "a"
149+ ],
150+ "steps": [
151+ {
152+ "id": "__run",
153+ "if": "success()"
154+ }
155+ ]
156+ }
157+ ],
158+ "events": {
159+ "push": {}
160+ }
161+ },
162+ "bad_needs_cycle_with_root.yml": {
163+ "errors": [
164+ "bad_needs_cycle_with_root.yml (Line: 8, Col: 13): Job 'a' depends on job 'b' which creates a cycle in the dependency graph.",
165+ "bad_needs_cycle_with_root.yml (Line: 12, Col: 13): Job 'b' depends on job 'a' which creates a cycle in the dependency graph."
166+ ],
167+ "jobs": [
168+ {
169+ "id": "r",
170+ "name": "r",
171+ "if": "success()",
172+ "needs": [],
173+ "steps": [
174+ {
175+ "id": "__run",
176+ "if": "success()"
177+ }
178+ ]
179+ },
180+ {
181+ "id": "a",
182+ "name": "a",
183+ "if": "success()",
184+ "needs": [
185+ "b"
186+ ],
187+ "steps": [
188+ {
189+ "id": "__run",
190+ "if": "success()"
191+ }
192+ ]
193+ },
194+ {
195+ "id": "b",
196+ "name": "b",
197+ "if": "success()",
198+ "needs": [
199+ "a",
200+ "r"
201+ ],
202+ "steps": [
203+ {
204+ "id": "__run",
205+ "if": "success()"
206+ }
207+ ]
208+ }
209+ ],
210+ "events": {
211+ "push": {}
212+ }
213+ },
214+ "bad_needs_self.yml": {
215+ "errors": [
216+ "bad_needs_self.yml (Line: 8, Col: 13): Job 'a' depends on job 'a' which creates a cycle in the dependency graph."
217+ ],
218+ "jobs": [
219+ {
220+ "id": "r",
221+ "name": "r",
222+ "if": "success()",
223+ "needs": [],
224+ "steps": [
225+ {
226+ "id": "__run",
227+ "if": "success()"
228+ }
229+ ]
230+ },
231+ {
232+ "id": "a",
233+ "name": "a",
234+ "if": "success()",
235+ "needs": [
236+ "a"
237+ ],
238+ "steps": [
239+ {
240+ "id": "__run",
241+ "if": "success()"
242+ }
243+ ]
244+ }
245+ ],
246+ "events": {
247+ "push": {}
248+ }
249+ },
250+ "bad_needs_unknown.yml": {
251+ "errors": [
252+ "bad_needs_unknown.yml (Line: 8, Col: 13): Job 'a' depends on unknown job 'zz'."
253+ ],
254+ "jobs": [
255+ {
256+ "id": "r",
257+ "name": "r",
258+ "if": "success()",
259+ "needs": [],
260+ "steps": [
261+ {
262+ "id": "__run",
263+ "if": "success()"
264+ }
265+ ]
266+ },
267+ {
268+ "id": "a",
269+ "name": "a",
270+ "if": "success()",
271+ "needs": [
272+ "zz",
273+ "r"
274+ ],
275+ "steps": [
276+ {
277+ "id": "__run",
278+ "if": "success()"
279+ }
280+ ]
281+ }
282+ ],
283+ "events": {
284+ "push": {}
285+ }
286+ },
287+ "bad_step_id.yml": {
288+ "errors": [
289+ "bad_step_id.yml (Line: 5, Col: 96): Unexpected value ''"
290+ ],
291+ "jobs": []
292+ },
293+ "bad_yaml_flow.yml": {
294+ "errors": [
295+ "bad_yaml_flow.yml: Flow sequence in block collection must be sufficiently indented and end with a ] at line 2, column 1:\n\non: [push\n\n^\n"
296+ ]
297+ },
298+ "bad_yaml_tab.yml": {
299+ "errors": [
300+ "bad_yaml_tab.yml: Tabs are not allowed as indentation at line 3, column 1:\n\njobs:\n\ta: 1\n^\n"
301+ ]
302+ },
303+ "big_ids.yml": {
304+ "errors": [
305+ "big_ids.yml (Line: 3, Col: 3): The identifier 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters."
306+ ],
307+ "jobs": [
308+ {
309+ "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
310+ "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
311+ "if": "success()",
312+ "needs": [],
313+ "steps": [
314+ {
315+ "id": "__run",
316+ "if": "success()"
317+ }
318+ ]
319+ },
320+ {
321+ "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
322+ "name": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
323+ "if": "success()",
324+ "needs": [],
325+ "steps": [
326+ {
327+ "id": "__run",
328+ "if": "success()"
329+ }
330+ ]
331+ }
332+ ],
333+ "events": {
334+ "push": {}
335+ }
336+ },
337+ "bool_scalars.yml": {
338+ "errors": [],
339+ "jobs": [
340+ {
341+ "id": "a",
342+ "name": "a",
343+ "if": "success()",
344+ "needs": [],
345+ "steps": [
346+ {
347+ "id": "__run",
348+ "if": "success()"
349+ },
350+ {
351+ "id": "__run_2",
352+ "if": "success()"
353+ },
354+ {
355+ "id": "__run_3",
356+ "if": "success()"
357+ },
358+ {
359+ "id": "__run_4",
360+ "if": "success()"
361+ },
362+ {
363+ "id": "__run_5",
364+ "if": "success()"
365+ }
366+ ]
367+ }
368+ ],
369+ "events": {
370+ "push": {}
371+ }
372+ },
373+ "call_inputs.yml": {
374+ "errors": [
375+ "call_inputs.yml (Line: 10, Col: 18): Unexpected type 'NumberToken' encountered while reading 'input default'. The type 'BooleanToken' was expected."
376+ ],
377+ "jobs": [
378+ {
379+ "id": "a",
380+ "name": "a",
381+ "if": "success()",
382+ "needs": [],
383+ "steps": [
384+ {
385+ "id": "__run",
386+ "if": "success()"
387+ }
388+ ]
389+ }
390+ ],
391+ "events": {
392+ "workflow_call": {
393+ "inputs": {
394+ "a": {
395+ "type": "string",
396+ "required": true
397+ },
398+ "b": {
399+ "type": "boolean"
400+ }
401+ },
402+ "secrets": {
403+ "s": {
404+ "required": true
405+ },
406+ "t": {}
407+ }
408+ }
409+ }
410+ },
411+ "call_inputs_expr_default.yml": {
412+ "errors": [
413+ "call_inputs_expr_default.yml (Line: 9, Col: 18): Unexpected type 'NumberToken' encountered while reading 'input default'. The type 'StringToken' was expected."
414+ ],
415+ "jobs": [
416+ {
417+ "id": "a",
418+ "name": "a",
419+ "if": "success()",
420+ "needs": [],
421+ "steps": [
422+ {
423+ "id": "__run",
424+ "if": "success()"
425+ }
426+ ]
427+ }
428+ ],
429+ "events": {
430+ "workflow_call": {
431+ "inputs": {
432+ "b": {
433+ "type": "boolean"
434+ },
435+ "c": {
436+ "type": "string"
437+ }
438+ }
439+ }
440+ }
441+ },
442+ "comment_doc.yml": {
443+ "errors": [
444+ "comment_doc.yml: Expected a scalar value, a sequence, or a mapping"
445+ ]
446+ },
447+ "concurrency_bad.yml": {
448+ "errors": [
449+ "concurrency_bad.yml (Line: 2, Col: 25): Unexpected value 'bogus'"
450+ ],
451+ "jobs": []
452+ },
453+ "concurrency_converter.yml": {
454+ "errors": [],
455+ "jobs": [
456+ {
457+ "id": "a",
458+ "name": "a",
459+ "if": "success()",
460+ "needs": [],
461+ "steps": [
462+ {
463+ "id": "__run",
464+ "if": "success()"
465+ }
466+ ]
467+ }
468+ ],
469+ "events": {
470+ "push": {}
471+ }
472+ },
473+ "concurrency_job.yml": {
474+ "errors": [
475+ "concurrency_job.yml (Line: 5, Col: 49): Unexpected value '1'"
476+ ],
477+ "jobs": []
478+ },
479+ "concurrency_queue.yml": {
480+ "errors": [],
481+ "jobs": [
482+ {
483+ "id": "a",
484+ "name": "a",
485+ "if": "success()",
486+ "needs": [],
487+ "steps": [
488+ {
489+ "id": "__run",
490+ "if": "success()"
491+ }
492+ ]
493+ }
494+ ],
495+ "events": {
496+ "push": {}
497+ }
498+ },
499+ "concurrency_queue_bad.yml": {
500+ "errors": [
501+ "concurrency_queue_bad.yml (Line: 5, Col: 36): Unexpected value 'other'"
502+ ],
503+ "jobs": []
504+ },
505+ "concurrency_string.yml": {
506+ "errors": [],
507+ "jobs": [
508+ {
509+ "id": "a",
510+ "name": "a",
511+ "if": "success()",
512+ "needs": [],
513+ "steps": [
514+ {
515+ "id": "__run",
516+ "if": "success()"
517+ }
518+ ]
519+ }
520+ ],
521+ "events": {
522+ "push": {}
523+ }
524+ },
525+ "container_bad.yml": {
526+ "errors": [
527+ "container_bad.yml (Line: 5, Col: 27): Unexpected value 'bogus'"
528+ ],
529+ "jobs": []
530+ },
531+ "container_converter.yml": {
532+ "errors": [],
533+ "jobs": [
534+ {
535+ "id": "a",
536+ "name": "a",
537+ "if": "success()",
538+ "needs": [],
539+ "steps": [
540+ {
541+ "id": "__run",
542+ "if": "success()"
543+ }
544+ ]
545+ }
546+ ],
547+ "events": {
548+ "push": {}
549+ }
550+ },
551+ "container_empty_image.yml": {
552+ "errors": [
553+ "container_empty_image.yml (Line: 5, Col: 24): Unexpected value ''"
554+ ],
555+ "jobs": []
556+ },
557+ "container_entrypoint.yml": {
558+ "errors": [
559+ "container_entrypoint.yml (Line: 5, Col: 27): Unexpected value 'entrypoint'"
560+ ],
561+ "jobs": []
562+ },
563+ "container_expr.yml": {
564+ "errors": [],
565+ "jobs": [
566+ {
567+ "id": "a",
568+ "name": "a",
569+ "if": "success()",
570+ "needs": [],
571+ "steps": [
572+ {
573+ "id": "__run",
574+ "if": "success()"
575+ }
576+ ]
577+ }
578+ ],
579+ "events": {
580+ "push": {}
581+ }
582+ },
583+ "container_no_image.yml": {
584+ "errors": [
585+ "container_no_image.yml (Line: 5, Col: 16): Container image cannot be empty"
586+ ],
587+ "jobs": [
588+ {
589+ "id": "a",
590+ "name": "a",
591+ "if": "success()",
592+ "needs": [],
593+ "steps": [
594+ {
595+ "id": "__run",
596+ "if": "success()"
597+ }
598+ ]
599+ }
600+ ],
601+ "events": {
602+ "push": {}
603+ }
604+ },
605+ "container_string.yml": {
606+ "errors": [],
607+ "jobs": [
608+ {
609+ "id": "a",
610+ "name": "a",
611+ "if": "success()",
612+ "needs": [],
613+ "steps": [
614+ {
615+ "id": "__run",
616+ "if": "success()"
617+ }
618+ ]
619+ }
620+ ],
621+ "events": {
622+ "push": {}
623+ }
624+ },
625+ "context_always_job_env.yml": {
626+ "errors": [
627+ "context_always_job_env.yml (Line: 6, Col: 10): Unrecognized function: 'always'"
628+ ],
629+ "jobs": []
630+ },
631+ "context_defaults_run.yml": {
632+ "errors": [
633+ "context_defaults_run.yml (Line: 7, Col: 28): Unrecognized named-value: 'steps'"
634+ ],
635+ "jobs": []
636+ },
637+ "context_env_secrets.yml": {
638+ "errors": [],
639+ "jobs": [
640+ {
641+ "id": "a",
642+ "name": "a",
643+ "if": "success()",
644+ "needs": [],
645+ "steps": [
646+ {
647+ "id": "__run",
648+ "if": "success()"
649+ }
650+ ]
651+ }
652+ ],
653+ "events": {
654+ "push": {}
655+ }
656+ },
657+ "context_job_name.yml": {
658+ "errors": [
659+ "context_job_name.yml (Line: 5, Col: 11): Unrecognized named-value: 'steps'"
660+ ],
661+ "jobs": []
662+ },
663+ "context_job_outputs.yml": {
664+ "errors": [],
665+ "jobs": [
666+ {
667+ "id": "a",
668+ "name": "a",
669+ "if": "success()",
670+ "needs": [],
671+ "steps": [
672+ {
673+ "id": "__run",
674+ "if": "success()"
675+ }
676+ ]
677+ }
678+ ],
679+ "events": {
680+ "push": {}
681+ }
682+ },
683+ "context_outputs_hash.yml": {
684+ "errors": [
685+ "context_outputs_hash.yml (Line: 6, Col: 10): Unrecognized function: 'hashFiles'"
686+ ],
687+ "jobs": []
688+ },
689+ "context_run_name.yml": {
690+ "errors": [
691+ "context_run_name.yml (Line: 2, Col: 11): Unrecognized named-value: 'matrix'"
692+ ],
693+ "jobs": []
694+ },
695+ "context_runs_on_secrets.yml": {
696+ "errors": [
697+ "context_runs_on_secrets.yml (Line: 5, Col: 5): 'runs-on' is already defined"
698+ ],
699+ "jobs": []
700+ },
701+ "context_step_run.yml": {
702+ "errors": [
703+ "context_step_run.yml (Line: 5, Col: 19): Unexpected symbol: '\"a\"'. Located at position 11 within expression: hashFiles(\"a\")"
704+ ],
705+ "jobs": []
706+ },
707+ "context_success_in_run.yml": {
708+ "errors": [
709+ "context_success_in_run.yml (Line: 5, Col: 19): Unrecognized function: 'success'"
710+ ],
711+ "jobs": []
712+ },
713+ "context_unknown_func.yml": {
714+ "errors": [
715+ "context_unknown_func.yml (Line: 5, Col: 19): Unrecognized function: 'nope'"
716+ ],
717+ "jobs": []
718+ },
719+ "context_workflow_env.yml": {
720+ "errors": [
721+ "context_workflow_env.yml (Line: 3, Col: 6): Unrecognized named-value: 'matrix'"
722+ ],
723+ "jobs": []
724+ },
725+ "credentials_bad.yml": {
726+ "errors": [
727+ "credentials_bad.yml (Line: 5, Col: 54): Unexpected value 'token'"
728+ ],
729+ "jobs": []
730+ },
731+ "cron_only_bad.yml": {
732+ "errors": [
733+ "cron_only_bad.yml (Line: 3, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
734+ "cron_only_bad.yml (Line: 4, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
735+ "cron_only_bad.yml (Line: 5, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
736+ "cron_only_bad.yml (Line: 6, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
737+ "cron_only_bad.yml (Line: 8, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
738+ "cron_only_bad.yml (Line: 9, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
739+ "cron_only_bad.yml (Line: 14, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
740+ "cron_only_bad.yml (Line: 15, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
741+ "cron_only_bad.yml (Line: 16, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
742+ "cron_only_bad.yml (Line: 17, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
743+ "cron_only_bad.yml (Line: 18, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
744+ "cron_only_bad.yml (Line: 19, Col: 13): Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)"
745+ ],
746+ "jobs": [
747+ {
748+ "id": "a",
749+ "name": "a",
750+ "if": "success()",
751+ "needs": [],
752+ "steps": [
753+ {
754+ "id": "__run",
755+ "if": "success()"
756+ }
757+ ]
758+ }
759+ ],
760+ "events": {
761+ "schedule": [
762+ {
763+ "cron": "bad"
764+ },
765+ {
766+ "cron": "61 * * * *"
767+ },
768+ {
769+ "cron": "*/0 * * * *"
770+ },
771+ {
772+ "cron": "0 0 * * 7"
773+ },
774+ {
775+ "cron": "0 0 * jan-mar mon"
776+ },
777+ {
778+ "cron": "0 0 * mar-jan mon"
779+ },
780+ {
781+ "cron": "* * * * * *"
782+ },
783+ {
784+ "cron": "1-5/2 * * * *"
785+ },
786+ {
787+ "cron": "*/5 */2 1,15 * *"
788+ },
789+ {
790+ "cron": "5 4 * * sun"
791+ },
792+ {
793+ "cron": "0 0 * * *"
794+ },
795+ {
796+ "cron": " 0 0 * * *"
797+ },
798+ {
799+ "cron": "1,,2 * * * *"
800+ },
801+ {
802+ "cron": "*/5/2 * * * *"
803+ },
804+ {
805+ "cron": "1-2-3 * * * *"
806+ },
807+ {
808+ "cron": "*-5 * * * *"
809+ },
810+ {
811+ "cron": "5-1 * * * *"
812+ },
813+ {
814+ "cron": "0 0 1-31/2 * *"
815+ },
816+ {
817+ "cron": "0 0 * * MON"
818+ },
819+ {
820+ "cron": "0 0 * Feb *"
821+ }
822+ ]
823+ }
824+ },
825+ "description_key.yml": {
826+ "errors": [],
827+ "jobs": [
828+ {
829+ "id": "a",
830+ "name": "a",
831+ "if": "success()",
832+ "needs": [],
833+ "steps": [
834+ {
835+ "id": "__run",
836+ "if": "success()"
837+ }
838+ ]
839+ }
840+ ],
841+ "events": {
842+ "push": {}
843+ }
844+ },
845+ "dispatch_inputs.yml": {
846+ "errors": [
847+ "dispatch_inputs.yml (Line: 5, Col: 9): Missing 'options' for choice input",
848+ "dispatch_inputs.yml (Line: 7, Col: 9): Input type is not 'choice', but 'options' is defined",
849+ "dispatch_inputs.yml (Line: 11, Col: 18): Unexpected type 'StringToken' encountered while reading 'input default'. The type 'BooleanToken' was expected."
850+ ],
851+ "jobs": [
852+ {
853+ "id": "a",
854+ "name": "a",
855+ "if": "success()",
856+ "needs": [],
857+ "steps": [
858+ {
859+ "id": "__run",
860+ "if": "success()"
861+ }
862+ ]
863+ }
864+ ],
865+ "events": {
866+ "workflow_dispatch": {
867+ "inputs": {
868+ "a": {
869+ "type": "choice"
870+ },
871+ "b": {
872+ "type": "string",
873+ "options": [
874+ "x"
875+ ]
876+ },
877+ "c": {
878+ "type": "boolean"
879+ },
880+ "d": {
881+ "type": "environment"
882+ },
883+ "e": {
884+ "type": "string",
885+ "description": "x",
886+ "required": true,
887+ "default": "y"
888+ },
889+ "f": {}
890+ }
891+ }
892+ }
893+ },
894+ "dup_job_ids_case.yml": {
895+ "errors": [
896+ "dup_job_ids_case.yml (Line: 6, Col: 3): 'A' is already defined"
897+ ],
898+ "jobs": []
899+ },
900+ "dup_keys.yml": {
901+ "errors": [
902+ "dup_keys.yml (Line: 2, Col: 1): 'on' is already defined",
903+ "dup_keys.yml (Line: 3, Col: 1): Unexpected value 'Name'",
904+ "dup_keys.yml (Line: 4, Col: 1): 'name' is already defined",
905+ "dup_keys.yml (Line: 8, Col: 5): 'runs-on' is already defined"
906+ ],
907+ "jobs": []
908+ },
909+ "empty_doc.yml": {
910+ "errors": [
911+ "empty_doc.yml: Expected a scalar value, a sequence, or a mapping"
912+ ]
913+ },
914+ "env_nonmap_converter.yml": {
915+ "errors": [
916+ "env_nonmap_converter.yml (Line: 2, Col: 6): Unexpected value 'x'"
917+ ],
918+ "jobs": []
919+ },
920+ "environment_converter.yml": {
921+ "errors": [
922+ "environment_converter.yml (Line: 5, Col: 40): Unexpected value '1'"
923+ ],
924+ "jobs": []
925+ },
926+ "environment_forms.yml": {
927+ "errors": [],
928+ "jobs": [
929+ {
930+ "id": "a",
931+ "name": "a",
932+ "if": "success()",
933+ "needs": [],
934+ "steps": [
935+ {
936+ "id": "__run",
937+ "if": "success()"
938+ }
939+ ]
940+ }
941+ ],
942+ "events": {
943+ "push": {}
944+ }
945+ },
946+ "environment_map.yml": {
947+ "errors": [],
948+ "jobs": [
949+ {
950+ "id": "a",
951+ "name": "a",
952+ "if": "success()",
953+ "needs": [],
954+ "steps": [
955+ {
956+ "id": "__run",
957+ "if": "success()"
958+ }
959+ ]
960+ }
961+ ],
962+ "events": {
963+ "push": {}
964+ }
965+ },
966+ "environment_no_name.yml": {
967+ "errors": [
968+ "environment_no_name.yml (Line: 5, Col: 18): Required property is missing: name"
969+ ],
970+ "jobs": []
971+ },
972+ "expr_empty.yml": {
973+ "errors": [
974+ "expr_empty.yml (Line: 6, Col: 14): An expression was expected"
975+ ],
976+ "jobs": []
977+ },
978+ "expr_in_key_env.yml": {
979+ "errors": [],
980+ "jobs": [
981+ {
982+ "id": "a",
983+ "name": "a",
984+ "if": "success()",
985+ "needs": [],
986+ "steps": [
987+ {
988+ "id": "__run",
989+ "if": "success()"
990+ }
991+ ]
992+ }
993+ ],
994+ "events": {
995+ "push": {}
996+ }
997+ },
998+ "expr_insert_embedded.yml": {
999+ "errors": [
1000+ "expr_insert_embedded.yml (Line: 7, Col: 12): The directive 'insert' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.",
1001+ "expr_insert_embedded.yml (Line: 7, Col: 12): Unexpected value 'x ${{ insert }}'"
1002+ ],
1003+ "jobs": []
1004+ },
1005+ "expr_insert_key.yml": {
1006+ "errors": [
1007+ "expr_insert_key.yml (Line: 6, Col: 15): Unexpected value '${{ insert }}'"
1008+ ],
1009+ "jobs": []
1010+ },
1011+ "expr_insert_matrix.yml": {
1012+ "errors": [],
1013+ "jobs": [
1014+ {
1015+ "id": "a",
1016+ "name": "a",
1017+ "if": "success()",
1018+ "needs": [],
1019+ "steps": [
1020+ {
1021+ "id": "__run",
1022+ "if": "success()"
1023+ }
1024+ ]
1025+ }
1026+ ],
1027+ "events": {
1028+ "push": {}
1029+ }
1030+ },
1031+ "expr_insert_params.yml": {
1032+ "errors": [
1033+ "expr_insert_params.yml (Line: 7, Col: 9): Exactly 0 parameter(s) were expected following the directive 'insert'. Actual parameter count: 1",
1034+ "expr_insert_params.yml (Line: 7, Col: 26): Unexpected value '1'"
1035+ ],
1036+ "jobs": []
1037+ },
1038+ "expr_key_not_allowed.yml": {
1039+ "errors": [
1040+ "expr_key_not_allowed.yml (Line: 6, Col: 7): Unrecognized named-value: 'github'"
1041+ ],
1042+ "jobs": []
1043+ },
1044+ "expr_literal.yml": {
1045+ "errors": [],
1046+ "jobs": [
1047+ {
1048+ "id": "a",
1049+ "name": "a",
1050+ "if": "success()",
1051+ "needs": [],
1052+ "steps": [
1053+ {
1054+ "id": "__run",
1055+ "if": "success()"
1056+ }
1057+ ]
1058+ }
1059+ ],
1060+ "events": {
1061+ "push": {}
1062+ }
1063+ },
1064+ "expr_multi.yml": {
1065+ "errors": [
1066+ "expr_multi.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'",
1067+ "expr_multi.yml (Line: 6, Col: 14): Unexpected symbol: '+'. Located at position 3 within expression: 1 +"
1068+ ],
1069+ "jobs": []
1070+ },
1071+ "expr_not_closed.yml": {
1072+ "errors": [
1073+ "expr_not_closed.yml (Line: 6, Col: 14): The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found."
1074+ ],
1075+ "jobs": []
1076+ },
1077+ "expr_string_braces.yml": {
1078+ "errors": [],
1079+ "jobs": [
1080+ {
1081+ "id": "a",
1082+ "name": "a",
1083+ "if": "success()",
1084+ "needs": [],
1085+ "steps": [
1086+ {
1087+ "id": "__run",
1088+ "if": "success()"
1089+ }
1090+ ]
1091+ }
1092+ ],
1093+ "events": {
1094+ "push": {}
1095+ }
1096+ },
1097+ "if_bad_arity.yml": {
1098+ "errors": [
1099+ "if_bad_arity.yml (Line: 5, Col: 26): Too many parameters supplied: 'failure'"
1100+ ],
1101+ "jobs": [
1102+ {
1103+ "id": "a",
1104+ "name": "a",
1105+ "if": "success()",
1106+ "needs": [],
1107+ "steps": [
1108+ {
1109+ "id": "__run",
1110+ "if": "success()"
1111+ }
1112+ ]
1113+ }
1114+ ],
1115+ "events": {
1116+ "push": {}
1117+ }
1118+ },
1119+ "if_job_ctx.yml": {
1120+ "errors": [
1121+ "if_job_ctx.yml (Line: 5, Col: 9): Unrecognized named-value: 'steps'"
1122+ ],
1123+ "jobs": [
1124+ {
1125+ "id": "a",
1126+ "name": "a",
1127+ "if": "success()",
1128+ "needs": [],
1129+ "steps": [
1130+ {
1131+ "id": "__run",
1132+ "if": "success()"
1133+ }
1134+ ]
1135+ }
1136+ ],
1137+ "events": {
1138+ "push": {}
1139+ }
1140+ },
1141+ "if_step_job_funcs.yml": {
1142+ "errors": [],
1143+ "jobs": [
1144+ {
1145+ "id": "a",
1146+ "name": "a",
1147+ "if": "success(1)",
1148+ "needs": [],
1149+ "steps": [
1150+ {
1151+ "id": "__run",
1152+ "if": "success()"
1153+ }
1154+ ]
1155+ }
1156+ ],
1157+ "events": {
1158+ "push": {}
1159+ }
1160+ },
1161+ "if_wrapping.yml": {
1162+ "errors": [],
1163+ "jobs": [
1164+ {
1165+ "id": "a",
1166+ "name": "a",
1167+ "if": "success() && (github.ref == 'x')",
1168+ "needs": [],
1169+ "steps": [
1170+ {
1171+ "id": "__run",
1172+ "if": "success() && (hashFiles('a'))"
1173+ },
1174+ {
1175+ "id": "__run_2",
1176+ "if": "always()"
1177+ },
1178+ {
1179+ "id": "__run_3",
1180+ "if": "failure()"
1181+ },
1182+ {
1183+ "id": "__run_4",
1184+ "if": "success()"
1185+ },
1186+ {
1187+ "id": "__run_5",
1188+ "if": "!cancelled()"
1189+ },
1190+ {
1191+ "id": "__run_6",
1192+ "if": "contains(github.ref, 'a') || success()"
1193+ },
1194+ {
1195+ "id": "__run_7",
1196+ "if": "toJSON(success())"
1197+ },
1198+ {
1199+ "id": "__run_8",
1200+ "if": "success() && (true)"
1201+ },
1202+ {
1203+ "id": "__run_9",
1204+ "if": "success() && (format('{0} == ''x''', github.ref))"
1205+ }
1206+ ]
1207+ }
1208+ ],
1209+ "events": {
1210+ "push": {}
1211+ }
1212+ },
1213+ "if_wrapping_nonstring.yml": {
1214+ "errors": [],
1215+ "jobs": [
1216+ {
1217+ "id": "a",
1218+ "name": "a",
1219+ "if": "success() && (5)",
1220+ "needs": [],
1221+ "steps": [
1222+ {
1223+ "id": "__run",
1224+ "if": "success()"
1225+ },
1226+ {
1227+ "id": "__run_2",
1228+ "if": "success() && (false)"
1229+ }
1230+ ]
1231+ }
1232+ ],
1233+ "events": {
1234+ "push": {}
1235+ }
1236+ },
1237+ "inputs_converter_errors.yml": {
1238+ "errors": [
1239+ "inputs_converter_errors.yml (Line: 21, Col: 18): Unrecognized named-value: 'github'"
1240+ ],
1241+ "jobs": []
1242+ },
1243+ "job_env_nonmap.yml": {
1244+ "errors": [
1245+ "job_env_nonmap.yml (Line: 5, Col: 10): Unexpected value 'x'"
1246+ ],
1247+ "jobs": []
1248+ },
1249+ "job_name_empty.yml": {
1250+ "errors": [],
1251+ "jobs": [
1252+ {
1253+ "id": "a",
1254+ "name": "a",
1255+ "if": "success()",
1256+ "needs": [],
1257+ "steps": [
1258+ {
1259+ "id": "__run",
1260+ "if": "success()"
1261+ }
1262+ ]
1263+ }
1264+ ],
1265+ "events": {
1266+ "push": {}
1267+ }
1268+ },
1269+ "job_name_expr.yml": {
1270+ "errors": [],
1271+ "jobs": [
1272+ {
1273+ "id": "a",
1274+ "name": "${{ format('x-{0}', matrix.a) }}",
1275+ "if": "success()",
1276+ "needs": [],
1277+ "steps": [
1278+ {
1279+ "id": "__run",
1280+ "if": "success()"
1281+ }
1282+ ]
1283+ }
1284+ ],
1285+ "events": {
1286+ "push": {}
1287+ }
1288+ },
1289+ "job_not_map.yml": {
1290+ "errors": [
1291+ "job_not_map.yml (Line: 3, Col: 6): Unexpected value 'x'"
1292+ ],
1293+ "jobs": []
1294+ },
1295+ "jobs_dropped_job.yml": {
1296+ "errors": [
1297+ "jobs_dropped_job.yml (Line: 8, Col: 10): Unexpected value 'x'",
1298+ "jobs_dropped_job.yml (Line: 9, Col: 14): Unexpected value 'y'"
1299+ ],
1300+ "jobs": []
1301+ },
1302+ "jobs_empty.yml": {
1303+ "errors": [
1304+ "jobs_empty.yml (Line: 2, Col: 7): The workflow must contain at least one job with no dependencies."
1305+ ],
1306+ "jobs": [],
1307+ "events": {
1308+ "push": {}
1309+ }
1310+ },
1311+ "jobs_not_map.yml": {
1312+ "errors": [
1313+ "jobs_not_map.yml (Line: 2, Col: 7): A sequence was not expected"
1314+ ],
1315+ "jobs": []
1316+ },
1317+ "multiline_folded.yml": {
1318+ "errors": [
1319+ "multiline_folded.yml (Line: 6, Col: 14): Unrecognized named-value: 'bar'"
1320+ ],
1321+ "jobs": []
1322+ },
1323+ "multiline_plain.yml": {
1324+ "errors": [
1325+ "multiline_plain.yml (Line: 6, Col: 14): Unrecognized named-value: 'bar'"
1326+ ],
1327+ "jobs": []
1328+ },
1329+ "needs_bad_type.yml": {
1330+ "errors": [
1331+ "needs_bad_type.yml (Line: 3, Col: 3): The workflow must contain at least one job with no dependencies."
1332+ ],
1333+ "jobs": [
1334+ {
1335+ "id": "a",
1336+ "name": "a",
1337+ "if": "success()",
1338+ "needs": [
1339+ "3"
1340+ ],
1341+ "steps": [
1342+ {
1343+ "id": "__run",
1344+ "if": "success()"
1345+ }
1346+ ]
1347+ }
1348+ ],
1349+ "events": {
1350+ "push": {}
1351+ }
1352+ },
1353+ "needs_case.yml": {
1354+ "errors": [
1355+ "needs_case.yml (Line: 7, Col: 13): Job 'b' depends on unknown job 'A'."
1356+ ],
1357+ "jobs": [
1358+ {
1359+ "id": "a",
1360+ "name": "a",
1361+ "if": "success()",
1362+ "needs": [],
1363+ "steps": [
1364+ {
1365+ "id": "__run",
1366+ "if": "success()"
1367+ }
1368+ ]
1369+ },
1370+ {
1371+ "id": "b",
1372+ "name": "b",
1373+ "if": "success()",
1374+ "needs": [
1375+ "A"
1376+ ],
1377+ "steps": [
1378+ {
1379+ "id": "__run",
1380+ "if": "success()"
1381+ }
1382+ ]
1383+ }
1384+ ],
1385+ "events": {
1386+ "push": {}
1387+ }
1388+ },
1389+ "needs_dup.yml": {
1390+ "errors": [],
1391+ "jobs": [
1392+ {
1393+ "id": "a",
1394+ "name": "a",
1395+ "if": "success()",
1396+ "needs": [],
1397+ "steps": [
1398+ {
1399+ "id": "__run",
1400+ "if": "success()"
1401+ }
1402+ ]
1403+ },
1404+ {
1405+ "id": "b",
1406+ "name": "b",
1407+ "if": "success()",
1408+ "needs": [
1409+ "a",
1410+ "a"
1411+ ],
1412+ "steps": [
1413+ {
1414+ "id": "__run",
1415+ "if": "success()"
1416+ }
1417+ ]
1418+ }
1419+ ],
1420+ "events": {
1421+ "push": {}
1422+ }
1423+ },
1424+ "needs_empty.yml": {
1425+ "errors": [],
1426+ "jobs": [
1427+ {
1428+ "id": "a",
1429+ "name": "a",
1430+ "if": "success()",
1431+ "needs": [],
1432+ "steps": [
1433+ {
1434+ "id": "__run",
1435+ "if": "success()"
1436+ }
1437+ ]
1438+ }
1439+ ],
1440+ "events": {
1441+ "push": {}
1442+ }
1443+ },
1444+ "needs_string.yml": {
1445+ "errors": [],
1446+ "jobs": [
1447+ {
1448+ "id": "a",
1449+ "name": "a",
1450+ "if": "success()",
1451+ "needs": [],
1452+ "steps": [
1453+ {
1454+ "id": "__run",
1455+ "if": "success()"
1456+ }
1457+ ]
1458+ },
1459+ {
1460+ "id": "b",
1461+ "name": "b",
1462+ "if": "success()",
1463+ "needs": [
1464+ "a"
1465+ ],
1466+ "steps": [
1467+ {
1468+ "id": "__run",
1469+ "if": "success()"
1470+ }
1471+ ]
1472+ }
1473+ ],
1474+ "events": {
1475+ "push": {}
1476+ }
1477+ },
1478+ "no_jobs.yml": {
1479+ "errors": [
1480+ "no_jobs.yml (Line: 1, Col: 1): Required property is missing: jobs"
1481+ ],
1482+ "jobs": []
1483+ },
1484+ "no_on.yml": {
1485+ "errors": [
1486+ "no_on.yml (Line: 1, Col: 1): Required property is missing: on"
1487+ ],
1488+ "jobs": []
1489+ },
1490+ "no_runs_on.yml": {
1491+ "errors": [
1492+ "no_runs_on.yml (Line: 4, Col: 5): Required property is missing: runs-on"
1493+ ],
1494+ "jobs": []
1495+ },
1496+ "no_steps.yml": {
1497+ "errors": [],
1498+ "jobs": [
1499+ {
1500+ "id": "a",
1501+ "name": "a",
1502+ "if": "success()",
1503+ "needs": [],
1504+ "steps": []
1505+ }
1506+ ],
1507+ "events": {
1508+ "push": {}
1509+ }
1510+ },
1511+ "null_doc.yml": {
1512+ "errors": [
1513+ "null_doc.yml (Line: 1, Col: 1): Unexpected value ''"
1514+ ],
1515+ "jobs": []
1516+ },
1517+ "on_bad_type.yml": {
1518+ "errors": [
1519+ "on_bad_type.yml (Line: 1, Col: 5): Unexpected value '3'"
1520+ ],
1521+ "jobs": []
1522+ },
1523+ "on_call_secrets.yml": {
1524+ "errors": [
1525+ "on_call_secrets.yml (Line: 6, Col: 19): Unexpected value '3'"
1526+ ],
1527+ "jobs": []
1528+ },
1529+ "on_dispatch_input_unknown_key.yml": {
1530+ "errors": [
1531+ "on_dispatch_input_unknown_key.yml (Line: 5, Col: 9): Unexpected value 'bogus'"
1532+ ],
1533+ "jobs": []
1534+ },
1535+ "on_event_non_mapping.yml": {
1536+ "errors": [
1537+ "on_event_non_mapping.yml (Line: 2, Col: 9): Unexpected value 'x'"
1538+ ],
1539+ "jobs": []
1540+ },
1541+ "on_expression.yml": {
1542+ "errors": [
1543+ "on_expression.yml (Line: 1, Col: 5): Unrecognized named-value: 'x'",
1544+ "on_expression.yml (Line: 1, Col: 5): Unexpected value '${{ x }}'"
1545+ ],
1546+ "jobs": []
1547+ },
1548+ "on_filter_empty_item.yml": {
1549+ "errors": [
1550+ "on_filter_empty_item.yml (Line: 3, Col: 16): Unexpected value ''"
1551+ ],
1552+ "jobs": []
1553+ },
1554+ "on_filter_item_types.yml": {
1555+ "errors": [
1556+ "on_filter_item_types.yml (Line: 4, Col: 13): A sequence was not expected"
1557+ ],
1558+ "jobs": []
1559+ },
1560+ "on_filter_types.yml": {
1561+ "errors": [],
1562+ "jobs": [
1563+ {
1564+ "id": "a",
1565+ "name": "a",
1566+ "if": "success()",
1567+ "needs": [],
1568+ "steps": [
1569+ {
1570+ "id": "__run",
1571+ "if": "success()"
1572+ }
1573+ ]
1574+ }
1575+ ],
1576+ "events": {
1577+ "push": {
1578+ "branches": [
1579+ "1"
1580+ ]
1581+ }
1582+ }
1583+ },
1584+ "on_filters_both.yml": {
1585+ "errors": [],
1586+ "jobs": [
1587+ {
1588+ "id": "a",
1589+ "name": "a",
1590+ "if": "success()",
1591+ "needs": [],
1592+ "steps": [
1593+ {
1594+ "id": "__run",
1595+ "if": "success()"
1596+ }
1597+ ]
1598+ }
1599+ ],
1600+ "events": {
1601+ "push": {
1602+ "branches": [
1603+ "a"
1604+ ],
1605+ "branches-ignore": [
1606+ "b"
1607+ ],
1608+ "tags": [
1609+ "v*"
1610+ ],
1611+ "tags-ignore": [
1612+ "x"
1613+ ],
1614+ "paths": [
1615+ "**.go"
1616+ ],
1617+ "paths-ignore": []
1618+ }
1619+ }
1620+ },
1621+ "on_forms.yml": {
1622+ "errors": [],
1623+ "jobs": [
1624+ {
1625+ "id": "a",
1626+ "name": "a",
1627+ "if": "success()",
1628+ "needs": [],
1629+ "steps": [
1630+ {
1631+ "id": "__run",
1632+ "if": "success()"
1633+ }
1634+ ]
1635+ }
1636+ ],
1637+ "events": {
1638+ "push": {},
1639+ "pull_request": {},
1640+ "issues": {
1641+ "types": [
1642+ "opened"
1643+ ]
1644+ }
1645+ }
1646+ },
1647+ "on_schedule_non_seq.yml": {
1648+ "errors": [
1649+ "on_schedule_non_seq.yml (Line: 2, Col: 13): Unexpected value 'x'"
1650+ ],
1651+ "jobs": []
1652+ },
1653+ "on_unknown_event.yml": {
1654+ "errors": [
1655+ "on_unknown_event.yml (Line: 1, Col: 12): Unexpected value 'bogus'"
1656+ ],
1657+ "jobs": []
1658+ },
1659+ "on_unknown_key.yml": {
1660+ "errors": [
1661+ "on_unknown_key.yml (Line: 3, Col: 5): Unexpected value 'foo'"
1662+ ],
1663+ "jobs": []
1664+ },
1665+ "outputs_nonmap.yml": {
1666+ "errors": [
1667+ "outputs_nonmap.yml (Line: 5, Col: 14): Unexpected value 'x'"
1668+ ],
1669+ "jobs": []
1670+ },
1671+ "permissions_bad.yml": {
1672+ "errors": [
1673+ "permissions_bad.yml (Line: 3, Col: 3): Unexpected value 'bogus'",
1674+ "permissions_bad.yml (Line: 4, Col: 13): Unexpected value 'maybe'"
1675+ ],
1676+ "jobs": []
1677+ },
1678+ "permissions_forms.yml": {
1679+ "errors": [
1680+ "permissions_forms.yml (Line: 9, Col: 17): Unexpected value 'read'"
1681+ ],
1682+ "jobs": []
1683+ },
1684+ "quoted_multiline.yml": {
1685+ "errors": [
1686+ "quoted_multiline.yml (Line: 6, Col: 14): Unrecognized named-value: 'bar'"
1687+ ],
1688+ "jobs": []
1689+ },
1690+ "reusable_needs.yml": {
1691+ "errors": [],
1692+ "jobs": [
1693+ {
1694+ "id": "a",
1695+ "name": "a",
1696+ "if": "success()",
1697+ "needs": [],
1698+ "steps": [
1699+ {
1700+ "id": "__run",
1701+ "if": "success()"
1702+ }
1703+ ]
1704+ },
1705+ {
1706+ "id": "b",
1707+ "name": "b",
1708+ "if": "success()",
1709+ "needs": [
1710+ "a"
1711+ ],
1712+ "steps": []
1713+ }
1714+ ],
1715+ "events": {
1716+ "push": {}
1717+ }
1718+ },
1719+ "reusable_ok.yml": {
1720+ "errors": [],
1721+ "jobs": [
1722+ {
1723+ "id": "a",
1724+ "name": "a",
1725+ "if": "success()",
1726+ "needs": [],
1727+ "steps": []
1728+ }
1729+ ],
1730+ "events": {
1731+ "push": {}
1732+ }
1733+ },
1734+ "reusable_with_runs_on.yml": {
1735+ "errors": [
1736+ "reusable_with_runs_on.yml (Line: 5, Col: 5): Unexpected value 'runs-on'"
1737+ ],
1738+ "jobs": []
1739+ },
1740+ "reusable_with_steps.yml": {
1741+ "errors": [
1742+ "reusable_with_steps.yml (Line: 5, Col: 5): Unexpected value 'steps'"
1743+ ],
1744+ "jobs": []
1745+ },
1746+ "runs_on_bad.yml": {
1747+ "errors": [
1748+ "runs_on_bad.yml (Line: 4, Col: 15): A sequence was not expected",
1749+ "runs_on_bad.yml (Line: 10, Col: 14): Unexpected value ''"
1750+ ],
1751+ "jobs": []
1752+ },
1753+ "runs_on_converter_labels.yml": {
1754+ "errors": [
1755+ "runs_on_converter_labels.yml (Line: 4, Col: 24): A sequence was not expected"
1756+ ],
1757+ "jobs": []
1758+ },
1759+ "runs_on_forms.yml": {
1760+ "errors": [],
1761+ "jobs": [
1762+ {
1763+ "id": "a",
1764+ "name": "a",
1765+ "if": "success()",
1766+ "needs": [],
1767+ "steps": [
1768+ {
1769+ "id": "__run",
1770+ "if": "success()"
1771+ }
1772+ ]
1773+ },
1774+ {
1775+ "id": "b",
1776+ "name": "b",
1777+ "if": "success()",
1778+ "needs": [],
1779+ "steps": [
1780+ {
1781+ "id": "__run",
1782+ "if": "success()"
1783+ }
1784+ ]
1785+ },
1786+ {
1787+ "id": "c",
1788+ "name": "c",
1789+ "if": "success()",
1790+ "needs": [],
1791+ "steps": [
1792+ {
1793+ "id": "__run",
1794+ "if": "success()"
1795+ }
1796+ ]
1797+ }
1798+ ],
1799+ "events": {
1800+ "push": {}
1801+ }
1802+ },
1803+ "runs_on_group_bad.yml": {
1804+ "errors": [
1805+ "runs_on_group_bad.yml (Line: 4, Col: 22): Invalid runs-on group name 'foo/bar/baz. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'",
1806+ "runs_on_group_bad.yml (Line: 7, Col: 22): Invalid runs-on group name 'org/'.",
1807+ "runs_on_group_bad.yml (Line: 10, Col: 22): Invalid runs-on group name 'bogus/x. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'"
1808+ ],
1809+ "jobs": [
1810+ {
1811+ "id": "a",
1812+ "name": "a",
1813+ "if": "success()",
1814+ "needs": [],
1815+ "steps": [
1816+ {
1817+ "id": "__run",
1818+ "if": "success()"
1819+ }
1820+ ]
1821+ },
1822+ {
1823+ "id": "b",
1824+ "name": "b",
1825+ "if": "success()",
1826+ "needs": [],
1827+ "steps": [
1828+ {
1829+ "id": "__run",
1830+ "if": "success()"
1831+ }
1832+ ]
1833+ },
1834+ {
1835+ "id": "c",
1836+ "name": "c",
1837+ "if": "success()",
1838+ "needs": [],
1839+ "steps": [
1840+ {
1841+ "id": "__run",
1842+ "if": "success()"
1843+ }
1844+ ]
1845+ },
1846+ {
1847+ "id": "d",
1848+ "name": "d",
1849+ "if": "success()",
1850+ "needs": [],
1851+ "steps": [
1852+ {
1853+ "id": "__run",
1854+ "if": "success()"
1855+ }
1856+ ]
1857+ }
1858+ ],
1859+ "events": {
1860+ "push": {}
1861+ }
1862+ },
1863+ "scalar_doc.yml": {
1864+ "errors": [
1865+ "scalar_doc.yml (Line: 1, Col: 1): Unexpected value 'hello'"
1866+ ],
1867+ "jobs": []
1868+ },
1869+ "seq_doc.yml": {
1870+ "errors": [
1871+ "seq_doc.yml (Line: 1, Col: 1): A sequence was not expected"
1872+ ],
1873+ "jobs": []
1874+ },
1875+ "services_bad.yml": {
1876+ "errors": [
1877+ "services_bad.yml (Line: 5, Col: 30): Unexpected value 'bogus'"
1878+ ],
1879+ "jobs": []
1880+ },
1881+ "services_not_map.yml": {
1882+ "errors": [
1883+ "services_not_map.yml (Line: 5, Col: 15): A sequence was not expected"
1884+ ],
1885+ "jobs": []
1886+ },
1887+ "services_ok.yml": {
1888+ "errors": [],
1889+ "jobs": [
1890+ {
1891+ "id": "a",
1892+ "name": "a",
1893+ "if": "success()",
1894+ "needs": [],
1895+ "steps": [
1896+ {
1897+ "id": "__run",
1898+ "if": "success()"
1899+ }
1900+ ]
1901+ }
1902+ ],
1903+ "events": {
1904+ "push": {}
1905+ }
1906+ },
1907+ "snapshot.yml": {
1908+ "errors": [],
1909+ "jobs": [
1910+ {
1911+ "id": "a",
1912+ "name": "a",
1913+ "if": "success()",
1914+ "needs": [],
1915+ "steps": [
1916+ {
1917+ "id": "__run",
1918+ "if": "success()"
1919+ }
1920+ ]
1921+ }
1922+ ],
1923+ "events": {
1924+ "push": {}
1925+ }
1926+ },
1927+ "step_both.yml": {
1928+ "errors": [
1929+ "step_both.yml (Line: 7, Col: 9): Unexpected value 'uses'"
1930+ ],
1931+ "jobs": []
1932+ },
1933+ "step_coe_bad.yml": {
1934+ "errors": [
1935+ "step_coe_bad.yml (Line: 5, Col: 41): Unexpected value 'yes'"
1936+ ],
1937+ "jobs": []
1938+ },
1939+ "step_coe_expr.yml": {
1940+ "errors": [],
1941+ "jobs": [
1942+ {
1943+ "id": "a",
1944+ "name": "a",
1945+ "if": "success()",
1946+ "needs": [],
1947+ "steps": [
1948+ {
1949+ "id": "__run",
1950+ "if": "success()"
1951+ }
1952+ ]
1953+ }
1954+ ],
1955+ "events": {
1956+ "push": {}
1957+ }
1958+ },
1959+ "step_continue_on_error_num.yml": {
1960+ "errors": [
1961+ "step_continue_on_error_num.yml (Line: 5, Col: 41): Unexpected value '1'"
1962+ ],
1963+ "jobs": []
1964+ },
1965+ "step_empty_uses.yml": {
1966+ "errors": [
1967+ "step_empty_uses.yml (Line: 6, Col: 15): Unexpected value ''"
1968+ ],
1969+ "jobs": []
1970+ },
1971+ "step_env_nonmap.yml": {
1972+ "errors": [
1973+ "step_env_nonmap.yml (Line: 5, Col: 27): Unexpected value 'x'"
1974+ ],
1975+ "jobs": []
1976+ },
1977+ "step_id_100.yml": {
1978+ "errors": [
1979+ "step_id_100.yml (Line: 5, Col: 141): The identifier 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters."
1980+ ],
1981+ "jobs": [
1982+ {
1983+ "id": "a",
1984+ "name": "a",
1985+ "if": "success()",
1986+ "needs": [],
1987+ "steps": [
1988+ {
1989+ "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1990+ "if": "success()"
1991+ },
1992+ {
1993+ "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1994+ "if": "success()"
1995+ }
1996+ ]
1997+ }
1998+ ],
1999+ "events": {
2000+ "push": {}
2001+ }
2002+ },
2003+ "step_ids_only_bad.yml": {
2004+ "errors": [
2005+ "step_ids_only_bad.yml (Line: 5, Col: 26): The identifier '1x' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters.",
2006+ "step_ids_only_bad.yml (Line: 5, Col: 60): The identifier 's' may not be used more than once within the same scope.",
2007+ "step_ids_only_bad.yml (Line: 5, Col: 77): The identifier '__x' is invalid. IDs starting with '__' are reserved.",
2008+ "step_ids_only_bad.yml (Line: 5, Col: 113): The identifier 'a b' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters.",
2009+ "step_ids_only_bad.yml (Line: 5, Col: 134): The identifier 'x.y' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters."
2010+ ],
2011+ "jobs": [
2012+ {
2013+ "id": "a",
2014+ "name": "a",
2015+ "if": "success()",
2016+ "needs": [],
2017+ "steps": [
2018+ {
2019+ "id": "1x",
2020+ "if": "success()"
2021+ },
2022+ {
2023+ "id": "s",
2024+ "if": "success()"
2025+ },
2026+ {
2027+ "id": "s",
2028+ "if": "success()"
2029+ },
2030+ {
2031+ "id": "__x",
2032+ "if": "success()"
2033+ },
2034+ {
2035+ "id": "S",
2036+ "if": "success()"
2037+ },
2038+ {
2039+ "id": "a b",
2040+ "if": "success()"
2041+ },
2042+ {
2043+ "id": "x.y",
2044+ "if": "success()"
2045+ }
2046+ ]
2047+ }
2048+ ],
2049+ "events": {
2050+ "push": {}
2051+ }
2052+ },
2053+ "step_neither.yml": {
2054+ "errors": [
2055+ "step_neither.yml (Line: 6, Col: 9): There's not enough info to determine what you meant. Add one of these properties: run, shell, uses, with, working-directory"
2056+ ],
2057+ "jobs": []
2058+ },
2059+ "step_run_names_collide.yml": {
2060+ "errors": [
2061+ "step_run_names_collide.yml (Line: 5, Col: 26): The identifier '__run' is invalid. IDs starting with '__' are reserved."
2062+ ],
2063+ "jobs": [
2064+ {
2065+ "id": "a",
2066+ "name": "a",
2067+ "if": "success()",
2068+ "needs": [],
2069+ "steps": [
2070+ {
2071+ "id": "__run",
2072+ "if": "success()"
2073+ },
2074+ {
2075+ "id": "_run",
2076+ "if": "success()"
2077+ },
2078+ {
2079+ "id": "__run",
2080+ "if": "success()"
2081+ },
2082+ {
2083+ "id": "__run_2",
2084+ "if": "success()"
2085+ },
2086+ {
2087+ "id": "___x__y",
2088+ "if": "success()"
2089+ }
2090+ ]
2091+ }
2092+ ],
2093+ "events": {
2094+ "push": {}
2095+ }
2096+ },
2097+ "step_shell_empty.yml": {
2098+ "errors": [
2099+ "step_shell_empty.yml (Line: 5, Col: 29): Unexpected value ''"
2100+ ],
2101+ "jobs": []
2102+ },
2103+ "step_wd_empty.yml": {
2104+ "errors": [],
2105+ "jobs": [
2106+ {
2107+ "id": "a",
2108+ "name": "a",
2109+ "if": "success()",
2110+ "needs": [],
2111+ "steps": [
2112+ {
2113+ "id": "__run",
2114+ "if": "success()"
2115+ }
2116+ ]
2117+ }
2118+ ],
2119+ "events": {
2120+ "push": {}
2121+ }
2122+ },
2123+ "step_with_nonstring.yml": {
2124+ "errors": [
2125+ "step_with_nonstring.yml (Line: 5, Col: 53): A sequence was not expected"
2126+ ],
2127+ "jobs": []
2128+ },
2129+ "steps_empty_list.yml": {
2130+ "errors": [],
2131+ "jobs": [
2132+ {
2133+ "id": "a",
2134+ "name": "a",
2135+ "if": "success()",
2136+ "needs": [],
2137+ "steps": []
2138+ }
2139+ ],
2140+ "events": {
2141+ "push": {}
2142+ }
2143+ },
2144+ "steps_not_seq.yml": {
2145+ "errors": [
2146+ "steps_not_seq.yml (Line: 5, Col: 12): Unexpected value 'a'"
2147+ ],
2148+ "jobs": []
2149+ },
2150+ "strategy_bad_key.yml": {
2151+ "errors": [
2152+ "strategy_bad_key.yml (Line: 6, Col: 7): Unexpected value 'bogus'"
2153+ ],
2154+ "jobs": []
2155+ },
2156+ "strategy_ctx.yml": {
2157+ "errors": [
2158+ "strategy_ctx.yml (Line: 7, Col: 12): Unrecognized named-value: 'steps'",
2159+ "strategy_ctx.yml (Line: 7, Col: 12): Unexpected value '${{ steps.x.outputs.y }}'"
2160+ ],
2161+ "jobs": []
2162+ },
2163+ "strategy_matrix_bad.yml": {
2164+ "errors": [
2165+ "strategy_matrix_bad.yml (Line: 7, Col: 13): Unexpected value 'foo'"
2166+ ],
2167+ "jobs": []
2168+ },
2169+ "strategy_matrix_expr.yml": {
2170+ "errors": [],
2171+ "jobs": [
2172+ {
2173+ "id": "a",
2174+ "name": "a",
2175+ "if": "success()",
2176+ "needs": [],
2177+ "steps": [
2178+ {
2179+ "id": "__run",
2180+ "if": "success()"
2181+ }
2182+ ]
2183+ }
2184+ ],
2185+ "events": {
2186+ "push": {}
2187+ }
2188+ },
2189+ "strategy_matrix_ok.yml": {
2190+ "errors": [],
2191+ "jobs": [
2192+ {
2193+ "id": "a",
2194+ "name": "a",
2195+ "if": "success()",
2196+ "needs": [],
2197+ "steps": [
2198+ {
2199+ "id": "__run",
2200+ "if": "success()"
2201+ }
2202+ ]
2203+ }
2204+ ],
2205+ "events": {
2206+ "push": {}
2207+ }
2208+ },
2209+ "timeout_expr.yml": {
2210+ "errors": [],
2211+ "jobs": [
2212+ {
2213+ "id": "a",
2214+ "name": "a",
2215+ "if": "success()",
2216+ "needs": [],
2217+ "steps": [
2218+ {
2219+ "id": "__run",
2220+ "if": "success()"
2221+ }
2222+ ]
2223+ }
2224+ ],
2225+ "events": {
2226+ "push": {}
2227+ }
2228+ },
2229+ "timeout_str.yml": {
2230+ "errors": [
2231+ "timeout_str.yml (Line: 5, Col: 22): Unexpected value 'abc'"
2232+ ],
2233+ "jobs": []
2234+ },
2235+ "two_docs.yml": {
2236+ "errors": [
2237+ "two_docs.yml: Source contains multiple documents; please use YAML.parseAllDocuments() at line 3, column 1:\n\njobs: {}\n---\n^\n"
2238+ ]
2239+ },
2240+ "unicode.yml": {
2241+ "errors": [
2242+ "unicode.yml (Line: 6, Col: 19): Unrecognized named-value: 'foo'"
2243+ ],
2244+ "jobs": []
2245+ },
2246+ "unknown_job_key.yml": {
2247+ "errors": [
2248+ "unknown_job_key.yml (Line: 5, Col: 5): Unexpected value 'bogus'"
2249+ ],
2250+ "jobs": []
2251+ },
2252+ "unknown_top_key.yml": {
2253+ "errors": [
2254+ "unknown_top_key.yml (Line: 2, Col: 1): Unexpected value 'bogus'"
2255+ ],
2256+ "jobs": []
2257+ },
2258+ "uses_ids.yml": {
2259+ "errors": [
2260+ "uses_ids.yml (Line: 5, Col: 240): The identifier '__run' is invalid. IDs starting with '__' are reserved."
2261+ ],
2262+ "jobs": [
2263+ {
2264+ "id": "a",
2265+ "name": "a",
2266+ "if": "success()",
2267+ "needs": [],
2268+ "steps": [
2269+ {
2270+ "id": "__actions_checkout",
2271+ "if": "success()"
2272+ },
2273+ {
2274+ "id": "__self",
2275+ "if": "success()"
2276+ },
2277+ {
2278+ "id": "__alpine_3",
2279+ "if": "success()"
2280+ },
2281+ {
2282+ "id": "__run",
2283+ "if": "success()"
2284+ },
2285+ {
2286+ "id": "__run_2",
2287+ "if": "success()"
2288+ },
2289+ {
2290+ "id": "__actions_checkout_2",
2291+ "if": "success()"
2292+ },
2293+ {
2294+ "id": "__run_3",
2295+ "if": "success()"
2296+ },
2297+ {
2298+ "id": "__a_b",
2299+ "if": "success()"
2300+ },
2301+ {
2302+ "id": "__self_2",
2303+ "if": "success()"
2304+ },
2305+ {
2306+ "id": "__run_4",
2307+ "if": "success()"
2308+ },
2309+ {
2310+ "id": "__a_b_2",
2311+ "if": "success()"
2312+ },
2313+ {
2314+ "id": "__self_3",
2315+ "if": "success()"
2316+ },
2317+ {
2318+ "id": "__run",
2319+ "if": "success()"
2320+ }
2321+ ]
2322+ }
2323+ ],
2324+ "events": {
2325+ "push": {}
2326+ }
2327+ },
2328+ "valid_full.yml": {
2329+ "errors": [],
2330+ "jobs": [
2331+ {
2332+ "id": "build",
2333+ "name": "${{ format('Build {0}', matrix.os) }}",
2334+ "if": "success()",
2335+ "needs": [],
2336+ "steps": [
2337+ {
2338+ "id": "__actions_checkout",
2339+ "if": "success()"
2340+ },
2341+ {
2342+ "id": "s",
2343+ "if": "success() && (github.ref == 'refs/heads/main')"
2344+ },
2345+ {
2346+ "id": "__run",
2347+ "if": "always()"
2348+ }
2349+ ]
2350+ },
2351+ {
2352+ "id": "test",
2353+ "name": "test",
2354+ "if": "success() && (needs.build.result == 'success')",
2355+ "needs": [
2356+ "build"
2357+ ],
2358+ "steps": [
2359+ {
2360+ "id": "__run",
2361+ "if": "success()"
2362+ }
2363+ ]
2364+ },
2365+ {
2366+ "id": "deploy",
2367+ "name": "deploy",
2368+ "if": "success()",
2369+ "needs": [
2370+ "build",
2371+ "test"
2372+ ],
2373+ "steps": []
2374+ }
2375+ ],
2376+ "events": {
2377+ "push": {
2378+ "branches": [
2379+ "main",
2380+ "release/**"
2381+ ],
2382+ "tags": [
2383+ "v*"
2384+ ],
2385+ "paths": [
2386+ "src/**"
2387+ ]
2388+ },
2389+ "pull_request": {
2390+ "branches-ignore": [
2391+ "wip"
2392+ ]
2393+ },
2394+ "schedule": [
2395+ {
2396+ "cron": "30 5,17 * * 1-5"
2397+ }
2398+ ],
2399+ "workflow_dispatch": {
2400+ "inputs": {
2401+ "level": {
2402+ "type": "choice",
2403+ "options": [
2404+ "a",
2405+ "b"
2406+ ],
2407+ "default": "a"
2408+ }
2409+ }
2410+ }
2411+ }
2412+ },
2413+ "valid_minimal.yml": {
2414+ "errors": [],
2415+ "jobs": [
2416+ {
2417+ "id": "a",
2418+ "name": "a",
2419+ "if": "success()",
2420+ "needs": [],
2421+ "steps": [
2422+ {
2423+ "id": "__run",
2424+ "if": "success()"
2425+ }
2426+ ]
2427+ }
2428+ ],
2429+ "events": {
2430+ "push": {}
2431+ }
2432+ },
2433+ "workflow_defaults_bad.yml": {
2434+ "errors": [
2435+ "workflow_defaults_bad.yml (Line: 4, Col: 5): Unexpected value 'bogus'"
2436+ ],
2437+ "jobs": []
2438+ },
2439+ "yes_no.yml": {
2440+ "errors": [
2441+ "yes_no.yml (Line: 5, Col: 24): Unexpected value 'yes'"
2442+ ],
2443+ "jobs": []
2444+ }
2445+}
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
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}]
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
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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: ''}]
A workflow/testdata/convert/bad_yaml_flow.yml +1 -0
@@ -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
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}]
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: ~}]
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}]
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}]
A workflow/testdata/convert/comment_doc.yml +1 -0
@@ -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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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 }}'}]
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() }}'}]
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) }}'}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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 ${{ }}
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}]
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}]
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
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}]
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}]
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}]
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 }}' }}"}]
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 + }}
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
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) }}'"}]
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)}]
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}]
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}]
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'"}]
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}]
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}]
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}]
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}]
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}]
A workflow/testdata/convert/job_not_map.yml +3 -0
@@ -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}]
A workflow/testdata/convert/jobs_empty.yml +2 -0
@@ -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]
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
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 }}
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}]
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}]
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}]
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}]
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}]
A workflow/testdata/convert/no_jobs.yml +1 -0
@@ -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}]
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}]
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
A workflow/testdata/convert/null_doc.yml +1 -0
@@ -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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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}]
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 }}"
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
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 }}
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
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: []
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}]
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}]
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}]
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}]
A workflow/testdata/convert/scalar_doc.yml +1 -0
@@ -0,0 +1,1 @@
1+hello
A workflow/testdata/convert/seq_doc.yml +1 -0
@@ -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}]
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}]
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}]
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}]
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
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}]
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 }}'}]
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}]
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: ''
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}]
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}]
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}]
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
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}]
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: ''}]
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: ''}]
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]}}]
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: []
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
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}]
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}]
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}]
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}]
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}]
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}]
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}]
A workflow/testdata/convert/two_docs.yml +4 -0
@@ -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 }}'}]
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}]
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}]
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}]
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
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}]
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}]
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}]

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