detached 6cc6c69

fix(executor): reuse resolutions across local composites and guard local composite recursion

commit
6cc6c69786860cdfcf5dc16554659d7cde53788d
author
brice · · 2026-10-14 03:04 UTC
parent
acae6da
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +107 -19
M internal/executor/actions_composite.go +18 -8
@@ -19,6 +19,11 @@ func compositeGitHubKeys() []string {
1919 return keys
2020 }
2121
22+// maxLocalDepth is the nesting of composite actions the executor allows. It is
23+// ermite's guard against a local composite action that uses itself, not a limit
24+// of GitHub's, whose runner has none for local actions.
25+const maxLocalDepth = 100
26+
2227 // prepareLocalComposite is the first part of ActionRunner.RunAsync for a local
2328 // composite action (ActionRunner.cs:77-96): its steps are prepared when the
2429 // step runs, in the log of the step, and the pulls and builds they need run
@@ -28,14 +33,19 @@ func (j *job) prepareLocalComposite(s *stepRun, def *actionDef, cancel <-chan st
2833 if n.children != nil {
2934 return nil // pre and post stages reuse the nodes of the main stage
3035 }
31- // Depth restarts at 1 for the steps of a local composite action, as in the
32- // runner (ActionRunner.cs:77-96 calls PrepareActionsAsync with depth 1), so a
33- // chain of local composites has no depth limit here
34- // (TestLocalCompositeChainHasNoDepthLimit). Whether github.com limits it is
35- // to be settled by the recording of ermite-conformance
36- // corpus/act-composite-depth. Every level that needs a resolution is counted
37- // by the host's per-job budget (host/resolve.go), so the server is safe
38- // whatever is decided.
36+ // Local composite actions have no depth limit on GitHub: the recording of
37+ // ermite-conformance corpus/act-composite-depth has composites of 9, 10 and 12
38+ // levels, all of which succeed. The runner prepares the steps of a local
39+ // composite when its step runs, with the depth restarting at 1
40+ // (ActionRunner.cs:77-96), which is why. This port does the same, and the
41+ // resolutions it needs are cached and budgeted by the host (host/resolve.go).
42+ //
43+ // The one guard that is ermite's own, not GitHub's: a local composite that
44+ // uses itself (directly or in a cycle) would recurse until the executor
45+ // crashes, so the nesting of composite actions is capped at maxLocalDepth.
46+ if d := s.depth(); d >= maxLocalDepth {
47+ return ghErrorf("Composite action depth exceeded max depth %d", maxLocalDepth)
48+ }
3949 kids, err := newNodes(def.act.Runs.Steps, n, n.scope)
4050 if err != nil {
4151 return err
@@ -19,6 +19,11 @@ func compositeGitHubKeys() []string {
19 return keys19 return keys
20 }20 }
21 21
22+// maxLocalDepth is the nesting of composite actions the executor allows. It is
23+// ermite's guard against a local composite action that uses itself, not a limit
24+// of GitHub's, whose runner has none for local actions.
25+const maxLocalDepth = 100
26+
22 // prepareLocalComposite is the first part of ActionRunner.RunAsync for a local27 // prepareLocalComposite is the first part of ActionRunner.RunAsync for a local
23 // composite action (ActionRunner.cs:77-96): its steps are prepared when the28 // composite action (ActionRunner.cs:77-96): its steps are prepared when the
24 // step runs, in the log of the step, and the pulls and builds they need run29 // step runs, in the log of the step, and the pulls and builds they need run
@@ -28,14 +33,19 @@ func (j *job) prepareLocalComposite(s *stepRun, def *actionDef, cancel <-chan st
28 if n.children != nil {33 if n.children != nil {
29 return nil // pre and post stages reuse the nodes of the main stage34 return nil // pre and post stages reuse the nodes of the main stage
30 }35 }
31- // Depth restarts at 1 for the steps of a local composite action, as in the36+ // Local composite actions have no depth limit on GitHub: the recording of
32- // runner (ActionRunner.cs:77-96 calls PrepareActionsAsync with depth 1), so a37+ // ermite-conformance corpus/act-composite-depth has composites of 9, 10 and 12
33- // chain of local composites has no depth limit here38+ // levels, all of which succeed. The runner prepares the steps of a local
34- // (TestLocalCompositeChainHasNoDepthLimit). Whether github.com limits it is39+ // composite when its step runs, with the depth restarting at 1
35- // to be settled by the recording of ermite-conformance40+ // (ActionRunner.cs:77-96), which is why. This port does the same, and the
36- // corpus/act-composite-depth. Every level that needs a resolution is counted41+ // resolutions it needs are cached and budgeted by the host (host/resolve.go).
37- // by the host's per-job budget (host/resolve.go), so the server is safe42+ //
38- // whatever is decided.43+ // The one guard that is ermite's own, not GitHub's: a local composite that
44+ // uses itself (directly or in a cycle) would recurse until the executor
45+ // crashes, so the nesting of composite actions is capped at maxLocalDepth.
46+ if d := s.depth(); d >= maxLocalDepth {
47+ return ghErrorf("Composite action depth exceeded max depth %d", maxLocalDepth)
48+ }
39 kids, err := newNodes(def.act.Runs.Steps, n, n.scope)49 kids, err := newNodes(def.act.Runs.Steps, n, n.scope)
40 if err != nil {50 if err != nil {
41 return err51 return err
M internal/executor/actions_prepare.go +19 -1
@@ -26,6 +26,9 @@ type actionState struct {
2626 // resolved caches the download info by "name@ref" (ActionManager's legacy
2727 // path asks again at every level; ermite's server caches per run).
2828 downloaded map[string]bool
29+ // infos are the resolutions of the job by "Name@Ref": an action that a local
30+ // composite action brings again is not asked for again.
31+ infos map[string]ResolvedAction
2932 }
3033
3134 type buildInfo struct{ repository, dockerfile, workDir string }
@@ -34,7 +37,7 @@ func newActionState() *actionState {
3437 return &actionState{
3538 pulled: map[string]string{}, built: map[string]string{}, buildInfo: map[string]buildInfo{},
3639 pullUsers: map[string][]*node{}, buildUsers: map[string][]*node{}, pre: map[*node]bool{},
37- downloaded: map[string]bool{},
40+ downloaded: map[string]bool{}, infos: map[string]ResolvedAction{},
3841 }
3942 }
4043
@@ -138,6 +141,20 @@ func (j *job) downloadInfo(ctx context.Context, num int, nodes []*node) (map[str
138141 if len(uses) == 0 {
139142 return out, nil
140143 }
144+ // What the job resolved already is not asked again: the host would not call
145+ // the server for it either (host/resolve.go), but the round trip is saved.
146+ var askUses, askKeys []string
147+ for i, k := range keys {
148+ if ra, ok := j.actions.infos[k]; ok {
149+ out[k] = ra
150+ continue
151+ }
152+ askUses, askKeys = append(askUses, uses[i]), append(askKeys, k)
153+ }
154+ if len(askUses) == 0 {
155+ return out, nil
156+ }
157+ uses, keys = askUses, askKeys
141158 if j.cfg.Actions.Resolver == nil {
142159 return nil, ghErrorf("Failed to resolve action download info.")
143160 }
@@ -158,6 +175,7 @@ func (j *job) downloadInfo(ctx context.Context, num int, nodes []*node) (map[str
158175 ra.NameWithOwner = strings.SplitN(keys[i], "@", 2)[0]
159176 }
160177 out[keys[i]] = ra
178+ j.actions.infos[keys[i]] = ra
161179 }
162180 return out, nil
163181 }
@@ -26,6 +26,9 @@ type actionState struct {
26 // resolved caches the download info by "name@ref" (ActionManager's legacy26 // resolved caches the download info by "name@ref" (ActionManager's legacy
27 // path asks again at every level; ermite's server caches per run).27 // path asks again at every level; ermite's server caches per run).
28 downloaded map[string]bool28 downloaded map[string]bool
29+ // infos are the resolutions of the job by "Name@Ref": an action that a local
30+ // composite action brings again is not asked for again.
31+ infos map[string]ResolvedAction
29 }32 }
30 33
31 type buildInfo struct{ repository, dockerfile, workDir string }34 type buildInfo struct{ repository, dockerfile, workDir string }
@@ -34,7 +37,7 @@ func newActionState() *actionState {
34 return &actionState{37 return &actionState{
35 pulled: map[string]string{}, built: map[string]string{}, buildInfo: map[string]buildInfo{},38 pulled: map[string]string{}, built: map[string]string{}, buildInfo: map[string]buildInfo{},
36 pullUsers: map[string][]*node{}, buildUsers: map[string][]*node{}, pre: map[*node]bool{},39 pullUsers: map[string][]*node{}, buildUsers: map[string][]*node{}, pre: map[*node]bool{},
37- downloaded: map[string]bool{},40+ downloaded: map[string]bool{}, infos: map[string]ResolvedAction{},
38 }41 }
39 }42 }
40 43
@@ -138,6 +141,20 @@ func (j *job) downloadInfo(ctx context.Context, num int, nodes []*node) (map[str
138 if len(uses) == 0 {141 if len(uses) == 0 {
139 return out, nil142 return out, nil
140 }143 }
144+ // What the job resolved already is not asked again: the host would not call
145+ // the server for it either (host/resolve.go), but the round trip is saved.
146+ var askUses, askKeys []string
147+ for i, k := range keys {
148+ if ra, ok := j.actions.infos[k]; ok {
149+ out[k] = ra
150+ continue
151+ }
152+ askUses, askKeys = append(askUses, uses[i]), append(askKeys, k)
153+ }
154+ if len(askUses) == 0 {
155+ return out, nil
156+ }
157+ uses, keys = askUses, askKeys
141 if j.cfg.Actions.Resolver == nil {158 if j.cfg.Actions.Resolver == nil {
142 return nil, ghErrorf("Failed to resolve action download info.")159 return nil, ghErrorf("Failed to resolve action download info.")
143 }160 }
@@ -158,6 +175,7 @@ func (j *job) downloadInfo(ctx context.Context, num int, nodes []*node) (map[str
158 ra.NameWithOwner = strings.SplitN(keys[i], "@", 2)[0]175 ra.NameWithOwner = strings.SplitN(keys[i], "@", 2)[0]
159 }176 }
160 out[keys[i]] = ra177 out[keys[i]] = ra
178+ j.actions.infos[keys[i]] = ra
161 }179 }
162 return out, nil180 return out, nil
163 }181 }
M internal/executor/actions_test.go +61 -10
@@ -11,6 +11,7 @@ import (
1111 "net/http/httptest"
1212 "os"
1313 "path/filepath"
14+ "strconv"
1415 "strings"
1516 "sync"
1617 "sync/atomic"
@@ -429,18 +430,68 @@ func TestCompositeDepthLimit(t *testing.T) {
429430 }
430431 }
431432
432-func TestLocalCompositeChainHasNoDepthLimit(t *testing.T) {
433- tree := files{}
434- for i := 0; i < 12; i++ {
435- next := ""
436- if i < 11 {
437- next = " - uses: ./d" + string(rune('a'+i+1)) + "\n"
433+// Local composites are not limited on GitHub (the recording act-composite-depth
434+// has 9, 10 and 12 levels succeed); only ermite's guard against recursion stops
435+// a composite that uses itself.
436+func TestLocalCompositeDepth(t *testing.T) {
437+ chain := func(n int) files {
438+ tree := files{}
439+ for i := 0; i < n; i++ {
440+ next := ""
441+ if i < n-1 {
442+ next = " - uses: ./d" + strconv.Itoa(i+1) + "\n"
443+ }
444+ tree["d"+strconv.Itoa(i)+"/action.yml"] = "name: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo level\n" + next
445+ }
446+ return tree
447+ }
448+ for _, n := range []int{9, 10, 12} {
449+ rec, res := actionRun(t, chain(n), " - uses: ./d0\n", nil)
450+ if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.step(t, "Run ./d0")), "\nlevel") != n {
451+ t.Fatalf("%d levels: %+v\n%s", n, res, logOf(rec.step(t, "Run ./d0")))
438452 }
439- tree["d"+string(rune('a'+i))+"/action.yml"] = "name: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo level\n" + next
440453 }
441- rec, res := actionRun(t, tree, " - uses: ./da\n", nil)
442- if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.step(t, "Run ./da")), "\nlevel") != 12 {
443- t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Run ./da")))
454+ // Up to the guard.
455+ if _, res := actionRun(t, chain(maxLocalDepth), " - uses: ./d0\n", nil); res.Conclusion != ConclusionSuccess {
456+ t.Fatalf("%d levels: %+v", maxLocalDepth, res)
457+ }
458+ // A composite that uses itself fails cleanly, and the job goes on.
459+ self := files{"s/action.yml": "name: s\nruns:\n using: composite\n steps:\n - uses: ./s\n"}
460+ rec, res := actionRun(t, self, " - uses: ./s\n - if: always()\n run: echo after\n", nil)
461+ if res.Conclusion != ConclusionFailure {
462+ t.Fatalf("%+v", res)
463+ }
464+ if got := logOf(rec.step(t, "Run ./s")); strings.Count(got, "##[error]Composite action depth exceeded max depth 100") != 1 {
465+ t.Errorf("%s", got[:min(len(got), 600)])
466+ }
467+ if got := rec.step(t, "Run echo after").Conclusion; got != ConclusionSuccess {
468+ t.Errorf("the step after: %s", got)
469+ }
470+ // Two actions that use each other.
471+ cyc := files{
472+ "a/action.yml": "name: a\nruns:\n using: composite\n steps:\n - uses: ./b\n",
473+ "b/action.yml": "name: b\nruns:\n using: composite\n steps:\n - uses: ./a\n",
474+ }
475+ if _, res := actionRun(t, cyc, " - uses: ./a\n", nil); res.Conclusion != ConclusionFailure {
476+ t.Errorf("cycle: %+v", res)
477+ }
478+}
479+
480+// A repository composite used twice, and by a local composite used many times,
481+// is resolved and downloaded once.
482+func TestResolutionsAreReusedAcrossLocalComposites(t *testing.T) {
483+ forge := newForge(t, map[string][]byte{"/tarball/o/leaf/v1": tarOf(t, "o-leaf-x", files{"action.yml": nodeAction, "main.sh": "echo leaf\n"})})
484+ tree := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"}
485+ var yaml string
486+ for i := 0; i < 5; i++ {
487+ yaml += " - uses: ./c\n"
488+ }
489+ rec, res := actionRun(t, tree, yaml, func(c *Config) { c.Actions.Resolver = forge.resolver("") })
490+ if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.steps[1])+logOf(rec.steps[2])+logOf(rec.steps[5]), "leaf") < 3 {
491+ t.Fatalf("%+v %v", res, rec.names())
492+ }
493+ if len(forge.asked) != 1 || forge.hits.Load() != 1 {
494+ t.Errorf("asked %v, downloaded %d times: once is enough", forge.asked, forge.hits.Load())
444495 }
445496 }
446497
@@ -11,6 +11,7 @@ import (
11 "net/http/httptest"11 "net/http/httptest"
12 "os"12 "os"
13 "path/filepath"13 "path/filepath"
14+ "strconv"
14 "strings"15 "strings"
15 "sync"16 "sync"
16 "sync/atomic"17 "sync/atomic"
@@ -429,18 +430,68 @@ func TestCompositeDepthLimit(t *testing.T) {
429 }430 }
430 }431 }
431 432
432-func TestLocalCompositeChainHasNoDepthLimit(t *testing.T) {433+// Local composites are not limited on GitHub (the recording act-composite-depth
433- tree := files{}434+// has 9, 10 and 12 levels succeed); only ermite's guard against recursion stops
434- for i := 0; i < 12; i++ {435+// a composite that uses itself.
435- next := ""436+func TestLocalCompositeDepth(t *testing.T) {
436- if i < 11 {437+ chain := func(n int) files {
437- next = " - uses: ./d" + string(rune('a'+i+1)) + "\n"438+ tree := files{}
439+ for i := 0; i < n; i++ {
440+ next := ""
441+ if i < n-1 {
442+ next = " - uses: ./d" + strconv.Itoa(i+1) + "\n"
443+ }
444+ tree["d"+strconv.Itoa(i)+"/action.yml"] = "name: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo level\n" + next
445+ }
446+ return tree
447+ }
448+ for _, n := range []int{9, 10, 12} {
449+ rec, res := actionRun(t, chain(n), " - uses: ./d0\n", nil)
450+ if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.step(t, "Run ./d0")), "\nlevel") != n {
451+ t.Fatalf("%d levels: %+v\n%s", n, res, logOf(rec.step(t, "Run ./d0")))
438 }452 }
439- tree["d"+string(rune('a'+i))+"/action.yml"] = "name: d\nruns:\n using: composite\n steps:\n - shell: bash\n run: echo level\n" + next
440 }453 }
441- rec, res := actionRun(t, tree, " - uses: ./da\n", nil)454+ // Up to the guard.
442- if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.step(t, "Run ./da")), "\nlevel") != 12 {455+ if _, res := actionRun(t, chain(maxLocalDepth), " - uses: ./d0\n", nil); res.Conclusion != ConclusionSuccess {
443- t.Fatalf("%+v\n%s", res, logOf(rec.step(t, "Run ./da")))456+ t.Fatalf("%d levels: %+v", maxLocalDepth, res)
457+ }
458+ // A composite that uses itself fails cleanly, and the job goes on.
459+ self := files{"s/action.yml": "name: s\nruns:\n using: composite\n steps:\n - uses: ./s\n"}
460+ rec, res := actionRun(t, self, " - uses: ./s\n - if: always()\n run: echo after\n", nil)
461+ if res.Conclusion != ConclusionFailure {
462+ t.Fatalf("%+v", res)
463+ }
464+ if got := logOf(rec.step(t, "Run ./s")); strings.Count(got, "##[error]Composite action depth exceeded max depth 100") != 1 {
465+ t.Errorf("%s", got[:min(len(got), 600)])
466+ }
467+ if got := rec.step(t, "Run echo after").Conclusion; got != ConclusionSuccess {
468+ t.Errorf("the step after: %s", got)
469+ }
470+ // Two actions that use each other.
471+ cyc := files{
472+ "a/action.yml": "name: a\nruns:\n using: composite\n steps:\n - uses: ./b\n",
473+ "b/action.yml": "name: b\nruns:\n using: composite\n steps:\n - uses: ./a\n",
474+ }
475+ if _, res := actionRun(t, cyc, " - uses: ./a\n", nil); res.Conclusion != ConclusionFailure {
476+ t.Errorf("cycle: %+v", res)
477+ }
478+}
479+
480+// A repository composite used twice, and by a local composite used many times,
481+// is resolved and downloaded once.
482+func TestResolutionsAreReusedAcrossLocalComposites(t *testing.T) {
483+ forge := newForge(t, map[string][]byte{"/tarball/o/leaf/v1": tarOf(t, "o-leaf-x", files{"action.yml": nodeAction, "main.sh": "echo leaf\n"})})
484+ tree := files{"c/action.yml": "name: c\nruns:\n using: composite\n steps:\n - uses: o/leaf@v1\n"}
485+ var yaml string
486+ for i := 0; i < 5; i++ {
487+ yaml += " - uses: ./c\n"
488+ }
489+ rec, res := actionRun(t, tree, yaml, func(c *Config) { c.Actions.Resolver = forge.resolver("") })
490+ if res.Conclusion != ConclusionSuccess || strings.Count(logOf(rec.steps[1])+logOf(rec.steps[2])+logOf(rec.steps[5]), "leaf") < 3 {
491+ t.Fatalf("%+v %v", res, rec.names())
492+ }
493+ if len(forge.asked) != 1 || forge.hits.Load() != 1 {
494+ t.Errorf("asked %v, downloaded %d times: once is enough", forge.asked, forge.hits.Load())
444 }495 }
445 }496 }
446 497
M internal/executor/step.go +9 -0
@@ -47,6 +47,15 @@ type stepRun struct {
4747 nodeVersion string
4848 }
4949
50+// depth is the number of composite actions around the step.
51+func (s *stepRun) depth() int {
52+ d := 0
53+ for p := s.parent; p != nil; p = p.parent {
54+ d++
55+ }
56+ return d
57+}
58+
5059 // procEnv is the environment of the process the step starts.
5160 func (s *stepRun) procEnv() []string {
5261 if s.rawEnvList != nil {
@@ -47,6 +47,15 @@ type stepRun struct {
47 nodeVersion string47 nodeVersion string
48 }48 }
49 49
50+// depth is the number of composite actions around the step.
51+func (s *stepRun) depth() int {
52+ d := 0
53+ for p := s.parent; p != nil; p = p.parent {
54+ d++
55+ }
56+ return d
57+}
58+
50 // procEnv is the environment of the process the step starts.59 // procEnv is the environment of the process the step starts.
51 func (s *stepRun) procEnv() []string {60 func (s *stepRun) procEnv() []string {
52 if s.rawEnvList != nil {61 if s.rawEnvList != nil {

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