main 98594d0

load.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package action
4
5import (
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
15var schemaJSON []byte
16
17var (
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.
25func 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.)
35const rootType = "action-root"
36
37// The limits of the runner's TemplateContext for an action manifest
38// (ActionManifestManagerLegacy.CreateTemplateContext).
39const (
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.
46func 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>".
62func 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
80func 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
88type 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.
96func (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
101func (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
111func (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
144func (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
156func (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
190func 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.
214func (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}

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