1// SPDX-License-Identifier: MIT
2
3package action
4
5import (
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.
12const 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.
32func 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".
47func 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
80func 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}