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}