Merge pull request 'feat(workflow): workflow schema, model, filters and matrix' (#2) from feat/workflow into main
214 files · +10871 -1
this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.
M THIRD_PARTY.md +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Third-party material | |
| 2 | 2 | ||
| 3 | - | Test fixtures and schemas under `testdata/` and `template/schema/` are copied or ported from the projects below. Each copied file names its source in its first lines or in a `SOURCE` file next to it. | |
| 3 | + | Test fixtures and schemas under `testdata/`, `workflow/testdata/` and `template/schema/` are copied or ported from the projects below. Each copied file names its source in its first lines or in a `SOURCE` file next to it. | |
| 4 | 4 | ||
| 5 | 5 | ## actions/languageservices | |
| 6 | 6 | ||
A gen/.gitignore +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | node_modules/ | |
A gen/json-hook.mjs +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | export async function load(url, context, nextLoad) { | |
| 4 | + | if (url.endsWith(".json") && !context.importAttributes?.type) { | |
| 5 | + | return nextLoad(url, { ...context, importAttributes: { ...context.importAttributes, type: "json" } }); | |
| 6 | + | } | |
| 7 | + | return nextLoad(url, context); | |
| 8 | + | } | |
A gen/package-lock.json +65 -0 generated file: collapsed
A gen/package.json +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "ermite-actions-gen", | |
| 3 | + | "private": true, | |
| 4 | + | "description": "Pinned reference implementations that the generators in expr/gen, template/gen and workflow/gen run. Not shipped.", | |
| 5 | + | "license": "MIT", | |
| 6 | + | "type": "module", | |
| 7 | + | "dependencies": { | |
| 8 | + | "@actions/expressions": "0.3.61", | |
| 9 | + | "@actions/workflow-parser": "0.3.61", | |
| 10 | + | "cronstrue": "2.61.0", | |
| 11 | + | "yaml": "2.8.3" | |
| 12 | + | } | |
| 13 | + | } | |
A gen/regen.sh +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | # SPDX-License-Identifier: MIT | |
| 3 | + | # | |
| 4 | + | # Regenerates every file that is the answer of a reference implementation: | |
| 5 | + | # workflow/testdata/convert.json (@actions/workflow-parser) | |
| 6 | + | # template/testdata/scalars.json (yaml) | |
| 7 | + | # from the exact versions pinned in gen/package-lock.json, installed with | |
| 8 | + | # `npm ci`. Run from anywhere; `git diff` afterwards must be empty unless the | |
| 9 | + | # pins or the corpus changed. (expr/gen has its own generators, run the same | |
| 10 | + | # way with `--import ./gen/register.mjs` and the dist path of | |
| 11 | + | # gen/node_modules/@actions/expressions.) | |
| 12 | + | set -eu | |
| 13 | + | root=$(cd "$(dirname "$0")/.." && pwd) | |
| 14 | + | cd "$root/gen" | |
| 15 | + | npm ci --ignore-scripts --no-audit --no-fund | |
| 16 | + | cd "$root" | |
| 17 | + | node --import ./gen/register.mjs workflow/gen/convert.mjs > workflow/testdata/convert.json | |
| 18 | + | node --import ./gen/register.mjs template/gen/scalars.mjs > template/testdata/scalars.json | |
A gen/register.mjs +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | // | |
| 3 | + | // Node 22+ refuses to import a .json file without `with { type: "json" }`, | |
| 4 | + | // which @actions/workflow-parser 0.3.61 does for workflow-v1.0.min.json. This | |
| 5 | + | // loader hook adds the attribute so that the package can run unmodified: | |
| 6 | + | // | |
| 7 | + | // node --import ./gen/register.mjs workflow/gen/convert.mjs | |
| 8 | + | import { register } from "node:module"; | |
| 9 | + | ||
| 10 | + | register("./json-hook.mjs", import.meta.url); | |
M go.mod +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | 1 | module bnema.dev/ermite-actions | |
| 2 | 2 | ||
| 3 | 3 | go 1.27.1 | |
| 4 | + | ||
| 5 | + | require go.yaml.in/yaml/v3 v3.0.5 | |
A go.sum +2 -0 generated file: collapsed
A template/PORTING.md +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | # Porting notes for package template | |
| 2 | + | ||
| 3 | + | The package is a port of the template layer of `@actions/workflow-parser` | |
| 4 | + | 0.3.61 (MIT, GitHub, actions/languageservices): `templates/` (tokens, reader, | |
| 5 | + | schema) and `workflows/yaml-object-reader`. The schema | |
| 6 | + | `schema/workflow-v1.0.min.json` is copied unmodified. The reference's own | |
| 7 | + | output is the specification: `../workflow/testdata/convert.json` holds what it | |
| 8 | + | answers for 153 workflow files and `workflow.TestConvertAgainstReference` | |
| 9 | + | replays them (messages, line and column included). | |
| 10 | + | ||
| 11 | + | The C# runner (`ObjectTemplating`) is what runs on github.com. It was not | |
| 12 | + | available. Nothing here has been checked against it. | |
| 13 | + | ||
| 14 | + | ## Reference versions and regeneration | |
| 15 | + | ||
| 16 | + | The answers in `../workflow/testdata/convert.json` and `testdata/scalars.json` | |
| 17 | + | come from the versions pinned in `../gen/package.json` and | |
| 18 | + | `../gen/package-lock.json`: `@actions/workflow-parser` 0.3.61, | |
| 19 | + | `@actions/expressions` 0.3.61, `yaml` 2.8.3, `cronstrue` 2.61.0 (only imported | |
| 20 | + | by the parser's cron description helper, which is not used here). Run | |
| 21 | + | `../gen/regen.sh`: it does an `npm ci` and rewrites both files; `git diff` | |
| 22 | + | must show nothing. The loader hook `../gen/register.mjs` adds the | |
| 23 | + | `with { type: "json" }` import attribute that Node 22+ requires and the | |
| 24 | + | parser omits, so the packages run unmodified. The last check, on a clean | |
| 25 | + | `npm ci`, gave identical files. | |
| 26 | + | ||
| 27 | + | ## Differences from the reference | |
| 28 | + | ||
| 29 | + | - **YAML parser.** The reference uses the JavaScript `yaml` library (YAML 1.2, | |
| 30 | + | core schema); this port uses `go.yaml.in/yaml/v3` (a YAML 1.1/1.2 hybrid) and | |
| 31 | + | re-resolves plain scalars itself (`yaml.go`, `resolveScalar`) with the 1.2 | |
| 32 | + | core schema: `yes`/`no`/`on`/`off` are strings, `0o17` and `0x1F` are | |
| 33 | + | numbers, `.inf` and `.nan` are numbers, `0b11`, `1_000` and sexagesimal | |
| 34 | + | numbers are strings. Quoted, block and `!!str` scalars are strings. | |
| 35 | + | `TestPlainScalarsAgainstReference` checks 64 plain scalars (octal `017`, | |
| 36 | + | `00`, `1.`, `.inf`, `-.nan`, `0b11`, `1_000`, dates...) against the | |
| 37 | + | JavaScript library's answers (`testdata/scalars.json`). Not checked: tags | |
| 38 | + | other than `!!str`, `!!binary`, merge keys `<<` (kept as an ordinary key). | |
| 39 | + | - **YAML syntax errors** have yaml.v3's wording and only a line number, and are | |
| 40 | + | reported as `file.yml: message at line N`. The reference's are | |
| 41 | + | `file.yml: <message> at line N, column M:\n\n<excerpt>`. Only the shape | |
| 42 | + | (one error, prefixed by the file name) is tested. | |
| 43 | + | - **Multiple documents** are an error, with the reference's message text. | |
| 44 | + | - **Anchors and aliases** are expanded like the reference does. Added: a limit | |
| 45 | + | of `MaxNodes` (200 000) nodes after expansion, because the reference lets a | |
| 46 | + | few kilobytes of nested aliases allocate gigabytes; a circular alias is an | |
| 47 | + | error (the reference silently drops it). | |
| 48 | + | - **Positions of expressions in multi-line scalars.** The reference computes a | |
| 49 | + | precise range for the expression inside a multi-line string; this port | |
| 50 | + | reports the position of the scalar, which is what the reference's error | |
| 51 | + | messages use too (checked on `multiline_*`, `expr_multi`). | |
| 52 | + | `BasicExpression.Source` is the value of the scalar, not the raw source text | |
| 53 | + | (so a block scalar's indentation is not in it). | |
| 54 | + | - **File size.** A document over `MaxFileBytes` (1 MiB) is refused before any | |
| 55 | + | parsing, with `file.yml: The file is too large: N bytes, the limit is M | |
| 56 | + | bytes` (our wording; the reference has no limit, GitHub has its own). | |
| 57 | + | TODO(verify with recording). | |
| 58 | + | - **Error limit.** `Context` keeps at most `MaxErrors` (1 000) errors. | |
| 59 | + | - **Limits on evaluation.** `MaxEvalDepth` (100) nested tokens; the result | |
| 60 | + | budget of package expr applies per expression. | |
| 61 | + | - **No `featureFlags`.** Background steps (`background`, `wait`, `wait-all`, | |
| 62 | + | `cancel`, `parallel`) are always disabled, which is the reference's default. | |
| 63 | + | - **Duplicate keys** are compared case-insensitively with ASCII/Unicode upper | |
| 64 | + | casing (`strings.ToUpper`), the reference uses `toUpperCase`. | |
| 65 | + | - Mapping keys that are collections (`? [a] : b`) are reported as | |
| 66 | + | `Unexpected value ''`; the reference reads `undefined` there. | |
| 67 | + | ||
| 68 | + | ## To check against the C# runner / recordings | |
| 69 | + | ||
| 70 | + | - [ ] A string with `${{` inside a non-expression place (`name:` of a | |
| 71 | + | reusable workflow...) and the `Exactly 0 parameter(s)...` wording. | |
| 72 | + | - [ ] Which contexts and functions each key may use: taken from the schema, | |
| 73 | + | which is the language service's, not necessarily the server's. | |
| 74 | + | - [ ] `EvaluateBool`/`EvaluateNumber` accept the strings `"true"`/`"12"`. The | |
| 75 | + | C# `TemplateEvaluator` converts scalars the same way (assumed). | |
| 76 | + | - [ ] A mapping key expression that produces a non-scalar. | |
A template/assert.go +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 7 | + | // TokenError is an error about a token, positioned at it. It is what the | |
| 8 | + | // Assert functions return. | |
| 9 | + | type TokenError struct { | |
| 10 | + | Token Token | |
| 11 | + | Message string | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func (e *TokenError) Error() string { return e.Message } | |
| 15 | + | ||
| 16 | + | func assertErr(t Token, desc string, want string) error { | |
| 17 | + | return &TokenError{Token: t, Message: fmt.Sprintf("Unexpected type '%s' encountered while reading '%s'. The type '%s' was expected.", t.Kind(), desc, want)} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // AssertString returns t as a string token or a [*TokenError] worded like the | |
| 21 | + | // reference's "Unexpected type 'X' encountered while reading 'desc'". | |
| 22 | + | func AssertString(t Token, desc string) (*String, error) { | |
| 23 | + | if s, ok := t.(*String); ok { | |
| 24 | + | return s, nil | |
| 25 | + | } | |
| 26 | + | return nil, assertErr(t, desc, KindString.String()) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // AssertBoolean returns t as a boolean token or a [*TokenError]. | |
| 30 | + | func AssertBoolean(t Token, desc string) (*Boolean, error) { | |
| 31 | + | if s, ok := t.(*Boolean); ok { | |
| 32 | + | return s, nil | |
| 33 | + | } | |
| 34 | + | return nil, assertErr(t, desc, KindBoolean.String()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // AssertNumber returns t as a number token or a [*TokenError]. | |
| 38 | + | func AssertNumber(t Token, desc string) (*Number, error) { | |
| 39 | + | if s, ok := t.(*Number); ok { | |
| 40 | + | return s, nil | |
| 41 | + | } | |
| 42 | + | return nil, assertErr(t, desc, KindNumber.String()) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // AssertSequence returns t as a sequence token or a [*TokenError]. | |
| 46 | + | func AssertSequence(t Token, desc string) (*Sequence, error) { | |
| 47 | + | if s, ok := t.(*Sequence); ok { | |
| 48 | + | return s, nil | |
| 49 | + | } | |
| 50 | + | return nil, assertErr(t, desc, KindSequence.String()) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // AssertMapping returns t as a mapping token or a [*TokenError]. | |
| 54 | + | func AssertMapping(t Token, desc string) (*Mapping, error) { | |
| 55 | + | if s, ok := t.(*Mapping); ok { | |
| 56 | + | return s, nil | |
| 57 | + | } | |
| 58 | + | return nil, assertErr(t, desc, KindMapping.String()) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // AssertScalar returns t when it is neither a sequence nor a mapping, or a | |
| 62 | + | // [*TokenError]. | |
| 63 | + | func AssertScalar(t Token, desc string) (Token, error) { | |
| 64 | + | if IsScalar(t) { | |
| 65 | + | return t, nil | |
| 66 | + | } | |
| 67 | + | return nil, assertErr(t, desc, "ScalarToken") | |
| 68 | + | } | |
A template/context.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // MaxErrors is the number of errors a [Context] keeps; later ones are | |
| 11 | + | // dropped. The reference keeps all of them, which lets a hostile file make the | |
| 12 | + | // list as long as the file. | |
| 13 | + | const MaxErrors = 1000 | |
| 14 | + | ||
| 15 | + | // Error is a problem found while reading or evaluating a template. | |
| 16 | + | type Error struct { | |
| 17 | + | // File is the name of the file the problem is in, or "". | |
| 18 | + | File string | |
| 19 | + | // Pos is where the problem is; the zero value means no position. | |
| 20 | + | Pos Pos | |
| 21 | + | // Raw is the message without the "file (Line: l, Col: c): " prefix. | |
| 22 | + | Raw string | |
| 23 | + | ||
| 24 | + | // fileOnly makes the prefix the file name alone, for errors of the YAML | |
| 25 | + | // parser, which the reference reports without a position. | |
| 26 | + | fileOnly bool | |
| 27 | + | ||
| 28 | + | cause error | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Unwrap returns the error of package expr behind an evaluation error, if any. | |
| 32 | + | func (e *Error) Unwrap() error { return e.cause } | |
| 33 | + | ||
| 34 | + | // Error returns the message with its prefix, as the reference does: | |
| 35 | + | // "file.yml (Line: 3, Col: 5): message". | |
| 36 | + | func (e *Error) Error() string { | |
| 37 | + | pos := e.Pos | |
| 38 | + | if e.fileOnly { | |
| 39 | + | pos = Pos{} | |
| 40 | + | } | |
| 41 | + | if p := errorPrefix(e.File, pos); p != "" { | |
| 42 | + | return p + ": " + e.Raw | |
| 43 | + | } | |
| 44 | + | return e.Raw | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func errorPrefix(file string, p Pos) string { | |
| 48 | + | switch { | |
| 49 | + | case file != "" && p.Line > 0: | |
| 50 | + | return fmt.Sprintf("%s (Line: %d, Col: %d)", file, p.Line, p.Col) | |
| 51 | + | case file != "": | |
| 52 | + | return file | |
| 53 | + | case p.Line > 0: | |
| 54 | + | return fmt.Sprintf("(Line: %d, Col: %d)", p.Line, p.Col) | |
| 55 | + | } | |
| 56 | + | return "" | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // message is an error whose text is one of the reference's messages, which | |
| 60 | + | // start with a capital letter. | |
| 61 | + | type message string | |
| 62 | + | ||
| 63 | + | func (m message) Error() string { return string(m) } | |
| 64 | + | ||
| 65 | + | // Context collects the errors of a read and carries its schema. | |
| 66 | + | type Context struct { | |
| 67 | + | schema *Schema | |
| 68 | + | errors []*Error | |
| 69 | + | // Dropped counts the errors beyond [MaxErrors]. | |
| 70 | + | dropped int | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // NewContext returns a context that validates against schema. | |
| 74 | + | func NewContext(schema *Schema) *Context { return &Context{schema: schema} } | |
| 75 | + | ||
| 76 | + | // Schema returns the schema the context validates against. | |
| 77 | + | func (c *Context) Schema() *Schema { return c.schema } | |
| 78 | + | ||
| 79 | + | // Errors returns the errors recorded so far. | |
| 80 | + | func (c *Context) Errors() []*Error { return append([]*Error(nil), c.errors...) } | |
| 81 | + | ||
| 82 | + | // HasErrors reports whether an error was recorded. | |
| 83 | + | func (c *Context) HasErrors() bool { return len(c.errors) > 0 } | |
| 84 | + | ||
| 85 | + | // Errorf records an error located at tok, which may be nil. | |
| 86 | + | func (c *Context) Errorf(tok Token, format string, a ...any) { | |
| 87 | + | c.ErrorAt(tok, fmt.Sprintf(format, a...)) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // ErrorAt records an error with message msg located at tok, which may be nil. | |
| 91 | + | func (c *Context) ErrorAt(tok Token, msg string) { | |
| 92 | + | e := &Error{Raw: msg} | |
| 93 | + | if tok != nil { | |
| 94 | + | e.File, e.Pos = tok.File(), tok.Pos() | |
| 95 | + | } | |
| 96 | + | c.add(e) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // ErrorInFile records an error that is about a whole file. | |
| 100 | + | func (c *Context) ErrorInFile(file, msg string) { c.add(&Error{File: file, Raw: msg}) } | |
| 101 | + | ||
| 102 | + | // ErrorAtPos records an error at an explicit position of file. | |
| 103 | + | func (c *Context) ErrorAtPos(file string, p Pos, msg string) { | |
| 104 | + | c.add(&Error{File: file, Pos: p, Raw: msg}) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (c *Context) add(e *Error) { | |
| 108 | + | if len(c.errors) >= MaxErrors { | |
| 109 | + | c.dropped++ | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | c.errors = append(c.errors, e) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Messages returns the full text of the errors. | |
| 116 | + | func (c *Context) Messages() []string { | |
| 117 | + | out := make([]string, len(c.errors)) | |
| 118 | + | for i, e := range c.errors { | |
| 119 | + | out[i] = e.Error() | |
| 120 | + | } | |
| 121 | + | return out | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Join returns the errors as one text, one per line. | |
| 125 | + | func (c *Context) Join() string { return strings.Join(c.Messages(), "\n") } | |
A template/definfo.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite-actions/expr" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // DefinitionInfo is a definition together with the contexts and functions | |
| 13 | + | // expressions at that place may use: those of the definition and of every | |
| 14 | + | // definition it was reached through. | |
| 15 | + | type DefinitionInfo struct { | |
| 16 | + | schema *Schema | |
| 17 | + | // Definition is the schema definition. | |
| 18 | + | Definition *Definition | |
| 19 | + | ||
| 20 | + | allowed []string | |
| 21 | + | named []string | |
| 22 | + | funcs []expr.FuncInfo | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func newDefinitionInfo(s *Schema, parent *DefinitionInfo, d *Definition) *DefinitionInfo { | |
| 26 | + | di := &DefinitionInfo{schema: s, Definition: d} | |
| 27 | + | if len(d.Context) > 0 { | |
| 28 | + | seen := map[string]bool{} | |
| 29 | + | if parent != nil { | |
| 30 | + | for _, c := range parent.allowed { | |
| 31 | + | di.allowed = append(di.allowed, c) | |
| 32 | + | seen[strings.ToUpper(c)] = true | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | for _, c := range d.Context { | |
| 36 | + | if u := strings.ToUpper(c); !seen[u] { | |
| 37 | + | di.allowed = append(di.allowed, c) | |
| 38 | + | seen[u] = true | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } else if parent != nil { | |
| 42 | + | di.allowed, di.named, di.funcs = parent.allowed, parent.named, parent.funcs | |
| 43 | + | return di | |
| 44 | + | } | |
| 45 | + | di.named, di.funcs = splitAllowedContext(di.allowed) | |
| 46 | + | return di | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // child returns the info of the definition called name, reached through di. | |
| 50 | + | func (di *DefinitionInfo) child(name string) *DefinitionInfo { | |
| 51 | + | return newDefinitionInfo(di.schema, di, di.schema.get(name)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // AllowedContext returns the raw entries: context names and function | |
| 55 | + | // signatures such as "hashFiles(1,255)". | |
| 56 | + | func (di *DefinitionInfo) AllowedContext() []string { return di.allowed } | |
| 57 | + | ||
| 58 | + | // NamedContexts returns the names of the contexts expressions may use. | |
| 59 | + | func (di *DefinitionInfo) NamedContexts() []string { return di.named } | |
| 60 | + | ||
| 61 | + | // Functions returns the extra functions expressions may call, with their | |
| 62 | + | // arity. | |
| 63 | + | func (di *DefinitionInfo) Functions() []expr.FuncInfo { return di.funcs } | |
| 64 | + | ||
| 65 | + | // splitAllowedContext separates "name(min,max)" entries, with max a number or | |
| 66 | + | // MAX, from plain context names. | |
| 67 | + | func splitAllowedContext(allowed []string) ([]string, []expr.FuncInfo) { | |
| 68 | + | var named []string | |
| 69 | + | var funcs []expr.FuncInfo | |
| 70 | + | for _, item := range allowed { | |
| 71 | + | if f, ok := parseFuncEntry(item); ok { | |
| 72 | + | funcs = append(funcs, f) | |
| 73 | + | } else { | |
| 74 | + | named = append(named, item) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | return named, funcs | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // parseFuncEntry matches ^([a-zA-Z0-9_]+)\(([0-9]+),([0-9]+|MAX)\)$. | |
| 81 | + | func parseFuncEntry(s string) (expr.FuncInfo, bool) { | |
| 82 | + | open := strings.IndexByte(s, '(') | |
| 83 | + | if open <= 0 || !strings.HasSuffix(s, ")") { | |
| 84 | + | return expr.FuncInfo{}, false | |
| 85 | + | } | |
| 86 | + | name := s[:open] | |
| 87 | + | for _, r := range name { | |
| 88 | + | if r != '_' && (r < '0' || r > '9') && (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') { | |
| 89 | + | return expr.FuncInfo{}, false | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | minS, maxS, ok := strings.Cut(s[open+1:len(s)-1], ",") | |
| 93 | + | if !ok || !isDigits(minS) || (!isDigits(maxS) && maxS != "MAX") { | |
| 94 | + | return expr.FuncInfo{}, false | |
| 95 | + | } | |
| 96 | + | lo, err := strconv.Atoi(minS) | |
| 97 | + | if err != nil { | |
| 98 | + | return expr.FuncInfo{}, false | |
| 99 | + | } | |
| 100 | + | hi := -1 | |
| 101 | + | if maxS != "MAX" { | |
| 102 | + | if hi, err = strconv.Atoi(maxS); err != nil { | |
| 103 | + | return expr.FuncInfo{}, false | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | return expr.FuncInfo{Name: name, MinArgs: lo, MaxArgs: hi}, true | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func isDigits(s string) bool { | |
| 110 | + | if s == "" { | |
| 111 | + | return false | |
| 112 | + | } | |
| 113 | + | for i := 0; i < len(s); i++ { | |
| 114 | + | if s[i] < '0' || s[i] > '9' { | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | return true | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Info returns the info of the schema definition called name, with the | |
| 122 | + | // contexts and functions that definition allows, or nil when there is none. | |
| 123 | + | func (s *Schema) Info(name string) *DefinitionInfo { | |
| 124 | + | d := s.definitions[name] | |
| 125 | + | if d == nil { | |
| 126 | + | return nil | |
| 127 | + | } | |
| 128 | + | return newDefinitionInfo(s, nil, d) | |
| 129 | + | } | |
A template/doc.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package template reads, validates and evaluates GitHub Actions workflow | |
| 4 | + | // templates: YAML documents whose strings may contain ${{ }} expressions. | |
| 5 | + | // | |
| 6 | + | // It is a port of the template layer of the npm package | |
| 7 | + | // @actions/workflow-parser 0.3.61 (MIT, GitHub, actions/languageservices): | |
| 8 | + | // the same tokens, the same reading rules and, where feasible, the same error | |
| 9 | + | // messages. The workflow schema (schema/workflow-v1.0.min.json) is vendored | |
| 10 | + | // unmodified and drives both validation and the contexts and functions that | |
| 11 | + | // each key may use in its expressions. | |
| 12 | + | // | |
| 13 | + | // [ReadYAML] turns a document into a tree of [Token] values, checked against | |
| 14 | + | // a [Schema]; problems are collected in a [Context] as [Error] values. A | |
| 15 | + | // string holding expressions becomes a [*BasicExpression] (a single ${{ }}) or | |
| 16 | + | // a format() call that joins the literal text and the expressions; ${{ insert }} | |
| 17 | + | // becomes an [*InsertExpression]. [Evaluate] later computes a token against | |
| 18 | + | // named contexts and functions, using package expr. | |
| 19 | + | // | |
| 20 | + | // Differences from the reference are listed in PORTING.md. | |
| 21 | + | package template | |
A template/eval.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "math" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "bnema.dev/ermite-actions/expr" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Env is what expressions are evaluated against: the values of the named | |
| 17 | + | // contexts ("github", "needs", "matrix"...) and the implementations of the | |
| 18 | + | // extra functions ("success", "hashFiles"...). Which of them an expression may | |
| 19 | + | // use was decided when it was read, from the schema. | |
| 20 | + | type Env struct { | |
| 21 | + | Contexts map[string]expr.Value | |
| 22 | + | Funcs map[string]expr.Func | |
| 23 | + | // MaxResultBytes is the output budget of one expression, see | |
| 24 | + | // [expr.EvalContext]. Zero means the default. | |
| 25 | + | MaxResultBytes int | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (e Env) evalContext() expr.EvalContext { | |
| 29 | + | return expr.EvalContext{Contexts: e.Contexts, Funcs: e.Funcs, MaxResultBytes: e.MaxResultBytes} | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // MaxEvalDepth bounds the nesting of tokens that [Evaluate] follows. | |
| 33 | + | const MaxEvalDepth = 100 | |
| 34 | + | ||
| 35 | + | func (t *BasicExpression) errorf(format string, a ...any) error { | |
| 36 | + | return &Error{File: t.file, Pos: t.pos, Raw: fmt.Sprintf(format, a...)} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // EvaluateExpression computes one expression. Errors are [*Error] values | |
| 40 | + | // positioned at the expression and wrap the underlying error of package expr. | |
| 41 | + | func EvaluateExpression(ctx context.Context, t *BasicExpression, env Env) (expr.Value, error) { | |
| 42 | + | if t.parseErr != nil { | |
| 43 | + | return nil, &Error{File: t.file, Pos: t.pos, Raw: t.parseErr.Error(), cause: t.parseErr} | |
| 44 | + | } | |
| 45 | + | if t.node == nil { | |
| 46 | + | return nil, t.errorf("An expression was expected") | |
| 47 | + | } | |
| 48 | + | v, err := expr.Evaluate(ctx, t.node, env.evalContext()) | |
| 49 | + | if err != nil { | |
| 50 | + | return nil, &Error{File: t.file, Pos: t.pos, Raw: err.Error(), cause: err} | |
| 51 | + | } | |
| 52 | + | return v, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Evaluate converts a token to a value of package expr, computing the | |
| 56 | + | // expressions in it: literals become the corresponding scalars, sequences | |
| 57 | + | // arrays and mappings dictionaries. A mapping key that is ${{ insert }} must | |
| 58 | + | // have a mapping as value, whose entries are merged in place; a key that is | |
| 59 | + | // another expression is evaluated and must produce a scalar. | |
| 60 | + | func Evaluate(ctx context.Context, t Token, env Env) (expr.Value, error) { | |
| 61 | + | return evaluate(ctx, t, env, 0) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func evaluate(ctx context.Context, t Token, env Env, depth int) (expr.Value, error) { | |
| 65 | + | if depth > MaxEvalDepth { | |
| 66 | + | return nil, &Error{File: t.File(), Pos: t.Pos(), Raw: "Maximum object depth exceeded"} | |
| 67 | + | } | |
| 68 | + | switch t := t.(type) { | |
| 69 | + | case *Null: | |
| 70 | + | return expr.Null{}, nil | |
| 71 | + | case *Boolean: | |
| 72 | + | return expr.Boolean(t.Value), nil | |
| 73 | + | case *Number: | |
| 74 | + | return expr.Number(t.Value), nil | |
| 75 | + | case *String: | |
| 76 | + | return expr.String(t.Value), nil | |
| 77 | + | case *BasicExpression: | |
| 78 | + | return EvaluateExpression(ctx, t, env) | |
| 79 | + | case *InsertExpression: | |
| 80 | + | return nil, &Error{File: t.file, Pos: t.pos, Raw: "The directive 'insert' is only allowed as a mapping key"} | |
| 81 | + | case *Sequence: | |
| 82 | + | out := expr.NewArray() | |
| 83 | + | for _, it := range t.Items { | |
| 84 | + | v, err := evaluate(ctx, it, env, depth+1) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, err | |
| 87 | + | } | |
| 88 | + | out.Append(v) | |
| 89 | + | } | |
| 90 | + | return out, nil | |
| 91 | + | case *Mapping: | |
| 92 | + | out := expr.NewDictionary() | |
| 93 | + | if err := evaluateMapping(ctx, t, env, depth, out); err != nil { | |
| 94 | + | return nil, err | |
| 95 | + | } | |
| 96 | + | return out, nil | |
| 97 | + | } | |
| 98 | + | return nil, errors.New("unknown token") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func evaluateMapping(ctx context.Context, m *Mapping, env Env, depth int, out *expr.Dictionary) error { | |
| 102 | + | for _, p := range m.Pairs { | |
| 103 | + | if _, ok := p.Key.(*InsertExpression); ok { | |
| 104 | + | v, err := evaluate(ctx, p.Value, env, depth+1) | |
| 105 | + | if err != nil { | |
| 106 | + | return err | |
| 107 | + | } | |
| 108 | + | d, ok := v.(*expr.Dictionary) | |
| 109 | + | if !ok { | |
| 110 | + | return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("Expected a mapping for ${{ insert }} but got %s", v.Kind())} | |
| 111 | + | } | |
| 112 | + | for _, kv := range d.Pairs() { | |
| 113 | + | out.Add(kv.Key, kv.Value) | |
| 114 | + | } | |
| 115 | + | continue | |
| 116 | + | } | |
| 117 | + | kv, err := evaluate(ctx, p.Key, env, depth+1) | |
| 118 | + | if err != nil { | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | if k := kv.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |
| 122 | + | return &Error{File: p.Key.File(), Pos: p.Key.Pos(), Raw: fmt.Sprintf("A mapping key must be a scalar but the expression produced %s", k)} | |
| 123 | + | } | |
| 124 | + | v, err := evaluate(ctx, p.Value, env, depth+1) | |
| 125 | + | if err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | out.Add(expr.CoerceString(kv), v) | |
| 129 | + | } | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // EvaluateString computes a token that must produce a string. Numbers, | |
| 134 | + | // booleans and null are converted as GitHub does (null is the empty string); | |
| 135 | + | // a sequence or mapping is an error. | |
| 136 | + | func EvaluateString(ctx context.Context, t Token, env Env) (string, error) { | |
| 137 | + | if s, ok := t.(*String); ok { | |
| 138 | + | return s.Value, nil | |
| 139 | + | } | |
| 140 | + | v, err := Evaluate(ctx, t, env) | |
| 141 | + | if err != nil { | |
| 142 | + | return "", err | |
| 143 | + | } | |
| 144 | + | if k := v.Kind(); k == expr.KindArray || k == expr.KindDictionary { | |
| 145 | + | return "", &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'string'. The type 'StringToken' was expected.", tokenKindOf(v))} | |
| 146 | + | } | |
| 147 | + | return expr.CoerceString(v), nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // EvaluateBool computes a token that must produce a boolean. A string | |
| 151 | + | // "true" or "false" (any case) is accepted, as is the result of an | |
| 152 | + | // expression of that kind; anything else is an error. | |
| 153 | + | func EvaluateBool(ctx context.Context, t Token, env Env) (bool, error) { | |
| 154 | + | if b, ok := t.(*Boolean); ok { | |
| 155 | + | return b.Value, nil | |
| 156 | + | } | |
| 157 | + | v, err := Evaluate(ctx, t, env) | |
| 158 | + | if err != nil { | |
| 159 | + | return false, err | |
| 160 | + | } | |
| 161 | + | switch x := v.(type) { | |
| 162 | + | case expr.Boolean: | |
| 163 | + | return bool(x), nil | |
| 164 | + | case expr.String: | |
| 165 | + | switch lowerASCII(string(x)) { | |
| 166 | + | case "true": | |
| 167 | + | return true, nil | |
| 168 | + | case "false": | |
| 169 | + | return false, nil | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | return false, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'boolean'. The type 'BooleanToken' was expected.", tokenKindOf(v))} | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // EvaluateNumber computes a token that must produce a number. Numeric strings | |
| 176 | + | // are accepted. | |
| 177 | + | func EvaluateNumber(ctx context.Context, t Token, env Env) (float64, error) { | |
| 178 | + | if n, ok := t.(*Number); ok { | |
| 179 | + | return n.Value, nil | |
| 180 | + | } | |
| 181 | + | v, err := Evaluate(ctx, t, env) | |
| 182 | + | if err != nil { | |
| 183 | + | return 0, err | |
| 184 | + | } | |
| 185 | + | switch x := v.(type) { | |
| 186 | + | case expr.Number: | |
| 187 | + | return float64(x), nil | |
| 188 | + | case expr.String: | |
| 189 | + | if f, ok := parseDecimal(string(x)); ok { | |
| 190 | + | return f, nil | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | return 0, &Error{File: t.File(), Pos: t.Pos(), Raw: fmt.Sprintf("Unexpected type '%s' encountered while reading 'number'. The type 'NumberToken' was expected.", tokenKindOf(v))} | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func tokenKindOf(v expr.Value) string { | |
| 197 | + | switch v.Kind() { | |
| 198 | + | case expr.KindNull: | |
| 199 | + | return KindNull.String() | |
| 200 | + | case expr.KindBoolean: | |
| 201 | + | return KindBoolean.String() | |
| 202 | + | case expr.KindNumber: | |
| 203 | + | return KindNumber.String() | |
| 204 | + | case expr.KindString: | |
| 205 | + | return KindString.String() | |
| 206 | + | case expr.KindArray: | |
| 207 | + | return KindSequence.String() | |
| 208 | + | } | |
| 209 | + | return KindMapping.String() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func lowerASCII(s string) string { | |
| 213 | + | b := []byte(s) | |
| 214 | + | for i, c := range b { | |
| 215 | + | if c >= 'A' && c <= 'Z' { | |
| 216 | + | b[i] = c + 32 | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | return string(b) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func parseDecimal(s string) (float64, bool) { | |
| 223 | + | f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) | |
| 224 | + | if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { | |
| 225 | + | return 0, false | |
| 226 | + | } | |
| 227 | + | return f, true | |
| 228 | + | } | |
A template/eval_test.go +246 -0
| @@ -0,0 +1,246 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func testEnv(t testing.TB) Env { | |
| 15 | + | t.Helper() | |
| 16 | + | gh, err := expr.FromJSON([]byte(`{"ref": "refs/heads/main", "n": 3, "flag": true}`)) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | m, _ := expr.FromJSON([]byte(`{"os": "linux", "extra": {"a": "1", "b": "2"}}`)) | |
| 21 | + | return Env{Contexts: map[string]expr.Value{"github": gh, "matrix": m}} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func stepOf(t testing.TB, src string) *Mapping { | |
| 25 | + | t.Helper() | |
| 26 | + | tok, errs := readWorkflow(t, head+src) | |
| 27 | + | if len(errs) > 0 { | |
| 28 | + | t.Fatalf("read: %q", errs) | |
| 29 | + | } | |
| 30 | + | return tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestEvaluateTokens(t *testing.T) { | |
| 34 | + | step := stepOf(t, " - name: n-${{ github.ref }}-${{ matrix.os }}\n run: echo\n env:\n A: ${{ github.n }}\n B: ${{ github.flag }}\n C: x\n") | |
| 35 | + | env := testEnv(t) | |
| 36 | + | ctx := context.Background() | |
| 37 | + | name, err := EvaluateString(ctx, step.Find("name"), env) | |
| 38 | + | if err != nil || name != "n-refs/heads/main-linux" { | |
| 39 | + | t.Errorf("name = %q, %v", name, err) | |
| 40 | + | } | |
| 41 | + | v, err := Evaluate(ctx, step.Find("env"), env) | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | if got := expr.ToJSON(v); got != "{\n \"A\": 3,\n \"B\": true,\n \"C\": \"x\"\n}" { | |
| 46 | + | t.Errorf("env = %s", got) | |
| 47 | + | } | |
| 48 | + | s, err := EvaluateString(ctx, step.Find("env").(*Mapping).Find("A"), env) | |
| 49 | + | if err != nil || s != "3" { | |
| 50 | + | t.Errorf("A = %q, %v", s, err) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestInsertAndExpressionKeys(t *testing.T) { | |
| 55 | + | tok, errs := readWorkflow(t, head+" - run: x\n env:\n ${{ insert }}: ${{ matrix.extra }}\n ${{ github.ref }}: v\n K: w\n") | |
| 56 | + | if len(errs) > 0 { | |
| 57 | + | t.Fatal(errs) | |
| 58 | + | } | |
| 59 | + | env := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("env") | |
| 60 | + | v, err := Evaluate(context.Background(), env, testEnv(t)) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | if got := strings.ReplaceAll(strings.ReplaceAll(expr.ToJSON(v), "\n", ""), " ", ""); got != `{"a": "1","b": "2","refs/heads/main": "v","K": "w"}` { | |
| 65 | + | t.Errorf("env = %s", got) | |
| 66 | + | } | |
| 67 | + | // insert needs a mapping. | |
| 68 | + | bad := NewMapping(Origin{}) | |
| 69 | + | bad.Add(&InsertExpression{}, NewString(Origin{}, "x")) | |
| 70 | + | if _, err := Evaluate(context.Background(), bad, testEnv(t)); err == nil || !strings.Contains(err.Error(), "insert") { | |
| 71 | + | t.Errorf("err = %v", err) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestEvaluateTypedHelpers(t *testing.T) { | |
| 76 | + | ctx := context.Background() | |
| 77 | + | env := testEnv(t) | |
| 78 | + | ex := func(s string) *BasicExpression { | |
| 79 | + | return NewExpression(Origin{File: "f.yml", Pos: Pos{4, 2}}, s, mustInfo(t, "step-env")) | |
| 80 | + | } | |
| 81 | + | if b, err := EvaluateBool(ctx, ex("github.flag"), env); err != nil || !b { | |
| 82 | + | t.Errorf("bool = %v, %v", b, err) | |
| 83 | + | } | |
| 84 | + | if b, err := EvaluateBool(ctx, ex("'FALSE'"), env); err != nil || b { | |
| 85 | + | t.Errorf("bool string = %v, %v", b, err) | |
| 86 | + | } | |
| 87 | + | if _, err := EvaluateBool(ctx, ex("github.n"), env); err == nil || !strings.Contains(err.Error(), "f.yml (Line: 4, Col: 2): Unexpected type 'NumberToken'") { | |
| 88 | + | t.Errorf("bool number: %v", err) | |
| 89 | + | } | |
| 90 | + | if n, err := EvaluateNumber(ctx, ex("github.n"), env); err != nil || n != 3 { | |
| 91 | + | t.Errorf("number = %v, %v", n, err) | |
| 92 | + | } | |
| 93 | + | if n, err := EvaluateNumber(ctx, ex("'12'"), env); err != nil || n != 12 { | |
| 94 | + | t.Errorf("number string = %v, %v", n, err) | |
| 95 | + | } | |
| 96 | + | if _, err := EvaluateNumber(ctx, ex("'x'"), env); err == nil { | |
| 97 | + | t.Error("number x: no error") | |
| 98 | + | } | |
| 99 | + | if _, err := EvaluateString(ctx, ex("matrix.extra"), env); err == nil || !strings.Contains(err.Error(), "MappingToken") { | |
| 100 | + | t.Errorf("string of mapping: %v", err) | |
| 101 | + | } | |
| 102 | + | if s, err := EvaluateString(ctx, ex("null"), env); err != nil || s != "" { | |
| 103 | + | t.Errorf("string of null = %q, %v", s, err) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func mustInfo(t testing.TB, name string) *DefinitionInfo { | |
| 108 | + | t.Helper() | |
| 109 | + | s, err := WorkflowSchema() | |
| 110 | + | if err != nil { | |
| 111 | + | t.Fatal(err) | |
| 112 | + | } | |
| 113 | + | return s.Info(name) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestExpressionErrors(t *testing.T) { | |
| 117 | + | ctx := context.Background() | |
| 118 | + | bad := NewExpression(Origin{File: "f.yml", Pos: Pos{1, 1}}, "github.", mustInfo(t, "step-env")) | |
| 119 | + | if bad.Err() == nil { | |
| 120 | + | t.Fatal("parse error expected") | |
| 121 | + | } | |
| 122 | + | _, err := Evaluate(ctx, bad, testEnv(t)) | |
| 123 | + | var te *Error | |
| 124 | + | if !errors.As(err, &te) || !strings.HasPrefix(err.Error(), "f.yml (Line: 1, Col: 1): ") { | |
| 125 | + | t.Errorf("err = %v", err) | |
| 126 | + | } | |
| 127 | + | // Not allowed context: secrets in job-if. | |
| 128 | + | e := NewExpression(Origin{}, "secrets.x", mustInfo(t, "job-if")) | |
| 129 | + | if e.Err() == nil || !strings.Contains(e.Err().Error(), "Unrecognized named-value: 'secrets'") { | |
| 130 | + | t.Errorf("err = %v", e.Err()) | |
| 131 | + | } | |
| 132 | + | // Evaluation errors wrap the expr error. | |
| 133 | + | ev := NewExpression(Origin{}, "fromJSON('x')", mustInfo(t, "step-env")) | |
| 134 | + | _, err = Evaluate(ctx, ev, testEnv(t)) | |
| 135 | + | var ee *expr.EvalError | |
| 136 | + | if !errors.As(err, &ee) { | |
| 137 | + | t.Errorf("err = %v (%T)", err, err) | |
| 138 | + | } | |
| 139 | + | // Cancellation. | |
| 140 | + | cctx, cancel := context.WithCancel(ctx) | |
| 141 | + | cancel() | |
| 142 | + | if _, err := Evaluate(cctx, NewExpression(Origin{}, "github.ref", mustInfo(t, "step-env")), testEnv(t)); err == nil { | |
| 143 | + | t.Error("cancelled: no error") | |
| 144 | + | } | |
| 145 | + | // Stray insert. | |
| 146 | + | if _, err := Evaluate(ctx, &InsertExpression{}, testEnv(t)); err == nil { | |
| 147 | + | t.Error("insert alone: no error") | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestEvaluateDepthLimit(t *testing.T) { | |
| 152 | + | var tok Token = NewString(Origin{}, "x") | |
| 153 | + | for i := 0; i < MaxEvalDepth+5; i++ { | |
| 154 | + | tok = NewSequence(Origin{}, tok) | |
| 155 | + | } | |
| 156 | + | if _, err := Evaluate(context.Background(), tok, Env{}); err == nil { | |
| 157 | + | t.Error("deep token: no error") | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestResultBudget(t *testing.T) { | |
| 162 | + | text := "'xxxxxxxxxxxxxxxx'" | |
| 163 | + | for i := 0; i < 7; i++ { | |
| 164 | + | text = "format('{0}{0}{0}{0}', " + text + ")" | |
| 165 | + | } | |
| 166 | + | e := NewExpression(Origin{}, text, mustInfo(t, "step-env")) | |
| 167 | + | if e.Err() != nil { | |
| 168 | + | t.Fatal(e.Err()) | |
| 169 | + | } | |
| 170 | + | env := testEnv(t) | |
| 171 | + | env.MaxResultBytes = 1000 | |
| 172 | + | _, err := Evaluate(context.Background(), e, env) | |
| 173 | + | var ee *expr.EvalError | |
| 174 | + | if !errors.As(err, &ee) { | |
| 175 | + | t.Fatalf("err = %v", err) | |
| 176 | + | } | |
| 177 | + | env.MaxResultBytes = -1 | |
| 178 | + | if _, err := Evaluate(context.Background(), e, env); err != nil { | |
| 179 | + | t.Errorf("no limit: %v", err) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestMatchDirective(t *testing.T) { | |
| 184 | + | tests := []struct { | |
| 185 | + | in string | |
| 186 | + | match bool | |
| 187 | + | wantErr string | |
| 188 | + | }{ | |
| 189 | + | {"insert", true, ""}, | |
| 190 | + | {"insert ", true, ""}, | |
| 191 | + | {"inserted", false, ""}, | |
| 192 | + | {"insert x", false, "Exactly 0 parameter(s) were expected following the directive 'insert'. Actual parameter count: 1"}, | |
| 193 | + | {"insert x y", false, "Actual parameter count: 1"}, | |
| 194 | + | {"github.ref", false, ""}, | |
| 195 | + | } | |
| 196 | + | for _, tc := range tests { | |
| 197 | + | got, err := matchDirective(tc.in, "insert", 0) | |
| 198 | + | if got != tc.match || (tc.wantErr == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), tc.wantErr)) { | |
| 199 | + | t.Errorf("matchDirective(%q) = %v, %v", tc.in, got, err) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestJSNumberString(t *testing.T) { | |
| 205 | + | for in, want := range map[float64]string{ | |
| 206 | + | 0: "0", 1: "1", -1: "-1", 1.5: "1.5", 100: "100", 1e21: "1e+21", 1e-7: "1e-7", 123456789012: "123456789012", 0.000001: "0.000001", | |
| 207 | + | } { | |
| 208 | + | if got := JSNumberString(in); got != want { | |
| 209 | + | t.Errorf("JSNumberString(%v) = %s, want %s", in, got, want) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestUsesContext(t *testing.T) { | |
| 215 | + | info := mustInfo(t, "step-env") | |
| 216 | + | tests := []struct { | |
| 217 | + | expr, ctx string | |
| 218 | + | want bool | |
| 219 | + | }{ | |
| 220 | + | {"matrix.os", "matrix", true}, | |
| 221 | + | {"MATRIX.os", "matrix", true}, | |
| 222 | + | {"github.ref == 'x' && matrix.os", "matrix", true}, | |
| 223 | + | {"format('{0}', fromJSON(matrix.a))", "matrix", true}, | |
| 224 | + | {"!(matrix.a)", "matrix", true}, | |
| 225 | + | {"github.event.matrix", "matrix", false}, | |
| 226 | + | {"github['matrix']", "matrix", false}, | |
| 227 | + | {"github[matrix.k]", "matrix", true}, | |
| 228 | + | {"'matrix.os'", "matrix", false}, | |
| 229 | + | {"contains(github.ref, 'matrix.')", "matrix", false}, | |
| 230 | + | {"matrix", "matrix", true}, | |
| 231 | + | {"env.matrix", "matrix", false}, | |
| 232 | + | } | |
| 233 | + | for _, tc := range tests { | |
| 234 | + | e := NewExpression(Origin{}, tc.expr, info) | |
| 235 | + | if e.Err() != nil { | |
| 236 | + | t.Errorf("%s: %v", tc.expr, e.Err()) | |
| 237 | + | continue | |
| 238 | + | } | |
| 239 | + | if got := e.UsesContext(tc.ctx); got != tc.want { | |
| 240 | + | t.Errorf("UsesContext(%q, %q) = %v, want %v", tc.expr, tc.ctx, got, tc.want) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | if (&BasicExpression{}).UsesContext("matrix") { | |
| 244 | + | t.Error("empty expression uses a context") | |
| 245 | + | } | |
| 246 | + | } | |
A template/fuzz_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import "testing" | |
| 6 | + | ||
| 7 | + | func FuzzReadYAML(f *testing.F) { | |
| 8 | + | for _, s := range []string{ | |
| 9 | + | "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo ${{ github.ref }}\n", | |
| 10 | + | "a: &x [1, *x]\n", | |
| 11 | + | "on: [push\n", | |
| 12 | + | "${{ insert }}: 1\n", | |
| 13 | + | "x: ${{ '${{' }}\n", | |
| 14 | + | "\x00", | |
| 15 | + | "a: !!str 1\nb: !!int x\n", | |
| 16 | + | "? [a]\n: b\n", | |
| 17 | + | "on: push\njobs:\n a:\n runs-on: ${{ matrix.os }}\n strategy:\n matrix:\n ${{ insert }}: ${{ fromJSON(needs.x.outputs.y) }}\n", | |
| 18 | + | } { | |
| 19 | + | f.Add(s) | |
| 20 | + | } | |
| 21 | + | schema, err := WorkflowSchema() | |
| 22 | + | if err != nil { | |
| 23 | + | f.Fatal(err) | |
| 24 | + | } | |
| 25 | + | f.Fuzz(func(t *testing.T, src string) { | |
| 26 | + | ctx := NewContext(schema) | |
| 27 | + | ReadYAML(ctx, "workflow-root-strict", "f.yml", []byte(src)) | |
| 28 | + | for _, e := range ctx.Errors() { | |
| 29 | + | _ = e.Error() | |
| 30 | + | } | |
| 31 | + | }) | |
| 32 | + | } | |
A template/gen/scalars.mjs +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | // | |
| 3 | + | // Generates testdata/scalars.json: how the JavaScript `yaml` library (2.x, the | |
| 4 | + | // one @actions/workflow-parser uses) types plain scalars. | |
| 5 | + | // | |
| 6 | + | // gen/regen.sh | |
| 7 | + | ||
| 8 | + | import { createRequire } from "node:module"; | |
| 9 | + | ||
| 10 | + | // The yaml package pinned in gen/package.json. | |
| 11 | + | const { parseDocument } = createRequire(new URL("../../gen/package.json", import.meta.url))("yaml"); | |
| 12 | + | const vals = ["yes","no","on","off","y","n","~","null","Null","NULL","","true","True","TRUE","tRUE","false","0","-0","+1","017","0o17","0O17","0x1F","0X1f","0b11","1_000","1:30","1e3","1E3","1.","1.5","-.5",".5","+.5",".inf","-.inf",".Inf",".INF","+.inf",".nan",".NaN",".NAN","-.nan","1e","e1","0x","0xg","12abc","2001-01-01","1.5e+3","0.","00","08","-017","+0x1F","-0x1F","1,000","Infinity","NaN","inf","=","<<","!!str 1","!!int 5"]; | |
| 13 | + | const out = {}; | |
| 14 | + | for (const v of vals) { | |
| 15 | + | const d = parseDocument("k: " + v + "\n"); | |
| 16 | + | const x = d.get("k"); | |
| 17 | + | out[v] = d.errors.length ? "ERR" : (x === null || x === undefined ? "null" : typeof x === "number" ? "num:" + (Number.isNaN(x) ? "NaN" : x) : typeof x === "boolean" ? "bool:" + x : typeof x === "string" ? "str:" + x : typeof x); | |
| 18 | + | } | |
| 19 | + | console.log(JSON.stringify(out)); | |
A template/number.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "math" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // JSNumberString formats x like JavaScript's Number.prototype.toString: the | |
| 12 | + | // shortest decimal that round-trips, positional for decimal exponents from -7 | |
| 13 | + | // to 20, exponent notation beyond. The reference uses it for the text of a | |
| 14 | + | // number token. | |
| 15 | + | func JSNumberString(x float64) string { | |
| 16 | + | switch { | |
| 17 | + | case math.IsNaN(x): | |
| 18 | + | return "NaN" | |
| 19 | + | case x == 0: | |
| 20 | + | return "0" | |
| 21 | + | case math.IsInf(x, 1): | |
| 22 | + | return "Infinity" | |
| 23 | + | case math.IsInf(x, -1): | |
| 24 | + | return "-Infinity" | |
| 25 | + | } | |
| 26 | + | sign := "" | |
| 27 | + | if x < 0 { | |
| 28 | + | sign = "-" | |
| 29 | + | x = -x | |
| 30 | + | } | |
| 31 | + | s := strconv.FormatFloat(x, 'e', -1, 64) | |
| 32 | + | mant, expStr, _ := strings.Cut(s, "e") | |
| 33 | + | exp, _ := strconv.Atoi(expStr) | |
| 34 | + | digits := strings.Replace(mant, ".", "", 1) | |
| 35 | + | k := len(digits) | |
| 36 | + | n := exp + 1 | |
| 37 | + | switch { | |
| 38 | + | case k <= n && n <= 21: | |
| 39 | + | return sign + digits + strings.Repeat("0", n-k) | |
| 40 | + | case 0 < n && n <= 21: | |
| 41 | + | return sign + digits[:n] + "." + digits[n:] | |
| 42 | + | case -6 < n && n <= 0: | |
| 43 | + | return sign + "0." + strings.Repeat("0", -n) + digits | |
| 44 | + | } | |
| 45 | + | e := n - 1 | |
| 46 | + | esign := "+" | |
| 47 | + | if e < 0 { | |
| 48 | + | esign = "-" | |
| 49 | + | e = -e | |
| 50 | + | } | |
| 51 | + | if k == 1 { | |
| 52 | + | return sign + digits + "e" + esign + strconv.Itoa(e) | |
| 53 | + | } | |
| 54 | + | return sign + digits[:1] + "." + digits[1:] + "e" + esign + strconv.Itoa(e) | |
| 55 | + | } | |
A template/read_test.go +161 -0
| @@ -0,0 +1,161 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func readWorkflow(t testing.TB, src string) (Token, []string) { | |
| 11 | + | t.Helper() | |
| 12 | + | s, err := WorkflowSchema() | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | ctx := NewContext(s) | |
| 17 | + | tok := ReadYAML(ctx, "workflow-root-strict", "a.yml", []byte(src)) | |
| 18 | + | return tok, ctx.Messages() | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | const head = "on: push\njobs:\n a:\n runs-on: x\n steps:\n" | |
| 22 | + | ||
| 23 | + | func TestReadErrors(t *testing.T) { | |
| 24 | + | // Expected messages come from @actions/workflow-parser 0.3.61 run on the | |
| 25 | + | // same inputs. | |
| 26 | + | tests := []struct { | |
| 27 | + | name, src string | |
| 28 | + | want []string | |
| 29 | + | }{ | |
| 30 | + | {"unknown context, block scalar", head + " - run: |\n echo hi\n echo ${{ foo.bar }}\n", | |
| 31 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |
| 32 | + | {"unknown context, plain", head + " - run: echo ${{ foo.bar }} abc\n", | |
| 33 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |
| 34 | + | {"unknown context, double quoted", head + " - run: \"echo ${{ foo.bar }} abc\"\n", | |
| 35 | + | []string{"a.yml (Line: 6, Col: 14): Unrecognized named-value: 'foo'"}}, | |
| 36 | + | {"bad expression and insert in a name", head + " - run: echo ${{ 1 + }}\n - name: ${{ insert }}\n run: x\n", | |
| 37 | + | []string{"a.yml (Line: 6, Col: 14): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", "a.yml (Line: 7, Col: 15): Unexpected value '${{ insert }}'"}}, | |
| 38 | + | {"not closed", head + " - run: echo ${{ 1\n", | |
| 39 | + | []string{"a.yml (Line: 6, Col: 14): The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found."}}, | |
| 40 | + | {"empty document", "", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}}, | |
| 41 | + | {"comment only", "# c\n", []string{"a.yml: Expected a scalar value, a sequence, or a mapping"}}, | |
| 42 | + | {"sequence root", "[1,2]", []string{"a.yml (Line: 1, Col: 1): A sequence was not expected"}}, | |
| 43 | + | {"duplicate key", "on: push\non: pull_request\njobs: {}", []string{"a.yml (Line: 2, Col: 1): 'on' is already defined"}}, | |
| 44 | + | {"alias is resolved", "on: push\njobs:\n a: &x\n runs-on: x\n steps: [{run: a}]\n b: *x\n", nil}, | |
| 45 | + | {"valid", head + " - run: echo ${{ github.ref }}\n", nil}, | |
| 46 | + | } | |
| 47 | + | for _, tc := range tests { | |
| 48 | + | t.Run(tc.name, func(t *testing.T) { | |
| 49 | + | _, got := readWorkflow(t, tc.src) | |
| 50 | + | if strings.Join(got, "\n") != strings.Join(tc.want, "\n") { | |
| 51 | + | t.Errorf("errors:\n got %q\nwant %q", got, tc.want) | |
| 52 | + | } | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestReadSyntaxErrorsHaveNoTokens(t *testing.T) { | |
| 58 | + | for _, src := range []string{"on: [push\n", "a: b: c", "on: push\n---\nx: 1\n", "\t"} { | |
| 59 | + | tok, errs := readWorkflow(t, src) | |
| 60 | + | if tok != nil || len(errs) == 0 { | |
| 61 | + | t.Errorf("%q: tok=%v errs=%q", src, tok, errs) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestMixedStringBecomesFormat(t *testing.T) { | |
| 67 | + | tok, errs := readWorkflow(t, head+" - run: it's ${{ github.ref }} {x} ${{ 1 }}\n") | |
| 68 | + | if len(errs) > 0 { | |
| 69 | + | t.Fatal(errs) | |
| 70 | + | } | |
| 71 | + | run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run") | |
| 72 | + | be, ok := run.(*BasicExpression) | |
| 73 | + | if !ok { | |
| 74 | + | t.Fatalf("run is %T", run) | |
| 75 | + | } | |
| 76 | + | if want := `format('it''s {0} {{x}} {1}', github.ref, 1)`; be.Expression != want { | |
| 77 | + | t.Errorf("expression = %s, want %s", be.Expression, want) | |
| 78 | + | } | |
| 79 | + | if len(be.Original) != 2 { | |
| 80 | + | t.Errorf("originals = %d", len(be.Original)) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestEscapedLiteralExpression(t *testing.T) { | |
| 85 | + | tok, errs := readWorkflow(t, head+" - run: ${{ '{{ literal }}' }}\n") | |
| 86 | + | if len(errs) > 0 { | |
| 87 | + | t.Fatal(errs) | |
| 88 | + | } | |
| 89 | + | run := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("run") | |
| 90 | + | if s, ok := run.(*String); !ok || s.Value != "{{ literal }}" { | |
| 91 | + | t.Errorf("run = %#v", run) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestScalarTypes(t *testing.T) { | |
| 96 | + | tok, errs := readWorkflow(t, "on: push\njobs:\n a:\n runs-on: x\n timeout-minutes: 0x10\n continue-on-error: yes\n steps: [{run: 1}]\n") | |
| 97 | + | // yes is a string in YAML 1.2, so continue-on-error is rejected. | |
| 98 | + | if len(errs) == 0 || !strings.Contains(errs[0], "Unexpected value 'yes'") { | |
| 99 | + | t.Errorf("errs = %q", errs) | |
| 100 | + | } | |
| 101 | + | job := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping) | |
| 102 | + | if n, ok := job.Find("timeout-minutes").(*Number); !ok || n.Value != 16 { | |
| 103 | + | t.Errorf("timeout-minutes = %#v", job.Find("timeout-minutes")) | |
| 104 | + | } | |
| 105 | + | step := job.Find("steps").(*Sequence).Items[0].(*Mapping) | |
| 106 | + | if s, ok := step.Find("run").(*String); !ok || s.Value != "1" { | |
| 107 | + | t.Errorf("run = %#v", step.Find("run")) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestAliasBombIsRejected(t *testing.T) { | |
| 112 | + | var b strings.Builder | |
| 113 | + | b.WriteString("a0: &a0 [x, x, x, x, x, x, x, x, x, x]\n") | |
| 114 | + | for i := 1; i < 8; i++ { | |
| 115 | + | b.WriteString("a") | |
| 116 | + | b.WriteString(string(rune('0' + i))) | |
| 117 | + | b.WriteString(": &a") | |
| 118 | + | b.WriteString(string(rune('0' + i))) | |
| 119 | + | b.WriteString(" [") | |
| 120 | + | for j := 0; j < 10; j++ { | |
| 121 | + | b.WriteString("*a") | |
| 122 | + | b.WriteString(string(rune('0' + i - 1))) | |
| 123 | + | b.WriteString(",") | |
| 124 | + | } | |
| 125 | + | b.WriteString("]\n") | |
| 126 | + | } | |
| 127 | + | _, errs := readWorkflow(t, b.String()) | |
| 128 | + | if len(errs) == 0 || !strings.Contains(strings.Join(errs, "\n"), "too large") { | |
| 129 | + | t.Errorf("errs = %q", errs) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestFileSizeLimit(t *testing.T) { | |
| 134 | + | s, err := WorkflowSchema() | |
| 135 | + | if err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | big := []byte("on: push\n# " + strings.Repeat("x", MaxFileBytes) + "\n") | |
| 139 | + | ctx := NewContext(s) | |
| 140 | + | if tok := ReadYAML(ctx, "workflow-root-strict", "big.yml", big); tok != nil { | |
| 141 | + | t.Error("token returned for an oversized file") | |
| 142 | + | } | |
| 143 | + | errs := ctx.Messages() | |
| 144 | + | if len(errs) != 1 || !strings.HasPrefix(errs[0], "big.yml: The file is too large: ") { | |
| 145 | + | t.Errorf("errors = %q", errs) | |
| 146 | + | } | |
| 147 | + | // Exactly at the limit is read. | |
| 148 | + | ok := append([]byte("on: push\njobs: {}\n# "), make([]byte, 0)...) | |
| 149 | + | pad := MaxFileBytes - len(ok) - 1 | |
| 150 | + | ok = append(ok, []byte(strings.Repeat("x", pad)+"\n")...) | |
| 151 | + | if len(ok) != MaxFileBytes { | |
| 152 | + | t.Fatalf("test file is %d bytes", len(ok)) | |
| 153 | + | } | |
| 154 | + | ctx = NewContext(s) | |
| 155 | + | ReadYAML(ctx, "workflow-root-strict", "ok.yml", ok) | |
| 156 | + | for _, m := range ctx.Messages() { | |
| 157 | + | if strings.Contains(m, "too large") { | |
| 158 | + | t.Errorf("file at the limit refused: %s", m) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
A template/reader.go +259 -0
| @@ -0,0 +1,259 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "sort" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ReadYAML parses a YAML document and reads it as a template of the schema | |
| 12 | + | // definition rootType (for workflows, "workflow-root-strict"). Problems | |
| 13 | + | // found in the document, such as syntax errors, unexpected keys or | |
| 14 | + | // misplaced expressions, are recorded in ctx; the returned token holds what | |
| 15 | + | // could be read and is nil when the document is not valid YAML. | |
| 16 | + | // | |
| 17 | + | // An empty document is an error, as in the reference. A document larger than | |
| 18 | + | // [MaxFileBytes] is refused before it is parsed, with one error about the | |
| 19 | + | // file. | |
| 20 | + | func ReadYAML(ctx *Context, rootType, file string, content []byte) (root Token) { | |
| 21 | + | if len(content) > MaxFileBytes { | |
| 22 | + | e := &Error{File: file, fileOnly: true, Raw: fmt.Sprintf("The file is too large: %d bytes, the limit is %d bytes", len(content), MaxFileBytes)} | |
| 23 | + | ctx.add(e) | |
| 24 | + | return nil | |
| 25 | + | } | |
| 26 | + | res := readYAMLDocument(file, content) | |
| 27 | + | for _, e := range res.errs { | |
| 28 | + | ctx.add(e) | |
| 29 | + | } | |
| 30 | + | if len(res.errs) > 0 { | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | defer func() { | |
| 34 | + | if r := recover(); r != nil { | |
| 35 | + | f, ok := r.(fatal) | |
| 36 | + | if !ok { | |
| 37 | + | panic(r) | |
| 38 | + | } | |
| 39 | + | ctx.ErrorInFile(file, f.msg) | |
| 40 | + | } | |
| 41 | + | }() | |
| 42 | + | rd := &reader{ctx: ctx, schema: ctx.schema, file: file} | |
| 43 | + | if res.root == nil { | |
| 44 | + | fatalf("Expected a scalar value, a sequence, or a mapping") | |
| 45 | + | } | |
| 46 | + | def := newDefinitionInfo(ctx.schema, nil, ctx.schema.get(rootType)) | |
| 47 | + | return rd.readValue(res.root, def) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | type reader struct { | |
| 51 | + | ctx *Context | |
| 52 | + | schema *Schema | |
| 53 | + | file string | |
| 54 | + | // noExpressions is set when reading the result of an evaluation, whose | |
| 55 | + | // strings are text even if they contain ${{. | |
| 56 | + | noExpressions bool | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (r *reader) origin(n *rnode) Origin { return Origin{File: r.file, Pos: n.pos} } | |
| 60 | + | ||
| 61 | + | func (r *reader) readValue(n *rnode, def *DefinitionInfo) Token { | |
| 62 | + | switch n.kind { | |
| 63 | + | case rLiteral: | |
| 64 | + | scalar := r.parseScalar(n, n.lit, def) | |
| 65 | + | return r.validate(scalar, def) | |
| 66 | + | case rSeq: | |
| 67 | + | seq := NewSequence(r.origin(n)) | |
| 68 | + | defs := def.schema.definitionsOfType(def.Definition, DefSequence) | |
| 69 | + | if len(defs) > 0 { | |
| 70 | + | itemDef := def.child(defs[0].ItemType) | |
| 71 | + | for _, it := range n.items { | |
| 72 | + | seq.Items = append(seq.Items, r.readValue(it, itemDef)) | |
| 73 | + | } | |
| 74 | + | } else { | |
| 75 | + | r.ctx.ErrorAt(seq, "A sequence was not expected") | |
| 76 | + | } | |
| 77 | + | seq.def = def | |
| 78 | + | return seq | |
| 79 | + | } | |
| 80 | + | m := NewMapping(r.origin(n)) | |
| 81 | + | defs := def.schema.definitionsOfType(def.Definition, DefMapping) | |
| 82 | + | if len(defs) > 0 { | |
| 83 | + | if len(defs) > 1 || len(defs[0].Properties) > 0 || defs[0].LooseKeyType == "" { | |
| 84 | + | r.mappingWithProperties(n, m, def, defs) | |
| 85 | + | } else { | |
| 86 | + | keyDef := def.child(defs[0].LooseKeyType) | |
| 87 | + | valDef := def.child(defs[0].LooseValueType) | |
| 88 | + | r.mappingAllLoose(n, m, def, keyDef, valDef, defs[0]) | |
| 89 | + | } | |
| 90 | + | } else { | |
| 91 | + | r.ctx.ErrorAt(m, "A mapping was not expected") | |
| 92 | + | } | |
| 93 | + | if m.def == nil { | |
| 94 | + | m.def = def | |
| 95 | + | } | |
| 96 | + | return m | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // key reads the key of a mapping entry; a key that is not a scalar is an | |
| 100 | + | // error and reads as an empty string. | |
| 101 | + | func (r *reader) key(n *rnode, i int, def *DefinitionInfo) Token { | |
| 102 | + | k := n.keys[i] | |
| 103 | + | if k.badKey { | |
| 104 | + | r.ctx.ErrorAt(NewString(r.origin(k), ""), "Unexpected value ''") | |
| 105 | + | } | |
| 106 | + | return r.parseScalar(k, k.lit, def) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (r *reader) mappingWithProperties(n *rnode, m *Mapping, def *DefinitionInfo, defs []*Definition) { | |
| 110 | + | var looseKey, looseVal string | |
| 111 | + | if defs[0].LooseKeyType != "" { | |
| 112 | + | looseKey, looseVal = defs[0].LooseKeyType, defs[0].LooseValueType | |
| 113 | + | } | |
| 114 | + | var looseKeyDef, looseValDef *DefinitionInfo | |
| 115 | + | upperKeys := map[string]bool{} | |
| 116 | + | hasExpressionKey := false | |
| 117 | + | for i := range n.keys { | |
| 118 | + | keyScalar := r.key(n, i, def) | |
| 119 | + | val := n.vals[i] | |
| 120 | + | if IsExpression(keyScalar) { | |
| 121 | + | hasExpressionKey = true | |
| 122 | + | if len(def.allowed) > 0 { | |
| 123 | + | m.Add(keyScalar, r.readValue(val, def.child("any"))) | |
| 124 | + | } else { | |
| 125 | + | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | |
| 126 | + | } | |
| 127 | + | continue | |
| 128 | + | } | |
| 129 | + | nextKey, ok := keyScalar.(*String) | |
| 130 | + | if !ok { | |
| 131 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | |
| 132 | + | } | |
| 133 | + | if nextKey.Value != "" { | |
| 134 | + | up := strings.ToUpper(nextKey.Value) | |
| 135 | + | if upperKeys[up] { | |
| 136 | + | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | |
| 137 | + | continue | |
| 138 | + | } | |
| 139 | + | upperKeys[up] = true | |
| 140 | + | } | |
| 141 | + | var prop *Property | |
| 142 | + | prop, defs = matchPropertyAndFilter(defs, nextKey.Value) | |
| 143 | + | if prop != nil { | |
| 144 | + | if backgroundStepProperties[nextKey.Value] { | |
| 145 | + | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | |
| 146 | + | continue | |
| 147 | + | } | |
| 148 | + | nd := def.child(prop.Type) | |
| 149 | + | nextKey.def = nd | |
| 150 | + | m.Add(nextKey, r.readValue(val, nd)) | |
| 151 | + | continue | |
| 152 | + | } | |
| 153 | + | if looseKey != "" { | |
| 154 | + | if looseKeyDef == nil { | |
| 155 | + | looseKeyDef = def.child(looseKey) | |
| 156 | + | looseValDef = def.child(looseVal) | |
| 157 | + | } | |
| 158 | + | r.validate(nextKey, looseKeyDef) | |
| 159 | + | nextKey.def = def.child(defs[0].LooseValueType) | |
| 160 | + | m.Add(nextKey, r.readValue(val, looseValDef)) | |
| 161 | + | continue | |
| 162 | + | } | |
| 163 | + | r.ctx.Errorf(nextKey, "Unexpected value '%s'", nextKey.Value) | |
| 164 | + | } | |
| 165 | + | if len(defs) == 1 { | |
| 166 | + | m.def = newDefinitionInfo(def.schema, def, defs[0]) | |
| 167 | + | } | |
| 168 | + | switch { | |
| 169 | + | case len(defs) > 1: | |
| 170 | + | hits := map[string]int{} | |
| 171 | + | for _, d := range defs { | |
| 172 | + | for _, k := range d.PropertyNames { | |
| 173 | + | hits[k]++ | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | var props []string | |
| 177 | + | for k, c := range hits { | |
| 178 | + | if c == 1 && !backgroundStepOnlyProperties[k] { | |
| 179 | + | props = append(props, k) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | sort.Strings(props) | |
| 183 | + | r.ctx.Errorf(m, "There's not enough info to determine what you meant. Add one of these properties: %s", strings.Join(props, ", ")) | |
| 184 | + | case len(defs) == 1 && !hasExpressionKey: | |
| 185 | + | for _, name := range defs[0].PropertyNames { | |
| 186 | + | if defs[0].Properties[name].Required && !upperKeys[strings.ToUpper(name)] { | |
| 187 | + | r.ctx.Errorf(m, "Required property is missing: %s", name) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | var ( | |
| 194 | + | backgroundStepProperties = map[string]bool{"background": true, "wait": true, "wait-all": true, "cancel": true} | |
| 195 | + | backgroundStepOnlyProperties = map[string]bool{"wait": true, "wait-all": true, "cancel": true} | |
| 196 | + | ) | |
| 197 | + | ||
| 198 | + | func (r *reader) mappingAllLoose(n *rnode, m *Mapping, def, keyDef, valDef *DefinitionInfo, md *Definition) { | |
| 199 | + | upperKeys := map[string]bool{} | |
| 200 | + | for i := range n.keys { | |
| 201 | + | keyScalar := r.key(n, i, def) | |
| 202 | + | keyScalar.base().def = keyDef | |
| 203 | + | val := n.vals[i] | |
| 204 | + | if IsExpression(keyScalar) { | |
| 205 | + | if len(def.allowed) > 0 { | |
| 206 | + | m.Add(keyScalar, r.readValue(val, valDef)) | |
| 207 | + | } else { | |
| 208 | + | r.ctx.ErrorAt(keyScalar, "A template expression is not allowed in this context") | |
| 209 | + | } | |
| 210 | + | continue | |
| 211 | + | } | |
| 212 | + | nextKey, ok := keyScalar.(*String) | |
| 213 | + | if !ok { | |
| 214 | + | nextKey = &String{tokenBase: *keyScalar.base(), Value: keyScalar.String()} | |
| 215 | + | } | |
| 216 | + | if nextKey.Value != "" { | |
| 217 | + | up := strings.ToUpper(nextKey.Value) | |
| 218 | + | if upperKeys[up] { | |
| 219 | + | r.ctx.Errorf(nextKey, "'%s' is already defined", nextKey.Value) | |
| 220 | + | continue | |
| 221 | + | } | |
| 222 | + | upperKeys[up] = true | |
| 223 | + | } | |
| 224 | + | r.validate(nextKey, keyDef) | |
| 225 | + | nextKey.def = def.child(md.LooseValueType) | |
| 226 | + | m.Add(nextKey, r.readValue(val, valDef)) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // validate checks a scalar against the scalar definitions of def. A literal | |
| 231 | + | // that is not a string may be converted to one. | |
| 232 | + | func (r *reader) validate(scalar Token, def *DefinitionInfo) Token { | |
| 233 | + | switch scalar.Kind() { | |
| 234 | + | case KindNull, KindBoolean, KindNumber, KindString: | |
| 235 | + | for _, d := range def.schema.scalarDefinitions(def.Definition) { | |
| 236 | + | if d.isMatch(scalar) { | |
| 237 | + | scalar.base().def = newDefinitionInfo(def.schema, def, d) | |
| 238 | + | return scalar | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | if scalar.Kind() != KindString { | |
| 242 | + | sl := &String{tokenBase: *scalar.base(), Value: scalar.String()} | |
| 243 | + | for _, d := range def.schema.scalarDefinitions(def.Definition) { | |
| 244 | + | if d.isMatch(sl) { | |
| 245 | + | sl.def = newDefinitionInfo(def.schema, def, d) | |
| 246 | + | return sl | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String())) | |
| 251 | + | case KindBasicExpression: | |
| 252 | + | if len(def.allowed) == 0 { | |
| 253 | + | r.ctx.ErrorAt(scalar, "A template expression is not allowed in this context") | |
| 254 | + | } | |
| 255 | + | default: | |
| 256 | + | r.ctx.ErrorAt(scalar, fmt.Sprintf("Unexpected value '%s'", scalar.String())) | |
| 257 | + | } | |
| 258 | + | return scalar | |
| 259 | + | } | |
A template/scalar.go +242 -0
| @@ -0,0 +1,242 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "unicode" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-actions/expr" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | openExpression = "${{" | |
| 15 | + | insertDirective = "insert" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // parseScalar splits a string that holds ${{ }} expressions into literal | |
| 19 | + | // text and expression tokens. Strings without an expression, other scalars | |
| 20 | + | // and strings with an error in an expression are returned as they are. | |
| 21 | + | // | |
| 22 | + | // The reference works on the raw source of a multi-line YAML scalar, which | |
| 23 | + | // keeps the indentation of a block scalar; this port always works on the | |
| 24 | + | // value, as the runner does. | |
| 25 | + | func (r *reader) parseScalar(_ *rnode, token Token, def *DefinitionInfo) Token { | |
| 26 | + | s, ok := token.(*String) | |
| 27 | + | if r.noExpressions || !ok || s.Value == "" { | |
| 28 | + | return token | |
| 29 | + | } | |
| 30 | + | raw := s.Value | |
| 31 | + | startExpression := strings.Index(raw, openExpression) | |
| 32 | + | if startExpression < 0 { | |
| 33 | + | return token | |
| 34 | + | } | |
| 35 | + | var segments []Token | |
| 36 | + | encounteredError := false | |
| 37 | + | i := 0 | |
| 38 | + | for i < len(raw) { | |
| 39 | + | switch { | |
| 40 | + | case i == startExpression: | |
| 41 | + | end := -1 | |
| 42 | + | inString := false | |
| 43 | + | for i += len(openExpression); i < len(raw); i++ { | |
| 44 | + | if raw[i] == '\'' { | |
| 45 | + | inString = !inString | |
| 46 | + | } else if !inString && raw[i] == '}' && raw[i-1] == '}' { | |
| 47 | + | end = i | |
| 48 | + | i++ | |
| 49 | + | break | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if end < startExpression { | |
| 53 | + | r.ctx.ErrorAt(token, "The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found.") | |
| 54 | + | return token | |
| 55 | + | } | |
| 56 | + | rawExpression := raw[startExpression+len(openExpression) : end-1] | |
| 57 | + | e := r.parseIntoExpressionToken(token, rawExpression, def) | |
| 58 | + | if e == nil { | |
| 59 | + | encounteredError = true | |
| 60 | + | } else { | |
| 61 | + | if _, isInsert := e.(*InsertExpression); isInsert && (startExpression != 0 || i < len(raw)) { | |
| 62 | + | r.ctx.ErrorAt(token, "The directive 'insert' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.") | |
| 63 | + | return token | |
| 64 | + | } | |
| 65 | + | segments = append(segments, e) | |
| 66 | + | } | |
| 67 | + | if j := strings.Index(raw[i:], openExpression); j >= 0 { | |
| 68 | + | startExpression = i + j | |
| 69 | + | } else { | |
| 70 | + | startExpression = -1 | |
| 71 | + | } | |
| 72 | + | case i < startExpression: | |
| 73 | + | segments = r.addString(segments, token, raw[i:startExpression], s.def) | |
| 74 | + | i = startExpression | |
| 75 | + | default: | |
| 76 | + | segments = r.addString(segments, token, raw[i:], s.def) | |
| 77 | + | i = len(raw) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | if encounteredError { | |
| 81 | + | return token | |
| 82 | + | } | |
| 83 | + | if len(segments) == 1 { | |
| 84 | + | if be, ok := segments[0].(*BasicExpression); ok { | |
| 85 | + | if str, ok := expressionString(be.Expression); ok { | |
| 86 | + | return &String{tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: s.def}, Value: str} | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | return segments[0] | |
| 90 | + | } | |
| 91 | + | var format strings.Builder | |
| 92 | + | var args strings.Builder | |
| 93 | + | var originals []*BasicExpression | |
| 94 | + | argIndex := 0 | |
| 95 | + | for _, seg := range segments { | |
| 96 | + | if ss, ok := seg.(*String); ok { | |
| 97 | + | text := strings.ReplaceAll(ss.Value, "'", "''") | |
| 98 | + | text = strings.ReplaceAll(text, "{", "{{") | |
| 99 | + | text = strings.ReplaceAll(text, "}", "}}") | |
| 100 | + | format.WriteString(text) | |
| 101 | + | continue | |
| 102 | + | } | |
| 103 | + | be := seg.(*BasicExpression) | |
| 104 | + | fmt.Fprintf(&format, "{%d}", argIndex) | |
| 105 | + | argIndex++ | |
| 106 | + | args.WriteString(", ") | |
| 107 | + | args.WriteString(be.Expression) | |
| 108 | + | originals = append(originals, be) | |
| 109 | + | } | |
| 110 | + | t := &BasicExpression{ | |
| 111 | + | tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def}, | |
| 112 | + | Expression: "format('" + format.String() + "'" + args.String() + ")", | |
| 113 | + | Original: originals, | |
| 114 | + | Source: raw, | |
| 115 | + | } | |
| 116 | + | t.node, t.parseErr = parseWith(t.Expression, def) | |
| 117 | + | return t | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (r *reader) addString(segs []Token, at Token, value string, def *DefinitionInfo) []Token { | |
| 121 | + | if n := len(segs); n > 0 { | |
| 122 | + | if last, ok := segs[n-1].(*String); ok { | |
| 123 | + | segs[n-1] = &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: last.Value + value} | |
| 124 | + | return segs | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | return append(segs, &String{tokenBase: tokenBase{file: r.file, pos: at.Pos(), def: def}, Value: value}) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (r *reader) parseIntoExpressionToken(token Token, rawExpression string, def *DefinitionInfo) Token { | |
| 131 | + | e, err := r.parseExpression(token, rawExpression, def) | |
| 132 | + | if err != nil { | |
| 133 | + | r.ctx.ErrorAt(token, err.Error()) | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | return e | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (r *reader) parseExpression(token Token, value string, def *DefinitionInfo) (Token, error) { | |
| 140 | + | trimmed := strings.TrimSpace(value) | |
| 141 | + | if trimmed == "" { | |
| 142 | + | return nil, message("An expression was expected") | |
| 143 | + | } | |
| 144 | + | isMatch, err := matchDirective(trimmed, insertDirective, 0) | |
| 145 | + | if err != nil { | |
| 146 | + | return nil, err | |
| 147 | + | } | |
| 148 | + | if isMatch { | |
| 149 | + | return &InsertExpression{tokenBase{file: r.file, pos: token.Pos(), def: def}}, nil | |
| 150 | + | } | |
| 151 | + | node, err := parseWith(trimmed, def) | |
| 152 | + | if err != nil { | |
| 153 | + | return nil, err | |
| 154 | + | } | |
| 155 | + | src, _ := token.(*String) | |
| 156 | + | t := &BasicExpression{ | |
| 157 | + | tokenBase: tokenBase{file: r.file, pos: token.Pos(), def: def}, | |
| 158 | + | Expression: trimmed, | |
| 159 | + | node: node, | |
| 160 | + | } | |
| 161 | + | if src != nil { | |
| 162 | + | t.Source = src.Value | |
| 163 | + | } | |
| 164 | + | return t, nil | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // parseWith parses an expression against the contexts and functions allowed | |
| 168 | + | // by def. A nil def allows nothing. | |
| 169 | + | func parseWith(text string, def *DefinitionInfo) (expr.Node, error) { | |
| 170 | + | var named []string | |
| 171 | + | var funcs []expr.FuncInfo | |
| 172 | + | if def != nil { | |
| 173 | + | named, funcs = def.named, def.funcs | |
| 174 | + | } | |
| 175 | + | return expr.Parse(text, named, funcs) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // matchDirective reports whether trimmed is the directive followed by exactly | |
| 179 | + | // expectedParameters parameters. It is a literal port of the reference, | |
| 180 | + | // including the way it counts parameters. | |
| 181 | + | func matchDirective(trimmed, directive string, expectedParameters int) (bool, error) { | |
| 182 | + | if !strings.HasPrefix(trimmed, directive) { | |
| 183 | + | return false, nil | |
| 184 | + | } | |
| 185 | + | rest := trimmed[len(directive):] | |
| 186 | + | if rest != "" { | |
| 187 | + | c := []rune(rest)[0] | |
| 188 | + | if !unicode.IsSpace(c) && c != '\ufeff' { | |
| 189 | + | return false, nil | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | // The reference only counts the text after the last separator that is | |
| 193 | + | // outside quotes and parentheses; its loop never counts earlier ones. | |
| 194 | + | rs := []rune(trimmed) | |
| 195 | + | startIndex := len([]rune(directive)) | |
| 196 | + | inString := false | |
| 197 | + | parens := 0 | |
| 198 | + | for i := startIndex; i < len(rs); i++ { | |
| 199 | + | c := rs[i] | |
| 200 | + | switch { | |
| 201 | + | case unicode.IsSpace(c) && !inString && parens == 0: | |
| 202 | + | startIndex = i + 1 | |
| 203 | + | case c == '\'': | |
| 204 | + | inString = !inString | |
| 205 | + | case c == '(' && !inString: | |
| 206 | + | parens++ | |
| 207 | + | case c == ')' && !inString: | |
| 208 | + | parens-- | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | parameters := 0 | |
| 212 | + | if startIndex < len(rs) { | |
| 213 | + | parameters = 1 | |
| 214 | + | } | |
| 215 | + | if expectedParameters != parameters { | |
| 216 | + | return false, message(fmt.Sprintf("Exactly %d parameter(s) were expected following the directive '%s'. Actual parameter count: %d", expectedParameters, directive, parameters)) | |
| 217 | + | } | |
| 218 | + | return true, nil | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // expressionString returns the text of an expression that is a single quoted | |
| 222 | + | // string, such as ${{ '{{ literal }}' }}, which is how GitHub lets a workflow | |
| 223 | + | // contain a literal ${{. | |
| 224 | + | func expressionString(trimmed string) (string, bool) { | |
| 225 | + | var out []byte | |
| 226 | + | inString := false | |
| 227 | + | for i := 0; i < len(trimmed); i++ { | |
| 228 | + | c := trimmed[i] | |
| 229 | + | switch { | |
| 230 | + | case c == '\'': | |
| 231 | + | inString = !inString | |
| 232 | + | if inString && i != 0 { | |
| 233 | + | out = append(out, c) | |
| 234 | + | } | |
| 235 | + | case !inString: | |
| 236 | + | return "", false | |
| 237 | + | default: | |
| 238 | + | out = append(out, c) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | return string(out), true | |
| 242 | + | } | |
A template/scalar_ref_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "fmt" | |
| 8 | + | "math" | |
| 9 | + | "os" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // TestPlainScalarsAgainstReference compares how plain scalars are typed with | |
| 14 | + | // the JavaScript yaml library the reference uses. testdata/scalars.json is | |
| 15 | + | // generated by gen/scalars.mjs. | |
| 16 | + | func TestPlainScalarsAgainstReference(t *testing.T) { | |
| 17 | + | data, err := os.ReadFile("testdata/scalars.json") | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | var want map[string]string | |
| 22 | + | if err := json.Unmarshal(data, &want); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | for src, w := range want { | |
| 26 | + | res := readYAMLDocument("f.yml", []byte("k: "+src+"\n")) | |
| 27 | + | if w == "ERR" { | |
| 28 | + | continue | |
| 29 | + | } | |
| 30 | + | if len(res.errs) > 0 { | |
| 31 | + | t.Errorf("%q: error %v, reference %s", src, res.errs[0], w) | |
| 32 | + | continue | |
| 33 | + | } | |
| 34 | + | var got string | |
| 35 | + | switch v := res.root.vals[0].lit.(type) { | |
| 36 | + | case *Null: | |
| 37 | + | got = "null" | |
| 38 | + | case *Number: | |
| 39 | + | if math.IsNaN(v.Value) { | |
| 40 | + | got = "num:NaN" | |
| 41 | + | } else { | |
| 42 | + | got = "num:" + JSNumberString(v.Value) | |
| 43 | + | } | |
| 44 | + | case *Boolean: | |
| 45 | + | got = fmt.Sprintf("bool:%v", v.Value) | |
| 46 | + | case *String: | |
| 47 | + | got = "str:" + v.Value | |
| 48 | + | } | |
| 49 | + | if got != w { | |
| 50 | + | t.Errorf("%q: got %s, reference %s", src, got, w) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | } | |
A template/schema.go +169 -0
| @@ -0,0 +1,169 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // DefinitionType is the type of a schema [Definition]. | |
| 11 | + | type DefinitionType int | |
| 12 | + | ||
| 13 | + | // The definition types. | |
| 14 | + | const ( | |
| 15 | + | DefNull DefinitionType = iota | |
| 16 | + | DefBoolean | |
| 17 | + | DefNumber | |
| 18 | + | DefString | |
| 19 | + | DefSequence | |
| 20 | + | DefMapping | |
| 21 | + | DefOneOf | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // Property is a well-known key of a mapping definition. | |
| 25 | + | type Property struct { | |
| 26 | + | // Type is the name of the definition of the value. | |
| 27 | + | Type string | |
| 28 | + | Required bool | |
| 29 | + | Description string | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Definition is a named type of the schema. Which fields are meaningful | |
| 33 | + | // depends on Type. | |
| 34 | + | type Definition struct { | |
| 35 | + | Name string | |
| 36 | + | Type DefinitionType | |
| 37 | + | Description string | |
| 38 | + | // Context lists the named contexts and functions ("github", | |
| 39 | + | // "success(0,MAX)") expressions in values of this type may use. | |
| 40 | + | Context []string | |
| 41 | + | ||
| 42 | + | // String definitions. | |
| 43 | + | Constant string | |
| 44 | + | IgnoreCase bool | |
| 45 | + | RequireNonEmpty bool | |
| 46 | + | IsExpression bool | |
| 47 | + | ||
| 48 | + | // Sequence definitions. | |
| 49 | + | ItemType string | |
| 50 | + | ||
| 51 | + | // Mapping definitions: Properties in schema order. | |
| 52 | + | PropertyNames []string | |
| 53 | + | Properties map[string]Property | |
| 54 | + | LooseKeyType string | |
| 55 | + | LooseValueType string | |
| 56 | + | ||
| 57 | + | // One-of definitions: the names of the alternatives. | |
| 58 | + | OneOf []string | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Schema is a set of named definitions. | |
| 62 | + | type Schema struct { | |
| 63 | + | Version string | |
| 64 | + | definitions map[string]*Definition | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Definition returns the definition called name, or nil. | |
| 68 | + | func (s *Schema) Definition(name string) *Definition { return s.definitions[name] } | |
| 69 | + | ||
| 70 | + | // get is the reference's getDefinition, which throws when the name is unknown. | |
| 71 | + | func (s *Schema) get(name string) *Definition { | |
| 72 | + | d := s.definitions[name] | |
| 73 | + | if d == nil { | |
| 74 | + | fatalf("Schema definition '%s' not found", name) | |
| 75 | + | } | |
| 76 | + | return d | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // scalarDefinitions expands one-of definitions and returns all scalar ones. | |
| 80 | + | func (s *Schema) scalarDefinitions(d *Definition) []*Definition { | |
| 81 | + | switch d.Type { | |
| 82 | + | case DefNull, DefBoolean, DefNumber, DefString: | |
| 83 | + | return []*Definition{d} | |
| 84 | + | case DefOneOf: | |
| 85 | + | var out []*Definition | |
| 86 | + | for _, n := range d.OneOf { | |
| 87 | + | out = append(out, s.scalarDefinitions(s.get(n))...) | |
| 88 | + | } | |
| 89 | + | return out | |
| 90 | + | } | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // definitionsOfType expands a one-of definition one level and returns the | |
| 95 | + | // alternatives of type t. | |
| 96 | + | func (s *Schema) definitionsOfType(d *Definition, t DefinitionType) []*Definition { | |
| 97 | + | if d.Type == t { | |
| 98 | + | return []*Definition{d} | |
| 99 | + | } | |
| 100 | + | var out []*Definition | |
| 101 | + | if d.Type == DefOneOf { | |
| 102 | + | for _, n := range d.OneOf { | |
| 103 | + | if nd := s.get(n); nd.Type == t { | |
| 104 | + | out = append(out, nd) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return out | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // isMatch reports whether a literal token is accepted by a scalar definition. | |
| 112 | + | func (d *Definition) isMatch(t Token) bool { | |
| 113 | + | switch d.Type { | |
| 114 | + | case DefNull: | |
| 115 | + | return t.Kind() == KindNull | |
| 116 | + | case DefBoolean: | |
| 117 | + | return t.Kind() == KindBoolean | |
| 118 | + | case DefNumber: | |
| 119 | + | return t.Kind() == KindNumber | |
| 120 | + | case DefString: | |
| 121 | + | s, ok := t.(*String) | |
| 122 | + | if !ok { | |
| 123 | + | return false | |
| 124 | + | } | |
| 125 | + | switch { | |
| 126 | + | case d.Constant != "": | |
| 127 | + | if d.IgnoreCase { | |
| 128 | + | return strings.EqualFold(d.Constant, s.Value) | |
| 129 | + | } | |
| 130 | + | return d.Constant == s.Value | |
| 131 | + | case d.RequireNonEmpty: | |
| 132 | + | return s.Value != "" | |
| 133 | + | } | |
| 134 | + | return true | |
| 135 | + | } | |
| 136 | + | return false | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // matchPropertyAndFilter looks for a property called name in any of defs; when | |
| 140 | + | // found in some only, defs is narrowed to those. It returns the property, if | |
| 141 | + | // any, and the possibly narrowed list. | |
| 142 | + | func matchPropertyAndFilter(defs []*Definition, name string) (*Property, []*Definition) { | |
| 143 | + | var result *Property | |
| 144 | + | notFoundInSome := false | |
| 145 | + | for _, d := range defs { | |
| 146 | + | if p, ok := d.Properties[name]; ok { | |
| 147 | + | p := p | |
| 148 | + | result = &p | |
| 149 | + | } else { | |
| 150 | + | notFoundInSome = true | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | if result != nil && notFoundInSome { | |
| 154 | + | kept := defs[:0:0] | |
| 155 | + | for _, d := range defs { | |
| 156 | + | if _, ok := d.Properties[name]; ok { | |
| 157 | + | kept = append(kept, d) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | defs = kept | |
| 161 | + | } | |
| 162 | + | return result, defs | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // fatal is the panic value used for errors that abort a read, which the | |
| 166 | + | // reference throws as exceptions. | |
| 167 | + | type fatal struct{ msg string } | |
| 168 | + | ||
| 169 | + | func fatalf(format string, a ...any) { panic(fatal{fmt.Sprintf(format, a...)}) } | |
A template/schema/SOURCE +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | workflow-v1.0.min.json is dist/workflow-v1.0.min.json of the npm package | |
| 2 | + | @actions/workflow-parser 0.3.61 (https://github.com/actions/languageservices, | |
| 3 | + | MIT, Copyright GitHub, Inc.), copied unmodified. It drives validation and the | |
| 4 | + | contexts and functions allowed per key. See ../../THIRD_PARTY.md. | |
A template/schema/workflow-v1.0.min.json +1 -0 too large to show in the browser
too large to show in the browser view raw
A template/schema_load.go +346 -0
| @@ -0,0 +1,346 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | ||
| 14 | + | _ "embed" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | //go:embed schema/workflow-v1.0.min.json | |
| 18 | + | var workflowSchemaJSON []byte | |
| 19 | + | ||
| 20 | + | var ( | |
| 21 | + | workflowSchemaOnce sync.Once | |
| 22 | + | workflowSchema *Schema | |
| 23 | + | workflowSchemaErr error | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // WorkflowSchema returns the vendored workflow schema (workflow-v1.0). The | |
| 27 | + | // schema is read once and shared; it must not be modified. | |
| 28 | + | func WorkflowSchema() (*Schema, error) { | |
| 29 | + | workflowSchemaOnce.Do(func() { | |
| 30 | + | workflowSchema, workflowSchemaErr = ParseSchema(workflowSchemaJSON) | |
| 31 | + | }) | |
| 32 | + | return workflowSchema, workflowSchemaErr | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // jnode is a JSON value that keeps the order of object keys. | |
| 36 | + | type jnode struct { | |
| 37 | + | kind byte // 'o'bject, 'a'rray, 's'tring, 'b'ool, 'n'umber, 'z' null | |
| 38 | + | keys []string | |
| 39 | + | vals []*jnode | |
| 40 | + | str string | |
| 41 | + | b bool | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (n *jnode) get(key string) *jnode { | |
| 45 | + | for i, k := range n.keys { | |
| 46 | + | if k == key { | |
| 47 | + | return n.vals[i] | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func readJNode(dec *json.Decoder) (*jnode, error) { | |
| 54 | + | t, err := dec.Token() | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, err | |
| 57 | + | } | |
| 58 | + | switch v := t.(type) { | |
| 59 | + | case json.Delim: | |
| 60 | + | switch v { | |
| 61 | + | case '{': | |
| 62 | + | n := &jnode{kind: 'o'} | |
| 63 | + | for dec.More() { | |
| 64 | + | kt, err := dec.Token() | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, err | |
| 67 | + | } | |
| 68 | + | val, err := readJNode(dec) | |
| 69 | + | if err != nil { | |
| 70 | + | return nil, err | |
| 71 | + | } | |
| 72 | + | n.keys = append(n.keys, kt.(string)) | |
| 73 | + | n.vals = append(n.vals, val) | |
| 74 | + | } | |
| 75 | + | _, err := dec.Token() | |
| 76 | + | return n, err | |
| 77 | + | case '[': | |
| 78 | + | n := &jnode{kind: 'a'} | |
| 79 | + | for dec.More() { | |
| 80 | + | val, err := readJNode(dec) | |
| 81 | + | if err != nil { | |
| 82 | + | return nil, err | |
| 83 | + | } | |
| 84 | + | n.vals = append(n.vals, val) | |
| 85 | + | } | |
| 86 | + | _, err := dec.Token() | |
| 87 | + | return n, err | |
| 88 | + | } | |
| 89 | + | case string: | |
| 90 | + | return &jnode{kind: 's', str: v}, nil | |
| 91 | + | case bool: | |
| 92 | + | return &jnode{kind: 'b', b: v}, nil | |
| 93 | + | case nil: | |
| 94 | + | return &jnode{kind: 'z'}, nil | |
| 95 | + | case json.Number, float64: | |
| 96 | + | return &jnode{kind: 'n'}, nil | |
| 97 | + | } | |
| 98 | + | return nil, errors.New("unexpected JSON token") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // ParseSchema reads a schema in the JSON form of workflow-v1.0.min.json: an | |
| 102 | + | // object with "version" and "definitions". The built-in definitions null, | |
| 103 | + | // boolean, number, string, sequence, mapping and any are always present. | |
| 104 | + | func ParseSchema(data []byte) (s *Schema, err error) { | |
| 105 | + | defer func() { | |
| 106 | + | if r := recover(); r != nil { | |
| 107 | + | f, ok := r.(fatal) | |
| 108 | + | if !ok { | |
| 109 | + | panic(r) | |
| 110 | + | } | |
| 111 | + | s, err = nil, errors.New(f.msg) | |
| 112 | + | } | |
| 113 | + | }() | |
| 114 | + | dec := json.NewDecoder(bytes.NewReader(data)) | |
| 115 | + | root, err := readJNode(dec) | |
| 116 | + | if err != nil { | |
| 117 | + | return nil, fmt.Errorf("invalid schema JSON: %w", err) | |
| 118 | + | } | |
| 119 | + | if _, err := dec.Token(); err != io.EOF { | |
| 120 | + | return nil, errors.New("invalid schema JSON: trailing data") | |
| 121 | + | } | |
| 122 | + | if root.kind != 'o' { | |
| 123 | + | fatalf("The schema must be an object") | |
| 124 | + | } | |
| 125 | + | s = newBuiltinSchema() | |
| 126 | + | for i, key := range root.keys { | |
| 127 | + | val := root.vals[i] | |
| 128 | + | switch key { | |
| 129 | + | case "version": | |
| 130 | + | if val.kind != 's' || val.str == "" { | |
| 131 | + | fatalf("Unexpected value in template-schema version") | |
| 132 | + | } | |
| 133 | + | s.Version = val.str | |
| 134 | + | case "definitions": | |
| 135 | + | if val.kind != 'o' { | |
| 136 | + | fatalf("template-schema definitions must be an object") | |
| 137 | + | } | |
| 138 | + | for j, name := range val.keys { | |
| 139 | + | s.loadDefinition(name, val.vals[j]) | |
| 140 | + | } | |
| 141 | + | default: | |
| 142 | + | fatalf("Error while reading 'template-schema key'. Unexpected value '%s'", key) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | s.validate() | |
| 146 | + | return s, nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func newBuiltinSchema() *Schema { | |
| 150 | + | s := &Schema{definitions: map[string]*Definition{}} | |
| 151 | + | for _, d := range []*Definition{ | |
| 152 | + | {Name: "null", Type: DefNull}, | |
| 153 | + | {Name: "boolean", Type: DefBoolean}, | |
| 154 | + | {Name: "number", Type: DefNumber}, | |
| 155 | + | {Name: "string", Type: DefString}, | |
| 156 | + | {Name: "sequence", Type: DefSequence, ItemType: "any"}, | |
| 157 | + | {Name: "mapping", Type: DefMapping, LooseKeyType: "string", LooseValueType: "any"}, | |
| 158 | + | {Name: "any", Type: DefOneOf, OneOf: []string{"null", "boolean", "number", "string", "sequence", "mapping"}}, | |
| 159 | + | } { | |
| 160 | + | s.definitions[d.Name] = d | |
| 161 | + | } | |
| 162 | + | return s | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (s *Schema) loadDefinition(name string, n *jnode) { | |
| 166 | + | if n.kind != 'o' { | |
| 167 | + | fatalf("Error while reading 'definitions value'. Unexpected value") | |
| 168 | + | } | |
| 169 | + | d := &Definition{Name: name} | |
| 170 | + | var body *jnode | |
| 171 | + | typed := false | |
| 172 | + | for i, k := range n.keys { | |
| 173 | + | v := n.vals[i] | |
| 174 | + | switch k { | |
| 175 | + | case "description": | |
| 176 | + | d.Description = v.str | |
| 177 | + | case "context": | |
| 178 | + | seen := map[string]bool{} | |
| 179 | + | for _, it := range v.vals { | |
| 180 | + | u := strings.ToUpper(it.str) | |
| 181 | + | if seen[u] { | |
| 182 | + | fatalf("Duplicate context item '%s'", it.str) | |
| 183 | + | } | |
| 184 | + | seen[u] = true | |
| 185 | + | d.Context = append(d.Context, it.str) | |
| 186 | + | } | |
| 187 | + | case "null", "boolean", "number", "string", "sequence", "mapping", "one-of", "allowed-values": | |
| 188 | + | if typed { | |
| 189 | + | continue | |
| 190 | + | } | |
| 191 | + | typed = true | |
| 192 | + | body = v | |
| 193 | + | d.Type = map[string]DefinitionType{ | |
| 194 | + | "null": DefNull, "boolean": DefBoolean, "number": DefNumber, "string": DefString, | |
| 195 | + | "sequence": DefSequence, "mapping": DefMapping, "one-of": DefOneOf, "allowed-values": DefOneOf, | |
| 196 | + | }[k] | |
| 197 | + | if k == "allowed-values" { | |
| 198 | + | for _, it := range v.vals { | |
| 199 | + | key := name + "-" + it.str | |
| 200 | + | s.definitions[key] = &Definition{Name: key, Type: DefString, Constant: trimDisplay(it.str)} | |
| 201 | + | d.OneOf = append(d.OneOf, key) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | default: | |
| 205 | + | fatalf("Error while reading 'definition mapping key'. Unexpected value '%s'", k) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | if !typed { | |
| 209 | + | fatalf("Not enough information to construct definition '%s'", name) | |
| 210 | + | } | |
| 211 | + | switch d.Type { | |
| 212 | + | case DefString: | |
| 213 | + | for i, k := range body.keys { | |
| 214 | + | v := body.vals[i] | |
| 215 | + | switch k { | |
| 216 | + | case "constant": | |
| 217 | + | d.Constant = v.str | |
| 218 | + | case "ignore-case": | |
| 219 | + | d.IgnoreCase = v.b | |
| 220 | + | case "require-non-empty": | |
| 221 | + | d.RequireNonEmpty = v.b | |
| 222 | + | case "is-expression": | |
| 223 | + | d.IsExpression = v.b | |
| 224 | + | default: | |
| 225 | + | fatalf("Error while reading 'definition string key'. Unexpected value '%s'", k) | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | case DefSequence: | |
| 229 | + | if t := body.get("item-type"); t != nil { | |
| 230 | + | d.ItemType = t.str | |
| 231 | + | } | |
| 232 | + | case DefMapping: | |
| 233 | + | d.Properties = map[string]Property{} | |
| 234 | + | for i, k := range body.keys { | |
| 235 | + | v := body.vals[i] | |
| 236 | + | switch k { | |
| 237 | + | case "properties": | |
| 238 | + | for j, pn := range v.keys { | |
| 239 | + | d.PropertyNames = append(d.PropertyNames, pn) | |
| 240 | + | d.Properties[pn] = loadProperty(v.vals[j]) | |
| 241 | + | } | |
| 242 | + | case "loose-key-type": | |
| 243 | + | d.LooseKeyType = v.str | |
| 244 | + | case "loose-value-type": | |
| 245 | + | d.LooseValueType = v.str | |
| 246 | + | default: | |
| 247 | + | fatalf("Error while reading 'definition mapping key'. Unexpected value '%s'", k) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | case DefOneOf: | |
| 251 | + | if body != nil && body.kind == 'a' && len(d.OneOf) == 0 { | |
| 252 | + | for _, it := range body.vals { | |
| 253 | + | d.OneOf = append(d.OneOf, it.str) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | s.definitions[name] = d | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func loadProperty(n *jnode) Property { | |
| 261 | + | if n.kind == 's' { | |
| 262 | + | return Property{Type: n.str} | |
| 263 | + | } | |
| 264 | + | var p Property | |
| 265 | + | for i, k := range n.keys { | |
| 266 | + | v := n.vals[i] | |
| 267 | + | switch k { | |
| 268 | + | case "type": | |
| 269 | + | p.Type = v.str | |
| 270 | + | case "required": | |
| 271 | + | p.Required = v.b | |
| 272 | + | case "description": | |
| 273 | + | p.Description = v.str | |
| 274 | + | default: | |
| 275 | + | fatalf("Error while reading 'mapping-property-value key'. Unexpected value '%s'", k) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | return p | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // validate checks that every reference resolves and the one-of definitions | |
| 282 | + | // are unambiguous, as TemplateSchema.validate does. | |
| 283 | + | func (s *Schema) validate() { | |
| 284 | + | for name, d := range s.definitions { | |
| 285 | + | if !validDefinitionName(name) { | |
| 286 | + | fatalf("Invalid definition name '%s'", name) | |
| 287 | + | } | |
| 288 | + | switch d.Type { | |
| 289 | + | case DefString: | |
| 290 | + | if d.Constant != "" && d.RequireNonEmpty { | |
| 291 | + | fatalf("Properties 'constant' and 'require-non-empty' cannot both be set") | |
| 292 | + | } | |
| 293 | + | case DefSequence: | |
| 294 | + | if d.ItemType == "" { | |
| 295 | + | fatalf("'%s' does not defined 'item-type'", name) | |
| 296 | + | } | |
| 297 | + | s.get(d.ItemType) | |
| 298 | + | case DefMapping: | |
| 299 | + | switch { | |
| 300 | + | case d.LooseKeyType != "": | |
| 301 | + | s.get(d.LooseKeyType) | |
| 302 | + | if d.LooseValueType == "" { | |
| 303 | + | fatalf("Property 'loose-key-type' is defined but 'loose-value-type' is not defined on '%s'", name) | |
| 304 | + | } | |
| 305 | + | s.get(d.LooseValueType) | |
| 306 | + | case d.LooseValueType != "": | |
| 307 | + | fatalf("Property 'loose-value-type' is defined but 'loose-key-type' is not defined on '%s'", name) | |
| 308 | + | } | |
| 309 | + | for pn, p := range d.Properties { | |
| 310 | + | if p.Type == "" { | |
| 311 | + | fatalf("Type not specified for the property '%s' on '%s'", pn, name) | |
| 312 | + | } | |
| 313 | + | s.get(p.Type) | |
| 314 | + | } | |
| 315 | + | case DefOneOf: | |
| 316 | + | if len(d.OneOf) == 0 { | |
| 317 | + | fatalf("'%s' does not contain any references", name) | |
| 318 | + | } | |
| 319 | + | seen := map[string]bool{} | |
| 320 | + | for _, n := range d.OneOf { | |
| 321 | + | if seen[n] { | |
| 322 | + | fatalf("'%s' contains duplicate nested type '%s'", name, n) | |
| 323 | + | } | |
| 324 | + | seen[n] = true | |
| 325 | + | if len(s.get(n).Context) > 0 { | |
| 326 | + | fatalf("'%s' is a one-of definition and references another definition that defines context. This is currently not supported.", name) | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func validDefinitionName(n string) bool { | |
| 334 | + | if n == "" { | |
| 335 | + | return false | |
| 336 | + | } | |
| 337 | + | for i, r := range n { | |
| 338 | + | switch { | |
| 339 | + | case r == '_' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z': | |
| 340 | + | case i > 0 && (r == '-' || r >= '0' && r <= '9'): | |
| 341 | + | default: | |
| 342 | + | return false | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | return true | |
| 346 | + | } | |
A template/schema_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "reflect" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestWorkflowSchemaLoads(t *testing.T) { | |
| 12 | + | s, err := WorkflowSchema() | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | if s.Version != "workflow-v1.0" { | |
| 17 | + | t.Errorf("version = %q", s.Version) | |
| 18 | + | } | |
| 19 | + | root := s.Definition("workflow-root-strict") | |
| 20 | + | if root == nil || !root.Properties["jobs"].Required || !root.Properties["on"].Required { | |
| 21 | + | t.Fatalf("workflow-root-strict = %+v", root) | |
| 22 | + | } | |
| 23 | + | if d := s.Definition("push-string"); d == nil || d.Constant != "push" { | |
| 24 | + | t.Errorf("push-string = %+v", d) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestAllowedContext(t *testing.T) { | |
| 29 | + | s, err := WorkflowSchema() | |
| 30 | + | if err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | di := newDefinitionInfo(s, nil, s.Definition("step-if")) | |
| 34 | + | if got, want := di.NamedContexts(), []string{"github", "inputs", "vars", "needs", "strategy", "matrix", "steps", "job", "runner", "env"}; !reflect.DeepEqual(got, want) { | |
| 35 | + | t.Errorf("named = %v", got) | |
| 36 | + | } | |
| 37 | + | var fs []string | |
| 38 | + | for _, f := range di.Functions() { | |
| 39 | + | fs = append(fs, f.Name) | |
| 40 | + | } | |
| 41 | + | if got, want := strings.Join(fs, ","), "always,failure,cancelled,success,hashFiles"; got != want { | |
| 42 | + | t.Errorf("funcs = %s", got) | |
| 43 | + | } | |
| 44 | + | ji := newDefinitionInfo(s, nil, s.Definition("job-if")) | |
| 45 | + | for _, f := range ji.Functions() { | |
| 46 | + | if f.Name == "success" && f.MaxArgs != -1 { | |
| 47 | + | t.Errorf("job-if success max = %d", f.MaxArgs) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestParseFuncEntry(t *testing.T) { | |
| 53 | + | for in, ok := range map[string]bool{ | |
| 54 | + | "hashFiles(1,255)": true, "always(0,0)": true, "failure(0,MAX)": true, | |
| 55 | + | "github": false, "f(1)": false, "f(a,2)": false, "(1,2)": false, "f(1,2) ": false, | |
| 56 | + | } { | |
| 57 | + | if _, got := parseFuncEntry(in); got != ok { | |
| 58 | + | t.Errorf("%q: %v", in, got) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestParseSchemaErrors(t *testing.T) { | |
| 64 | + | for in, want := range map[string]string{ | |
| 65 | + | `{"definitions":{"a":{"sequence":{}}}}`: "'a' does not defined 'item-type'", | |
| 66 | + | `{"definitions":{"a":{"mapping":{"loose-key-type":"string"}}}}`: "Property 'loose-key-type' is defined but 'loose-value-type' is not defined on 'a'", | |
| 67 | + | `{"definitions":{"a":{"one-of":["zzz"]}}}`: "Schema definition 'zzz' not found", | |
| 68 | + | `{"definitions":{"a":{"description":"x"}}}`: "Not enough information to construct definition 'a'", | |
| 69 | + | `{"definitions":{"a b":{"string":{}}}}`: "Invalid definition name 'a b'", | |
| 70 | + | `{"definitions":{"a":{"string":{"constant":"x","require-non-empty":true}}}}`: "cannot both be set", | |
| 71 | + | `{"foo":1}`: "Unexpected value 'foo'", | |
| 72 | + | } { | |
| 73 | + | _, err := ParseSchema([]byte(in)) | |
| 74 | + | if err == nil || !strings.Contains(err.Error(), want) { | |
| 75 | + | t.Errorf("%s: err = %v, want %q", in, err, want) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
A template/testdata/SOURCE +2 -0
| @@ -0,0 +1,2 @@ | |||
| 1 | + | scalars.json is the output of the yaml npm package (https://github.com/eemeli/yaml, ISC) used by | |
| 2 | + | @actions/workflow-parser 0.3.61, on the scalars listed in ../gen/scalars.mjs. | |
A template/testdata/scalars.json +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | {"0":"num:0","yes":"str:yes","no":"str:no","on":"str:on","off":"str:off","y":"str:y","n":"str:n","~":"null","null":"null","Null":"null","NULL":"null","":"null","true":"bool:true","True":"bool:true","TRUE":"bool:true","tRUE":"str:tRUE","false":"bool:false","-0":"num:0","+1":"num:1","017":"num:17","0o17":"num:15","0O17":"str:0O17","0x1F":"num:31","0X1f":"str:0X1f","0b11":"str:0b11","1_000":"str:1_000","1:30":"str:1:30","1e3":"num:1000","1E3":"num:1000","1.":"num:1","1.5":"num:1.5","-.5":"num:-0.5",".5":"num:0.5","+.5":"num:0.5",".inf":"num:Infinity","-.inf":"num:-Infinity",".Inf":"num:Infinity",".INF":"num:Infinity","+.inf":"num:Infinity",".nan":"num:NaN",".NaN":"num:NaN",".NAN":"num:NaN","-.nan":"str:-.nan","1e":"str:1e","e1":"str:e1","0x":"str:0x","0xg":"str:0xg","12abc":"str:12abc","2001-01-01":"str:2001-01-01","1.5e+3":"num:1500","0.":"num:0","00":"num:0","08":"num:8","-017":"num:-17","+0x1F":"str:+0x1F","-0x1F":"str:-0x1F","1,000":"str:1,000","Infinity":"str:Infinity","NaN":"str:NaN","inf":"str:inf","=":"str:=","<<":"str:<<","!!str 1":"str:1","!!int 5":"num:5"} | |
A template/tokens.go +323 -0
| @@ -0,0 +1,323 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite-actions/expr" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Kind is the type of a [Token], numbered as in the reference. | |
| 12 | + | type Kind int | |
| 13 | + | ||
| 14 | + | // The token kinds. | |
| 15 | + | const ( | |
| 16 | + | KindString Kind = iota | |
| 17 | + | KindSequence | |
| 18 | + | KindMapping | |
| 19 | + | KindBasicExpression | |
| 20 | + | KindInsertExpression | |
| 21 | + | KindBoolean | |
| 22 | + | KindNumber | |
| 23 | + | KindNull | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // String returns the reference's name for the kind, such as "StringToken". | |
| 27 | + | func (k Kind) String() string { | |
| 28 | + | switch k { | |
| 29 | + | case KindString: | |
| 30 | + | return "StringToken" | |
| 31 | + | case KindSequence: | |
| 32 | + | return "SequenceToken" | |
| 33 | + | case KindMapping: | |
| 34 | + | return "MappingToken" | |
| 35 | + | case KindBasicExpression: | |
| 36 | + | return "BasicExpressionToken" | |
| 37 | + | case KindInsertExpression: | |
| 38 | + | return "InsertExpressionToken" | |
| 39 | + | case KindBoolean: | |
| 40 | + | return "BooleanToken" | |
| 41 | + | case KindNumber: | |
| 42 | + | return "NumberToken" | |
| 43 | + | case KindNull: | |
| 44 | + | return "NullToken" | |
| 45 | + | } | |
| 46 | + | return "unknown" | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Pos is a position in a source file. Line and Col are 1-based; the zero | |
| 50 | + | // value means the position is unknown (a token made by the program). | |
| 51 | + | type Pos struct{ Line, Col int } | |
| 52 | + | ||
| 53 | + | // Token is a node of a template: a scalar, a sequence or a mapping. The | |
| 54 | + | // concrete types are [*String], [*Number], [*Boolean], [*Null], | |
| 55 | + | // [*Sequence], [*Mapping], [*BasicExpression] and [*InsertExpression]. | |
| 56 | + | type Token interface { | |
| 57 | + | // Kind reports the type of the token. | |
| 58 | + | Kind() Kind | |
| 59 | + | // File is the name of the file the token was read from, or "". | |
| 60 | + | File() string | |
| 61 | + | // Pos is where the token starts in File. | |
| 62 | + | Pos() Pos | |
| 63 | + | // Definition is the schema definition the token was read against, or nil. | |
| 64 | + | Definition() *DefinitionInfo | |
| 65 | + | // String is the text the reference shows in messages: the value of a | |
| 66 | + | // literal, "${{ expr }}" for an expression. | |
| 67 | + | String() string | |
| 68 | + | base() *tokenBase | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | type tokenBase struct { | |
| 72 | + | file string | |
| 73 | + | pos Pos | |
| 74 | + | def *DefinitionInfo | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (m *tokenBase) File() string { return m.file } | |
| 78 | + | func (m *tokenBase) Pos() Pos { return m.pos } | |
| 79 | + | func (m *tokenBase) Definition() *DefinitionInfo { return m.def } | |
| 80 | + | func (m *tokenBase) base() *tokenBase { return m } | |
| 81 | + | ||
| 82 | + | // Origin is where a token comes from; the zero value is a token made by the | |
| 83 | + | // program. | |
| 84 | + | type Origin struct { | |
| 85 | + | File string | |
| 86 | + | Pos Pos | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func mk(o Origin) tokenBase { return tokenBase{file: o.File, pos: o.Pos} } | |
| 90 | + | ||
| 91 | + | // String is a string literal. | |
| 92 | + | type String struct { | |
| 93 | + | tokenBase | |
| 94 | + | Value string | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Number is a number literal. | |
| 98 | + | type Number struct { | |
| 99 | + | tokenBase | |
| 100 | + | Value float64 | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Boolean is a true or false literal. | |
| 104 | + | type Boolean struct { | |
| 105 | + | tokenBase | |
| 106 | + | Value bool | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Null is a null literal. | |
| 110 | + | type Null struct{ tokenBase } | |
| 111 | + | ||
| 112 | + | // Sequence is a list of tokens. | |
| 113 | + | type Sequence struct { | |
| 114 | + | tokenBase | |
| 115 | + | Items []Token | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // Pair is a key and its value in a [Mapping]. The key is a scalar: a | |
| 119 | + | // [*String] or an expression. | |
| 120 | + | type Pair struct { | |
| 121 | + | Key, Value Token | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Mapping is an ordered list of key-value pairs. Duplicate keys are rejected | |
| 125 | + | // when reading, comparing case-insensitively. | |
| 126 | + | type Mapping struct { | |
| 127 | + | tokenBase | |
| 128 | + | Pairs []Pair | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // BasicExpression is a ${{ }} expression. Expression is the text between the | |
| 132 | + | // braces, trimmed, or a format() call when a string mixes text and | |
| 133 | + | // expressions. | |
| 134 | + | type BasicExpression struct { | |
| 135 | + | tokenBase | |
| 136 | + | Expression string | |
| 137 | + | // Original holds the individual expressions when Expression is the | |
| 138 | + | // format() call built from a string with several parts. | |
| 139 | + | Original []*BasicExpression | |
| 140 | + | // Source is the unprocessed text of the value the token was read from. | |
| 141 | + | Source string | |
| 142 | + | ||
| 143 | + | node expr.Node | |
| 144 | + | parseErr error | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // InsertExpression is the ${{ insert }} directive. | |
| 148 | + | type InsertExpression struct{ tokenBase } | |
| 149 | + | ||
| 150 | + | func (*String) Kind() Kind { return KindString } | |
| 151 | + | func (*Number) Kind() Kind { return KindNumber } | |
| 152 | + | func (*Boolean) Kind() Kind { return KindBoolean } | |
| 153 | + | func (*Null) Kind() Kind { return KindNull } | |
| 154 | + | func (*Sequence) Kind() Kind { return KindSequence } | |
| 155 | + | func (*Mapping) Kind() Kind { return KindMapping } | |
| 156 | + | func (*BasicExpression) Kind() Kind { return KindBasicExpression } | |
| 157 | + | func (*InsertExpression) Kind() Kind { return KindInsertExpression } | |
| 158 | + | ||
| 159 | + | func (t *String) String() string { return t.Value } | |
| 160 | + | func (t *Number) String() string { return JSNumberString(t.Value) } | |
| 161 | + | func (*Null) String() string { return "" } | |
| 162 | + | func (*Sequence) String() string { return "" } | |
| 163 | + | func (*Mapping) String() string { return "" } | |
| 164 | + | func (t *Boolean) String() string { return boolString(t.Value) } | |
| 165 | + | ||
| 166 | + | func (t *BasicExpression) String() string { return "${{ " + t.Expression + " }}" } | |
| 167 | + | func (*InsertExpression) String() string { return "${{ insert }}" } | |
| 168 | + | ||
| 169 | + | func boolString(b bool) string { | |
| 170 | + | if b { | |
| 171 | + | return "true" | |
| 172 | + | } | |
| 173 | + | return "false" | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // NewString returns a string token. | |
| 177 | + | func NewString(o Origin, v string) *String { return &String{tokenBase: mk(o), Value: v} } | |
| 178 | + | ||
| 179 | + | // NewNumber returns a number token. | |
| 180 | + | func NewNumber(o Origin, v float64) *Number { return &Number{tokenBase: mk(o), Value: v} } | |
| 181 | + | ||
| 182 | + | // NewBoolean returns a boolean token. | |
| 183 | + | func NewBoolean(o Origin, v bool) *Boolean { return &Boolean{tokenBase: mk(o), Value: v} } | |
| 184 | + | ||
| 185 | + | // NewNull returns a null token. | |
| 186 | + | func NewNull(o Origin) *Null { return &Null{tokenBase: mk(o)} } | |
| 187 | + | ||
| 188 | + | // NewSequence returns a sequence token holding items. | |
| 189 | + | func NewSequence(o Origin, items ...Token) *Sequence { | |
| 190 | + | return &Sequence{tokenBase: mk(o), Items: items} | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // NewMapping returns an empty mapping token. | |
| 194 | + | func NewMapping(o Origin) *Mapping { return &Mapping{tokenBase: mk(o)} } | |
| 195 | + | ||
| 196 | + | // NewExpression returns an expression token. The text is parsed at once | |
| 197 | + | // against the contexts and functions allowed by def; with a nil def no | |
| 198 | + | // context and no function besides the built-in ones is allowed. A parse error | |
| 199 | + | // is returned by [Evaluate] and [BasicExpression.Err]. | |
| 200 | + | func NewExpression(o Origin, expression string, def *DefinitionInfo) *BasicExpression { | |
| 201 | + | t := &BasicExpression{tokenBase: mk(o), Expression: expression} | |
| 202 | + | t.def = def | |
| 203 | + | t.node, t.parseErr = parseWith(expression, def) | |
| 204 | + | return t | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Err returns the error of parsing the expression, or nil. | |
| 208 | + | func (t *BasicExpression) Err() error { return t.parseErr } | |
| 209 | + | ||
| 210 | + | // Add appends a pair. | |
| 211 | + | func (m *Mapping) Add(key, value Token) { m.Pairs = append(m.Pairs, Pair{key, value}) } | |
| 212 | + | ||
| 213 | + | // Find returns the value of the first pair whose key's text is exactly key, | |
| 214 | + | // or nil. | |
| 215 | + | func (m *Mapping) Find(key string) Token { | |
| 216 | + | for _, p := range m.Pairs { | |
| 217 | + | if p.Key.String() == key { | |
| 218 | + | return p.Value | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | return nil | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // IsScalar reports whether t is neither a sequence nor a mapping. | |
| 225 | + | func IsScalar(t Token) bool { | |
| 226 | + | switch t.Kind() { | |
| 227 | + | case KindSequence, KindMapping: | |
| 228 | + | return false | |
| 229 | + | } | |
| 230 | + | return true | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // IsLiteral reports whether t is a string, number, boolean or null. | |
| 234 | + | func IsLiteral(t Token) bool { | |
| 235 | + | switch t.Kind() { | |
| 236 | + | case KindString, KindNumber, KindBoolean, KindNull: | |
| 237 | + | return true | |
| 238 | + | } | |
| 239 | + | return false | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // IsExpression reports whether t is a basic or insert expression. | |
| 243 | + | func IsExpression(t Token) bool { | |
| 244 | + | k := t.Kind() | |
| 245 | + | return k == KindBasicExpression || k == KindInsertExpression | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // Walk calls fn for t and for every token below it, depth first, mapping keys | |
| 249 | + | // included. It stops descending into a token when fn returns false. | |
| 250 | + | func Walk(t Token, fn func(Token) bool) { | |
| 251 | + | if t == nil || !fn(t) { | |
| 252 | + | return | |
| 253 | + | } | |
| 254 | + | switch t := t.(type) { | |
| 255 | + | case *Sequence: | |
| 256 | + | for _, it := range t.Items { | |
| 257 | + | Walk(it, fn) | |
| 258 | + | } | |
| 259 | + | case *Mapping: | |
| 260 | + | for _, p := range t.Pairs { | |
| 261 | + | Walk(p.Key, fn) | |
| 262 | + | Walk(p.Value, fn) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // HasExpression reports whether t, or anything below it, is an expression. | |
| 268 | + | func HasExpression(t Token) bool { | |
| 269 | + | found := false | |
| 270 | + | Walk(t, func(x Token) bool { | |
| 271 | + | if IsExpression(x) { | |
| 272 | + | found = true | |
| 273 | + | } | |
| 274 | + | return !found | |
| 275 | + | }) | |
| 276 | + | return found | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func trimDisplay(s string) string { | |
| 280 | + | s = strings.TrimLeft(s, " \t\r\n\v\f") | |
| 281 | + | if i := strings.IndexAny(s, "\r\n"); i >= 0 { | |
| 282 | + | s = s[:i] | |
| 283 | + | } | |
| 284 | + | return s | |
| 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 } | |
| 291 | + | ||
| 292 | + | // UsesContext reports whether the expression reads the named context | |
| 293 | + | // ("matrix", "secrets"...), anywhere in its syntax tree and whatever the | |
| 294 | + | // case. A function call is not a use of a context named like the function. | |
| 295 | + | func (t *BasicExpression) UsesContext(name string) bool { return nodeUsesContext(t.node, name) } | |
| 296 | + | ||
| 297 | + | func nodeUsesContext(n expr.Node, name string) bool { | |
| 298 | + | switch n := n.(type) { | |
| 299 | + | case *expr.ContextAccess: | |
| 300 | + | return strings.EqualFold(n.Name.Lexeme, name) | |
| 301 | + | case *expr.Unary: | |
| 302 | + | return nodeUsesContext(n.Expr, name) | |
| 303 | + | case *expr.Binary: | |
| 304 | + | return nodeUsesContext(n.Left, name) || nodeUsesContext(n.Right, name) | |
| 305 | + | case *expr.Grouping: | |
| 306 | + | return nodeUsesContext(n.Group, name) | |
| 307 | + | case *expr.IndexAccess: | |
| 308 | + | return nodeUsesContext(n.Expr, name) || nodeUsesContext(n.Index, name) | |
| 309 | + | case *expr.Logical: | |
| 310 | + | for _, a := range n.Args { | |
| 311 | + | if nodeUsesContext(a, name) { | |
| 312 | + | return true | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | case *expr.FunctionCall: | |
| 316 | + | for _, a := range n.Args { | |
| 317 | + | if nodeUsesContext(a, name) { | |
| 318 | + | return true | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | return false | |
| 323 | + | } | |
A template/yaml.go +272 -0
| @@ -0,0 +1,272 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package template | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "math" | |
| 11 | + | "regexp" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "go.yaml.in/yaml/v3" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // MaxFileBytes is the largest document [ReadYAML] accepts: 1 MiB. The check | |
| 19 | + | // comes before any parsing, so that a hostile file costs nothing more than | |
| 20 | + | // its length; a file this size can still expand through aliases (see | |
| 21 | + | // [MaxNodes]). | |
| 22 | + | // | |
| 23 | + | // TODO(verify with recording): GitHub's own limit and message. | |
| 24 | + | const MaxFileBytes = 1 << 20 | |
| 25 | + | ||
| 26 | + | // MaxNodes is the number of YAML nodes a document may expand to, counting | |
| 27 | + | // every use of an alias. The reference expands aliases without a limit, so a | |
| 28 | + | // small document of nested aliases can allocate gigabytes. | |
| 29 | + | const MaxNodes = 200_000 | |
| 30 | + | ||
| 31 | + | type rkind int | |
| 32 | + | ||
| 33 | + | const ( | |
| 34 | + | rLiteral rkind = iota | |
| 35 | + | rSeq | |
| 36 | + | rMap | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // rnode is a YAML node after alias resolution: the tree the template reader | |
| 40 | + | // walks. | |
| 41 | + | type rnode struct { | |
| 42 | + | kind rkind | |
| 43 | + | pos Pos | |
| 44 | + | lit Token // rLiteral: a *String, *Number, *Boolean or *Null | |
| 45 | + | items []*rnode | |
| 46 | + | keys []*rnode // rMap: the keys, rLiteral nodes with string tokens | |
| 47 | + | vals []*rnode // rMap: the values, parallel to keys | |
| 48 | + | // quoted and block describe how a string scalar was written; they only | |
| 49 | + | // matter to the position of expressions inside it. | |
| 50 | + | quoted bool | |
| 51 | + | block bool | |
| 52 | + | // badKey is set on a key that is a collection. | |
| 53 | + | badKey bool | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // yamlResult is the outcome of reading a YAML document. | |
| 57 | + | type yamlResult struct { | |
| 58 | + | root *rnode // nil for an empty document | |
| 59 | + | errs []*Error | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | type yamlBuilder struct { | |
| 63 | + | file string | |
| 64 | + | nodes int | |
| 65 | + | errs []*Error | |
| 66 | + | stack map[*yaml.Node]bool | |
| 67 | + | over bool | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // readYAMLDocument parses content with yaml.v3 and builds the tree. Syntax | |
| 71 | + | // errors are returned in errs, with root nil. | |
| 72 | + | func readYAMLDocument(file string, content []byte) (res yamlResult) { | |
| 73 | + | defer func() { | |
| 74 | + | if r := recover(); r != nil { | |
| 75 | + | res = yamlResult{errs: []*Error{{File: file, Raw: fmt.Sprintf("%v", r)}}} | |
| 76 | + | } | |
| 77 | + | }() | |
| 78 | + | dec := yaml.NewDecoder(bytes.NewReader(content)) | |
| 79 | + | var doc yaml.Node | |
| 80 | + | err := dec.Decode(&doc) | |
| 81 | + | if errors.Is(err, io.EOF) { | |
| 82 | + | return yamlResult{} | |
| 83 | + | } | |
| 84 | + | if err != nil { | |
| 85 | + | return yamlResult{errs: []*Error{yamlError(file, err)}} | |
| 86 | + | } | |
| 87 | + | var second yaml.Node | |
| 88 | + | if err := dec.Decode(&second); err == nil { | |
| 89 | + | e := &Error{File: file, Raw: "Source contains multiple documents; please use YAML.parseAllDocuments()"} | |
| 90 | + | e.Pos = Pos{second.Line, second.Column} | |
| 91 | + | e.fileOnly = true | |
| 92 | + | e.Raw += fmt.Sprintf(" at line %d, column %d", second.Line, second.Column) | |
| 93 | + | return yamlResult{errs: []*Error{e}} | |
| 94 | + | } else if !errors.Is(err, io.EOF) { | |
| 95 | + | return yamlResult{errs: []*Error{yamlError(file, err)}} | |
| 96 | + | } | |
| 97 | + | if len(doc.Content) == 0 { | |
| 98 | + | return yamlResult{} | |
| 99 | + | } | |
| 100 | + | b := &yamlBuilder{file: file, stack: map[*yaml.Node]bool{}} | |
| 101 | + | root := b.build(doc.Content[0], false) | |
| 102 | + | if b.over { | |
| 103 | + | return yamlResult{errs: b.errs} | |
| 104 | + | } | |
| 105 | + | return yamlResult{root: root, errs: b.errs} | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | var yamlLineRE = regexp.MustCompile(`^(?:yaml: )?(?:unmarshal errors:\n\s*)?(?:line (\d+): )?`) | |
| 109 | + | ||
| 110 | + | // yamlError converts an error of yaml.v3 ("yaml: line 3: did not find ...") | |
| 111 | + | // to an Error. The wording is yaml.v3's, not the JavaScript library's, and | |
| 112 | + | // only the line is known. | |
| 113 | + | func yamlError(file string, err error) *Error { | |
| 114 | + | msg := err.Error() | |
| 115 | + | e := &Error{File: file, fileOnly: true} | |
| 116 | + | if m := yamlLineRE.FindStringSubmatch(msg); m != nil { | |
| 117 | + | if m[1] != "" { | |
| 118 | + | e.Pos.Line, _ = strconv.Atoi(m[1]) | |
| 119 | + | } | |
| 120 | + | msg = msg[len(m[0]):] | |
| 121 | + | } | |
| 122 | + | e.Raw = msg | |
| 123 | + | if e.Pos.Line > 0 { | |
| 124 | + | e.Raw += fmt.Sprintf(" at line %d", e.Pos.Line) | |
| 125 | + | } | |
| 126 | + | return e | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func (b *yamlBuilder) count() bool { | |
| 130 | + | b.nodes++ | |
| 131 | + | if b.nodes > MaxNodes { | |
| 132 | + | if !b.over { | |
| 133 | + | b.over = true | |
| 134 | + | b.errs = append(b.errs, &Error{File: b.file, Raw: fmt.Sprintf("The document is too large: it has more than %d nodes once aliases are expanded", MaxNodes)}) | |
| 135 | + | } | |
| 136 | + | return false | |
| 137 | + | } | |
| 138 | + | return true | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (b *yamlBuilder) build(n *yaml.Node, asKey bool) *rnode { | |
| 142 | + | if b.over || !b.count() { | |
| 143 | + | return &rnode{kind: rLiteral, lit: NewNull(Origin{})} | |
| 144 | + | } | |
| 145 | + | pos := Pos{n.Line, n.Column} | |
| 146 | + | o := Origin{File: b.file, Pos: pos} | |
| 147 | + | switch n.Kind { | |
| 148 | + | case yaml.AliasNode: | |
| 149 | + | target := n.Alias | |
| 150 | + | if target == nil || b.stack[target] { | |
| 151 | + | b.errs = append(b.errs, &Error{File: b.file, Pos: pos, Raw: fmt.Sprintf("Circular reference through the YAML alias '*%s'", n.Value)}) | |
| 152 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)} | |
| 153 | + | } | |
| 154 | + | b.stack[target] = true | |
| 155 | + | r := b.build(target, asKey) | |
| 156 | + | delete(b.stack, target) | |
| 157 | + | return r | |
| 158 | + | case yaml.SequenceNode: | |
| 159 | + | if asKey { | |
| 160 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true} | |
| 161 | + | } | |
| 162 | + | r := &rnode{kind: rSeq, pos: pos} | |
| 163 | + | b.stack[n] = true | |
| 164 | + | for _, c := range n.Content { | |
| 165 | + | r.items = append(r.items, b.build(c, false)) | |
| 166 | + | } | |
| 167 | + | delete(b.stack, n) | |
| 168 | + | return r | |
| 169 | + | case yaml.MappingNode: | |
| 170 | + | if asKey { | |
| 171 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewString(o, ""), badKey: true} | |
| 172 | + | } | |
| 173 | + | r := &rnode{kind: rMap, pos: pos} | |
| 174 | + | b.stack[n] = true | |
| 175 | + | for i := 0; i+1 < len(n.Content); i += 2 { | |
| 176 | + | r.keys = append(r.keys, b.build(n.Content[i], true)) | |
| 177 | + | r.vals = append(r.vals, b.build(n.Content[i+1], false)) | |
| 178 | + | } | |
| 179 | + | delete(b.stack, n) | |
| 180 | + | return r | |
| 181 | + | case yaml.ScalarNode: | |
| 182 | + | r := &rnode{kind: rLiteral, pos: pos} | |
| 183 | + | if asKey { | |
| 184 | + | // Keys are always strings, whatever they look like. | |
| 185 | + | r.lit = NewString(o, n.Value) | |
| 186 | + | } else { | |
| 187 | + | r.lit = resolveScalar(o, n) | |
| 188 | + | } | |
| 189 | + | r.quoted = n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle) != 0 | |
| 190 | + | r.block = n.Style&(yaml.LiteralStyle|yaml.FoldedStyle) != 0 | |
| 191 | + | return r | |
| 192 | + | } | |
| 193 | + | return &rnode{kind: rLiteral, pos: pos, lit: NewNull(o)} | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // resolveScalar gives a scalar its type the way the YAML 1.2 core schema of | |
| 197 | + | // the JavaScript yaml library does: only plain scalars are resolved, and an | |
| 198 | + | // explicit !!tag overrides the guess. | |
| 199 | + | func resolveScalar(o Origin, n *yaml.Node) Token { | |
| 200 | + | if n.Style&yaml.TaggedStyle != 0 { | |
| 201 | + | switch n.ShortTag() { | |
| 202 | + | case "!!str": | |
| 203 | + | return NewString(o, n.Value) | |
| 204 | + | case "!!null", "!!bool", "!!int", "!!float": | |
| 205 | + | if t := resolvePlain(o, n.Value); t.Kind() != KindString { | |
| 206 | + | return t | |
| 207 | + | } | |
| 208 | + | if n.ShortTag() == "!!null" && n.Value == "" { | |
| 209 | + | return NewNull(o) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | return NewString(o, n.Value) | |
| 213 | + | } | |
| 214 | + | if n.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 { | |
| 215 | + | return NewString(o, n.Value) | |
| 216 | + | } | |
| 217 | + | return resolvePlain(o, n.Value) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | var ( | |
| 221 | + | reInt = regexp.MustCompile(`^[-+]?[0-9]+$`) | |
| 222 | + | reOct = regexp.MustCompile(`^0o[0-7]+$`) | |
| 223 | + | reHex = regexp.MustCompile(`^0x[0-9a-fA-F]+$`) | |
| 224 | + | reFloat = regexp.MustCompile(`^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?$`) | |
| 225 | + | reInf = regexp.MustCompile(`^[-+]?\.(?:inf|Inf|INF)$`) | |
| 226 | + | reNaN = regexp.MustCompile(`^\.(?:nan|NaN|NAN)$`) | |
| 227 | + | ) | |
| 228 | + | ||
| 229 | + | func resolvePlain(o Origin, s string) Token { | |
| 230 | + | switch s { | |
| 231 | + | case "", "~", "null", "Null", "NULL": | |
| 232 | + | return NewNull(o) | |
| 233 | + | case "true", "True", "TRUE": | |
| 234 | + | return NewBoolean(o, true) | |
| 235 | + | case "false", "False", "FALSE": | |
| 236 | + | return NewBoolean(o, false) | |
| 237 | + | } | |
| 238 | + | switch { | |
| 239 | + | case reInt.MatchString(s): | |
| 240 | + | f, _ := strconv.ParseFloat(s, 64) | |
| 241 | + | return NewNumber(o, f) | |
| 242 | + | case reOct.MatchString(s): | |
| 243 | + | return NewNumber(o, parseBase(s[2:], 8)) | |
| 244 | + | case reHex.MatchString(s): | |
| 245 | + | return NewNumber(o, parseBase(s[2:], 16)) | |
| 246 | + | case reFloat.MatchString(s): | |
| 247 | + | f, _ := strconv.ParseFloat(s, 64) | |
| 248 | + | return NewNumber(o, f) | |
| 249 | + | case reInf.MatchString(s): | |
| 250 | + | if s[0] == '-' { | |
| 251 | + | return NewNumber(o, math.Inf(-1)) | |
| 252 | + | } | |
| 253 | + | return NewNumber(o, math.Inf(1)) | |
| 254 | + | case reNaN.MatchString(s): | |
| 255 | + | return NewNumber(o, math.NaN()) | |
| 256 | + | } | |
| 257 | + | return NewString(o, s) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // parseBase converts digits in base 8 or 16 to a float64 without overflowing, | |
| 261 | + | // like JavaScript's parseInt. | |
| 262 | + | func parseBase(digits string, base int) float64 { | |
| 263 | + | var f float64 | |
| 264 | + | for _, c := range strings.ToLower(digits) { | |
| 265 | + | d := int(c - '0') | |
| 266 | + | if c >= 'a' { | |
| 267 | + | d = int(c-'a') + 10 | |
| 268 | + | } | |
| 269 | + | f = f*float64(base) + float64(d) | |
| 270 | + | } | |
| 271 | + | return f | |
| 272 | + | } | |
A workflow/PORTING.md +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | # Porting notes for package workflow | |
| 2 | + | ||
| 3 | + | `Parse` is a port of `model/convert` and `model/converter/*` of | |
| 4 | + | `@actions/workflow-parser` 0.3.61 on top of package `template`. The reference's | |
| 5 | + | errors are replayed in `TestConvertAgainstReference` (153 files in | |
| 6 | + | `testdata/convert`, answers in `testdata/convert.json`, generated by | |
| 7 | + | `gen/convert.mjs` from the unmodified npm package). | |
| 8 | + | ||
| 9 | + | The parts below are **not** in that package. They are written from GitHub's | |
| 10 | + | documentation and from what is known of the official runner, and each needs a | |
| 11 | + | recording of github.com (`ermite-conformance`) or a look at the C# runner | |
| 12 | + | before being trusted. Every one carries a `TODO(verify with recording)` in the | |
| 13 | + | code. | |
| 14 | + | ||
| 15 | + | ## Differences from the reference | |
| 16 | + | ||
| 17 | + | - **`branches` with `branches-ignore`** (and `tags`, `paths`): the reference | |
| 18 | + | accepts them; GitHub refuses the file. `Parse` reports | |
| 19 | + | `Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one | |
| 20 | + | of them.` (our wording, to replace with GitHub's). Applied to every event. | |
| 21 | + | - **A filter list made only of `!` patterns** (`branches: ['!main']`) is an | |
| 22 | + | error (`Event 'push' filter 'branches' has only patterns that start with | |
| 23 | + | '!'...`), and so is a pattern that does not compile. Both are ours; GitHub's | |
| 24 | + | texts are unknown. Applied to every event and every filter. | |
| 25 | + | - **Matrix exclude** with a key that is not a vector of the matrix is | |
| 26 | + | `Matrix exclude key '<k>' does not match any key within the matrix` | |
| 27 | + | (GitHub's text as remembered, not recorded). | |
| 28 | + | - **Limits that GitHub does not state**: the cross product of the vectors may | |
| 29 | + | not exceed `MaxMatrixProduct` (65 536) before exclusions, include and exclude | |
| 30 | + | have at most `MaxMatrixEntries` (256) entries each, and the context is | |
| 31 | + | checked every 1 024 combinations. They keep a hostile matrix from running | |
| 32 | + | for long; a legitimate one (at most 256 jobs) is far below them. The | |
| 33 | + | product limit also refuses a matrix GitHub would accept by excluding most | |
| 34 | + | of a huge product. | |
| 35 | + | - **Reusable workflows** are parsed (`uses`, `with`, `secrets`, `inherit`) but | |
| 36 | + | not followed, and the called workflow's inputs and secrets are not checked. | |
| 37 | + | - Unknown event types are reported by the schema (`Unexpected value 'x'`). | |
| 38 | + | - `permissions` are modelled but not validated beyond the schema. | |
| 39 | + | - `Job.If`, `Step.If`: the status-function wrapping is the reference's | |
| 40 | + | (`success() && (cond)` when the condition calls none of `success`, | |
| 41 | + | `failure`, `cancelled`, `always`, looking through nested calls). | |
| 42 | + | ||
| 43 | + | ## To verify (recordings) | |
| 44 | + | ||
| 45 | + | - [ ] **Glob syntax** (`glob.go`): `*`, `**`, `?`, `+`, `[]`, `\`, `!`. The | |
| 46 | + | cheat sheet's examples pass; `**/` matching nothing, `?` and `+` after a | |
| 47 | + | `]`, `*` or an escaped character, `[!x]`, an unclosed `[` (literal here) | |
| 48 | + | and `/`-prefixed patterns are assumed. | |
| 49 | + | - [ ] **`MatchPush`**: deletes never run; more than 3 tags run nothing for | |
| 50 | + | tags (not for the branch); branches-only filter and a tag push (no run) | |
| 51 | + | and the reverse; with both filters each ref uses its own; `paths` only | |
| 52 | + | for branches; first 300 files (also for `paths-ignore`); truncated list | |
| 53 | + | passes; a push whose diff is empty never matches a `paths` filter and | |
| 54 | + | always matches a `paths-ignore` one; unknown ref kinds never match. | |
| 55 | + | - [ ] **`PushInfo.Created`** is not used: a new branch's file list is the | |
| 56 | + | caller's job (diff against the default branch merge-base). | |
| 57 | + | - [ ] **`SkipCI`**: marker case, trailer rules (see its doc comment); whether | |
| 58 | + | only the head commit counts; `[skip ci]` in the subject vs the body. | |
| 59 | + | - [ ] **`ExpandMatrix`**: order of the product (first key slowest), include | |
| 60 | + | semantics (added to every matching combination, never overwriting | |
| 61 | + | original values, later includes overwrite earlier added values, a | |
| 62 | + | non-matching include is a new combination, match by vector keys only), | |
| 63 | + | exclude (partial match, applied before include), the 256 limit counting | |
| 64 | + | combinations after include, key case-insensitivity, value comparison | |
| 65 | + | (same kind; objects by containment), the error when all combinations are | |
| 66 | + | excluded (GitHub may fail the run differently), empty vector error. | |
| 67 | + | - [ ] A **null matrix value** is shown as an empty segment (`job (, 1)`); | |
| 68 | + | GitHub may skip it. | |
| 69 | + | - [ ] **Job display names** (`MatrixCombo.DisplayName`, `Job.DisplayName`): | |
| 70 | + | `name (v1, v2)` listing every scalar value of the combination in key | |
| 71 | + | order, then keys added by include; a name that reads the `matrix` | |
| 72 | + | context (found in the expression's syntax tree, not by searching the | |
| 73 | + | text) is used as is. Booleans/numbers/null are shown with `CoerceString`. | |
| 74 | + | - [ ] **`strategy` context** (`Strategy.Context`): `fail-fast`, `job-index`, | |
| 75 | + | `job-total`, `max-parallel` (the job count when unlimited). | |
| 76 | + | - [ ] `fail-fast` and `max-parallel` accept strings (`"false"`, `"2"`) after | |
| 77 | + | evaluation; `max-parallel` must be a whole number ≥ 1. | |
| 78 | + | - [ ] The 1 000-commit and 300-file limits themselves are the caller's. | |
| 79 | + | ||
| 80 | + | ## New in the second pass | |
| 81 | + | ||
| 82 | + | - `Job.EvaluateRunsOn`, `Job.EvaluateIf`, `Step.EvaluateIf` and | |
| 83 | + | `Workflow.JobLevels` are conveniences for the planner and are not in the | |
| 84 | + | reference. `EvaluateRunsOn` applies the reference's runs-on checks to the | |
| 85 | + | evaluated value (labels must be strings; group names as in the converter). | |
| 86 | + | `JobLevels` is a layering of `needs` by depth, file order inside a level. | |
| 87 | + | - The reference versions and the regeneration of its answers are in | |
| 88 | + | `../template/PORTING.md`. | |
A workflow/convert.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 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 ck[T any](v T, err error) T { | |
| 40 | + | if err != nil { | |
| 41 | + | panic(err) | |
| 42 | + | } | |
| 43 | + | return v | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func asString(t template.Token, desc string) *template.String { | |
| 47 | + | return ck(template.AssertString(t, desc)) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func asBool(t template.Token, desc string) *template.Boolean { | |
| 51 | + | return ck(template.AssertBoolean(t, desc)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func asSeq(t template.Token, desc string) *template.Sequence { | |
| 55 | + | return ck(template.AssertSequence(t, desc)) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func asMap(t template.Token, desc string) *template.Mapping { | |
| 59 | + | return ck(template.AssertMapping(t, desc)) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func asScalar(t template.Token, desc string) template.Token { | |
| 63 | + | return ck(template.AssertScalar(t, desc)) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func origin(t template.Token) template.Origin { | |
| 67 | + | return template.Origin{File: t.File(), Pos: t.Pos()} | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // convertRoot converts the root mapping. | |
| 71 | + | func (c *converter) convertRoot(root template.Token) { | |
| 72 | + | defer func() { | |
| 73 | + | if r := recover(); r != nil { | |
| 74 | + | te, ok := r.(*template.TokenError) | |
| 75 | + | if !ok { | |
| 76 | + | panic(r) | |
| 77 | + | } | |
| 78 | + | if te.Token != nil { | |
| 79 | + | c.ctx.ErrorAt(te.Token, te.Message) | |
| 80 | + | } else { | |
| 81 | + | c.ctx.ErrorAt(root, te.Message) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | }() | |
| 85 | + | m := asMap(root, "root") | |
| 86 | + | for _, p := range m.Pairs { | |
| 87 | + | key := asString(p.Key, "root key") | |
| 88 | + | switch key.Value { | |
| 89 | + | case "on": | |
| 90 | + | c.handle(root, func() { c.w.On = c.convertOn(p.Value) }) | |
| 91 | + | case "jobs": | |
| 92 | + | c.w.Jobs = nil | |
| 93 | + | c.handle(root, func() { c.w.Jobs = c.convertJobs(p.Value) }) | |
| 94 | + | case "concurrency": | |
| 95 | + | c.handle(root, func() { c.convertConcurrency(p.Value) }) | |
| 96 | + | c.w.Concurrency = p.Value | |
| 97 | + | case "env": | |
| 98 | + | if em, ok := p.Value.(*template.Mapping); ok { | |
| 99 | + | c.w.Env = em | |
| 100 | + | } | |
| 101 | + | case "name": | |
| 102 | + | if s, ok := p.Value.(*template.String); ok { | |
| 103 | + | c.w.Name = s.Value | |
| 104 | + | } | |
| 105 | + | case "run-name": | |
| 106 | + | c.w.RunName = p.Value | |
| 107 | + | case "defaults": | |
| 108 | + | c.w.Defaults = convertDefaults(p.Value) | |
| 109 | + | case "permissions": | |
| 110 | + | c.w.Permissions = convertPermissions(p.Value) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func convertDefaults(t template.Token) *Defaults { | |
| 116 | + | m, ok := t.(*template.Mapping) | |
| 117 | + | if !ok { | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | d := &Defaults{} | |
| 121 | + | if run, ok := m.Find("run").(*template.Mapping); ok { | |
| 122 | + | d.Run = &RunDefaults{Shell: run.Find("shell"), WorkingDirectory: run.Find("working-directory")} | |
| 123 | + | } | |
| 124 | + | return d | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func convertPermissions(t template.Token) *Permissions { | |
| 128 | + | switch t := t.(type) { | |
| 129 | + | case *template.String: | |
| 130 | + | return &Permissions{All: t.Value} | |
| 131 | + | case *template.Mapping: | |
| 132 | + | p := &Permissions{} | |
| 133 | + | for _, kv := range t.Pairs { | |
| 134 | + | p.Scopes = append(p.Scopes, PermissionScope{Scope: kv.Key.String(), Level: kv.Value.String()}) | |
| 135 | + | } | |
| 136 | + | return p | |
| 137 | + | } | |
| 138 | + | return nil | |
| 139 | + | } | |
A workflow/convert_container.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bnema.dev/ermite-actions/template" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // convertContainer validates a job container or, when service is set, a | |
| 10 | + | // service container. A token that contains an expression is not checked, as | |
| 11 | + | // in the reference. | |
| 12 | + | func (c *converter) convertContainer(t template.Token, service bool) { | |
| 13 | + | if template.HasExpression(t) { | |
| 14 | + | return | |
| 15 | + | } | |
| 16 | + | if _, ok := t.(*template.String); ok { | |
| 17 | + | return | |
| 18 | + | } | |
| 19 | + | var image template.Token | |
| 20 | + | c.handle(t, func() { | |
| 21 | + | m := asMap(t, "container item") | |
| 22 | + | for _, p := range m.Pairs { | |
| 23 | + | key := asString(p.Key, "container item key") | |
| 24 | + | v := p.Value | |
| 25 | + | switch key.Value { | |
| 26 | + | case "image": | |
| 27 | + | image = asString(v, "container image") | |
| 28 | + | case "credentials": | |
| 29 | + | c.convertCredentials(v) | |
| 30 | + | case "env": | |
| 31 | + | for _, e := range asMap(v, "container env").Pairs { | |
| 32 | + | asString(e.Key, "container env value") | |
| 33 | + | } | |
| 34 | + | case "ports": | |
| 35 | + | for _, it := range asSeq(v, "container ports").Items { | |
| 36 | + | asString(it, "container port") | |
| 37 | + | } | |
| 38 | + | case "volumes": | |
| 39 | + | for _, it := range asSeq(v, "container volumes").Items { | |
| 40 | + | asString(it, "container volume") | |
| 41 | + | } | |
| 42 | + | case "options": | |
| 43 | + | asString(v, "container options") | |
| 44 | + | case "entrypoint": | |
| 45 | + | if !service { | |
| 46 | + | c.ctx.Errorf(key, "Unexpected container item key: %s", key.Value) | |
| 47 | + | continue | |
| 48 | + | } | |
| 49 | + | asString(v, "container entrypoint") | |
| 50 | + | case "command": | |
| 51 | + | if !service { | |
| 52 | + | c.ctx.Errorf(key, "Unexpected container item key: %s", key.Value) | |
| 53 | + | continue | |
| 54 | + | } | |
| 55 | + | asString(v, "container command") | |
| 56 | + | default: | |
| 57 | + | c.ctx.Errorf(key, "Unexpected container item key: %s", key.Value) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | }) | |
| 61 | + | if image == nil { | |
| 62 | + | if _, isMap := t.(*template.Mapping); isMap { | |
| 63 | + | c.ctx.ErrorAt(t, "Container image cannot be empty") | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (c *converter) convertCredentials(t template.Token) { | |
| 69 | + | for _, p := range asMap(t, "credentials").Pairs { | |
| 70 | + | key := asString(p.Key, "credentials item") | |
| 71 | + | switch key.Value { | |
| 72 | + | case "username": | |
| 73 | + | asString(p.Value, "credentials username") | |
| 74 | + | case "password": | |
| 75 | + | asString(p.Value, "credentials password") | |
| 76 | + | default: | |
| 77 | + | c.ctx.Errorf(key, "credentials key %s", key.Value) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // convertServices validates the services mapping of a job. | |
| 83 | + | func (c *converter) convertServices(t template.Token) { | |
| 84 | + | for _, p := range asMap(t, "services").Pairs { | |
| 85 | + | asString(p.Key, "service key") | |
| 86 | + | c.convertContainer(p.Value, true) | |
| 87 | + | } | |
| 88 | + | } | |
A workflow/convert_jobs.go +181 -0
| @@ -0,0 +1,181 @@ | |||
| 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 | + | for _, p := range m.Pairs { | |
| 97 | + | prop := asString(p.Key, "job property name") | |
| 98 | + | v := p.Value | |
| 99 | + | switch prop.Value { | |
| 100 | + | case "concurrency": | |
| 101 | + | c.handle(v, func() { c.convertConcurrency(v) }) | |
| 102 | + | j.Concurrency = v | |
| 103 | + | case "container": | |
| 104 | + | c.convertContainer(v, false) | |
| 105 | + | j.Container = v | |
| 106 | + | case "env": | |
| 107 | + | c.handle(v, func() { j.Env = asMap(v, "job env") }) | |
| 108 | + | case "environment": | |
| 109 | + | c.handle(v, func() { c.convertEnvironment(v) }) | |
| 110 | + | j.Environment = v | |
| 111 | + | case "if": | |
| 112 | + | j.If = c.convertIf(v, "job-if") | |
| 113 | + | case "name": | |
| 114 | + | name = asScalar(v, "job name") | |
| 115 | + | case "needs": | |
| 116 | + | j.NeedTokens = nil | |
| 117 | + | if s, ok := v.(*template.String); ok { | |
| 118 | + | j.NeedTokens = append(j.NeedTokens, asString(s, "job needs id")) | |
| 119 | + | } | |
| 120 | + | if seq, ok := v.(*template.Sequence); ok { | |
| 121 | + | for _, it := range seq.Items { | |
| 122 | + | j.NeedTokens = append(j.NeedTokens, asString(it, "job needs id")) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | case "outputs": | |
| 126 | + | c.handle(v, func() { j.Outputs = asMap(v, "job outputs") }) | |
| 127 | + | case "runs-on": | |
| 128 | + | c.handle(v, func() { c.convertRunsOn(v) }) | |
| 129 | + | j.RunsOn = v | |
| 130 | + | case "services": | |
| 131 | + | c.convertServices(v) | |
| 132 | + | j.Services = v | |
| 133 | + | case "snapshot": | |
| 134 | + | j.Snapshot = v | |
| 135 | + | case "steps": | |
| 136 | + | j.Steps = c.convertSteps(v) | |
| 137 | + | case "strategy": | |
| 138 | + | j.Strategy = v | |
| 139 | + | case "uses": | |
| 140 | + | j.Uses = asString(v, "job uses value") | |
| 141 | + | case "with": | |
| 142 | + | c.handle(v, func() { j.With = asMap(v, "uses-with value") }) | |
| 143 | + | case "secrets": | |
| 144 | + | if s, ok := v.(*template.String); ok && s.Value == "inherit" { | |
| 145 | + | j.InheritSecrets = true | |
| 146 | + | } else { | |
| 147 | + | c.handle(v, func() { j.Secrets = asMap(v, "uses-secrets value") }) | |
| 148 | + | } | |
| 149 | + | case "timeout-minutes": | |
| 150 | + | j.TimeoutMinutes = v | |
| 151 | + | case "cancel-timeout-minutes": | |
| 152 | + | j.CancelTimeoutMinutes = v | |
| 153 | + | case "continue-on-error": | |
| 154 | + | j.ContinueOnError = v | |
| 155 | + | case "defaults": | |
| 156 | + | j.Defaults = convertDefaults(v) | |
| 157 | + | case "permissions": | |
| 158 | + | j.Permissions = convertPermissions(v) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | for _, n := range j.NeedTokens { | |
| 162 | + | j.Needs = append(j.Needs, n.Value) | |
| 163 | + | } | |
| 164 | + | if j.If == nil { | |
| 165 | + | j.If = c.defaultIf("job-if") | |
| 166 | + | } | |
| 167 | + | switch { | |
| 168 | + | case name == nil: | |
| 169 | + | j.Name = key | |
| 170 | + | case isEmptyString(name): | |
| 171 | + | j.Name = key | |
| 172 | + | default: | |
| 173 | + | j.Name, j.HasName = name, true | |
| 174 | + | } | |
| 175 | + | return j | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func isEmptyString(t template.Token) bool { | |
| 179 | + | s, ok := t.(*template.String) | |
| 180 | + | return ok && s.Value == "" | |
| 181 | + | } | |
A workflow/convert_misc.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite-actions/expr" | |
| 9 | + | "bnema.dev/ermite-actions/template" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // convertConcurrency validates a concurrency token. Expressions are skipped. | |
| 13 | + | func (c *converter) convertConcurrency(t template.Token) { | |
| 14 | + | if template.IsExpression(t) { | |
| 15 | + | return | |
| 16 | + | } | |
| 17 | + | if _, ok := t.(*template.String); ok { | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | m := asMap(t, "concurrency group") | |
| 21 | + | for _, p := range m.Pairs { | |
| 22 | + | name := asString(p.Key, "concurrency group key") | |
| 23 | + | if template.IsExpression(p.Key) || template.IsExpression(p.Value) { | |
| 24 | + | continue | |
| 25 | + | } | |
| 26 | + | switch name.Value { | |
| 27 | + | case "group": | |
| 28 | + | asString(p.Value, "concurrency group") | |
| 29 | + | case "cancel-in-progress": | |
| 30 | + | asBool(p.Value, "cancel-in-progress") | |
| 31 | + | case "queue": | |
| 32 | + | asString(p.Value, "queue") | |
| 33 | + | default: | |
| 34 | + | c.ctx.Errorf(name, "Invalid property name: %s", name.Value) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // convertEnvironment validates a job environment token. | |
| 40 | + | func (c *converter) convertEnvironment(t template.Token) { | |
| 41 | + | if template.IsExpression(t) || template.IsScalar(t) { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | m := asMap(t, "job environment") | |
| 45 | + | for _, p := range m.Pairs { | |
| 46 | + | name := asString(p.Key, "job environment key") | |
| 47 | + | if template.IsExpression(p.Key) || template.IsExpression(p.Value) { | |
| 48 | + | continue | |
| 49 | + | } | |
| 50 | + | switch name.Value { | |
| 51 | + | case "name": | |
| 52 | + | asScalar(p.Value, "job environment name key") | |
| 53 | + | case "deployment": | |
| 54 | + | asBool(p.Value, "job environment deployment") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // convertRunsOn validates a runs-on token. | |
| 60 | + | func (c *converter) convertRunsOn(t template.Token) { | |
| 61 | + | labels := runsOnLabels(t) | |
| 62 | + | _ = labels | |
| 63 | + | m, ok := t.(*template.Mapping) | |
| 64 | + | if !ok { | |
| 65 | + | return | |
| 66 | + | } | |
| 67 | + | for _, p := range m.Pairs { | |
| 68 | + | key := asString(p.Key, "job runs-on property name") | |
| 69 | + | switch key.Value { | |
| 70 | + | case "group": | |
| 71 | + | if template.IsExpression(p.Value) { | |
| 72 | + | continue | |
| 73 | + | } | |
| 74 | + | group := asString(p.Value, "job runs-on group name").Value | |
| 75 | + | if msg := checkGroup(group); msg != "" { | |
| 76 | + | c.ctx.ErrorAt(p.Value, msg) | |
| 77 | + | } | |
| 78 | + | case "labels": | |
| 79 | + | runsOnLabels(p.Value) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // runsOnLabels collects the literal labels of a runs-on token, asserting that | |
| 85 | + | // the items are strings. | |
| 86 | + | func runsOnLabels(t template.Token) []string { | |
| 87 | + | if template.IsExpression(t) { | |
| 88 | + | return nil | |
| 89 | + | } | |
| 90 | + | switch t := t.(type) { | |
| 91 | + | case *template.String: | |
| 92 | + | return []string{t.Value} | |
| 93 | + | case *template.Sequence: | |
| 94 | + | var out []string | |
| 95 | + | for _, it := range t.Items { | |
| 96 | + | if template.IsExpression(it) { | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | out = append(out, asString(it, "job runs-on label sequence item").Value) | |
| 100 | + | } | |
| 101 | + | return out | |
| 102 | + | } | |
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // statusFunctions are the functions that make a condition a status check. | |
| 107 | + | var statusFunctions = map[string]bool{"success": true, "failure": true, "cancelled": true, "always": true} | |
| 108 | + | ||
| 109 | + | func hasStatusFunction(n expr.Node) bool { | |
| 110 | + | switch n := n.(type) { | |
| 111 | + | case *expr.FunctionCall: | |
| 112 | + | if statusFunctions[strings.ToLower(n.Name.Lexeme)] { | |
| 113 | + | return true | |
| 114 | + | } | |
| 115 | + | for _, a := range n.Args { | |
| 116 | + | if hasStatusFunction(a) { | |
| 117 | + | return true | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | case *expr.Binary: | |
| 121 | + | return hasStatusFunction(n.Left) || hasStatusFunction(n.Right) | |
| 122 | + | case *expr.Unary: | |
| 123 | + | return hasStatusFunction(n.Expr) | |
| 124 | + | case *expr.Logical: | |
| 125 | + | for _, a := range n.Args { | |
| 126 | + | if hasStatusFunction(a) { | |
| 127 | + | return true | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | case *expr.Grouping: | |
| 131 | + | return hasStatusFunction(n.Group) | |
| 132 | + | case *expr.IndexAccess: | |
| 133 | + | return hasStatusFunction(n.Expr) || hasStatusFunction(n.Index) | |
| 134 | + | } | |
| 135 | + | return false | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // convertIf converts an if condition: the result always contains a status | |
| 139 | + | // function, wrapping the condition as "success() && (cond)" when it has none. | |
| 140 | + | // A condition that does not parse is recorded as an error and returns nil. | |
| 141 | + | func (c *converter) convertIf(t template.Token, defName string) *template.BasicExpression { | |
| 142 | + | scalar := asScalar(t, "if condition") | |
| 143 | + | def := t.Definition() | |
| 144 | + | if def == nil { | |
| 145 | + | def = c.schema.Info(defName) | |
| 146 | + | } | |
| 147 | + | var condition string | |
| 148 | + | switch s := scalar.(type) { | |
| 149 | + | case *template.BasicExpression: | |
| 150 | + | condition = s.Expression | |
| 151 | + | default: | |
| 152 | + | condition = strings.TrimSpace(asString(scalar, "if condition").Value) | |
| 153 | + | } | |
| 154 | + | final := "success()" | |
| 155 | + | if condition != "" { | |
| 156 | + | final = condition | |
| 157 | + | if node, err := expr.Parse(condition, def.NamedContexts(), def.Functions()); err == nil && !hasStatusFunction(node) { | |
| 158 | + | final = "success() && (" + condition + ")" | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | if _, err := expr.Parse(final, def.NamedContexts(), def.Functions()); err != nil { | |
| 162 | + | c.ctx.ErrorAt(t, err.Error()) | |
| 163 | + | return nil | |
| 164 | + | } | |
| 165 | + | return template.NewExpression(origin(t), final, def) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // defaultIf is the condition of a job or step that has none. | |
| 169 | + | func (c *converter) defaultIf(defName string) *template.BasicExpression { | |
| 170 | + | return template.NewExpression(template.Origin{}, "success()", c.schema.Info(defName)) | |
| 171 | + | } | |
A workflow/convert_steps.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 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 | + | path := strings.FieldsFunc(segments[0], func(r rune) bool { return r == '/' || r == '\\' }) | |
| 119 | + | if len(path) >= 2 && path[0] != "" && path[1] != "" && segments[1] != "" { | |
| 120 | + | return path[0] + "/" + path[1] | |
| 121 | + | } | |
| 122 | + | return "" | |
| 123 | + | } | |
A workflow/convert_test.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 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 | + | // ermiteOnly lists corpus files on which this package reports errors the | |
| 58 | + | // reference does not; the errors expected are given here instead. | |
| 59 | + | var ermiteOnly = map[string][]string{ | |
| 60 | + | "on_filters_both.yml": { | |
| 61 | + | "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one of them.", | |
| 62 | + | "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'tags' and 'tags-ignore'. Use only one of them.", | |
| 63 | + | "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'paths' and 'paths-ignore'. Use only one of them.", | |
| 64 | + | }, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestConvertAgainstReference(t *testing.T) { | |
| 68 | + | ref := loadRef(t) | |
| 69 | + | files, err := filepath.Glob("testdata/convert/*.yml") | |
| 70 | + | if err != nil || len(files) == 0 { | |
| 71 | + | t.Fatalf("no corpus: %v", err) | |
| 72 | + | } | |
| 73 | + | for _, f := range files { | |
| 74 | + | name := filepath.Base(f) | |
| 75 | + | want, ok := ref[name] | |
| 76 | + | if !ok { | |
| 77 | + | t.Errorf("%s: missing from convert.json", name) | |
| 78 | + | continue | |
| 79 | + | } | |
| 80 | + | t.Run(strings.TrimSuffix(name, ".yml"), func(t *testing.T) { | |
| 81 | + | content, err := os.ReadFile(f) | |
| 82 | + | if err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | w, errs := Parse(name, content) | |
| 86 | + | got := errorTexts(errs) | |
| 87 | + | if extra, ok := ermiteOnly[name]; ok { | |
| 88 | + | if !reflect.DeepEqual(got, extra) || w != nil { | |
| 89 | + | t.Errorf("errors:\n got %q\nwant %q", got, extra) | |
| 90 | + | } | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | if len(want.Errors) == 1 && isYAMLError(want.Errors[0]) { | |
| 94 | + | // Syntax errors: the Go parser words them differently. Only | |
| 95 | + | // the fact that there is exactly one, on the file, is checked. | |
| 96 | + | if len(got) != 1 || !strings.HasPrefix(got[0], name+": ") { | |
| 97 | + | t.Errorf("syntax error: got %q, reference %q", got, want.Errors) | |
| 98 | + | } | |
| 99 | + | if w != nil { | |
| 100 | + | t.Error("workflow returned with errors") | |
| 101 | + | } | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | if !reflect.DeepEqual(nonNil(got), nonNil(want.Errors)) { | |
| 105 | + | t.Errorf("errors differ\n got: %q\nwant: %q", got, want.Errors) | |
| 106 | + | } | |
| 107 | + | if len(want.Errors) > 0 { | |
| 108 | + | if w != nil { | |
| 109 | + | t.Error("workflow returned with errors") | |
| 110 | + | } | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | if w == nil { | |
| 114 | + | t.Fatal("no workflow and no errors") | |
| 115 | + | } | |
| 116 | + | if len(w.Jobs) != len(want.Jobs) { | |
| 117 | + | t.Fatalf("jobs: got %d, reference %d", len(w.Jobs), len(want.Jobs)) | |
| 118 | + | } | |
| 119 | + | for i, wj := range want.Jobs { | |
| 120 | + | j := w.Jobs[i] | |
| 121 | + | if j.ID != wj.ID || j.Name.String() != wj.Name || j.If.Expression != wj.If { | |
| 122 | + | 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) | |
| 123 | + | } | |
| 124 | + | if !reflect.DeepEqual(nonNil(j.Needs), nonNil(wj.Needs)) && (!j.IsReusable() || len(wj.Needs) != 0) { | |
| 125 | + | t.Errorf("job %s needs: got %v, reference %v", j.ID, j.Needs, wj.Needs) | |
| 126 | + | } | |
| 127 | + | if len(j.Steps) != len(wj.Steps) { | |
| 128 | + | t.Errorf("job %s: %d steps, reference %d", j.ID, len(j.Steps), len(wj.Steps)) | |
| 129 | + | continue | |
| 130 | + | } | |
| 131 | + | for k, ws := range wj.Steps { | |
| 132 | + | if j.Steps[k].ID != ws.ID || j.Steps[k].If.Expression != ws.If { | |
| 133 | + | 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) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | checkEvents(t, w, want.Events) | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func nonNil(s []string) []string { | |
| 143 | + | if s == nil { | |
| 144 | + | return []string{} | |
| 145 | + | } | |
| 146 | + | return s | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // checkEvents compares the filters of the events with the reference. | |
| 150 | + | func checkEvents(t *testing.T, w *Workflow, want map[string]any) { | |
| 151 | + | t.Helper() | |
| 152 | + | var names []string | |
| 153 | + | for _, e := range w.On.Events { | |
| 154 | + | names = append(names, e.Name) | |
| 155 | + | } | |
| 156 | + | var wnames []string | |
| 157 | + | for k := range want { | |
| 158 | + | wnames = append(wnames, k) | |
| 159 | + | } | |
| 160 | + | sort.Strings(names) | |
| 161 | + | sort.Strings(wnames) | |
| 162 | + | if !reflect.DeepEqual(names, wnames) { | |
| 163 | + | t.Errorf("events: got %v, reference %v", names, wnames) | |
| 164 | + | return | |
| 165 | + | } | |
| 166 | + | for _, e := range w.On.Events { | |
| 167 | + | wf, _ := want[e.Name].(map[string]any) | |
| 168 | + | for key, got := range map[string][]string{ | |
| 169 | + | "branches": e.Branches, "branches-ignore": e.BranchesIgnore, "tags": e.Tags, | |
| 170 | + | "tags-ignore": e.TagsIgnore, "paths": e.Paths, "paths-ignore": e.PathsIgnore, "types": e.Types, | |
| 171 | + | } { | |
| 172 | + | raw, present := wf[key] | |
| 173 | + | if (got != nil) != present { | |
| 174 | + | t.Errorf("event %s filter %s: present=%v, reference present=%v", e.Name, key, got != nil, present) | |
| 175 | + | continue | |
| 176 | + | } | |
| 177 | + | if present { | |
| 178 | + | var ws []string | |
| 179 | + | for _, x := range raw.([]any) { | |
| 180 | + | ws = append(ws, x.(string)) | |
| 181 | + | } | |
| 182 | + | if !reflect.DeepEqual(nonNil(got), nonNil(ws)) { | |
| 183 | + | t.Errorf("event %s filter %s: got %v, reference %v", e.Name, key, got, ws) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | } | |
A workflow/cron.go +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "math" | |
| 7 | + | "regexp" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Cron validation, ported from converter/cron.ts. | |
| 13 | + | ||
| 14 | + | type cronRange struct { | |
| 15 | + | min, max int | |
| 16 | + | names map[string]int | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | var ( | |
| 20 | + | cronMinute = cronRange{0, 59, nil} | |
| 21 | + | cronHour = cronRange{0, 23, nil} | |
| 22 | + | cronDOM = cronRange{1, 31, nil} | |
| 23 | + | cronMonth = cronRange{1, 12, map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}} | |
| 24 | + | cronDOW = cronRange{0, 6, map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}} | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | func isValidCron(cron string) bool { | |
| 28 | + | parts := splitSpaces(cron) | |
| 29 | + | if len(parts) != 5 { | |
| 30 | + | return false | |
| 31 | + | } | |
| 32 | + | return validateCronPart(parts[0], cronMinute, true) && validateCronPart(parts[1], cronHour, true) && | |
| 33 | + | validateCronPart(parts[2], cronDOM, true) && validateCronPart(parts[3], cronMonth, true) && | |
| 34 | + | validateCronPart(parts[4], cronDOW, true) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // splitSpaces is JavaScript's split(/ +/): runs of spaces separate, other | |
| 38 | + | // whitespace does not, and leading or trailing spaces give empty parts. | |
| 39 | + | func splitSpaces(s string) []string { | |
| 40 | + | var parts []string | |
| 41 | + | start := 0 | |
| 42 | + | i := 0 | |
| 43 | + | for i < len(s) { | |
| 44 | + | if s[i] != ' ' { | |
| 45 | + | i++ | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | parts = append(parts, s[start:i]) | |
| 49 | + | for i < len(s) && s[i] == ' ' { | |
| 50 | + | i++ | |
| 51 | + | } | |
| 52 | + | start = i | |
| 53 | + | } | |
| 54 | + | return append(parts, s[start:]) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | var jsDecimal = regexp.MustCompile(`^[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$`) | |
| 58 | + | ||
| 59 | + | // jsNumber is JavaScript's Number(string): surrounding white space is | |
| 60 | + | // ignored, an empty string is 0, and decimal, hexadecimal, octal and binary | |
| 61 | + | // literals and Infinity are numbers. ok is false for NaN. | |
| 62 | + | func jsNumber(v string) (float64, bool) { | |
| 63 | + | t := strings.Trim(v, " \t\n\v\f\r\u00a0\u2028\u2029\ufeff") | |
| 64 | + | switch { | |
| 65 | + | case t == "": | |
| 66 | + | return 0, true | |
| 67 | + | case jsDecimal.MatchString(t): | |
| 68 | + | f, err := strconv.ParseFloat(t, 64) | |
| 69 | + | return f, err == nil || math.IsInf(f, 0) | |
| 70 | + | case t == "Infinity" || t == "+Infinity": | |
| 71 | + | return math.Inf(1), true | |
| 72 | + | case t == "-Infinity": | |
| 73 | + | return math.Inf(-1), true | |
| 74 | + | case len(t) > 2 && t[0] == '0': | |
| 75 | + | base := map[byte]int{'x': 16, 'X': 16, 'o': 8, 'O': 8, 'b': 2, 'B': 2}[t[1]] | |
| 76 | + | if base == 0 { | |
| 77 | + | return 0, false | |
| 78 | + | } | |
| 79 | + | n, err := strconv.ParseUint(t[2:], base, 64) | |
| 80 | + | if err != nil { | |
| 81 | + | if ne, ok := err.(*strconv.NumError); ok && ne.Err == strconv.ErrRange { | |
| 82 | + | return math.Inf(1), true | |
| 83 | + | } | |
| 84 | + | return 0, false | |
| 85 | + | } | |
| 86 | + | return float64(n), true | |
| 87 | + | } | |
| 88 | + | return 0, false | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func cronNumber(v string) (float64, bool) { return jsNumber(v) } | |
| 92 | + | ||
| 93 | + | func validateCronPart(value string, r cronRange, allowSeparators bool) bool { | |
| 94 | + | lower := strings.ToLower(value) | |
| 95 | + | if _, ok := r.names[lower]; ok { | |
| 96 | + | return true | |
| 97 | + | } | |
| 98 | + | if value == "*" { | |
| 99 | + | return true | |
| 100 | + | } | |
| 101 | + | if strings.Contains(value, ",") { | |
| 102 | + | if !allowSeparators { | |
| 103 | + | return false | |
| 104 | + | } | |
| 105 | + | for _, v := range strings.Split(value, ",") { | |
| 106 | + | if v == "" || !validateCronPart(v, r, true) { | |
| 107 | + | return false | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | return true | |
| 111 | + | } | |
| 112 | + | if strings.Contains(value, "/") { | |
| 113 | + | if !allowSeparators { | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | parts := strings.Split(value, "/") | |
| 117 | + | start, step := parts[0], "" | |
| 118 | + | if len(parts) > 1 { | |
| 119 | + | step = parts[1] | |
| 120 | + | } | |
| 121 | + | n, ok := cronNumber(step) | |
| 122 | + | if len(parts) > 2 || !ok || n <= 0 || start == "" || step == "" { | |
| 123 | + | return false | |
| 124 | + | } | |
| 125 | + | return validateCronPart(start, r, true) && validateCronPart(step, r, false) | |
| 126 | + | } | |
| 127 | + | if strings.Contains(value, "-") { | |
| 128 | + | if !allowSeparators { | |
| 129 | + | return false | |
| 130 | + | } | |
| 131 | + | parts := strings.Split(value, "-") | |
| 132 | + | if len(parts) != 2 || parts[0] == "" || parts[1] == "" { | |
| 133 | + | return false | |
| 134 | + | } | |
| 135 | + | s, e := cronRangeValue(parts[0], r), cronRangeValue(parts[1], r) | |
| 136 | + | return validateCronPart(parts[0], r, false) && validateCronPart(parts[1], r, false) && e >= s | |
| 137 | + | } | |
| 138 | + | n, ok := cronNumber(value) | |
| 139 | + | return ok && n >= float64(r.min) && n <= float64(r.max) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func cronRangeValue(v string, r cronRange) float64 { | |
| 143 | + | if n, ok := r.names[strings.ToLower(v)]; ok { | |
| 144 | + | return float64(n) | |
| 145 | + | } | |
| 146 | + | n, ok := cronNumber(v) | |
| 147 | + | if !ok { | |
| 148 | + | return math.NaN() | |
| 149 | + | } | |
| 150 | + | return n | |
| 151 | + | } | |
A workflow/doc.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | // Package workflow parses GitHub Actions workflow files into a model and | |
| 4 | + | // implements the planning rules that depend only on the file: validation, | |
| 5 | + | // job matrices, push trigger filters and skip directives in commit messages. | |
| 6 | + | // | |
| 7 | + | // Parse is a port of the converter of the npm package | |
| 8 | + | // @actions/workflow-parser 0.3.61 (MIT, GitHub, actions/languageservices) on | |
| 9 | + | // top of package template. Values that GitHub computes later, such as | |
| 10 | + | // runs-on, strategy, names and conditions, stay unevaluated template tokens | |
| 11 | + | // in the model; package template evaluates them. | |
| 12 | + | // | |
| 13 | + | // The trigger rules (MatchPush, SkipCI) and the matrix expansion | |
| 14 | + | // (ExpandMatrix) are written from GitHub's documentation and from the | |
| 15 | + | // behaviour of the official runner; PORTING.md lists what to verify against | |
| 16 | + | // recordings of github.com. | |
| 17 | + | package workflow | |
A workflow/eval_test.go +193 -0
| @@ -0,0 +1,193 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite-actions/expr" | |
| 11 | + | "bnema.dev/ermite-actions/template" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func envWith(t testing.TB, contexts map[string]string) template.Env { | |
| 15 | + | t.Helper() | |
| 16 | + | env := template.Env{Contexts: map[string]expr.Value{}} | |
| 17 | + | for k, v := range contexts { | |
| 18 | + | env.Contexts[k] = jsonValue(t, v) | |
| 19 | + | } | |
| 20 | + | return env | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func mustParse(t testing.TB, src string) *Workflow { | |
| 24 | + | t.Helper() | |
| 25 | + | w, errs := Parse("w.yml", []byte(src)) | |
| 26 | + | if len(errs) > 0 { | |
| 27 | + | t.Fatalf("Parse: %v", errs) | |
| 28 | + | } | |
| 29 | + | return w | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestEvaluateStrategyEndToEnd(t *testing.T) { | |
| 33 | + | w := mustParse(t, `on: push | |
| 34 | + | jobs: | |
| 35 | + | build: | |
| 36 | + | name: Build | |
| 37 | + | runs-on: ${{ matrix.os }} | |
| 38 | + | strategy: | |
| 39 | + | fail-fast: false | |
| 40 | + | max-parallel: 2 | |
| 41 | + | matrix: | |
| 42 | + | os: [ubuntu-latest, macos-latest] | |
| 43 | + | node: ${{ fromJSON(needs.setup.outputs.versions) }} | |
| 44 | + | include: | |
| 45 | + | - os: ubuntu-latest | |
| 46 | + | extra: ${{ github.ref_name }} | |
| 47 | + | steps: | |
| 48 | + | - run: echo | |
| 49 | + | setup: | |
| 50 | + | runs-on: x | |
| 51 | + | steps: [{run: a}] | |
| 52 | + | `) | |
| 53 | + | env := envWith(t, map[string]string{ | |
| 54 | + | "github": `{"ref_name": "main"}`, | |
| 55 | + | "needs": `{"setup": {"outputs": {"versions": "[18, 20]"}}}`, | |
| 56 | + | }) | |
| 57 | + | job := w.Job("build") | |
| 58 | + | s, err := job.EvaluateStrategy(context.Background(), env) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | if s.FailFast || s.MaxParallel != 2 || len(s.Combos) != 4 { | |
| 63 | + | t.Fatalf("strategy = %+v", s) | |
| 64 | + | } | |
| 65 | + | names := make([]string, len(s.Combos)) | |
| 66 | + | runsOn := make([]string, len(s.Combos)) | |
| 67 | + | for i := range s.Combos { | |
| 68 | + | e, err := s.MatrixEnv(env, i) | |
| 69 | + | if err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 72 | + | names[i], err = job.DisplayName(context.Background(), e, s, i) | |
| 73 | + | if err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | runsOn[i], err = template.EvaluateString(context.Background(), job.RunsOn, e) | |
| 77 | + | if err != nil { | |
| 78 | + | t.Fatal(err) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | wantNames := []string{ | |
| 82 | + | "Build (ubuntu-latest, 18, main)", "Build (ubuntu-latest, 20, main)", | |
| 83 | + | "Build (macos-latest, 18)", "Build (macos-latest, 20)", | |
| 84 | + | } | |
| 85 | + | if strings.Join(names, "|") != strings.Join(wantNames, "|") { | |
| 86 | + | t.Errorf("names = %q, want %q", names, wantNames) | |
| 87 | + | } | |
| 88 | + | if runsOn[0] != "ubuntu-latest" || runsOn[3] != "macos-latest" { | |
| 89 | + | t.Errorf("runs-on = %q", runsOn) | |
| 90 | + | } | |
| 91 | + | // The strategy context. | |
| 92 | + | schema, _ := template.WorkflowSchema() | |
| 93 | + | e := template.NewExpression(template.Origin{}, "format('{0}/{1}', strategy.job-index, strategy.job-total)", schema.Info("runs-on")) | |
| 94 | + | if got, err := template.EvaluateString(context.Background(), e, mustMatrixEnv(t, s, env, 2)); err != nil || got != "2/4" { | |
| 95 | + | t.Errorf("strategy context = %q, %v", got, err) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestDisplayNameUsingMatrix(t *testing.T) { | |
| 100 | + | w := mustParse(t, `on: push | |
| 101 | + | jobs: | |
| 102 | + | a: | |
| 103 | + | name: build-${{ matrix.os }} | |
| 104 | + | runs-on: x | |
| 105 | + | strategy: | |
| 106 | + | matrix: | |
| 107 | + | os: [l, m] | |
| 108 | + | steps: [{run: a}] | |
| 109 | + | b: | |
| 110 | + | runs-on: x | |
| 111 | + | steps: [{run: a}] | |
| 112 | + | `) | |
| 113 | + | env := envWith(t, nil) | |
| 114 | + | j := w.Job("a") | |
| 115 | + | s, err := j.EvaluateStrategy(context.Background(), env) | |
| 116 | + | if err != nil { | |
| 117 | + | t.Fatal(err) | |
| 118 | + | } | |
| 119 | + | if got, _ := j.DisplayName(context.Background(), mustMatrixEnv(t, s, env, 1), s, 1); got != "build-m" { | |
| 120 | + | t.Errorf("name = %q", got) | |
| 121 | + | } | |
| 122 | + | b := w.Job("b") | |
| 123 | + | sb, _ := b.EvaluateStrategy(context.Background(), env) | |
| 124 | + | if got, _ := b.DisplayName(context.Background(), env, sb, 0); got != "b" { | |
| 125 | + | t.Errorf("name = %q", got) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestEvaluateStrategyErrors(t *testing.T) { | |
| 130 | + | w := mustParse(t, `on: push | |
| 131 | + | jobs: | |
| 132 | + | a: | |
| 133 | + | runs-on: x | |
| 134 | + | strategy: | |
| 135 | + | matrix: ${{ fromJSON(inputs.m) }} | |
| 136 | + | steps: [{run: a}] | |
| 137 | + | `) | |
| 138 | + | env := envWith(t, map[string]string{"inputs": `{"m": "not json"}`}) | |
| 139 | + | _, err := w.Job("a").EvaluateStrategy(context.Background(), env) | |
| 140 | + | if err == nil || !strings.Contains(err.Error(), "w.yml (Line: 6, Col: 15)") { | |
| 141 | + | t.Errorf("err = %v", err) | |
| 142 | + | } | |
| 143 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 144 | + | cancel() | |
| 145 | + | if _, err := w.Job("a").EvaluateStrategy(ctx, envWith(t, map[string]string{"inputs": `{"m": "{}"}`})); err == nil { | |
| 146 | + | t.Error("cancelled context: no error") | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestJobIfAndStatusFunctions(t *testing.T) { | |
| 151 | + | w := mustParse(t, `on: push | |
| 152 | + | jobs: | |
| 153 | + | a: | |
| 154 | + | runs-on: x | |
| 155 | + | steps: [{run: a}] | |
| 156 | + | b: | |
| 157 | + | needs: a | |
| 158 | + | if: needs.a.result == 'failure' | |
| 159 | + | runs-on: x | |
| 160 | + | steps: | |
| 161 | + | - run: a | |
| 162 | + | if: always() | |
| 163 | + | - run: b | |
| 164 | + | `) | |
| 165 | + | b := w.Job("b") | |
| 166 | + | env := envWith(t, map[string]string{"needs": `{"a": {"result": "failure"}}`}) | |
| 167 | + | funcs := map[string]expr.Func{ | |
| 168 | + | "success": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(true), nil }, | |
| 169 | + | "always": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(true), nil }, | |
| 170 | + | "failure": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(false), nil }, | |
| 171 | + | "cancelled": func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(false), nil }, | |
| 172 | + | } | |
| 173 | + | env.Funcs = funcs | |
| 174 | + | if b.If.Expression != "success() && (needs.a.result == 'failure')" { | |
| 175 | + | t.Fatalf("if = %s", b.If.Expression) | |
| 176 | + | } | |
| 177 | + | ok, err := template.EvaluateBool(context.Background(), b.If, env) | |
| 178 | + | if err != nil || !ok { | |
| 179 | + | t.Errorf("job if = %v, %v", ok, err) | |
| 180 | + | } | |
| 181 | + | if b.Steps[0].If.Expression != "always()" || b.Steps[1].If.Expression != "success()" { | |
| 182 | + | t.Errorf("step ifs = %s, %s", b.Steps[0].If.Expression, b.Steps[1].If.Expression) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func mustMatrixEnv(t testing.TB, s *Strategy, env template.Env, i int) template.Env { | |
| 187 | + | t.Helper() | |
| 188 | + | e, err := s.MatrixEnv(env, i) | |
| 189 | + | if err != nil { | |
| 190 | + | t.Fatal(err) | |
| 191 | + | } | |
| 192 | + | return e | |
| 193 | + | } | |
A workflow/events.go +373 -0
| @@ -0,0 +1,373 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bnema.dev/ermite-actions/template" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // On is the "on" key: the events that trigger a workflow. | |
| 11 | + | type On struct { | |
| 12 | + | // Events are the events in file order. | |
| 13 | + | Events []*Event | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // Event returns the configuration of the named event, or nil. | |
| 17 | + | func (o *On) Event(name string) *Event { | |
| 18 | + | if o == nil { | |
| 19 | + | return nil | |
| 20 | + | } | |
| 21 | + | for _, e := range o.Events { | |
| 22 | + | if e.Name == name { | |
| 23 | + | return e | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Has reports whether the workflow is triggered by the named event. | |
| 30 | + | func (o *On) Has(name string) bool { return o.Event(name) != nil } | |
| 31 | + | ||
| 32 | + | // Event is one event of the "on" key with its filters. A filter that is | |
| 33 | + | // absent from the file is nil; one written as an empty list is empty and | |
| 34 | + | // non-nil. | |
| 35 | + | type Event struct { | |
| 36 | + | Name string | |
| 37 | + | // Token is the position of the event name. | |
| 38 | + | Token template.Token | |
| 39 | + | ||
| 40 | + | Branches []string | |
| 41 | + | BranchesIgnore []string | |
| 42 | + | Tags []string | |
| 43 | + | TagsIgnore []string | |
| 44 | + | Paths []string | |
| 45 | + | PathsIgnore []string | |
| 46 | + | Types []string | |
| 47 | + | Versions []string | |
| 48 | + | Names []string | |
| 49 | + | Workflows []string | |
| 50 | + | ||
| 51 | + | cf *compiledFilters | |
| 52 | + | ||
| 53 | + | // Schedule holds the entries of the "schedule" event. | |
| 54 | + | Schedule []Schedule | |
| 55 | + | // Inputs holds the inputs of workflow_dispatch and workflow_call. | |
| 56 | + | Inputs []Input | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Schedule is an entry of the "schedule" event. | |
| 60 | + | type Schedule struct { | |
| 61 | + | Cron string | |
| 62 | + | Timezone string | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Input is an input of workflow_dispatch or workflow_call. | |
| 66 | + | type Input struct { | |
| 67 | + | Name string | |
| 68 | + | Type string // "string", "choice", "boolean", "number", "environment" | |
| 69 | + | Description string | |
| 70 | + | Required bool | |
| 71 | + | // HasDefault reports whether the file gave a default; Default is its text | |
| 72 | + | // (an expression keeps its ${{ }} form). | |
| 73 | + | HasDefault bool | |
| 74 | + | Default string | |
| 75 | + | Options []string | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // convertOn is a port of convertOn in converter/events.ts. | |
| 79 | + | func (c *converter) convertOn(t template.Token) *On { | |
| 80 | + | on := &On{} | |
| 81 | + | switch t := t.(type) { | |
| 82 | + | case *template.String: | |
| 83 | + | on.Events = append(on.Events, &Event{Name: t.Value, Token: t}) | |
| 84 | + | case *template.Sequence: | |
| 85 | + | for _, it := range t.Items { | |
| 86 | + | s := asString(it, "on") | |
| 87 | + | on.Events = append(on.Events, &Event{Name: s.Value, Token: s}) | |
| 88 | + | } | |
| 89 | + | case *template.Mapping: | |
| 90 | + | for _, p := range t.Pairs { | |
| 91 | + | key := asString(p.Key, "event name") | |
| 92 | + | ev := &Event{Name: key.Value, Token: key} | |
| 93 | + | on.Events = append(on.Events, ev) | |
| 94 | + | if p.Value.Kind() == template.KindNull { | |
| 95 | + | continue | |
| 96 | + | } | |
| 97 | + | if key.Value == "schedule" { | |
| 98 | + | ev.Schedule = c.convertSchedule(asSeq(p.Value, "event schedule")) | |
| 99 | + | continue | |
| 100 | + | } | |
| 101 | + | m := asMap(p.Value, "event "+key.Value) | |
| 102 | + | switch key.Value { | |
| 103 | + | case "workflow_call": | |
| 104 | + | c.convertWorkflowCall(ev, m) | |
| 105 | + | continue | |
| 106 | + | case "workflow_dispatch": | |
| 107 | + | c.convertWorkflowDispatch(ev, m) | |
| 108 | + | continue | |
| 109 | + | } | |
| 110 | + | ev.Branches = patternFilter("branches", m) | |
| 111 | + | ev.BranchesIgnore = patternFilter("branches-ignore", m) | |
| 112 | + | ev.Tags = patternFilter("tags", m) | |
| 113 | + | ev.TagsIgnore = patternFilter("tags-ignore", m) | |
| 114 | + | ev.Paths = patternFilter("paths", m) | |
| 115 | + | ev.PathsIgnore = patternFilter("paths-ignore", m) | |
| 116 | + | ev.Types = patternFilter("types", m) | |
| 117 | + | ev.Versions = patternFilter("versions", m) | |
| 118 | + | ev.Names = patternFilter("names", m) | |
| 119 | + | ev.Workflows = patternFilter("workflows", m) | |
| 120 | + | } | |
| 121 | + | default: | |
| 122 | + | c.ctx.ErrorAt(t, "Invalid format for 'on'") | |
| 123 | + | return &On{} | |
| 124 | + | } | |
| 125 | + | return on | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // patternFilter reads a filter that is a string or a list of strings; nil | |
| 129 | + | // when the key is absent. | |
| 130 | + | func patternFilter(name string, m *template.Mapping) []string { | |
| 131 | + | var out []string | |
| 132 | + | for _, p := range m.Pairs { | |
| 133 | + | key := asString(p.Key, name+" filter key") | |
| 134 | + | if key.Value != name { | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | if s, ok := p.Value.(*template.String); ok { | |
| 138 | + | out = []string{s.Value} | |
| 139 | + | continue | |
| 140 | + | } | |
| 141 | + | out = []string{} | |
| 142 | + | for _, it := range asSeq(p.Value, name+" list").Items { | |
| 143 | + | out = append(out, asString(it, name+" list item").Value) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | return out | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (c *converter) convertSchedule(seq *template.Sequence) []Schedule { | |
| 150 | + | var out []Schedule | |
| 151 | + | for _, it := range seq.Items { | |
| 152 | + | m := asMap(it, "event schedule") | |
| 153 | + | var cfg Schedule | |
| 154 | + | valid := true | |
| 155 | + | for _, e := range m.Pairs { | |
| 156 | + | key := asString(e.Key, "schedule key") | |
| 157 | + | switch key.Value { | |
| 158 | + | case "cron": | |
| 159 | + | cron := asString(e.Value, "schedule cron") | |
| 160 | + | if !isValidCron(cron.Value) { | |
| 161 | + | c.ctx.ErrorAt(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)") | |
| 162 | + | } | |
| 163 | + | cfg.Cron = cron.Value | |
| 164 | + | case "timezone": | |
| 165 | + | cfg.Timezone = asString(e.Value, "schedule timezone").Value | |
| 166 | + | default: | |
| 167 | + | c.ctx.ErrorAt(key, "Invalid schedule key") | |
| 168 | + | valid = false | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | switch { | |
| 172 | + | case valid && cfg.Cron != "": | |
| 173 | + | out = append(out, cfg) | |
| 174 | + | case valid: | |
| 175 | + | c.ctx.ErrorAt(m, "Missing required key 'cron' in schedule entry") | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return out | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (c *converter) convertWorkflowDispatch(ev *Event, m *template.Mapping) { | |
| 182 | + | for _, p := range m.Pairs { | |
| 183 | + | if asString(p.Key, "workflow dispatch input key").Value == "inputs" { | |
| 184 | + | ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), false) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (c *converter) convertWorkflowCall(ev *Event, m *template.Mapping) { | |
| 190 | + | for _, p := range m.Pairs { | |
| 191 | + | switch asString(p.Key, "workflow dispatch input key").Value { | |
| 192 | + | case "inputs": | |
| 193 | + | ev.Inputs = c.convertInputs(asMap(p.Value, "workflow dispatch inputs"), true) | |
| 194 | + | case "secrets": | |
| 195 | + | for _, s := range asMap(p.Value, "workflow dispatch inputs").Pairs { | |
| 196 | + | asString(s.Key, "secret name") | |
| 197 | + | if sm, ok := s.Value.(*template.Mapping); ok { | |
| 198 | + | for _, kv := range sm.Pairs { | |
| 199 | + | switch asString(kv.Key, "workflow call secret key").Value { | |
| 200 | + | case "description": | |
| 201 | + | asString(kv.Value, "secret description") | |
| 202 | + | case "required": | |
| 203 | + | asBool(kv.Value, "secret required") | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // convertInputs ports convertWorkflowDispatchInputs and convertWorkflowInputs. | |
| 213 | + | // The two differ only in that call inputs skip the default check when the | |
| 214 | + | // default is an expression. | |
| 215 | + | func (c *converter) convertInputs(m *template.Mapping, call bool) []Input { | |
| 216 | + | var out []Input | |
| 217 | + | for _, p := range m.Pairs { | |
| 218 | + | name := asString(p.Key, "input name") | |
| 219 | + | im := asMap(p.Value, "input configuration") | |
| 220 | + | out = append(out, c.convertInput(name.Value, im, call)) | |
| 221 | + | } | |
| 222 | + | return out | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func (c *converter) convertInput(name string, m *template.Mapping, call bool) Input { | |
| 226 | + | in := Input{Name: name, Type: "string"} | |
| 227 | + | var def template.Token | |
| 228 | + | var options []string | |
| 229 | + | hasOptions := false | |
| 230 | + | for _, p := range m.Pairs { | |
| 231 | + | key := asString(p.Key, "workflow dispatch input key") | |
| 232 | + | switch key.Value { | |
| 233 | + | case "description": | |
| 234 | + | in.Description = asString(p.Value, "input description").Value | |
| 235 | + | case "required": | |
| 236 | + | in.Required = asBool(p.Value, "input required").Value | |
| 237 | + | case "default": | |
| 238 | + | def = asScalar(p.Value, "input default") | |
| 239 | + | case "type": | |
| 240 | + | in.Type = asString(p.Value, "input type").Value | |
| 241 | + | case "options": | |
| 242 | + | hasOptions = true | |
| 243 | + | options = []string{} | |
| 244 | + | for _, it := range asSeq(p.Value, "input options").Items { | |
| 245 | + | options = append(options, asString(it, "input options item").Value) | |
| 246 | + | } | |
| 247 | + | in.Options = options | |
| 248 | + | default: | |
| 249 | + | c.ctx.Errorf(p.Key, "Invalid key '%s'", key.Value) | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | if def != nil { | |
| 253 | + | in.HasDefault = true | |
| 254 | + | in.Default = def.String() | |
| 255 | + | if !call || !template.IsExpression(def) { | |
| 256 | + | c.handle(def, func() { | |
| 257 | + | switch in.Type { | |
| 258 | + | case "boolean": | |
| 259 | + | asBool(def, "input default") | |
| 260 | + | case "string", "choice", "environment": | |
| 261 | + | asString(def, "input default") | |
| 262 | + | } | |
| 263 | + | }) | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | if in.Type == "choice" { | |
| 267 | + | if !hasOptions || len(options) == 0 { | |
| 268 | + | c.ctx.ErrorAt(m, "Missing 'options' for choice input") | |
| 269 | + | } | |
| 270 | + | } else if hasOptions { | |
| 271 | + | c.ctx.ErrorAt(m, "Input type is not 'choice', but 'options' is defined") | |
| 272 | + | } | |
| 273 | + | return in | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // filterNames are the pattern filters of an event, in the order they are | |
| 277 | + | // checked and reported. | |
| 278 | + | var filterNames = []string{"branches", "branches-ignore", "tags", "tags-ignore", "paths", "paths-ignore"} | |
| 279 | + | ||
| 280 | + | // filterLists returns the lists of the six pattern filters by name. | |
| 281 | + | func (e *Event) filterLists() map[string][]string { | |
| 282 | + | return map[string][]string{ | |
| 283 | + | "branches": e.Branches, "branches-ignore": e.BranchesIgnore, | |
| 284 | + | "tags": e.Tags, "tags-ignore": e.TagsIgnore, | |
| 285 | + | "paths": e.Paths, "paths-ignore": e.PathsIgnore, | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // compiledFilters holds the compiled pattern lists of an event. | |
| 290 | + | type compiledFilters struct { | |
| 291 | + | branches, branchesIgnore, tags, tagsIgnore, paths, pathsIgnore []*Pattern | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func (e *Event) compileFilters() (*compiledFilters, map[string]error) { | |
| 295 | + | errs := map[string]error{} | |
| 296 | + | var cf compiledFilters | |
| 297 | + | dst := map[string]*[]*Pattern{ | |
| 298 | + | "branches": &cf.branches, "branches-ignore": &cf.branchesIgnore, | |
| 299 | + | "tags": &cf.tags, "tags-ignore": &cf.tagsIgnore, | |
| 300 | + | "paths": &cf.paths, "paths-ignore": &cf.pathsIgnore, | |
| 301 | + | } | |
| 302 | + | for name, list := range e.filterLists() { | |
| 303 | + | l, err := compileList(list) | |
| 304 | + | if err != nil { | |
| 305 | + | errs[name] = err | |
| 306 | + | } | |
| 307 | + | *dst[name] = l | |
| 308 | + | } | |
| 309 | + | return &cf, errs | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // filters returns the compiled filters: those made by Parse, or, for an | |
| 313 | + | // Event built by hand, a fresh compilation (a pattern that does not compile | |
| 314 | + | // never matches). | |
| 315 | + | func (e *Event) filters() *compiledFilters { | |
| 316 | + | if e.cf != nil { | |
| 317 | + | return e.cf | |
| 318 | + | } | |
| 319 | + | cf, _ := e.compileFilters() | |
| 320 | + | return cf | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // validateFilters checks the pattern filters of every event, as GitHub does | |
| 324 | + | // and @actions/workflow-parser does not: | |
| 325 | + | // | |
| 326 | + | // - a filter and its "-ignore" counterpart (branches and branches-ignore, | |
| 327 | + | // tags and tags-ignore, paths and paths-ignore) cannot be used together; | |
| 328 | + | // - a list made only of "!" patterns is an error: such a list can match | |
| 329 | + | // nothing, and GitHub requires at least one pattern without "!"; | |
| 330 | + | // - every pattern must compile. | |
| 331 | + | // | |
| 332 | + | // The messages are ours. The compiled patterns are kept in the events. | |
| 333 | + | // | |
| 334 | + | // TODO(verify with recording): the text and position of GitHub's errors, | |
| 335 | + | // whether they apply to every event that has these filters, and whether a | |
| 336 | + | // "!" pattern is refused in the "-ignore" lists too. | |
| 337 | + | func (c *converter) validateFilters() { | |
| 338 | + | if c.w.On == nil { | |
| 339 | + | return | |
| 340 | + | } | |
| 341 | + | for _, ev := range c.w.On.Events { | |
| 342 | + | lists := ev.filterLists() | |
| 343 | + | for _, pr := range [][2]string{{"branches", "branches-ignore"}, {"tags", "tags-ignore"}, {"paths", "paths-ignore"}} { | |
| 344 | + | if lists[pr[0]] != nil && lists[pr[1]] != nil { | |
| 345 | + | c.ctx.Errorf(ev.Token, "Event '%s' cannot use both '%s' and '%s'. Use only one of them.", ev.Name, pr[0], pr[1]) | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | cf, errs := ev.compileFilters() | |
| 349 | + | for _, name := range filterNames { | |
| 350 | + | list := lists[name] | |
| 351 | + | if err := errs[name]; err != nil { | |
| 352 | + | c.ctx.Errorf(ev.Token, "Event '%s' filter '%s': %v", ev.Name, name, err) | |
| 353 | + | } | |
| 354 | + | if onlyNegated(list) { | |
| 355 | + | c.ctx.Errorf(ev.Token, "Event '%s' filter '%s' has only patterns that start with '!'. Add at least one pattern without '!'.", ev.Name, name) | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | ev.cf = cf | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // onlyNegated reports whether a non-empty list has no positive pattern. | |
| 363 | + | func onlyNegated(patterns []string) bool { | |
| 364 | + | if len(patterns) == 0 { | |
| 365 | + | return false | |
| 366 | + | } | |
| 367 | + | for _, p := range patterns { | |
| 368 | + | if !strings.HasPrefix(p, "!") { | |
| 369 | + | return false | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | return true | |
| 373 | + | } | |
A workflow/fuzz_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func FuzzParseWorkflow(f *testing.F) { | |
| 12 | + | files, _ := filepath.Glob("testdata/convert/*.yml") | |
| 13 | + | for _, name := range files { | |
| 14 | + | if data, err := os.ReadFile(name); err == nil { | |
| 15 | + | f.Add(data) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | f.Add([]byte("on: push\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")) | |
| 19 | + | f.Add([]byte("")) | |
| 20 | + | f.Add([]byte("\x00\xff")) | |
| 21 | + | f.Fuzz(func(t *testing.T, data []byte) { | |
| 22 | + | w, errs := Parse("f.yml", data) | |
| 23 | + | if (w == nil) == (len(errs) == 0) { | |
| 24 | + | t.Fatalf("workflow nil = %v with %d errors", w == nil, len(errs)) | |
| 25 | + | } | |
| 26 | + | for _, e := range errs { | |
| 27 | + | _ = e.Error() | |
| 28 | + | } | |
| 29 | + | if w != nil { | |
| 30 | + | w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: []string{"a/b"}}) | |
| 31 | + | w.MatchPush(PushInfo{Ref: "refs/tags/v1", TagCount: 1}) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func FuzzExpandMatrix(f *testing.F) { | |
| 37 | + | f.Add(`{"matrix": {"a": [1, 2], "include": [{"a": 1, "b": 2}], "exclude": [{"a": 2}]}}`) | |
| 38 | + | f.Add(`{"matrix": {"a": [1], "b": [[1], {"c": 2}]}}`) | |
| 39 | + | f.Fuzz(func(t *testing.T, s string) { | |
| 40 | + | v := jsonValue2(s) | |
| 41 | + | if v == nil { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | _, _ = ExpandMatrix(ctxbg, v) | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func FuzzSkipCI(f *testing.F) { | |
| 49 | + | f.Add("fix\n\nskip-checks: true") | |
| 50 | + | f.Fuzz(func(t *testing.T, s string) { SkipCI(s) }) | |
| 51 | + | } | |
A workflow/gen/convert.mjs +49 -0
| @@ -0,0 +1,49 @@ | |||
| 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 | + | // gen/regen.sh | |
| 8 | + | // | |
| 9 | + | // which installs the versions pinned in gen/package-lock.json and runs this | |
| 10 | + | // script with the loader of gen/register.mjs (the package imports its JSON | |
| 11 | + | // schema without the import attribute recent Node versions require). A dist | |
| 12 | + | // path may be given as the first argument. | |
| 13 | + | ||
| 14 | + | import { pathToFileURL } from "node:url"; | |
| 15 | + | import path from "node:path"; | |
| 16 | + | import fs from "node:fs"; | |
| 17 | + | ||
| 18 | + | const dist = path.resolve(process.argv[2] ?? new URL("../../gen/node_modules/@actions/workflow-parser/dist", import.meta.url).pathname); | |
| 19 | + | const imp = p => import(pathToFileURL(path.join(dist, p)).href); | |
| 20 | + | const { parseWorkflow } = await imp("workflows/workflow-parser.js"); | |
| 21 | + | const { NoOperationTraceWriter } = await imp("templates/trace-writer.js"); | |
| 22 | + | const { convertWorkflowTemplate } = await imp("model/convert.js"); | |
| 23 | + | ||
| 24 | + | const dir = new URL("../testdata/convert/", import.meta.url); | |
| 25 | + | const out = {}; | |
| 26 | + | for (const f of fs.readdirSync(dir).sort()) { | |
| 27 | + | if (!f.endsWith(".yml")) continue; | |
| 28 | + | const content = fs.readFileSync(new URL(f, dir), "utf8"); | |
| 29 | + | const r = parseWorkflow({ name: f, content }, new NoOperationTraceWriter()); | |
| 30 | + | const res = { errors: [] }; | |
| 31 | + | if (!r.value) { | |
| 32 | + | res.errors = r.context.errors.getErrors().map(e => e.message); | |
| 33 | + | } else { | |
| 34 | + | // Default options: ReturnErrorsOnly, i.e. the converter does not run when the | |
| 35 | + | // schema reported errors. | |
| 36 | + | const t = await convertWorkflowTemplate(r.context, r.value); | |
| 37 | + | res.errors = (t.errors || []).map(e => e.Message); | |
| 38 | + | res.jobs = (t.jobs || []).map(j => ({ | |
| 39 | + | id: j.id.value, | |
| 40 | + | name: String(j.name), | |
| 41 | + | if: j.if?.expression, | |
| 42 | + | needs: (j.needs || []).map(n => n.value), | |
| 43 | + | steps: (j.steps || []).map(s => ({ id: s.id, if: s.if?.expression })), | |
| 44 | + | })); | |
| 45 | + | res.events = t.events; | |
| 46 | + | } | |
| 47 | + | out[f] = res; | |
| 48 | + | } | |
| 49 | + | console.log(JSON.stringify(out, null, 1)); | |
A workflow/glob.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "regexp" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Pattern is a compiled filter pattern in the syntax GitHub documents as the | |
| 12 | + | // "filter pattern cheat sheet" for branches, tags and paths: | |
| 13 | + | // | |
| 14 | + | // - zero or more characters, but not / | |
| 15 | + | // ** zero or more of any character; "**/" at the start of a pattern or | |
| 16 | + | // after a "/" may also match nothing, so "**/README.md" matches | |
| 17 | + | // "README.md" and "docs/**/*.md" matches "docs/a.md" | |
| 18 | + | // ? zero or one of the preceding character | |
| 19 | + | // - one or more of the preceding character | |
| 20 | + | // [abc] one character listed or in a range (a-z, A-Z, 0-9) | |
| 21 | + | // ! at the start of a pattern: negates the patterns before it | |
| 22 | + | // \ escapes the next character | |
| 23 | + | // | |
| 24 | + | // Every other character, "." included, stands for itself. Matching is | |
| 25 | + | // case-sensitive and covers the whole name. | |
| 26 | + | // | |
| 27 | + | // TODO(verify with recording): the exact GitHub implementation is not | |
| 28 | + | // public. The cheat sheet fixes the common cases; "**/" matching nothing, | |
| 29 | + | // "\" escapes and how ? and + behave after a wildcard or a bracket are | |
| 30 | + | // reconstructed. | |
| 31 | + | type Pattern struct { | |
| 32 | + | // Negated is set for a pattern that started with "!". | |
| 33 | + | Negated bool | |
| 34 | + | // Source is the pattern as written. | |
| 35 | + | Source string | |
| 36 | + | re *regexp.Regexp | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // CompilePattern compiles one pattern. A pattern that cannot be compiled never | |
| 40 | + | // matches; the error says why. | |
| 41 | + | func CompilePattern(p string) (*Pattern, error) { | |
| 42 | + | pat := &Pattern{Source: p} | |
| 43 | + | body := p | |
| 44 | + | if strings.HasPrefix(body, "!") { | |
| 45 | + | pat.Negated = true | |
| 46 | + | body = body[1:] | |
| 47 | + | } | |
| 48 | + | re, err := regexp.Compile("^(?s:" + translateGlob(body) + ")$") | |
| 49 | + | if err != nil { | |
| 50 | + | return pat, err | |
| 51 | + | } | |
| 52 | + | pat.re = re | |
| 53 | + | return pat, nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Match reports whether name matches the pattern, ignoring any "!" prefix. | |
| 57 | + | func (p *Pattern) Match(name string) bool { return p.re != nil && p.re.MatchString(name) } | |
| 58 | + | ||
| 59 | + | // translateGlob converts a pattern to an RE2 expression. | |
| 60 | + | func translateGlob(p string) string { | |
| 61 | + | var b strings.Builder | |
| 62 | + | rs := []rune(p) | |
| 63 | + | // quantifiable is true when the last thing written is a single character | |
| 64 | + | // or a class that ? and + can apply to. | |
| 65 | + | quantifiable := false | |
| 66 | + | for i := 0; i < len(rs); i++ { | |
| 67 | + | c := rs[i] | |
| 68 | + | switch c { | |
| 69 | + | case '\\': | |
| 70 | + | if i+1 < len(rs) { | |
| 71 | + | i++ | |
| 72 | + | b.WriteString(regexp.QuoteMeta(string(rs[i]))) | |
| 73 | + | } else { | |
| 74 | + | b.WriteString(`\\`) | |
| 75 | + | } | |
| 76 | + | quantifiable = true | |
| 77 | + | case '*': | |
| 78 | + | if i+1 < len(rs) && rs[i+1] == '*' { | |
| 79 | + | for i+1 < len(rs) && rs[i+1] == '*' { | |
| 80 | + | i++ | |
| 81 | + | } | |
| 82 | + | atStart := b.Len() == 0 || (i-2 >= 0 && rs[i-2] == '/') | |
| 83 | + | if atStart && i+1 < len(rs) && rs[i+1] == '/' { | |
| 84 | + | i++ | |
| 85 | + | b.WriteString(`(?:.*/)?`) | |
| 86 | + | } else { | |
| 87 | + | b.WriteString(`.*`) | |
| 88 | + | } | |
| 89 | + | } else { | |
| 90 | + | b.WriteString(`[^/]*`) | |
| 91 | + | } | |
| 92 | + | quantifiable = false | |
| 93 | + | case '?', '+': | |
| 94 | + | if quantifiable { | |
| 95 | + | b.WriteRune(c) | |
| 96 | + | quantifiable = false | |
| 97 | + | } else { | |
| 98 | + | b.WriteString(regexp.QuoteMeta(string(c))) | |
| 99 | + | quantifiable = true | |
| 100 | + | } | |
| 101 | + | case '[': | |
| 102 | + | if class, n, ok := parseClass(rs[i:]); ok { | |
| 103 | + | b.WriteString(class) | |
| 104 | + | i += n - 1 | |
| 105 | + | quantifiable = true | |
| 106 | + | } else { | |
| 107 | + | b.WriteString(`\[`) | |
| 108 | + | quantifiable = true | |
| 109 | + | } | |
| 110 | + | default: | |
| 111 | + | b.WriteString(regexp.QuoteMeta(string(c))) | |
| 112 | + | quantifiable = true | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | return b.String() | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // parseClass reads "[...]" at the start of rs: letters, digits and ranges of | |
| 119 | + | // them. It returns the RE2 class and the number of runes consumed. | |
| 120 | + | func parseClass(rs []rune) (string, int, bool) { | |
| 121 | + | var b strings.Builder | |
| 122 | + | b.WriteByte('[') | |
| 123 | + | n := 0 | |
| 124 | + | for i := 1; i < len(rs); i++ { | |
| 125 | + | c := rs[i] | |
| 126 | + | switch { | |
| 127 | + | case c == ']': | |
| 128 | + | if n == 0 { | |
| 129 | + | return "", 0, false | |
| 130 | + | } | |
| 131 | + | b.WriteByte(']') | |
| 132 | + | return b.String(), i + 1, true | |
| 133 | + | case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': | |
| 134 | + | b.WriteRune(c) | |
| 135 | + | n++ | |
| 136 | + | case c == '-' && n > 0 && i+1 < len(rs) && rs[i+1] != ']': | |
| 137 | + | b.WriteByte('-') | |
| 138 | + | default: | |
| 139 | + | return "", 0, false | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | return "", 0, false | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // matchList applies compiled patterns to name in order. A pattern that | |
| 146 | + | // matches includes the name, a "!" pattern that matches excludes it again, and | |
| 147 | + | // the last pattern that matches decides. A list with no positive pattern | |
| 148 | + | // therefore matches nothing. | |
| 149 | + | func matchList(patterns []*Pattern, name string) bool { | |
| 150 | + | matched := false | |
| 151 | + | for _, p := range patterns { | |
| 152 | + | if p.Match(name) { | |
| 153 | + | matched = !p.Negated | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | return matched | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // compileList compiles a filter list. It reports the first pattern that does | |
| 160 | + | // not compile. | |
| 161 | + | func compileList(patterns []string) ([]*Pattern, error) { | |
| 162 | + | out := make([]*Pattern, 0, len(patterns)) | |
| 163 | + | for _, s := range patterns { | |
| 164 | + | p, err := CompilePattern(s) | |
| 165 | + | if err != nil { | |
| 166 | + | return nil, fmt.Errorf("invalid pattern '%s': %w", s, err) | |
| 167 | + | } | |
| 168 | + | out = append(out, p) | |
| 169 | + | } | |
| 170 | + | return out, nil | |
| 171 | + | } | |
A workflow/glob_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import "testing" | |
| 6 | + | ||
| 7 | + | func TestPatternMatch(t *testing.T) { | |
| 8 | + | // Cases from GitHub's "Filter pattern cheat sheet" and the examples | |
| 9 | + | // around it. | |
| 10 | + | tests := []struct { | |
| 11 | + | pattern, name string | |
| 12 | + | want bool | |
| 13 | + | }{ | |
| 14 | + | // feature/*: the * matches any character except /. | |
| 15 | + | {"feature/*", "feature/my-branch", true}, | |
| 16 | + | {"feature/*", "feature/your-branch", true}, | |
| 17 | + | {"feature/*", "feature", false}, | |
| 18 | + | {"feature/*", "feature/a/b", false}, | |
| 19 | + | // feature/**: matches any branch with feature/ and slashes. | |
| 20 | + | {"feature/**", "feature/beta-a/my-branch", true}, | |
| 21 | + | {"feature/**", "feature/your-branch", true}, | |
| 22 | + | {"feature/**", "feature/mona/the/octocat", true}, | |
| 23 | + | // main, releases/mona-the-octocat: exact. | |
| 24 | + | {"main", "main", true}, | |
| 25 | + | {"main", "main2", false}, | |
| 26 | + | {"main", "xmain", false}, | |
| 27 | + | {"releases/mona-the-octocat", "releases/mona-the-octocat", true}, | |
| 28 | + | // '*': all names that do not contain a slash. | |
| 29 | + | {"*", "main", true}, | |
| 30 | + | {"*", "releases", true}, | |
| 31 | + | {"*", "releases/10", false}, | |
| 32 | + | // '**': all names. | |
| 33 | + | {"**", "all/the/branches", true}, | |
| 34 | + | {"**", "every/tag", true}, | |
| 35 | + | {"**", "", true}, | |
| 36 | + | // '*feature': * is a special character in YAML, quoted here. | |
| 37 | + | {"*feature", "mona-feature", true}, | |
| 38 | + | {"*feature", "feature", true}, | |
| 39 | + | {"*feature", "ver-10-feature", true}, | |
| 40 | + | {"*feature", "a/feature", false}, | |
| 41 | + | // v2* | |
| 42 | + | {"v2*", "v2", true}, | |
| 43 | + | {"v2*", "v2.0", true}, | |
| 44 | + | {"v2*", "v2.9", true}, | |
| 45 | + | {"v2*", "v2/x", false}, | |
| 46 | + | // v[12].[0-9]+.[0-9]+ | |
| 47 | + | {"v[12].[0-9]+.[0-9]+", "v1.10.1", true}, | |
| 48 | + | {"v[12].[0-9]+.[0-9]+", "v2.0.0", true}, | |
| 49 | + | {"v[12].[0-9]+.[0-9]+", "v3.0.0", false}, | |
| 50 | + | {"v[12].[0-9]+.[0-9]+", "v1.x.1", false}, | |
| 51 | + | // releases/**-alpha | |
| 52 | + | {"releases/**-alpha", "releases/beta/3-alpha", true}, | |
| 53 | + | {"releases/**-alpha", "releases/v10/3-alpha", true}, | |
| 54 | + | {"releases/**-alpha", "releases/3-alpha", true}, | |
| 55 | + | {"releases/**-alpha", "releases/3-beta", false}, | |
| 56 | + | // ? : zero or one of the preceding character. | |
| 57 | + | {"colou?r", "color", true}, | |
| 58 | + | {"colou?r", "colour", true}, | |
| 59 | + | {"colou?r", "colouur", false}, | |
| 60 | + | // + : one or more. | |
| 61 | + | {"a+b", "ab", true}, | |
| 62 | + | {"a+b", "aaab", true}, | |
| 63 | + | {"a+b", "b", false}, | |
| 64 | + | // paths | |
| 65 | + | {"**.js", "app.js", true}, | |
| 66 | + | {"**.js", "src/app.js", true}, | |
| 67 | + | {"**.js", "src/app.ts", false}, | |
| 68 | + | {"*.js", "app.js", true}, | |
| 69 | + | {"*.js", "src/app.js", false}, | |
| 70 | + | {"docs/**", "docs/readme.md", true}, | |
| 71 | + | {"docs/**", "docs/a/b/c.md", true}, | |
| 72 | + | {"docs/**", "docs", false}, | |
| 73 | + | {"docs/**/*.md", "docs/a.md", true}, | |
| 74 | + | {"docs/**/*.md", "docs/x/y/a.md", true}, | |
| 75 | + | {"docs/**/*.md", "docs/a.txt", false}, | |
| 76 | + | {"**/README.md", "README.md", true}, | |
| 77 | + | {"**/README.md", "a/b/README.md", true}, | |
| 78 | + | {"**/README.md", "a/XREADME.md", false}, | |
| 79 | + | {"sub-project/**", "sub-project/src/main.go", true}, | |
| 80 | + | {"/README.md", "README.md", false}, | |
| 81 | + | // Special characters stand for themselves. | |
| 82 | + | {"a.b", "a.b", true}, | |
| 83 | + | {"a.b", "axb", false}, | |
| 84 | + | {"a(b)", "a(b)", true}, | |
| 85 | + | {`a\*b`, "a*b", true}, | |
| 86 | + | {`a\*b`, "axb", false}, | |
| 87 | + | // Case-sensitive. | |
| 88 | + | {"Main", "main", false}, | |
| 89 | + | } | |
| 90 | + | for _, tc := range tests { | |
| 91 | + | p, err := CompilePattern(tc.pattern) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Errorf("%q: %v", tc.pattern, err) | |
| 94 | + | continue | |
| 95 | + | } | |
| 96 | + | if got := p.Match(tc.name); got != tc.want { | |
| 97 | + | t.Errorf("Match(%q, %q) = %v, want %v", tc.pattern, tc.name, got, tc.want) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestMatchListOrder(t *testing.T) { | |
| 103 | + | // "The order that you define patterns matters": a later negative pattern | |
| 104 | + | // excludes, a later positive one includes again. | |
| 105 | + | tests := []struct { | |
| 106 | + | patterns []string | |
| 107 | + | name string | |
| 108 | + | want bool | |
| 109 | + | }{ | |
| 110 | + | {[]string{"releases/**", "!releases/**-alpha"}, "releases/v1", true}, | |
| 111 | + | {[]string{"releases/**", "!releases/**-alpha"}, "releases/v1-alpha", false}, | |
| 112 | + | {[]string{"releases/**", "!releases/**-alpha", "releases/**/ga"}, "releases/v1-alpha/ga", true}, | |
| 113 | + | {[]string{"!releases/**-alpha", "releases/**"}, "releases/v1-alpha", true}, | |
| 114 | + | {[]string{"!docs/**"}, "src/a.go", false}, // only negatives: matches nothing | |
| 115 | + | {[]string{"**", "!docs/**"}, "src/a.go", true}, | |
| 116 | + | {[]string{"**", "!docs/**"}, "docs/a.md", false}, | |
| 117 | + | {nil, "x", false}, | |
| 118 | + | } | |
| 119 | + | for _, tc := range tests { | |
| 120 | + | if got := matchList(mustList(t, tc.patterns), tc.name); got != tc.want { | |
| 121 | + | t.Errorf("matchList(%q, %q) = %v, want %v", tc.patterns, tc.name, got, tc.want) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func mustList(t testing.TB, patterns []string) []*Pattern { | |
| 127 | + | t.Helper() | |
| 128 | + | l, err := compileList(patterns) | |
| 129 | + | if err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | return l | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestUnclosedBracketIsLiteral(t *testing.T) { | |
| 136 | + | if !matchList(mustList(t, []string{"[abc"}), "[abc") { | |
| 137 | + | t.Error("an unclosed bracket should be literal") | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func FuzzPattern(f *testing.F) { | |
| 142 | + | for _, s := range []string{"*", "**", "a/**/b", "[a-z]+", "!x", `\`, "?+", "[", "]]"} { | |
| 143 | + | f.Add(s, "a/b") | |
| 144 | + | } | |
| 145 | + | f.Fuzz(func(t *testing.T, pattern, name string) { | |
| 146 | + | if p, err := CompilePattern(pattern); err == nil { | |
| 147 | + | p.Match(name) | |
| 148 | + | } | |
| 149 | + | }) | |
| 150 | + | } | |
A workflow/ids.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | idSeparator = "_" | |
| 12 | + | idMaxAttempts = 1000 | |
| 13 | + | idMaxLength = 100 | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // idBuilder is a port of IdBuilder in the reference: it validates the ids | |
| 17 | + | // written in a file and makes unique ids for steps that have none. | |
| 18 | + | type idBuilder struct { | |
| 19 | + | name []rune | |
| 20 | + | distinct map[string]bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func newIDBuilder() *idBuilder { return &idBuilder{distinct: map[string]bool{}} } | |
| 24 | + | ||
| 25 | + | func isAlpha(c rune) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' } | |
| 26 | + | func isNumeric(c rune) bool { return c >= '0' && c <= '9' } | |
| 27 | + | ||
| 28 | + | func (b *idBuilder) appendSegment(value string) { | |
| 29 | + | if value == "" { | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | rs := []rune(value) | |
| 33 | + | if len(b.name) == 0 { | |
| 34 | + | first := rs[0] | |
| 35 | + | switch { | |
| 36 | + | case isAlpha(first) || first == '_': | |
| 37 | + | case isNumeric(first) || first == '-': | |
| 38 | + | b.name = append(b.name, '_') | |
| 39 | + | } | |
| 40 | + | } else { | |
| 41 | + | b.name = append(b.name, '_') | |
| 42 | + | } | |
| 43 | + | for _, c := range rs { | |
| 44 | + | if isAlpha(c) || isNumeric(c) || c == '_' || c == '-' { | |
| 45 | + | b.name = append(b.name, c) | |
| 46 | + | } else { | |
| 47 | + | b.name = append(b.name, '_') | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // build returns a unique id from the appended segments, "job" when there are | |
| 53 | + | // none. It returns "" when a thousand ids are taken. | |
| 54 | + | func (b *idBuilder) build() string { | |
| 55 | + | original := "job" | |
| 56 | + | if len(b.name) > 0 { | |
| 57 | + | original = string(b.name) | |
| 58 | + | } | |
| 59 | + | for attempt := 1; attempt < idMaxAttempts; attempt++ { | |
| 60 | + | suffix := "" | |
| 61 | + | if attempt > 1 { | |
| 62 | + | suffix = fmt.Sprintf("_%d", attempt) | |
| 63 | + | } | |
| 64 | + | n := min(len(original), idMaxLength-len(suffix)) | |
| 65 | + | candidate := original[:n] + suffix | |
| 66 | + | if !b.distinct[candidate] { | |
| 67 | + | b.distinct[candidate] = true | |
| 68 | + | b.name = nil | |
| 69 | + | return candidate | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | return "" | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // tryAddKnownID records an id written in the file. It returns the error | |
| 76 | + | // message when the id is invalid or already used. | |
| 77 | + | func (b *idBuilder) tryAddKnownID(value string) string { | |
| 78 | + | if value == "" || !validID(value) || len(value) >= idMaxLength { | |
| 79 | + | return fmt.Sprintf("The identifier '%s' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than %d characters.", value, idMaxLength) | |
| 80 | + | } | |
| 81 | + | if strings.HasPrefix(value, idSeparator+idSeparator) { | |
| 82 | + | return fmt.Sprintf("The identifier '%s' is invalid. IDs starting with '__' are reserved.", value) | |
| 83 | + | } | |
| 84 | + | if b.distinct[value] { | |
| 85 | + | return fmt.Sprintf("The identifier '%s' may not be used more than once within the same scope.", value) | |
| 86 | + | } | |
| 87 | + | b.distinct[value] = true | |
| 88 | + | return "" | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func validID(name string) bool { | |
| 92 | + | for i, c := range name { | |
| 93 | + | if i == 0 { | |
| 94 | + | if !isAlpha(c) && c != '_' { | |
| 95 | + | return false | |
| 96 | + | } | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | if !isAlpha(c) && !isNumeric(c) && c != '_' && c != '-' { | |
| 100 | + | return false | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | return true | |
| 104 | + | } | |
A workflow/job_eval.go +226 -0
| @@ -0,0 +1,226 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "slices" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 12 | + | "bnema.dev/ermite-actions/template" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // StatusFuncs is the state the status functions of conditions report: | |
| 16 | + | // success(), failure(), cancelled() and always(). | |
| 17 | + | type StatusFuncs struct { | |
| 18 | + | // Success is the result of success(): true while nothing before has | |
| 19 | + | // failed or been cancelled. | |
| 20 | + | Success bool | |
| 21 | + | // Failure is the result of failure(). | |
| 22 | + | Failure bool | |
| 23 | + | // Cancelled is the result of cancelled(). | |
| 24 | + | Cancelled bool | |
| 25 | + | // always() is true, whatever the state. | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Funcs returns the implementations to put in [template.Env.Funcs]. The | |
| 29 | + | // functions ignore their arguments. | |
| 30 | + | func (s StatusFuncs) Funcs() map[string]expr.Func { | |
| 31 | + | fixed := func(b bool) expr.Func { | |
| 32 | + | return func(context.Context, []expr.Value) (expr.Value, error) { return expr.Boolean(b), nil } | |
| 33 | + | } | |
| 34 | + | return map[string]expr.Func{ | |
| 35 | + | "success": fixed(s.Success), | |
| 36 | + | "failure": fixed(s.Failure), | |
| 37 | + | "cancelled": fixed(s.Cancelled), | |
| 38 | + | "always": fixed(true), | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // evaluateIf evaluates a condition with the status functions added to env and | |
| 43 | + | // converts the result to a boolean the way GitHub does (see expr.Truthy). | |
| 44 | + | func evaluateIf(ctx context.Context, cond *template.BasicExpression, env template.Env, st StatusFuncs) (bool, error) { | |
| 45 | + | out := env | |
| 46 | + | out.Funcs = make(map[string]expr.Func, len(env.Funcs)+4) | |
| 47 | + | for k, f := range env.Funcs { | |
| 48 | + | out.Funcs[k] = f | |
| 49 | + | } | |
| 50 | + | for k, f := range st.Funcs() { | |
| 51 | + | out.Funcs[k] = f | |
| 52 | + | } | |
| 53 | + | v, err := template.EvaluateExpression(ctx, cond, out) | |
| 54 | + | if err != nil { | |
| 55 | + | return false, err | |
| 56 | + | } | |
| 57 | + | return expr.Truthy(v), nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // EvaluateIf evaluates the job's condition. env must provide the contexts | |
| 61 | + | // the schema allows in a job's "if": github, inputs, vars and needs. | |
| 62 | + | func (j *Job) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) { | |
| 63 | + | return evaluateIf(ctx, j.If, env, st) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // EvaluateIf evaluates the step's condition. env must provide the contexts | |
| 67 | + | // the schema allows in a step's "if" and may provide hashFiles. | |
| 68 | + | func (s *Step) EvaluateIf(ctx context.Context, env template.Env, st StatusFuncs) (bool, error) { | |
| 69 | + | return evaluateIf(ctx, s.If, env, st) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // EvaluateRunsOn evaluates the job's runs-on and returns the labels and the | |
| 73 | + | // runner group. runs-on may be a string, a list of strings, or a mapping with | |
| 74 | + | // "group" and "labels" (a string or a list), each part possibly an | |
| 75 | + | // expression. A job that calls a reusable workflow has no runs-on. | |
| 76 | + | // | |
| 77 | + | // The checks are those of the reference's converter: a group is a name, or | |
| 78 | + | // "org/name", "organization/name", "ent/name" or "enterprise/name". | |
| 79 | + | func (j *Job) EvaluateRunsOn(ctx context.Context, env template.Env) (labels []string, group string, err error) { | |
| 80 | + | if j.RunsOn == nil { | |
| 81 | + | return nil, "", fmt.Errorf("job '%s' has no runs-on", j.ID) | |
| 82 | + | } | |
| 83 | + | v, err := template.Evaluate(ctx, j.RunsOn, env) | |
| 84 | + | if err != nil { | |
| 85 | + | return nil, "", err | |
| 86 | + | } | |
| 87 | + | fail := func(format string, a ...any) ([]string, string, error) { | |
| 88 | + | return nil, "", &template.Error{File: j.RunsOn.File(), Pos: j.RunsOn.Pos(), Raw: fmt.Sprintf(format, a...)} | |
| 89 | + | } | |
| 90 | + | switch v := v.(type) { | |
| 91 | + | case *expr.Dictionary: | |
| 92 | + | for _, p := range v.Pairs() { | |
| 93 | + | switch strings.ToLower(p.Key) { | |
| 94 | + | case "group": | |
| 95 | + | g, ok := p.Value.(expr.String) | |
| 96 | + | if !ok { | |
| 97 | + | return fail("Unexpected type '%s' encountered while reading 'job runs-on group name'. The type 'StringToken' was expected.", tokenKind(p.Value)) | |
| 98 | + | } | |
| 99 | + | if msg := checkGroup(string(g)); msg != "" { | |
| 100 | + | return fail("%s", msg) | |
| 101 | + | } | |
| 102 | + | group = string(g) | |
| 103 | + | case "labels": | |
| 104 | + | l, bad := labelList(p.Value) | |
| 105 | + | if bad != "" { | |
| 106 | + | return fail("%s", bad) | |
| 107 | + | } | |
| 108 | + | labels = append(labels, l...) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | return labels, group, nil | |
| 112 | + | } | |
| 113 | + | labels, bad := labelList(v) | |
| 114 | + | if bad != "" { | |
| 115 | + | return fail("%s", bad) | |
| 116 | + | } | |
| 117 | + | return labels, "", nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func tokenKind(v expr.Value) string { | |
| 121 | + | switch v.(type) { | |
| 122 | + | case expr.String: | |
| 123 | + | return template.KindString.String() | |
| 124 | + | case expr.Number: | |
| 125 | + | return template.KindNumber.String() | |
| 126 | + | case expr.Boolean: | |
| 127 | + | return template.KindBoolean.String() | |
| 128 | + | case *expr.Array: | |
| 129 | + | return template.KindSequence.String() | |
| 130 | + | case *expr.Dictionary: | |
| 131 | + | return template.KindMapping.String() | |
| 132 | + | } | |
| 133 | + | return template.KindNull.String() | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // labelList reads a string or a list of strings. It returns the problem as a | |
| 137 | + | // message. | |
| 138 | + | func labelList(v expr.Value) ([]string, string) { | |
| 139 | + | switch v := v.(type) { | |
| 140 | + | case expr.String: | |
| 141 | + | return []string{string(v)}, "" | |
| 142 | + | case *expr.Array: | |
| 143 | + | var out []string | |
| 144 | + | for _, it := range v.Items() { | |
| 145 | + | s, ok := it.(expr.String) | |
| 146 | + | if !ok { | |
| 147 | + | return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(it)) | |
| 148 | + | } | |
| 149 | + | out = append(out, string(s)) | |
| 150 | + | } | |
| 151 | + | return out, "" | |
| 152 | + | } | |
| 153 | + | return nil, fmt.Sprintf("Unexpected type '%s' encountered while reading 'job runs-on label sequence item'. The type 'StringToken' was expected.", tokenKind(v)) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // checkGroup returns the reference's message for an invalid runner group. | |
| 157 | + | func checkGroup(group string) string { | |
| 158 | + | names := strings.Split(group, "/") | |
| 159 | + | switch len(names) { | |
| 160 | + | case 1: | |
| 161 | + | return "" | |
| 162 | + | case 2: | |
| 163 | + | switch names[0] { | |
| 164 | + | case "org", "organization", "ent", "enterprise": | |
| 165 | + | default: | |
| 166 | + | return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |
| 167 | + | } | |
| 168 | + | if names[1] == "" { | |
| 169 | + | return fmt.Sprintf("Invalid runs-on group name '%s'.", group) | |
| 170 | + | } | |
| 171 | + | return "" | |
| 172 | + | } | |
| 173 | + | return fmt.Sprintf("Invalid runs-on group name '%s. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'", group) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // JobLevels groups the jobs by dependency depth: the first level holds the | |
| 177 | + | // jobs without needs, and every later job is in the level after the last of | |
| 178 | + | // its dependencies. Within a level the jobs keep the order of the file. A | |
| 179 | + | // job whose needs name an unknown job, or that is part of a cycle, is left | |
| 180 | + | // out; Parse rejects such workflows, so this only happens for a Workflow | |
| 181 | + | // built by hand. | |
| 182 | + | func (w *Workflow) JobLevels() [][]*Job { | |
| 183 | + | level := make(map[string]int, len(w.Jobs)) | |
| 184 | + | var levels [][]*Job | |
| 185 | + | placed := 0 | |
| 186 | + | for placed < len(w.Jobs) { | |
| 187 | + | progressed := false | |
| 188 | + | for _, j := range w.Jobs { | |
| 189 | + | if _, done := level[j.ID]; done { | |
| 190 | + | continue | |
| 191 | + | } | |
| 192 | + | depth, ready := 0, true | |
| 193 | + | for _, n := range j.Needs { | |
| 194 | + | l, ok := level[n] | |
| 195 | + | if !ok { | |
| 196 | + | ready = false | |
| 197 | + | break | |
| 198 | + | } | |
| 199 | + | depth = max(depth, l+1) | |
| 200 | + | } | |
| 201 | + | if !ready { | |
| 202 | + | continue | |
| 203 | + | } | |
| 204 | + | level[j.ID] = depth | |
| 205 | + | for len(levels) <= depth { | |
| 206 | + | levels = append(levels, nil) | |
| 207 | + | } | |
| 208 | + | levels[depth] = append(levels[depth], j) | |
| 209 | + | placed++ | |
| 210 | + | progressed = true | |
| 211 | + | } | |
| 212 | + | if !progressed { | |
| 213 | + | break | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | // A job placed in a later pass of the file may belong to an earlier level | |
| 217 | + | // than one placed before it; restore file order inside each level. | |
| 218 | + | pos := make(map[*Job]int, len(w.Jobs)) | |
| 219 | + | for i, j := range w.Jobs { | |
| 220 | + | pos[j] = i | |
| 221 | + | } | |
| 222 | + | for _, l := range levels { | |
| 223 | + | slices.SortFunc(l, func(a, b *Job) int { return pos[a] - pos[b] }) | |
| 224 | + | } | |
| 225 | + | return levels | |
| 226 | + | } | |
A workflow/job_eval_test.go +202 -0
| @@ -0,0 +1,202 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestEvaluateRunsOn(t *testing.T) { | |
| 11 | + | w := mustParse(t, `on: push | |
| 12 | + | jobs: | |
| 13 | + | s: | |
| 14 | + | runs-on: ubuntu-latest | |
| 15 | + | steps: [{run: a}] | |
| 16 | + | l: | |
| 17 | + | runs-on: [self-hosted, "${{ matrix.arch }}"] | |
| 18 | + | steps: [{run: a}] | |
| 19 | + | m: | |
| 20 | + | runs-on: | |
| 21 | + | group: org/big | |
| 22 | + | labels: ${{ fromJSON('["a","b"]') }} | |
| 23 | + | steps: [{run: a}] | |
| 24 | + | e: | |
| 25 | + | runs-on: ${{ matrix.os }} | |
| 26 | + | steps: [{run: a}] | |
| 27 | + | g: | |
| 28 | + | runs-on: | |
| 29 | + | group: ${{ matrix.g }} | |
| 30 | + | steps: [{run: a}] | |
| 31 | + | r: | |
| 32 | + | uses: ./x.yml | |
| 33 | + | `) | |
| 34 | + | env := envWith(t, map[string]string{"matrix": `{"arch": "arm64", "os": "linux", "g": "bogus/x", "n": 1}`}) | |
| 35 | + | tests := []struct { | |
| 36 | + | job, labels, group, err string | |
| 37 | + | }{ | |
| 38 | + | {"s", "ubuntu-latest", "", ""}, | |
| 39 | + | {"l", "self-hosted,arm64", "", ""}, | |
| 40 | + | {"m", "a,b", "org/big", ""}, | |
| 41 | + | {"e", "linux", "", ""}, | |
| 42 | + | {"g", "", "", "Invalid runs-on group name 'bogus/x. Please use"}, | |
| 43 | + | {"r", "", "", "has no runs-on"}, | |
| 44 | + | } | |
| 45 | + | for _, tc := range tests { | |
| 46 | + | labels, group, err := w.Job(tc.job).EvaluateRunsOn(ctxbg, env) | |
| 47 | + | if tc.err != "" { | |
| 48 | + | if err == nil || !strings.Contains(err.Error(), tc.err) { | |
| 49 | + | t.Errorf("%s: err = %v, want %q", tc.job, err, tc.err) | |
| 50 | + | } | |
| 51 | + | continue | |
| 52 | + | } | |
| 53 | + | if err != nil || strings.Join(labels, ",") != tc.labels || group != tc.group { | |
| 54 | + | t.Errorf("%s: %q, %q, %v", tc.job, labels, group, err) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | // Non-string results. | |
| 58 | + | bad := mustParse(t, "on: push\njobs:\n a:\n runs-on: ${{ matrix.n }}\n steps: [{run: a}]\n b:\n runs-on: [\"${{ matrix.n }}\"]\n steps: [{run: a}]\n") | |
| 59 | + | for _, id := range []string{"a", "b"} { | |
| 60 | + | _, _, err := bad.Job(id).EvaluateRunsOn(ctxbg, env) | |
| 61 | + | if err == nil || !strings.Contains(err.Error(), "Unexpected type") { | |
| 62 | + | t.Errorf("%s: err = %v", id, err) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestEvaluateIf(t *testing.T) { | |
| 68 | + | w := mustParse(t, `on: push | |
| 69 | + | jobs: | |
| 70 | + | a: | |
| 71 | + | runs-on: x | |
| 72 | + | steps: [{run: a}] | |
| 73 | + | b: | |
| 74 | + | needs: a | |
| 75 | + | if: needs.a.result == 'failure' | |
| 76 | + | runs-on: x | |
| 77 | + | steps: | |
| 78 | + | - run: a | |
| 79 | + | if: failure() | |
| 80 | + | - run: b | |
| 81 | + | - run: c | |
| 82 | + | if: always() | |
| 83 | + | c: | |
| 84 | + | needs: a | |
| 85 | + | if: ${{ always() && !cancelled() }} | |
| 86 | + | runs-on: x | |
| 87 | + | steps: [{run: a}] | |
| 88 | + | `) | |
| 89 | + | env := envWith(t, map[string]string{"needs": `{"a": {"result": "failure"}}`}) | |
| 90 | + | b := w.Job("b") | |
| 91 | + | tests := []struct { | |
| 92 | + | name string | |
| 93 | + | got func() (bool, error) | |
| 94 | + | want bool | |
| 95 | + | }{ | |
| 96 | + | {"job, success", func() (bool, error) { return b.EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, true}, | |
| 97 | + | {"job, after failure", func() (bool, error) { return b.EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, false}, | |
| 98 | + | {"step failure()", func() (bool, error) { return b.Steps[0].EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, true}, | |
| 99 | + | {"step failure() when ok", func() (bool, error) { return b.Steps[0].EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, false}, | |
| 100 | + | {"step default", func() (bool, error) { return b.Steps[1].EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, true}, | |
| 101 | + | {"step default after failure", func() (bool, error) { return b.Steps[1].EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, false}, | |
| 102 | + | {"always", func() (bool, error) { return b.Steps[2].EvaluateIf(ctxbg, env, StatusFuncs{Cancelled: true}) }, true}, | |
| 103 | + | {"always and not cancelled", func() (bool, error) { return w.Job("c").EvaluateIf(ctxbg, env, StatusFuncs{Cancelled: true}) }, false}, | |
| 104 | + | {"always and not cancelled, ok", func() (bool, error) { return w.Job("c").EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, true}, | |
| 105 | + | } | |
| 106 | + | for _, tc := range tests { | |
| 107 | + | got, err := tc.got() | |
| 108 | + | if err != nil || got != tc.want { | |
| 109 | + | t.Errorf("%s = %v, %v; want %v", tc.name, got, err, tc.want) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | // The caller's functions (hashFiles) are kept, and env is not modified. | |
| 113 | + | env.Funcs = nil | |
| 114 | + | if _, err := b.EvaluateIf(ctxbg, env, StatusFuncs{}); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | if env.Funcs != nil { | |
| 118 | + | t.Error("env.Funcs was modified") | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestJobLevels(t *testing.T) { | |
| 123 | + | w := mustParse(t, `on: push | |
| 124 | + | jobs: | |
| 125 | + | deploy: | |
| 126 | + | needs: [test, lint] | |
| 127 | + | runs-on: x | |
| 128 | + | steps: [{run: a}] | |
| 129 | + | test: | |
| 130 | + | needs: build | |
| 131 | + | runs-on: x | |
| 132 | + | steps: [{run: a}] | |
| 133 | + | lint: | |
| 134 | + | runs-on: x | |
| 135 | + | steps: [{run: a}] | |
| 136 | + | build: | |
| 137 | + | runs-on: x | |
| 138 | + | steps: [{run: a}] | |
| 139 | + | docs: | |
| 140 | + | needs: [build] | |
| 141 | + | runs-on: x | |
| 142 | + | steps: [{run: a}] | |
| 143 | + | notify: | |
| 144 | + | needs: [deploy, docs] | |
| 145 | + | runs-on: x | |
| 146 | + | steps: [{run: a}] | |
| 147 | + | `) | |
| 148 | + | var got []string | |
| 149 | + | for _, l := range w.JobLevels() { | |
| 150 | + | var ids []string | |
| 151 | + | for _, j := range l { | |
| 152 | + | ids = append(ids, j.ID) | |
| 153 | + | } | |
| 154 | + | got = append(got, strings.Join(ids, ",")) | |
| 155 | + | } | |
| 156 | + | if want := "lint,build|test,docs|deploy|notify"; strings.Join(got, "|") != want { | |
| 157 | + | t.Errorf("levels = %v, want %s", got, want) | |
| 158 | + | } | |
| 159 | + | // Stable across calls; a hand-built cycle is left out, not looped on. | |
| 160 | + | cyc := &Workflow{Jobs: []*Job{{ID: "a", Needs: []string{"b"}}, {ID: "b", Needs: []string{"a"}}, {ID: "c"}, {ID: "d", Needs: []string{"zz"}}}} | |
| 161 | + | l := cyc.JobLevels() | |
| 162 | + | if len(l) != 1 || len(l[0]) != 1 || l[0][0].ID != "c" { | |
| 163 | + | t.Errorf("cycle levels = %v", l) | |
| 164 | + | } | |
| 165 | + | if len((&Workflow{}).JobLevels()) != 0 { | |
| 166 | + | t.Error("empty workflow has levels") | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestDisplayNameAstWalk(t *testing.T) { | |
| 171 | + | // The word "matrix." in a string literal or another context is not a use | |
| 172 | + | // of the matrix context. | |
| 173 | + | w := mustParse(t, `on: push | |
| 174 | + | jobs: | |
| 175 | + | a: | |
| 176 | + | name: "${{ github.event.matrix }} and matrix. text" | |
| 177 | + | runs-on: x | |
| 178 | + | strategy: | |
| 179 | + | matrix: | |
| 180 | + | os: [l] | |
| 181 | + | steps: [{run: a}] | |
| 182 | + | b: | |
| 183 | + | name: ${{ format('{0}', matrix.os) }} | |
| 184 | + | runs-on: x | |
| 185 | + | strategy: | |
| 186 | + | matrix: | |
| 187 | + | os: [l] | |
| 188 | + | steps: [{run: a}] | |
| 189 | + | `) | |
| 190 | + | env := envWith(t, map[string]string{"github": `{"event": {"matrix": "m"}}`}) | |
| 191 | + | for id, want := range map[string]string{"a": "m and matrix. text (l)", "b": "l"} { | |
| 192 | + | j := w.Job(id) | |
| 193 | + | s, err := j.EvaluateStrategy(ctxbg, env) | |
| 194 | + | if err != nil { | |
| 195 | + | t.Fatal(err) | |
| 196 | + | } | |
| 197 | + | got, err := j.DisplayName(ctxbg, mustMatrixEnv(t, s, env, 0), s, 0) | |
| 198 | + | if err != nil || got != want { | |
| 199 | + | t.Errorf("%s: name = %q, %v; want %q", id, got, err, want) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
A workflow/matrix.go +379 -0
| @@ -0,0 +1,379 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite-actions/expr" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Limits of matrix expansion. GitHub only limits the number of jobs; the | |
| 15 | + | // other limits keep the work done for a hostile matrix bounded. | |
| 16 | + | const ( | |
| 17 | + | // MaxMatrixJobs is the number of jobs a matrix may produce. | |
| 18 | + | MaxMatrixJobs = 256 | |
| 19 | + | // MaxMatrixProduct is the size of the cross product of the vectors, before | |
| 20 | + | // exclusions, above which a matrix is refused. | |
| 21 | + | MaxMatrixProduct = 1 << 16 | |
| 22 | + | // MaxMatrixEntries is the number of entries of include and, separately, | |
| 23 | + | // of exclude. | |
| 24 | + | MaxMatrixEntries = 256 | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // checkEvery is the number of combinations between two checks of the context. | |
| 28 | + | const checkEvery = 1024 | |
| 29 | + | ||
| 30 | + | // MatrixCombo is one job of a matrix. | |
| 31 | + | type MatrixCombo struct { | |
| 32 | + | // Values is the "matrix" context of the job: the vector values in the | |
| 33 | + | // order of the keys, then the keys added by include entries in the order | |
| 34 | + | // they were first added. | |
| 35 | + | Values *expr.Dictionary | |
| 36 | + | // Segments are the scalar values of Values, in order, shown in the name | |
| 37 | + | // of the job: "build (ubuntu-latest, 14)". Arrays and mappings are left | |
| 38 | + | // out; null shows as an empty segment. | |
| 39 | + | Segments []string | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // DisplayName returns the name of the job: base alone when there are no | |
| 43 | + | // segments, else base followed by the segments in parentheses. | |
| 44 | + | func (c MatrixCombo) DisplayName(base string) string { | |
| 45 | + | if len(c.Segments) == 0 { | |
| 46 | + | return base | |
| 47 | + | } | |
| 48 | + | return base + " (" + strings.Join(c.Segments, ", ") + ")" | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // ExpandMatrix expands the matrix of an evaluated strategy (a dictionary with | |
| 52 | + | // the optional keys "matrix", "fail-fast" and "max-parallel", or null) into | |
| 53 | + | // the job combinations, like GitHub: | |
| 54 | + | // | |
| 55 | + | // - the combinations are the cross product of the vectors, the first key | |
| 56 | + | // varying slowest; | |
| 57 | + | // - exclude removes every combination that contains all the key-value | |
| 58 | + | // pairs of one of its entries; every key of an entry must be a vector of | |
| 59 | + | // the matrix, or the matrix is an error; | |
| 60 | + | // - then each include entry is added to every remaining combination whose | |
| 61 | + | // values for the vector keys it mentions are equal (an entry that | |
| 62 | + | // mentions no vector key matches all), without overwriting the original | |
| 63 | + | // values; an entry that matches no combination becomes a combination of | |
| 64 | + | // its own; | |
| 65 | + | // - more than 256 jobs is an error. A strategy without a matrix gives a | |
| 66 | + | // single combination. | |
| 67 | + | // | |
| 68 | + | // The work is bounded: the product of the vectors may not exceed | |
| 69 | + | // [MaxMatrixProduct], include and exclude may have [MaxMatrixEntries] entries | |
| 70 | + | // each, and ctx is checked every few thousand combinations. | |
| 71 | + | // | |
| 72 | + | // TODO(verify with recording): the documented semantics of include and | |
| 73 | + | // exclude are implemented as documented, but the following are assumed: the | |
| 74 | + | // order of the cross product (first key slowest), the display name listing | |
| 75 | + | // every scalar value including those added by include (see | |
| 76 | + | // [MatrixCombo.DisplayName]) and showing null as an empty segment, the | |
| 77 | + | // comparison of values (same kind, strings compared exactly, mappings by | |
| 78 | + | // containment), an include key matching a vector key case-insensitively, the | |
| 79 | + | // error when every combination is excluded, the limits above, and the | |
| 80 | + | // wording of all errors except the unknown exclude key, which is GitHub's | |
| 81 | + | // (also to verify). | |
| 82 | + | func ExpandMatrix(ctx context.Context, strategy expr.Value) ([]MatrixCombo, error) { | |
| 83 | + | s, err := ParseStrategy(ctx, strategy) | |
| 84 | + | if err != nil { | |
| 85 | + | return nil, err | |
| 86 | + | } | |
| 87 | + | return s.Combos, nil | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func buildMatrix(ctx context.Context, m expr.Value) ([]MatrixCombo, bool, error) { | |
| 91 | + | switch m.(type) { | |
| 92 | + | case nil, expr.Null: | |
| 93 | + | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | |
| 94 | + | } | |
| 95 | + | md, ok := m.(*expr.Dictionary) | |
| 96 | + | if !ok { | |
| 97 | + | return nil, false, errors.New("the matrix must be a mapping") | |
| 98 | + | } | |
| 99 | + | if md == nil { | |
| 100 | + | return []MatrixCombo{{Values: expr.NewDictionary()}}, false, nil | |
| 101 | + | } | |
| 102 | + | var vecs []vector | |
| 103 | + | var includes, excludes []*expr.Dictionary | |
| 104 | + | for _, p := range md.Pairs() { | |
| 105 | + | switch p.Key { | |
| 106 | + | case "include": | |
| 107 | + | list, err := matrixEntries("include", p.Value) | |
| 108 | + | if err != nil { | |
| 109 | + | return nil, false, err | |
| 110 | + | } | |
| 111 | + | includes = list | |
| 112 | + | case "exclude": | |
| 113 | + | list, err := matrixEntries("exclude", p.Value) | |
| 114 | + | if err != nil { | |
| 115 | + | return nil, false, err | |
| 116 | + | } | |
| 117 | + | excludes = list | |
| 118 | + | default: | |
| 119 | + | arr, ok := p.Value.(*expr.Array) | |
| 120 | + | if !ok || arr == nil || arr.Len() == 0 { | |
| 121 | + | return nil, false, fmt.Errorf("matrix vector '%s' does not contain any values", p.Key) | |
| 122 | + | } | |
| 123 | + | if arr.Len() > MaxMatrixProduct { | |
| 124 | + | return nil, false, errTooBig | |
| 125 | + | } | |
| 126 | + | vecs = append(vecs, vector{key: p.Key, items: arr.Items()}) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | return expand(ctx, vecs, includes, excludes) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func matrixEntries(name string, v expr.Value) ([]*expr.Dictionary, error) { | |
| 133 | + | arr, ok := v.(*expr.Array) | |
| 134 | + | if !ok || arr == nil { | |
| 135 | + | return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name) | |
| 136 | + | } | |
| 137 | + | if arr.Len() > MaxMatrixEntries { | |
| 138 | + | return nil, fmt.Errorf("matrix %s has more than %d entries", name, MaxMatrixEntries) | |
| 139 | + | } | |
| 140 | + | var out []*expr.Dictionary | |
| 141 | + | for _, it := range arr.Items() { | |
| 142 | + | d, ok := it.(*expr.Dictionary) | |
| 143 | + | if !ok || d == nil { | |
| 144 | + | return nil, fmt.Errorf("matrix %s must be a sequence of mappings", name) | |
| 145 | + | } | |
| 146 | + | out = append(out, d) | |
| 147 | + | } | |
| 148 | + | return out, nil | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // message is an error whose text is GitHub's, which starts with a capital. | |
| 152 | + | type message string | |
| 153 | + | ||
| 154 | + | func (m message) Error() string { return string(m) } | |
| 155 | + | ||
| 156 | + | var errTooBig = fmt.Errorf("matrix size limit of %d exceeded", MaxMatrixJobs) | |
| 157 | + | ||
| 158 | + | type vector struct { | |
| 159 | + | key string | |
| 160 | + | items []expr.Value | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // condition says which items of vector key may appear in a combination that | |
| 164 | + | // an exclude or include entry applies to. | |
| 165 | + | type condition struct { | |
| 166 | + | vec int | |
| 167 | + | ok []bool // by index of the item in the vector | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | type includeEntry struct { | |
| 171 | + | conds []condition | |
| 172 | + | extra []expr.Pair // pairs whose key is not a vector | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func compileCondition(vecs []vector, vi int, want expr.Value) condition { | |
| 176 | + | c := condition{vec: vi, ok: make([]bool, len(vecs[vi].items))} | |
| 177 | + | for i, it := range vecs[vi].items { | |
| 178 | + | c.ok[i] = valuesMatch(it, want) | |
| 179 | + | } | |
| 180 | + | return c | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func expand(ctx context.Context, vecs []vector, includes, excludes []*expr.Dictionary) ([]MatrixCombo, bool, error) { | |
| 184 | + | index := make(map[string]int, len(vecs)) | |
| 185 | + | for i, v := range vecs { | |
| 186 | + | index[strings.ToLower(v.key)] = i | |
| 187 | + | } | |
| 188 | + | // Exclude entries become lists of conditions on item positions, so that | |
| 189 | + | // testing a combination costs a few array reads. | |
| 190 | + | var excl [][]condition | |
| 191 | + | for _, ex := range excludes { | |
| 192 | + | var conds []condition | |
| 193 | + | for _, p := range ex.Pairs() { | |
| 194 | + | vi, ok := index[strings.ToLower(p.Key)] | |
| 195 | + | if !ok { | |
| 196 | + | return nil, false, message(fmt.Sprintf("Matrix exclude key '%s' does not match any key within the matrix", p.Key)) | |
| 197 | + | } | |
| 198 | + | conds = append(conds, compileCondition(vecs, vi, p.Value)) | |
| 199 | + | } | |
| 200 | + | if len(conds) > 0 { | |
| 201 | + | excl = append(excl, conds) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | incl := make([]includeEntry, len(includes)) | |
| 205 | + | for n, inc := range includes { | |
| 206 | + | for _, p := range inc.Pairs() { | |
| 207 | + | if vi, ok := index[strings.ToLower(p.Key)]; ok { | |
| 208 | + | incl[n].conds = append(incl[n].conds, compileCondition(vecs, vi, p.Value)) | |
| 209 | + | } else { | |
| 210 | + | incl[n].extra = append(incl[n].extra, p) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | var combos []MatrixCombo | |
| 216 | + | matched := make([]bool, len(includes)) | |
| 217 | + | if len(vecs) > 0 { | |
| 218 | + | product := 1 | |
| 219 | + | for _, v := range vecs { | |
| 220 | + | product *= len(v.items) | |
| 221 | + | if product > MaxMatrixProduct { | |
| 222 | + | return nil, false, errTooBig | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | tuple := make([]int, len(vecs)) | |
| 226 | + | next: | |
| 227 | + | for i := 0; i < product; i++ { | |
| 228 | + | if i%checkEvery == 0 { | |
| 229 | + | if err := ctx.Err(); err != nil { | |
| 230 | + | return nil, false, err | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | if i > 0 { | |
| 234 | + | // Advance the last position first: the first key varies slowest. | |
| 235 | + | for k := len(tuple) - 1; k >= 0; k-- { | |
| 236 | + | tuple[k]++ | |
| 237 | + | if tuple[k] < len(vecs[k].items) { | |
| 238 | + | break | |
| 239 | + | } | |
| 240 | + | tuple[k] = 0 | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | for _, conds := range excl { | |
| 244 | + | if allHold(conds, tuple) { | |
| 245 | + | continue next | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | b := &comboBuilder{vals: map[string]expr.Value{}} | |
| 249 | + | for k, v := range vecs { | |
| 250 | + | b.set(v.key, v.items[tuple[k]]) | |
| 251 | + | } | |
| 252 | + | for n, inc := range incl { | |
| 253 | + | if !allHold(inc.conds, tuple) { | |
| 254 | + | continue | |
| 255 | + | } | |
| 256 | + | matched[n] = true | |
| 257 | + | for _, p := range inc.extra { | |
| 258 | + | b.set(p.Key, p.Value) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | combos = append(combos, b.combo()) | |
| 262 | + | if len(combos) > MaxMatrixJobs { | |
| 263 | + | return nil, false, errTooBig | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | for n, inc := range includes { | |
| 268 | + | if matched[n] { | |
| 269 | + | continue | |
| 270 | + | } | |
| 271 | + | b := &comboBuilder{vals: map[string]expr.Value{}} | |
| 272 | + | for _, p := range inc.Pairs() { | |
| 273 | + | b.set(p.Key, p.Value) | |
| 274 | + | } | |
| 275 | + | combos = append(combos, b.combo()) | |
| 276 | + | if len(combos) > MaxMatrixJobs { | |
| 277 | + | return nil, false, errTooBig | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | if len(combos) == 0 { | |
| 281 | + | if len(vecs) == 0 && len(includes) == 0 && len(excludes) == 0 { | |
| 282 | + | // An empty matrix runs the job once. | |
| 283 | + | return []MatrixCombo{{Values: expr.NewDictionary()}}, true, nil | |
| 284 | + | } | |
| 285 | + | // TODO(verify with recording): GitHub's behaviour when every | |
| 286 | + | // combination is excluded. | |
| 287 | + | return nil, false, errors.New("the matrix has no combinations: all of them are excluded") | |
| 288 | + | } | |
| 289 | + | return combos, true, nil | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func allHold(conds []condition, tuple []int) bool { | |
| 293 | + | for _, c := range conds { | |
| 294 | + | if !c.ok[tuple[c.vec]] { | |
| 295 | + | return false | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | return true | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // comboBuilder holds the values of one combination while includes are | |
| 302 | + | // applied; added values may be overwritten by later includes, original ones | |
| 303 | + | // never are (an include's vector keys are conditions, not assignments). | |
| 304 | + | type comboBuilder struct { | |
| 305 | + | keys []string | |
| 306 | + | vals map[string]expr.Value // by lower-cased key | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (b *comboBuilder) set(key string, v expr.Value) { | |
| 310 | + | lk := strings.ToLower(key) | |
| 311 | + | if _, ok := b.vals[lk]; !ok { | |
| 312 | + | b.keys = append(b.keys, key) | |
| 313 | + | } | |
| 314 | + | b.vals[lk] = v | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func (b *comboBuilder) combo() MatrixCombo { | |
| 318 | + | d := expr.NewDictionary() | |
| 319 | + | var segs []string | |
| 320 | + | for _, k := range b.keys { | |
| 321 | + | v := b.vals[strings.ToLower(k)] | |
| 322 | + | d.Add(k, v) | |
| 323 | + | if s, ok := segment(v); ok { | |
| 324 | + | segs = append(segs, s) | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | return MatrixCombo{Values: d, Segments: segs} | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // valuesMatch compares a matrix value with the value in an include or | |
| 331 | + | // exclude entry: scalars of the same kind and value are equal, arrays must be | |
| 332 | + | // equal element by element, and a mapping in the entry must be contained in | |
| 333 | + | // the matrix value. | |
| 334 | + | func valuesMatch(have, want expr.Value) bool { | |
| 335 | + | if kindName(have) != kindName(want) { | |
| 336 | + | return false | |
| 337 | + | } | |
| 338 | + | switch w := want.(type) { | |
| 339 | + | case expr.Null: | |
| 340 | + | return true | |
| 341 | + | case expr.Boolean: | |
| 342 | + | return have.(expr.Boolean) == w | |
| 343 | + | case expr.Number: | |
| 344 | + | return have.(expr.Number) == w | |
| 345 | + | case expr.String: | |
| 346 | + | return have.(expr.String) == w | |
| 347 | + | case *expr.Array: | |
| 348 | + | h := have.(*expr.Array) | |
| 349 | + | if h.Len() != w.Len() { | |
| 350 | + | return false | |
| 351 | + | } | |
| 352 | + | for i := 0; i < w.Len(); i++ { | |
| 353 | + | if !valuesMatch(h.At(i), w.At(i)) { | |
| 354 | + | return false | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | return true | |
| 358 | + | case *expr.Dictionary: | |
| 359 | + | h := have.(*expr.Dictionary) | |
| 360 | + | for _, p := range w.Pairs() { | |
| 361 | + | hv, ok := h.Get(p.Key) | |
| 362 | + | if !ok || !valuesMatch(hv, p.Value) { | |
| 363 | + | return false | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | return true | |
| 367 | + | } | |
| 368 | + | return false | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // segment is the text a matrix value adds to the name of a job; arrays and | |
| 372 | + | // mappings add nothing. | |
| 373 | + | func segment(v expr.Value) (string, bool) { | |
| 374 | + | switch v.(type) { | |
| 375 | + | case *expr.Array, *expr.Dictionary: | |
| 376 | + | return "", false | |
| 377 | + | } | |
| 378 | + | return expr.CoerceString(v), true | |
| 379 | + | } | |
A workflow/matrix_test.go +374 -0
| @@ -0,0 +1,374 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "reflect" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite-actions/expr" | |
| 15 | + | "bnema.dev/ermite-actions/template" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | var ctxbg = context.Background() | |
| 19 | + | ||
| 20 | + | func jsonValue(t testing.TB, s string) expr.Value { | |
| 21 | + | t.Helper() | |
| 22 | + | v, err := expr.FromJSON([]byte(s)) | |
| 23 | + | if err != nil { | |
| 24 | + | t.Fatalf("FromJSON(%s): %v", s, err) | |
| 25 | + | } | |
| 26 | + | return v | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func compact(s string) string { | |
| 30 | + | return strings.NewReplacer("\n", "", " ", "", `": `, `":`).Replace(s) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // summarize renders combos as "k=v,k=v" lines with the display segments. | |
| 34 | + | func summarize(combos []MatrixCombo) []string { | |
| 35 | + | var out []string | |
| 36 | + | for _, c := range combos { | |
| 37 | + | var kv []string | |
| 38 | + | for _, p := range c.Values.Pairs() { | |
| 39 | + | kv = append(kv, p.Key+"="+compact(expr.ToJSON(p.Value))) | |
| 40 | + | } | |
| 41 | + | out = append(out, strings.Join(kv, ",")+" | "+c.DisplayName("job")) | |
| 42 | + | } | |
| 43 | + | return out | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestExpandMatrix(t *testing.T) { | |
| 47 | + | tests := []struct { | |
| 48 | + | name string | |
| 49 | + | strategy string | |
| 50 | + | want []string | |
| 51 | + | }{ | |
| 52 | + | {"no strategy", `null`, []string{" | job"}}, | |
| 53 | + | {"strategy without matrix", `{"fail-fast": false}`, []string{" | job"}}, | |
| 54 | + | {"empty matrix", `{"matrix": {}}`, []string{" | job"}}, | |
| 55 | + | { | |
| 56 | + | "one vector", | |
| 57 | + | `{"matrix": {"os": ["ubuntu", "macos"]}}`, | |
| 58 | + | []string{`os="ubuntu" | job (ubuntu)`, `os="macos" | job (macos)`}, | |
| 59 | + | }, | |
| 60 | + | { | |
| 61 | + | "cross product, first key slowest", | |
| 62 | + | `{"matrix": {"os": ["a", "b"], "node": [14, 16, 18]}}`, | |
| 63 | + | []string{ | |
| 64 | + | `os="a",node=14 | job (a, 14)`, `os="a",node=16 | job (a, 16)`, `os="a",node=18 | job (a, 18)`, | |
| 65 | + | `os="b",node=14 | job (b, 14)`, `os="b",node=16 | job (b, 16)`, `os="b",node=18 | job (b, 18)`, | |
| 66 | + | }, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | // GitHub docs, "Expanding or adding matrix configurations". | |
| 70 | + | "docs include example", | |
| 71 | + | `{"matrix": {"fruit": ["apple", "pear"], "animal": ["cat", "dog"], "include": [ | |
| 72 | + | {"color": "green"}, | |
| 73 | + | {"color": "pink", "animal": "cat"}, | |
| 74 | + | {"fruit": "apple", "shape": "circle"}, | |
| 75 | + | {"fruit": "banana"}, | |
| 76 | + | {"fruit": "banana", "animal": "cat"} | |
| 77 | + | ]}}`, | |
| 78 | + | []string{ | |
| 79 | + | `fruit="apple",animal="cat",color="pink",shape="circle" | job (apple, cat, pink, circle)`, | |
| 80 | + | `fruit="apple",animal="dog",color="green",shape="circle" | job (apple, dog, green, circle)`, | |
| 81 | + | `fruit="pear",animal="cat",color="pink" | job (pear, cat, pink)`, | |
| 82 | + | `fruit="pear",animal="dog",color="green" | job (pear, dog, green)`, | |
| 83 | + | `fruit="banana" | job (banana)`, | |
| 84 | + | `fruit="banana",animal="cat" | job (banana, cat)`, | |
| 85 | + | }, | |
| 86 | + | }, | |
| 87 | + | { | |
| 88 | + | // GitHub docs, "Excluding matrix configurations". | |
| 89 | + | "docs exclude example", | |
| 90 | + | `{"matrix": {"os": ["macos-latest", "windows-latest"], "version": [12, 14, 16], "environment": ["staging", "production"], | |
| 91 | + | "exclude": [{"os": "macos-latest", "version": 12, "environment": "production"}, {"os": "windows-latest", "version": 16}]}}`, | |
| 92 | + | []string{ | |
| 93 | + | `os="macos-latest",version=12,environment="staging" | job (macos-latest, 12, staging)`, | |
| 94 | + | `os="macos-latest",version=14,environment="staging" | job (macos-latest, 14, staging)`, | |
| 95 | + | `os="macos-latest",version=14,environment="production" | job (macos-latest, 14, production)`, | |
| 96 | + | `os="macos-latest",version=16,environment="staging" | job (macos-latest, 16, staging)`, | |
| 97 | + | `os="macos-latest",version=16,environment="production" | job (macos-latest, 16, production)`, | |
| 98 | + | `os="windows-latest",version=12,environment="staging" | job (windows-latest, 12, staging)`, | |
| 99 | + | `os="windows-latest",version=12,environment="production" | job (windows-latest, 12, production)`, | |
| 100 | + | `os="windows-latest",version=14,environment="staging" | job (windows-latest, 14, staging)`, | |
| 101 | + | `os="windows-latest",version=14,environment="production" | job (windows-latest, 14, production)`, | |
| 102 | + | }, | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | // GitHub docs, "Using a matrix with only include": the include | |
| 106 | + | // entries do not overwrite a vector value. | |
| 107 | + | "include does not overwrite original values", | |
| 108 | + | `{"matrix": {"os": ["a", "b"], "include": [{"os": "c"}, {"os": "a", "node": 1}]}}`, | |
| 109 | + | []string{ | |
| 110 | + | `os="a",node=1 | job (a, 1)`, | |
| 111 | + | `os="b" | job (b)`, | |
| 112 | + | `os="c" | job (c)`, | |
| 113 | + | }, | |
| 114 | + | }, | |
| 115 | + | { | |
| 116 | + | "include only", | |
| 117 | + | `{"matrix": {"include": [{"os": "a", "n": 1}, {"os": "b", "n": 2}]}}`, | |
| 118 | + | []string{`os="a",n=1 | job (a, 1)`, `os="b",n=2 | job (b, 2)`}, | |
| 119 | + | }, | |
| 120 | + | { | |
| 121 | + | "added value overwritten by a later include", | |
| 122 | + | `{"matrix": {"os": ["a"], "include": [{"color": "red"}, {"color": "blue", "os": "a"}]}}`, | |
| 123 | + | []string{`os="a",color="blue" | job (a, blue)`}, | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | "exclude then include re-adds", | |
| 127 | + | `{"matrix": {"os": ["a", "b"], "exclude": [{"os": "a"}], "include": [{"os": "a", "x": 1}]}}`, | |
| 128 | + | []string{`os="b" | job (b)`, `os="a",x=1 | job (a, 1)`}, | |
| 129 | + | }, | |
| 130 | + | { | |
| 131 | + | "exclude partial match", | |
| 132 | + | `{"matrix": {"a": [1, 2], "b": [1, 2], "exclude": [{"a": 1}]}}`, | |
| 133 | + | []string{`a=2,b=1 | job (2, 1)`, `a=2,b=2 | job (2, 2)`}, | |
| 134 | + | }, | |
| 135 | + | { | |
| 136 | + | "exclude no match", | |
| 137 | + | `{"matrix": {"a": [1], "exclude": [{"a": 2}]}}`, | |
| 138 | + | []string{`a=1 | job (1)`}, | |
| 139 | + | }, | |
| 140 | + | { | |
| 141 | + | "booleans, null and numbers in names", | |
| 142 | + | `{"matrix": {"a": [true, null, 1.5]}}`, | |
| 143 | + | []string{`a=true | job (true)`, `a=null | job ()`, `a=1.5 | job (1.5)`}, | |
| 144 | + | }, | |
| 145 | + | { | |
| 146 | + | "objects and arrays are not in names", | |
| 147 | + | `{"matrix": {"v": [{"k": 1}, [1]], "w": ["x"]}}`, | |
| 148 | + | []string{`v={"k":1},w="x" | job (x)`, `v=[1],w="x" | job (x)`}, | |
| 149 | + | }, | |
| 150 | + | { | |
| 151 | + | "exclude by object containment", | |
| 152 | + | `{"matrix": {"v": [{"k": 1, "m": 2}, {"k": 2}], "exclude": [{"v": {"k": 1}}]}}`, | |
| 153 | + | []string{`v={"k":2} | job`}, | |
| 154 | + | }, | |
| 155 | + | { | |
| 156 | + | "keys are case-insensitive for include matching", | |
| 157 | + | `{"matrix": {"OS": ["a"], "include": [{"os": "a", "x": 1}]}}`, | |
| 158 | + | []string{`OS="a",x=1 | job (a, 1)`}, | |
| 159 | + | }, | |
| 160 | + | } | |
| 161 | + | for _, tc := range tests { | |
| 162 | + | t.Run(tc.name, func(t *testing.T) { | |
| 163 | + | combos, err := ExpandMatrix(ctxbg, jsonValue(t, tc.strategy)) | |
| 164 | + | if err != nil { | |
| 165 | + | t.Fatal(err) | |
| 166 | + | } | |
| 167 | + | if got := summarize(combos); !reflect.DeepEqual(got, tc.want) { | |
| 168 | + | t.Errorf("got:\n %s\nwant:\n %s", strings.Join(got, "\n "), strings.Join(tc.want, "\n ")) | |
| 169 | + | } | |
| 170 | + | }) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestExpandMatrixErrors(t *testing.T) { | |
| 175 | + | tests := []struct{ name, strategy, want string }{ | |
| 176 | + | {"strategy not a mapping", `[1]`, "the strategy must be a mapping"}, | |
| 177 | + | {"matrix not a mapping", `{"matrix": [1]}`, "the matrix must be a mapping"}, | |
| 178 | + | {"vector empty", `{"matrix": {"a": []}}`, "matrix vector 'a' does not contain any values"}, | |
| 179 | + | {"vector scalar", `{"matrix": {"a": 1}}`, "matrix vector 'a' does not contain any values"}, | |
| 180 | + | {"include not a list", `{"matrix": {"include": 1}}`, "matrix include must be a sequence of mappings"}, | |
| 181 | + | {"include item", `{"matrix": {"include": [1]}}`, "matrix include must be a sequence of mappings"}, | |
| 182 | + | {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "matrix exclude must be a sequence of mappings"}, | |
| 183 | + | {"exclude unknown key", `{"matrix": {"a": [1], "exclude": [{"zzz": 1}]}}`, "Matrix exclude key 'zzz' does not match any key within the matrix"}, | |
| 184 | + | {"exclude include-only key", `{"matrix": {"a": [1], "include": [{"b": 1}], "exclude": [{"b": 1}]}}`, "Matrix exclude key 'b' does not match any key within the matrix"}, | |
| 185 | + | {"exclude without vectors", `{"matrix": {"exclude": [{"a": 1}]}}`, "Matrix exclude key 'a' does not match any key within the matrix"}, | |
| 186 | + | {"too many includes", `{"matrix": {"include": ` + entries(257) + `}}`, "more than 256 entries"}, | |
| 187 | + | {"too many excludes", `{"matrix": {"a": [1], "exclude": ` + entries(257) + `}}`, "more than 256 entries"}, | |
| 188 | + | {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"}, | |
| 189 | + | {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"}, | |
| 190 | + | {"max-parallel", `{"max-parallel": 0}`, "max-parallel"}, | |
| 191 | + | {"max-parallel fraction", `{"max-parallel": 1.5}`, "max-parallel"}, | |
| 192 | + | } | |
| 193 | + | for _, tc := range tests { | |
| 194 | + | t.Run(tc.name, func(t *testing.T) { | |
| 195 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, tc.strategy)) | |
| 196 | + | if err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 197 | + | t.Errorf("err = %v, want it to contain %q", err, tc.want) | |
| 198 | + | } | |
| 199 | + | }) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestMatrixLimit(t *testing.T) { | |
| 204 | + | vec := func(n int) string { | |
| 205 | + | var items []string | |
| 206 | + | for i := 0; i < n; i++ { | |
| 207 | + | items = append(items, fmt.Sprint(i)) | |
| 208 | + | } | |
| 209 | + | return "[" + strings.Join(items, ",") + "]" | |
| 210 | + | } | |
| 211 | + | if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`}}`)); err != nil || len(c) != 256 { | |
| 212 | + | t.Errorf("256 jobs: %d, %v", len(c), err) | |
| 213 | + | } | |
| 214 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`)) | |
| 215 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit of 256 exceeded") { | |
| 216 | + | t.Errorf("257 jobs: %v", err) | |
| 217 | + | } | |
| 218 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`)) | |
| 219 | + | if err == nil { | |
| 220 | + | t.Error("272 jobs: no error") | |
| 221 | + | } | |
| 222 | + | // Excluding brings it back under the limit. | |
| 223 | + | if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`, "exclude": [{"a": 16}]}}`)); err != nil || len(c) != 256 { | |
| 224 | + | t.Errorf("272 minus 16: %d, %v", len(c), err) | |
| 225 | + | } | |
| 226 | + | // A huge product fails fast. | |
| 227 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(200)+`, "b": `+vec(200)+`, "c": `+vec(200)+`, "d": `+vec(200)+`}}`)) | |
| 228 | + | if err == nil { | |
| 229 | + | t.Error("huge product: no error") | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestStrategyOptions(t *testing.T) { | |
| 234 | + | s, err := ParseStrategy(ctxbg, jsonValue(t, `{"fail-fast": "false", "max-parallel": 2, "matrix": {"a": [1, 2, 3]}}`)) | |
| 235 | + | if err != nil { | |
| 236 | + | t.Fatal(err) | |
| 237 | + | } | |
| 238 | + | if s.FailFast || s.MaxParallel != 2 || !s.HasMatrix || len(s.Combos) != 3 { | |
| 239 | + | t.Errorf("%+v", s) | |
| 240 | + | } | |
| 241 | + | d := s.Context(1) | |
| 242 | + | if got := expr.ToJSON(d); got != "{\n \"fail-fast\": false,\n \"job-index\": 1,\n \"job-total\": 3,\n \"max-parallel\": 2\n}" { | |
| 243 | + | t.Errorf("context = %s", got) | |
| 244 | + | } | |
| 245 | + | s, err = ParseStrategy(ctxbg, nil) | |
| 246 | + | if err != nil || !s.FailFast || s.MaxParallel != 0 || s.HasMatrix || len(s.Combos) != 1 { | |
| 247 | + | t.Errorf("default: %+v, %v", s, err) | |
| 248 | + | } | |
| 249 | + | if got := expr.ToJSON(s.Context(0)); !strings.Contains(got, `"max-parallel": 1`) { | |
| 250 | + | t.Errorf("default context = %s", got) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func jsonValue2(s string) expr.Value { | |
| 255 | + | v, err := expr.FromJSON([]byte(s)) | |
| 256 | + | if err != nil { | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | return v | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func entries(n int) string { | |
| 263 | + | var items []string | |
| 264 | + | for i := 0; i < n; i++ { | |
| 265 | + | items = append(items, `{"a": 1}`) | |
| 266 | + | } | |
| 267 | + | return "[" + strings.Join(items, ",") + "]" | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func TestMatrixEntryLimitsAtTheEdge(t *testing.T) { | |
| 271 | + | if _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "exclude": `+strings.ReplaceAll(entries(256), `"a": 1`, `"a": 9`)+`}}`)); err != nil { | |
| 272 | + | t.Errorf("256 excludes: %v", err) | |
| 273 | + | } | |
| 274 | + | if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "include": `+entries(256)+`}}`)); err != nil || len(c) != 1 { | |
| 275 | + | t.Errorf("256 includes: %d, %v", len(c), err) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // TestHostileMatrixFailsFast is the probe of the review: many vectors whose | |
| 280 | + | // product is huge and whose exclusions hide the 256-job limit used to run for | |
| 281 | + | // close to a minute. | |
| 282 | + | func TestHostileMatrixFailsFast(t *testing.T) { | |
| 283 | + | vec := func(n int) string { | |
| 284 | + | var items []string | |
| 285 | + | for i := 0; i < n; i++ { | |
| 286 | + | items = append(items, fmt.Sprint(i)) | |
| 287 | + | } | |
| 288 | + | return "[" + strings.Join(items, ",") + "]" | |
| 289 | + | } | |
| 290 | + | src := `{"matrix": {"a": ` + vec(100) + `, "b": ` + vec(100) + `, "c": ` + vec(100) + `, "exclude": ` + strings.ReplaceAll(entries(256), `"a": 1`, `"c": 5`) + `}}` | |
| 291 | + | start := time.Now() | |
| 292 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, src)) | |
| 293 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit") { | |
| 294 | + | t.Errorf("err = %v", err) | |
| 295 | + | } | |
| 296 | + | if d := time.Since(start); d > time.Second { | |
| 297 | + | t.Errorf("took %v", d) | |
| 298 | + | } | |
| 299 | + | // Just under the product cap, with nothing to stop the loop early, ends. | |
| 300 | + | var parts []string | |
| 301 | + | for _, k := range []string{"a", "b", "c", "d"} { | |
| 302 | + | parts = append(parts, `"`+k+`": `+vec(16)) | |
| 303 | + | } | |
| 304 | + | start = time.Now() | |
| 305 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {`+strings.Join(parts, ",")+`, "exclude": [{"a": 0}, {"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}, {"a": 7}, {"a": 8}, {"a": 9}, {"a": 10}, {"a": 11}, {"a": 12}, {"a": 13}, {"a": 14}]}}`)) | |
| 306 | + | if err == nil { | |
| 307 | + | t.Error("65536 combinations of which 4096 remain: no error") | |
| 308 | + | } | |
| 309 | + | if d := time.Since(start); d > 2*time.Second { | |
| 310 | + | t.Errorf("took %v", d) | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestMatrixProductCap(t *testing.T) { | |
| 315 | + | vec := func(n int) string { | |
| 316 | + | var items []string | |
| 317 | + | for i := 0; i < n; i++ { | |
| 318 | + | items = append(items, fmt.Sprint(i)) | |
| 319 | + | } | |
| 320 | + | return "[" + strings.Join(items, ",") + "]" | |
| 321 | + | } | |
| 322 | + | // 2^16 is allowed by the cap (and fails on the job limit), 2^16+1 is refused outright. | |
| 323 | + | _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(MaxMatrixProduct/2)+`, "b": `+vec(2)+`, "c": `+vec(1)+`}}`)) | |
| 324 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit") { | |
| 325 | + | t.Errorf("err = %v", err) | |
| 326 | + | } | |
| 327 | + | _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(MaxMatrixProduct+1)+`}}`)) | |
| 328 | + | if err == nil || !strings.Contains(err.Error(), "matrix size limit") { | |
| 329 | + | t.Errorf("one huge vector: %v", err) | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func TestMatrixContextIsHonoured(t *testing.T) { | |
| 334 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 335 | + | cancel() | |
| 336 | + | // 3000 combinations, nearly all excluded, so the job limit does not stop the loop. | |
| 337 | + | var items []string | |
| 338 | + | for i := 0; i < 3000; i++ { | |
| 339 | + | items = append(items, fmt.Sprint(i)) | |
| 340 | + | } | |
| 341 | + | _, err := ExpandMatrix(ctx, jsonValue(t, `{"matrix": {"a": [`+strings.Join(items, ",")+`], "exclude": [{"a": 1}]}}`)) | |
| 342 | + | if !errors.Is(err, context.Canceled) { | |
| 343 | + | t.Errorf("err = %v", err) | |
| 344 | + | } | |
| 345 | + | _, err = ParseStrategy(ctx, jsonValue(t, `{"matrix": {"a": [`+strings.Join(items, ",")+`]}}`)) | |
| 346 | + | if !errors.Is(err, context.Canceled) { | |
| 347 | + | t.Errorf("ParseStrategy err = %v", err) | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func TestNullMatrixValueSegment(t *testing.T) { | |
| 352 | + | // TODO(verify with recording): a null value shows as an empty segment, | |
| 353 | + | // "job (, 1)"; GitHub may skip it. | |
| 354 | + | combos, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [null], "b": [1]}}`)) | |
| 355 | + | if err != nil { | |
| 356 | + | t.Fatal(err) | |
| 357 | + | } | |
| 358 | + | if got := combos[0].DisplayName("job"); got != "job (, 1)" { | |
| 359 | + | t.Errorf("name = %q", got) | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestMatrixEnvBounds(t *testing.T) { | |
| 364 | + | s, _ := ParseStrategy(ctxbg, jsonValue(t, `{"matrix": {"a": [1, 2]}}`)) | |
| 365 | + | for _, i := range []int{-1, 2, 99} { | |
| 366 | + | if _, err := s.MatrixEnv(template.Env{}, i); err == nil { | |
| 367 | + | t.Errorf("MatrixEnv(%d): no error", i) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | j := &Job{Name: template.NewString(template.Origin{}, "n")} | |
| 371 | + | if _, err := j.DisplayName(ctxbg, template.Env{}, s, 5); err == nil { | |
| 372 | + | t.Error("DisplayName out of range: no error") | |
| 373 | + | } | |
| 374 | + | } | |
A workflow/model.go +161 -0
| @@ -0,0 +1,161 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bnema.dev/ermite-actions/template" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Error is a problem found in a workflow file. Its message has the form | |
| 10 | + | // "file.yml (Line: 3, Col: 5): message". | |
| 11 | + | type Error = template.Error | |
| 12 | + | ||
| 13 | + | // Workflow is a parsed and validated workflow file. | |
| 14 | + | type Workflow struct { | |
| 15 | + | // File is the name given to Parse. | |
| 16 | + | File string | |
| 17 | + | // Name is the "name" key, or "". | |
| 18 | + | Name string | |
| 19 | + | // RunName is the "run-name" token (an expression is allowed), or nil. | |
| 20 | + | RunName template.Token | |
| 21 | + | // On lists the events that trigger the workflow, in file order. | |
| 22 | + | On *On | |
| 23 | + | // Env is the workflow-level "env" mapping, or nil. Values may hold | |
| 24 | + | // expressions. | |
| 25 | + | Env *template.Mapping | |
| 26 | + | // Defaults holds the workflow-level "defaults", or nil. | |
| 27 | + | Defaults *Defaults | |
| 28 | + | // Concurrency is the "concurrency" token (a string or a mapping), or nil. | |
| 29 | + | Concurrency template.Token | |
| 30 | + | // Permissions is the "permissions" key, or nil. | |
| 31 | + | Permissions *Permissions | |
| 32 | + | // Jobs are the jobs in file order. | |
| 33 | + | Jobs []*Job | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Job returns the job with the given id, or nil. | |
| 37 | + | func (w *Workflow) Job(id string) *Job { | |
| 38 | + | for _, j := range w.Jobs { | |
| 39 | + | if j.ID == id { | |
| 40 | + | return j | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Defaults is the "defaults" key. | |
| 47 | + | type Defaults struct { | |
| 48 | + | // Run holds defaults.run; nil when absent. | |
| 49 | + | Run *RunDefaults | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // RunDefaults is defaults.run: the shell and working directory of run steps. | |
| 53 | + | type RunDefaults struct { | |
| 54 | + | Shell template.Token | |
| 55 | + | WorkingDirectory template.Token | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Permissions is the "permissions" key: either all permissions read or | |
| 59 | + | // write, or a level per scope. | |
| 60 | + | type Permissions struct { | |
| 61 | + | // All is "read-all" or "write-all", or "" when Scopes is used. | |
| 62 | + | All string | |
| 63 | + | // Scopes lists the scopes in file order ("contents": "read"). | |
| 64 | + | Scopes []PermissionScope | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // PermissionScope is one entry of a permissions mapping. | |
| 68 | + | type PermissionScope struct { | |
| 69 | + | Scope string | |
| 70 | + | Level string // "read", "write" or "none" | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Job is a job of a workflow. | |
| 74 | + | type Job struct { | |
| 75 | + | // ID is the key of the job. | |
| 76 | + | ID string | |
| 77 | + | // IDToken is the key token, with its position. | |
| 78 | + | IDToken template.Token | |
| 79 | + | // Name is the "name" token; when the file gives none it is the id. | |
| 80 | + | Name template.Token | |
| 81 | + | // HasName reports whether the file gave a non-empty name. | |
| 82 | + | HasName bool | |
| 83 | + | // Needs are the ids of the jobs this one depends on, with their tokens. | |
| 84 | + | Needs []string | |
| 85 | + | NeedTokens []*template.String | |
| 86 | + | // If is the condition, always with a status function: a condition | |
| 87 | + | // without one is wrapped as "success() && (cond)", and a missing one is | |
| 88 | + | // "success()". | |
| 89 | + | If *template.BasicExpression | |
| 90 | + | // RunsOn is the "runs-on" token (string, sequence, mapping or an | |
| 91 | + | // expression); nil for a reusable workflow job. | |
| 92 | + | RunsOn template.Token | |
| 93 | + | // Strategy is the "strategy" token, or nil. | |
| 94 | + | Strategy template.Token | |
| 95 | + | // Env is the job's "env" mapping, or nil. | |
| 96 | + | Env *template.Mapping | |
| 97 | + | // Defaults holds the job-level "defaults", or nil. | |
| 98 | + | Defaults *Defaults | |
| 99 | + | // Steps are the steps in file order. | |
| 100 | + | Steps []*Step | |
| 101 | + | // Outputs is the "outputs" mapping, or nil. | |
| 102 | + | Outputs *template.Mapping | |
| 103 | + | // TimeoutMinutes, CancelTimeoutMinutes and ContinueOnError are tokens | |
| 104 | + | // (a literal or an expression), or nil. | |
| 105 | + | TimeoutMinutes template.Token | |
| 106 | + | CancelTimeoutMinutes template.Token | |
| 107 | + | ContinueOnError template.Token | |
| 108 | + | // Container, Services, Environment, Concurrency and Snapshot are | |
| 109 | + | // the raw tokens, or nil. | |
| 110 | + | Container template.Token | |
| 111 | + | Services template.Token | |
| 112 | + | Environment template.Token | |
| 113 | + | Concurrency template.Token | |
| 114 | + | Snapshot template.Token | |
| 115 | + | // Permissions is the job's "permissions", or nil. | |
| 116 | + | Permissions *Permissions | |
| 117 | + | ||
| 118 | + | // Uses is set for a job that calls a reusable workflow ("uses"); such a | |
| 119 | + | // job has no steps and no runs-on. | |
| 120 | + | Uses *template.String | |
| 121 | + | // With and Secrets are the inputs and secrets passed to the called | |
| 122 | + | // workflow. InheritSecrets is "secrets: inherit". | |
| 123 | + | With *template.Mapping | |
| 124 | + | Secrets *template.Mapping | |
| 125 | + | InheritSecrets bool | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // IsReusable reports whether the job calls a reusable workflow. | |
| 129 | + | func (j *Job) IsReusable() bool { return j.Uses != nil } | |
| 130 | + | ||
| 131 | + | // Step is a step of a job: a run step or a uses step. | |
| 132 | + | type Step struct { | |
| 133 | + | // ID is the id of the step. When the file gives none, one is generated | |
| 134 | + | // the way the reference does: "__run", "__run_2", "__actions_checkout". | |
| 135 | + | ID string | |
| 136 | + | // Explicit reports whether the id came from the file. | |
| 137 | + | Explicit bool | |
| 138 | + | // Pos is where the step starts. | |
| 139 | + | Pos template.Pos | |
| 140 | + | // Name is the "name" token, or nil. | |
| 141 | + | Name template.Token | |
| 142 | + | // If is the condition, with a status function, as for jobs. | |
| 143 | + | If *template.BasicExpression | |
| 144 | + | // Run is the script of a run step, or nil. | |
| 145 | + | Run template.Token | |
| 146 | + | // Shell and WorkingDirectory are the run step's options, or nil. | |
| 147 | + | Shell template.Token | |
| 148 | + | WorkingDirectory template.Token | |
| 149 | + | // Uses is the action of a uses step, or nil. | |
| 150 | + | Uses *template.String | |
| 151 | + | // With holds the inputs of a uses step, or nil. | |
| 152 | + | With *template.Mapping | |
| 153 | + | // Env is the step's "env" mapping, or nil. | |
| 154 | + | Env *template.Mapping | |
| 155 | + | // ContinueOnError and TimeoutMinutes are tokens, or nil. | |
| 156 | + | ContinueOnError template.Token | |
| 157 | + | TimeoutMinutes template.Token | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // IsRun reports whether the step runs a script. | |
| 161 | + | func (s *Step) IsRun() bool { return s.Run != nil } | |
A workflow/parse.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 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. One check is not the reference's: an event that uses both a | |
| 21 | + | // filter and its "-ignore" counterpart (branches and branches-ignore, tags and | |
| 22 | + | // tags-ignore, paths and paths-ignore) is an error, as on GitHub. | |
| 23 | + | func Parse(path string, content []byte) (*Workflow, []Error) { | |
| 24 | + | schema, err := template.WorkflowSchema() | |
| 25 | + | if err != nil { | |
| 26 | + | return nil, []Error{{Raw: "internal error: " + err.Error()}} | |
| 27 | + | } | |
| 28 | + | ctx := template.NewContext(schema) | |
| 29 | + | root := template.ReadYAML(ctx, rootType, path, content) | |
| 30 | + | w := &Workflow{File: path} | |
| 31 | + | if root != nil && !ctx.HasErrors() { | |
| 32 | + | c := &converter{ctx: ctx, schema: schema, w: w, jobIDs: newIDBuilder()} | |
| 33 | + | c.convertRoot(root) | |
| 34 | + | c.validateFilters() | |
| 35 | + | } | |
| 36 | + | errs := collect(ctx) | |
| 37 | + | if len(errs) > 0 { | |
| 38 | + | return nil, errs | |
| 39 | + | } | |
| 40 | + | return w, nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func collect(ctx *template.Context) []Error { | |
| 44 | + | var out []Error | |
| 45 | + | for _, e := range ctx.Errors() { | |
| 46 | + | out = append(out, *e) | |
| 47 | + | } | |
| 48 | + | return out | |
| 49 | + | } | |
A workflow/push.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import "strings" | |
| 6 | + | ||
| 7 | + | // Limits GitHub applies to push events. | |
| 8 | + | const ( | |
| 9 | + | // MaxFilesEvaluated is the number of changed files a paths filter looks | |
| 10 | + | // at; files after the 300th are ignored. | |
| 11 | + | MaxFilesEvaluated = 300 | |
| 12 | + | // MaxTagsPerPush is the number of tags that can be pushed at once before | |
| 13 | + | // GitHub creates no event for them. | |
| 14 | + | MaxTagsPerPush = 3 | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // PushInfo describes a push to one ref. | |
| 18 | + | type PushInfo struct { | |
| 19 | + | // Ref is the full name, "refs/heads/main" or "refs/tags/v1". | |
| 20 | + | Ref string | |
| 21 | + | // Created and Deleted say whether the push created or deleted the ref. | |
| 22 | + | Created bool | |
| 23 | + | Deleted bool | |
| 24 | + | // ChangedFiles are the paths changed by the push, in the order of the | |
| 25 | + | // diff. Only the first MaxFilesEvaluated are looked at. | |
| 26 | + | ChangedFiles []string | |
| 27 | + | // FilesTruncated is set when the list of changed files is not available: | |
| 28 | + | // the push has more than 1000 commits, or the diff could not be | |
| 29 | + | // computed. A paths filter is then considered satisfied. | |
| 30 | + | FilesTruncated bool | |
| 31 | + | // TagCount is the number of tags pushed in the same push. | |
| 32 | + | TagCount int | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // MatchPush reports whether a push triggers the workflow, applying the | |
| 36 | + | // "push" event's filters like GitHub: | |
| 37 | + | // | |
| 38 | + | // - the deletion of a ref triggers nothing; | |
| 39 | + | // - a push of more than three tags creates no event for its tags; | |
| 40 | + | // - a workflow with only branches or branches-ignore does not run for a tag, | |
| 41 | + | // and one with only tags or tags-ignore does not run for a branch; with | |
| 42 | + | // neither it runs for both; | |
| 43 | + | // - when both a branches and a tags filter exist, a ref is checked against | |
| 44 | + | // the filter for its kind; | |
| 45 | + | // - paths and paths-ignore apply to branch pushes only, against the first | |
| 46 | + | // 300 changed files; when the list is truncated they pass; | |
| 47 | + | // - patterns are evaluated in order and the last one that matches wins. | |
| 48 | + | // | |
| 49 | + | // Commit-message directives are separate: see [SkipCI]. | |
| 50 | + | // | |
| 51 | + | // TODO(verify with recording): the order in which a branch filter and a | |
| 52 | + | // paths filter combine is "both must pass" (documented); behaviour on refs | |
| 53 | + | // that are neither a branch nor a tag, and on a push that changes no file, is | |
| 54 | + | // assumed. | |
| 55 | + | func (w *Workflow) MatchPush(p PushInfo) bool { | |
| 56 | + | if w == nil || w.On == nil { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | ev := w.On.Event("push") | |
| 60 | + | if ev == nil { | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | if p.Deleted { | |
| 64 | + | return false | |
| 65 | + | } | |
| 66 | + | switch { | |
| 67 | + | case strings.HasPrefix(p.Ref, "refs/heads/"): | |
| 68 | + | name := strings.TrimPrefix(p.Ref, "refs/heads/") | |
| 69 | + | if !ev.refFilterAllows(true, name) { | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | return ev.pathsAllow(p) | |
| 73 | + | case strings.HasPrefix(p.Ref, "refs/tags/"): | |
| 74 | + | if p.TagCount > MaxTagsPerPush { | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | return ev.refFilterAllows(false, strings.TrimPrefix(p.Ref, "refs/tags/")) | |
| 78 | + | } | |
| 79 | + | return false | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // refFilterAllows applies the branches or tags filters to the name of a | |
| 83 | + | // branch (branch is true) or a tag. | |
| 84 | + | func (e *Event) refFilterAllows(branch bool, name string) bool { | |
| 85 | + | cf := e.filters() | |
| 86 | + | inc, exc := cf.branches, cf.branchesIgnore | |
| 87 | + | own, ownIgnore, other, otherIgnore := e.Branches, e.BranchesIgnore, e.Tags, e.TagsIgnore | |
| 88 | + | if !branch { | |
| 89 | + | inc, exc = cf.tags, cf.tagsIgnore | |
| 90 | + | own, ownIgnore, other, otherIgnore = e.Tags, e.TagsIgnore, e.Branches, e.BranchesIgnore | |
| 91 | + | } | |
| 92 | + | if own == nil && ownIgnore == nil && (other != nil || otherIgnore != nil) { | |
| 93 | + | return false | |
| 94 | + | } | |
| 95 | + | if own != nil && !matchList(inc, name) { | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | if ownIgnore != nil && matchList(exc, name) { | |
| 99 | + | return false | |
| 100 | + | } | |
| 101 | + | return true | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // pathsAllow applies paths and paths-ignore to a branch push. | |
| 105 | + | func (e *Event) pathsAllow(p PushInfo) bool { | |
| 106 | + | if e.Paths == nil && e.PathsIgnore == nil { | |
| 107 | + | return true | |
| 108 | + | } | |
| 109 | + | if p.FilesTruncated { | |
| 110 | + | return true | |
| 111 | + | } | |
| 112 | + | cf := e.filters() | |
| 113 | + | files := p.ChangedFiles | |
| 114 | + | if len(files) > MaxFilesEvaluated { | |
| 115 | + | files = files[:MaxFilesEvaluated] | |
| 116 | + | } | |
| 117 | + | if e.Paths != nil { | |
| 118 | + | any := false | |
| 119 | + | for _, f := range files { | |
| 120 | + | if matchList(cf.paths, f) { | |
| 121 | + | any = true | |
| 122 | + | break | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | if !any { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if e.PathsIgnore != nil && len(files) > 0 { | |
| 130 | + | any := false | |
| 131 | + | for _, f := range files { | |
| 132 | + | if !matchList(cf.pathsIgnore, f) { | |
| 133 | + | any = true | |
| 134 | + | break | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if !any { | |
| 138 | + | return false | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | return true | |
| 142 | + | } | |
A workflow/push_test.go +180 -0
| @@ -0,0 +1,180 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func pushWorkflow(t testing.TB, on string) *Workflow { | |
| 12 | + | t.Helper() | |
| 13 | + | w, errs := Parse("w.yml", []byte(on+"\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")) | |
| 14 | + | if len(errs) > 0 { | |
| 15 | + | t.Fatalf("Parse: %v", errs) | |
| 16 | + | } | |
| 17 | + | return w | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestMatchPush(t *testing.T) { | |
| 21 | + | br := func(n string) PushInfo { return PushInfo{Ref: "refs/heads/" + n} } | |
| 22 | + | tg := func(n string) PushInfo { return PushInfo{Ref: "refs/tags/" + n, TagCount: 1} } | |
| 23 | + | files := func(n string, f ...string) PushInfo { return PushInfo{Ref: "refs/heads/" + n, ChangedFiles: f} } | |
| 24 | + | tests := []struct { | |
| 25 | + | name string | |
| 26 | + | on string | |
| 27 | + | push PushInfo | |
| 28 | + | want bool | |
| 29 | + | }{ | |
| 30 | + | {"push string, branch", "on: push", br("main"), true}, | |
| 31 | + | {"push string, tag", "on: push", tg("v1"), true}, | |
| 32 | + | {"push list", "on: [push, pull_request]", br("main"), true}, | |
| 33 | + | {"other event only", "on: pull_request", br("main"), false}, | |
| 34 | + | {"push null", "on:\n push:", br("x/y"), true}, | |
| 35 | + | {"deleted branch", "on: push", PushInfo{Ref: "refs/heads/main", Deleted: true}, false}, | |
| 36 | + | {"deleted tag", "on: push", PushInfo{Ref: "refs/tags/v1", Deleted: true, TagCount: 1}, false}, | |
| 37 | + | {"created branch", "on: push", PushInfo{Ref: "refs/heads/new", Created: true}, true}, | |
| 38 | + | {"other ref kind", "on: push", PushInfo{Ref: "refs/notes/x"}, false}, | |
| 39 | + | ||
| 40 | + | // branches | |
| 41 | + | {"branches match", "on:\n push:\n branches: [main]", br("main"), true}, | |
| 42 | + | {"branches no match", "on:\n push:\n branches: [main]", br("dev"), false}, | |
| 43 | + | {"branches glob", "on:\n push:\n branches: ['release/**']", br("release/1/x"), true}, | |
| 44 | + | {"branches single string", "on:\n push:\n branches: main", br("main"), true}, | |
| 45 | + | {"branches-ignore hit", "on:\n push:\n branches-ignore: ['wip/**']", br("wip/x"), false}, | |
| 46 | + | {"branches-ignore miss", "on:\n push:\n branches-ignore: ['wip/**']", br("main"), true}, | |
| 47 | + | {"branches negation order", "on:\n push:\n branches: ['releases/**', '!releases/**-alpha']", br("releases/1-alpha"), false}, | |
| 48 | + | {"branches negation re-include", "on:\n push:\n branches: ['releases/**', '!releases/**-alpha', 'releases/**/ga']", br("releases/1-alpha/ga"), true}, | |
| 49 | + | {"branches empty list", "on:\n push:\n branches: []", br("main"), false}, | |
| 50 | + | ||
| 51 | + | // branches-only filter: tags don't run; tags-only filter: branches don't run. | |
| 52 | + | {"branches filter, tag push", "on:\n push:\n branches: [main]", tg("main"), false}, | |
| 53 | + | {"branches-ignore filter, tag push", "on:\n push:\n branches-ignore: [dev]", tg("v1"), false}, | |
| 54 | + | {"tags filter, branch push", "on:\n push:\n tags: ['v*']", br("v1"), false}, | |
| 55 | + | {"tags-ignore filter, branch push", "on:\n push:\n tags-ignore: ['v*']", br("main"), false}, | |
| 56 | + | {"tags match", "on:\n push:\n tags: ['v*']", tg("v1.2"), true}, | |
| 57 | + | {"tags no match", "on:\n push:\n tags: ['v*']", tg("x1"), false}, | |
| 58 | + | {"tags-ignore hit", "on:\n push:\n tags-ignore: ['v*-rc*']", tg("v1-rc1"), false}, | |
| 59 | + | {"tags-ignore miss", "on:\n push:\n tags-ignore: ['v*-rc*']", tg("v1"), true}, | |
| 60 | + | {"branches and tags: branch", "on:\n push:\n branches: [main]\n tags: ['v*']", br("main"), true}, | |
| 61 | + | {"branches and tags: other branch", "on:\n push:\n branches: [main]\n tags: ['v*']", br("v1"), false}, | |
| 62 | + | {"branches and tags: tag", "on:\n push:\n branches: [main]\n tags: ['v*']", tg("v1"), true}, | |
| 63 | + | {"branches and tags: other tag", "on:\n push:\n branches: [main]\n tags: ['v*']", tg("main"), false}, | |
| 64 | + | {"tags with paths ignores paths", "on:\n push:\n tags: ['v*']\n paths: ['src/**']", tg("v1"), true}, | |
| 65 | + | ||
| 66 | + | // tag count | |
| 67 | + | {"3 tags", "on: push", PushInfo{Ref: "refs/tags/v1", TagCount: 3}, true}, | |
| 68 | + | {"4 tags", "on: push", PushInfo{Ref: "refs/tags/v1", TagCount: 4}, false}, | |
| 69 | + | {"4 tags and a branch push", "on: push", PushInfo{Ref: "refs/heads/main", TagCount: 4}, true}, | |
| 70 | + | ||
| 71 | + | // paths | |
| 72 | + | {"paths match", "on:\n push:\n paths: ['src/**']", files("main", "src/a.go"), true}, | |
| 73 | + | {"paths no match", "on:\n push:\n paths: ['src/**']", files("main", "docs/a.md"), false}, | |
| 74 | + | {"paths any file", "on:\n push:\n paths: ['src/**']", files("main", "docs/a.md", "src/b.go"), true}, | |
| 75 | + | {"paths no files", "on:\n push:\n paths: ['src/**']", files("main"), false}, | |
| 76 | + | {"paths-ignore all ignored", "on:\n push:\n paths-ignore: ['docs/**']", files("main", "docs/a.md", "docs/b.md"), false}, | |
| 77 | + | {"paths-ignore one other", "on:\n push:\n paths-ignore: ['docs/**']", files("main", "docs/a.md", "x.go"), true}, | |
| 78 | + | {"paths negation", "on:\n push:\n paths: ['sub-project/**', '!sub-project/docs/**']", files("main", "sub-project/docs/a.md"), false}, | |
| 79 | + | {"paths negation, other file", "on:\n push:\n paths: ['sub-project/**', '!sub-project/docs/**']", files("main", "sub-project/docs/a.md", "sub-project/a.go"), true}, | |
| 80 | + | {"paths with branches", "on:\n push:\n branches: [main]\n paths: ['src/**']", files("dev", "src/a.go"), false}, | |
| 81 | + | {"paths with branches both", "on:\n push:\n branches: [main]\n paths: ['src/**']", files("main", "src/a.go"), true}, | |
| 82 | + | {"paths truncated", "on:\n push:\n paths: ['src/**']", PushInfo{Ref: "refs/heads/main", ChangedFiles: []string{"docs/a.md"}, FilesTruncated: true}, true}, | |
| 83 | + | {"paths-ignore truncated", "on:\n push:\n paths-ignore: ['**']", PushInfo{Ref: "refs/heads/main", FilesTruncated: true}, true}, | |
| 84 | + | {"no paths filter, no files", "on: push", files("main"), true}, | |
| 85 | + | } | |
| 86 | + | for _, tc := range tests { | |
| 87 | + | t.Run(tc.name, func(t *testing.T) { | |
| 88 | + | w := pushWorkflow(t, tc.on) | |
| 89 | + | if got := w.MatchPush(tc.push); got != tc.want { | |
| 90 | + | t.Errorf("MatchPush = %v, want %v", got, tc.want) | |
| 91 | + | } | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestMatchPushFirst300Files(t *testing.T) { | |
| 97 | + | w := pushWorkflow(t, "on:\n push:\n paths: ['src/**']") | |
| 98 | + | files := make([]string, 0, 301) | |
| 99 | + | for i := 0; i < MaxFilesEvaluated; i++ { | |
| 100 | + | files = append(files, fmt.Sprintf("docs/%d.md", i)) | |
| 101 | + | } | |
| 102 | + | files = append(files, "src/late.go") // the 301st | |
| 103 | + | if w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) { | |
| 104 | + | t.Error("a match beyond the 300th file must not run the workflow") | |
| 105 | + | } | |
| 106 | + | files[299] = "src/early.go" | |
| 107 | + | if !w.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) { | |
| 108 | + | t.Error("a match among the first 300 files must run the workflow") | |
| 109 | + | } | |
| 110 | + | wi := pushWorkflow(t, "on:\n push:\n paths-ignore: ['docs/**']") | |
| 111 | + | files = files[:0] | |
| 112 | + | for i := 0; i < MaxFilesEvaluated; i++ { | |
| 113 | + | files = append(files, fmt.Sprintf("docs/%d.md", i)) | |
| 114 | + | } | |
| 115 | + | files = append(files, "src/late.go") | |
| 116 | + | // TODO(verify with recording): with paths-ignore, files beyond the 300th | |
| 117 | + | // are not considered either, so everything seen is ignored. | |
| 118 | + | if wi.MatchPush(PushInfo{Ref: "refs/heads/main", ChangedFiles: files}) { | |
| 119 | + | t.Error("paths-ignore: only the first 300 files are evaluated") | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestMatchPushNilSafe(t *testing.T) { | |
| 124 | + | var w *Workflow | |
| 125 | + | if w.MatchPush(PushInfo{Ref: "refs/heads/main"}) { | |
| 126 | + | t.Error("nil workflow matched") | |
| 127 | + | } | |
| 128 | + | if (&Workflow{}).MatchPush(PushInfo{Ref: "refs/heads/main"}) { | |
| 129 | + | t.Error("workflow without on matched") | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestFilterPairsAreRejected(t *testing.T) { | |
| 134 | + | for _, on := range []string{ | |
| 135 | + | "on:\n push:\n branches: [a]\n branches-ignore: [b]", | |
| 136 | + | "on:\n push:\n tags: [a]\n tags-ignore: [b]", | |
| 137 | + | "on:\n pull_request:\n paths: [a]\n paths-ignore: [b]", | |
| 138 | + | } { | |
| 139 | + | if _, errs := Parse("w.yml", []byte(on+"\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n")); len(errs) != 1 { | |
| 140 | + | t.Errorf("%q: errors = %v", on, errs) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestFilterValidation(t *testing.T) { | |
| 146 | + | tail := "\njobs:\n a:\n runs-on: x\n steps: [{run: a}]\n" | |
| 147 | + | tests := []struct{ name, on, want string }{ | |
| 148 | + | {"only negated branches", "on:\n push:\n branches: ['!main']", "Event 'push' filter 'branches' has only patterns that start with '!'"}, | |
| 149 | + | {"only negated paths", "on:\n push:\n paths: ['!docs/**', '!x']", "filter 'paths' has only patterns"}, | |
| 150 | + | {"negated with positive", "on:\n push:\n branches: ['**', '!main']", ""}, | |
| 151 | + | {"empty list", "on:\n push:\n branches: []", ""}, | |
| 152 | + | {"pull_request too", "on:\n pull_request:\n branches: ['!x']", "Event 'pull_request' filter 'branches'"}, | |
| 153 | + | } | |
| 154 | + | for _, tc := range tests { | |
| 155 | + | _, errs := Parse("w.yml", []byte(tc.on+tail)) | |
| 156 | + | switch { | |
| 157 | + | case tc.want == "" && len(errs) > 0: | |
| 158 | + | t.Errorf("%s: unexpected errors %v", tc.name, errs) | |
| 159 | + | case tc.want != "" && (len(errs) != 1 || !strings.Contains(errs[0].Error(), tc.want)): | |
| 160 | + | t.Errorf("%s: errors = %v, want %q", tc.name, errs, tc.want) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestFiltersAreCompiledOnce(t *testing.T) { | |
| 166 | + | w := pushWorkflow(t, "on:\n push:\n branches: ['release/**']") | |
| 167 | + | ev := w.On.Event("push") | |
| 168 | + | if ev.cf == nil { | |
| 169 | + | t.Fatal("Parse did not compile the filters") | |
| 170 | + | } | |
| 171 | + | first := ev.filters() | |
| 172 | + | if ev.filters() != first || len(first.branches) != 1 { | |
| 173 | + | t.Error("filters are recompiled on each use") | |
| 174 | + | } | |
| 175 | + | // An Event built by hand still works. | |
| 176 | + | hand := &Workflow{On: &On{Events: []*Event{{Name: "push", Branches: []string{"main"}}}}} | |
| 177 | + | if !hand.MatchPush(PushInfo{Ref: "refs/heads/main"}) || hand.MatchPush(PushInfo{Ref: "refs/heads/dev"}) { | |
| 178 | + | t.Error("hand-built workflow does not filter") | |
| 179 | + | } | |
| 180 | + | } | |
A workflow/skip.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import "strings" | |
| 6 | + | ||
| 7 | + | var skipMarkers = []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"} | |
| 8 | + | ||
| 9 | + | // SkipCI reports whether the message of the head commit of a push tells | |
| 10 | + | // GitHub not to run workflows for it: it contains [skip ci], [ci skip], | |
| 11 | + | // [no ci], [skip actions] or [actions skip], or it ends with a | |
| 12 | + | // "skip-checks: true" (or "skip-checks:true") trailer in its last paragraph. | |
| 13 | + | // | |
| 14 | + | // TODO(verify with recording): the following is assumed. Markers are matched | |
| 15 | + | // without regard to case, anywhere in the message, subject or body. GitHub | |
| 16 | + | // documents the trailer as "two empty lines followed by skip-checks: true"; | |
| 17 | + | // here one blank line is enough, the trailer must be in the last paragraph | |
| 18 | + | // and not the first one, its key and value are case-insensitive, and other | |
| 19 | + | // trailers may share its paragraph. Whether GitHub looks at the head commit | |
| 20 | + | // only, or at every commit of the push, is not settled by its documentation. | |
| 21 | + | func SkipCI(headCommitMessage string) bool { | |
| 22 | + | lower := strings.ToLower(headCommitMessage) | |
| 23 | + | for _, m := range skipMarkers { | |
| 24 | + | if strings.Contains(lower, m) { | |
| 25 | + | return true | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | return hasSkipChecksTrailer(headCommitMessage) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func hasSkipChecksTrailer(msg string) bool { | |
| 32 | + | msg = strings.ReplaceAll(msg, "\r\n", "\n") | |
| 33 | + | var paras [][]string | |
| 34 | + | var cur []string | |
| 35 | + | for _, line := range strings.Split(msg, "\n") { | |
| 36 | + | if strings.TrimSpace(line) == "" { | |
| 37 | + | if len(cur) > 0 { | |
| 38 | + | paras = append(paras, cur) | |
| 39 | + | cur = nil | |
| 40 | + | } | |
| 41 | + | continue | |
| 42 | + | } | |
| 43 | + | cur = append(cur, line) | |
| 44 | + | } | |
| 45 | + | if len(cur) > 0 { | |
| 46 | + | paras = append(paras, cur) | |
| 47 | + | } | |
| 48 | + | if len(paras) < 2 { | |
| 49 | + | return false | |
| 50 | + | } | |
| 51 | + | for _, line := range paras[len(paras)-1] { | |
| 52 | + | key, val, ok := strings.Cut(line, ":") | |
| 53 | + | if ok && strings.EqualFold(strings.TrimSpace(key), "skip-checks") && strings.EqualFold(strings.TrimSpace(val), "true") { | |
| 54 | + | return true | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return false | |
| 58 | + | } | |
A workflow/skip_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import "testing" | |
| 6 | + | ||
| 7 | + | func TestSkipCI(t *testing.T) { | |
| 8 | + | tests := []struct { | |
| 9 | + | name, msg string | |
| 10 | + | want bool | |
| 11 | + | }{ | |
| 12 | + | {"empty", "", false}, | |
| 13 | + | {"plain", "fix: a bug", false}, | |
| 14 | + | {"skip ci", "docs: typo [skip ci]", true}, | |
| 15 | + | {"ci skip", "docs: typo [ci skip]", true}, | |
| 16 | + | {"no ci", "docs: typo [no ci]", true}, | |
| 17 | + | {"skip actions", "docs: typo [skip actions]", true}, | |
| 18 | + | {"actions skip", "docs: typo [actions skip]", true}, | |
| 19 | + | {"in body", "docs: typo\n\nlong text [skip ci] more", true}, | |
| 20 | + | {"in subject start", "[skip ci] docs", true}, | |
| 21 | + | {"no brackets", "skip ci", false}, | |
| 22 | + | {"other brackets", "[skip tests]", false}, | |
| 23 | + | {"partial", "[skip ci]", false}, | |
| 24 | + | // TODO(verify with recording): case of the markers. | |
| 25 | + | {"upper case marker", "docs [SKIP CI]", true}, | |
| 26 | + | {"mixed case marker", "docs [Skip Ci]", true}, | |
| 27 | + | ||
| 28 | + | // skip-checks trailer. | |
| 29 | + | {"trailer", "feat: x\n\nskip-checks: true", true}, | |
| 30 | + | {"trailer no space", "feat: x\n\nskip-checks:true", true}, | |
| 31 | + | {"trailer with body", "feat: x\n\nbody text\n\nskip-checks: true", true}, | |
| 32 | + | {"trailer after two blank lines", "feat: x\n\n\nskip-checks: true\n", true}, | |
| 33 | + | {"trailer crlf", "feat: x\r\n\r\nskip-checks: true\r\n", true}, | |
| 34 | + | {"trailer with other trailers", "feat: x\n\nSigned-off-by: A <a@x>\nskip-checks: true", true}, | |
| 35 | + | {"trailer false", "feat: x\n\nskip-checks: false", false}, | |
| 36 | + | {"trailer not last paragraph", "feat: x\n\nskip-checks: true\n\nmore text", false}, | |
| 37 | + | {"trailer in first paragraph only", "skip-checks: true", false}, | |
| 38 | + | {"trailer in subject", "feat: x skip-checks: true", false}, | |
| 39 | + | // TODO(verify with recording): case of key and value. | |
| 40 | + | {"trailer upper", "feat: x\n\nSkip-Checks: TRUE", true}, | |
| 41 | + | } | |
| 42 | + | for _, tc := range tests { | |
| 43 | + | if got := SkipCI(tc.msg); got != tc.want { | |
| 44 | + | t.Errorf("%s: SkipCI(%q) = %v, want %v", tc.name, tc.msg, got, tc.want) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
A workflow/strategy.go +190 -0
| @@ -0,0 +1,190 @@ | |||
| 1 | + | // SPDX-License-Identifier: MIT | |
| 2 | + | ||
| 3 | + | package workflow | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite-actions/expr" | |
| 13 | + | "bnema.dev/ermite-actions/template" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Strategy is an evaluated "strategy" key. | |
| 17 | + | type Strategy struct { | |
| 18 | + | // FailFast is true unless the file says otherwise. | |
| 19 | + | FailFast bool | |
| 20 | + | // MaxParallel is the limit of concurrent jobs, 0 for no limit. | |
| 21 | + | MaxParallel int | |
| 22 | + | // HasMatrix reports whether the strategy has a matrix. Without one, | |
| 23 | + | // Combos holds a single empty combination: the job runs once. | |
| 24 | + | HasMatrix bool | |
| 25 | + | // Combos are the jobs, in the order GitHub creates them. | |
| 26 | + | Combos []MatrixCombo | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // ParseStrategy reads an evaluated strategy value and expands its matrix, | |
| 30 | + | // see [ExpandMatrix] for the rules and limits. | |
| 31 | + | func ParseStrategy(ctx context.Context, v expr.Value) (*Strategy, error) { | |
| 32 | + | s := &Strategy{FailFast: true} | |
| 33 | + | var matrix expr.Value | |
| 34 | + | switch d := v.(type) { | |
| 35 | + | case nil, expr.Null: | |
| 36 | + | case *expr.Dictionary: | |
| 37 | + | if d == nil { | |
| 38 | + | break | |
| 39 | + | } | |
| 40 | + | for _, p := range d.Pairs() { | |
| 41 | + | switch strings.ToLower(p.Key) { | |
| 42 | + | case "fail-fast": | |
| 43 | + | b, err := strategyBool(p.Value) | |
| 44 | + | if err != nil { | |
| 45 | + | return nil, fmt.Errorf("unexpected value for 'fail-fast': %w", err) | |
| 46 | + | } | |
| 47 | + | s.FailFast = b | |
| 48 | + | case "max-parallel": | |
| 49 | + | n, err := strategyInt(p.Value) | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, fmt.Errorf("unexpected value for 'max-parallel': %w", err) | |
| 52 | + | } | |
| 53 | + | s.MaxParallel = n | |
| 54 | + | case "matrix": | |
| 55 | + | matrix = p.Value | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | default: | |
| 59 | + | return nil, errors.New("the strategy must be a mapping") | |
| 60 | + | } | |
| 61 | + | combos, has, err := buildMatrix(ctx, matrix) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, err | |
| 64 | + | } | |
| 65 | + | s.Combos, s.HasMatrix = combos, has | |
| 66 | + | return s, nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Context returns the "strategy" context of job number index (from 0). | |
| 70 | + | func (s *Strategy) Context(index int) *expr.Dictionary { | |
| 71 | + | d := expr.NewDictionary() | |
| 72 | + | d.Add("fail-fast", expr.Boolean(s.FailFast)) | |
| 73 | + | d.Add("job-index", expr.Number(index)) | |
| 74 | + | d.Add("job-total", expr.Number(len(s.Combos))) | |
| 75 | + | max := s.MaxParallel | |
| 76 | + | if max == 0 { | |
| 77 | + | max = len(s.Combos) | |
| 78 | + | } | |
| 79 | + | d.Add("max-parallel", expr.Number(max)) | |
| 80 | + | return d | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func strategyBool(v expr.Value) (bool, error) { | |
| 84 | + | switch x := v.(type) { | |
| 85 | + | case expr.Boolean: | |
| 86 | + | return bool(x), nil | |
| 87 | + | case expr.String: | |
| 88 | + | switch strings.ToLower(string(x)) { | |
| 89 | + | case "true": | |
| 90 | + | return true, nil | |
| 91 | + | case "false": | |
| 92 | + | return false, nil | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | return false, fmt.Errorf("expected a boolean but got %s", kindName(v)) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func strategyInt(v expr.Value) (int, error) { | |
| 99 | + | var f float64 | |
| 100 | + | switch x := v.(type) { | |
| 101 | + | case expr.Number: | |
| 102 | + | f = float64(x) | |
| 103 | + | case expr.String: | |
| 104 | + | n, err := strconv.ParseFloat(strings.TrimSpace(string(x)), 64) | |
| 105 | + | if err != nil { | |
| 106 | + | return 0, fmt.Errorf("expected a number but got '%s'", string(x)) | |
| 107 | + | } | |
| 108 | + | f = n | |
| 109 | + | default: | |
| 110 | + | return 0, fmt.Errorf("expected a number but got %s", kindName(v)) | |
| 111 | + | } | |
| 112 | + | if f != float64(int(f)) || f < 1 || f > 1<<31 { | |
| 113 | + | return 0, errors.New("expected a whole number greater than 0") | |
| 114 | + | } | |
| 115 | + | return int(f), nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func kindName(v expr.Value) string { | |
| 119 | + | if v == nil { | |
| 120 | + | return expr.KindNull.String() | |
| 121 | + | } | |
| 122 | + | return v.Kind().String() | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // EvaluateStrategy evaluates the job's strategy token against env and | |
| 126 | + | // expands its matrix. env must provide the contexts the schema allows in | |
| 127 | + | // "strategy": github, inputs, vars and needs. A job without a strategy gives | |
| 128 | + | // a Strategy with one empty combination. | |
| 129 | + | func (j *Job) EvaluateStrategy(ctx context.Context, env template.Env) (*Strategy, error) { | |
| 130 | + | if j.Strategy == nil { | |
| 131 | + | return ParseStrategy(ctx, nil) | |
| 132 | + | } | |
| 133 | + | v, err := template.Evaluate(ctx, j.Strategy, env) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, err | |
| 136 | + | } | |
| 137 | + | return ParseStrategy(ctx, v) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // MatrixEnv returns env with the "matrix" and "strategy" contexts of | |
| 141 | + | // combination index added, for evaluating the parts of a job that may use | |
| 142 | + | // them: runs-on, name, env, container, services, steps... It fails when index | |
| 143 | + | // is out of range. | |
| 144 | + | func (s *Strategy) MatrixEnv(env template.Env, index int) (template.Env, error) { | |
| 145 | + | if index < 0 || index >= len(s.Combos) { | |
| 146 | + | return env, fmt.Errorf("matrix combination %d out of range (%d combinations)", index, len(s.Combos)) | |
| 147 | + | } | |
| 148 | + | out := env | |
| 149 | + | out.Contexts = make(map[string]expr.Value, len(env.Contexts)+2) | |
| 150 | + | for k, v := range env.Contexts { | |
| 151 | + | out.Contexts[k] = v | |
| 152 | + | } | |
| 153 | + | out.Contexts["matrix"] = s.Combos[index].Values | |
| 154 | + | out.Contexts["strategy"] = s.Context(index) | |
| 155 | + | return out, nil | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // DisplayName evaluates the job's name for combination index of its | |
| 159 | + | // strategy: the name token (or the job id) with the matrix values added in | |
| 160 | + | // parentheses, unless the name is set and reads the matrix context, in which | |
| 161 | + | // case it is used as is. env must already include the matrix and strategy | |
| 162 | + | // contexts, see [Strategy.MatrixEnv]. | |
| 163 | + | // | |
| 164 | + | // TODO(verify with recording): GitHub adds the values to the name unless the | |
| 165 | + | // name is set and refers to the matrix. | |
| 166 | + | func (j *Job) DisplayName(ctx context.Context, env template.Env, s *Strategy, index int) (string, error) { | |
| 167 | + | if index < 0 || index >= len(s.Combos) { | |
| 168 | + | return "", fmt.Errorf("matrix combination %d out of range (%d combinations)", index, len(s.Combos)) | |
| 169 | + | } | |
| 170 | + | name, err := template.EvaluateString(ctx, j.Name, env) | |
| 171 | + | if err != nil { | |
| 172 | + | return "", err | |
| 173 | + | } | |
| 174 | + | if !s.HasMatrix || (j.HasName && usesMatrix(j.Name)) { | |
| 175 | + | return name, nil | |
| 176 | + | } | |
| 177 | + | return s.Combos[index].DisplayName(name), nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // usesMatrix reports whether an expression in t reads the matrix context. | |
| 181 | + | func usesMatrix(t template.Token) bool { | |
| 182 | + | found := false | |
| 183 | + | template.Walk(t, func(x template.Token) bool { | |
| 184 | + | if be, ok := x.(*template.BasicExpression); ok && be.UsesContext("matrix") { | |
| 185 | + | found = true | |
| 186 | + | } | |
| 187 | + | return !found | |
| 188 | + | }) | |
| 189 | + | return found | |
| 190 | + | } | |
A workflow/testdata/SOURCE +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | convert.json is the output of @actions/workflow-parser 0.3.61 | |
| 2 | + | (https://github.com/actions/languageservices, MIT, Copyright GitHub, Inc.) on | |
| 3 | + | the workflow files in convert/, which are written for this repository. It is | |
| 4 | + | generated by ../gen/convert.mjs. See ../../THIRD_PARTY.md. | |
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 | |
A workflow/testdata/convert/alias_scalar.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | x: &v x | |
| 3 | + | jobs: | |
| 4 | + | a: | |
| 5 | + | runs-on: *v | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/background_steps.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - run: a | |
| 7 | + | background: true | |
| 8 | + | - wait: a | |
A workflow/testdata/convert/bad_cron.yml +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | on: | |
| 2 | + | schedule: | |
| 3 | + | - cron: bad | |
| 4 | + | - cron: '61 * * * *' | |
| 5 | + | - cron: '*/0 * * * *' | |
| 6 | + | - cron: '0 0 * * 7' | |
| 7 | + | - cron: '0 0 * jan-mar mon' | |
| 8 | + | - cron: '0 0 * mar-jan mon' | |
| 9 | + | - cron: '* * * * * *' | |
| 10 | + | - cron: '1-5/2 * * * *' | |
| 11 | + | - cron: '*/5 */2 1,15 * *' | |
| 12 | + | - cron: '5 4 * * sun' | |
| 13 | + | - timezone: UTC | |
| 14 | + | - cron: '0 0 * * *' | |
| 15 | + | timezone: Europe/Paris | |
| 16 | + | extra: 1 | |
| 17 | + | jobs: | |
| 18 | + | a: | |
| 19 | + | runs-on: x | |
| 20 | + | steps: [{run: a}] | |
A workflow/testdata/convert/bad_if.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | if: github.ref == 1 && | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/bad_job_ids.yml +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | 1a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | __b: | |
| 7 | + | runs-on: x | |
| 8 | + | steps: [{run: a}] | |
| 9 | + | a b: | |
| 10 | + | runs-on: x | |
| 11 | + | steps: [{run: a}] | |
A workflow/testdata/convert/bad_needs_cycle.yml +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | needs: [b] | |
| 6 | + | steps: [{run: a}] | |
| 7 | + | b: | |
| 8 | + | runs-on: x | |
| 9 | + | needs: a | |
| 10 | + | steps: [{run: a}] | |
A workflow/testdata/convert/bad_needs_cycle_with_root.yml +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | r: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | a: | |
| 7 | + | runs-on: x | |
| 8 | + | needs: [b] | |
| 9 | + | steps: [{run: a}] | |
| 10 | + | b: | |
| 11 | + | runs-on: x | |
| 12 | + | needs: [a, r] | |
| 13 | + | steps: [{run: a}] | |
A workflow/testdata/convert/bad_needs_self.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | r: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | a: | |
| 7 | + | runs-on: x | |
| 8 | + | needs: [a] | |
| 9 | + | steps: [{run: a}] | |
A workflow/testdata/convert/bad_needs_unknown.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | r: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | a: | |
| 7 | + | runs-on: x | |
| 8 | + | needs: [zz, r] | |
| 9 | + | steps: [{run: a}] | |
A workflow/testdata/convert/bad_step_id.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, id: 1x}, {run: b, id: s},{run: b, id: s}, {run: c, id: __x}, {run: d, id: ''}] | |
A workflow/testdata/convert/bad_yaml_flow.yml +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | on: [push | |
A workflow/testdata/convert/bad_yaml_tab.yml +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: 1 | |
A workflow/testdata/convert/big_ids.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: | |
| 7 | + | runs-on: x | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/bool_scalars.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | continue-on-error: True | |
| 6 | + | timeout-minutes: 0x10 | |
| 7 | + | steps: [{run: true}, {run: 12}, {run: 1.5}, {run: null}, {run: ~}] | |
A workflow/testdata/convert/call_inputs.yml +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | on: | |
| 2 | + | workflow_call: | |
| 3 | + | inputs: | |
| 4 | + | a: | |
| 5 | + | type: string | |
| 6 | + | required: true | |
| 7 | + | default: ${{ github.ref }} | |
| 8 | + | b: | |
| 9 | + | type: boolean | |
| 10 | + | default: 1 | |
| 11 | + | secrets: | |
| 12 | + | s: | |
| 13 | + | required: true | |
| 14 | + | t: | |
| 15 | + | outputs: | |
| 16 | + | o: | |
| 17 | + | value: ${{ jobs.a.outputs.x }} | |
| 18 | + | jobs: | |
| 19 | + | a: | |
| 20 | + | runs-on: x | |
| 21 | + | outputs: {x: 1} | |
| 22 | + | steps: [{run: a}] | |
A workflow/testdata/convert/call_inputs_expr_default.yml +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | on: | |
| 2 | + | workflow_call: | |
| 3 | + | inputs: | |
| 4 | + | b: | |
| 5 | + | type: boolean | |
| 6 | + | default: ${{ github.ref }} | |
| 7 | + | c: | |
| 8 | + | type: string | |
| 9 | + | default: 5 | |
| 10 | + | jobs: | |
| 11 | + | a: | |
| 12 | + | runs-on: x | |
| 13 | + | steps: [{run: a}] | |
A workflow/testdata/convert/comment_doc.yml +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | # only | |
A workflow/testdata/convert/concurrency_bad.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | concurrency: {group: x, bogus: 1} | |
| 3 | + | jobs: | |
| 4 | + | a: | |
| 5 | + | runs-on: x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/concurrency_converter.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | concurrency: {group: x, cancel-in-progress: true, queue: single} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/concurrency_job.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | concurrency: {group: x, cancel-in-progress: 1} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/concurrency_queue.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | concurrency: {group: x, queue: max} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/concurrency_queue_bad.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | concurrency: {group: x, queue: other} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/concurrency_string.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | concurrency: g-${{ github.ref }} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/container_bad.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | container: {image: a, bogus: 1} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/container_converter.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | container: {image: a, env: {A: 1}, ports: [80, '81'], volumes: [x], options: 1, credentials: {username: 1}} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/container_empty_image.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | container: {image: ''} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/container_entrypoint.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | container: {image: a, entrypoint: b} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/container_expr.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | container: ${{ matrix.c }} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/container_no_image.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | container: {env: {A: b}} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/container_string.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | container: node:20 | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/context_always_job_env.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | env: | |
| 6 | + | A: ${{ always() }} | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/context_defaults_run.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | defaults: | |
| 6 | + | run: | |
| 7 | + | working-directory: ${{ steps.x.outputs.y }} | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/context_env_secrets.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | env: | |
| 6 | + | A: ${{ secrets.x }} | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/context_job_name.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | name: ${{ steps.x.outputs.y }} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/context_job_outputs.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | outputs: | |
| 6 | + | a: ${{ steps.x.outputs.y }} | |
| 7 | + | b: ${{ secrets.z }} | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/context_outputs_hash.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | outputs: | |
| 6 | + | a: ${{ hashFiles('x') }} | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/context_run_name.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | run-name: ${{ matrix.x }} | |
| 3 | + | jobs: | |
| 4 | + | a: | |
| 5 | + | runs-on: x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/context_runs_on_secrets.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | runs-on: ${{ secrets.x }} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/context_step_run.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: 'echo ${{ matrix.a }} ${{ secrets.b }} ${{ job.status }} ${{ runner.os }} ${{ steps.x.outputs.y }} ${{ hashFiles("a") }} ${{ vars.q }} ${{ inputs.i }} ${{ env.x }}'}] | |
A workflow/testdata/convert/context_success_in_run.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: '${{ success() }}'}] | |
A workflow/testdata/convert/context_unknown_func.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: '${{ nope(1) }}'}] | |
A workflow/testdata/convert/context_workflow_env.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | env: | |
| 3 | + | A: ${{ matrix.x }} | |
| 4 | + | jobs: | |
| 5 | + | a: | |
| 6 | + | runs-on: x | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/credentials_bad.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | container: {image: a, credentials: {username: u, token: t}} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/cron_only_bad.yml +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | on: | |
| 2 | + | schedule: | |
| 3 | + | - cron: bad | |
| 4 | + | - cron: '61 * * * *' | |
| 5 | + | - cron: '*/0 * * * *' | |
| 6 | + | - cron: '0 0 * * 7' | |
| 7 | + | - cron: '0 0 * jan-mar mon' | |
| 8 | + | - cron: '0 0 * mar-jan mon' | |
| 9 | + | - cron: '* * * * * *' | |
| 10 | + | - cron: '1-5/2 * * * *' | |
| 11 | + | - cron: '*/5 */2 1,15 * *' | |
| 12 | + | - cron: '5 4 * * sun' | |
| 13 | + | - cron: '0 0 * * *' | |
| 14 | + | - cron: ' 0 0 * * *' | |
| 15 | + | - cron: '1,,2 * * * *' | |
| 16 | + | - cron: '*/5/2 * * * *' | |
| 17 | + | - cron: '1-2-3 * * * *' | |
| 18 | + | - cron: '*-5 * * * *' | |
| 19 | + | - cron: '5-1 * * * *' | |
| 20 | + | - cron: '0 0 1-31/2 * *' | |
| 21 | + | - cron: '0 0 * * MON' | |
| 22 | + | - cron: '0 0 * Feb *' | |
| 23 | + | jobs: | |
| 24 | + | a: | |
| 25 | + | runs-on: x | |
| 26 | + | steps: [{run: a}] | |
A workflow/testdata/convert/description_key.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | description: hi | |
| 3 | + | jobs: | |
| 4 | + | a: | |
| 5 | + | runs-on: x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/dispatch_inputs.yml +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | on: | |
| 2 | + | workflow_dispatch: | |
| 3 | + | inputs: | |
| 4 | + | a: | |
| 5 | + | type: choice | |
| 6 | + | b: | |
| 7 | + | type: string | |
| 8 | + | options: [x] | |
| 9 | + | c: | |
| 10 | + | type: boolean | |
| 11 | + | default: foo | |
| 12 | + | d: | |
| 13 | + | type: environment | |
| 14 | + | e: | |
| 15 | + | description: x | |
| 16 | + | required: true | |
| 17 | + | default: y | |
| 18 | + | f: | |
| 19 | + | type: number | |
| 20 | + | jobs: | |
| 21 | + | a: | |
| 22 | + | runs-on: x | |
| 23 | + | steps: [{run: a}] | |
A workflow/testdata/convert/dup_job_ids_case.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | A: | |
| 7 | + | runs-on: x | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/dup_keys.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | on: push | |
| 2 | + | on: pull_request | |
| 3 | + | Name: a | |
| 4 | + | name: b | |
| 5 | + | jobs: | |
| 6 | + | a: | |
| 7 | + | runs-on: x | |
| 8 | + | runs-on: y | |
| 9 | + | steps: [{run: a}] | |
A workflow/testdata/convert/empty_doc.yml +0 -0
no textual change.
A workflow/testdata/convert/env_nonmap_converter.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | env: x | |
| 3 | + | jobs: | |
| 4 | + | a: | |
| 5 | + | runs-on: x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/environment_converter.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | environment: {name: a, deployment: 1} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/environment_forms.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | environment: prod | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/environment_map.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | environment: {name: prod, url: 'https://x', deployment: false} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/environment_no_name.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | environment: {url: x} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/expr_empty.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - run: echo ${{ }} | |
A workflow/testdata/convert/expr_in_key_env.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | env: | |
| 6 | + | ${{ matrix.k }}: v | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/expr_insert_embedded.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | strategy: | |
| 6 | + | matrix: | |
| 7 | + | a: x ${{ insert }} | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/expr_insert_key.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - name: ${{ insert }} | |
| 7 | + | run: x | |
A workflow/testdata/convert/expr_insert_matrix.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | strategy: | |
| 6 | + | matrix: | |
| 7 | + | ${{ insert }}: ${{ fromJSON(needs.x.outputs.m) }} | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/expr_insert_params.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | strategy: | |
| 6 | + | matrix: | |
| 7 | + | ${{ insert x }}: 1 | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/expr_key_not_allowed.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | outputs: | |
| 6 | + | ${{ github.ref }}: 1 | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/expr_literal.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: "${{ '${{ x }}' }}"}] | |
A workflow/testdata/convert/expr_multi.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - run: | | |
| 7 | + | echo ${{ github.ref }} | |
| 8 | + | echo ${{ foo.bar }} | |
| 9 | + | echo ${{ 1 + }} | |
A workflow/testdata/convert/expr_not_closed.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - run: echo ${{ 1 | |
A workflow/testdata/convert/expr_string_braces.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: "echo '${{ format('{0}}}', 1) }}'"}] | |
A workflow/testdata/convert/if_bad_arity.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: c, if: failure(1)}] | |
A workflow/testdata/convert/if_job_ctx.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | if: steps.a.outputs.x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/if_step_job_funcs.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | if: success(1) | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/if_wrapping.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | if: github.ref == 'x' | |
| 6 | + | steps: [{run: a, if: "hashFiles('a')"}, {run: b, if: "${{ always() }}"}, {run: c, if: failure()}, {run: d, if: ''}, {run: e, if: '!cancelled()'}, {run: f, if: "contains(github.ref, 'a') || success()"}, {run: g, if: "toJSON(success())"}, {run: h, if: true}, {run: i, if: "${{ github.ref }} == 'x'"}] | |
A workflow/testdata/convert/if_wrapping_nonstring.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | if: 5 | |
| 6 | + | steps: [{run: a, if: null}, {run: b, if: false}] | |
A workflow/testdata/convert/inputs_converter_errors.yml +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | on: | |
| 2 | + | workflow_dispatch: | |
| 3 | + | inputs: | |
| 4 | + | a: | |
| 5 | + | type: choice | |
| 6 | + | options: [] | |
| 7 | + | b: | |
| 8 | + | type: string | |
| 9 | + | options: [x] | |
| 10 | + | c: | |
| 11 | + | type: boolean | |
| 12 | + | default: foo | |
| 13 | + | d: | |
| 14 | + | type: string | |
| 15 | + | default: 5 | |
| 16 | + | e: | |
| 17 | + | type: number | |
| 18 | + | default: 'x' | |
| 19 | + | f: | |
| 20 | + | type: boolean | |
| 21 | + | default: ${{ github.ref }} | |
| 22 | + | jobs: | |
| 23 | + | a: | |
| 24 | + | runs-on: x | |
| 25 | + | steps: [{run: a}] | |
A workflow/testdata/convert/job_env_nonmap.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | env: x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/job_name_empty.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | name: '' | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/job_name_expr.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | name: x-${{ matrix.a }} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/job_not_map.yml +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: x | |
A workflow/testdata/convert/jobs_dropped_job.yml +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | b: | |
| 7 | + | runs-on: x | |
| 8 | + | env: x | |
| 9 | + | outputs: y | |
| 10 | + | steps: [{run: a}] | |
| 11 | + | c: | |
| 12 | + | needs: b | |
| 13 | + | runs-on: x | |
| 14 | + | steps: [{run: a}] | |
A workflow/testdata/convert/jobs_empty.yml +2 -0
| @@ -0,0 +1,2 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: {} | |
A workflow/testdata/convert/jobs_not_map.yml +2 -0
| @@ -0,0 +1,2 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: [a] | |
A workflow/testdata/convert/multiline_folded.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - run: >- | |
| 7 | + | a ${{ bar }} | |
| 8 | + | b | |
A workflow/testdata/convert/multiline_plain.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - run: a | |
| 7 | + | ${{ bar }} | |
A workflow/testdata/convert/needs_bad_type.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | needs: 3 | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/needs_case.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | b: | |
| 7 | + | needs: [A] | |
| 8 | + | runs-on: x | |
| 9 | + | steps: [{run: a}] | |
A workflow/testdata/convert/needs_dup.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | b: | |
| 7 | + | needs: [a, a] | |
| 8 | + | runs-on: x | |
| 9 | + | steps: [{run: a}] | |
A workflow/testdata/convert/needs_empty.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | needs: [] | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/needs_string.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | b: | |
| 7 | + | needs: a | |
| 8 | + | runs-on: x | |
| 9 | + | steps: [{run: a}] | |
A workflow/testdata/convert/no_jobs.yml +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | on: push | |
A workflow/testdata/convert/no_on.yml +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | jobs: | |
| 2 | + | a: | |
| 3 | + | runs-on: x | |
| 4 | + | steps: [{run: a}] | |
A workflow/testdata/convert/no_runs_on.yml +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | steps: [{run: a}] | |
A workflow/testdata/convert/no_steps.yml +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
A workflow/testdata/convert/null_doc.yml +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | ~ | |
A workflow/testdata/convert/on_bad_type.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: 3 | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_call_secrets.yml +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | on: | |
| 2 | + | workflow_call: | |
| 3 | + | secrets: | |
| 4 | + | a: | |
| 5 | + | description: x | |
| 6 | + | required: 3 | |
| 7 | + | jobs: | |
| 8 | + | a: | |
| 9 | + | runs-on: x | |
| 10 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_dispatch_input_unknown_key.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | on: | |
| 2 | + | workflow_dispatch: | |
| 3 | + | inputs: | |
| 4 | + | a: | |
| 5 | + | bogus: 1 | |
| 6 | + | jobs: | |
| 7 | + | a: | |
| 8 | + | runs-on: x | |
| 9 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_event_non_mapping.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: | |
| 2 | + | push: x | |
| 3 | + | jobs: | |
| 4 | + | a: | |
| 5 | + | runs-on: x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_expression.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: ${{ x }} | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_filter_empty_item.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: | |
| 2 | + | push: | |
| 3 | + | branches: [''] | |
| 4 | + | jobs: | |
| 5 | + | a: | |
| 6 | + | runs-on: x | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_filter_item_types.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: | |
| 2 | + | push: | |
| 3 | + | branches: [1, a] | |
| 4 | + | paths: [[x]] | |
| 5 | + | jobs: | |
| 6 | + | a: | |
| 7 | + | runs-on: x | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_filter_types.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: | |
| 2 | + | push: | |
| 3 | + | branches: 1 | |
| 4 | + | jobs: | |
| 5 | + | a: | |
| 6 | + | runs-on: x | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_filters_both.yml +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | on: | |
| 2 | + | push: | |
| 3 | + | branches: [a] | |
| 4 | + | branches-ignore: [b] | |
| 5 | + | tags: v* | |
| 6 | + | tags-ignore: [x] | |
| 7 | + | paths: ['**.go'] | |
| 8 | + | paths-ignore: [] | |
| 9 | + | jobs: | |
| 10 | + | a: | |
| 11 | + | runs-on: x | |
| 12 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_forms.yml +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | on: | |
| 2 | + | push: | |
| 3 | + | pull_request: {} | |
| 4 | + | issues: | |
| 5 | + | types: opened | |
| 6 | + | jobs: | |
| 7 | + | a: | |
| 8 | + | runs-on: x | |
| 9 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_schedule_non_seq.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: | |
| 2 | + | schedule: x | |
| 3 | + | jobs: | |
| 4 | + | a: | |
| 5 | + | runs-on: x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_unknown_event.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: [push, bogus] | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
A workflow/testdata/convert/on_unknown_key.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: | |
| 2 | + | push: | |
| 3 | + | foo: 1 | |
| 4 | + | jobs: | |
| 5 | + | a: | |
| 6 | + | runs-on: x | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/outputs_nonmap.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | outputs: x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/permissions_bad.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | permissions: | |
| 3 | + | bogus: read | |
| 4 | + | contents: maybe | |
| 5 | + | jobs: | |
| 6 | + | a: | |
| 7 | + | runs-on: x | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/permissions_forms.yml +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | on: push | |
| 2 | + | permissions: read-all | |
| 3 | + | jobs: | |
| 4 | + | a: | |
| 5 | + | runs-on: x | |
| 6 | + | permissions: | |
| 7 | + | contents: write | |
| 8 | + | packages: none | |
| 9 | + | id-token: read | |
| 10 | + | steps: [{run: a}] | |
A workflow/testdata/convert/quoted_multiline.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - run: "a\n${{ bar }}" | |
A workflow/testdata/convert/reusable_needs.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: x | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | b: | |
| 7 | + | needs: a | |
| 8 | + | uses: ./x.yml | |
A workflow/testdata/convert/reusable_ok.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | uses: org/repo/.github/workflows/x.yml@v1 | |
| 5 | + | with: | |
| 6 | + | a: 1 | |
| 7 | + | secrets: | |
| 8 | + | s: ${{ secrets.t }} | |
A workflow/testdata/convert/reusable_with_runs_on.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | uses: ./x.yml | |
| 5 | + | runs-on: x | |
A workflow/testdata/convert/reusable_with_steps.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | uses: ./x.yml | |
| 5 | + | steps: [] | |
A workflow/testdata/convert/runs_on_bad.yml +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: [[a]] | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | b: | |
| 7 | + | runs-on: 3 | |
| 8 | + | steps: [{run: a}] | |
| 9 | + | c: | |
| 10 | + | runs-on: '' | |
| 11 | + | steps: [{run: a}] | |
A workflow/testdata/convert/runs_on_converter_labels.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: {labels: [[a]]} | |
| 5 | + | steps: [{run: a}] | |
A workflow/testdata/convert/runs_on_forms.yml +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: {group: g, labels: [a, b]} | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | b: | |
| 7 | + | runs-on: {labels: c} | |
| 8 | + | steps: [{run: a}] | |
| 9 | + | c: | |
| 10 | + | runs-on: [a, '${{ matrix.x }}'] | |
| 11 | + | steps: [{run: a}] | |
A workflow/testdata/convert/runs_on_group_bad.yml +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: {group: foo/bar/baz} | |
| 5 | + | steps: [{run: a}] | |
| 6 | + | b: | |
| 7 | + | runs-on: {group: org/} | |
| 8 | + | steps: [{run: a}] | |
| 9 | + | c: | |
| 10 | + | runs-on: {group: bogus/x} | |
| 11 | + | steps: [{run: a}] | |
| 12 | + | d: | |
| 13 | + | runs-on: {group: org/x} | |
| 14 | + | steps: [{run: a}] | |
A workflow/testdata/convert/scalar_doc.yml +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | hello | |
A workflow/testdata/convert/seq_doc.yml +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | [1,2] | |
A workflow/testdata/convert/services_bad.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | services: {s: {image: a, bogus: 1}} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/services_not_map.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | services: [a] | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/services_ok.yml +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | services: | |
| 6 | + | s: | |
| 7 | + | image: a | |
| 8 | + | entrypoint: b | |
| 9 | + | command: c | |
| 10 | + | options: --x | |
| 11 | + | volumes: [a:b] | |
| 12 | + | steps: [{run: a}] | |
A workflow/testdata/convert/snapshot.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | snapshot: img | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/step_both.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - run: a | |
| 7 | + | uses: b | |
A workflow/testdata/convert/step_coe_bad.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, continue-on-error: yes}] | |
A workflow/testdata/convert/step_coe_expr.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, continue-on-error: '${{ true }}'}] | |
A workflow/testdata/convert/step_continue_on_error_num.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, continue-on-error: 1}] | |
A workflow/testdata/convert/step_empty_uses.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - uses: '' | |
A workflow/testdata/convert/step_env_nonmap.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, env: x}] | |
A workflow/testdata/convert/step_id_100.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}, {run: a, id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}] | |
A workflow/testdata/convert/step_ids_only_bad.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, id: 1x}, {run: b, id: s},{run: b, id: s}, {run: c, id: __x}, {run: d, id: S}, {run: e, id: 'a b'}, {run: f, id: x.y}] | |
A workflow/testdata/convert/step_neither.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: | |
| 6 | + | - name: a | |
A workflow/testdata/convert/step_run_names_collide.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, id: __run}, {run: b, id: _run}, {run: c}, {run: d}, {uses: _x/_y@v1}] | |
A workflow/testdata/convert/step_shell_empty.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, shell: ''}] | |
A workflow/testdata/convert/step_wd_empty.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a, working-directory: ''}] | |
A workflow/testdata/convert/step_with_nonstring.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{uses: a/b@v1, with: {a: 1, b: true, c: [1]}}] | |
A workflow/testdata/convert/steps_empty_list.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [] | |
A workflow/testdata/convert/steps_not_seq.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: a | |
A workflow/testdata/convert/strategy_bad_key.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | strategy: | |
| 6 | + | bogus: 1 | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/strategy_ctx.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | strategy: | |
| 6 | + | matrix: | |
| 7 | + | a: ${{ steps.x.outputs.y }} | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/strategy_matrix_bad.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | strategy: | |
| 6 | + | matrix: | |
| 7 | + | os: foo | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/strategy_matrix_expr.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | strategy: | |
| 6 | + | matrix: ${{ fromJSON(needs.a.outputs.m) }} | |
| 7 | + | steps: [{run: a}] | |
A workflow/testdata/convert/strategy_matrix_ok.yml +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | strategy: | |
| 6 | + | matrix: | |
| 7 | + | a: [1, 2] | |
| 8 | + | include: | |
| 9 | + | - a: 3 | |
| 10 | + | exclude: [] | |
| 11 | + | steps: [{run: a}] | |
A workflow/testdata/convert/timeout_expr.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | timeout-minutes: ${{ matrix.t }} | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/timeout_str.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | timeout-minutes: abc | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/two_docs.yml +4 -0
| @@ -0,0 +1,4 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: {} | |
| 3 | + | --- | |
| 4 | + | x: 1 | |
A workflow/testdata/convert/unicode.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | name: héllo ${{ github.ref }} 日本 | |
| 5 | + | runs-on: x | |
| 6 | + | steps: [{run: 'é${{ foo }}'}] | |
A workflow/testdata/convert/unknown_job_key.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | bogus: 1 | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/unknown_top_key.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | bogus: 1 | |
| 3 | + | jobs: | |
| 4 | + | a: | |
| 5 | + | runs-on: x | |
| 6 | + | steps: [{run: a}] | |
A workflow/testdata/convert/uses_ids.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{uses: actions/checkout@v4}, {uses: ./x}, {uses: docker://alpine:3}, {run: a}, {run: b}, {uses: actions/checkout@v4}, {uses: a}, {uses: 'a/b/c@v1'}, {uses: '$/x'}, {uses: 'a@b@c'}, {uses: '/a/b@v'}, {uses: '.\x'}, {run: 1, id: __run}] | |
A workflow/testdata/convert/valid_full.yml +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | name: CI | |
| 2 | + | run-name: Run ${{ github.actor }} | |
| 3 | + | on: | |
| 4 | + | push: | |
| 5 | + | branches: [main, 'release/**'] | |
| 6 | + | tags: ['v*'] | |
| 7 | + | paths: ['src/**'] | |
| 8 | + | pull_request: | |
| 9 | + | branches-ignore: [wip] | |
| 10 | + | schedule: | |
| 11 | + | - cron: '30 5,17 * * 1-5' | |
| 12 | + | workflow_dispatch: | |
| 13 | + | inputs: | |
| 14 | + | level: | |
| 15 | + | type: choice | |
| 16 | + | options: [a, b] | |
| 17 | + | default: a | |
| 18 | + | env: | |
| 19 | + | A: 1 | |
| 20 | + | B: ${{ github.ref }} | |
| 21 | + | defaults: | |
| 22 | + | run: | |
| 23 | + | shell: bash | |
| 24 | + | working-directory: ./x | |
| 25 | + | permissions: | |
| 26 | + | contents: read | |
| 27 | + | id-token: write | |
| 28 | + | concurrency: | |
| 29 | + | group: ${{ github.workflow }}-${{ github.ref }} | |
| 30 | + | cancel-in-progress: true | |
| 31 | + | jobs: | |
| 32 | + | build: | |
| 33 | + | name: Build ${{ matrix.os }} | |
| 34 | + | runs-on: ${{ matrix.os }} | |
| 35 | + | timeout-minutes: 10 | |
| 36 | + | strategy: | |
| 37 | + | fail-fast: false | |
| 38 | + | max-parallel: 2 | |
| 39 | + | matrix: | |
| 40 | + | os: [ubuntu-latest, macos-latest] | |
| 41 | + | node: [18, 20] | |
| 42 | + | include: | |
| 43 | + | - os: ubuntu-latest | |
| 44 | + | extra: yes | |
| 45 | + | exclude: | |
| 46 | + | - os: macos-latest | |
| 47 | + | node: 18 | |
| 48 | + | outputs: | |
| 49 | + | v: ${{ steps.s.outputs.v }} | |
| 50 | + | env: | |
| 51 | + | X: ${{ matrix.node }} | |
| 52 | + | steps: | |
| 53 | + | - uses: actions/checkout@v4 | |
| 54 | + | with: | |
| 55 | + | fetch-depth: 0 | |
| 56 | + | - id: s | |
| 57 | + | run: echo "v=1" >> "$GITHUB_OUTPUT" | |
| 58 | + | shell: bash | |
| 59 | + | working-directory: sub | |
| 60 | + | if: github.ref == 'refs/heads/main' | |
| 61 | + | continue-on-error: ${{ matrix.experimental }} | |
| 62 | + | timeout-minutes: 3 | |
| 63 | + | env: | |
| 64 | + | Y: ${{ steps.s.outputs.v }} | |
| 65 | + | - run: echo | |
| 66 | + | if: always() | |
| 67 | + | test: | |
| 68 | + | needs: build | |
| 69 | + | runs-on: [self-hosted, linux] | |
| 70 | + | if: ${{ needs.build.result == 'success' }} | |
| 71 | + | container: | |
| 72 | + | image: node:20 | |
| 73 | + | env: {A: b} | |
| 74 | + | ports: ['80'] | |
| 75 | + | services: | |
| 76 | + | db: | |
| 77 | + | image: postgres | |
| 78 | + | credentials: {username: u, password: p} | |
| 79 | + | steps: | |
| 80 | + | - run: echo ${{ needs.build.outputs.v }} | |
| 81 | + | deploy: | |
| 82 | + | needs: [build, test] | |
| 83 | + | uses: ./.github/workflows/d.yml | |
| 84 | + | with: | |
| 85 | + | a: ${{ needs.build.outputs.v }} | |
| 86 | + | secrets: inherit | |
A workflow/testdata/convert/valid_minimal.yml +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | steps: [{run: a}] | |
A workflow/testdata/convert/workflow_defaults_bad.yml +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | on: push | |
| 2 | + | defaults: | |
| 3 | + | run: | |
| 4 | + | bogus: 1 | |
| 5 | + | jobs: | |
| 6 | + | a: | |
| 7 | + | runs-on: x | |
| 8 | + | steps: [{run: a}] | |
A workflow/testdata/convert/yes_no.yml +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | on: push | |
| 2 | + | jobs: | |
| 3 | + | a: | |
| 4 | + | runs-on: ubuntu-latest | |
| 5 | + | continue-on-error: yes | |
| 6 | + | steps: [{run: a}] | |