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 | # Third-party material | 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 | ## actions/languageservices | 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 | module bnema.dev/ermite-actions | 1 | module bnema.dev/ermite-actions | ||
| 2 | 2 | ||||
| 3 | go 1.27.1 | 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}] | |||