detached b6c4c3a

Merge pull request 'feat(workflow): workflow schema, model, filters and matrix' (#2) from feat/workflow into main

commit
b6c4c3a2f7ffc65ae70a74867aa9d8e4c443b118
author
bnema · · 2026-10-12 16:57 UTC
merge of
0925bf3 + a52e9b5 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
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 @@
11 # Third-party material
22
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.
44
55 ## actions/languageservices
66
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 @@
11 module bnema.dev/ermite-actions
22
33 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}]

Keyboard shortcuts

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

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

Branches 1

Tags 3

↑↓ moveenter openesc close