detached 9e57131

fix(executor): log parity for action steps (CI-2 compare)

commit
9e5713165a5d241184245619f033a31a881177b3
author
brice · · 2026-10-14 10:26 UTC
committer
bnema · · 2026-10-14 10:26 UTC
parent
819edf3
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
11 files · +430 -28
M internal/executor/actions_composite.go +39 -3
@@ -50,6 +50,9 @@ func (j *job) prepareLocalComposite(s *stepRun, def *actionDef, cancel <-chan st
5050 if err != nil {
5151 return err
5252 }
53+ for _, k := range kids {
54+ k.runtimePrepared = true
55+ }
5356 n.children = kids
5457 n.preSteps, n.postSteps = nil, nil
5558 j.out(s.num, "Prepare all required actions")
@@ -150,7 +153,7 @@ func (j *job) runComposite(s *stepRun, def *actionDef, in *inputSet, env *ordere
150153 ok := resSucceeded
151154 s.result = &ok
152155 for _, c := range kids {
153- j.runEmbedded(s, c)
156+ j.runEmbedded(s, c, scope)
154157 }
155158 // Outputs are evaluated in the steps context of the action and belong to the
156159 // step that uses it (ExecutionContext.SetOutput of the composite step).
@@ -196,7 +199,10 @@ func (j *job) compositeOutputs(s *stepRun, def *actionDef) []Pair {
196199 // step of the composite action runs inside the step that uses it, with its own
197200 // environment, condition, cancellation watch and result. Its log lines belong
198201 // to the outer step.
199-func (j *job) runEmbedded(parent *stepRun, c *node) {
202+//
203+// scope is the steps context of the action; with the id of the step it is the
204+// id of the step's markers (the fully qualified context name).
205+func (j *job) runEmbedded(parent *stepRun, c *node, scope string) {
200206 s := &stepRun{j: j, num: parent.num, st: c.st, n: c, stage: parent.stage, parent: parent, fc: j.fc.Fork()}
201207 // The issues, summaries and state of an inner step belong to the step that
202208 // uses the action, whose collector counts them (10 per level, the outer
@@ -211,9 +217,26 @@ func (j *job) runEmbedded(parent *stepRun, c *node) {
211217 env, envErr := j.stepEnvironment(s)
212218 s.env = env
213219 s.name = j.displayName(c.st, env)
220+ // The display name is masked before it is cut and escaped. The runner masks
221+ // the finished line (ExecutionContext.Write), after SanitizeDisplayName and
222+ // EscapeValue, so a secret holding ";", "]" or "%" (escaped by then) or cut by
223+ // the 1000-character limit would escape its masker. Masking first is stricter;
224+ // the line is masked again when it is written, as every line is.
225+ id := scope + "." + c.st.ID
214226 if envErr != nil {
227+ // The error of the environment is logged and the step marked failed, then
228+ // the start marker is printed (CompositeActionHandler.cs:296-302, :308-324).
229+ // DIVERGENCE: the runner does not stop there. It falls through to the
230+ // condition (:372-390); success() of an embedded main step reads
231+ // github.action_status, set from the composite's result before the env was
232+ // evaluated (:246-248, SuccessFunction.cs:28), so the default condition still
233+ // holds and the runner RUNS the step, without the env that failed, and ends
234+ // with the marker of its failed result and the elapsed time. Here the step
235+ // does not run and the end marker says duration_ms=0.
215236 j.errorf(s.num, "%s", templateNotValid(envErr, true))
237+ j.out(parent.num, startMarker(j.mask(s.name), id))
216238 j.finishStep(s, resFailed)
239+ j.out(parent.num, endMarker(id, "failure", "failure", 0))
217240 return
218241 }
219242 q := qstep{n: c, stage: parent.stage}
@@ -225,5 +248,18 @@ func (j *job) runEmbedded(parent *stepRun, c *node) {
225248 case stagePost:
226249 q.cond = c.postCond
227250 }
228- j.runGuarded(s, q, true)
251+ j.out(parent.num, startMarker(j.mask(s.name), id))
252+ start := j.now()
253+ r := j.runGuarded(s, q, true)
254+ // Outcome is the result before continue-on-error, conclusion the final one;
255+ // a skipped step takes no time (CompositeActionHandler.cs:415, :448).
256+ outcome := r
257+ if s.outcome != nil {
258+ outcome = *s.outcome
259+ }
260+ ms := j.now().Sub(start).Milliseconds()
261+ if r == resSkipped {
262+ ms = 0
263+ }
264+ j.out(parent.num, endMarker(id, markerResult(outcome), markerResult(r), ms))
229265 }
@@ -50,6 +50,9 @@ func (j *job) prepareLocalComposite(s *stepRun, def *actionDef, cancel <-chan st
50 if err != nil {50 if err != nil {
51 return err51 return err
52 }52 }
53+ for _, k := range kids {
54+ k.runtimePrepared = true
55+ }
53 n.children = kids56 n.children = kids
54 n.preSteps, n.postSteps = nil, nil57 n.preSteps, n.postSteps = nil, nil
55 j.out(s.num, "Prepare all required actions")58 j.out(s.num, "Prepare all required actions")
@@ -150,7 +153,7 @@ func (j *job) runComposite(s *stepRun, def *actionDef, in *inputSet, env *ordere
150 ok := resSucceeded153 ok := resSucceeded
151 s.result = &ok154 s.result = &ok
152 for _, c := range kids {155 for _, c := range kids {
153- j.runEmbedded(s, c)156+ j.runEmbedded(s, c, scope)
154 }157 }
155 // Outputs are evaluated in the steps context of the action and belong to the158 // Outputs are evaluated in the steps context of the action and belong to the
156 // step that uses it (ExecutionContext.SetOutput of the composite step).159 // step that uses it (ExecutionContext.SetOutput of the composite step).
@@ -196,7 +199,10 @@ func (j *job) compositeOutputs(s *stepRun, def *actionDef) []Pair {
196 // step of the composite action runs inside the step that uses it, with its own199 // step of the composite action runs inside the step that uses it, with its own
197 // environment, condition, cancellation watch and result. Its log lines belong200 // environment, condition, cancellation watch and result. Its log lines belong
198 // to the outer step.201 // to the outer step.
199-func (j *job) runEmbedded(parent *stepRun, c *node) {202+//
203+// scope is the steps context of the action; with the id of the step it is the
204+// id of the step's markers (the fully qualified context name).
205+func (j *job) runEmbedded(parent *stepRun, c *node, scope string) {
200 s := &stepRun{j: j, num: parent.num, st: c.st, n: c, stage: parent.stage, parent: parent, fc: j.fc.Fork()}206 s := &stepRun{j: j, num: parent.num, st: c.st, n: c, stage: parent.stage, parent: parent, fc: j.fc.Fork()}
201 // The issues, summaries and state of an inner step belong to the step that207 // The issues, summaries and state of an inner step belong to the step that
202 // uses the action, whose collector counts them (10 per level, the outer208 // uses the action, whose collector counts them (10 per level, the outer
@@ -211,9 +217,26 @@ func (j *job) runEmbedded(parent *stepRun, c *node) {
211 env, envErr := j.stepEnvironment(s)217 env, envErr := j.stepEnvironment(s)
212 s.env = env218 s.env = env
213 s.name = j.displayName(c.st, env)219 s.name = j.displayName(c.st, env)
220+ // The display name is masked before it is cut and escaped. The runner masks
221+ // the finished line (ExecutionContext.Write), after SanitizeDisplayName and
222+ // EscapeValue, so a secret holding ";", "]" or "%" (escaped by then) or cut by
223+ // the 1000-character limit would escape its masker. Masking first is stricter;
224+ // the line is masked again when it is written, as every line is.
225+ id := scope + "." + c.st.ID
214 if envErr != nil {226 if envErr != nil {
227+ // The error of the environment is logged and the step marked failed, then
228+ // the start marker is printed (CompositeActionHandler.cs:296-302, :308-324).
229+ // DIVERGENCE: the runner does not stop there. It falls through to the
230+ // condition (:372-390); success() of an embedded main step reads
231+ // github.action_status, set from the composite's result before the env was
232+ // evaluated (:246-248, SuccessFunction.cs:28), so the default condition still
233+ // holds and the runner RUNS the step, without the env that failed, and ends
234+ // with the marker of its failed result and the elapsed time. Here the step
235+ // does not run and the end marker says duration_ms=0.
215 j.errorf(s.num, "%s", templateNotValid(envErr, true))236 j.errorf(s.num, "%s", templateNotValid(envErr, true))
237+ j.out(parent.num, startMarker(j.mask(s.name), id))
216 j.finishStep(s, resFailed)238 j.finishStep(s, resFailed)
239+ j.out(parent.num, endMarker(id, "failure", "failure", 0))
217 return240 return
218 }241 }
219 q := qstep{n: c, stage: parent.stage}242 q := qstep{n: c, stage: parent.stage}
@@ -225,5 +248,18 @@ func (j *job) runEmbedded(parent *stepRun, c *node) {
225 case stagePost:248 case stagePost:
226 q.cond = c.postCond249 q.cond = c.postCond
227 }250 }
228- j.runGuarded(s, q, true)251+ j.out(parent.num, startMarker(j.mask(s.name), id))
252+ start := j.now()
253+ r := j.runGuarded(s, q, true)
254+ // Outcome is the result before continue-on-error, conclusion the final one;
255+ // a skipped step takes no time (CompositeActionHandler.cs:415, :448).
256+ outcome := r
257+ if s.outcome != nil {
258+ outcome = *s.outcome
259+ }
260+ ms := j.now().Sub(start).Milliseconds()
261+ if r == resSkipped {
262+ ms = 0
263+ }
264+ j.out(parent.num, endMarker(id, markerResult(outcome), markerResult(r), ms))
229 }265 }
M internal/executor/actions_docker.go +2 -1
@@ -82,6 +82,7 @@ func (j *job) dockerBuildCmd(tag, dockerfile, context string) dockerCmd {
8282
8383 // buildImage builds an image from a Dockerfile, in the step.
8484 func (j *job) buildImage(s *stepRun, tag, dockerfile, workDir string, cancel <-chan struct{}) error {
85+ // The context is Directory.GetParent(dockerfile).FullName (see buildActionImage).
8586 c := j.dockerBuildCmd(tag, dockerfile, filepath.Dir(dockerfile))
8687 code, err := j.runDockerCmd(s, c, dirOrCwd(workDir), nil, cancel)
8788 if err != nil {
@@ -122,7 +123,7 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered,
122123 if im.Kind == action.ImageRegistry {
123124 image = im.Ref
124125 } else {
125- build = filepath.Join(def.dir, filepath.FromSlash(im.Path))
126+ build = pathCombine(def.dir, filepath.FromSlash(im.Path))
126127 }
127128 }
128129 if build != "" {
@@ -82,6 +82,7 @@ func (j *job) dockerBuildCmd(tag, dockerfile, context string) dockerCmd {
82 82
83 // buildImage builds an image from a Dockerfile, in the step.83 // buildImage builds an image from a Dockerfile, in the step.
84 func (j *job) buildImage(s *stepRun, tag, dockerfile, workDir string, cancel <-chan struct{}) error {84 func (j *job) buildImage(s *stepRun, tag, dockerfile, workDir string, cancel <-chan struct{}) error {
85+ // The context is Directory.GetParent(dockerfile).FullName (see buildActionImage).
85 c := j.dockerBuildCmd(tag, dockerfile, filepath.Dir(dockerfile))86 c := j.dockerBuildCmd(tag, dockerfile, filepath.Dir(dockerfile))
86 code, err := j.runDockerCmd(s, c, dirOrCwd(workDir), nil, cancel)87 code, err := j.runDockerCmd(s, c, dirOrCwd(workDir), nil, cancel)
87 if err != nil {88 if err != nil {
@@ -122,7 +123,7 @@ func (j *job) runDocker(s *stepRun, def *actionDef, in *inputSet, env *ordered,
122 if im.Kind == action.ImageRegistry {123 if im.Kind == action.ImageRegistry {
123 image = im.Ref124 image = im.Ref
124 } else {125 } else {
125- build = filepath.Join(def.dir, filepath.FromSlash(im.Path))126+ build = pathCombine(def.dir, filepath.FromSlash(im.Path))
126 }127 }
127 }128 }
128 if build != "" {129 if build != "" {
M internal/executor/actions_docker_test.go +23 -0
@@ -229,3 +229,26 @@ func TestTranslatePath(t *testing.T) {
229229 }
230230 }
231231 }
232+
233+// The Dockerfile is passed as written (Path.Combine) and the build context is
234+// Directory.GetParent(dockerFile).FullName, which Path.GetFullPath resolves
235+// lexically: "." and ".." are folded, symlinks are not looked at. Recorded in
236+// act-docker-pre-post: -f ".../work/r/r/./.github/actions/x/Dockerfile" with the
237+// context ".../work/r/r/.github/actions/x".
238+func TestDockerfileContextIsTheLexicalParent(t *testing.T) {
239+ tree := files{
240+ "act/action.yml": "name: d\nruns:\n using: docker\n image: sub/../Dockerfile\n",
241+ "act/Dockerfile": "FROM alpine\n",
242+ "act/sub/keep": "",
243+ }
244+ rec, res, cs, _ := dockerRun(t, tree, " - uses: ./act\n", 0)
245+ if res.Conclusion != ConclusionSuccess {
246+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
247+ }
248+ if len(cs) < 1 || cs[0][0] != "build" {
249+ t.Fatalf("calls %v", cs)
250+ }
251+ if f, c := cs[0][4], cs[0][5]; !strings.HasSuffix(f, "/./act/sub/../Dockerfile") || !strings.HasSuffix(c, "/act") || strings.Contains(c, "..") || strings.Contains(c, "/./") {
252+ t.Errorf("-f %q, context %q", f, c)
253+ }
254+}
@@ -229,3 +229,26 @@ func TestTranslatePath(t *testing.T) {
229 }229 }
230 }230 }
231 }231 }
232+
233+// The Dockerfile is passed as written (Path.Combine) and the build context is
234+// Directory.GetParent(dockerFile).FullName, which Path.GetFullPath resolves
235+// lexically: "." and ".." are folded, symlinks are not looked at. Recorded in
236+// act-docker-pre-post: -f ".../work/r/r/./.github/actions/x/Dockerfile" with the
237+// context ".../work/r/r/.github/actions/x".
238+func TestDockerfileContextIsTheLexicalParent(t *testing.T) {
239+ tree := files{
240+ "act/action.yml": "name: d\nruns:\n using: docker\n image: sub/../Dockerfile\n",
241+ "act/Dockerfile": "FROM alpine\n",
242+ "act/sub/keep": "",
243+ }
244+ rec, res, cs, _ := dockerRun(t, tree, " - uses: ./act\n", 0)
245+ if res.Conclusion != ConclusionSuccess {
246+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
247+ }
248+ if len(cs) < 1 || cs[0][0] != "build" {
249+ t.Fatalf("calls %v", cs)
250+ }
251+ if f, c := cs[0][4], cs[0][5]; !strings.HasSuffix(f, "/./act/sub/../Dockerfile") || !strings.HasSuffix(c, "/act") || strings.Contains(c, "..") || strings.Contains(c, "/./") {
252+ t.Errorf("-f %q, context %q", f, c)
253+ }
254+}
M internal/executor/actions_load.go +29 -7
@@ -22,8 +22,8 @@ import (
2222 func (j *job) loadManifest(num int, dir string) (*action.Action, string, error) {
2323 var file string
2424 for _, name := range []string{"action.yml", "action.yaml"} {
25- if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
26- file = filepath.Join(dir, name)
25+ if _, err := os.Stat(pathCombine(dir, name)); err == nil {
26+ file = pathCombine(dir, name)
2727 break
2828 }
2929 }
@@ -79,8 +79,8 @@ func (j *job) loadDef(num int, dir string) (*actionDef, error) {
7979 return d, nil
8080 }
8181 for _, name := range []string{"Dockerfile", "dockerfile"} {
82- if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
83- return &actionDef{kind: kindDocker, dir: dir, dockerfile: filepath.Join(dir, name)}, nil
82+ if _, err := os.Stat(pathCombine(dir, name)); err == nil {
83+ return &actionDef{kind: kindDocker, dir: dir, dockerfile: pathCombine(dir, name)}, nil
8484 }
8585 }
8686 return nil, errNoAction
@@ -94,11 +94,11 @@ var errNoAction = fmt.Errorf("no action")
9494 func (j *job) actionDir(n *node) string {
9595 switch n.uses.Kind {
9696 case action.UsesLocal:
97- return filepath.Join(j.state.GitHub("workspace"), filepath.FromSlash(strings.ReplaceAll(n.uses.Path, `\`, "/")))
97+ return pathCombine(j.state.GitHub("workspace"), filepath.FromSlash(strings.ReplaceAll(n.uses.Path, `\`, "/")))
9898 case action.UsesRepository:
99- d := filepath.Join(j.state.Layout().Actions, filepath.FromSlash(n.uses.Name), n.uses.Ref)
99+ d := pathCombine(j.state.Layout().Actions, filepath.FromSlash(n.uses.Name), n.uses.Ref)
100100 if n.uses.Path != "" {
101- d = filepath.Join(d, filepath.FromSlash(n.uses.Path))
101+ d = pathCombine(d, filepath.FromSlash(n.uses.Path))
102102 }
103103 return d
104104 }
@@ -121,3 +121,25 @@ func refString(u action.Uses) string {
121121 }
122122 return ""
123123 }
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.
131+func 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+}
@@ -22,8 +22,8 @@ import (
22 func (j *job) loadManifest(num int, dir string) (*action.Action, string, error) {22 func (j *job) loadManifest(num int, dir string) (*action.Action, string, error) {
23 var file string23 var file string
24 for _, name := range []string{"action.yml", "action.yaml"} {24 for _, name := range []string{"action.yml", "action.yaml"} {
25- if _, err := os.Stat(filepath.Join(dir, name)); err == nil {25+ if _, err := os.Stat(pathCombine(dir, name)); err == nil {
26- file = filepath.Join(dir, name)26+ file = pathCombine(dir, name)
27 break27 break
28 }28 }
29 }29 }
@@ -79,8 +79,8 @@ func (j *job) loadDef(num int, dir string) (*actionDef, error) {
79 return d, nil79 return d, nil
80 }80 }
81 for _, name := range []string{"Dockerfile", "dockerfile"} {81 for _, name := range []string{"Dockerfile", "dockerfile"} {
82- if _, err := os.Stat(filepath.Join(dir, name)); err == nil {82+ if _, err := os.Stat(pathCombine(dir, name)); err == nil {
83- return &actionDef{kind: kindDocker, dir: dir, dockerfile: filepath.Join(dir, name)}, nil83+ return &actionDef{kind: kindDocker, dir: dir, dockerfile: pathCombine(dir, name)}, nil
84 }84 }
85 }85 }
86 return nil, errNoAction86 return nil, errNoAction
@@ -94,11 +94,11 @@ var errNoAction = fmt.Errorf("no action")
94 func (j *job) actionDir(n *node) string {94 func (j *job) actionDir(n *node) string {
95 switch n.uses.Kind {95 switch n.uses.Kind {
96 case action.UsesLocal:96 case action.UsesLocal:
97- return filepath.Join(j.state.GitHub("workspace"), filepath.FromSlash(strings.ReplaceAll(n.uses.Path, `\`, "/")))97+ return pathCombine(j.state.GitHub("workspace"), filepath.FromSlash(strings.ReplaceAll(n.uses.Path, `\`, "/")))
98 case action.UsesRepository:98 case action.UsesRepository:
99- d := filepath.Join(j.state.Layout().Actions, filepath.FromSlash(n.uses.Name), n.uses.Ref)99+ d := pathCombine(j.state.Layout().Actions, filepath.FromSlash(n.uses.Name), n.uses.Ref)
100 if n.uses.Path != "" {100 if n.uses.Path != "" {
101- d = filepath.Join(d, filepath.FromSlash(n.uses.Path))101+ d = pathCombine(d, filepath.FromSlash(n.uses.Path))
102 }102 }
103 return d103 return d
104 }104 }
@@ -121,3 +121,25 @@ func refString(u action.Uses) string {
121 }121 }
122 return ""122 return ""
123 }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.
131+func 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+}
A internal/executor/actions_markers.go +60 -0
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strconv"
7+ "strings"
8+)
9+
10+// The start-action and end-action markers the runner prints around each step of
11+// a composite action (CompositeActionHandler.RunStepsAsync, :323 and :415-466).
12+// GitHub's runner emits them when the EmitCompositeMarkers feature is on, which
13+// is the case for the recorded runs, so they are always printed here.
14+
15+// maxMarkerDisplayName is CompositeActionHandler.MaxDisplayNameLength.
16+const maxMarkerDisplayName = 1000
17+
18+// markerEscaper is ActionCommand.EscapeValue (ActionCommand.cs:211-225): the
19+// escape mappings applied last to first, so that "%" goes first and nothing is
20+// escaped twice.
21+var markerEscaper = strings.NewReplacer("%", "%25", "]", "%5D", "\n", "%0A", "\r", "%0D", ";", "%3B")
22+
23+// sanitizeDisplayName is CompositeActionHandler.SanitizeDisplayName (:552): the
24+// first line without its leading white space, cut to 1000 UTF-16 code units.
25+func sanitizeDisplayName(name string) string {
26+ r := firstLine(name)
27+ units := 0
28+ for i, c := range r {
29+ w := 1
30+ if c >= 0x10000 {
31+ w = 2
32+ }
33+ if units+w > maxMarkerDisplayName {
34+ return r[:i]
35+ }
36+ units += w
37+ }
38+ return r
39+}
40+
41+func startMarker(display, id string) string {
42+ return "##[start-action display=" + markerEscaper.Replace(sanitizeDisplayName(display)) + ";id=" + markerEscaper.Replace(id) + "]"
43+}
44+
45+func endMarker(id, outcome, conclusion string, ms int64) string {
46+ return "##[end-action id=" + markerEscaper.Replace(id) + ";outcome=" + outcome + ";conclusion=" + conclusion + ";duration_ms=" + strconv.FormatInt(ms, 10) + "]"
47+}
48+
49+// markerResult is TaskResult.ToActionResult in lower case.
50+func markerResult(r stepResult) string {
51+ switch r {
52+ case resFailed:
53+ return "failure"
54+ case resCanceled:
55+ return "cancelled"
56+ case resSkipped:
57+ return "skipped"
58+ }
59+ return "success"
60+}
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: MIT
2+
3+package executor
4+
5+import (
6+ "strconv"
7+ "strings"
8+)
9+
10+// The start-action and end-action markers the runner prints around each step of
11+// a composite action (CompositeActionHandler.RunStepsAsync, :323 and :415-466).
12+// GitHub's runner emits them when the EmitCompositeMarkers feature is on, which
13+// is the case for the recorded runs, so they are always printed here.
14+
15+// maxMarkerDisplayName is CompositeActionHandler.MaxDisplayNameLength.
16+const maxMarkerDisplayName = 1000
17+
18+// markerEscaper is ActionCommand.EscapeValue (ActionCommand.cs:211-225): the
19+// escape mappings applied last to first, so that "%" goes first and nothing is
20+// escaped twice.
21+var markerEscaper = strings.NewReplacer("%", "%25", "]", "%5D", "\n", "%0A", "\r", "%0D", ";", "%3B")
22+
23+// sanitizeDisplayName is CompositeActionHandler.SanitizeDisplayName (:552): the
24+// first line without its leading white space, cut to 1000 UTF-16 code units.
25+func sanitizeDisplayName(name string) string {
26+ r := firstLine(name)
27+ units := 0
28+ for i, c := range r {
29+ w := 1
30+ if c >= 0x10000 {
31+ w = 2
32+ }
33+ if units+w > maxMarkerDisplayName {
34+ return r[:i]
35+ }
36+ units += w
37+ }
38+ return r
39+}
40+
41+func startMarker(display, id string) string {
42+ return "##[start-action display=" + markerEscaper.Replace(sanitizeDisplayName(display)) + ";id=" + markerEscaper.Replace(id) + "]"
43+}
44+
45+func endMarker(id, outcome, conclusion string, ms int64) string {
46+ return "##[end-action id=" + markerEscaper.Replace(id) + ";outcome=" + outcome + ";conclusion=" + conclusion + ";duration_ms=" + strconv.FormatInt(ms, 10) + "]"
47+}
48+
49+// markerResult is TaskResult.ToActionResult in lower case.
50+func markerResult(r stepResult) string {
51+ switch r {
52+ case resFailed:
53+ return "failure"
54+ case resCanceled:
55+ return "cancelled"
56+ case resSkipped:
57+ return "skipped"
58+ }
59+ return "success"
60+}
M internal/executor/actions_node.go +1 -1
@@ -177,7 +177,7 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
177177 if target == "" {
178178 return fail(ghErrorf("Value cannot be null or empty. (Parameter 'target')"))
179179 }
180- script := filepath.Join(def.dir, filepath.FromSlash(target))
180+ script := pathCombine(def.dir, filepath.FromSlash(target))
181181 if st, err := os.Stat(script); err != nil || st.IsDir() {
182182 return fail(ghErrorf("File not found: '%s'", script))
183183 }
@@ -177,7 +177,7 @@ func (j *job) runNode(s *stepRun, def *actionDef, in *inputSet, env *ordered, ca
177 if target == "" {177 if target == "" {
178 return fail(ghErrorf("Value cannot be null or empty. (Parameter 'target')"))178 return fail(ghErrorf("Value cannot be null or empty. (Parameter 'target')"))
179 }179 }
180- script := filepath.Join(def.dir, filepath.FromSlash(target))180+ script := pathCombine(def.dir, filepath.FromSlash(target))
181 if st, err := os.Stat(script); err != nil || st.IsDir() {181 if st, err := os.Stat(script); err != nil || st.IsDir() {
182 return fail(ghErrorf("File not found: '%s'", script))182 return fail(ghErrorf("File not found: '%s'", script))
183 }183 }
M internal/executor/actions_prepare.go +23 -3
@@ -247,7 +247,7 @@ func (j *job) dockerImageOf(def *actionDef) (pull, build string, err error) {
247247 return "", "", err
248248 }
249249 if im.Kind == action.ImageDockerfile {
250- return "", def.dir + "/" + im.Path, nil
250+ return "", pathCombine(def.dir, im.Path), nil
251251 }
252252 return im.Ref, "", nil
253253 }
@@ -275,7 +275,7 @@ func (j *job) registerStages(n *node, depth int) {
275275 }
276276 return
277277 }
278- if p := n.parent; p != nil {
278+ if p := n.registrar(); p != nil {
279279 if pre {
280280 p.preSteps = append(p.preSteps, n)
281281 }
@@ -285,9 +285,29 @@ func (j *job) registerStages(n *node, depth int) {
285285 }
286286 }
287287
288+// registrar is the node whose pre and post steps the node is registered with:
289+// its parent, except for the steps of a local composite action prepared when
290+// the action runs. The runner keys what it registers there by the id of the
291+// execution context (ActionManager.cs:92-113 and :424-430, rootStepId =
292+// ExecutionContext.Id), and an inner step's context shares the record, hence
293+// the id, of the step of the job that contains it: everything lands on that
294+// step, and the inner composite actions themselves never get a post of their
295+// own (recorded: one "Post job cleanup." for act-composite-depth, not one per
296+// level).
297+func (n *node) registrar() *node {
298+ if n.runtimePrepared {
299+ for p := n.parent; p != nil; p = p.parent {
300+ if p.parent == nil {
301+ return p
302+ }
303+ }
304+ }
305+ return n.parent
306+}
307+
288308 // parentPost is the assumption for a local action inside a composite action.
289309 func (n *node) parentPost() {
290- if p := n.parent; p != nil {
310+ if p := n.registrar(); p != nil {
291311 p.postSteps = append(p.postSteps, n)
292312 }
293313 }
@@ -247,7 +247,7 @@ func (j *job) dockerImageOf(def *actionDef) (pull, build string, err error) {
247 return "", "", err247 return "", "", err
248 }248 }
249 if im.Kind == action.ImageDockerfile {249 if im.Kind == action.ImageDockerfile {
250- return "", def.dir + "/" + im.Path, nil250+ return "", pathCombine(def.dir, im.Path), nil
251 }251 }
252 return im.Ref, "", nil252 return im.Ref, "", nil
253 }253 }
@@ -275,7 +275,7 @@ func (j *job) registerStages(n *node, depth int) {
275 }275 }
276 return276 return
277 }277 }
278- if p := n.parent; p != nil {278+ if p := n.registrar(); p != nil {
279 if pre {279 if pre {
280 p.preSteps = append(p.preSteps, n)280 p.preSteps = append(p.preSteps, n)
281 }281 }
@@ -285,9 +285,29 @@ func (j *job) registerStages(n *node, depth int) {
285 }285 }
286 }286 }
287 287
288+// registrar is the node whose pre and post steps the node is registered with:
289+// its parent, except for the steps of a local composite action prepared when
290+// the action runs. The runner keys what it registers there by the id of the
291+// execution context (ActionManager.cs:92-113 and :424-430, rootStepId =
292+// ExecutionContext.Id), and an inner step's context shares the record, hence
293+// the id, of the step of the job that contains it: everything lands on that
294+// step, and the inner composite actions themselves never get a post of their
295+// own (recorded: one "Post job cleanup." for act-composite-depth, not one per
296+// level).
297+func (n *node) registrar() *node {
298+ if n.runtimePrepared {
299+ for p := n.parent; p != nil; p = p.parent {
300+ if p.parent == nil {
301+ return p
302+ }
303+ }
304+ }
305+ return n.parent
306+}
307+
288 // parentPost is the assumption for a local action inside a composite action.308 // parentPost is the assumption for a local action inside a composite action.
289 func (n *node) parentPost() {309 func (n *node) parentPost() {
290- if p := n.parent; p != nil {310+ if p := n.registrar(); p != nil {
291 p.postSteps = append(p.postSteps, n)311 p.postSteps = append(p.postSteps, n)
292 }312 }
293 }313 }
M internal/executor/actions_setup.go +5 -11
@@ -4,7 +4,7 @@ package executor
44
55 import (
66 "fmt"
7- "os"
7+ "path/filepath"
88 "time"
99 )
1010
@@ -83,7 +83,10 @@ func (j *job) buildActionImage(s *stepRun, info buildInfo, users []*node, cancel
8383 tag := newImageTag(j.dockerLabel())
8484 code := 0
8585 for try := 1; try <= buildAttempts; try++ {
86- c := j.dockerBuildCmd(tag, info.dockerfile, dirOf(info.dockerfile))
86+ // Directory.GetParent(Dockerfile).FullName: the parent after Path.GetFullPath,
87+ // which folds "." and ".." lexically, as filepath.Dir does; the Dockerfile
88+ // itself is passed as written.
89+ c := j.dockerBuildCmd(tag, info.dockerfile, filepath.Dir(info.dockerfile))
8790 var err error
8891 code, err = j.runDockerCmd(s, c, dirOrCwd(info.workDir), nil, cancel)
8992 if err == errCancelled {
@@ -118,15 +121,6 @@ func (j *job) buildActionImage(s *stepRun, info buildInfo, users []*node, cancel
118121 return resSucceeded
119122 }
120123
121-func dirOf(p string) string {
122- for i := len(p) - 1; i >= 0; i-- {
123- if p[i] == os.PathSeparator {
124- return p[:i]
125- }
126- }
127- return "."
128-}
129-
130124 // pullBackoff is a random wait of 1 to 10 seconds.
131125 func (j *job) pullBackoff() time.Duration {
132126 if f := j.cfg.Actions.Backoff; f != nil {
@@ -4,7 +4,7 @@ package executor
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7- "os"7+ "path/filepath"
8 "time"8 "time"
9 )9 )
10 10
@@ -83,7 +83,10 @@ func (j *job) buildActionImage(s *stepRun, info buildInfo, users []*node, cancel
83 tag := newImageTag(j.dockerLabel())83 tag := newImageTag(j.dockerLabel())
84 code := 084 code := 0
85 for try := 1; try <= buildAttempts; try++ {85 for try := 1; try <= buildAttempts; try++ {
86- c := j.dockerBuildCmd(tag, info.dockerfile, dirOf(info.dockerfile))86+ // Directory.GetParent(Dockerfile).FullName: the parent after Path.GetFullPath,
87+ // which folds "." and ".." lexically, as filepath.Dir does; the Dockerfile
88+ // itself is passed as written.
89+ c := j.dockerBuildCmd(tag, info.dockerfile, filepath.Dir(info.dockerfile))
87 var err error90 var err error
88 code, err = j.runDockerCmd(s, c, dirOrCwd(info.workDir), nil, cancel)91 code, err = j.runDockerCmd(s, c, dirOrCwd(info.workDir), nil, cancel)
89 if err == errCancelled {92 if err == errCancelled {
@@ -118,15 +121,6 @@ func (j *job) buildActionImage(s *stepRun, info buildInfo, users []*node, cancel
118 return resSucceeded121 return resSucceeded
119 }122 }
120 123
121-func dirOf(p string) string {
122- for i := len(p) - 1; i >= 0; i-- {
123- if p[i] == os.PathSeparator {
124- return p[:i]
125- }
126- }
127- return "."
128-}
129-
130 // pullBackoff is a random wait of 1 to 10 seconds.124 // pullBackoff is a random wait of 1 to 10 seconds.
131 func (j *job) pullBackoff() time.Duration {125 func (j *job) pullBackoff() time.Duration {
132 if f := j.cfg.Actions.Backoff; f != nil {126 if f := j.cfg.Actions.Backoff; f != nil {
M internal/executor/actions_test.go +244 -1
@@ -11,6 +11,7 @@ import (
1111 "net/http/httptest"
1212 "os"
1313 "path/filepath"
14+ "regexp"
1415 "strconv"
1516 "strings"
1617 "sync"
@@ -377,7 +378,7 @@ runs:
377378 t.Fatalf("%+v", res)
378379 }
379380 got := logOf(rec.steps[1])
380- if !strings.Contains(got, "##[error]Process completed with exit code 3.") || strings.Contains(got, "never") ||
381+ if !strings.Contains(got, "##[error]Process completed with exit code 3.") || strings.Contains(got, "\nnever") ||
381382 !strings.Contains(got, "status=failure") {
382383 t.Errorf("%s", got)
383384 }
@@ -948,3 +949,245 @@ func TestTokenAllowedOrigins(t *testing.T) {
948949 t.Error("no token, nothing to allow")
949950 }
950951 }
952+
953+// The markers of the runner around the steps of a composite action
954+// (CompositeActionHandler.cs:323, :415-466), as recorded in act-composite-basic,
955+// act-composite-nested and act-composite-depth: the id is the dotted context
956+// name, a step without an id gets the id the workflow reader generated.
957+func TestCompositeStepMarkers(t *testing.T) {
958+ tree := files{
959+ ".github/actions/mid/action.yml": `name: mid
960+runs:
961+ using: composite
962+ steps:
963+ - id: leaf-step
964+ shell: bash
965+ name: "multi %; ] line"
966+ run: echo leaf
967+ - uses: ./.github/actions/leaf
968+`,
969+ ".github/actions/leaf/action.yml": `name: leaf
970+runs:
971+ using: composite
972+ steps:
973+ - shell: bash
974+ run: echo "level 2"
975+ - shell: bash
976+ if: false
977+ run: echo skipped
978+ - shell: bash
979+ continue-on-error: true
980+ run: exit 1
981+`,
982+ }
983+ rec, res := actionRun(t, tree, " - id: call\n uses: ./.github/actions/mid\n - uses: ./.github/actions/leaf\n", nil)
984+ if res.Conclusion != ConclusionSuccess {
985+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
986+ }
987+ var got []string
988+ for _, st := range rec.steps[1:3] {
989+ for _, l := range st.Log {
990+ if strings.HasPrefix(l, "##[start-action") || strings.HasPrefix(l, "##[end-action") {
991+ if !strings.Contains(l, "outcome=skipped") {
992+ l = regexp.MustCompile(`duration_ms=\d+`).ReplaceAllString(l, "duration_ms=N")
993+ }
994+ got = append(got, l)
995+ }
996+ }
997+ }
998+ want := []string{
999+ "##[start-action display=multi %25%3B %5D line;id=call.leaf-step]",
1000+ "##[end-action id=call.leaf-step;outcome=success;conclusion=success;duration_ms=N]",
1001+ "##[start-action display=Run ./.github/actions/leaf;id=call.__self]",
1002+ `##[start-action display=Run echo "level 2";id=call.__self.__run]`,
1003+ "##[end-action id=call.__self.__run;outcome=success;conclusion=success;duration_ms=N]",
1004+ "##[start-action display=Run echo skipped;id=call.__self.__run_2]",
1005+ "##[end-action id=call.__self.__run_2;outcome=skipped;conclusion=skipped;duration_ms=0]",
1006+ "##[start-action display=Run exit 1;id=call.__self.__run_3]",
1007+ "##[end-action id=call.__self.__run_3;outcome=failure;conclusion=success;duration_ms=N]",
1008+ "##[end-action id=call.__self;outcome=success;conclusion=success;duration_ms=N]",
1009+ `##[start-action display=Run echo "level 2";id=__self.__run]`,
1010+ "##[end-action id=__self.__run;outcome=success;conclusion=success;duration_ms=N]",
1011+ "##[start-action display=Run echo skipped;id=__self.__run_2]",
1012+ "##[end-action id=__self.__run_2;outcome=skipped;conclusion=skipped;duration_ms=0]",
1013+ "##[start-action display=Run exit 1;id=__self.__run_3]",
1014+ "##[end-action id=__self.__run_3;outcome=failure;conclusion=success;duration_ms=N]",
1015+ }
1016+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
1017+ t.Errorf("markers:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
1018+ }
1019+}
1020+
1021+// A local composite action that uses other local composite actions has one post
1022+// step, which prints "Post job cleanup." once (act-composite-depth: 9, 10 and 12
1023+// levels, one line on GitHub). What the runner registers while it prepares the
1024+// steps of a local composite is keyed by the id of the step of the job.
1025+func TestNestedLocalCompositePostsOnce(t *testing.T) {
1026+ tree := files{
1027+ "c1/action.yml": "name: c1\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo one\n - uses: ./c2\n",
1028+ "c2/action.yml": "name: c2\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo two\n - uses: ./c3\n",
1029+ "c3/action.yml": "name: c3\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo three\n",
1030+ }
1031+ rec, res := actionRun(t, tree, " - uses: ./c1\n", nil)
1032+ if res.Conclusion != ConclusionSuccess {
1033+ t.Fatalf("%+v", res)
1034+ }
1035+ post := logOf(rec.step(t, "Post Run ./c1"))
1036+ if post != "Post job cleanup." {
1037+ t.Errorf("post of c1: %q", post)
1038+ }
1039+ for _, n := range rec.names() {
1040+ if strings.HasPrefix(n, "Post ") && n != "Post Run ./c1" {
1041+ t.Errorf("unexpected step %q in %v", n, rec.names())
1042+ }
1043+ }
1044+}
1045+
1046+// A local action's directory is the workspace and the path of the step joined as
1047+// .NET's Path.Combine does, without cleaning: github.action_path and the paths in
1048+// the manifest errors keep the "./" of "uses: ./.github/actions/x" (recorded in
1049+// act-composite-basic and act-manifest-errors).
1050+func TestLocalActionPathKeepsDotSlash(t *testing.T) {
1051+ tree := files{
1052+ ".github/actions/ok/action.yml": "name: ok\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo \"path=$GITHUB_ACTION_PATH\"\n",
1053+ ".github/actions/bad/action.yml": "name: bad\nruns:\n using: composite\n steps:\n - run: echo no shell\n",
1054+ }
1055+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/ok\n - uses: ./.github/actions/bad\n", nil)
1056+ if res.Conclusion != ConclusionFailure {
1057+ t.Fatalf("%+v", res)
1058+ }
1059+ ws := regexp.MustCompile(`path=(.*)/\./\.github/actions/ok`).FindStringSubmatch(logOf(rec.steps[1]))
1060+ if ws == nil {
1061+ t.Fatalf("action_path lost its ./:\n%s", logOf(rec.steps[1]))
1062+ }
1063+ bad := logOf(rec.steps[2])
1064+ if !strings.Contains(bad, "##[error]"+ws[1]+"/./.github/actions/bad/action.yml (Line: 5, Col: 7): Required property is missing: shell") ||
1065+ !strings.Contains(bad, "##[error]Failed to load "+ws[1]+"/./.github/actions/bad/action.yml") {
1066+ t.Errorf("manifest errors:\n%s", bad)
1067+ }
1068+}
1069+
1070+// The markers of a composite step go through the masker like every log line
1071+// (ExecutionContext.Write masks tag+message after the marker was built): a
1072+// secret in the name of an inner step, from the configuration or from an
1073+// ::add-mask:: of an earlier step, never reaches the log.
1074+func TestCompositeMarkersAreMasked(t *testing.T) {
1075+ tree := files{
1076+ ".github/actions/m/action.yml": `name: m
1077+runs:
1078+ using: composite
1079+ steps:
1080+ - shell: bash
1081+ run: echo "::add-mask::late-secret-""value"
1082+ - name: "token ${{ github.token }} and late-secret-value"
1083+ shell: bash
1084+ run: echo hi
1085+ - name: "env ${{ env.TOK }}"
1086+ env:
1087+ TOK: late-secret-value
1088+ shell: bash
1089+ run: echo hi
1090+`,
1091+ }
1092+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/m\n", func(c *Config) { c.ContextsJSON = tokenContexts() })
1093+ if res.Conclusion != ConclusionSuccess {
1094+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
1095+ }
1096+ log := logOf(rec.steps[1])
1097+ for _, secret := range []string{testToken, "late-secret-value"} {
1098+ if strings.Contains(log, secret) {
1099+ t.Errorf("%q is in the log:\n%s", secret, log)
1100+ }
1101+ }
1102+ // A secret with a character the marker escapes is masked before the escape.
1103+ if got := markerEscaper.Replace(sanitizeDisplayName("a;b")); got != "a%3Bb" {
1104+ t.Errorf("escape %q", got)
1105+ }
1106+ for _, w := range []string{"##[start-action display=token *** and ***;id=", "##[start-action display=env ***;id="} {
1107+ if !strings.Contains(log, w) {
1108+ t.Errorf("missing %q:\n%s", w, log)
1109+ }
1110+ }
1111+}
1112+
1113+// An inner step whose env does not evaluate still gets its markers: the error
1114+// is logged while the environment is set up, the start marker follows, then the
1115+// end marker with the step failed (CompositeActionHandler.cs:296-302, :308-324).
1116+// The runner would go on and run the step; this port does not (see runEmbedded).
1117+func TestCompositeEnvErrorKeepsMarkers(t *testing.T) {
1118+ tree := files{
1119+ ".github/actions/e/action.yml": `name: e
1120+runs:
1121+ using: composite
1122+ steps:
1123+ - name: bad env
1124+ shell: bash
1125+ env:
1126+ X: ${{ fromJSON('{') }}
1127+ run: echo never
1128+`,
1129+ }
1130+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/e\n", nil)
1131+ if res.Conclusion != ConclusionFailure {
1132+ t.Fatalf("%+v", res)
1133+ }
1134+ var got []string
1135+ for _, l := range rec.steps[1].Log {
1136+ switch {
1137+ case strings.HasPrefix(l, "##[error]"):
1138+ got = append(got, "error")
1139+ case strings.HasPrefix(l, "##[start-action"), strings.HasPrefix(l, "##[end-action"):
1140+ got = append(got, l)
1141+ }
1142+ }
1143+ want := []string{"error", "##[start-action display=bad env;id=__self.__run]", "##[end-action id=__self.__run;outcome=failure;conclusion=failure;duration_ms=0]"}
1144+ if strings.Join(got, "\n") != strings.Join(want, "\n") || strings.Contains(logOf(rec.steps[1]), "never") {
1145+ t.Errorf("markers:\n%s\n%s", strings.Join(got, "\n"), logOf(rec.steps[1]))
1146+ }
1147+}
1148+
1149+// A secret that the marker would escape, or that the cut at 1000 characters
1150+// would split, is masked before: the display name of an inner step holding it
1151+// leaves no part of it in the log.
1152+func TestCompositeMarkerMasksBeforeEscapeAndCut(t *testing.T) {
1153+ secret := "sec;ret]%value"
1154+ long := strings.Repeat("x", 996) + secret // the cut falls inside the secret
1155+ tree := files{
1156+ ".github/actions/m/action.yml": "name: m\nruns:\n using: composite\n steps:\n" +
1157+ " - name: \"pre ${{ env.S }}\"\n shell: bash\n run: echo hi\n" +
1158+ " - name: \"" + long + "\"\n shell: bash\n run: echo hi\n",
1159+ }
1160+ rec, res := actionRun(t, tree, " - run: echo \"::add-mask::"+secret+"\"\n - uses: ./.github/actions/m\n env:\n S: "+secret+"\n", nil)
1161+ if res.Conclusion != ConclusionSuccess {
1162+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[2]))
1163+ }
1164+ log := logOf(rec.steps[2])
1165+ for _, frag := range []string{"sec;", "sec%3B", "value", "%5D%25", "xsec"} {
1166+ if strings.Contains(log, frag) {
1167+ t.Errorf("%q is in the log:\n%.1500s", frag, log)
1168+ }
1169+ }
1170+ if !strings.Contains(log, "##[start-action display=pre ***;id=") {
1171+ t.Errorf("marker:\n%s", log)
1172+ }
1173+}
1174+
1175+// pathCombine is Path.Combine: a rooted part replaces what precedes it, an empty
1176+// part is skipped, nothing is cleaned.
1177+func TestPathCombine(t *testing.T) {
1178+ for _, c := range []struct {
1179+ in []string
1180+ want string
1181+ }{
1182+ {[]string{"/w/r", "./.github/a"}, "/w/r/./.github/a"},
1183+ {[]string{"/w/r/", "x"}, "/w/r/x"},
1184+ {[]string{"/w/r/a", "/etc/x.js"}, "/etc/x.js"},
1185+ {[]string{"/w/r/a", "../../x.js"}, "/w/r/a/../../x.js"},
1186+ {[]string{"", "x"}, "x"},
1187+ {[]string{"/w", ""}, "/w"},
1188+ } {
1189+ if got := pathCombine(c.in...); got != c.want {
1190+ t.Errorf("%q: %q, want %q", c.in, got, c.want)
1191+ }
1192+ }
1193+}
@@ -11,6 +11,7 @@ import (
11 "net/http/httptest"11 "net/http/httptest"
12 "os"12 "os"
13 "path/filepath"13 "path/filepath"
14+ "regexp"
14 "strconv"15 "strconv"
15 "strings"16 "strings"
16 "sync"17 "sync"
@@ -377,7 +378,7 @@ runs:
377 t.Fatalf("%+v", res)378 t.Fatalf("%+v", res)
378 }379 }
379 got := logOf(rec.steps[1])380 got := logOf(rec.steps[1])
380- if !strings.Contains(got, "##[error]Process completed with exit code 3.") || strings.Contains(got, "never") ||381+ if !strings.Contains(got, "##[error]Process completed with exit code 3.") || strings.Contains(got, "\nnever") ||
381 !strings.Contains(got, "status=failure") {382 !strings.Contains(got, "status=failure") {
382 t.Errorf("%s", got)383 t.Errorf("%s", got)
383 }384 }
@@ -948,3 +949,245 @@ func TestTokenAllowedOrigins(t *testing.T) {
948 t.Error("no token, nothing to allow")949 t.Error("no token, nothing to allow")
949 }950 }
950 }951 }
952+
953+// The markers of the runner around the steps of a composite action
954+// (CompositeActionHandler.cs:323, :415-466), as recorded in act-composite-basic,
955+// act-composite-nested and act-composite-depth: the id is the dotted context
956+// name, a step without an id gets the id the workflow reader generated.
957+func TestCompositeStepMarkers(t *testing.T) {
958+ tree := files{
959+ ".github/actions/mid/action.yml": `name: mid
960+runs:
961+ using: composite
962+ steps:
963+ - id: leaf-step
964+ shell: bash
965+ name: "multi %; ] line"
966+ run: echo leaf
967+ - uses: ./.github/actions/leaf
968+`,
969+ ".github/actions/leaf/action.yml": `name: leaf
970+runs:
971+ using: composite
972+ steps:
973+ - shell: bash
974+ run: echo "level 2"
975+ - shell: bash
976+ if: false
977+ run: echo skipped
978+ - shell: bash
979+ continue-on-error: true
980+ run: exit 1
981+`,
982+ }
983+ rec, res := actionRun(t, tree, " - id: call\n uses: ./.github/actions/mid\n - uses: ./.github/actions/leaf\n", nil)
984+ if res.Conclusion != ConclusionSuccess {
985+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
986+ }
987+ var got []string
988+ for _, st := range rec.steps[1:3] {
989+ for _, l := range st.Log {
990+ if strings.HasPrefix(l, "##[start-action") || strings.HasPrefix(l, "##[end-action") {
991+ if !strings.Contains(l, "outcome=skipped") {
992+ l = regexp.MustCompile(`duration_ms=\d+`).ReplaceAllString(l, "duration_ms=N")
993+ }
994+ got = append(got, l)
995+ }
996+ }
997+ }
998+ want := []string{
999+ "##[start-action display=multi %25%3B %5D line;id=call.leaf-step]",
1000+ "##[end-action id=call.leaf-step;outcome=success;conclusion=success;duration_ms=N]",
1001+ "##[start-action display=Run ./.github/actions/leaf;id=call.__self]",
1002+ `##[start-action display=Run echo "level 2";id=call.__self.__run]`,
1003+ "##[end-action id=call.__self.__run;outcome=success;conclusion=success;duration_ms=N]",
1004+ "##[start-action display=Run echo skipped;id=call.__self.__run_2]",
1005+ "##[end-action id=call.__self.__run_2;outcome=skipped;conclusion=skipped;duration_ms=0]",
1006+ "##[start-action display=Run exit 1;id=call.__self.__run_3]",
1007+ "##[end-action id=call.__self.__run_3;outcome=failure;conclusion=success;duration_ms=N]",
1008+ "##[end-action id=call.__self;outcome=success;conclusion=success;duration_ms=N]",
1009+ `##[start-action display=Run echo "level 2";id=__self.__run]`,
1010+ "##[end-action id=__self.__run;outcome=success;conclusion=success;duration_ms=N]",
1011+ "##[start-action display=Run echo skipped;id=__self.__run_2]",
1012+ "##[end-action id=__self.__run_2;outcome=skipped;conclusion=skipped;duration_ms=0]",
1013+ "##[start-action display=Run exit 1;id=__self.__run_3]",
1014+ "##[end-action id=__self.__run_3;outcome=failure;conclusion=success;duration_ms=N]",
1015+ }
1016+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
1017+ t.Errorf("markers:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
1018+ }
1019+}
1020+
1021+// A local composite action that uses other local composite actions has one post
1022+// step, which prints "Post job cleanup." once (act-composite-depth: 9, 10 and 12
1023+// levels, one line on GitHub). What the runner registers while it prepares the
1024+// steps of a local composite is keyed by the id of the step of the job.
1025+func TestNestedLocalCompositePostsOnce(t *testing.T) {
1026+ tree := files{
1027+ "c1/action.yml": "name: c1\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo one\n - uses: ./c2\n",
1028+ "c2/action.yml": "name: c2\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo two\n - uses: ./c3\n",
1029+ "c3/action.yml": "name: c3\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo three\n",
1030+ }
1031+ rec, res := actionRun(t, tree, " - uses: ./c1\n", nil)
1032+ if res.Conclusion != ConclusionSuccess {
1033+ t.Fatalf("%+v", res)
1034+ }
1035+ post := logOf(rec.step(t, "Post Run ./c1"))
1036+ if post != "Post job cleanup." {
1037+ t.Errorf("post of c1: %q", post)
1038+ }
1039+ for _, n := range rec.names() {
1040+ if strings.HasPrefix(n, "Post ") && n != "Post Run ./c1" {
1041+ t.Errorf("unexpected step %q in %v", n, rec.names())
1042+ }
1043+ }
1044+}
1045+
1046+// A local action's directory is the workspace and the path of the step joined as
1047+// .NET's Path.Combine does, without cleaning: github.action_path and the paths in
1048+// the manifest errors keep the "./" of "uses: ./.github/actions/x" (recorded in
1049+// act-composite-basic and act-manifest-errors).
1050+func TestLocalActionPathKeepsDotSlash(t *testing.T) {
1051+ tree := files{
1052+ ".github/actions/ok/action.yml": "name: ok\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo \"path=$GITHUB_ACTION_PATH\"\n",
1053+ ".github/actions/bad/action.yml": "name: bad\nruns:\n using: composite\n steps:\n - run: echo no shell\n",
1054+ }
1055+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/ok\n - uses: ./.github/actions/bad\n", nil)
1056+ if res.Conclusion != ConclusionFailure {
1057+ t.Fatalf("%+v", res)
1058+ }
1059+ ws := regexp.MustCompile(`path=(.*)/\./\.github/actions/ok`).FindStringSubmatch(logOf(rec.steps[1]))
1060+ if ws == nil {
1061+ t.Fatalf("action_path lost its ./:\n%s", logOf(rec.steps[1]))
1062+ }
1063+ bad := logOf(rec.steps[2])
1064+ if !strings.Contains(bad, "##[error]"+ws[1]+"/./.github/actions/bad/action.yml (Line: 5, Col: 7): Required property is missing: shell") ||
1065+ !strings.Contains(bad, "##[error]Failed to load "+ws[1]+"/./.github/actions/bad/action.yml") {
1066+ t.Errorf("manifest errors:\n%s", bad)
1067+ }
1068+}
1069+
1070+// The markers of a composite step go through the masker like every log line
1071+// (ExecutionContext.Write masks tag+message after the marker was built): a
1072+// secret in the name of an inner step, from the configuration or from an
1073+// ::add-mask:: of an earlier step, never reaches the log.
1074+func TestCompositeMarkersAreMasked(t *testing.T) {
1075+ tree := files{
1076+ ".github/actions/m/action.yml": `name: m
1077+runs:
1078+ using: composite
1079+ steps:
1080+ - shell: bash
1081+ run: echo "::add-mask::late-secret-""value"
1082+ - name: "token ${{ github.token }} and late-secret-value"
1083+ shell: bash
1084+ run: echo hi
1085+ - name: "env ${{ env.TOK }}"
1086+ env:
1087+ TOK: late-secret-value
1088+ shell: bash
1089+ run: echo hi
1090+`,
1091+ }
1092+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/m\n", func(c *Config) { c.ContextsJSON = tokenContexts() })
1093+ if res.Conclusion != ConclusionSuccess {
1094+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
1095+ }
1096+ log := logOf(rec.steps[1])
1097+ for _, secret := range []string{testToken, "late-secret-value"} {
1098+ if strings.Contains(log, secret) {
1099+ t.Errorf("%q is in the log:\n%s", secret, log)
1100+ }
1101+ }
1102+ // A secret with a character the marker escapes is masked before the escape.
1103+ if got := markerEscaper.Replace(sanitizeDisplayName("a;b")); got != "a%3Bb" {
1104+ t.Errorf("escape %q", got)
1105+ }
1106+ for _, w := range []string{"##[start-action display=token *** and ***;id=", "##[start-action display=env ***;id="} {
1107+ if !strings.Contains(log, w) {
1108+ t.Errorf("missing %q:\n%s", w, log)
1109+ }
1110+ }
1111+}
1112+
1113+// An inner step whose env does not evaluate still gets its markers: the error
1114+// is logged while the environment is set up, the start marker follows, then the
1115+// end marker with the step failed (CompositeActionHandler.cs:296-302, :308-324).
1116+// The runner would go on and run the step; this port does not (see runEmbedded).
1117+func TestCompositeEnvErrorKeepsMarkers(t *testing.T) {
1118+ tree := files{
1119+ ".github/actions/e/action.yml": `name: e
1120+runs:
1121+ using: composite
1122+ steps:
1123+ - name: bad env
1124+ shell: bash
1125+ env:
1126+ X: ${{ fromJSON('{') }}
1127+ run: echo never
1128+`,
1129+ }
1130+ rec, res := actionRun(t, tree, " - uses: ./.github/actions/e\n", nil)
1131+ if res.Conclusion != ConclusionFailure {
1132+ t.Fatalf("%+v", res)
1133+ }
1134+ var got []string
1135+ for _, l := range rec.steps[1].Log {
1136+ switch {
1137+ case strings.HasPrefix(l, "##[error]"):
1138+ got = append(got, "error")
1139+ case strings.HasPrefix(l, "##[start-action"), strings.HasPrefix(l, "##[end-action"):
1140+ got = append(got, l)
1141+ }
1142+ }
1143+ want := []string{"error", "##[start-action display=bad env;id=__self.__run]", "##[end-action id=__self.__run;outcome=failure;conclusion=failure;duration_ms=0]"}
1144+ if strings.Join(got, "\n") != strings.Join(want, "\n") || strings.Contains(logOf(rec.steps[1]), "never") {
1145+ t.Errorf("markers:\n%s\n%s", strings.Join(got, "\n"), logOf(rec.steps[1]))
1146+ }
1147+}
1148+
1149+// A secret that the marker would escape, or that the cut at 1000 characters
1150+// would split, is masked before: the display name of an inner step holding it
1151+// leaves no part of it in the log.
1152+func TestCompositeMarkerMasksBeforeEscapeAndCut(t *testing.T) {
1153+ secret := "sec;ret]%value"
1154+ long := strings.Repeat("x", 996) + secret // the cut falls inside the secret
1155+ tree := files{
1156+ ".github/actions/m/action.yml": "name: m\nruns:\n using: composite\n steps:\n" +
1157+ " - name: \"pre ${{ env.S }}\"\n shell: bash\n run: echo hi\n" +
1158+ " - name: \"" + long + "\"\n shell: bash\n run: echo hi\n",
1159+ }
1160+ rec, res := actionRun(t, tree, " - run: echo \"::add-mask::"+secret+"\"\n - uses: ./.github/actions/m\n env:\n S: "+secret+"\n", nil)
1161+ if res.Conclusion != ConclusionSuccess {
1162+ t.Fatalf("%+v\n%s", res, logOf(rec.steps[2]))
1163+ }
1164+ log := logOf(rec.steps[2])
1165+ for _, frag := range []string{"sec;", "sec%3B", "value", "%5D%25", "xsec"} {
1166+ if strings.Contains(log, frag) {
1167+ t.Errorf("%q is in the log:\n%.1500s", frag, log)
1168+ }
1169+ }
1170+ if !strings.Contains(log, "##[start-action display=pre ***;id=") {
1171+ t.Errorf("marker:\n%s", log)
1172+ }
1173+}
1174+
1175+// pathCombine is Path.Combine: a rooted part replaces what precedes it, an empty
1176+// part is skipped, nothing is cleaned.
1177+func TestPathCombine(t *testing.T) {
1178+ for _, c := range []struct {
1179+ in []string
1180+ want string
1181+ }{
1182+ {[]string{"/w/r", "./.github/a"}, "/w/r/./.github/a"},
1183+ {[]string{"/w/r/", "x"}, "/w/r/x"},
1184+ {[]string{"/w/r/a", "/etc/x.js"}, "/etc/x.js"},
1185+ {[]string{"/w/r/a", "../../x.js"}, "/w/r/a/../../x.js"},
1186+ {[]string{"", "x"}, "x"},
1187+ {[]string{"/w", ""}, "/w"},
1188+ } {
1189+ if got := pathCombine(c.in...); got != c.want {
1190+ t.Errorf("%q: %q, want %q", c.in, got, c.want)
1191+ }
1192+ }
1193+}
M internal/executor/actions_types.go +3 -0
@@ -158,6 +158,9 @@ type node struct {
158158 // postRegistered is Root.StepsWithPostRegistered / EmbeddedStepsWithPostRegistered.
159159 postRegistered bool
160160 postCond string
161+ // runtimePrepared marks the steps of a local composite action, which are
162+ // prepared when the action runs (see registrar).
163+ runtimePrepared bool
161164 }
162165
163166 // nodeSeq numbers the nodes, for their state keys.
@@ -158,6 +158,9 @@ type node struct {
158 // postRegistered is Root.StepsWithPostRegistered / EmbeddedStepsWithPostRegistered.158 // postRegistered is Root.StepsWithPostRegistered / EmbeddedStepsWithPostRegistered.
159 postRegistered bool159 postRegistered bool
160 postCond string160 postCond string
161+ // runtimePrepared marks the steps of a local composite action, which are
162+ // prepared when the action runs (see registrar).
163+ runtimePrepared bool
161 }164 }
162 165
163 // nodeSeq numbers the nodes, for their state keys.166 // nodeSeq numbers the nodes, for their state keys.
M internal/executor/containers_exec_test.go +1 -1
@@ -108,7 +108,7 @@ func TestNodeActionInContainerUsesExternalsAndAlpine(t *testing.T) {
108108 t.Errorf("probe: %v", ex[0])
109109 }
110110 run := strings.Join(ex[1], "\n")
111- if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/.github/actions/x/index.js") {
111+ if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/./.github/actions/x/index.js") {
112112 t.Errorf("%s: node call:\n%s", tc.node, run)
113113 }
114114 if got := logOf(rec.step(t, "a")); !strings.Contains(got, "exec "+strings.Repeat("0", 63)+"1 sh -c \"cat /etc/*release | grep ^ID\"") {
@@ -108,7 +108,7 @@ func TestNodeActionInContainerUsesExternalsAndAlpine(t *testing.T) {
108 t.Errorf("probe: %v", ex[0])108 t.Errorf("probe: %v", ex[0])
109 }109 }
110 run := strings.Join(ex[1], "\n")110 run := strings.Join(ex[1], "\n")
111- if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/.github/actions/x/index.js") {111+ if !strings.Contains(run, "\n/__e/"+tc.node+"/bin/node\n") || !strings.Contains(run, "\n/__w/hello/hello/./.github/actions/x/index.js") {
112 t.Errorf("%s: node call:\n%s", tc.node, run)112 t.Errorf("%s: node call:\n%s", tc.node, run)
113 }113 }
114 if got := logOf(rec.step(t, "a")); !strings.Contains(got, "exec "+strings.Repeat("0", 63)+"1 sh -c \"cat /etc/*release | grep ^ID\"") {114 if got := logOf(rec.step(t, "a")); !strings.Contains(got, "exec "+strings.Repeat("0", 63)+"1 sh -c \"cat /etc/*release | grep ^ID\"") {

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 4

Tags 11

↑↓ moveenter openesc close