detached 4f9c11b

Merge pull request 'feat(action): parse action.yml like GitHub, ParseUses and ParseImage' (#8) from feat/action-manifest into main

commit
4f9c11ba9117d37d48bb0280995151e1d784e6b0
author
bnema · · 2026-10-13 14:12 UTC
committer
brice · · 2026-10-18 14:59 UTC
merge of
28f9d6e + 43e3663 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
tag: v0.2.0
380 files · +17565 -34
M README.md +1 -1
@@ -1,6 +1,6 @@
11 # ermite-actions
22
3-The GitHub Actions workflow language in Go: expressions `${{ }}`, template tokens and the workflow schema, the workflow model, matrices, job graphs and trigger filters. [ermite](https://git.bnema.dev/bnema/ermite) plans runs with it and [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) executes jobs with it.
3+The GitHub Actions workflow language in Go: expressions `${{ }}`, template tokens and the workflow schema, the workflow model, matrices, job graphs and trigger filters, and `action.yml` manifests (`action`) with the `uses:` parser. [ermite](https://git.bnema.dev/bnema/ermite) plans runs with it and [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) executes jobs with it.
44
55 The goal is GitHub's exact behaviour. Semantics and test cases come from GitHub's MIT sources ([actions/languageservices](https://github.com/actions/languageservices), [actions/runner](https://github.com/actions/runner)); see [THIRD_PARTY.md](THIRD_PARTY.md).
66
M THIRD_PARTY.md +4 -2
@@ -1,12 +1,12 @@
11 # Third-party material
22
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.
3+Test fixtures and schemas under `testdata/`, `workflow/testdata/`, `template/schema/`, `action/testdata/` and `action/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
77 https://github.com/actions/languageservices
88
9-Used: `expr/testdata/languageservices/*.json` (the cross-language expression fixtures, commit 4043eda158e16579cc5fb1b0b07a4bce2a76f0b5) and the `@actions/expressions` package that the other `expr` oracle files were generated from.
9+Used: `expr/testdata/languageservices/*.json` (the cross-language expression fixtures, commit 4043eda158e16579cc5fb1b0b07a4bce2a76f0b5) and the `@actions/expressions` package that the other `expr` oracle files were generated from; `action/schema/action-v1.0.min.json` (the action manifest schema, from `@actions/workflow-parser` 0.3.61, same content as `workflow-parser/src/action-v1.0.json` of that commit), the manifests inlined in `workflow-parser/src/actions/action-parser.test.ts` and `languageservice/src/validate-action.test.ts` (`action/testdata/cases/ls_*.yml`), and `action/testdata/strict.json`, generated with the npm package.
1010
1111 ```
1212 MIT License
@@ -35,3 +35,5 @@ SOFTWARE.
3535 ## actions/runner
3636
3737 https://github.com/actions/runner — MIT License, Copyright (c) GitHub, Inc. Same terms as above.
38+
39+Used: `action/testdata/cases/runner_*.yml` (`src/Test/TestData`, commit 5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798), `action/gen/runner/Driver.cs` (derived from `Runner.Worker/ActionManifestManagerLegacy.cs`), and `action/testdata/runner.json` and `uses.json`, which are the output of the runner's own loader built from that commit.
A action/PORTING.md +235 -0
@@ -0,0 +1,235 @@
1+# Porting notes for package action
2+
3+`Parse` reads an `action.yml` the way **the C# runner** does when it loads an
4+action (actions/runner at `5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798`), with the
5+vendored schema of `@actions/workflow-parser` 0.3.61 (MIT, GitHub,
6+actions/languageservices). `Lint` reproduces what that npm package reports.
7+Nothing here was recorded on github.com: phase A of the CI-2 plan has not
8+produced recordings of action manifests yet. What is checked is the runner's
9+own code, run by `gen/runner`, and the npm package's own code, run by
10+`gen/strict.mjs`.
11+
12+## Which loader the runner runs
13+
14+`ActionManifestManagerWrapper.Load` (the only caller) runs
15+`ActionManifestManagerLegacy` and returns its result. The newer
16+`ActionManifestManager` (on `Sdk/WorkflowParser`, the code the npm package is
17+a port of) runs next to it only when the feature flag
18+`actions_runner_compare_workflow_parser` or the environment variable
19+`ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER` is set, and its result is thrown away
20+(`EvaluateAndCompare`). So `Parse` follows the **legacy** loader and the legacy
21+converter `PipelineTemplateConverter.ConvertToSteps`. The two loaders agree on
22+most files and differ on a few (below); the new one is not reproduced.
23+
24+## Checks
25+
26+- `testdata/runner.json`: the answer of the runner's loader on every file of
27+ `testdata/cases` (338 files). `gen/runner/Driver.cs` is
28+ `ActionManifestManagerLegacy.Load/ConvertRuns/ConvertInputs` with what needs
29+ an `IExecutionContext` removed (tracing, the file table, expression values:
30+ none of it changes loading), compiled into a copy of `src/Sdk` with
31+ `gen/runner/build.sh`, which refuses a clone at another commit or with
32+ changes. `TestAgainstRunner` compares the error list (text and order) and the
33+ model (inputs and defaults, deprecations, every field of `runs`, every step's
34+ id, condition, name, env, with, shell, working directory, reference, and the
35+ outputs). The only thing not compared word for word is YamlDotNet's wording of
36+ YAML syntax errors (`While parsing a node, ...`), where yaml.v3's wording is
37+ used; only the shape is compared.
38+- `testdata/uses.json`: the same driver on 68 `uses:` values; `ParseUses` is
39+ compared on error text and on the reference the runner builds
40+ (`TestParseUsesAgainstRunner`).
41+- `testdata/strict.json`: `parseAction` + `convertActionTemplate` of the npm
42+ package on every case; `Lint` is compared on the error list
43+ (`TestLintAgainstReference`). Eleven files are excused, each with its reason
44+ (`knownLintDifferences`).
45+- `FuzzParse` (no panic, an action iff no error, at most ten errors, every step
46+ has an id and a condition) and `FuzzParseUses`.
47+
48+Regenerate with `gen/regen.sh` (`RUNNER_CLONE=<clone>` for the runner part); the
49+diff must be empty. The cases are: the 24 files of the runner's
50+`src/Test/TestData` that are action manifests (`runner_*`), the 96 manifests
51+inlined in the language service tests (`ls_*`: `action-parser.test.ts`,
52+`validate-action.test.ts`), and 218 written for this package. The
53+language service's own expectations (`toBeGreaterThan(0)`) are not ported: the
54+cases are replayed through the reference instead, which gives the messages.
55+
56+## Model
57+
58+`Action` has `Name`, `Description`, `Author`, ordered `Inputs` (with
59+`Description`, `Required`, `Default` as a token, `HasDefault`,
60+`DeprecationMessage`), ordered `Outputs` (with `Value`), and `Runs`:
61+`Kind` (`RunsNode`, `RunsDocker`, `RunsComposite`, `RunsPlugin`), `Using`
62+(lower case), node `Main/Pre/Post`, docker `Image/Entrypoint/PreEntrypoint/
63+PostEntrypoint/Args/Env`, `PreIf/PostIf`, composite `Steps` (`[]*workflow.Step`),
64+`Plugin`. Conditions default to `always()` for node and docker actions
65+(`ActionManifestManagerLegacy.cs`, `preIfToken?.Value ?? "always()"`); they are
66+kept as written, without `success() &&` (the schema's own description of
67+`pre-if` says the same). A composite action's conditions are `always()`.
68+
69+Steps are `workflow.Step`, with two additions to that type:
70+`EnvExpression` and `WithExpression` hold `env: ${{ fromJSON(...) }}` and
71+`with: ${{ ... }}`, which the runner accepts on a step of an action and the
72+workflow converter does not. `Step.If` is the runner's: `success()` when empty,
73+`success() && (cond)` when the condition has no status function; the contexts
74+of `if` are the schema's `step-if` plus `vars` and `needs`, which the runner's
75+converter adds (`s_stepNamedValues`) and the schema lacks.
76+
77+## What the runner does that the other readers do not
78+
79+All verified by `TestAgainstRunner`.
80+
81+- **Schema.** The runner reads `action-root` (all keys but `name`,
82+ `description`, `inputs`, `outputs`, `runs` are accepted, nothing is
83+ required); the language service reads `action-root-strict`. `using` is any
84+ non-empty string for the schema and compared with `OrdinalIgnoreCase` by the
85+ loader: `Node20` and `DOCKER` run on GitHub, the strict schema refuses them.
86+ `branding` is not read at all: `branding: {icon: nope}` loads.
87+- **Errors are exceptions.** After the positioned errors, the exception that
88+ ended the load is printed as `Exception.ToString()` (class name, `.NET`
89+ message format, then a stack trace that is not reproduced): `using: node99`
90+ is `System.ArgumentOutOfRangeException: Specified argument was out of the
91+ range of valid values. (Parameter ''using: node99' is not supported, use
92+ 'docker', 'node12', 'node16', 'node20' or 'node24' instead.')`. `Error.Exception`
93+ holds the class; `Error.Error()` formats it. At most ten errors are kept;
94+ `Failed to load <path>` is the runner's final message (`LoadFailed`).
95+- **Missing runs, using, main, image, steps** each have their text
96+ (`Top level 'runs:' section is required for ...`, `Missing 'using' value. ...`,
97+ `You are using a JavaScript Action but there is not an entry JavaScript file
98+ provided in ...`, `... Container Action but an image is not provided in ...`,
99+ `... composite action but there are no steps provided in ...`), after the
100+ schema's own errors when it has some (`There's not enough info to determine
101+ what you meant. Add one of these properties: ...`).
102+- **YAML.** Anchors and aliases are refused (`Anchors are not currently
103+ supported. Remove the anchor 'x'`); an explicit tag other than `!!bool`,
104+ `!!float`, `!!int`, `!!null` is `Unexpected tag '...'` (the runner's string tag
105+ is `tag:yaml.org,2002:string`, which no document spells, so `!!str` fails);
106+ a tagged quoted scalar, an out-of-range tagged number and a plain `0x`/`0o`
107+ integer over 32 bits (`Int32.TryParse`) have their messages; a second document
108+ is `Expected stream end parse event`, an empty file `Expected document start
109+ parse event`, a collection used as a key `Expected mapping end`. Such an error
110+ stops the read where the runner meets it, so errors found earlier in the
111+ document stay (`template.Context.RunnerYAML`).
112+- **Keys, integers, case.** Mapping keys are resolved like values: `~` is null
113+ (`Unexpected value ''`), `0x10` is the key `16`, `1.0` is `1`, `TRUE` is `true`,
114+ so `0x10` and `16` are duplicates. `0x` and `0o` integers, plain or `!!int`,
115+ are int32 with wraparound (`0xFFFFFFFF` is `-1`, `0x80000000` is
116+ `-2147483648`; over 32 bits is an error, and `!!float` refuses them). Duplicate
117+ keys, input names and step ids compare with .NET's OrdinalIgnoreCase
118+ (`expr.OrdinalFold`): `é` and `É` are the same, `s` and `ſ` are not.
119+- **Depth.** `TemplateMemory(maxDepth: 100)`: `Maximum object depth exceeded`
120+ from the 100th nested collection, the root mapping being the first. Skipped
121+ values (unknown keys) count (`template.Context.MaxDepth`).
122+- **Numbers as strings.** A number read where a string is wanted shows as .NET's
123+ `G15` (`1E+21`, `0.3` for `0.30000000000000004`, `-0`), not as JavaScript's
124+ (`template.DotNetNumberString`).
125+- **Steps** (legacy `ConvertToSteps`): the first token of the wrong type ends the
126+ conversion (`Unexpected type 'StringToken' encountered while reading 'steps'.
127+ ...`) instead of being recorded and skipped; an unknown key in a step is
128+ `Error while reading 'steps item key'. Unexpected value 'k'`; the id check
129+ (`ReferenceNameBuilder`) accepts ids that start with `__`, lets an id of 100
130+ characters or more through, compares ids with OrdinalIgnoreCase, and words its
131+ message `... IDs must start with a letter or '_' and must be less than 100
132+ characters.` (the language service has `and and`); generated ids are
133+ `__run`, `__owner_repo`, `__<image>`, `__self`, `__selfRepository`, made unique
134+ with `_2`, `_3`, ... A step whose `uses` is malformed is recorded as an error
135+ and **kept**, which makes the load fail later with
136+ `System.NullReferenceException` if it has no id (the runner dereferences its
137+ reference); a `$/x@ref` step is dropped instead.
138+- **`uses`.** `Expected format {org}/{repo}[/path]@ref. Actual 'x'` and
139+ `Self-repository references do not support an '@ref' suffix. Actual '$/x@v1'`.
140+ Segments are split on `/` and `\` and empty ones dropped, so `/a/b@c` and
141+ `a//b@c` are `a/b@c`; `docker://` is case-sensitive (`Docker://x` is an error)
142+ although the image of a docker *action* is not (`ParseImage`).
143+
144+## Where the sources disagree
145+
146+1. **Runner (legacy) / runner (new) / language service**, on the same file:
147+ - Step id text: legacy `... '_' and must be ...`; new loader adds
148+ `IDs starting with '__' are reserved.` and refuses them; language service
149+ has `and and` and the reserved check too. `Parse` follows legacy (what runs).
150+ - `$/` steps: legacy generates `__selfRepository`; new generates the same;
151+ language service `__self` (`createActionStepId`).
152+ - Conditions of `runs.pre-if`, `post-if`: the runner reads them as plain
153+ strings and never parses them when loading (an unknown context fails at run
154+ time); the language service parses them with the contexts of `runs-if`
155+ (`Unrecognized named-value: 'foo'`). `pre-if: ${{ always() }}` makes the
156+ runner fail (`Unrecognized function: 'always'`, the schema type there is
157+ `non-empty-string`, which allows no context), while the language service
158+ accepts the schema and flags the markers in its own pass.
159+2. **Schema vs runner's `action_yaml.json`.** Equal in meaning for every
160+ definition `Parse` reads (`action-root` and below); the vendored file adds
161+ `-strict` variants, `branding`, `using`, `runs-if` and descriptions, orders
162+ the `runs` alternatives differently and writes `{"type": x}` where the
163+ runner writes `x`. Checked by diffing the two after removing descriptions.
164+3. **Schema vs plan/task text.** The task lists the composite step keys as
165+ `uses, run, shell, with, env, name, id, if, working-directory,
166+ continue-on-error`. The schema: a `run` step takes `name, id, if, run, shell,
167+ env, continue-on-error, working-directory`; a `uses` step takes `name, id, if,
168+ uses, with, env, continue-on-error`. `shell` and `working-directory` on a
169+ `uses` step and `with` on a `run` step are refused; so is `timeout-minutes`,
170+ although the runner's converter would read it.
171+4. **`required` inputs: the plan vs the runner.** The CI-2 plan says "required
172+ inputs warned like GitHub". The runner has no such warning for an action:
173+ `ActionRunner.cs` warns `Unexpected input(s) 'x', valid inputs are ['a', 'b']`
174+ (only for actions that have a manifest, `entryPoint` and `args` being valid
175+ for docker actions) and `Input 'x' has been deprecated with message: ...`,
176+ and `inputs.<id>.required` is not read by the loader nor checked later (a
177+ `grep` of `Runner.Worker`, `Runner.Common`, `Sdk/WorkflowParser` and
178+ `Sdk/DTPipelines` finds `required` checks only for reusable workflow inputs
179+ and secrets, `WorkflowTemplateConverter.cs:2166,2223`). `Missing required
180+ input` is the language service's editor message
181+ (`validate-action-reference.ts`: `Missing required input \`x\`` / `Missing
182+ required inputs: \`x\`, \`y\``), shown when no `with:` gives the input and it
183+ has no default. `Input.Required` is parsed so that an editor can do the same;
184+ the executor should not warn.
185+5. **Language service TypeScript vs its own schema.** `convertRuns` falls back to
186+ an empty composite when `using` or `main` is missing; the model here follows
187+ the runner (an error instead).
188+6. **`uses` messages.** Runner `{org}/{repo}[/path]@ref`; language service
189+ `{owner}/{repo}[/path]@{ref}`, `'uses' value in action cannot be blank`, and
190+ it rejects `/repo@v4` where the runner accepts `/a/b@c`. `ParseUses` is the
191+ runner's.
192+
193+## Differences from the runner
194+
195+- **Stack traces** are not reproduced.
196+- **YAML syntax errors** have yaml.v3's wording (`file.yml: did not find expected
197+ key at line 5`); YamlDotNet's is `file.yml: (Line: 6, Col: 34, Idx: 122) -
198+ (Line: 6, Col: 44, Idx: 132): While parsing a block mapping, ...`.
199+- **Limits that the runner does not state**: a manifest over `template.MaxFileBytes`
200+ (1 MiB) and a document that expands to more than `template.MaxNodes` nodes are
201+ refused before reading. The runner reads the file with
202+ `TemplateMemory(maxBytes: 10 MiB, maxEvents: 1 000 000)`; those two are not
203+ reproduced.
204+- **Not ported**: what happens after loading (`ActionManager`: choosing between
205+ `action.yml`, `action.yaml` and `Dockerfile`, `Can't find 'action.yml',
206+ 'action.yaml' or 'Dockerfile'`, the `Dockerfile` image check `ParseImage`
207+ reproduces, input defaults evaluated at run time, `Input '<n>' has been
208+ deprecated with message: ...`, composite depth). `ParseImage` and
209+ `Input.DeprecationMessage` carry what is needed.
210+- **Expression errors** come from package `expr`, which follows the runner's
211+ expression engine (`... Located at position N within expression: ...`). `Lint`
212+ strips nothing: the language service's expression messages lack that suffix, so
213+ `TestLintAgainstReference` strips it from ours before comparing.
214+- **`Lint` ordering of schema errors** for files with several (`Required property
215+ is missing` after `Unexpected value`) is the reference's and is replayed.
216+- A mapping key that is a collection: the reference reads `undefined`
217+ (`Unexpected value 'undefined'`); `Lint` reports `Unexpected value ''`
218+ (`knownLintDifferences`). `Parse` is the runner's (`Expected mapping end`).
219+- `Lint` keeps keys as strings, so it differs from the reference on keys that
220+ are not strings: it misses a null key under a loose-key mapping (`inputs`,
221+ `env`), which the reference and the runner reject, and the reference fails on
222+ other non-string keys with `raw.indexOf is not a function`. Fixing the null
223+ key in `Lint` would change how `template` reads workflow files, so it is
224+ left alone.
225+
226+## To verify against recordings
227+
228+- [ ] What github.com prints for a manifest that fails to load (the runner's
229+ messages are the ones above; whether the hosted service wraps or reorders
230+ them is unknown).
231+- [ ] That the hosted runners run the legacy loader (the flag
232+ `actions_runner_compare_workflow_parser` may be set there; its result is
233+ still discarded in this commit).
234+- [ ] `using: node12`/`node16` after the migration to node20 (`HandlerFactory`),
235+ which belongs to the executor.
A action/action_test.go +310 -0
@@ -0,0 +1,310 @@
1+// SPDX-License-Identifier: MIT
2+
3+package action
4+
5+import (
6+ "sort"
7+ "strings"
8+ "testing"
9+
10+ "bnema.dev/ermite-actions/template"
11+)
12+
13+func mustParse(t *testing.T, src string) *Action {
14+ t.Helper()
15+ a, errs := Parse("action.yml", []byte(src))
16+ if len(errs) > 0 {
17+ t.Fatalf("errors: %q", errTexts(errs))
18+ }
19+ return a
20+}
21+
22+func TestModel(t *testing.T) {
23+ a := mustParse(t, `
24+name: Say
25+description: Says things
26+author: me
27+inputs:
28+ Who:
29+ description: the one
30+ required: true
31+ default: world
32+ old:
33+ deprecationMessage: use who
34+ expr:
35+ default: ${{ github.ref }}
36+outputs:
37+ out:
38+ description: the out
39+ value: ${{ steps.a.outputs.r }}
40+branding:
41+ icon: award
42+ color: blue
43+runs:
44+ using: Node20
45+ main: dist/index.js
46+ post: dist/post.js
47+`)
48+ if a.Name != "Say" || a.Description != "Says things" || a.Author != "me" {
49+ t.Errorf("header: %+v", a)
50+ }
51+ if len(a.Inputs) != 3 || a.Inputs[0].Name != "Who" || !a.Inputs[0].Required || a.Inputs[0].Default.String() != "world" || a.Inputs[0].Description != "the one" {
52+ t.Errorf("inputs: %+v", a.Inputs)
53+ }
54+ if in := a.Input("who"); in == nil || in.Name != "Who" {
55+ t.Errorf("Input(who) = %v", in)
56+ }
57+ if in := a.Input("nope"); in != nil {
58+ t.Errorf("Input(nope) = %v", in)
59+ }
60+ if !a.Inputs[1].Deprecated() || a.Inputs[1].DeprecationMessage != "use who" || a.Inputs[1].HasDefault || a.Inputs[1].Default.String() != "" {
61+ t.Errorf("deprecated input: %+v", a.Inputs[1])
62+ }
63+ if got := a.Inputs[2].Default.String(); got != "${{ github.ref }}" || !a.Inputs[2].HasDefault {
64+ t.Errorf("expression default: %q", got)
65+ }
66+ if len(a.Outputs) != 1 || a.Outputs[0].Name != "out" || a.Outputs[0].Value.String() != "${{ steps.a.outputs.r }}" || a.Outputs[0].Description != "the out" {
67+ t.Errorf("outputs: %+v", a.Outputs)
68+ }
69+ r := a.Runs
70+ if r.Kind != RunsNode || r.Using != "node20" || r.Main != "dist/index.js" || r.Post != "dist/post.js" || r.Pre != "" {
71+ t.Errorf("runs: %+v", r)
72+ }
73+ // The runner defaults both conditions to always(), pre or not.
74+ if r.PreIf != "always()" || r.PostIf != "always()" || r.HasPre() || !r.HasPost() {
75+ t.Errorf("conditions: %+v", r)
76+ }
77+}
78+
79+func TestRunsConditions(t *testing.T) {
80+ a := mustParse(t, "runs:\n using: docker\n image: Dockerfile\n pre-entrypoint: p.sh\n pre-if: runner.os == 'Linux'\n post-entrypoint: q.sh\n post-if: failure()\n entrypoint: e.sh\n args: [a, '${{ inputs.x }}']\n env: {K: V}\n")
81+ r := a.Runs
82+ // Conditions are kept as written: the runner does not add success().
83+ if r.PreIf != "runner.os == 'Linux'" || r.PostIf != "failure()" || !r.HasPre() || !r.HasPost() {
84+ t.Errorf("%+v", r)
85+ }
86+ if r.Image != "Dockerfile" || r.Entrypoint != "e.sh" || r.PreEntrypoint != "p.sh" || r.PostEntrypoint != "q.sh" {
87+ t.Errorf("%+v", r)
88+ }
89+ if r.Args == nil || len(r.Args.Items) != 2 || r.Args.Items[1].String() != "${{ inputs.x }}" || r.Env == nil || r.Env.Pairs[0].Value.String() != "V" {
90+ t.Errorf("args/env: %+v %+v", r.Args, r.Env)
91+ }
92+ c := mustParse(t, "runs:\n using: composite\n steps: []\n")
93+ if c.Runs.PreIf != "always()" || c.Runs.PostIf != "always()" || c.Runs.HasPre() || c.Runs.HasPost() {
94+ t.Errorf("composite: %+v", c.Runs)
95+ }
96+}
97+
98+func TestCompositeSteps(t *testing.T) {
99+ a := mustParse(t, `
100+runs:
101+ using: composite
102+ steps:
103+ - run: echo
104+ shell: bash
105+ - uses: actions/checkout@v4
106+ with:
107+ fetch-depth: 0
108+ - id: named
109+ if: always()
110+ run: echo
111+ shell: sh
112+ working-directory: sub
113+ env:
114+ A: ${{ inputs.a }}
115+ continue-on-error: true
116+ name: n
117+ - uses: docker://alpine:3
118+ - uses: ./x
119+ - uses: actions/checkout@v4
120+`)
121+ var ids []string
122+ for _, s := range a.Runs.Steps {
123+ ids = append(ids, s.ID)
124+ }
125+ if got, want := strings.Join(ids, ","), "__run,__actions_checkout,named,__alpine_3,__self,__actions_checkout_2"; got != want {
126+ t.Errorf("ids %s, want %s", got, want)
127+ }
128+ s := a.Runs.Steps[2]
129+ if !s.Explicit || s.If.Expression != "always()" || s.Shell.String() != "bash" && s.Shell.String() != "sh" || s.WorkingDirectory.String() != "sub" || s.Name.String() != "n" || s.ContinueOnError.String() != "true" {
130+ t.Errorf("step: %+v", s)
131+ }
132+ if got := a.Runs.Steps[0].If.Expression; got != "success()" {
133+ t.Errorf("default if %q", got)
134+ }
135+ if got := a.Runs.Steps[1].With.Pairs[0].Value.String(); got != "0" {
136+ t.Errorf("with %q", got)
137+ }
138+}
139+
140+func TestStepIfIsWrappedLikeTheRunner(t *testing.T) {
141+ a := mustParse(t, "runs:\n using: composite\n steps:\n - run: x\n shell: sh\n if: github.ref == 'main'\n - run: y\n shell: sh\n if: ${{ failure() }}\n - run: z\n shell: sh\n if: vars.A == needs.b.result\n")
142+ want := []string{"success() && (github.ref == 'main')", "failure()", "success() && (vars.A == needs.b.result)"}
143+ for i, s := range a.Runs.Steps {
144+ if s.If.Expression != want[i] {
145+ t.Errorf("step %d: %q, want %q", i, s.If.Expression, want[i])
146+ }
147+ }
148+}
149+
150+func TestErrorsAreTheRunners(t *testing.T) {
151+ tests := []struct {
152+ name, src string
153+ want []string
154+ }{
155+ {"unsupported using", "runs:\n using: node99\n main: x\n", []string{
156+ "System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter ''using: node99' is not supported, use 'docker', 'node12', 'node16', 'node20' or 'node24' instead.')"}},
157+ {"missing using", "runs:\n main: x\n", []string{
158+ "System.NotSupportedException: Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20' or 'node24'."}},
159+ {"no main", "runs:\n using: node20\n", []string{
160+ "action.yml (Line: 2, Col: 3): There's not enough info to determine what you meant. Add one of these properties: args, entrypoint, env, image, main, post, post-entrypoint, pre, pre-entrypoint, steps",
161+ "System.ArgumentNullException: Value cannot be null. (Parameter 'You are using a JavaScript Action but there is not an entry JavaScript file provided in action.yml.')"}},
162+ {"no image", "runs:\n using: docker\n", []string{
163+ "action.yml (Line: 2, Col: 3): There's not enough info to determine what you meant. Add one of these properties: args, entrypoint, env, image, main, post, post-entrypoint, pre, pre-entrypoint, steps",
164+ "System.ArgumentNullException: Value cannot be null. (Parameter 'You are using a Container Action but an image is not provided in action.yml.')"}},
165+ {"no steps", "runs:\n using: composite\n", []string{
166+ "action.yml (Line: 2, Col: 3): There's not enough info to determine what you meant. Add one of these properties: args, entrypoint, env, image, main, post, post-entrypoint, pre, pre-entrypoint, steps",
167+ "System.ArgumentNullException: Value cannot be null. (Parameter 'You are using a composite action but there are no steps provided in action.yml.')"}},
168+ {"no runs", "name: a\n", []string{"Top level 'runs:' section is required for action.yml"}},
169+ {"shell required", "runs:\n using: composite\n steps:\n - run: x\n", []string{
170+ "action.yml (Line: 4, Col: 7): Required property is missing: shell"}},
171+ }
172+ for _, tc := range tests {
173+ t.Run(tc.name, func(t *testing.T) {
174+ a, errs := Parse("action.yml", []byte(tc.src))
175+ if a != nil {
176+ t.Fatal("action returned")
177+ }
178+ got := errTexts(errs)
179+ if tc.name == "shell required" {
180+ // The runner follows the schema error with the exception that
181+ // stops the load.
182+ if len(got) != 2 || got[0] != tc.want[0] || !strings.HasPrefix(got[1], "GitHub.DistributedTask.ObjectTemplating.TemplateValidationException: The template is not valid. action.yml (Line: 4, Col: 7): Required property is missing: shell") {
183+ t.Errorf("got %q", got)
184+ }
185+ return
186+ }
187+ if strings.Join(got, "\n") != strings.Join(tc.want, "\n") {
188+ t.Errorf("got %q\nwant %q", got, tc.want)
189+ }
190+ })
191+ }
192+ if got := LoadFailed("action.yml"); got != "Failed to load action.yml" {
193+ t.Errorf("LoadFailed: %q", got)
194+ }
195+}
196+
197+func TestLimits(t *testing.T) {
198+ // Ten errors at most, as the runner keeps.
199+ var b strings.Builder
200+ b.WriteString("runs:\n using: node20\n main: m\n")
201+ for i := 0; i < 25; i++ {
202+ b.WriteString(" k" + string(rune('a'+i)) + ": 1\n")
203+ }
204+ _, errs := Parse("action.yml", []byte(b.String()))
205+ if len(errs) != 10 {
206+ t.Errorf("%d errors, want 10", len(errs))
207+ }
208+ // The runner's TemplateMemory allows 100 nested collections, the root
209+ // mapping being the first: 99 brackets pass, 100 do not (depth_seq_99 and
210+ // depth_seq_100 in the oracle).
211+ deep := func(n int) string {
212+ return "x: " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\nruns:\n using: node20\n main: m\n"
213+ }
214+ if _, errs := Parse("action.yml", []byte(deep(99))); len(errs) != 0 {
215+ t.Errorf("99 levels: %q", errTexts(errs))
216+ }
217+ _, errs = Parse("action.yml", []byte(deep(100)))
218+ if len(errs) != 2 || errs[0].Error() != "action.yml: Maximum object depth exceeded" {
219+ t.Errorf("100 levels: %q", errTexts(errs))
220+ }
221+}
222+
223+func TestLintIsTheLanguageService(t *testing.T) {
224+ // The runner runs this file; the language service refuses it.
225+ src := "icon: x.svg\nruns:\n using: node20\n main: m.js\n"
226+ if _, errs := Parse("action.yml", []byte(src)); len(errs) != 0 {
227+ t.Errorf("Parse: %q", errTexts(errs))
228+ }
229+ got := errTexts(Lint("action.yml", []byte(src)))
230+ want := []string{
231+ "action.yml (Line: 1, Col: 1): Unexpected value 'icon'",
232+ "action.yml (Line: 1, Col: 1): Required property is missing: name",
233+ "action.yml (Line: 1, Col: 1): Required property is missing: description",
234+ }
235+ // Order: the reference reports the unexpected key first, the missing
236+ // properties after the mapping.
237+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
238+ t.Errorf("Lint:\n got %q\nwant %q", got, want)
239+ }
240+ ok := "name: a\ndescription: d\nruns:\n using: node20\n main: m.js\n"
241+ if errs := Lint("action.yml", []byte(ok)); len(errs) != 0 {
242+ t.Errorf("Lint of a valid file: %q", errTexts(errs))
243+ }
244+}
245+
246+func contexts(t *testing.T, tok interface {
247+ Definition() *template.DefinitionInfo
248+}) string {
249+ t.Helper()
250+ d := tok.Definition()
251+ if d == nil {
252+ t.Fatal("token without definition")
253+ }
254+ names := append([]string(nil), d.NamedContexts()...)
255+ for _, f := range d.Functions() {
256+ names = append(names, f.Name+"()")
257+ }
258+ sort.Strings(names)
259+ return strings.Join(names, ",")
260+}
261+
262+// The contexts an expression may use come from the schema, key by key; the
263+// tokens carry them so that a caller can evaluate with the right environment.
264+func TestContextsPerKey(t *testing.T) {
265+ a := mustParse(t, `
266+inputs:
267+ d:
268+ default: ${{ github.ref }}
269+outputs:
270+ o:
271+ value: ${{ steps.a.outputs.x }}
272+runs:
273+ using: composite
274+ steps:
275+ - run: echo ${{ inputs.d }}
276+ shell: sh
277+ env:
278+ A: ${{ matrix.a }}
279+ - uses: a/b@c
280+ with:
281+ k: ${{ steps.a.outputs.x }}
282+`)
283+ const stepCtx = "env,github,hashFiles(),inputs,job,matrix,runner,steps,strategy"
284+ if got, want := contexts(t, a.Inputs[0].Default), "github,hashFiles(),job,matrix,runner,strategy"; got != want {
285+ t.Errorf("input default: %s, want %s", got, want)
286+ }
287+ if got, want := contexts(t, a.Outputs[0].Value), "env,github,inputs,job,matrix,runner,steps,strategy"; got != want {
288+ t.Errorf("output value: %s, want %s", got, want)
289+ }
290+ if got := contexts(t, a.Runs.Steps[0].Run); got != stepCtx {
291+ t.Errorf("run: %s, want %s", got, stepCtx)
292+ }
293+ if got := contexts(t, a.Runs.Steps[0].Env.Pairs[0].Value); got != stepCtx {
294+ t.Errorf("env: %s", got)
295+ }
296+ if got := contexts(t, a.Runs.Steps[1].With.Pairs[0].Value); got != stepCtx {
297+ t.Errorf("with: %s", got)
298+ }
299+ // "if" takes the status functions, and the runner adds vars and needs.
300+ if got, want := contexts(t, a.Runs.Steps[0].If), "always(),cancelled(),env,failure(),github,hashFiles(),inputs,job,matrix,needs,runner,steps,strategy,success(),vars"; got != want {
301+ t.Errorf("if: %s, want %s", got, want)
302+ }
303+ b := mustParse(t, "runs:\n using: docker\n image: Dockerfile\n args: ['${{ inputs.x }}']\n env:\n K: ${{ inputs.y }}\n")
304+ if got := contexts(t, b.Runs.Args.Items[0]); got != "inputs" {
305+ t.Errorf("args: %s", got)
306+ }
307+ if got := contexts(t, b.Runs.Env.Pairs[0].Value); got != "inputs" {
308+ t.Errorf("env: %s", got)
309+ }
310+}
A action/doc.go +33 -0
@@ -0,0 +1,33 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package action reads GitHub Actions action.yml manifests the way GitHub
4+// does: the model it returns is what the runner's ActionManifestManager gets
5+// out of the same file, with the same errors.
6+//
7+// [Parse] follows the C# runner (actions/runner, Runner.Worker/
8+// ActionManifestManagerLegacy.cs, which is what ActionManifestManagerWrapper
9+// runs; the newer ActionManifestManager only runs next to it, behind a
10+// comparison flag, and its result is thrown away). The runner reads the file
11+// with its own schema, action_yaml.json, which is the "action-root" definition
12+// of the schema that this package vendors, action-v1.0.json of the npm package
13+// @actions/workflow-parser 0.3.61 (MIT, GitHub, actions/languageservices): that
14+// definition accepts unknown top-level keys (icon, color, branding...), needs
15+// no name and no description, and checks the keys under "runs" and the
16+// contexts that expressions may use. [ParseStrict] reads the file against the
17+// same schema's "action-root-strict" definition instead, which is the language
18+// service's linter: name, description and runs are required, branding and
19+// "using" are checked and unknown top-level keys are refused. GitHub accepts
20+// files that the strict form refuses; use it to lint, not to run.
21+//
22+// The steps of a composite action are [workflow.Step] values, converted the way
23+// the runner converts them (which is not the way package workflow converts the
24+// steps of a workflow file). Expressions stay unevaluated [template.Token]
25+// values; package template evaluates them.
26+//
27+// Errors read like the runner's: positioned messages ("action.yml (Line: 3,
28+// Col: 5): Unexpected value 'x'") and, after them, the exceptions that the
29+// runner reports with their .NET class name ("System.ArgumentNullException:
30+// ..."). The runner prints exception stack traces after them; those are not
31+// reproduced. PORTING.md lists what differs from the runner and from the
32+// language service, and what was checked against which source.
33+package action
A action/fuzz_test.go +70 -0
@@ -0,0 +1,70 @@
1+// SPDX-License-Identifier: MIT
2+
3+package action
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+)
10+
11+func FuzzParse(f *testing.F) {
12+ files, _ := filepath.Glob("testdata/cases/*.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("name: a\ndescription: d\nruns:\n using: node20\n main: m.js\n"))
19+ f.Add([]byte(""))
20+ f.Add([]byte("\x00\xff"))
21+ f.Add([]byte("runs: &a\n using: *a\n"))
22+ f.Add([]byte("runs:\n using: composite\n steps:\n - uses: $/x@\n"))
23+ f.Fuzz(func(t *testing.T, data []byte) {
24+ a, errs := Parse("action.yml", data)
25+ if (a == nil) == (len(errs) == 0) {
26+ t.Fatalf("action nil = %v with %d errors", a == nil, len(errs))
27+ }
28+ if len(errs) > maxErrors {
29+ t.Fatalf("%d errors, the limit is %d", len(errs), maxErrors)
30+ }
31+ for _, e := range errs {
32+ _ = e.Error()
33+ }
34+ if a != nil {
35+ if a.Runs == nil {
36+ t.Fatal("no runs")
37+ }
38+ for _, s := range a.Runs.Steps {
39+ if s.ID == "" || s.If == nil {
40+ t.Fatalf("step without id or condition: %+v", s)
41+ }
42+ if s.Uses != nil {
43+ _, _ = ParseUses(s.Uses.Value)
44+ }
45+ }
46+ _ = a.Runs.HasPre()
47+ _ = a.Runs.HasPost()
48+ if a.Runs.Kind == RunsDocker {
49+ _, _ = ParseImage(a.Runs.Image)
50+ }
51+ }
52+ _ = Lint("action.yml", data)
53+ })
54+}
55+
56+func FuzzParseUses(f *testing.F) {
57+ for _, s := range []string{"actions/checkout@v4", "./x", "docker://a", "$/a", "a@b@c", "", "//", `\@`, "a/b@"} {
58+ f.Add(s)
59+ }
60+ f.Fuzz(func(t *testing.T, s string) {
61+ u, err := ParseUses(s)
62+ if (err == nil) != (u.Kind != 0) {
63+ t.Fatalf("%q: kind %d with error %v", s, u.Kind, err)
64+ }
65+ if u.Kind == UsesRepository && (u.Owner == "" || u.Repo == "" || u.Ref == "") {
66+ t.Fatalf("%q: %+v", s, u)
67+ }
68+ _, _ = ParseImage(s)
69+ })
70+}
A action/gen/regen.sh +18 -0
@@ -0,0 +1,18 @@
1+#!/bin/sh
2+# SPDX-License-Identifier: MIT
3+#
4+# Regenerates the two files that are the answer of a reference implementation:
5+# action/testdata/strict.json (@actions/workflow-parser, from gen/package-lock.json)
6+# action/testdata/runner.json (the C# runner; needs RUNNER_CLONE, see gen/runner/build.sh)
7+# Run from anywhere; `git diff` afterwards must be empty unless the cases changed.
8+set -eu
9+root=$(cd "$(dirname "$0")/../.." && pwd)
10+cd "$root/gen"
11+npm ci --ignore-scripts --no-audit --no-fund
12+cd "$root"
13+node --import ./gen/register.mjs action/gen/strict.mjs > action/testdata/strict.json
14+if [ -n "${RUNNER_CLONE:-}" ]; then
15+ action/gen/runner/build.sh
16+else
17+ echo "RUNNER_CLONE not set: runner.json left as it is" >&2
18+fi
A action/gen/runner/Driver.cs +422 -0
@@ -0,0 +1,422 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Driver that runs the C# runner's action.yml loader on a directory of files.
4+// It is compiled into a copy of actions/runner src/Sdk (see build.sh), so that
5+// it can use the internal types the runner uses.
6+//
7+// Load, ConvertRuns and ConvertInputs below are
8+// Runner.Worker/ActionManifestManagerLegacy.cs at the commit in
9+// runner-commit.txt, with what needs an IExecutionContext removed (tracing,
10+// the file table, expression values: none of it influences loading) and the
11+// result written to JSON instead of ActionDefinitionData. Nothing else was
12+// changed: the flow of exceptions and errors is the runner's, because it
13+// decides which messages a user sees.
14+//
15+// Why the legacy loader: ActionManifestManagerWrapper.Load runs
16+// ActionManifestManagerLegacy and returns its result; the newer
17+// ActionManifestManager only runs next to it when the feature flag
18+// actions_runner_compare_workflow_parser or the environment variable
19+// ACTIONS_RUNNER_COMPARE_WORKFLOW_PARSER is set, and its result is discarded.
20+
21+using System;
22+using System.Collections.Generic;
23+using System.IO;
24+using System.Linq;
25+using GitHub.DistributedTask.ObjectTemplating;
26+using GitHub.DistributedTask.ObjectTemplating.Schema;
27+using GitHub.DistributedTask.ObjectTemplating.Tokens;
28+using GitHub.DistributedTask.Pipelines;
29+using GitHub.DistributedTask.Pipelines.ObjectTemplating;
30+using Newtonsoft.Json;
31+using Newtonsoft.Json.Linq;
32+
33+namespace ActionOracle
34+{
35+ public static class Program
36+ {
37+ public static int Main(string[] args)
38+ {
39+ if (args.Length != 2 && args.Length != 3)
40+ {
41+ Console.Error.WriteLine("usage: Sdk.dll <action_yaml.json> <dir with *.yml> [<file with one uses value per line>]");
42+ return 2;
43+ }
44+
45+ var schemaJson = File.ReadAllText(args[0]);
46+ var schema = TemplateSchema.Load(new JsonObjectReader(null, schemaJson));
47+ if (args.Length == 3)
48+ {
49+ // One-step composite action per "uses" value (with an id, so that
50+ // a value of the wrong form is reported and nothing else fails):
51+ // the answer is the error list and the reference the runner made.
52+ var uses = new JObject();
53+ foreach (var line in File.ReadAllLines(args[2]))
54+ {
55+ var yaml = "name: a\ndescription: d\nruns:\n using: composite\n steps:\n - uses: " + JsonConvert.ToString(line) + "\n id: x\n";
56+ uses[line] = Load(schema, "u.yml", yaml);
57+ }
58+ Console.Out.Write(uses.ToString(Formatting.Indented));
59+ Console.Out.WriteLine();
60+ return 0;
61+ }
62+
63+ var result = new JObject();
64+ foreach (var file in Directory.GetFiles(args[1], "*.yml").OrderBy(x => x, StringComparer.Ordinal))
65+ {
66+ var name = Path.GetFileName(file);
67+ result[name] = Load(schema, name, File.ReadAllText(file));
68+ }
69+
70+ Console.Out.Write(result.ToString(Formatting.Indented));
71+ Console.Out.WriteLine();
72+ return 0;
73+ }
74+
75+ static JObject Load(TemplateSchema schema, string fileRelativePath, string fileContent)
76+ {
77+ var templateContext = new TemplateContext
78+ {
79+ CancellationToken = System.Threading.CancellationToken.None,
80+ Errors = new TemplateValidationErrors(10, int.MaxValue),
81+ Memory = new TemplateMemory(maxDepth: 100, maxEvents: 1000000, maxBytes: 10 * 1024 * 1024),
82+ Schema = schema,
83+ TraceWriter = new EmptyTraceWriter(),
84+ };
85+
86+ var summary = new JObject();
87+ JObject execution = null;
88+ var inputs = new JArray();
89+ var deprecated = new JObject();
90+
91+ try
92+ {
93+ var token = default(TemplateToken);
94+ var fileId = templateContext.GetFileId(fileRelativePath);
95+ using (var stringReader = new StringReader(fileContent))
96+ {
97+ var yamlObjectReader = new YamlObjectReader(fileId, stringReader);
98+ token = TemplateReader.Read(templateContext, "action-root", yamlObjectReader, fileId, out _);
99+ }
100+
101+ var actionMapping = token.AssertMapping("action manifest root");
102+ var actionOutputs = default(MappingToken);
103+ var actionRunValueToken = default(TemplateToken);
104+
105+ foreach (var actionPair in actionMapping)
106+ {
107+ var propertyName = actionPair.Key.AssertString($"action.yml property key");
108+
109+ switch (propertyName.Value)
110+ {
111+ case "name":
112+ summary["name"] = actionPair.Value.AssertString("name").Value;
113+ break;
114+ case "outputs":
115+ actionOutputs = actionPair.Value.AssertMapping("outputs");
116+ break;
117+ case "description":
118+ summary["description"] = actionPair.Value.AssertString("description").Value;
119+ break;
120+ case "inputs":
121+ ConvertInputs(actionPair.Value, inputs, deprecated);
122+ break;
123+ case "runs":
124+ actionRunValueToken = actionPair.Value;
125+ break;
126+ default:
127+ break;
128+ }
129+ }
130+
131+ if (actionRunValueToken != null)
132+ {
133+ execution = ConvertRuns(templateContext, actionRunValueToken, fileRelativePath, actionOutputs);
134+ }
135+ }
136+ catch (Exception ex)
137+ {
138+ templateContext.Errors.Add(ex);
139+ }
140+
141+ var errors = new JArray();
142+ foreach (var error in templateContext.Errors)
143+ {
144+ // ex.ToString() ends with the stack trace, which names temporary
145+ // paths and line numbers of this driver: keep the message only.
146+ var message = error.Message;
147+ var at = message.IndexOf("\n at ", StringComparison.Ordinal);
148+ errors.Add(at >= 0 ? message.Substring(0, at) : message);
149+ }
150+
151+ if (errors.Count > 0)
152+ {
153+ summary["errors"] = errors;
154+ summary["failed"] = $"Failed to load {fileRelativePath}";
155+ return summary;
156+ }
157+
158+ if (execution == null)
159+ {
160+ summary["errors"] = new JArray($"Top level 'runs:' section is required for {fileRelativePath}");
161+ return summary;
162+ }
163+
164+ summary["inputs"] = inputs;
165+ if (deprecated.Count > 0)
166+ {
167+ summary["deprecated"] = deprecated;
168+ }
169+ summary["execution"] = execution;
170+ return summary;
171+ }
172+
173+ static JObject ConvertRuns(
174+ TemplateContext templateContext,
175+ TemplateToken inputsToken,
176+ String fileRelativePath,
177+ MappingToken outputs = null)
178+ {
179+ var runsMapping = inputsToken.AssertMapping("runs");
180+ var usingToken = default(StringToken);
181+ var imageToken = default(StringToken);
182+ var argsToken = default(SequenceToken);
183+ var entrypointToken = default(StringToken);
184+ var envToken = default(MappingToken);
185+ var mainToken = default(StringToken);
186+ var pluginToken = default(StringToken);
187+ var preToken = default(StringToken);
188+ var preEntrypointToken = default(StringToken);
189+ var preIfToken = default(StringToken);
190+ var postToken = default(StringToken);
191+ var postEntrypointToken = default(StringToken);
192+ var postIfToken = default(StringToken);
193+ var steps = default(List<Step>);
194+
195+ foreach (var run in runsMapping)
196+ {
197+ var runsKey = run.Key.AssertString("runs key").Value;
198+ switch (runsKey)
199+ {
200+ case "using":
201+ usingToken = run.Value.AssertString("using");
202+ break;
203+ case "image":
204+ imageToken = run.Value.AssertString("image");
205+ break;
206+ case "args":
207+ argsToken = run.Value.AssertSequence("args");
208+ break;
209+ case "entrypoint":
210+ entrypointToken = run.Value.AssertString("entrypoint");
211+ break;
212+ case "env":
213+ envToken = run.Value.AssertMapping("env");
214+ break;
215+ case "main":
216+ mainToken = run.Value.AssertString("main");
217+ break;
218+ case "plugin":
219+ pluginToken = run.Value.AssertString("plugin");
220+ break;
221+ case "post":
222+ postToken = run.Value.AssertString("post");
223+ break;
224+ case "post-entrypoint":
225+ postEntrypointToken = run.Value.AssertString("post-entrypoint");
226+ break;
227+ case "post-if":
228+ postIfToken = run.Value.AssertString("post-if");
229+ break;
230+ case "pre":
231+ preToken = run.Value.AssertString("pre");
232+ break;
233+ case "pre-entrypoint":
234+ preEntrypointToken = run.Value.AssertString("pre-entrypoint");
235+ break;
236+ case "pre-if":
237+ preIfToken = run.Value.AssertString("pre-if");
238+ break;
239+ case "steps":
240+ var stepsToken = run.Value.AssertSequence("steps");
241+ steps = PipelineTemplateConverter.ConvertToSteps(templateContext, stepsToken);
242+ templateContext.Errors.Check();
243+ break;
244+ default:
245+ break;
246+ }
247+ }
248+
249+ if (usingToken != null)
250+ {
251+ if (string.Equals(usingToken.Value, "docker", StringComparison.OrdinalIgnoreCase))
252+ {
253+ if (string.IsNullOrEmpty(imageToken?.Value))
254+ {
255+ throw new ArgumentNullException($"You are using a Container Action but an image is not provided in {fileRelativePath}.");
256+ }
257+ else
258+ {
259+ return new JObject
260+ {
261+ ["type"] = "docker",
262+ ["using"] = usingToken.Value,
263+ ["image"] = imageToken.Value,
264+ ["args"] = argsToken == null ? null : new JArray(argsToken.Select(x => x.ToString())),
265+ ["entrypoint"] = entrypointToken?.Value,
266+ ["env"] = envToken == null ? null : Pairs(envToken),
267+ ["pre"] = preEntrypointToken?.Value,
268+ ["preIf"] = preIfToken?.Value ?? "always()",
269+ ["post"] = postEntrypointToken?.Value,
270+ ["postIf"] = postIfToken?.Value ?? "always()",
271+ };
272+ }
273+ }
274+ else if (string.Equals(usingToken.Value, "node12", StringComparison.OrdinalIgnoreCase) ||
275+ string.Equals(usingToken.Value, "node16", StringComparison.OrdinalIgnoreCase) ||
276+ string.Equals(usingToken.Value, "node20", StringComparison.OrdinalIgnoreCase) ||
277+ string.Equals(usingToken.Value, "node24", StringComparison.OrdinalIgnoreCase))
278+ {
279+ if (string.IsNullOrEmpty(mainToken?.Value))
280+ {
281+ throw new ArgumentNullException($"You are using a JavaScript Action but there is not an entry JavaScript file provided in {fileRelativePath}.");
282+ }
283+ else
284+ {
285+ return new JObject
286+ {
287+ ["type"] = "node",
288+ ["using"] = usingToken.Value,
289+ ["main"] = mainToken.Value,
290+ ["pre"] = preToken?.Value,
291+ ["preIf"] = preIfToken?.Value ?? "always()",
292+ ["post"] = postToken?.Value,
293+ ["postIf"] = postIfToken?.Value ?? "always()",
294+ };
295+ }
296+ }
297+ else if (string.Equals(usingToken.Value, "composite", StringComparison.OrdinalIgnoreCase))
298+ {
299+ if (steps == null)
300+ {
301+ throw new ArgumentNullException($"You are using a composite action but there are no steps provided in {fileRelativePath}.");
302+ }
303+ else
304+ {
305+ return new JObject
306+ {
307+ ["type"] = "composite",
308+ ["using"] = usingToken.Value,
309+ ["steps"] = new JArray(steps.Cast<ActionStep>().Select(Summarize)),
310+ ["outputs"] = outputs == null ? null : new JArray(outputs.Select(x => new JArray(x.Key.ToString(), OutputValue(x.Value)))),
311+ };
312+ }
313+ }
314+ else
315+ {
316+ throw new ArgumentOutOfRangeException($"'using: {usingToken.Value}' is not supported, use 'docker', 'node12', 'node16', 'node20' or 'node24' instead.");
317+ }
318+ }
319+ else if (pluginToken != null)
320+ {
321+ return new JObject { ["type"] = "plugin", ["plugin"] = pluginToken.Value };
322+ }
323+
324+ throw new NotSupportedException("Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20' or 'node24'.");
325+ }
326+
327+ static string OutputValue(TemplateToken value)
328+ {
329+ if (value is MappingToken m)
330+ {
331+ foreach (var p in m)
332+ {
333+ if (p.Key.ToString() == "value") return p.Value.ToString();
334+ }
335+ }
336+ return null;
337+ }
338+
339+ static JObject Summarize(ActionStep a)
340+ {
341+ var o = new JObject { ["id"] = a.ContextName, ["if"] = a.Condition };
342+ o["name"] = a.DisplayNameToken?.ToString();
343+ o["continueOnError"] = a.ContinueOnError?.ToString();
344+ o["timeout"] = a.TimeoutInMinutes?.ToString();
345+ if (a.Environment is MappingToken)
346+ {
347+ o["env"] = Pairs(a.Environment);
348+ }
349+ else if (a.Environment != null)
350+ {
351+ o["envExpr"] = a.Environment.ToString();
352+ }
353+ if (a.Reference is ScriptReference)
354+ {
355+ o["kind"] = "run";
356+ var inputs = (MappingToken)a.Inputs;
357+ foreach (var p in inputs)
358+ {
359+ o[p.Key.ToString()] = p.Value.ToString();
360+ }
361+ }
362+ else
363+ {
364+ o["kind"] = "uses";
365+ if (a.Inputs is MappingToken)
366+ {
367+ o["with"] = Pairs(a.Inputs);
368+ }
369+ else if (a.Inputs != null)
370+ {
371+ o["withExpr"] = a.Inputs.ToString();
372+ }
373+ if (a.Reference is ContainerRegistryReference c)
374+ {
375+ o["reference"] = new JObject { ["type"] = "docker", ["image"] = c.Image };
376+ }
377+ else if (a.Reference is RepositoryPathReference r)
378+ {
379+ o["reference"] = new JObject { ["type"] = r.RepositoryType, ["name"] = r.Name, ["ref"] = r.Ref, ["path"] = r.Path };
380+ }
381+ }
382+ return o;
383+ }
384+
385+ static JArray Pairs(TemplateToken t)
386+ {
387+ return t is MappingToken m ? new JArray(m.Select(x => new JArray(x.Key.ToString(), x.Value.ToString()))) : new JArray(t.ToString());
388+ }
389+
390+ static void ConvertInputs(TemplateToken inputsToken, JArray inputs, JObject deprecated)
391+ {
392+ var inputsMapping = inputsToken.AssertMapping("inputs");
393+ foreach (var input in inputsMapping)
394+ {
395+ bool hasDefault = false;
396+ var inputName = input.Key.AssertString("input name");
397+ var inputMetadata = input.Value.AssertMapping("input metadata");
398+ var entry = new JObject { ["name"] = inputName.Value };
399+ foreach (var metadata in inputMetadata)
400+ {
401+ var metadataName = metadata.Key.AssertString("input metadata").Value;
402+ if (string.Equals(metadataName, "default", StringComparison.OrdinalIgnoreCase))
403+ {
404+ hasDefault = true;
405+ entry["default"] = metadata.Value.ToString();
406+ }
407+ else if (string.Equals(metadataName, "deprecationMessage", StringComparison.OrdinalIgnoreCase))
408+ {
409+ var message = metadata.Value.AssertString("input deprecationMessage");
410+ deprecated.Add(inputName.Value, message.Value);
411+ }
412+ }
413+
414+ if (!hasDefault)
415+ {
416+ entry["default"] = string.Empty;
417+ }
418+ inputs.Add(entry);
419+ }
420+ }
421+ }
422+}
A action/gen/runner/README.md +18 -0
@@ -0,0 +1,18 @@
1+# The runner's action.yml loader, as an oracle
2+
3+`Driver.cs` is `ActionManifestManagerLegacy.Load`, `ConvertRuns` and
4+`ConvertInputs` of actions/runner (the loader the runner runs; see
5+`../../PORTING.md`) with what needs an `IExecutionContext` removed, writing its
6+result as JSON. `build.sh` copies `src/Sdk` of a clone at the commit in
7+`runner-commit.txt` to a temporary directory, adds the driver and builds it.
8+
9+ RUNNER_CLONE=~/dev/clones/runner action/gen/runner/build.sh
10+
11+It writes `testdata/runner.json` (the files of `testdata/cases`) and
12+`testdata/uses.json` (each line of `testdata/uses.txt` as the `uses` of a
13+one-step composite action).
14+
15+Needs the .NET SDK (any version that builds `net8.0`; the output was made with
16+SDK 10 and the 9.0 runtime through `DOTNET_ROLL_FORWARD=Major`), network access
17+for NuGet, and a clone that is clean in `src/Sdk`. The driver prints the message
18+of each recorded error without its stack trace.
A action/gen/runner/build.sh +36 -0
@@ -0,0 +1,36 @@
1+#!/bin/sh
2+# SPDX-License-Identifier: MIT
3+#
4+# Builds the runner-oracle for package action and runs it on the files in
5+# ../../testdata/cases, writing ../../testdata/runner.json, and on each line of
6+# ../../testdata/uses.txt as the "uses" of a one-step composite action, writing
7+# ../../testdata/uses.json.
8+#
9+# RUNNER_CLONE=~/dev/clones/runner action/gen/runner/build.sh
10+#
11+# Requirements: a clone of https://github.com/actions/runner at the commit in
12+# runner-commit.txt (checked), the .NET SDK, network access for NuGet
13+# (restoring the runner's packages), and a .NET runtime of version 8 or newer
14+# (the Sdk project targets net8.0; DOTNET_ROLL_FORWARD=Major runs it on a newer
15+# runtime). The clone is copied to a temporary directory, never modified.
16+set -eu
17+here=$(cd "$(dirname "$0")" && pwd)
18+: "${RUNNER_CLONE:?set RUNNER_CLONE to a clone of actions/runner}"
19+want=$(cat "$here/runner-commit.txt")
20+have=$(git -C "$RUNNER_CLONE" rev-parse HEAD)
21+[ "$have" = "$want" ] || { echo "runner clone is at $have, want $want" >&2; exit 1; }
22+git -C "$RUNNER_CLONE" diff --quiet -- src/Sdk src/Runner.Worker/action_yaml.json || { echo "runner clone is modified" >&2; exit 1; }
23+work=$(mktemp -d)
24+trap 'rm -rf "$work"' EXIT
25+cp -r "$RUNNER_CLONE/src/Sdk" "$work/Sdk"
26+cp "$RUNNER_CLONE/src/Directory.Build.props" "$RUNNER_CLONE/src/NuGet.Config" "$work/" 2>/dev/null || true
27+cp "$here/Driver.cs" "$work/Sdk/Driver.cs"
28+(cd "$work/Sdk" && dotnet build Sdk.csproj -c Release -p:OutputType=Exe -p:RuntimeIdentifiers= -v:q -nologo >&2)
29+out="$here/../../testdata/runner.json"
30+DOTNET_ROLL_FORWARD=Major dotnet "$work/Sdk/bin/Release/net8.0/Sdk.dll" \
31+ "$RUNNER_CLONE/src/Runner.Worker/action_yaml.json" "$here/../../testdata/cases" > "$out"
32+echo "wrote $out" >&2
33+out="$here/../../testdata/uses.json"
34+DOTNET_ROLL_FORWARD=Major dotnet "$work/Sdk/bin/Release/net8.0/Sdk.dll" \
35+ "$RUNNER_CLONE/src/Runner.Worker/action_yaml.json" "$here/../../testdata/cases" "$here/../../testdata/uses.txt" > "$out"
36+echo "wrote $out" >&2
A action/gen/runner/runner-commit.txt +1 -0
@@ -0,0 +1,1 @@
1+5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798
A action/gen/strict.mjs +41 -0
@@ -0,0 +1,41 @@
1+// SPDX-License-Identifier: MIT
2+//
3+// Generates testdata/strict.json: for each file in testdata/cases/*.yml, the
4+// errors that @actions/workflow-parser 0.3.61 (MIT, GitHub,
5+// actions/languageservices) reports when it parses the file with parseAction
6+// (schema "action-root-strict") and converts it with convertActionTemplate
7+// (which validates the pre-if and post-if conditions and the step conditions).
8+// strict_test.go replays the answers against ParseStrict.
9+//
10+// action/gen/regen.sh
11+
12+import { pathToFileURL } from "node:url";
13+import path from "node:path";
14+import fs from "node:fs";
15+
16+const dist = path.resolve(process.argv[2] ?? new URL("../../gen/node_modules/@actions/workflow-parser/dist", import.meta.url).pathname);
17+const imp = p => import(pathToFileURL(path.join(dist, p)).href);
18+const { parseAction } = await imp("actions/action-parser.js");
19+const { convertActionTemplate } = await imp("actions/action-template.js");
20+const { NoOperationTraceWriter } = await imp("templates/trace-writer.js");
21+
22+const dir = new URL("../testdata/cases/", import.meta.url);
23+const out = {};
24+for (const f of fs.readdirSync(dir).sort()) {
25+ if (!f.endsWith(".yml")) continue;
26+ const content = fs.readFileSync(new URL(f, dir), "utf8");
27+ const r = parseAction({ name: f, content }, new NoOperationTraceWriter());
28+ const res = { errors: [] };
29+ if (r.value) {
30+ // Default error policy: the conversion only runs on a document without
31+ // schema errors, and adds its own (pre-if, post-if, step conditions).
32+ const t = convertActionTemplate(r.context, r.value);
33+ res.name = t.name; res.description = t.description; res.author = t.author;
34+ res.inputs = (t.inputs || []).map(i => ({ id: i.id, required: i.required, deprecationMessage: i.deprecationMessage, default: i.default === undefined ? undefined : String(i.default) }));
35+ res.outputs = (t.outputs || []).map(o => ({ id: o.id, value: o.value === undefined ? undefined : String(o.value) }));
36+ res.runs = { using: t.runs?.using, main: t.runs?.main, image: t.runs?.image, preIf: t.runs?.preIf, postIf: t.runs?.postIf, steps: (t.runs?.steps || []).length };
37+ }
38+ res.errors = r.context.errors.getErrors().map(e => e.message);
39+ out[f] = res;
40+}
41+console.log(JSON.stringify(out, null, 1));
A action/lint.go +106 -0
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: MIT
2+
3+package action
4+
5+import (
6+ "bnema.dev/ermite-actions/expr"
7+ "bnema.dev/ermite-actions/template"
8+ "bnema.dev/ermite-actions/workflow"
9+)
10+
11+// strictRootType is the language service's definition for an editor.
12+const strictRootType = "action-root-strict"
13+
14+// Lint reports what the language service (@actions/workflow-parser
15+// parseAction and convertActionTemplate, the code behind an editor's
16+// squiggles) finds wrong in an action.yml, and returns no model.
17+//
18+// It reads the file against "action-root-strict": name, description and runs
19+// are required, "using" must be exactly one of docker, node12, node16, node20,
20+// node24 or composite, "branding" is checked, and unknown top-level keys such
21+// as "icon" and "color" and a "plugin" action are refused. GitHub's runner
22+// accepts several of those; [Parse] says what GitHub does, Lint what a linter
23+// should flag. The conversion adds the errors of the conditions: pre-if and
24+// post-if, and the "if" of composite steps, are parsed with the contexts the
25+// schema grants (a step condition may not use vars or needs here, though the
26+// runner lets it).
27+//
28+// The messages are the language service's: positioned, the expression errors
29+// without the runner's ". Located at position" suffix being the one
30+// difference of ours (package expr follows the runner). YAML syntax errors
31+// have yaml.v3's wording.
32+func Lint(path string, data []byte) []Error {
33+ sch, err := Schema()
34+ if err != nil {
35+ return []Error{{Raw: "internal error: " + err.Error()}}
36+ }
37+ ctx := template.NewContext(sch)
38+ root := template.ReadYAML(ctx, strictRootType, path, data)
39+ if !ctx.HasErrors() && root != nil {
40+ lintConditions(ctx, sch, root)
41+ }
42+ return collect(ctx)
43+}
44+
45+// lintConditions is validateRunsIfCondition for pre-if and post-if and
46+// convertToIfCondition for the "if" of every step of "runs.steps".
47+func lintConditions(ctx *template.Context, sch *template.Schema, root template.Token) {
48+ m, ok := root.(*template.Mapping)
49+ if !ok {
50+ return
51+ }
52+ runs, ok := m.Find("runs").(*template.Mapping)
53+ if !ok {
54+ return
55+ }
56+ runsIf := sch.Info("runs-if")
57+ stepIf := sch.Info("step-if")
58+ for _, p := range runs.Pairs {
59+ switch p.Key.String() {
60+ case "pre-if", "post-if":
61+ if s, ok := p.Value.(*template.String); ok {
62+ if _, err := expr.Parse(s.Value, runsIf.NamedContexts(), runsIf.Functions()); err != nil {
63+ ctx.ErrorAt(s, err.Error())
64+ }
65+ }
66+ case "steps":
67+ seq, ok := p.Value.(*template.Sequence)
68+ if !ok {
69+ continue
70+ }
71+ for _, it := range seq.Items {
72+ if sm, ok := it.(*template.Mapping); ok {
73+ lintStepIf(ctx, stepIf, sm.Find("if"))
74+ }
75+ }
76+ }
77+ }
78+}
79+
80+func lintStepIf(ctx *template.Context, def *template.DefinitionInfo, tok template.Token) {
81+ var condition string
82+ switch s := tok.(type) {
83+ case nil:
84+ return
85+ case *template.BasicExpression:
86+ condition = s.Expression
87+ case *template.String:
88+ condition = s.Value
89+ default:
90+ return
91+ }
92+ if isNullOrWhiteSpace(condition) {
93+ return
94+ }
95+ // Condition text is trimmed for a string; an expression is used as it is.
96+ if _, isString := tok.(*template.String); isString {
97+ condition = trimSpaceJS(condition)
98+ }
99+ final := condition
100+ if node, err := expr.Parse(condition, def.NamedContexts(), def.Functions()); err == nil && !workflow.HasStatusFunction(node) {
101+ final = "success() && (" + condition + ")"
102+ }
103+ if _, err := expr.Parse(final, def.NamedContexts(), def.Functions()); err != nil {
104+ ctx.ErrorAt(tok, err.Error())
105+ }
106+}
A action/lint_test.go +120 -0
@@ -0,0 +1,120 @@
1+// SPDX-License-Identifier: MIT
2+
3+package action
4+
5+import (
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "regexp"
10+ "strings"
11+ "testing"
12+)
13+
14+// testdata/strict.json is what @actions/workflow-parser 0.3.61 answers on the
15+// files of testdata/cases with parseAction and convertActionTemplate (see
16+// gen/strict.mjs). TestLintAgainstReference replays it against Lint.
17+
18+type strictAnswer struct {
19+ Errors []string `json:"errors"`
20+ Name string `json:"name"`
21+ Descr string `json:"description"`
22+ Author string `json:"author"`
23+ Inputs []struct {
24+ ID string `json:"id"`
25+ Required bool `json:"required"`
26+ DeprecationMessage string `json:"deprecationMessage"`
27+ Default string `json:"default"`
28+ } `json:"inputs"`
29+ Outputs []struct {
30+ ID string `json:"id"`
31+ Value string `json:"value"`
32+ } `json:"outputs"`
33+ Runs struct {
34+ Using string `json:"using"`
35+ Main string `json:"main"`
36+ Image string `json:"image"`
37+ PreIf string `json:"preIf"`
38+ PostIf string `json:"postIf"`
39+ Steps int `json:"steps"`
40+ } `json:"runs"`
41+}
42+
43+var (
44+ // The reference words expression parse errors like the JavaScript
45+ // expression library: no position suffix. package expr follows the C#
46+ // runner, which adds ". Located at position N within expression: ...".
47+ exprSuffix = regexp.MustCompile(`\. Located at position \d+ within expression: .*$`)
48+ // yaml.v3 and the JavaScript yaml library word syntax errors differently.
49+ jsYAMLError = regexp.MustCompile(` at line \d+, column \d+:\n`)
50+)
51+
52+// knownLintDifferences are the files on which the reference's answer is not
53+// reproduced, with the reason.
54+var knownLintDifferences = map[string]string{
55+ "yaml_key_collection.yml": "a mapping key that is a collection: the reference reads it as undefined (\"Unexpected value 'undefined'\"), package template reports \"Unexpected value ''\" (twice); see template/PORTING.md",
56+ "fold_inputs_sharp.yml": "duplicate keys: the reference compares with JavaScript's toUpperCase (\u00df is SS), template with strings.ToUpper; Lint is not the runner's OrdinalIgnoreCase, Parse is",
57+ "int32_key.yml": jsKey,
58+ "key_bool_dup.yml": jsKey,
59+ "key_float_dup.yml": jsKey,
60+ "key_hex_dup.yml": jsKey,
61+ "key_hex_input.yml": jsKey,
62+ "key_null_env.yml": nullKey,
63+ "key_null_inputs.yml": nullKey,
64+ "key_null_top.yml": jsKey,
65+ "key_number_runs.yml": jsKey,
66+}
67+
68+// nullKey: Lint misses a null key under a loose-key mapping; the reference and
69+// the runner reject it. Fixing it in template would change how workflow files
70+// are read, so it is left as it is.
71+const nullKey = "Lint misses a null key under a loose-key mapping; the reference and the runner reject it"
72+
73+// jsKey: the reference resolves a plain key to a number, boolean or null (and
74+// then crashes on it, "raw.indexOf is not a function", or words it
75+// differently); template keeps keys of the workflow-style readers as strings.
76+// The runner's reader resolves keys like values, which Parse reproduces.
77+const jsKey = "the reference resolves non-string keys and fails on them with its own wording; Lint keeps keys as strings"
78+
79+func TestLintAgainstReference(t *testing.T) {
80+ data, err := os.ReadFile("testdata/strict.json")
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ var want map[string]strictAnswer
85+ if err := json.Unmarshal(data, &want); err != nil {
86+ t.Fatal(err)
87+ }
88+ files, _ := filepath.Glob("testdata/cases/*.yml")
89+ for _, f := range files {
90+ name := filepath.Base(f)
91+ w, ok := want[name]
92+ if !ok {
93+ t.Errorf("%s: missing from strict.json", name)
94+ continue
95+ }
96+ t.Run(strings.TrimSuffix(name, ".yml"), func(t *testing.T) {
97+ src, err := os.ReadFile(f)
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ errs := Lint(name, src)
102+ if len(w.Errors) == 1 && jsYAMLError.MatchString(w.Errors[0]) {
103+ if len(errs) != 1 || !strings.HasPrefix(errs[0].Error(), name+": ") {
104+ t.Errorf("syntax error: got %q, reference %q", errTexts(errs), w.Errors)
105+ }
106+ return
107+ }
108+ var got []string
109+ for _, e := range errs {
110+ got = append(got, exprSuffix.ReplaceAllString(e.Error(), ""))
111+ }
112+ if note, known := knownLintDifferences[name]; known {
113+ t.Skip(note)
114+ }
115+ if strings.Join(got, "\n") != strings.Join(w.Errors, "\n") {
116+ t.Fatalf("errors differ\n got: %q\nwant: %q", got, w.Errors)
117+ }
118+ })
119+ }
120+}
A action/load.go +309 -0
@@ -0,0 +1,309 @@
1+// SPDX-License-Identifier: MIT
2+
3+package action
4+
5+import (
6+ _ "embed"
7+ "strings"
8+ "sync"
9+
10+ "bnema.dev/ermite-actions/template"
11+ "bnema.dev/ermite-actions/workflow"
12+)
13+
14+//go:embed schema/action-v1.0.min.json
15+var schemaJSON []byte
16+
17+var (
18+ schemaOnce sync.Once
19+ schema *template.Schema
20+ schemaErr error
21+)
22+
23+// Schema returns the vendored action schema (action-v1.0). It is read once and
24+// shared; it must not be modified.
25+func Schema() (*template.Schema, error) {
26+ schemaOnce.Do(func() { schema, schemaErr = template.ParseSchema(schemaJSON) })
27+ return schema, schemaErr
28+}
29+
30+// rootType is the definition the runner reads an action.yml against: its
31+// action_yaml.json has "action-root", whose definitions the vendored schema
32+// keeps under the same names. ("action-root-strict" is the language service's
33+// linter, which also wants name and description and refuses unknown keys that
34+// GitHub accepts.)
35+const rootType = "action-root"
36+
37+// The limits of the runner's TemplateContext for an action manifest
38+// (ActionManifestManagerLegacy.CreateTemplateContext).
39+const (
40+ maxDepth = 100
41+ maxErrors = 10
42+)
43+
44+// LoadFailed returns the line the runner ends with when [Parse] returned
45+// errors: it reports each error, then fails the step with this text.
46+func LoadFailed(file string) string { return "Failed to load " + file }
47+
48+// Parse reads, validates and converts an action.yml (or action.yaml) the way
49+// GitHub's runner does. path is only used in messages, where the runner puts
50+// the manifest's path inside the actions directory ("actions/checkout/v4/
51+// action.yml").
52+//
53+// The errors are the runner's, in its order: first the ones the template
54+// reader and the converters record with a position, then the one exception
55+// that stopped the load, if any, worded like the runner prints it
56+// ("System.ArgumentNullException: Value cannot be null. (Parameter 'You are
57+// using a JavaScript Action but there is not an entry JavaScript file provided
58+// in action.yml.')", stack trace left out). At most ten errors are kept. When
59+// the list is not empty the action is nil, and the runner fails with
60+// [LoadFailed]. A manifest without "runs" gives the one error "Top level
61+// 'runs:' section is required for <path>".
62+func Parse(path string, data []byte) (*Action, []Error) {
63+ sch, err := Schema()
64+ if err != nil {
65+ return nil, []Error{{Raw: "internal error: " + err.Error()}}
66+ }
67+ ctx := template.NewContext(sch)
68+ ctx.MaxDepth, ctx.ErrorLimit, ctx.RunnerYAML = maxDepth, maxErrors, true
69+ l := &loader{ctx: ctx, schema: sch, file: path}
70+ a := l.load(data)
71+ if ctx.HasErrors() {
72+ return nil, collect(ctx)
73+ }
74+ if a == nil || a.Runs == nil {
75+ return nil, []Error{{Raw: "Top level 'runs:' section is required for " + path}}
76+ }
77+ return a, nil
78+}
79+
80+func collect(ctx *template.Context) []Error {
81+ var out []Error
82+ for _, e := range ctx.Errors() {
83+ out = append(out, *e)
84+ }
85+ return out
86+}
87+
88+type loader struct {
89+ ctx *template.Context
90+ schema *template.Schema
91+ file string
92+}
93+
94+// load is ActionManifestManagerLegacy.Load: everything that is thrown is
95+// recorded in the context, like the runner's catch.
96+func (l *loader) load(data []byte) (a *Action) {
97+ defer l.catch()
98+ return l.loadRoot(template.ReadYAML(l.ctx, rootType, l.file, data))
99+}
100+
101+func (l *loader) catch() {
102+ if r := recover(); r != nil {
103+ t, ok := r.(thrown)
104+ if !ok {
105+ panic(r)
106+ }
107+ l.ctx.ErrorException(t.class, t.msg)
108+ }
109+}
110+
111+func (l *loader) loadRoot(root template.Token) (a *Action) {
112+ a = &Action{File: l.file}
113+ m := assertMapping(root, "action manifest root")
114+ var outputs *template.Mapping
115+ var runs template.Token
116+ for _, p := range m.Pairs {
117+ key := assertString(p.Key, "action.yml property key")
118+ switch key.Value {
119+ case "name":
120+ a.Name = assertString(p.Value, "name").Value
121+ case "outputs":
122+ outputs = assertMapping(p.Value, "outputs")
123+ case "description":
124+ a.Description = assertString(p.Value, "description").Value
125+ case "inputs":
126+ l.convertInputs(p.Value, a)
127+ case "runs":
128+ runs = p.Value
129+ case "author":
130+ if s, ok := p.Value.(*template.String); ok {
131+ a.Author = s.Value
132+ }
133+ }
134+ }
135+ if outputs != nil {
136+ a.Outputs = convertOutputs(outputs)
137+ }
138+ if runs != nil {
139+ a.Runs = l.convertRuns(runs, m)
140+ }
141+ return a
142+}
143+
144+func (l *loader) check() {
145+ errs := l.ctx.Errors()
146+ if len(errs) == 0 {
147+ return
148+ }
149+ msgs := make([]string, len(errs))
150+ for i, e := range errs {
151+ msgs[i] = e.Error()
152+ }
153+ throwf(exTemplate, "The template is not valid. %s", strings.Join(msgs, ","))
154+}
155+
156+func (l *loader) convertInputs(t template.Token, a *Action) {
157+ for _, p := range assertMapping(t, "inputs").Pairs {
158+ name := assertString(p.Key, "input name")
159+ in := Input{Name: name.Value, Pos: name.Pos()}
160+ for _, md := range assertMapping(p.Value, "input metadata").Pairs {
161+ mdName := assertString(md.Key, "input metadata").Value
162+ switch {
163+ case asciiEqualFold(mdName, "default"):
164+ in.Default, in.HasDefault = md.Value, true
165+ case asciiEqualFold(mdName, "deprecationMessage"):
166+ // The runner's Dictionary.Add would throw on a repeated input
167+ // name, but the template reader has already refused one
168+ // (names compare case-insensitively there).
169+ in.DeprecationMessage = assertString(md.Value, "input deprecationMessage").Value
170+ case mdName == "description":
171+ if s, ok := md.Value.(*template.String); ok {
172+ in.Description = s.Value
173+ }
174+ case mdName == "required":
175+ switch v := md.Value.(type) {
176+ case *template.Boolean:
177+ in.Required = v.Value
178+ case *template.String:
179+ in.Required = v.Value == "true"
180+ }
181+ }
182+ }
183+ if !in.HasDefault {
184+ in.Default = template.NewString(template.Origin{}, "")
185+ }
186+ a.Inputs = append(a.Inputs, in)
187+ }
188+}
189+
190+func convertOutputs(m *template.Mapping) []Output {
191+ var out []Output
192+ for _, p := range m.Pairs {
193+ o := Output{Name: p.Key.String(), Pos: p.Key.Pos()}
194+ if om, ok := p.Value.(*template.Mapping); ok {
195+ for _, kv := range om.Pairs {
196+ switch kv.Key.String() {
197+ case "description":
198+ if s, ok := kv.Value.(*template.String); ok {
199+ o.Description = s.Value
200+ }
201+ case "value":
202+ if template.IsScalar(kv.Value) {
203+ o.Value = kv.Value
204+ }
205+ }
206+ }
207+ }
208+ out = append(out, o)
209+ }
210+ return out
211+}
212+
213+// convertRuns is ConvertRuns.
214+func (l *loader) convertRuns(t template.Token, _ *template.Mapping) *Runs {
215+ rm := assertMapping(t, "runs")
216+ var (
217+ usingTok, imageTok, entrypointTok, mainTok, pluginTok *template.String
218+ preTok, preEntrypointTok, preIfTok *template.String
219+ postTok, postEntrypointTok, postIfTok *template.String
220+ argsTok *template.Sequence
221+ envTok *template.Mapping
222+ steps []*workflow.Step
223+ haveSteps bool
224+ )
225+ for _, p := range rm.Pairs {
226+ key := assertString(p.Key, "runs key").Value
227+ v := p.Value
228+ switch key {
229+ case "using":
230+ usingTok = assertString(v, "using")
231+ case "image":
232+ imageTok = assertString(v, "image")
233+ case "args":
234+ argsTok = assertSequence(v, "args")
235+ case "entrypoint":
236+ entrypointTok = assertString(v, "entrypoint")
237+ case "env":
238+ envTok = assertMapping(v, "env")
239+ case "main":
240+ mainTok = assertString(v, "main")
241+ case "plugin":
242+ pluginTok = assertString(v, "plugin")
243+ case "post":
244+ postTok = assertString(v, "post")
245+ case "post-entrypoint":
246+ postEntrypointTok = assertString(v, "post-entrypoint")
247+ case "post-if":
248+ postIfTok = assertString(v, "post-if")
249+ case "pre":
250+ preTok = assertString(v, "pre")
251+ case "pre-entrypoint":
252+ preEntrypointTok = assertString(v, "pre-entrypoint")
253+ case "pre-if":
254+ preIfTok = assertString(v, "pre-if")
255+ case "steps":
256+ steps, haveSteps = l.convertSteps(assertSequence(v, "steps")), true
257+ l.check()
258+ }
259+ }
260+ str := func(s *template.String) string {
261+ if s == nil {
262+ return ""
263+ }
264+ return s.Value
265+ }
266+ orAlways := func(s *template.String) string {
267+ if s == nil {
268+ return "always()"
269+ }
270+ return s.Value
271+ }
272+ r := &Runs{Pos: rm.Pos()}
273+ if usingTok != nil {
274+ using := usingTok.Value
275+ switch {
276+ case asciiEqualFold(using, "docker"):
277+ if str(imageTok) == "" {
278+ throwf(exArgumentNull, "You are using a Container Action but an image is not provided in %s.", l.file)
279+ }
280+ r.Kind, r.Using = RunsDocker, "docker"
281+ r.Image, r.Entrypoint = imageTok.Value, str(entrypointTok)
282+ r.Args, r.Env = argsTok, envTok
283+ r.PreEntrypoint, r.PostEntrypoint = str(preEntrypointTok), str(postEntrypointTok)
284+ r.PreIf, r.PostIf = orAlways(preIfTok), orAlways(postIfTok)
285+ case asciiEqualFold(using, "node12"), asciiEqualFold(using, "node16"), asciiEqualFold(using, "node20"), asciiEqualFold(using, "node24"):
286+ if str(mainTok) == "" {
287+ throwf(exArgumentNull, "You are using a JavaScript Action but there is not an entry JavaScript file provided in %s.", l.file)
288+ }
289+ r.Kind, r.Using = RunsNode, strings.ToLower(using)
290+ r.Main, r.Pre, r.Post = mainTok.Value, str(preTok), str(postTok)
291+ r.PreIf, r.PostIf = orAlways(preIfTok), orAlways(postIfTok)
292+ case asciiEqualFold(using, "composite"):
293+ if !haveSteps {
294+ throwf(exArgumentNull, "You are using a composite action but there are no steps provided in %s.", l.file)
295+ }
296+ r.Kind, r.Using, r.Steps = RunsComposite, "composite", steps
297+ r.PreIf, r.PostIf = "always()", "always()"
298+ default:
299+ throwf(exArgumentRng, "'using: %s' is not supported, use 'docker', 'node12', 'node16', 'node20' or 'node24' instead.", using)
300+ }
301+ return r
302+ }
303+ if pluginTok != nil {
304+ r.Kind, r.Using, r.Plugin = RunsPlugin, "plugin", pluginTok.Value
305+ return r
306+ }
307+ throwf(exNotSupported, "Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20' or 'node24'.")
308+ return nil
309+}
A action/model.go +162 -0
@@ -0,0 +1,162 @@
1+// SPDX-License-Identifier: MIT
2+
3+package action
4+
5+import (
6+ "bnema.dev/ermite-actions/template"
7+ "bnema.dev/ermite-actions/workflow"
8+)
9+
10+// Error is a problem found in an action manifest. See the package comment for
11+// the form of its message.
12+type Error = template.Error
13+
14+// Action is a parsed action.yml.
15+type Action struct {
16+ // File is the name given to Parse.
17+ File string
18+ // Name, Description and Author are the keys of the same names; "" when
19+ // absent, or not a string. The runner does not read Author.
20+ Name, Description, Author string
21+ // Inputs are the declared inputs in file order.
22+ Inputs []Input
23+ // Outputs are the declared outputs in file order. Only a composite
24+ // action's outputs have a Value that is used.
25+ Outputs []Output
26+ // Runs says how the action runs. It is never nil in a returned Action.
27+ Runs *Runs
28+}
29+
30+// Input returns the input called name, compared like the runner compares
31+// input names (OrdinalIgnoreCase), or nil.
32+func (a *Action) Input(name string) *Input {
33+ for i := range a.Inputs {
34+ if ordinalEqual(a.Inputs[i].Name, name) {
35+ return &a.Inputs[i]
36+ }
37+ }
38+ return nil
39+}
40+
41+// Input is an entry of "inputs".
42+type Input struct {
43+ // Name is the key, as written.
44+ Name string
45+ // Pos is where the key is.
46+ Pos template.Pos
47+ // Description is the "description" key, or "".
48+ Description string
49+ // Required is "required: true" (a boolean, or the string "true"). The
50+ // runner does not check it.
51+ Required bool
52+ // Default is the default value: a string, or an expression
53+ // (${{ github.ref }}) that the runner evaluates when the step does not
54+ // give the input; the context it may use is the one of
55+ // "input-default-context" in the schema (github, strategy, matrix, job,
56+ // runner and hashFiles). It is never nil: an input without a default has
57+ // the empty string, as in the runner. HasDefault tells them apart.
58+ Default template.Token
59+ HasDefault bool
60+ // DeprecationMessage is "deprecationMessage", or "". The runner warns
61+ // "Input '<name>' has been deprecated with message: <message>" when a
62+ // step gives the input.
63+ DeprecationMessage string
64+}
65+
66+// Deprecated reports whether the input has a deprecation message.
67+func (in *Input) Deprecated() bool { return in.DeprecationMessage != "" }
68+
69+// Output is an entry of "outputs".
70+type Output struct {
71+ Name string
72+ Pos template.Pos
73+ Description string
74+ // Value is the "value" token of a composite action's output: a string or
75+ // an expression over github, strategy, matrix, steps, inputs, job, runner
76+ // and env. nil when the output has none.
77+ Value template.Token
78+}
79+
80+// RunsKind is how an action runs.
81+type RunsKind int
82+
83+// The kinds of action.
84+const (
85+ // RunsNode is a JavaScript action: using node12, node16, node20 or node24.
86+ RunsNode RunsKind = iota + 1
87+ // RunsDocker is a container action: using docker.
88+ RunsDocker
89+ // RunsComposite is a composite action: using composite.
90+ RunsComposite
91+ // RunsPlugin is "runs: {plugin: name}", an internal runner plugin. The
92+ // manifest loads, but only the runner's own plugins can run.
93+ RunsPlugin
94+)
95+
96+// Runs is the "runs" key. Which fields are set depends on Kind.
97+type Runs struct {
98+ Kind RunsKind
99+ // Using is the runtime: "node12", "node16", "node20", "node24", "docker",
100+ // "composite", or "plugin". The runner compares "using" without regard to
101+ // case; Using is lower case.
102+ Using string
103+ // Pos is where "runs" is.
104+ Pos template.Pos
105+
106+ // Node actions.
107+ //
108+ // Main is the script to run; Pre and Post are scripts that run before the
109+ // job's first step and after its last, or "". PreIf and PostIf are their
110+ // conditions: expressions written without ${{ }}, evaluated as they are
111+ // (the runner does not add "success() &&"), "always()" when absent.
112+ // Docker actions have the same four conditions for their entrypoints.
113+ Main, Pre, Post string
114+ PreIf, PostIf string
115+
116+ // Docker actions.
117+ //
118+ // Image is a path to a Dockerfile ("Dockerfile", "images/Dockerfile") or
119+ // "docker://image[:tag]"; see [ParseImage]. Entrypoint overrides the
120+ // image's ENTRYPOINT. Args is a sequence of strings and Env a mapping of
121+ // strings, both of which may hold ${{ inputs.x }} expressions (and no other
122+ // context); nil when absent. PreEntrypoint and PostEntrypoint are the
123+ // entrypoints that run before and after, or "".
124+ Image, Entrypoint, PreEntrypoint, PostEntrypoint string
125+ Args *template.Sequence
126+ Env *template.Mapping
127+
128+ // Composite actions.
129+ //
130+ // Steps are the steps in file order. A run step has a shell; the contexts
131+ // of each key are those of the schema (github, inputs, strategy, matrix,
132+ // steps, job, runner, env), and "if" also takes vars and needs. A step
133+ // has no timeout-minutes and no working-directory on a uses step.
134+ Steps []*workflow.Step
135+
136+ // Plugin actions.
137+ Plugin string
138+}
139+
140+// HasPre reports whether the action has something to run before the job's
141+// steps: a node "pre" or a docker "pre-entrypoint".
142+func (r *Runs) HasPre() bool {
143+ switch r.Kind {
144+ case RunsNode:
145+ return r.Pre != ""
146+ case RunsDocker:
147+ return r.PreEntrypoint != ""
148+ }
149+ return false
150+}
151+
152+// HasPost reports whether the action has something to run after the job's
153+// steps: a node "post" or a docker "post-entrypoint".
154+func (r *Runs) HasPost() bool {
155+ switch r.Kind {
156+ case RunsNode:
157+ return r.Post != ""
158+ case RunsDocker:
159+ return r.PostEntrypoint != ""
160+ }
161+ return false
162+}
A action/oracle_test.go +323 -0
@@ -0,0 +1,323 @@
1+// SPDX-License-Identifier: MIT
2+
3+package action
4+
5+import (
6+ "encoding/json"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "regexp"
11+ "strings"
12+ "testing"
13+
14+ "bnema.dev/ermite-actions/template"
15+)
16+
17+// testdata/runner.json is what the C# runner's own loader answers on the files
18+// of testdata/cases (see gen/runner): the errors it reports and a summary of
19+// what it loaded. TestAgainstRunner replays it.
20+
21+type runnerAnswer struct {
22+ Name string `json:"name"`
23+ Description string `json:"description"`
24+ Errors []string `json:"errors"`
25+ Inputs []struct {
26+ Name string `json:"name"`
27+ Default *string `json:"default"`
28+ } `json:"inputs"`
29+ Deprecated map[string]string `json:"deprecated"`
30+ Execution *struct {
31+ Type string `json:"type"`
32+ Using string `json:"using"`
33+ Image string `json:"image"`
34+ Args []string `json:"args"`
35+ Entrypoint *string `json:"entrypoint"`
36+ Env [][2]string `json:"env"`
37+ Main string `json:"main"`
38+ Pre *string `json:"pre"`
39+ PreIf string `json:"preIf"`
40+ Post *string `json:"post"`
41+ PostIf string `json:"postIf"`
42+ Plugin string `json:"plugin"`
43+ Steps []struct {
44+ ID string `json:"id"`
45+ If string `json:"if"`
46+ Kind string `json:"kind"`
47+ Name *string `json:"name"`
48+ ContinueOnError *string `json:"continueOnError"`
49+ Timeout *string `json:"timeout"`
50+ Env [][2]string `json:"env"`
51+ EnvExpr *string `json:"envExpr"`
52+ WithExpr *string `json:"withExpr"`
53+ Script *string `json:"script"`
54+ Shell *string `json:"shell"`
55+ WorkingDir *string `json:"workingDirectory"`
56+ With [][2]string `json:"with"`
57+ Reference *struct {
58+ Type string `json:"type"`
59+ Image string `json:"image"`
60+ Name *string `json:"name"`
61+ Ref *string `json:"ref"`
62+ Path *string `json:"path"`
63+ } `json:"reference"`
64+ } `json:"steps"`
65+ Outputs [][2]*string `json:"outputs"`
66+ } `json:"execution"`
67+}
68+
69+func loadRunner(t *testing.T) map[string]runnerAnswer {
70+ t.Helper()
71+ data, err := os.ReadFile("testdata/runner.json")
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ var m map[string]runnerAnswer
76+ if err := json.Unmarshal(data, &m); err != nil {
77+ t.Fatal(err)
78+ }
79+ return m
80+}
81+
82+func errTexts(errs []Error) []string {
83+ out := make([]string, len(errs))
84+ for i := range errs {
85+ out[i] = errs[i].Error()
86+ }
87+ return out
88+}
89+
90+func orEmpty(s *string) string {
91+ if s == nil {
92+ return ""
93+ }
94+ return *s
95+}
96+
97+func pairsOf(m *template.Mapping) [][2]string {
98+ if m == nil {
99+ return nil
100+ }
101+ var out [][2]string
102+ for _, p := range m.Pairs {
103+ out = append(out, [2]string{p.Key.String(), p.Value.String()})
104+ }
105+ return out
106+}
107+
108+func TestAgainstRunner(t *testing.T) {
109+ want := loadRunner(t)
110+ files, err := filepath.Glob("testdata/cases/*.yml")
111+ if err != nil || len(files) == 0 {
112+ t.Fatalf("no cases: %v", err)
113+ }
114+ if len(files) != len(want) {
115+ t.Errorf("%d case files, %d answers: run gen/runner/build.sh", len(files), len(want))
116+ }
117+ for _, f := range files {
118+ name := filepath.Base(f)
119+ w, ok := want[name]
120+ if !ok {
121+ t.Errorf("%s: no answer in runner.json", name)
122+ continue
123+ }
124+ t.Run(strings.TrimSuffix(name, ".yml"), func(t *testing.T) {
125+ data, err := os.ReadFile(f)
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ a, errs := Parse(name, data)
130+ got := errTexts(errs)
131+ if mismatch := compareErrors(got, w.Errors); mismatch != "" {
132+ t.Fatalf("errors differ: %s\n got: %q\nwant: %q", mismatch, got, w.Errors)
133+ }
134+ if len(w.Errors) > 0 {
135+ if a != nil {
136+ t.Error("action returned with errors")
137+ }
138+ return
139+ }
140+ if a == nil {
141+ t.Fatal("no action and no errors")
142+ }
143+ compareAction(t, a, w)
144+ })
145+ }
146+}
147+
148+// compareErrors compares messages with the runner's, which are recorded
149+// without their stack traces. A YAML syntax error has YamlDotNet's wording in
150+// the runner and yaml.v3's here: only the shape is compared.
151+// yamlDotNetError matches the runner's YAML syntax errors: "file.yml: (Line: 3,
152+// Col: 1, Idx: 16) - (Line: 3, Col: 1, Idx: 16): While parsing a node, ...".
153+var yamlDotNetError = regexp.MustCompile(`^[^ ]+: \(Line: \d+, Col: \d+, Idx: \d+\) - \(Line: \d+, Col: \d+, Idx: \d+\): `)
154+
155+func compareErrors(got, want []string) string {
156+ if len(want) >= 1 && yamlDotNetError.MatchString(want[0]) {
157+ if len(got) == 0 || !strings.HasPrefix(got[0], strings.SplitN(want[0], ":", 2)[0]+": ") {
158+ return "syntax error not reported on the file"
159+ }
160+ // The runner follows a syntax error with the exception from the null
161+ // root; compare the rest.
162+ if len(got) != len(want) {
163+ return fmt.Sprintf("%d errors, want %d", len(got), len(want))
164+ }
165+ for i := 1; i < len(got); i++ {
166+ if got[i] != want[i] {
167+ return fmt.Sprintf("error %d", i)
168+ }
169+ }
170+ return ""
171+ }
172+ if len(got) != len(want) {
173+ return fmt.Sprintf("%d errors, want %d", len(got), len(want))
174+ }
175+ for i := range got {
176+ if got[i] != want[i] {
177+ return fmt.Sprintf("error %d", i)
178+ }
179+ }
180+ return ""
181+}
182+
183+func compareAction(t *testing.T, a *Action, w runnerAnswer) {
184+ t.Helper()
185+ if a.Name != w.Name || a.Description != w.Description {
186+ t.Errorf("name/description: got %q/%q, want %q/%q", a.Name, a.Description, w.Name, w.Description)
187+ }
188+ if len(a.Inputs) != len(w.Inputs) {
189+ t.Fatalf("%d inputs, want %d", len(a.Inputs), len(w.Inputs))
190+ }
191+ for i, in := range w.Inputs {
192+ g := a.Inputs[i]
193+ if g.Name != in.Name || g.Default.String() != orEmpty(in.Default) {
194+ t.Errorf("input %d: got %q default %q, want %q default %q", i, g.Name, g.Default.String(), in.Name, orEmpty(in.Default))
195+ }
196+ if dep := w.Deprecated[in.Name]; g.DeprecationMessage != dep {
197+ t.Errorf("input %q: deprecation %q, want %q", in.Name, g.DeprecationMessage, dep)
198+ }
199+ }
200+ e := w.Execution
201+ r := a.Runs
202+ if e == nil || r == nil {
203+ t.Fatalf("execution: %v / %v", e, r)
204+ }
205+ kinds := map[string]RunsKind{"node": RunsNode, "docker": RunsDocker, "composite": RunsComposite, "plugin": RunsPlugin}
206+ if r.Kind != kinds[e.Type] {
207+ t.Fatalf("kind %v, want %s", r.Kind, e.Type)
208+ }
209+ switch r.Kind {
210+ case RunsNode:
211+ if r.Main != e.Main || r.Pre != orEmpty(e.Pre) || r.Post != orEmpty(e.Post) || r.PreIf != e.PreIf || r.PostIf != e.PostIf || !strings.EqualFold(r.Using, e.Using) {
212+ t.Errorf("node: got %+v, want %+v", r, e)
213+ }
214+ case RunsDocker:
215+ var args []string
216+ if r.Args != nil {
217+ for _, it := range r.Args.Items {
218+ args = append(args, it.String())
219+ }
220+ }
221+ if r.Image != e.Image || r.Entrypoint != orEmpty(e.Entrypoint) || r.PreEntrypoint != orEmpty(e.Pre) || r.PostEntrypoint != orEmpty(e.Post) || r.PreIf != e.PreIf || r.PostIf != e.PostIf {
222+ t.Errorf("docker: got %+v, want %+v", r, e)
223+ }
224+ if fmt.Sprint(args) != fmt.Sprint(e.Args) {
225+ t.Errorf("args: got %q, want %q", args, e.Args)
226+ }
227+ if fmt.Sprint(pairsOf(r.Env)) != fmt.Sprint(e.Env) {
228+ t.Errorf("env: got %q, want %q", pairsOf(r.Env), e.Env)
229+ }
230+ case RunsPlugin:
231+ if r.Plugin != e.Plugin {
232+ t.Errorf("plugin: %q, want %q", r.Plugin, e.Plugin)
233+ }
234+ case RunsComposite:
235+ if len(r.Steps) != len(e.Steps) {
236+ t.Fatalf("%d steps, want %d", len(r.Steps), len(e.Steps))
237+ }
238+ for i, ws := range e.Steps {
239+ s := r.Steps[i]
240+ if s.ID != ws.ID || s.If.String() != "${{ "+ws.If+" }}" && s.If.Expression != ws.If {
241+ t.Errorf("step %d: id %q if %q, want id %q if %q", i, s.ID, s.If.Expression, ws.ID, ws.If)
242+ }
243+ if tokStr(s.Name) != orEmpty(ws.Name) || tokStr(s.ContinueOnError) != orEmpty(ws.ContinueOnError) {
244+ t.Errorf("step %d: name/continue-on-error %q/%q, want %q/%q", i, tokStr(s.Name), tokStr(s.ContinueOnError), orEmpty(ws.Name), orEmpty(ws.ContinueOnError))
245+ }
246+ if tokStr(s.EnvExpression) != orEmpty(ws.EnvExpr) || tokStr(s.WithExpression) != orEmpty(ws.WithExpr) {
247+ t.Errorf("step %d env/with expression: got %q/%q, want %q/%q", i, tokStr(s.EnvExpression), tokStr(s.WithExpression), orEmpty(ws.EnvExpr), orEmpty(ws.WithExpr))
248+ }
249+ if fmt.Sprint(pairsOf(s.Env)) != fmt.Sprint(nilIfEmpty(ws.Env)) {
250+ t.Errorf("step %d env: got %q, want %q", i, pairsOf(s.Env), ws.Env)
251+ }
252+ switch ws.Kind {
253+ case "run":
254+ if s.Run == nil || tokStr(s.Run) != orEmpty(ws.Script) || tokStr(s.Shell) != orEmpty(ws.Shell) || tokStr(s.WorkingDirectory) != orEmpty(ws.WorkingDir) {
255+ t.Errorf("step %d run: got %q/%q/%q, want %+v", i, tokStr(s.Run), tokStr(s.Shell), tokStr(s.WorkingDirectory), ws)
256+ }
257+ case "uses":
258+ if s.Uses == nil || fmt.Sprint(pairsOf(s.With)) != fmt.Sprint(nilIfEmpty(ws.With)) {
259+ t.Errorf("step %d uses: got %v with %q, want %+v", i, s.Uses, pairsOf(s.With), ws)
260+ }
261+ u, err := ParseUses(s.Uses.Value)
262+ if err != nil {
263+ t.Fatalf("step %d: %v", i, err)
264+ }
265+ checkReference(t, i, u, ws.Reference)
266+ }
267+ }
268+ if len(a.Outputs) != len(e.Outputs) {
269+ t.Fatalf("%d outputs, want %d", len(a.Outputs), len(e.Outputs))
270+ }
271+ for i, o := range e.Outputs {
272+ if a.Outputs[i].Name != *o[0] || tokStr(a.Outputs[i].Value) != orEmpty(o[1]) {
273+ t.Errorf("output %d: got %q=%q, want %v", i, a.Outputs[i].Name, tokStr(a.Outputs[i].Value), o)
274+ }
275+ }
276+ }
277+}
278+
279+func nilIfEmpty(p [][2]string) [][2]string {
280+ if len(p) == 0 {
281+ return nil
282+ }
283+ return p
284+}
285+
286+func tokStr(t template.Token) string {
287+ if t == nil {
288+ return ""
289+ }
290+ return t.String()
291+}
292+
293+func checkReference(t *testing.T, i int, u Uses, ref *struct {
294+ Type string `json:"type"`
295+ Image string `json:"image"`
296+ Name *string `json:"name"`
297+ Ref *string `json:"ref"`
298+ Path *string `json:"path"`
299+}) {
300+ t.Helper()
301+ if ref == nil {
302+ t.Errorf("step %d: no reference in the answer", i)
303+ return
304+ }
305+ switch u.Kind {
306+ case UsesDocker:
307+ if ref.Type != "docker" || ref.Image != u.Image {
308+ t.Errorf("step %d: %+v, want %+v", i, u, ref)
309+ }
310+ case UsesLocal:
311+ if ref.Type != "self" || orEmpty(ref.Path) != u.Path {
312+ t.Errorf("step %d: %+v, want %+v", i, u, ref)
313+ }
314+ case UsesSelfRepository:
315+ if ref.Type != "selfRepository" || orEmpty(ref.Path) != u.Path {
316+ t.Errorf("step %d: %+v, want %+v", i, u, ref)
317+ }
318+ case UsesRepository:
319+ if ref.Type != "GitHub" || orEmpty(ref.Name) != u.Name || orEmpty(ref.Ref) != u.Ref || orEmpty(ref.Path) != u.Path {
320+ t.Errorf("step %d: %+v, want %+v", i, u, ref)
321+ }
322+ }
323+}
A action/refnames.go +113 -0
@@ -0,0 +1,113 @@
1+// SPDX-License-Identifier: MIT
2+
3+package action
4+
5+import (
6+ "fmt"
7+ "unicode/utf16"
8+
9+ "bnema.dev/ermite-actions/expr"
10+)
11+
12+// refNames is a port of the runner's ReferenceNameBuilder
13+// (Sdk/DTPipelines/Pipelines/ObjectTemplating/ReferenceNameBuilder.cs), which
14+// validates the ids written in a composite action and makes the ones the file
15+// leaves out. It is not package workflow's idBuilder: the runner's accepts ids
16+// that start with "__", lets an id of 100 characters or more through, counts
17+// UTF-16 code units, and words its error without the reference parser's
18+// doubled "and".
19+type refNames struct {
20+ name []uint16
21+ distinct map[string]bool // OrdinalIgnoreCase folds
22+}
23+
24+const maxNodeNameLength = 100
25+
26+func newRefNames() *refNames { return &refNames{distinct: map[string]bool{}} }
27+
28+// add records name and reports whether it was new (HashSet.Add with
29+// OrdinalIgnoreCase). A name with non-ASCII characters can get here when it has
30+// 100 characters or more, and "é...é" then collides with "É...É".
31+func (b *refNames) add(name string) bool {
32+ k := expr.OrdinalFold(name)
33+ if b.distinct[k] {
34+ return false
35+ }
36+ b.distinct[k] = true
37+ return true
38+}
39+
40+// appendSegment is AppendSegment.
41+func (b *refNames) appendSegment(value string) {
42+ if value == "" {
43+ return
44+ }
45+ units := utf16.Encode([]rune(value))
46+ if len(b.name) == 0 {
47+ first := units[0]
48+ legal := first >= 'a' && first <= 'z' || first >= 'A' && first <= 'Z' || first == '_'
49+ if !legal && (first >= '0' && first <= '9' || first == '-') {
50+ b.name = append(b.name, '_')
51+ }
52+ } else {
53+ b.name = append(b.name, '_')
54+ }
55+ for _, c := range units {
56+ switch {
57+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-':
58+ b.name = append(b.name, c)
59+ default:
60+ b.name = append(b.name, '_')
61+ }
62+ }
63+}
64+
65+// build is Build: the name made from the segments ("job" when there are none)
66+// made unique with a numeric suffix.
67+func (b *refNames) build() string {
68+ original := "job"
69+ if len(b.name) > 0 {
70+ original = string(utf16.Decode(b.name))
71+ }
72+ for attempt := 1; ; attempt++ {
73+ suffix := ""
74+ if attempt > 1 {
75+ if attempt >= 1000 {
76+ throwf(exInvalidOp, "Unable to create a unique name")
77+ }
78+ suffix = fmt.Sprintf("_%d", attempt)
79+ }
80+ // original holds only ASCII: appendSegment replaced everything else.
81+ candidate := original[:min(len(original), maxNodeNameLength-len(suffix))] + suffix
82+ if b.add(candidate) {
83+ b.name = b.name[:0]
84+ return candidate
85+ }
86+ }
87+}
88+
89+// tryAddKnownName is TryAddKnownName: it returns the error text, or "". Note
90+// the runner's condition: an invalid name is only refused when it is shorter
91+// than 100 characters.
92+func (b *refNames) tryAddKnownName(value string) string {
93+ if !validName(value) && utf16Len(value) < maxNodeNameLength {
94+ return fmt.Sprintf("The identifier '%s' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and must be less than %d characters.", value, maxNodeNameLength)
95+ }
96+ if !b.add(value) {
97+ return fmt.Sprintf("The identifier '%s' may not be used more than once within the same scope.", value)
98+ }
99+ return ""
100+}
101+
102+// validName is NameValidation.IsValid(name, allowHyphens: true).
103+func validName(name string) bool {
104+ for i, c := range utf16.Encode([]rune(name)) {
105+ switch {
106+ case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_':
107+ case i > 0 && (c >= '0' && c <= '9' || c == '-'):
108+ default:
109+ return false
110+ }
111+ }
112+ return true
113+}
A action/schema/SOURCE +6 -0
@@ -0,0 +1,6 @@
1+action-v1.0.min.json is dist/action-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 is the same JSON as
4+workflow-parser/src/action-v1.0.json at commit
5+4043eda158e16579cc5fb1b0b07a4bce2a76f0b5 (same content, different spacing).
6+Package action reads its "action-root" definition. See ../../THIRD_PARTY.md.
A action/schema/action-v1.0.min.json +1 -0
@@ -0,0 +1,1 @@
1+{ "definitions": { "action-root": { "description": "Action file", "mapping": { "properties": { "name": "string", "description": "string", "inputs": "inputs", "outputs": "outputs", "runs": "runs" }, "loose-key-type": "non-empty-string", "loose-value-type": "any" } }, "action-root-strict": { "description": "GitHub Action manifest file (action.yml/action.yaml) that defines an action's metadata, inputs, outputs, and execution configuration.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions)", "mapping": { "properties": { "name": { "type": "non-empty-string", "required": true, "description": "The name of your action. GitHub displays the name in the Actions tab to help visually identify actions in each job.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#name)" }, "description": { "type": "string", "required": true, "description": "A short description of the action. GitHub displays this description in the Actions Marketplace.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#description)" }, "author": { "type": "string", "description": "The name of the action's author.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#author)" }, "inputs": "inputs-strict", "outputs": "outputs", "runs": { "type": "runs-strict", "required": true }, "branding": "branding" } } }, "inputs": { "mapping": { "loose-key-type": "non-empty-string", "loose-value-type": "input" } }, "inputs-strict": { "description": "Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Inputs ids with uppercase letters are converted to lowercase during runtime.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputs)", "mapping": { "loose-key-type": "non-empty-string", "loose-value-type": "input-strict" } }, "input": { "mapping": { "properties": { "default": "input-default-context" }, "loose-key-type": "non-empty-string", "loose-value-type": "any" } }, "input-strict": { "description": "An input parameter for this action.", "mapping": { "properties": { "description": { "type": "string", "description": "A string description of the input parameter.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_iddescription)" }, "required": { "type": "boolean", "description": "A boolean to indicate whether the action requires the input parameter. Set to true when the parameter is required.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_idrequired)" }, "default": { "type": "input-default-context", "description": "A string representing the default value. The default value is used when an input parameter isn't specified in a workflow file.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_iddefault)" }, "deprecationMessage": { "type": "string", "description": "A string shown to users using the deprecated input, warning them that the input is deprecated and mentioning any alternatives.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_iddeprecationmessage)" } }, "loose-key-type": "non-empty-string", "loose-value-type": "any" } }, "input-default-context": { "description": "A string representing the default value. The default value is used when an input parameter isn't specified in a workflow file.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_iddefault)", "context": ["github", "strategy", "matrix", "job", "runner", "hashFiles(1,255)"], "string": {} }, "outputs": { "description": "Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-composite-actions)", "mapping": { "loose-key-type": "non-empty-string", "loose-value-type": "output-definition" } }, "output-definition": { "description": "An output parameter for this action.", "mapping": { "properties": { "description": { "type": "string", "description": "A string description of the output parameter.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputsoutput_iddescription)" }, "value": { "type": "output-value", "description": "The value that the output parameter will be mapped to. You can set this to a string or an expression with context.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputsoutput_idvalue)" } } } }, "output-value": { "description": "The value that the output parameter will be mapped to. You can set this to a string or an expression with context.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputsoutput_idvalue)", "context": ["github", "strategy", "matrix", "steps", "inputs", "job", "runner", "env"], "string": {} }, "runs-if": { "description": "Condition to control when this action's pre or post script runs.", "context": ["runner", "github", "job", "strategy", "matrix", "env", "inputs", "always(0,0)", "success(0,0)", "failure(0,0)", "cancelled(0,0)", "hashFiles(1,255)"], "string": {} }, "runs": { "one-of": ["container-runs", "node-runs", "composite-runs", "plugin-runs"] }, "runs-strict": { "description": "Specifies whether this is a JavaScript action, a composite action, or a Docker container action and how the action is executed.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runs)", "one-of": ["container-runs-strict", "node-runs-strict", "composite-runs-strict"] }, "plugin-runs": { "mapping": { "properties": { "plugin": "non-empty-string" } } }, "container-runs": { "mapping": { "properties": { "using": "non-empty-string", "image": "non-empty-string", "entrypoint": "non-empty-string", "args": "container-runs-args", "env": "container-runs-env", "pre-entrypoint": "non-empty-string", "pre-if": "non-empty-string", "post-entrypoint": "non-empty-string", "post-if": "non-empty-string" } } }, "container-runs-args": { "description": "An array of strings that define the inputs for a Docker container. Inputs can include hardcoded strings. GitHub passes the args to the container's ENTRYPOINT when the container starts up.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsargs)", "sequence": { "item-type": "container-runs-context" } }, "container-runs-env": { "description": "Specifies a key/value map of environment variables to set in the container environment.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsenv)", "context": ["inputs"], "mapping": { "loose-key-type": "non-empty-string", "loose-value-type": "string" } }, "container-runs-context": { "context": ["inputs"], "string": {} }, "node-runs": { "mapping": { "properties": { "using": "non-empty-string", "main": "non-empty-string", "pre": "non-empty-string", "pre-if": "non-empty-string", "post": "non-empty-string", "post-if": "non-empty-string" } } }, "composite-runs": { "mapping": { "properties": { "using": "non-empty-string", "steps": "composite-steps" } } }, "container-runs-strict": { "description": "Configuration for Docker container actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runs)", "mapping": { "properties": { "using": { "type": "using", "required": true, "description": "The runtime used to execute the action. Must be docker for Docker container actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsusing)" }, "image": { "type": "non-empty-string", "required": true, "description": "The Docker image to use as the container to run the action. The value can be the Docker base image name, a local Dockerfile in your repository, or a public image in Docker Hub or another registry.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsimage)" }, "entrypoint": { "type": "non-empty-string", "description": "Overrides the Docker ENTRYPOINT in the Dockerfile, or sets it if one wasn't already specified.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsentrypoint)" }, "args": "container-runs-args", "env": "container-runs-env", "pre-entrypoint": { "type": "non-empty-string", "description": "Allows you to run a script before the entrypoint action begins.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-entrypoint)" }, "pre-if": { "type": "runs-if", "description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)" }, "post-entrypoint": { "type": "non-empty-string", "description": "Allows you to run a cleanup script once the runs.entrypoint action has completed.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-entrypoint)" }, "post-if": { "type": "runs-if", "description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)" } } } }, "node-runs-strict": { "description": "Configuration for JavaScript actions executed with Node.js.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runs)", "mapping": { "properties": { "using": { "type": "using", "required": true, "description": "The runtime used to execute the action. Use node20 or node24 for JavaScript actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsusing)" }, "main": { "type": "non-empty-string", "required": true, "description": "The file that contains your action code. The runtime specified in using executes this file.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsmain)" }, "pre": { "type": "non-empty-string", "description": "Allows you to run a script at the start of a job, before the main: action begins. You can use pre: to run prerequisite setup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre)" }, "pre-if": { "type": "runs-if", "description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)" }, "post": { "type": "non-empty-string", "description": "Allows you to run a script at the end of a job, once the main: action has completed. You can use post: to run cleanup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost)" }, "post-if": { "type": "runs-if", "description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)" } } } }, "composite-runs-strict": { "description": "Configuration for composite actions that run multiple steps.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runs)", "mapping": { "properties": { "using": { "type": "using", "required": true, "description": "The runtime used to execute the action. Must be composite for composite actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsusing)" }, "steps": { "type": "composite-steps", "required": true } } } }, "composite-steps": { "description": "The steps that you plan to run in this action. These can be either run steps or uses steps.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runssteps)", "sequence": { "item-type": "composite-step" } }, "composite-step": { "description": "A step within a composite action.", "one-of": ["run-step", "uses-step"] }, "run-step": { "description": "Runs a command-line program using the operating system's shell.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsrun)", "mapping": { "properties": { "name": { "type": "string-steps-context", "description": "A name for your step to display on GitHub.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsname)" }, "id": { "type": "non-empty-string", "description": "A unique identifier for the step. You can use the id to reference the step in contexts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsid)" }, "if": { "type": "step-if", "description": "You can use the if conditional to prevent a step from running unless a condition is met. You can use any supported context and expression to create a conditional.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsif)" }, "run": { "type": "string-steps-context", "required": true, "description": "The command you want to run. This can be inline or a script in your action repository.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsrun)" }, "shell": { "type": "string-steps-context", "required": true, "description": "The shell where you want to run the command. Any shell supported by the runner can be used. Required if run is set.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsshell)" }, "env": "step-env", "continue-on-error": { "type": "boolean-steps-context", "description": "Prevents the action from failing when a step fails. Set to true to allow the action to pass when this step fails.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepscontinue-on-error)" }, "working-directory": { "type": "string-steps-context", "description": "Specifies the working directory where the command is run.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsworking-directory)" } } } }, "uses-step": { "description": "Runs another action as part of a step in your action.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsuses)", "mapping": { "properties": { "name": { "type": "string-steps-context", "description": "A name for your step to display on GitHub.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsname)" }, "id": { "type": "non-empty-string", "description": "A unique identifier for the step. You can use the id to reference the step in contexts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsid)" }, "if": { "type": "step-if", "description": "You can use the if conditional to prevent a step from running unless a condition is met. You can use any supported context and expression to create a conditional.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsif)" }, "uses": { "type": "non-empty-string", "required": true, "description": "Selects an action to run as part of a step in your action. An action is a reusable unit of code.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsuses)" }, "with": "step-with", "env": "step-env", "continue-on-error": { "type": "boolean-steps-context", "description": "Prevents the action from failing when a step fails. Set to true to allow the action to pass when this step fails.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepscontinue-on-error)" } } } }, "string-steps-context": { "context": ["github", "inputs", "strategy", "matrix", "steps", "job", "runner", "env", "hashFiles(1,255)"], "string": {} }, "boolean-steps-context": { "context": ["github", "inputs", "strategy", "matrix", "steps", "job", "runner", "env", "hashFiles(1,255)"], "boolean": {} }, "step-env": { "description": "Sets variables for steps to use in the runner environment. You can also set variables for the entire action.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsenv)", "context": ["github", "inputs", "strategy", "matrix", "steps", "job", "runner", "env", "hashFiles(1,255)"], "mapping": { "loose-key-type": "non-empty-string", "loose-value-type": "string" } }, "step-if": { "description": "You can use the if conditional to prevent a step from running unless a condition is met. You can use any supported context and expression to create a conditional.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsif)", "context": ["github", "inputs", "strategy", "matrix", "steps", "job", "runner", "env", "always(0,0)", "failure(0,0)", "cancelled(0,0)", "success(0,0)", "hashFiles(1,255)"], "string": {} }, "step-with": { "description": "A map of the input parameters defined by the action. Each input parameter is a key/value pair.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepswith)", "context": ["github", "inputs", "strategy", "matrix", "steps", "job", "runner", "env", "hashFiles(1,255)"], "mapping": { "loose-key-type": "non-empty-string", "loose-value-type": "string" } }, "branding": { "description": "You can use a color and Feather icon to create a badge to personalize and distinguish your action in GitHub Marketplace.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#branding)", "mapping": { "properties": { "icon": { "type": "branding-icon", "description": "The name of the v4.28.0 Feather icon to use.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#brandingicon)" }, "color": { "type": "branding-color", "description": "The background color of the badge.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#brandingcolor)" } } } }, "branding-icon": { "description": "The name of the v4.28.0 Feather icon to use. Brand icons are omitted as well as: coffee, columns, divide-circle, divide-square, divide, frown, hexagon, key, meh, mouse-pointer, smile, tool, x-octagon.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#brandingicon)", "allowed-values": ["activity", "airplay", "alert-circle", "alert-octagon", "alert-triangle", "align-center", "align-justify", "align-left", "align-right", "anchor", "aperture", "archive", "arrow-down-circle", "arrow-down-left", "arrow-down-right", "arrow-down", "arrow-left-circle", "arrow-left", "arrow-right-circle", "arrow-right", "arrow-up-circle", "arrow-up-left", "arrow-up-right", "arrow-up", "at-sign", "award", "bar-chart-2", "bar-chart", "battery-charging", "battery", "bell-off", "bell", "bluetooth", "bold", "book-open", "book", "bookmark", "box", "briefcase", "calendar", "camera-off", "camera", "cast", "check-circle", "check-square", "check", "chevron-down", "chevron-left", "chevron-right", "chevron-up", "chevrons-down", "chevrons-left", "chevrons-right", "chevrons-up", "circle", "clipboard", "clock", "cloud-drizzle", "cloud-lightning", "cloud-off", "cloud-rain", "cloud-snow", "cloud", "code", "command", "compass", "copy", "corner-down-left", "corner-down-right", "corner-left-down", "corner-left-up", "corner-right-down", "corner-right-up", "corner-up-left", "corner-up-right", "cpu", "credit-card", "crop", "crosshair", "database", "delete", "disc", "dollar-sign", "download-cloud", "download", "droplet", "edit-2", "edit-3", "edit", "external-link", "eye-off", "eye", "fast-forward", "feather", "file-minus", "file-plus", "file-text", "file", "film", "filter", "flag", "folder-minus", "folder-plus", "folder", "gift", "git-branch", "git-commit", "git-merge", "git-pull-request", "globe", "grid", "hard-drive", "hash", "headphones", "heart", "help-circle", "home", "image", "inbox", "info", "italic", "layers", "layout", "life-buoy", "link-2", "link", "list", "loader", "lock", "log-in", "log-out", "mail", "map-pin", "map", "maximize-2", "maximize", "menu", "message-circle", "message-square", "mic-off", "mic", "minimize-2", "minimize", "minus-circle", "minus-square", "minus", "monitor", "moon", "more-horizontal", "more-vertical", "move", "music", "navigation-2", "navigation", "octagon", "package", "paperclip", "pause-circle", "pause", "percent", "phone-call", "phone-forwarded", "phone-incoming", "phone-missed", "phone-off", "phone-outgoing", "phone", "pie-chart", "play-circle", "play", "plus-circle", "plus-square", "plus", "pocket", "power", "printer", "radio", "refresh-ccw", "refresh-cw", "repeat", "rewind", "rotate-ccw", "rotate-cw", "rss", "save", "scissors", "search", "send", "server", "settings", "share-2", "share", "shield-off", "shield", "shopping-bag", "shopping-cart", "shuffle", "sidebar", "skip-back", "skip-forward", "slash", "sliders", "smartphone", "speaker", "square", "star", "stop-circle", "sun", "sunrise", "sunset", "tablet", "tag", "target", "terminal", "thermometer", "thumbs-down", "thumbs-up", "toggle-left", "toggle-right", "trash-2", "trash", "trending-down", "trending-up", "triangle", "truck", "tv", "type", "umbrella", "underline", "unlock", "upload-cloud", "upload", "user-check", "user-minus", "user-plus", "user-x", "user", "users", "video-off", "video", "voicemail", "volume-1", "volume-2", "volume-x", "volume", "watch", "wifi-off", "wifi", "wind", "x-circle", "x-square", "x", "zap-off", "zap", "zoom-in", "zoom-out"] }, "branding-color": { "description": "The background color of the badge.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#brandingcolor)", "allowed-values": ["white", "yellow", "blue", "green", "orange", "red", "purple", "gray-dark"] }, "using": { "description": "The runtime used to execute the action.", "allowed-values": ["docker", "node12", "node16", "node20", "node24", "composite"] }, "non-empty-string": { "string": { "require-non-empty": true } } } }
A action/steps.go +205 -0
@@ -0,0 +1,205 @@
1+// SPDX-License-Identifier: MIT
2+
3+package action
4+
5+import (
6+ "strings"
7+
8+ "bnema.dev/ermite-actions/expr"
9+ "bnema.dev/ermite-actions/template"
10+ "bnema.dev/ermite-actions/workflow"
11+)
12+
13+// convertSteps is the runner's PipelineTemplateConverter.ConvertToSteps
14+// (Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs).
15+// It is not the conversion of package workflow, which reads the steps of a
16+// workflow file: this one throws at the first token of the wrong type, keeps
17+// a step whose "uses" has the wrong form (and then fails on it later if it has
18+// no id), accepts ids that start with "__", and names a "$/" action
19+// "selfRepository". See the runner for each of those.
20+func (l *loader) convertSteps(items *template.Sequence) []*workflow.Step {
21+ names := newRefNames()
22+ var result []*workflow.Step
23+ var refs []*stepRef
24+ for _, it := range items.Items {
25+ s, ref := l.convertStep(it, names)
26+ if s != nil {
27+ result = append(result, s)
28+ refs = append(refs, ref)
29+ }
30+ }
31+ for i, s := range result {
32+ if s.ID != "" {
33+ continue
34+ }
35+ ref := refs[i]
36+ if ref == nil {
37+ // The runner dereferences the Reference of a step whose "uses" had
38+ // the wrong form.
39+ throwf(exNullRef, "Object reference not set to an instance of an object.")
40+ }
41+ name := ref.name
42+ if name == "" {
43+ name = "run"
44+ }
45+ names.appendSegment("__" + name)
46+ s.ID = names.build()
47+ }
48+ return result
49+}
50+
51+// stepRef is what the runner makes of a step's action reference that decides
52+// the generated id: the image of a docker:// action, "owner/repo", "self" for
53+// a local action, "selfRepository" for a $/ action, "" for a run step.
54+type stepRef struct{ name string }
55+
56+func (l *loader) convertStep(t template.Token, names *refNames) (*workflow.Step, *stepRef) {
57+ m := assertMapping(t, "steps item")
58+ step := &workflow.Step{Pos: t.Pos()}
59+ var run template.Token
60+ var ifToken template.Token
61+ var uses *template.String
62+ for _, p := range m.Pairs {
63+ key := assertString(p.Key, "steps item key")
64+ v := p.Value
65+ switch key.Value {
66+ case "continue-on-error":
67+ if !template.IsExpression(v) {
68+ assertBoolean(v, "step continue-on-error")
69+ }
70+ step.ContinueOnError = assertScalar(v, "steps continue-on-error")
71+ case "env":
72+ if !template.IsExpression(v) {
73+ for _, kv := range assertMapping(v, "environment").Pairs {
74+ if template.IsExpression(kv.Key) {
75+ continue
76+ }
77+ assertString(kv.Key, "environment key")
78+ if template.IsExpression(kv.Value) {
79+ continue
80+ }
81+ assertString(kv.Value, "environment value")
82+ }
83+ }
84+ if em, ok := v.(*template.Mapping); ok {
85+ step.Env = em
86+ } else {
87+ step.EnvExpression = v
88+ }
89+ case "id":
90+ id := assertString(v, "steps item id")
91+ if id.Value != "" {
92+ if msg := names.tryAddKnownName(id.Value); msg != "" {
93+ l.ctx.ErrorAt(id, msg)
94+ }
95+ }
96+ step.ID, step.Explicit = id.Value, id.Value != ""
97+ case "if":
98+ ifToken = assertScalar(v, "steps item if")
99+ case "name":
100+ step.Name = assertScalar(v, "steps item name")
101+ case "run":
102+ run = assertScalar(v, "steps item run")
103+ case "shell":
104+ step.Shell = assertScalar(v, "steps item shell")
105+ case "timeout-minutes":
106+ if !template.IsExpression(v) {
107+ if _, ok := v.(*template.Number); !ok {
108+ assertFail(v, "step timeout-minutes", "NumberToken")
109+ }
110+ }
111+ step.TimeoutMinutes = assertScalar(v, "steps item timeout-minutes")
112+ case "uses":
113+ uses = assertString(v, "steps item uses")
114+ case "with":
115+ if !template.IsExpression(v) {
116+ for _, kv := range assertMapping(v, "inputs").Pairs {
117+ if template.IsExpression(kv.Key) {
118+ continue
119+ }
120+ assertString(kv.Key, "inputs key")
121+ if template.IsExpression(kv.Value) {
122+ continue
123+ }
124+ assertString(kv.Value, "inputs value")
125+ }
126+ }
127+ if wm, ok := v.(*template.Mapping); ok {
128+ step.With = wm
129+ } else {
130+ step.WithExpression = v
131+ }
132+ case "working-directory":
133+ step.WorkingDirectory = assertScalar(v, "steps item working-directory")
134+ case "clean", "fetch-depth", "lfs", "path", "submodules":
135+ assertScalar(v, "steps item "+key.Value)
136+ default:
137+ throwf(exArgument, "Error while reading 'steps item key'. Unexpected value '%s'", key.Value)
138+ }
139+ }
140+ step.If = l.convertIf(ifToken)
141+
142+ if run != nil {
143+ step.Run = run
144+ return step, &stepRef{}
145+ }
146+ if uses == nil {
147+ assertFail(nil, "steps item uses", "StringToken")
148+ }
149+ step.Uses = uses
150+ u, err := ParseUses(uses.Value)
151+ if err != nil {
152+ l.ctx.ErrorAt(uses, err.Error())
153+ if strings.HasPrefix(uses.Value, "$/") {
154+ // A $/ value with a ref drops the step.
155+ return nil, nil
156+ }
157+ return step, nil
158+ }
159+ ref := &stepRef{name: "self"}
160+ switch u.Kind {
161+ case UsesDocker:
162+ ref.name = u.Image
163+ case UsesRepository:
164+ ref.name = u.Name
165+ case UsesSelfRepository:
166+ ref.name = "selfRepository"
167+ }
168+ return step, ref
169+}
170+
171+// stepIfContexts are the contexts the runner's converter grants the "if" of a
172+// step on top of the ones the schema's step-if gives (which lacks vars and
173+// needs): s_stepNamedValues.
174+var stepIfContexts = []string{"vars", "needs"}
175+
176+// convertIf is ConvertToIfCondition for a step: a condition without a status
177+// function is wrapped as "success() && (cond)", an empty one is "success()".
178+func (l *loader) convertIf(tok template.Token) *template.BasicExpression {
179+ def := l.schema.Info("step-if").WithContexts(stepIfContexts...)
180+ var condition string
181+ switch s := tok.(type) {
182+ case nil:
183+ case *template.BasicExpression:
184+ condition = s.Expression
185+ default:
186+ condition = assertString(tok, "step if").Value
187+ }
188+ final := "success()"
189+ if !isNullOrWhiteSpace(condition) {
190+ node, err := expr.Parse(condition, def.NamedContexts(), def.Functions())
191+ if err != nil {
192+ l.ctx.ErrorAt(tok, err.Error())
193+ return nil
194+ }
195+ final = condition
196+ if node != nil && !workflow.HasStatusFunction(node) {
197+ final = "success() && (" + condition + ")"
198+ }
199+ }
200+ var o template.Origin
201+ if tok != nil {
202+ o = template.Origin{File: tok.File(), Pos: tok.Pos()}
203+ }
204+ return template.NewExpression(o, final, def)
205+}
A action/testdata/SOURCE +22 -0
@@ -0,0 +1,22 @@
1+cases/runner_*.yml are copied from actions/runner (MIT, Copyright (c) GitHub, Inc.,
2+https://github.com/actions/runner), src/Test/TestData, at commit
3+5f0b3ddcc6ee9fe9441e00d55e85b94ea4aad798 (the self_repository_* files, the
4+docker and node action manifests, the conditional composite action).
5+
6+cases/ls_parser_*.yml and cases/ls_validate_*.yml are the manifests written
7+inline in the tests of actions/languageservices (MIT, Copyright GitHub, Inc.),
8+commit 4043eda158e16579cc5fb1b0b07a4bce2a76f0b5:
9+workflow-parser/src/actions/action-parser.test.ts and
10+languageservice/src/validate-action.test.ts. Each file name is the test title.
11+
12+The other files of cases/ are written for this repository. uses.txt too.
13+
14+runner.json and uses.json are the output of the C# runner's own action.yml
15+loader (actions/runner commit above) on the files of cases/ and uses.txt, run
16+by ../gen/runner (Driver.cs is derived from Runner.Worker/
17+ActionManifestManagerLegacy.cs, MIT, Copyright (c) GitHub, Inc.).
18+
19+strict.json is the output of @actions/workflow-parser 0.3.61 (MIT, Copyright
20+GitHub, Inc.) on the files of cases/, generated by ../gen/strict.mjs.
21+
22+See ../../THIRD_PARTY.md. Regenerate with ../gen/regen.sh.
A action/testdata/cases/branding_bad_icon.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+branding:
4+ icon: not-an-icon
5+ color: pink
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/composite_all_keys.yml +29 -0
@@ -0,0 +1,29 @@
1+name: A
2+description: D
3+inputs:
4+ who:
5+ default: world
6+outputs:
7+ out:
8+ description: the out
9+ value: ${{ steps.a.outputs.r }}
10+runs:
11+ using: composite
12+ steps:
13+ - id: a
14+ name: Say ${{ inputs.who }}
15+ if: always() && inputs.who != ''
16+ run: echo "r=1" >> $GITHUB_OUTPUT
17+ shell: bash
18+ working-directory: ${{ github.workspace }}
19+ env:
20+ X: ${{ inputs.who }}
21+ continue-on-error: true
22+ - uses: actions/checkout@v4
23+ with:
24+ fetch-depth: 0
25+ ref: ${{ github.sha }}
26+ env:
27+ E: 1
28+ continue-on-error: ${{ inputs.who == 'x' }}
29+ if: ${{ failure() }}
A action/testdata/cases/composite_basic.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo hi
7+ shell: bash
A action/testdata/cases/composite_continue_expr.yml +11 -0
@@ -0,0 +1,11 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ continue-on-error: ${{ inputs.x == 'y' }}
9+ env: ${{ fromJSON(inputs.env) }}
10+ - uses: a/b@c
11+ with: ${{ fromJSON(inputs.with) }}
A action/testdata/cases/composite_continue_str.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ continue-on-error: 'yes'
A action/testdata/cases/composite_continue_true_str.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ continue-on-error: 'true'
A action/testdata/cases/composite_env_expr_mapping.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ env: ${{ fromJSON('{}') }}
A action/testdata/cases/composite_env_scalar.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ env: x
A action/testdata/cases/composite_expr_bad_function.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo ${{ nope(1) }}
7+ shell: bash
A action/testdata/cases/composite_expr_empty.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo ${{ }}
7+ shell: bash
A action/testdata/cases/composite_expr_hashfiles.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo ${{ hashFiles('**/go.sum') }}
7+ shell: bash
A action/testdata/cases/composite_expr_insert.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: ${{ insert }}
7+ shell: bash
A action/testdata/cases/composite_expr_needs_in_run.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo ${{ needs.a.result }}
7+ shell: bash
A action/testdata/cases/composite_expr_secrets.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo ${{ secrets.X }}
7+ shell: bash
A action/testdata/cases/composite_expr_status_in_run.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo ${{ success() }}
7+ shell: bash
A action/testdata/cases/composite_expr_unclosed.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo ${{ github.ref
7+ shell: bash
A action/testdata/cases/composite_expr_vars_in_run.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo ${{ vars.A }}
7+ shell: bash
A action/testdata/cases/composite_id_dup.yml +11 -0
@@ -0,0 +1,11 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ id: a
9+ - run: echo
10+ shell: bash
11+ id: A
A action/testdata/cases/composite_id_empty.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ id: ''
A action/testdata/cases/composite_id_hyphen_first.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ id: -a
A action/testdata/cases/composite_id_invalid.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ id: 1a
A action/testdata/cases/composite_id_long.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
A action/testdata/cases/composite_id_reserved.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ id: __run
A action/testdata/cases/composite_if_bad.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ if: foo == 1
A action/testdata/cases/composite_if_bool.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ if: false
A action/testdata/cases/composite_if_empty.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ if: ''
A action/testdata/cases/composite_if_literal_text.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ if: foo ${{ github.ref }}
A action/testdata/cases/composite_if_marker_vars.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ if: ${{ vars.x == 'y' }}
A action/testdata/cases/composite_if_needs.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ if: needs.a.result == 'success'
A action/testdata/cases/composite_if_syntax.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ if: 1 ==
A action/testdata/cases/composite_if_vars.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ if: vars.x == 'y'
A action/testdata/cases/composite_name_only.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - name: x
A action/testdata/cases/composite_no_steps.yml +4 -0
@@ -0,0 +1,4 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
A action/testdata/cases/composite_outputs_bad_context.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+outputs:
4+ a:
5+ value: ${{ secrets.x }}
6+runs:
7+ using: composite
8+ steps: []
A action/testdata/cases/composite_outputs_needs.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+outputs:
4+ a:
5+ value: ${{ needs.x }}
6+runs:
7+ using: composite
8+ steps: []
A action/testdata/cases/composite_outputs_value.yml +15 -0
@@ -0,0 +1,15 @@
1+name: A
2+description: D
3+outputs:
4+ a:
5+ value: ${{ steps.x.outputs.y }}
6+ b:
7+ description: only desc
8+ c:
9+ value: plain
10+runs:
11+ using: composite
12+ steps:
13+ - run: echo
14+ shell: bash
15+ id: x
A action/testdata/cases/composite_run_and_uses.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ uses: a/b@c
A action/testdata/cases/composite_run_no_shell.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
A action/testdata/cases/composite_shell_only.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - shell: bash
A action/testdata/cases/composite_step_background.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ background: true
A action/testdata/cases/composite_step_ids_generated.yml +16 -0
@@ -0,0 +1,16 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: a
7+ shell: bash
8+ - run: b
9+ shell: bash
10+ - uses: actions/setup-go@v5
11+ - uses: docker://ghcr.io/a/b:1
12+ - uses: ./x
13+ - uses: actions/setup-go@v5
14+ - run: c
15+ shell: bash
16+ id: __run_2
A action/testdata/cases/composite_step_not_mapping.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - echo
A action/testdata/cases/composite_step_null.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ -
A action/testdata/cases/composite_step_shell_on_uses.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: a/b@c
7+ shell: bash
A action/testdata/cases/composite_step_unknown_key.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ timeout-minutes: 5
A action/testdata/cases/composite_step_with_on_run.yml +9 -0
@@ -0,0 +1,9 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo
7+ shell: bash
8+ with:
9+ a: b
A action/testdata/cases/composite_steps_empty.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps: []
A action/testdata/cases/composite_steps_scalar.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps: x
A action/testdata/cases/composite_upper.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: Composite
5+ steps: []
A action/testdata/cases/composite_uses_bad_emptyref.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout@
7+ id: x
A action/testdata/cases/composite_uses_bad_noat.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout
7+ id: x
A action/testdata/cases/composite_uses_bad_noat_noid.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout
A action/testdata/cases/composite_uses_bad_norepo.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: checkout@v4
7+ id: x
A action/testdata/cases/composite_uses_bad_two_at.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: a/b@c@d
7+ id: x
A action/testdata/cases/composite_uses_empty.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: ''
A action/testdata/cases/composite_uses_forms.yml +17 -0
@@ -0,0 +1,17 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout@v4
7+ - uses: actions/aws/ec2@main
8+ - uses: ./local/action
9+ - uses: docker://alpine:3
10+ - uses: '.\\win\\action'
11+ - uses: $/self
12+ - uses: owner/repo/a/b/c@v1.2
13+ - uses: actions/checkout@v4
14+ - run: echo
15+ shell: sh
16+ - run: echo
17+ shell: sh
A action/testdata/cases/composite_uses_self_ref.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: $/foo@v1
A action/testdata/cases/composite_uses_self_ref_id.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: $/foo@v1
7+ id: x
A action/testdata/cases/composite_with_nonstring.yml +11 -0
@@ -0,0 +1,11 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: a/b@c
7+ with:
8+ n: 1
9+ b: true
10+ z:
11+ l: [1]
A action/testdata/cases/composite_with_scalar.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: a/b@c
7+ with: x
A action/testdata/cases/depth_exceeded.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+x: [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
4+runs:
5+ using: node20
6+ main: m.js
A action/testdata/cases/depth_exceeded_in_skipped.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ main: m.js
6+ weird: [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
A action/testdata/cases/depth_ok.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+x: [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
4+runs:
5+ using: node20
6+ main: m.js
A action/testdata/cases/depth_seq_100.yml +4 -0
@@ -0,0 +1,4 @@
1+x: [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
2+runs:
3+ using: node20
4+ main: m.js
A action/testdata/cases/depth_seq_98.yml +4 -0
@@ -0,0 +1,4 @@
1+x: [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
2+runs:
3+ using: node20
4+ main: m.js
A action/testdata/cases/depth_seq_99.yml +4 -0
@@ -0,0 +1,4 @@
1+x: [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
2+runs:
3+ using: node20
4+ main: m.js
A action/testdata/cases/description_expr.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: ${{ x }}
3+runs:
4+ using: node20
5+ main: m.js
A action/testdata/cases/docker_args_context_github.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ args:
7+ - ${{ github.ref }}
A action/testdata/cases/docker_args_scalar.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ args: abc
A action/testdata/cases/docker_dockerfile.yml +19 -0
@@ -0,0 +1,19 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ args:
7+ - a
8+ - ${{ inputs.x }}
9+ entrypoint: main.sh
10+ env:
11+ A: b
12+ C: ${{ inputs.x }}
13+ pre-entrypoint: pre.sh
14+ pre-if: success()
15+ post-entrypoint: post.sh
16+ post-if: failure()
17+inputs:
18+ x:
19+ default: 'y'
A action/testdata/cases/docker_env_context_github.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ env:
7+ A: ${{ github.ref }}
A action/testdata/cases/docker_env_list.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ env:
7+ - A
A action/testdata/cases/docker_env_number.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ env:
7+ A: 1
8+ B: true
A action/testdata/cases/docker_image.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ image: docker://alpine:3.19
A action/testdata/cases/docker_no_image.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ entrypoint: x
A action/testdata/cases/docker_upper.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: DOCKER
5+ image: Dockerfile
A action/testdata/cases/docker_with_main.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ main: x.js
A action/testdata/cases/emoji_uses.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - uses: 😀/b@v1
A action/testdata/cases/empty_file.yml +0 -0

no textual change.

A action/testdata/cases/fold_inputs_accent.yml +10 -0
@@ -0,0 +1,10 @@
1+name: A
2+description: D
3+inputs:
4+ é:
5+ default: x
6+ É:
7+ default: y
8+runs:
9+ using: node20
10+ main: m.js
A action/testdata/cases/fold_inputs_dotless.yml +12 -0
@@ -0,0 +1,12 @@
1+name: A
2+description: D
3+inputs:
4+ ı:
5+ default: x
6+ I:
7+ default: y
8+ i:
9+ default: z
10+runs:
11+ using: node20
12+ main: m.js
A action/testdata/cases/fold_inputs_greek.yml +12 -0
@@ -0,0 +1,12 @@
1+name: A
2+description: D
3+inputs:
4+ σ:
5+ default: x
6+ ς:
7+ default: y
8+ Σ:
9+ default: z
10+runs:
11+ using: node20
12+ main: m.js
A action/testdata/cases/fold_inputs_kelvin.yml +10 -0
@@ -0,0 +1,10 @@
1+name: A
2+description: D
3+inputs:
4+ k:
5+ default: x
6+ K:
7+ default: y
8+runs:
9+ using: node20
10+ main: m.js
A action/testdata/cases/fold_inputs_s.yml +10 -0
@@ -0,0 +1,10 @@
1+name: A
2+description: D
3+inputs:
4+ s:
5+ default: x
6+ ſ:
7+ default: y
8+runs:
9+ using: node20
10+ main: m.js
A action/testdata/cases/fold_inputs_sharp.yml +10 -0
@@ -0,0 +1,10 @@
1+name: A
2+description: D
3+inputs:
4+ ß:
5+ default: x
6+ SS:
7+ default: y
8+runs:
9+ using: node20
10+ main: m.js
A action/testdata/cases/fold_runs_keys.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ main: m.js
6+ é: 1
7+ É: 2
A action/testdata/cases/fold_step_ids_ascii_dup.yml +11 -0
@@ -0,0 +1,11 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: x
7+ shell: sh
8+ id: abc
9+ - run: x
10+ shell: sh
11+ id: ABC
A action/testdata/cases/fold_step_ids_long.yml +11 -0
@@ -0,0 +1,11 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: x
7+ shell: sh
8+ id: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé
9+ - run: x
10+ shell: sh
11+ id: ÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉÉ
A action/testdata/cases/fold_step_ids_long_s.yml +11 -0
@@ -0,0 +1,11 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: x
7+ shell: sh
8+ id: ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss
9+ - run: x
10+ shell: sh
11+ id: ſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſſ
A action/testdata/cases/fold_top_keys.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+NAME: B
3+é: 1
4+É: 2
5+description: D
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/fold_using_dotless.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: DOCKER
5+ image: Dockerfile
A action/testdata/cases/fold_using_kelvin.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: Komposite
5+ steps: []
A action/testdata/cases/inputs_default_bad_context.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: ${{ inputs.b }}
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/inputs_default_hashfiles.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: ${{ hashFiles('x') }}
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/inputs_default_list.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: [1]
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/inputs_default_secrets.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: ${{ secrets.b }}
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/inputs_deprecation_nonstring.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ deprecationMessage: [x]
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/inputs_dup_deprecated_case.yml +9 -0
@@ -0,0 +1,9 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ deprecationMessage: x
6+ DeprecationMessage: y
7+runs:
8+ using: node20
9+ main: m.js
A action/testdata/cases/inputs_dup_names_case.yml +10 -0
@@ -0,0 +1,10 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 1
6+ A:
7+ default: 2
8+runs:
9+ using: node20
10+ main: m.js
A action/testdata/cases/inputs_empty_key.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ '':
5+ default: x
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/inputs_list.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+inputs:
4+ - a
5+runs:
6+ using: node20
7+ main: m.js
A action/testdata/cases/inputs_scalar_value.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+inputs:
4+ a: x
5+runs:
6+ using: node20
7+ main: m.js
A action/testdata/cases/inputs_variants.yml +31 -0
@@ -0,0 +1,31 @@
1+name: A
2+description: D
3+inputs:
4+ plain:
5+ with-default:
6+ default: x
7+ expr-default:
8+ default: ${{ github.ref }}
9+ required-bool:
10+ required: true
11+ description: desc
12+ required-str:
13+ required: 'true'
14+ deprecated:
15+ deprecationMessage: use other
16+ default: ''
17+ Cap:
18+ default: y
19+ upper-key:
20+ DEFAULT: z
21+ number-default:
22+ default: 5
23+ bool-default:
24+ default: false
25+ null-default:
26+ default:
27+ unknown:
28+ anything: [1, 2]
29+runs:
30+ using: node20
31+ main: m.js
A action/testdata/cases/inputs_variants_ok.yml +31 -0
@@ -0,0 +1,31 @@
1+name: A
2+description: D
3+inputs:
4+ with-default:
5+ default: x
6+ expr-default:
7+ default: ${{ github.ref }}
8+ required-bool:
9+ required: true
10+ description: desc
11+ required-str:
12+ required: 'true'
13+ deprecated:
14+ deprecationMessage: use other
15+ default: ''
16+ Cap:
17+ default: y
18+ upper-key:
19+ DEFAULT: z
20+ number-default:
21+ default: 5
22+ bool-default:
23+ default: false
24+ null-default:
25+ default:
26+ unknown:
27+ anything: [1, 2]
28+ empty-mapping: {}
29+runs:
30+ using: node20
31+ main: m.js
A action/testdata/cases/int32_00.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0x7FFFFFFF
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_01.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0x80000000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_02.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0xFFFFFFFF
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_03.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0x100000000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_04.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0o17777777777
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_05.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0o20000000000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_06.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0o37777777777
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_07.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0o40000000000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_08.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: !!int 0xFFFFFFFF
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_09.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: !!int 0x80000000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_10.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: !!int 0x100000000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_11.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: !!int 0o20000000000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_12.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: !!int 0o40000000000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_13.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: !!int 12
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_14.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: !!float 0x10
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_15.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0x0
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_16.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0xDEADBEEF
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/int32_key.yml +10 -0
@@ -0,0 +1,10 @@
1+name: A
2+description: D
3+inputs:
4+ 0xFFFFFFFF:
5+ default: x
6+ 0x80000000:
7+ default: y
8+runs:
9+ using: node20
10+ main: m.js
A action/testdata/cases/key_bool_dup.yml +10 -0
@@ -0,0 +1,10 @@
1+name: A
2+description: D
3+inputs:
4+ TRUE:
5+ default: x
6+ true:
7+ default: y
8+runs:
9+ using: node20
10+ main: m.js
A action/testdata/cases/key_float_dup.yml +10 -0
@@ -0,0 +1,10 @@
1+name: A
2+description: D
3+inputs:
4+ 1.0:
5+ default: x
6+ '1':
7+ default: y
8+runs:
9+ using: node20
10+ main: m.js
A action/testdata/cases/key_hex_dup.yml +10 -0
@@ -0,0 +1,10 @@
1+name: A
2+description: D
3+inputs:
4+ 0x10:
5+ default: x
6+ 16:
7+ default: y
8+runs:
9+ using: node20
10+ main: m.js
A action/testdata/cases/key_hex_input.yml +12 -0
@@ -0,0 +1,12 @@
1+name: A
2+description: D
3+inputs:
4+ 0x10:
5+ default: x
6+ 1.0:
7+ default: y
8+ TRUE:
9+ default: z
10+runs:
11+ using: node20
12+ main: m.js
A action/testdata/cases/key_null_env.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ env:
7+ ~: v
A action/testdata/cases/key_null_inputs.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ ~:
5+ default: x
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/key_null_top.yml +6 -0
@@ -0,0 +1,6 @@
1+~: 1
2+name: A
3+description: D
4+runs:
5+ using: node20
6+ main: m.js
A action/testdata/cases/key_number_runs.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ main: m.js
6+ 1e3: x
A action/testdata/cases/ls_parser_accepts_valid_context_in_pre_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: Node Action
2+description: A node action
3+runs:
4+ using: node20
5+ main: dist/index.js
6+ pre: dist/setup.js
7+ pre-if: runner.os == 'Linux'⏎̸
A action/testdata/cases/ls_parser_converts_a_composite_action.yml +22 -0
@@ -0,0 +1,22 @@
1+name: Composite Action
2+description: A composite action
3+author: Test Author
4+inputs:
5+ name:
6+ description: The name
7+ required: true
8+ default: World
9+outputs:
10+ result:
11+ description: The result
12+ value: ${{ steps.main.outputs.result }}
13+runs:
14+ using: composite
15+ steps:
16+ - id: main
17+ name: Main step
18+ run: echo Hello ${{ inputs.name }}
19+ shell: bash
20+branding:
21+ icon: star
22+ color: green⏎̸
A action/testdata/cases/ls_parser_converts_a_docker_action.yml +11 -0
@@ -0,0 +1,11 @@
1+name: Docker Action
2+description: A docker action
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ entrypoint: /entrypoint.sh
7+ args:
8+ - --name
9+ - ${{ inputs.name }}
10+ env:
11+ DEBUG: "true"⏎̸
A action/testdata/cases/ls_parser_converts_a_node_action.yml +9 -0
@@ -0,0 +1,9 @@
1+name: Node Action
2+description: A node action
3+runs:
4+ using: node20
5+ main: dist/index.js
6+ pre: dist/setup.js
7+ pre-if: runner.os == 'Linux'
8+ post: dist/cleanup.js
9+ post-if: always()⏎̸
A action/testdata/cases/ls_parser_converts_uses_steps_in_composite_action.yml +8 -0
@@ -0,0 +1,8 @@
1+name: Composite with Uses
2+description: Composite action with uses steps
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout@v4
7+ with:
8+ fetch-depth: 0⏎̸
A action/testdata/cases/ls_parser_parses_a_docker_action.yml +9 -0
@@ -0,0 +1,9 @@
1+name: Docker Action
2+description: A Docker action
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ args:
7+ - ${{ inputs.name }}
8+ env:
9+ DEBUG: "true"⏎̸
A action/testdata/cases/ls_parser_parses_a_javascript_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: JS Action
2+description: A JavaScript action
3+runs:
4+ using: node20
5+ main: dist/index.js
6+ pre: dist/setup.js
7+ post: dist/cleanup.js⏎̸
A action/testdata/cases/ls_parser_parses_a_minimal_action_yml.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: A simple action
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo Hello
7+ shell: bash⏎̸
A action/testdata/cases/ls_parser_parses_action_with_branding.yml +10 -0
@@ -0,0 +1,10 @@
1+name: Branded Action
2+description: Action with branding
3+branding:
4+ icon: award
5+ color: blue
6+runs:
7+ using: composite
8+ steps:
9+ - run: echo Hello
10+ shell: bash⏎̸
A action/testdata/cases/ls_parser_parses_action_with_inputs_and_outputs.yml +20 -0
@@ -0,0 +1,20 @@
1+name: Action with I/O
2+description: Action with inputs and outputs
3+inputs:
4+ name:
5+ description: The name to greet
6+ required: true
7+ default: World
8+ verbose:
9+ description: Enable verbose mode
10+ required: false
11+outputs:
12+ greeting:
13+ description: The greeting message
14+ value: ${{ steps.greet.outputs.message }}
15+runs:
16+ using: composite
17+ steps:
18+ - id: greet
19+ run: echo "::set-output name=message::Hello ${{ inputs.name }}"
20+ shell: bash⏎̸
A action/testdata/cases/ls_parser_reports_error_for_invalid_context_in_pre_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: Node Action
2+description: A node action
3+runs:
4+ using: node20
5+ main: dist/index.js
6+ pre: dist/setup.js
7+ pre-if: foo == bar⏎̸
A action/testdata/cases/ls_parser_reports_error_for_invalid_yaml.yml +8 -0
@@ -0,0 +1,8 @@
1+name: Invalid Action
2+description: Action with bad YAML
3+runs:
4+ using: composite
5+ steps:
6+ - name: 'Hello ${{ fromJSON('test') }}'
7+ run: echo test
8+ shell: bash⏎̸
A action/testdata/cases/ls_parser_validates_branding_color_values.yml +10 -0
@@ -0,0 +1,10 @@
1+name: Bad Color
2+description: Action with invalid branding color
3+branding:
4+ icon: award
5+ color: pink
6+runs:
7+ using: composite
8+ steps:
9+ - run: echo Hello
10+ shell: bash⏎̸
A action/testdata/cases/ls_parser_validates_branding_icon_values.yml +10 -0
@@ -0,0 +1,10 @@
1+name: Bad Icon
2+description: Action with invalid branding icon
3+branding:
4+ icon: invalid-icon-name
5+ color: blue
6+runs:
7+ using: composite
8+ steps:
9+ - run: echo Hello
10+ shell: bash⏎̸
A action/testdata/cases/ls_parser_validates_required_fields.yml +5 -0
@@ -0,0 +1,5 @@
1+runs:
2+ using: composite
3+ steps:
4+ - run: echo Hello
5+ shell: bash⏎̸
A action/testdata/cases/ls_parser_validates_shell_is_required_for_run_steps.yml +6 -0
@@ -0,0 +1,6 @@
1+name: Missing Shell
2+description: Action without shell in run step
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo Hello⏎̸
A action/testdata/cases/ls_validate_allows_expression_with_cancelled_in_post_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Valid post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: cancelled()
A action/testdata/cases/ls_validate_allows_expression_with_failure_in_post_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Valid post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: failure()
A action/testdata/cases/ls_validate_allows_format_with_only_replacement_tokens.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Format with only tokens
3+runs:
4+ using: composite
5+ steps:
6+ - if: ${{ format('{0}', github.event_name) }}
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_allows_full_sha.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Full SHA
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
A action/testdata/cases/ls_validate_allows_hashfiles_function_in_composite_step_if.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: hashFiles in if
3+runs:
4+ using: composite
5+ steps:
6+ - if: hashFiles('**/package-lock.json') != ''
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_allows_hashfiles_function_in_pre_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: hashFiles in pre-if
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: setup.js
7+ pre-if: hashFiles('**/package-lock.json') != ''
A action/testdata/cases/ls_validate_allows_if_without_expression_markers_auto_wrapped.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: If without markers
3+runs:
4+ using: composite
5+ steps:
6+ - if: github.event_name == 'push'
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_allows_status_functions_in_post_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Status functions in post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: always() || failure()
A action/testdata/cases/ls_validate_allows_success_failure_always_cancelled_functions_in_composi.yml +14 -0
@@ -0,0 +1,14 @@
1+name: My Action
2+description: Status functions in if
3+runs:
4+ using: composite
5+ steps:
6+ - if: success() && !cancelled()
7+ run: echo success
8+ shell: bash
9+ - if: failure()
10+ run: echo failure
11+ shell: bash
12+ - if: always()
13+ run: echo always
14+ shell: bash
A action/testdata/cases/ls_validate_allows_success_function.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Success function
3+runs:
4+ using: composite
5+ steps:
6+ - if: success()
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_allows_valid_contexts_in_composite_step_if.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Valid context in if
3+runs:
4+ using: composite
5+ steps:
6+ - if: github.event_name == 'push'
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_allows_valid_contexts_in_post_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Valid context in post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: runner.os == 'Linux'
A action/testdata/cases/ls_validate_allows_valid_contexts_in_pre_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Valid context in pre-if
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: setup.js
7+ pre-if: runner.os == 'Linux'
A action/testdata/cases/ls_validate_allows_valid_expression_in_if.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Valid if expression
3+runs:
4+ using: composite
5+ steps:
6+ - if: ${{ github.event_name == 'push' }}
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_allows_valid_expression_in_post_if_for_node_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Valid post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: always()
A action/testdata/cases/ls_validate_allows_valid_expression_in_pre_if_for_node_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Valid pre-if
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: setup.js
7+ pre-if: success()
A action/testdata/cases/ls_validate_allows_valid_format_call_in_composite_step_if.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Valid format
3+runs:
4+ using: composite
5+ steps:
6+ - if: format('{0} {1}', 'a', 'b') == 'a b'
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_allows_valid_format_call_in_pre_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Valid format in pre-if
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: setup.js
7+ pre-if: format('{0}', runner.os) == 'Linux'
A action/testdata/cases/ls_validate_errors_on_explicit_expression_syntax_in_post_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Explicit expression in post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: ${{ always() }}
A action/testdata/cases/ls_validate_errors_on_explicit_expression_syntax_in_pre_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Explicit expression in pre-if
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: setup.js
7+ pre-if: ${{ runner.os == 'Windows' }}
A action/testdata/cases/ls_validate_errors_on_explicit_expression_with_literal_text_in_post_if_f.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Literal text in post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: event == ${{ github.event_name }}
A action/testdata/cases/ls_validate_errors_on_explicit_expression_with_literal_text_in_post_if_f_2.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Literal text in post-if
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ post-entrypoint: /cleanup.sh
7+ post-if: event == ${{ github.event_name }}
A action/testdata/cases/ls_validate_errors_on_explicit_expression_with_literal_text_in_pre_if_fo.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Literal text in pre-if
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: setup.js
7+ pre-if: push == ${{ github.event_name }}
A action/testdata/cases/ls_validate_errors_on_explicit_expression_with_literal_text_in_pre_if_fo_2.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Literal text in pre-if
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ pre-entrypoint: /setup.sh
7+ pre-if: push == ${{ github.event_name }}
A action/testdata/cases/ls_validate_errors_on_format_with_literal_text_in_if.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Format with literal text
3+runs:
4+ using: composite
5+ steps:
6+ - if: ${{ format('event is {0}', github.event_name) }}
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_errors_on_format_with_too_few_arguments_in_composite_step_if.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Format mismatch
3+runs:
4+ using: composite
5+ steps:
6+ - if: format('{0} {1}', 'only-one')
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_errors_on_format_with_too_few_arguments_in_input_default.yml +9 -0
@@ -0,0 +1,9 @@
1+name: My Action
2+description: Format mismatch in input default
3+inputs:
4+ greeting:
5+ description: Greeting message
6+ default: ${{ format('{0} {1}', 'hello') }}
7+runs:
8+ using: node20
9+ main: index.js
A action/testdata/cases/ls_validate_errors_on_format_with_too_few_arguments_in_post_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Format mismatch in post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: format('{0} {1} {2}', 'a', 'b')
A action/testdata/cases/ls_validate_errors_on_format_with_too_few_arguments_in_pre_if.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Format mismatch in pre-if
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: setup.js
7+ pre-if: format('{0} {1}', 'only-one')
A action/testdata/cases/ls_validate_errors_on_format_with_too_few_arguments_in_run_expression.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Format mismatch in run
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo ${{ format('{0} {1}', 'only-one') }}
7+ shell: bash
A action/testdata/cases/ls_validate_errors_on_invalid_format.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Invalid format
3+runs:
4+ using: composite
5+ steps:
6+ - uses: invalid-format
A action/testdata/cases/ls_validate_errors_on_invalid_format_string_in_composite_step_if.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Invalid format
3+runs:
4+ using: composite
5+ steps:
6+ - if: format('{', 'arg')
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_errors_on_missing_ref.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Missing version
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout
A action/testdata/cases/ls_validate_errors_on_reusable_workflow_in_step_uses.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Wrong workflow reference
3+runs:
4+ using: composite
5+ steps:
6+ - uses: owner/repo/.github/workflows/build.yml@main
A action/testdata/cases/ls_validate_errors_on_unknown_function_in_composite_step_if.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Unknown function in if
3+runs:
4+ using: composite
5+ steps:
6+ - if: unknownFunc()
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_errors_on_unknown_function_in_post_if_for_docker_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Unknown function in post-if
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ post-entrypoint: /cleanup.sh
7+ post-if: unknownFunc()
A action/testdata/cases/ls_validate_errors_on_unknown_function_in_post_if_for_node_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Unknown function in post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: unknownFunc()
A action/testdata/cases/ls_validate_errors_on_unknown_function_in_pre_if_for_docker_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Unknown function in pre-if
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ pre-entrypoint: /setup.sh
7+ pre-if: unknownFunc()
A action/testdata/cases/ls_validate_errors_on_unknown_function_in_pre_if_for_node_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Unknown function in pre-if
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: setup.js
7+ pre-if: unknownFunc()
A action/testdata/cases/ls_validate_errors_when_literal_text_mixed_with_embedded_expression.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Literal text in if
3+runs:
4+ using: composite
5+ steps:
6+ - if: push == ${{ github.event_name }}
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_lets_schema_handle_invalid_using_value.yml +5 -0
@@ -0,0 +1,5 @@
1+name: My Action
2+description: Invalid - bad using value
3+runs:
4+ using: not-supported
5+ main: index.js
A action/testdata/cases/ls_validate_lets_schema_handle_missing_using.yml +4 -0
@@ -0,0 +1,4 @@
1+name: My Action
2+description: Invalid - no using
3+runs:
4+ main: index.js
A action/testdata/cases/ls_validate_passes_validation_for_valid_composite_action_uses_steps.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Composite Action
2+description: A composite action with uses steps
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout@v4
7+ with:
8+ repository: owner/repo
A action/testdata/cases/ls_validate_reports_error_for_composite_action_missing_steps.yml +4 -0
@@ -0,0 +1,4 @@
1+name: My Action
2+description: Invalid - composite without steps
3+runs:
4+ using: composite
A action/testdata/cases/ls_validate_reports_error_for_composite_action_with_main.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Invalid - composite with main
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo "hello"
7+ shell: bash
8+ main: index.js
A action/testdata/cases/ls_validate_reports_error_for_composite_step_missing_shell.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Missing shell
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo "Hi"
A action/testdata/cases/ls_validate_reports_error_for_docker_action_missing_image.yml +4 -0
@@ -0,0 +1,4 @@
1+name: My Action
2+description: Invalid - docker without image
3+runs:
4+ using: docker
A action/testdata/cases/ls_validate_reports_error_for_docker_action_with_entrypoint_but_missing_.yml +5 -0
@@ -0,0 +1,5 @@
1+name: My Action
2+description: Invalid - docker without image
3+runs:
4+ using: docker
5+ entrypoint: /entrypoint.sh
A action/testdata/cases/ls_validate_reports_error_for_docker_action_with_main.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Invalid - docker with main
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ main: index.js
A action/testdata/cases/ls_validate_reports_error_for_docker_action_with_steps.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Invalid - docker with steps
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ steps:
7+ - run: echo "hello"
8+ shell: bash
A action/testdata/cases/ls_validate_reports_error_for_invalid_branding_color.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Bad color
3+branding:
4+ icon: activity
5+ color: pink
6+runs:
7+ using: node20
8+ main: index.js
A action/testdata/cases/ls_validate_reports_error_for_invalid_branding_icon.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Bad icon
3+branding:
4+ icon: not-a-real-icon
5+ color: blue
6+runs:
7+ using: node20
8+ main: index.js
A action/testdata/cases/ls_validate_reports_error_for_invalid_yaml_syntax.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Bad YAML
3+runs:
4+ using: composite
5+ steps:
6+ - run: |
7+ echo "Bad indentation"
8+ shell: bash
A action/testdata/cases/ls_validate_reports_error_for_missing_required_description.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+runs:
3+ using: composite
4+ steps:
5+ - run: echo "Hi"
6+ shell: bash
A action/testdata/cases/ls_validate_reports_error_for_missing_required_name.yml +6 -0
@@ -0,0 +1,6 @@
1+description: An action without a name
2+runs:
3+ using: composite
4+ steps:
5+ - run: echo "Hi"
6+ shell: bash
A action/testdata/cases/ls_validate_reports_error_for_missing_runs.yml +2 -0
@@ -0,0 +1,2 @@
1+name: My Action
2+description: An action without runs
A action/testdata/cases/ls_validate_reports_error_for_missing_using_in_runs.yml +4 -0
@@ -0,0 +1,4 @@
1+name: My Action
2+description: Missing using
3+runs:
4+ main: index.js
A action/testdata/cases/ls_validate_reports_error_for_node20_action_missing_main.yml +5 -0
@@ -0,0 +1,5 @@
1+name: My Action
2+description: Invalid - node20 without main
3+runs:
4+ using: node20
5+ pre: setup.js
A action/testdata/cases/ls_validate_reports_error_for_node20_action_with_steps.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Invalid - node20 with steps
3+runs:
4+ using: node20
5+ main: index.js
6+ steps:
7+ - run: echo "hello"
8+ shell: bash
A action/testdata/cases/ls_validate_reports_error_for_node24_action_missing_main.yml +5 -0
@@ -0,0 +1,5 @@
1+name: My Action
2+description: Invalid - node24 without main
3+runs:
4+ using: node24
5+ pre: setup.js
A action/testdata/cases/ls_validate_reports_error_for_node24_action_with_only_using_no_narrowing.yml +4 -0
@@ -0,0 +1,4 @@
1+name: My Action
2+description: Invalid - node24 without main
3+runs:
4+ using: node24
A action/testdata/cases/ls_validate_reports_unresolved_action_in_composite_action_uses_steps.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Composite Action
2+description: A composite action with uses steps
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/nonexistent@v1
A action/testdata/cases/ls_validate_validates_a_composite_action_with_a_self_repository_step.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Does something
3+runs:
4+ using: composite
5+ steps:
6+ - uses: $/actions/nested
A action/testdata/cases/ls_validate_validates_a_docker_action.yml +5 -0
@@ -0,0 +1,5 @@
1+name: My Action
2+description: A Docker action
3+runs:
4+ using: docker
5+ image: Dockerfile
A action/testdata/cases/ls_validate_validates_a_minimal_composite_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Does something
3+runs:
4+ using: composite
5+ steps:
6+ - run: echo "Hello"
7+ shell: bash
A action/testdata/cases/ls_validate_validates_a_node20_action.yml +5 -0
@@ -0,0 +1,5 @@
1+name: My Action
2+description: A JavaScript action
3+runs:
4+ using: node20
5+ main: index.js
A action/testdata/cases/ls_validate_validates_action_inputs_in_composite_action_uses_steps.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Composite Action
2+description: A composite action with uses steps
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout@v4
7+ with:
8+ invalid-input: value
A action/testdata/cases/ls_validate_validates_an_action_with_branding.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Branded action
3+branding:
4+ icon: activity
5+ color: blue
6+runs:
7+ using: node20
8+ main: index.js
A action/testdata/cases/ls_validate_validates_an_action_with_inputs_and_outputs.yml +17 -0
@@ -0,0 +1,17 @@
1+name: My Action
2+description: Action with I/O
3+inputs:
4+ name:
5+ description: The name to greet
6+ required: true
7+ greeting:
8+ description: The greeting
9+ default: Hello
10+outputs:
11+ result:
12+ description: The greeting result
13+runs:
14+ using: composite
15+ steps:
16+ - run: echo "$\{{ inputs.greeting }} $\{{ inputs.name }}"
17+ shell: bash
A action/testdata/cases/ls_validate_validates_docker_uses_format.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Uses docker image
3+runs:
4+ using: composite
5+ steps:
6+ - uses: docker://alpine:3.14
A action/testdata/cases/ls_validate_validates_if_in_uses_step.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: If in uses step
3+runs:
4+ using: composite
5+ steps:
6+ - if: push == ${{ github.event_name }}
7+ uses: actions/checkout@v4
A action/testdata/cases/ls_validate_validates_local_uses_format.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Uses local action
3+runs:
4+ using: composite
5+ steps:
6+ - uses: ./local-action
A action/testdata/cases/ls_validate_validates_required_inputs_in_composite_action_uses_steps.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Composite Action
2+description: A composite action with uses steps
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/some-action@v1
A action/testdata/cases/ls_validate_validates_valid_uses_format_with_version.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Uses another action
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout@v4
A action/testdata/cases/ls_validate_warns_on_short_sha.yml +6 -0
@@ -0,0 +1,6 @@
1+name: My Action
2+description: Short SHA
3+runs:
4+ using: composite
5+ steps:
6+ - uses: actions/checkout@a1b2c3d
A action/testdata/cases/ls_validate_warns_on_unknown_context_in_composite_step_if.yml +8 -0
@@ -0,0 +1,8 @@
1+name: My Action
2+description: Unknown context in if
3+runs:
4+ using: composite
5+ steps:
6+ - if: foo == bar
7+ run: echo hi
8+ shell: bash
A action/testdata/cases/ls_validate_warns_on_unknown_context_in_post_if_for_docker_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Unknown context in post-if
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ post-entrypoint: /cleanup.sh
7+ post-if: foo == bar
A action/testdata/cases/ls_validate_warns_on_unknown_context_in_post_if_for_node_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Unknown context in post-if
3+runs:
4+ using: node20
5+ main: index.js
6+ post: cleanup.js
7+ post-if: foo == bar
A action/testdata/cases/ls_validate_warns_on_unknown_context_in_pre_if_for_docker_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Unknown context in pre-if
3+runs:
4+ using: docker
5+ image: Dockerfile
6+ pre-entrypoint: /setup.sh
7+ pre-if: foo == bar
A action/testdata/cases/ls_validate_warns_on_unknown_context_in_pre_if_for_node_action.yml +7 -0
@@ -0,0 +1,7 @@
1+name: My Action
2+description: Unknown context in pre-if
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: setup.js
7+ pre-if: foo == bar
A action/testdata/cases/many_errors.yml +20 -0
@@ -0,0 +1,20 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ main: m.js
6+ k0: 1
7+ k1: 1
8+ k2: 1
9+ k3: 1
10+ k4: 1
11+ k5: 1
12+ k6: 1
13+ k7: 1
14+ k8: 1
15+ k9: 1
16+ k10: 1
17+ k11: 1
18+ k12: 1
19+ k13: 1
20+ k14: 1
A action/testdata/cases/many_errors_composite.yml +41 -0
@@ -0,0 +1,41 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: x
7+ shell: bash
8+ k0: 1
9+ - run: x
10+ shell: bash
11+ k1: 1
12+ - run: x
13+ shell: bash
14+ k2: 1
15+ - run: x
16+ shell: bash
17+ k3: 1
18+ - run: x
19+ shell: bash
20+ k4: 1
21+ - run: x
22+ shell: bash
23+ k5: 1
24+ - run: x
25+ shell: bash
26+ k6: 1
27+ - run: x
28+ shell: bash
29+ k7: 1
30+ - run: x
31+ shell: bash
32+ k8: 1
33+ - run: x
34+ shell: bash
35+ k9: 1
36+ - run: x
37+ shell: bash
38+ k10: 1
39+ - run: x
40+ shell: bash
41+ k11: 1
A action/testdata/cases/name_empty.yml +5 -0
@@ -0,0 +1,5 @@
1+name: ''
2+description: D
3+runs:
4+ using: node20
5+ main: m.js
A action/testdata/cases/name_expr.yml +5 -0
@@ -0,0 +1,5 @@
1+name: ${{ github.ref }}
2+description: D
3+runs:
4+ using: node20
5+ main: m.js
A action/testdata/cases/name_list.yml +5 -0
@@ -0,0 +1,5 @@
1+name: [a]
2+description: D
3+runs:
4+ using: node20
5+ main: m.js
A action/testdata/cases/name_number.yml +5 -0
@@ -0,0 +1,5 @@
1+name: 123
2+description: true
3+runs:
4+ using: node20
5+ main: m.js
A action/testdata/cases/nested_steps_if_status.yml +14 -0
@@ -0,0 +1,14 @@
1+name: A
2+description: D
3+runs:
4+ using: composite
5+ steps:
6+ - run: x
7+ shell: bash
8+ if: contains(format('{0}', success()), 'x')
9+ - run: y
10+ shell: bash
11+ if: failure() || cancelled()
12+ - run: z
13+ shell: bash
14+ if: github.event_name == 'push'
A action/testdata/cases/no_description.yml +4 -0
@@ -0,0 +1,4 @@
1+name: A
2+runs:
3+ using: node20
4+ main: m.js
A action/testdata/cases/no_name.yml +4 -0
@@ -0,0 +1,4 @@
1+description: D
2+runs:
3+ using: node20
4+ main: m.js
A action/testdata/cases/node12.yml +9 -0
@@ -0,0 +1,9 @@
1+name: A
2+description: D
3+runs:
4+ using: node12
5+ main: index.js
6+ pre: setup.js
7+ pre-if: runner.os == 'Linux'
8+ post: cleanup.js
9+ post-if: failure()
A action/testdata/cases/node16.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: node16
5+ main: index.js
A action/testdata/cases/node20_min.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ main: dist/index.js
A action/testdata/cases/node24.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ using: node24
5+ main: index.js
6+ post: post.js
A action/testdata/cases/node_empty_main.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ main: ''
A action/testdata/cases/node_pre_if_bad_context.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: p.js
7+ pre-if: foo == bar
A action/testdata/cases/node_pre_if_expr_marker.yml +7 -0
@@ -0,0 +1,7 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ main: index.js
6+ pre: p.js
7+ pre-if: ${{ always() }}
A action/testdata/cases/node_upper.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: Node20
5+ main: index.js
A action/testdata/cases/node_with_steps.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ main: index.js
6+ steps:
7+ - run: echo
8+ shell: bash
A action/testdata/cases/node_without_main.yml +5 -0
@@ -0,0 +1,5 @@
1+name: A
2+description: D
3+runs:
4+ using: node20
5+ pre: a.js
A action/testdata/cases/num_00.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 1.0
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_01.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 1e3
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_02.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0x1F
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_03.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0o17
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_04.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 017
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_05.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 1e21
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_06.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 123456789012345678
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_07.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 123456789012345678901234
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_08.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0.1
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_09.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0.30000000000000004
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_10.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: .5
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_11.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 1_000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_12.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: +5
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_13.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: -0
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_14.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: -5
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_15.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 1.5e-7
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_16.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 1E5
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_17.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0xFFFFFFFFF
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_18.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0o7777777777777
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_19.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 1e999
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_20.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: .inf
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_21.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: -.inf
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_22.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: .nan
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_23.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: ~
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_24.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: null
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_25.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: True
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_26.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: yes
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_27.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 0.000001
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/num_28.yml +8 -0
@@ -0,0 +1,8 @@
1+name: A
2+description: D
3+inputs:
4+ a:
5+ default: 100000000000000000000
6+runs:
7+ using: node20
8+ main: m.js
A action/testdata/cases/only_comment.yml +1 -0
@@ -0,0 +1,1 @@
1+# nothing
A action/testdata/cases/only_runs.yml +3 -0
@@ -0,0 +1,3 @@
1+runs:
2+ using: node20
3+ main: m.js
A action/testdata/cases/outputs_scalar.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+outputs: x
4+runs:
5+ using: node20
6+ main: m.js
A action/testdata/cases/outputs_unknown_key.yml +9 -0
@@ -0,0 +1,9 @@
1+name: A
2+description: D
3+outputs:
4+ a:
5+ value: x
6+ other: y
7+runs:
8+ using: node20
9+ main: m.js
A action/testdata/cases/outputs_value_scalar.yml +7 -0 collapsed to keep the page light
A action/testdata/cases/plugin.yml +4 -0 collapsed to keep the page light
A action/testdata/cases/plugin_with_using.yml +6 -0 collapsed to keep the page light
A action/testdata/cases/root_list.yml +2 -0 collapsed to keep the page light
A action/testdata/cases/root_null.yml +1 -0 collapsed to keep the page light
A action/testdata/cases/root_scalar.yml +1 -0 collapsed to keep the page light
A action/testdata/cases/runner_composite_action_without_using_token.yml +9 -0 collapsed to keep the page light
A action/testdata/cases/runner_conditional_composite_action.yml +49 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerfileaction.yml +25 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerfileaction_arg_env_expression.yml +25 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerfileaction_cleanup.yml +27 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerfileaction_cleanup_default.yml +26 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerfileaction_env_invalid_context.yml +13 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerfileaction_init.yml +27 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerfileaction_init_default.yml +26 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerfileaction_noargs_noenv_noentrypoint.yml +19 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerfilerelativeaction.yml +25 -0 collapsed to keep the page light
A action/testdata/cases/runner_dockerhubaction.yml +25 -0 collapsed to keep the page light
A action/testdata/cases/runner_node16action.yml +20 -0 collapsed to keep the page light
A action/testdata/cases/runner_node20action.yml +20 -0 collapsed to keep the page light
A action/testdata/cases/runner_node24action.yml +20 -0 collapsed to keep the page light
A action/testdata/cases/runner_nodeaction.yml +20 -0 collapsed to keep the page light
A action/testdata/cases/runner_nodeaction_cleanup.yml +22 -0 collapsed to keep the page light
A action/testdata/cases/runner_nodeaction_cleanup_default.yml +21 -0 collapsed to keep the page light
A action/testdata/cases/runner_nodeaction_init.yml +22 -0 collapsed to keep the page light
A action/testdata/cases/runner_nodeaction_init_default.yml +21 -0 collapsed to keep the page light
A action/testdata/cases/runner_noderelativeaction.yml +19 -0 collapsed to keep the page light
A action/testdata/cases/runner_pluginaction.yml +18 -0 collapsed to keep the page light
A action/testdata/cases/runner_self_repository_composite_action.yml +25 -0 collapsed to keep the page light
A action/testdata/cases/runner_self_repository_ref_composite_action.yml +6 -0 collapsed to keep the page light
A action/testdata/cases/runs_empty_mapping.yml +3 -0 collapsed to keep the page light
A action/testdata/cases/runs_list.yml +4 -0 collapsed to keep the page light
A action/testdata/cases/runs_missing.yml +2 -0 collapsed to keep the page light
A action/testdata/cases/runs_string.yml +3 -0 collapsed to keep the page light
A action/testdata/cases/runs_unknown_key.yml +6 -0 collapsed to keep the page light
A action/testdata/cases/steps_json_ctx.yml +7 -0 collapsed to keep the page light
A action/testdata/cases/top_unknown_expr.yml +6 -0 collapsed to keep the page light
A action/testdata/cases/top_unknown_keys.yml +14 -0 collapsed to keep the page light
A action/testdata/cases/unicode_ids.yml +8 -0 collapsed to keep the page light
A action/testdata/cases/using_empty.yml +5 -0 collapsed to keep the page light
A action/testdata/cases/using_missing.yml +4 -0 collapsed to keep the page light
A action/testdata/cases/using_missing_only_steps.yml +6 -0
@@ -0,0 +1,6 @@
1+name: A
2+description: D
3+runs:
4+ steps:
5+ - run: echo
6+ shell: bash
A action/testdata/cases/using_null.yml +5 -0 collapsed to keep the page light
A action/testdata/cases/using_number.yml +5 -0 collapsed to keep the page light
A action/testdata/cases/using_unknown.yml +5 -0 collapsed to keep the page light
A action/testdata/cases/windows_uses.yml +6 -0 collapsed to keep the page light
A action/testdata/cases/yaml_anchor.yml +5 -0 collapsed to keep the page light
A action/testdata/cases/yaml_anchor_alias.yml +6 -0 collapsed to keep the page light
A action/testdata/cases/yaml_block_scalar.yml +11 -0 collapsed to keep the page light
A action/testdata/cases/yaml_bom.yml +5 -0 collapsed to keep the page light
A action/testdata/cases/yaml_crlf.yml +5 -0 collapsed to keep the page light
A action/testdata/cases/yaml_dup_in_runs.yml +6 -0 collapsed to keep the page light
A action/testdata/cases/yaml_dup_key.yml +6 -0 collapsed to keep the page light
A action/testdata/cases/yaml_dup_key_case.yml +6 -0 collapsed to keep the page light
A action/testdata/cases/yaml_key_collection.yml +7 -0 collapsed to keep the page light
A action/testdata/cases/yaml_merge_key.yml +5 -0 collapsed to keep the page light
A action/testdata/cases/yaml_syntax.yml +2 -0 collapsed to keep the page light
A action/testdata/cases/yaml_tab.yml +3 -0 collapsed to keep the page light
A action/testdata/cases/yaml_tag_str.yml +5 -0 collapsed to keep the page light
A action/testdata/cases/yaml_two_docs.yml +3 -0 collapsed to keep the page light
A action/testdata/runner.json +5625 -0 collapsed to keep the page light
A action/testdata/strict.json +4040 -0 collapsed to keep the page light
A action/testdata/uses.json +1373 -0 collapsed to keep the page light
A action/testdata/uses.txt +68 -0 collapsed to keep the page light
A action/uses.go +143 -0 collapsed to keep the page light
A action/uses_test.go +139 -0 collapsed to keep the page light
A action/util.go +133 -0 collapsed to keep the page light
M doc.go +3 -2 collapsed to keep the page light
M expr/limits_test.go +4 -3 collapsed to keep the page light
M expr/ordinal.go +8 -0 collapsed to keep the page light
M template/PORTING.md +16 -0 collapsed to keep the page light
M template/context.go +50 -2 collapsed to keep the page light
M template/definfo.go +10 -0 collapsed to keep the page light
A template/limits_test.go +143 -0 collapsed to keep the page light
M template/number.go +57 -0 collapsed to keep the page light
M template/reader.go +118 -9 collapsed to keep the page light
M template/scalar_ref_test.go +1 -1 collapsed to keep the page light
M template/yaml.go +142 -14 collapsed to keep the page light
M workflow/convert_misc.go +5 -0 collapsed to keep the page light
M workflow/model.go +5 -0 collapsed to keep the page light

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