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