1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "bnema.dev/ermite-actions/action"
12)
13
14// loadManifest reads action.yml or action.yaml (the first is preferred) from
15// dir, like ActionManager.LoadAction (ActionManager.cs:722-731) and
16// ActionManifestManagerLegacy.Load (:53-160): every error of the manifest is
17// logged as an error of the step, then the load fails with "Failed to load
18// <path>". shown is the path the messages use: relative to the actions
19// directory for downloaded actions, the whole path for local ones
20// (ActionManifestManagerLegacy.cs:61-66). It returns (nil, "", nil) without a
21// manifest.
22func (j *job) loadManifest(num int, dir string) (*action.Action, string, error) {
23 var file string
24 for _, name := range []string{"action.yml", "action.yaml"} {
25 if _, err := os.Stat(pathCombine(dir, name)); err == nil {
26 file = pathCombine(dir, name)
27 break
28 }
29 }
30 if file == "" {
31 return nil, "", nil
32 }
33 shown := file
34 if base := j.state.Layout().Actions; strings.HasPrefix(file, base+string(filepath.Separator)) {
35 shown = file[len(base)+1:]
36 }
37 data, err := os.ReadFile(file)
38 if err != nil {
39 return nil, "", err
40 }
41 act, errs := action.Parse(shown, data)
42 if len(errs) > 0 {
43 for _, e := range errs {
44 j.errorf(num, "%s", e.Error())
45 }
46 return nil, "", ghErrorf("%s", action.LoadFailed(shown))
47 }
48 return act, shown, nil
49}
50
51func kindOfRuns(r *action.Runs) kind {
52 switch r.Kind {
53 case action.RunsNode:
54 return kindNode
55 case action.RunsDocker:
56 return kindDocker
57 case action.RunsComposite:
58 return kindComposite
59 }
60 return kindScript
61}
62
63// loadDef loads the action in dir (ActionManager.LoadAction): its manifest, or
64// a Dockerfile when there is none (Dockerfile before dockerfile). what names
65// the action in the error for a directory without any.
66func (j *job) loadDef(num int, dir string) (*actionDef, error) {
67 act, _, err := j.loadManifest(num, dir)
68 if err != nil {
69 return nil, err
70 }
71 if act != nil {
72 d := &actionDef{kind: kindOfRuns(act.Runs), dir: dir, act: act}
73 switch act.Runs.Kind {
74 case action.RunsDocker:
75 d.image = act.Runs.Image
76 case action.RunsPlugin:
77 return nil, ghErrorf("Unsupported action type: Plugin")
78 }
79 return d, nil
80 }
81 for _, name := range []string{"Dockerfile", "dockerfile"} {
82 if _, err := os.Stat(pathCombine(dir, name)); err == nil {
83 return &actionDef{kind: kindDocker, dir: dir, dockerfile: pathCombine(dir, name)}, nil
84 }
85 }
86 return nil, errNoAction
87}
88
89// errNoAction is the sentinel of a directory without manifest and Dockerfile.
90var errNoAction = fmt.Errorf("no action")
91
92// actionDir is where the action of a node lives: the workspace for a local
93// action, the download directory for a repository one (ActionManager.cs:665-694).
94func (j *job) actionDir(n *node) string {
95 switch n.uses.Kind {
96 case action.UsesLocal:
97 return pathCombine(j.state.GitHub("workspace"), filepath.FromSlash(strings.ReplaceAll(n.uses.Path, `\`, "/")))
98 case action.UsesRepository:
99 d := pathCombine(j.state.Layout().Actions, filepath.FromSlash(n.uses.Name), n.uses.Ref)
100 if n.uses.Path != "" {
101 d = pathCombine(d, filepath.FromSlash(n.uses.Path))
102 }
103 return d
104 }
105 return ""
106}
107
108// refString is "Name[/Path]@Ref" (ActionRunner.cs:119-121, Handler.cs:130-148).
109func refString(u action.Uses) string {
110 switch u.Kind {
111 case action.UsesRepository:
112 p := u.Name
113 if u.Path != "" {
114 p += "/" + u.Path
115 }
116 return p + "@" + u.Ref
117 case action.UsesLocal:
118 return u.Path
119 case action.UsesDocker:
120 return "docker://" + u.Image
121 }
122 return ""
123}
124
125// pathCombine is .NET's Path.Combine: the parts are joined with a separator and
126// nothing is cleaned, so a "./" in the path of a local action stays in the
127// messages and in github.action_path (recorded: ".../work/repo/repo/./.github/
128// actions/basic"); a rooted part replaces what precedes it (an absolute `main`
129// is that file). Paths are not confined to the action directory, as in the
130// runner: ../ and symlinks may leave it.
131func pathCombine(parts ...string) string {
132 out := ""
133 for _, p := range parts {
134 switch {
135 case p == "":
136 case out == "" || filepath.IsAbs(p):
137 out = p
138 case strings.HasSuffix(out, string(filepath.Separator)):
139 out += p
140 default:
141 out += string(filepath.Separator) + p
142 }
143 }
144 return out
145}