detached e244502

feat(image,executor): node 22 + yarn, ACTIONS_CACHE_MODE, action context test (#30)

commit
e24450221a5f4130a8d1b68fed4d21f16a2e039a
author
brice · · 2026-10-16 08:04 UTC
committer
bnema · · 2026-10-16 08:04 UTC
parent
fa0055a
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
14 files · +176 -25
M images/ubuntu-24.04/Dockerfile +25 -0
@@ -40,6 +40,31 @@ RUN install -m 0755 -d /etc/apt/keyrings \
4040 docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
4141 && rm -rf /var/lib/apt/lists/*
4242
43+# Node.js 22 and yarn 1 system-wide in /usr/local, like GitHub's image
44+# (runner-images, install-nodejs.sh:11-24 and toolset-2404.json: node.default
45+# 22, node_modules yarn). The tarball is checked against the SHA-256 published
46+# in https://nodejs.org/dist/v22.23.3/SHASUMS256.txt. /usr/local/lib/node_modules
47+# and /usr/local/bin are world-writable so that "npm install -g" works as the
48+# runner user (runner-images issue 3727).
49+ARG NODE_VERSION=22.23.3
50+ARG NODE_SHA256_AMD64=df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de
51+ARG NODE_SHA256_ARM64=a44aeb94849a299b22df10b9e622ec2f605c2183501bc40590705131de7c740f
52+RUN arch=$(dpkg --print-architecture) \
53+ && case "$arch" in \
54+ amd64) node_arch=x64; node_sha=$NODE_SHA256_AMD64 ;; \
55+ arm64) node_arch=arm64; node_sha=$NODE_SHA256_ARM64 ;; \
56+ *) echo "unsupported architecture $arch" >&2; exit 1 ;; \
57+ esac \
58+ && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" -o /tmp/node.tar.xz \
59+ && echo "${node_sha} /tmp/node.tar.xz" | sha256sum -c - \
60+ && tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --no-same-owner \
61+ --exclude='*/CHANGELOG.md' --exclude='*/LICENSE' --exclude='*/README.md' \
62+ && rm /tmp/node.tar.xz \
63+ && npm install -g yarn@1.22.22 \
64+ && npm cache clean --force \
65+ && chmod -R 777 /usr/local/lib/node_modules \
66+ && chmod 777 /usr/local/bin
67+
4368 # Like GitHub-hosted runners: user "runner" (uid 1001), passwordless sudo,
4469 # member of "docker", with the job layout directories.
4570 RUN useradd --create-home --uid 1001 --shell /bin/bash runner \
@@ -40,6 +40,31 @@ RUN install -m 0755 -d /etc/apt/keyrings \
40 docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \40 docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
41 && rm -rf /var/lib/apt/lists/*41 && rm -rf /var/lib/apt/lists/*
42 42
43+# Node.js 22 and yarn 1 system-wide in /usr/local, like GitHub's image
44+# (runner-images, install-nodejs.sh:11-24 and toolset-2404.json: node.default
45+# 22, node_modules yarn). The tarball is checked against the SHA-256 published
46+# in https://nodejs.org/dist/v22.23.3/SHASUMS256.txt. /usr/local/lib/node_modules
47+# and /usr/local/bin are world-writable so that "npm install -g" works as the
48+# runner user (runner-images issue 3727).
49+ARG NODE_VERSION=22.23.3
50+ARG NODE_SHA256_AMD64=df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de
51+ARG NODE_SHA256_ARM64=a44aeb94849a299b22df10b9e622ec2f605c2183501bc40590705131de7c740f
52+RUN arch=$(dpkg --print-architecture) \
53+ && case "$arch" in \
54+ amd64) node_arch=x64; node_sha=$NODE_SHA256_AMD64 ;; \
55+ arm64) node_arch=arm64; node_sha=$NODE_SHA256_ARM64 ;; \
56+ *) echo "unsupported architecture $arch" >&2; exit 1 ;; \
57+ esac \
58+ && curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" -o /tmp/node.tar.xz \
59+ && echo "${node_sha} /tmp/node.tar.xz" | sha256sum -c - \
60+ && tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --no-same-owner \
61+ --exclude='*/CHANGELOG.md' --exclude='*/LICENSE' --exclude='*/README.md' \
62+ && rm /tmp/node.tar.xz \
63+ && npm install -g yarn@1.22.22 \
64+ && npm cache clean --force \
65+ && chmod -R 777 /usr/local/lib/node_modules \
66+ && chmod 777 /usr/local/bin
67+
43 # Like GitHub-hosted runners: user "runner" (uid 1001), passwordless sudo,68 # Like GitHub-hosted runners: user "runner" (uid 1001), passwordless sudo,
44 # member of "docker", with the job layout directories.69 # member of "docker", with the job layout directories.
45 RUN useradd --create-home --uid 1001 --shell /bin/bash runner \70 RUN useradd --create-home --uid 1001 --shell /bin/bash runner \
M internal/cachesvc/limits_test.go +2 -1
@@ -6,7 +6,6 @@ import (
66 "bufio"
77 "bytes"
88 "fmt"
9- "github.com/bnema/zerowrap"
109 "io"
1110 "net"
1211 "net/http"
@@ -17,6 +16,8 @@ import (
1716 "sync"
1817 "testing"
1918 "time"
19+
20+ "github.com/bnema/zerowrap"
2021 )
2122
2223 // chunkReq sends a PATCH whose body the test feeds through the returned writer;
@@ -6,7 +6,6 @@ import (
6 "bufio"6 "bufio"
7 "bytes"7 "bytes"
8 "fmt"8 "fmt"
9- "github.com/bnema/zerowrap"
10 "io"9 "io"
11 "net"10 "net"
12 "net/http"11 "net/http"
@@ -17,6 +16,8 @@ import (
17 "sync"16 "sync"
18 "testing"17 "testing"
19 "time"18 "time"
19+
20+ "github.com/bnema/zerowrap"
20 )21 )
21 22
22 // chunkReq sends a PATCH whose body the test feeds through the returned writer;23 // chunkReq sends a PATCH whose body the test feeds through the returned writer;
M internal/execstream/exec.proto +4 -0
@@ -115,6 +115,10 @@ message JobSpec {
115115 // server sent none (an older server): the executor then reads them from the
116116 // workflow.
117117 TokenPermissions token_permissions = 20;
118+ // cache_mode is ACTIONS_CACHE_MODE of the node and docker actions: "write"
119+ // when the job's cache scope has a write ref, "read" when it has none. Unset
120+ // without a cache service.
121+ string cache_mode = 21;
118122 }
119123
120124 // TokenPermissions maps a permission key ("contents", "pull-requests", ...) to
@@ -115,6 +115,10 @@ message JobSpec {
115 // server sent none (an older server): the executor then reads them from the115 // server sent none (an older server): the executor then reads them from the
116 // workflow.116 // workflow.
117 TokenPermissions token_permissions = 20;117 TokenPermissions token_permissions = 20;
118+ // cache_mode is ACTIONS_CACHE_MODE of the node and docker actions: "write"
119+ // when the job's cache scope has a write ref, "read" when it has none. Unset
120+ // without a cache service.
121+ string cache_mode = 21;
118 }122 }
119 123
120 // TokenPermissions maps a permission key ("contents", "pull-requests", ...) to124 // TokenPermissions maps a permission key ("contents", "pull-requests", ...) to
M internal/execstream/execpb/exec.pb.go +17 -4 generated file: collapsed
M internal/executor/actions_cache_test.go +43 -3
@@ -21,7 +21,7 @@ runs:
2121 main: main.sh
2222 `
2323
24-const envScript = "echo \"url=[$ACTIONS_CACHE_URL] rurl=[$ACTIONS_RUNTIME_URL] token=[$ACTIONS_RUNTIME_TOKEN] v2=[${ACTIONS_CACHE_SERVICE_V2-unset}] results=[${ACTIONS_RESULTS_URL-unset}]\"\n"
24+const envScript = "echo \"mode=[${ACTIONS_CACHE_MODE-unset}] url=[$ACTIONS_CACHE_URL] rurl=[$ACTIONS_RUNTIME_URL] token=[$ACTIONS_RUNTIME_TOKEN] v2=[${ACTIONS_CACHE_SERVICE_V2-unset}] results=[${ACTIONS_RESULTS_URL-unset}]\"\n"
2525
2626 // NodeScriptActionHandler.cs:55-72: a node action gets the runtime variables. The
2727 // URL is the base the toolkit appends "_apis/artifactcache/…" to (it ends with a
@@ -34,7 +34,7 @@ func TestNodeActionGetsCacheEnv(t *testing.T) {
3434 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
3535 }
3636 got := logOf(rec.step(t, "Run ./.github/actions/e"))
37- want := "url=[" + cacheTestURL + "] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[unset]"
37+ want := "mode=[unset] url=[" + cacheTestURL + "] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[unset]"
3838 if !strings.Contains(got, want) {
3939 t.Errorf("want %q in\n%s", want, got)
4040 }
@@ -58,7 +58,7 @@ func TestCacheEnvOnlyForActions(t *testing.T) {
5858 if res.Conclusion != ConclusionSuccess {
5959 t.Fatalf("%+v", res)
6060 }
61- if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, "url=[] rurl=[] token=[]") {
61+ if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, "mode=[unset] url=[] rurl=[] token=[]") {
6262 t.Errorf("no cache service, no variables:\n%s", got)
6363 }
6464 }
@@ -91,3 +91,43 @@ func TestDockerActionGetsCacheEnv(t *testing.T) {
9191 t.Errorf("the token is in the log:\n%s", run)
9292 }
9393 }
94+
95+// ACTIONS_CACHE_MODE (NodeScriptActionHandler.cs:71-84): set from the cache
96+// mode of the job, for node and docker actions, next to the URL only.
97+func TestCacheModeEnv(t *testing.T) {
98+ for _, mode := range []string{"write", "read"} {
99+ rec, res := actionRun(t, files{".github/actions/e/action.yml": envAction, ".github/actions/e/main.sh": envScript},
100+ " - uses: ./.github/actions/e\n", func(c *Config) { withCache(c); c.CacheMode = mode })
101+ if res.Conclusion != ConclusionSuccess {
102+ t.Fatalf("%+v", res)
103+ }
104+ if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, "mode=["+mode+"] url=[") {
105+ t.Errorf("mode %s:\n%s", mode, got)
106+ }
107+ }
108+ // A mode without a cache service sets nothing.
109+ rec, res := actionRun(t, files{".github/actions/e/action.yml": envAction, ".github/actions/e/main.sh": envScript},
110+ " - uses: ./.github/actions/e\n", func(c *Config) { c.CacheMode = "write" })
111+ if res.Conclusion != ConclusionSuccess {
112+ t.Fatalf("%+v", res)
113+ }
114+ if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, "mode=[unset]") {
115+ t.Errorf("no cache service:\n%s", got)
116+ }
117+}
118+
119+func TestDockerActionGetsCacheMode(t *testing.T) {
120+ d, lf := fakeDocker(t, 0)
121+ _, res := actionRun(t, files{}, " - uses: docker://alpine:3.20\n", func(c *Config) {
122+ c.Actions.DockerPath = d
123+ withCache(c)
124+ c.CacheMode = "read"
125+ })
126+ if res.Conclusion != ConclusionSuccess {
127+ t.Fatalf("%+v", res)
128+ }
129+ cs := calls(t, lf)
130+ if len(cs) != 2 || !strings.Contains("\n"+strings.Join(cs[1], "\n")+"\n", "\n-e\nACTIONS_CACHE_MODE\n") {
131+ t.Errorf("docker run lacks -e ACTIONS_CACHE_MODE: %v", cs)
132+ }
133+}
@@ -21,7 +21,7 @@ runs:
21 main: main.sh21 main: main.sh
22 `22 `
23 23
24-const envScript = "echo \"url=[$ACTIONS_CACHE_URL] rurl=[$ACTIONS_RUNTIME_URL] token=[$ACTIONS_RUNTIME_TOKEN] v2=[${ACTIONS_CACHE_SERVICE_V2-unset}] results=[${ACTIONS_RESULTS_URL-unset}]\"\n"24+const envScript = "echo \"mode=[${ACTIONS_CACHE_MODE-unset}] url=[$ACTIONS_CACHE_URL] rurl=[$ACTIONS_RUNTIME_URL] token=[$ACTIONS_RUNTIME_TOKEN] v2=[${ACTIONS_CACHE_SERVICE_V2-unset}] results=[${ACTIONS_RESULTS_URL-unset}]\"\n"
25 25
26 // NodeScriptActionHandler.cs:55-72: a node action gets the runtime variables. The26 // NodeScriptActionHandler.cs:55-72: a node action gets the runtime variables. The
27 // URL is the base the toolkit appends "_apis/artifactcache/…" to (it ends with a27 // URL is the base the toolkit appends "_apis/artifactcache/…" to (it ends with a
@@ -34,7 +34,7 @@ func TestNodeActionGetsCacheEnv(t *testing.T) {
34 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))34 t.Fatalf("%+v\n%s", res, logOf(rec.steps[1]))
35 }35 }
36 got := logOf(rec.step(t, "Run ./.github/actions/e"))36 got := logOf(rec.step(t, "Run ./.github/actions/e"))
37- want := "url=[" + cacheTestURL + "] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[unset]"37+ want := "mode=[unset] url=[" + cacheTestURL + "] rurl=[" + cacheTestURL + "] token=[***] v2=[unset] results=[unset]"
38 if !strings.Contains(got, want) {38 if !strings.Contains(got, want) {
39 t.Errorf("want %q in\n%s", want, got)39 t.Errorf("want %q in\n%s", want, got)
40 }40 }
@@ -58,7 +58,7 @@ func TestCacheEnvOnlyForActions(t *testing.T) {
58 if res.Conclusion != ConclusionSuccess {58 if res.Conclusion != ConclusionSuccess {
59 t.Fatalf("%+v", res)59 t.Fatalf("%+v", res)
60 }60 }
61- if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, "url=[] rurl=[] token=[]") {61+ if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, "mode=[unset] url=[] rurl=[] token=[]") {
62 t.Errorf("no cache service, no variables:\n%s", got)62 t.Errorf("no cache service, no variables:\n%s", got)
63 }63 }
64 }64 }
@@ -91,3 +91,43 @@ func TestDockerActionGetsCacheEnv(t *testing.T) {
91 t.Errorf("the token is in the log:\n%s", run)91 t.Errorf("the token is in the log:\n%s", run)
92 }92 }
93 }93 }
94+
95+// ACTIONS_CACHE_MODE (NodeScriptActionHandler.cs:71-84): set from the cache
96+// mode of the job, for node and docker actions, next to the URL only.
97+func TestCacheModeEnv(t *testing.T) {
98+ for _, mode := range []string{"write", "read"} {
99+ rec, res := actionRun(t, files{".github/actions/e/action.yml": envAction, ".github/actions/e/main.sh": envScript},
100+ " - uses: ./.github/actions/e\n", func(c *Config) { withCache(c); c.CacheMode = mode })
101+ if res.Conclusion != ConclusionSuccess {
102+ t.Fatalf("%+v", res)
103+ }
104+ if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, "mode=["+mode+"] url=[") {
105+ t.Errorf("mode %s:\n%s", mode, got)
106+ }
107+ }
108+ // A mode without a cache service sets nothing.
109+ rec, res := actionRun(t, files{".github/actions/e/action.yml": envAction, ".github/actions/e/main.sh": envScript},
110+ " - uses: ./.github/actions/e\n", func(c *Config) { c.CacheMode = "write" })
111+ if res.Conclusion != ConclusionSuccess {
112+ t.Fatalf("%+v", res)
113+ }
114+ if got := logOf(rec.step(t, "Run ./.github/actions/e")); !strings.Contains(got, "mode=[unset]") {
115+ t.Errorf("no cache service:\n%s", got)
116+ }
117+}
118+
119+func TestDockerActionGetsCacheMode(t *testing.T) {
120+ d, lf := fakeDocker(t, 0)
121+ _, res := actionRun(t, files{}, " - uses: docker://alpine:3.20\n", func(c *Config) {
122+ c.Actions.DockerPath = d
123+ withCache(c)
124+ c.CacheMode = "read"
125+ })
126+ if res.Conclusion != ConclusionSuccess {
127+ t.Fatalf("%+v", res)
128+ }
129+ cs := calls(t, lf)
130+ if len(cs) != 2 || !strings.Contains("\n"+strings.Join(cs[1], "\n")+"\n", "\n-e\nACTIONS_CACHE_MODE\n") {
131+ t.Errorf("docker run lacks -e ACTIONS_CACHE_MODE: %v", cs)
132+ }
133+}
M internal/executor/actions_node.go +5 -0
@@ -168,6 +168,11 @@ func (j *job) addRuntimeEnv(env *ordered) {
168168 env.Set("ACTIONS_RUNTIME_URL", j.cfg.CacheURL)
169169 env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken)
170170 env.Set("ACTIONS_CACHE_URL", j.cfg.CacheURL)
171+ // ACTIONS_CACHE_MODE: NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:
172+ // 237-245; the toolkit skips uploads in "read" mode (cache config.ts:27-40).
173+ if j.cfg.CacheMode != "" {
174+ env.Set("ACTIONS_CACHE_MODE", j.cfg.CacheMode)
175+ }
171176 }
172177
173178 // runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs:
@@ -168,6 +168,11 @@ func (j *job) addRuntimeEnv(env *ordered) {
168 env.Set("ACTIONS_RUNTIME_URL", j.cfg.CacheURL)168 env.Set("ACTIONS_RUNTIME_URL", j.cfg.CacheURL)
169 env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken)169 env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken)
170 env.Set("ACTIONS_CACHE_URL", j.cfg.CacheURL)170 env.Set("ACTIONS_CACHE_URL", j.cfg.CacheURL)
171+ // ACTIONS_CACHE_MODE: NodeScriptActionHandler.cs:71-84, ContainerActionHandler.cs:
172+ // 237-245; the toolkit skips uploads in "read" mode (cache config.ts:27-40).
173+ if j.cfg.CacheMode != "" {
174+ env.Set("ACTIONS_CACHE_MODE", j.cfg.CacheMode)
175+ }
171 }176 }
172 177
173 // runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs:178 // runNode is NodeScriptActionHandler.RunAsync (NodeScriptActionHandler.cs:
M internal/executor/actions_test.go +32 -0
@@ -719,6 +719,38 @@ func TestRepositoryActionPrePostAndState(t *testing.T) {
719719 }
720720 }
721721
722+// github.action_repository and github.action_ref are set by ActionRunner.RunAsync
723+// of an action step (ActionRunner.cs:143-154, and cleared to null for any other
724+// step, run: ones included), but the step env is evaluated before that, in
725+// StepsRunner.cs:118-124. So `env:` of a step sees the values left by the last
726+// step that ran RunAsync (a skipped step does not), while its own body sees
727+// its own, empty ones. Pinned for a main that succeeds and one that fails.
728+func TestActionContextInStepEnvAndBody(t *testing.T) {
729+ probe := " - run: echo \"ENV=[$R] BODY=[${{ github.action_repository }}@${{ github.action_ref }}]\"\n" +
730+ " if: always()\n" +
731+ " env:\n" +
732+ " R: ${{ github.action_repository }}@${{ github.action_ref }}\n"
733+ for _, tc := range []struct{ name, main, between, want string }{
734+ {"success", "exit 0\n", "", "ENV=[o/ar@v1] BODY=[@]"},
735+ {"failure", "exit 1\n", "", "ENV=[o/ar@v1] BODY=[@]"},
736+ {"a run step in between clears it", "exit 0\n", " - run: echo between\n if: always()\n", "ENV=[@] BODY=[@]"},
737+ } {
738+ t.Run(tc.name, func(t *testing.T) {
739+ forge := newForge(t, map[string][]byte{"/tarball/o/ar/v1": tarOf(t, "o-ar-x", files{"action.yml": nodeAction, "main.sh": tc.main})})
740+ steps := " - uses: o/ar@v1\n" +
741+ " - uses: ./skipped\n if: false\n" + tc.between + probe
742+ rec, res := actionRun(t, files{}, steps, func(c *Config) { c.Actions.Resolver = forge.resolver("") })
743+ if wantOK := tc.name != "failure"; (res.Conclusion == ConclusionSuccess) != wantOK {
744+ t.Fatalf("conclusion %+v", res)
745+ }
746+ got := logOf(rec.steps[len(rec.steps)-2]) // the probe, before "Complete job"
747+ if !strings.Contains(got, tc.want) {
748+ t.Errorf("want %q in\n%s\nsteps %v", tc.want, got, rec.names())
749+ }
750+ })
751+ }
752+}
753+
722754 func TestDockerPrePostEntrypoints(t *testing.T) {
723755 forge := newForge(t, map[string][]byte{"/tarball/o/dk/v1": tarOf(t, "o-dk-x", files{
724756 "action.yml": "name: dk\nruns:\n using: docker\n image: docker://alpine:3\n pre-entrypoint: /pre.sh\n entrypoint: /main.sh\n post-entrypoint: /post.sh\n"})})
@@ -719,6 +719,38 @@ func TestRepositoryActionPrePostAndState(t *testing.T) {
719 }719 }
720 }720 }
721 721
722+// github.action_repository and github.action_ref are set by ActionRunner.RunAsync
723+// of an action step (ActionRunner.cs:143-154, and cleared to null for any other
724+// step, run: ones included), but the step env is evaluated before that, in
725+// StepsRunner.cs:118-124. So `env:` of a step sees the values left by the last
726+// step that ran RunAsync (a skipped step does not), while its own body sees
727+// its own, empty ones. Pinned for a main that succeeds and one that fails.
728+func TestActionContextInStepEnvAndBody(t *testing.T) {
729+ probe := " - run: echo \"ENV=[$R] BODY=[${{ github.action_repository }}@${{ github.action_ref }}]\"\n" +
730+ " if: always()\n" +
731+ " env:\n" +
732+ " R: ${{ github.action_repository }}@${{ github.action_ref }}\n"
733+ for _, tc := range []struct{ name, main, between, want string }{
734+ {"success", "exit 0\n", "", "ENV=[o/ar@v1] BODY=[@]"},
735+ {"failure", "exit 1\n", "", "ENV=[o/ar@v1] BODY=[@]"},
736+ {"a run step in between clears it", "exit 0\n", " - run: echo between\n if: always()\n", "ENV=[@] BODY=[@]"},
737+ } {
738+ t.Run(tc.name, func(t *testing.T) {
739+ forge := newForge(t, map[string][]byte{"/tarball/o/ar/v1": tarOf(t, "o-ar-x", files{"action.yml": nodeAction, "main.sh": tc.main})})
740+ steps := " - uses: o/ar@v1\n" +
741+ " - uses: ./skipped\n if: false\n" + tc.between + probe
742+ rec, res := actionRun(t, files{}, steps, func(c *Config) { c.Actions.Resolver = forge.resolver("") })
743+ if wantOK := tc.name != "failure"; (res.Conclusion == ConclusionSuccess) != wantOK {
744+ t.Fatalf("conclusion %+v", res)
745+ }
746+ got := logOf(rec.steps[len(rec.steps)-2]) // the probe, before "Complete job"
747+ if !strings.Contains(got, tc.want) {
748+ t.Errorf("want %q in\n%s\nsteps %v", tc.want, got, rec.names())
749+ }
750+ })
751+ }
752+}
753+
722 func TestDockerPrePostEntrypoints(t *testing.T) {754 func TestDockerPrePostEntrypoints(t *testing.T) {
723 forge := newForge(t, map[string][]byte{"/tarball/o/dk/v1": tarOf(t, "o-dk-x", files{755 forge := newForge(t, map[string][]byte{"/tarball/o/dk/v1": tarOf(t, "o-dk-x", files{
724 "action.yml": "name: dk\nruns:\n using: docker\n image: docker://alpine:3\n pre-entrypoint: /pre.sh\n entrypoint: /main.sh\n post-entrypoint: /post.sh\n"})})756 "action.yml": "name: dk\nruns:\n using: docker\n image: docker://alpine:3\n pre-entrypoint: /pre.sh\n entrypoint: /main.sh\n post-entrypoint: /post.sh\n"})})
M internal/executor/job.go +3 -0
@@ -78,6 +78,9 @@ type Config struct {
7878 // ACTIONS_RUNTIME_TOKEN of the node and docker actions of the job: the cache
7979 // service of the runner. The token is masked in the log.
8080 CacheURL, CacheToken string
81+ // CacheMode is ACTIONS_CACHE_MODE ("read" or "write") next to the cache URL;
82+ // empty sets nothing.
83+ CacheMode string
8184 // Secrets and Vars are Job.secrets and Job.vars. Secrets are registered in
8285 // the masker before the first step and form the secrets context; they are
8386 // never logged. Vars are the fallback of the vars context.
@@ -78,6 +78,9 @@ type Config struct {
78 // ACTIONS_RUNTIME_TOKEN of the node and docker actions of the job: the cache78 // ACTIONS_RUNTIME_TOKEN of the node and docker actions of the job: the cache
79 // service of the runner. The token is masked in the log.79 // service of the runner. The token is masked in the log.
80 CacheURL, CacheToken string80 CacheURL, CacheToken string
81+ // CacheMode is ACTIONS_CACHE_MODE ("read" or "write") next to the cache URL;
82+ // empty sets nothing.
83+ CacheMode string
81 // Secrets and Vars are Job.secrets and Job.vars. Secrets are registered in84 // Secrets and Vars are Job.secrets and Job.vars. Secrets are registered in
82 // the masker before the first step and form the secrets context; they are85 // the masker before the first step and form the secrets context; they are
83 // never logged. Vars are the fallback of the vars context.86 // never logged. Vars are the fallback of the vars context.
M internal/executor/secrets_test.go +3 -2
@@ -8,13 +8,14 @@ import (
88 "os"
99 "path/filepath"
1010
11- "bnema.dev/ermite-actions/expr"
1211 "encoding/base64"
1312 "encoding/json"
14- "github.com/bnema/zerowrap"
1513 "net/url"
1614 "strings"
1715 "testing"
16+
17+ "bnema.dev/ermite-actions/expr"
18+ "github.com/bnema/zerowrap"
1819 )
1920
2021 // jobSecretMasker is the masker of a job holding these secrets, built the way
@@ -8,13 +8,14 @@ import (
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10 10
11- "bnema.dev/ermite-actions/expr"
12 "encoding/base64"11 "encoding/base64"
13 "encoding/json"12 "encoding/json"
14- "github.com/bnema/zerowrap"
15 "net/url"13 "net/url"
16 "strings"14 "strings"
17 "testing"15 "testing"
16+
17+ "bnema.dev/ermite-actions/expr"
18+ "github.com/bnema/zerowrap"
18 )19 )
19 20
20 // jobSecretMasker is the masker of a job holding these secrets, built the way21 // jobSecretMasker is the masker of a job holding these secrets, built the way
M internal/guest/cacheurl_test.go +2 -2
@@ -58,9 +58,9 @@ func TestStartCacheRelay(t *testing.T) {
5858 t.Cleanup(func() { _ = srv.Close() })
5959
6060 port := freePort(t)
61- spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok"}
61+ spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok", CacheMode: "read"}
6262 got := startCacheRelay(context.Background(), Target{Network: "unix", Addr: execSock}, spec)
63- if got.relay == nil || got.token != "tok" || got.url != cacheURL(port) {
63+ if got.relay == nil || got.token != "tok" || got.mode != "read" || got.url != cacheURL(port) {
6464 t.Fatalf("relay %+v", got)
6565 }
6666
@@ -58,9 +58,9 @@ func TestStartCacheRelay(t *testing.T) {
58 t.Cleanup(func() { _ = srv.Close() })58 t.Cleanup(func() { _ = srv.Close() })
59 59
60 port := freePort(t)60 port := freePort(t)
61- spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok"}61+ spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok", CacheMode: "read"}
62 got := startCacheRelay(context.Background(), Target{Network: "unix", Addr: execSock}, spec)62 got := startCacheRelay(context.Background(), Target{Network: "unix", Addr: execSock}, spec)
63- if got.relay == nil || got.token != "tok" || got.url != cacheURL(port) {63+ if got.relay == nil || got.token != "tok" || got.mode != "read" || got.url != cacheURL(port) {
64 t.Fatalf("relay %+v", got)64 t.Fatalf("relay %+v", got)
65 }65 }
66 66
M internal/guest/jobexec.go +4 -4
@@ -259,7 +259,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
259259 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
260260 RunnerVersion: spec.GetRunnerVersion(),
261261 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
262- CacheURL: cacheEnv.url, CacheToken: cacheEnv.token,
262+ CacheURL: cacheEnv.url, CacheToken: cacheEnv.token, CacheMode: cacheEnv.mode,
263263 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
264264 Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),
265265 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
@@ -286,8 +286,8 @@ func cacheURL(port uint32) string {
286286 // loopback it listens on and the token of the job. All are empty without a cache
287287 // service.
288288 type cacheRelay struct {
289- relay *Relay
290- url, token string
289+ relay *Relay
290+ url, token, mode string
291291 }
292292
293293 func (c cacheRelay) close() {
@@ -313,7 +313,7 @@ func startCacheRelay(ctx context.Context, target Target, spec *execpb.JobSpec) c
313313 log.Warn().Err(err).Msg("job runs without the cache")
314314 return cacheRelay{}
315315 }
316- return cacheRelay{relay: r, url: url, token: spec.GetCacheToken()}
316+ return cacheRelay{relay: r, url: url, token: spec.GetCacheToken(), mode: spec.GetCacheMode()}
317317 }
318318
319319 // failSetup ends a job that cannot start, like a failed "Set up job": step 1
@@ -259,7 +259,7 @@ func runJob(ctx context.Context, conn *execstream.Conn, cfg ExecConfig, spec *ex
259 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},259 Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env},
260 RunnerVersion: spec.GetRunnerVersion(),260 RunnerVersion: spec.GetRunnerVersion(),
261 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,261 Home: su.Home, ToolCache: os.Getenv("RUNNER_TOOL_CACHE"), BaseEnv: stepEnv(os.Environ(), su), Credential: su.Credential,
262- CacheURL: cacheEnv.url, CacheToken: cacheEnv.token,262+ CacheURL: cacheEnv.url, CacheToken: cacheEnv.token, CacheMode: cacheEnv.mode,
263 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),263 Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx),
264 Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),264 Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec),
265 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),265 SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(),
@@ -286,8 +286,8 @@ func cacheURL(port uint32) string {
286 // loopback it listens on and the token of the job. All are empty without a cache286 // loopback it listens on and the token of the job. All are empty without a cache
287 // service.287 // service.
288 type cacheRelay struct {288 type cacheRelay struct {
289- relay *Relay289+ relay *Relay
290- url, token string290+ url, token, mode string
291 }291 }
292 292
293 func (c cacheRelay) close() {293 func (c cacheRelay) close() {
@@ -313,7 +313,7 @@ func startCacheRelay(ctx context.Context, target Target, spec *execpb.JobSpec) c
313 log.Warn().Err(err).Msg("job runs without the cache")313 log.Warn().Err(err).Msg("job runs without the cache")
314 return cacheRelay{}314 return cacheRelay{}
315 }315 }
316- return cacheRelay{relay: r, url: url, token: spec.GetCacheToken()}316+ return cacheRelay{relay: r, url: url, token: spec.GetCacheToken(), mode: spec.GetCacheMode()}
317 }317 }
318 318
319 // failSetup ends a job that cannot start, like a failed "Set up job": step 1319 // failSetup ends a job that cannot start, like a failed "Set up job": step 1
M internal/host/cache.go +9 -5
@@ -108,20 +108,24 @@ func githubRepository(contexts string) string {
108108
109109 // cacheFor returns the loopback port the cache has in the guest and the token
110110 // of a job, or 0 and "" when the runner serves no cache or the job has no scope.
111-func (j *jobRun) cacheFor(rt *Runner) (port uint32, token string) {
111+func (j *jobRun) cacheFor(rt *Runner) (port uint32, token, mode string) {
112112 if rt.Cache == nil {
113- return 0, ""
113+ return 0, "", ""
114114 }
115115 sc, ok, err := cacheScope(j.job, j.contexts)
116116 if err != nil {
117117 // A bad scope from the server is not the job's failure: it runs without a
118118 // cache.
119119 j.log.Warn().Err(err).Msg("cache disabled for the job")
120- return 0, ""
120+ return 0, "", ""
121121 }
122122 if !ok {
123- return 0, ""
123+ return 0, "", ""
124124 }
125125 ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin
126- return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl)
126+ mode = "read"
127+ if sc.WriteRef != "" {
128+ mode = "write"
129+ }
130+ return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl), mode
127131 }
@@ -108,20 +108,24 @@ func githubRepository(contexts string) string {
108 108
109 // cacheFor returns the loopback port the cache has in the guest and the token109 // cacheFor returns the loopback port the cache has in the guest and the token
110 // of a job, or 0 and "" when the runner serves no cache or the job has no scope.110 // of a job, or 0 and "" when the runner serves no cache or the job has no scope.
111-func (j *jobRun) cacheFor(rt *Runner) (port uint32, token string) {111+func (j *jobRun) cacheFor(rt *Runner) (port uint32, token, mode string) {
112 if rt.Cache == nil {112 if rt.Cache == nil {
113- return 0, ""113+ return 0, "", ""
114 }114 }
115 sc, ok, err := cacheScope(j.job, j.contexts)115 sc, ok, err := cacheScope(j.job, j.contexts)
116 if err != nil {116 if err != nil {
117 // A bad scope from the server is not the job's failure: it runs without a117 // A bad scope from the server is not the job's failure: it runs without a
118 // cache.118 // cache.
119 j.log.Warn().Err(err).Msg("cache disabled for the job")119 j.log.Warn().Err(err).Msg("cache disabled for the job")
120- return 0, ""120+ return 0, "", ""
121 }121 }
122 if !ok {122 if !ok {
123- return 0, ""123+ return 0, "", ""
124 }124 }
125 ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin125 ttl := time.Duration(j.timeoutMinutes())*time.Minute + cacheTokenMargin
126- return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl)126+ mode = "read"
127+ if sc.WriteRef != "" {
128+ mode = "write"
129+ }
130+ return cachesvc.DefaultPort, rt.Cache.Token(j.job.JobId, sc, ttl), mode
127 }131 }
M internal/host/cache_test.go +26 -3
@@ -134,7 +134,7 @@ func TestCacheForMismatchDisablesCache(t *testing.T) {
134134 job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}},
135135 contexts: scopeCtx, log: zerowrap.New(zerowrap.Config{Output: &logs, Format: "json", Level: "debug"}),
136136 }
137- if port, tok := j.cacheFor(&Runner{Cache: svc}); port != 0 || tok != "" {
137+ if port, tok, _ := j.cacheFor(&Runner{Cache: svc}); port != 0 || tok != "" {
138138 t.Fatalf("cache for a mismatched scope: %d %q", port, tok)
139139 }
140140 if !strings.Contains(logs.String(), "cache disabled for the job") || !strings.Contains(logs.String(), "o/other") {
@@ -144,7 +144,7 @@ func TestCacheForMismatchDisablesCache(t *testing.T) {
144144
145145 func TestCacheForNeedsServiceAndScope(t *testing.T) {
146146 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}
147- if port, tok := j.cacheFor(&Runner{}); port != 0 || tok != "" {
147+ if port, tok, _ := j.cacheFor(&Runner{}); port != 0 || tok != "" {
148148 t.Fatalf("no service: %d %q", port, tok)
149149 }
150150 }
@@ -161,11 +161,15 @@ func TestCacheForToken(t *testing.T) {
161161 defer srv.Close()
162162 rt := &Runner{Cache: svc}
163163 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}
164- port, tok := j.cacheFor(rt)
164+ port, tok, mode := j.cacheFor(rt)
165165 // The port is the cache's loopback port in the guest, not a host port.
166166 if port != 9089 || tok == "" {
167167 t.Fatalf("%d %q", port, tok)
168168 }
169+ // No Job.cache: the narrowest scope writes the job's own ref.
170+ if mode != "write" {
171+ t.Fatalf("mode %q", mode)
172+ }
169173 do := func(token string) int {
170174 req, _ := http.NewRequest("POST", srv.URL+cachesvc.APIPath+"caches", strings.NewReader(`{"key":"k","version":"v","cacheSize":1}`))
171175 if token != "" {
@@ -186,3 +190,22 @@ func TestCacheForToken(t *testing.T) {
186190 t.Fatalf("foreign token: %d", c)
187191 }
188192 }
193+
194+// ACTIONS_CACHE_MODE follows the write ref of the scope: none makes the job
195+// read-only.
196+func TestCacheForMode(t *testing.T) {
197+ svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
198+ if err != nil {
199+ t.Fatal(err)
200+ }
201+ t.Cleanup(func() { _ = svc.CloseAll() })
202+ for _, tc := range []struct{ write, want string }{{"refs/heads/main", "write"}, {"", "read"}} {
203+ j := &jobRun{
204+ job: &ermite.Job{JobId: "j1", TimeoutMinutes: 5, Cache: &ermite.JobCache{Repository: "o/r", WriteRef: tc.write, ReadRefs: []string{"refs/heads/main"}}},
205+ contexts: `{"github":{"repository":"o/r"}}`,
206+ }
207+ if _, _, mode := j.cacheFor(&Runner{Cache: svc}); mode != tc.want {
208+ t.Errorf("write ref %q: mode %q, want %q", tc.write, mode, tc.want)
209+ }
210+ }
211+}
@@ -134,7 +134,7 @@ func TestCacheForMismatchDisablesCache(t *testing.T) {
134 job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}},134 job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}},
135 contexts: scopeCtx, log: zerowrap.New(zerowrap.Config{Output: &logs, Format: "json", Level: "debug"}),135 contexts: scopeCtx, log: zerowrap.New(zerowrap.Config{Output: &logs, Format: "json", Level: "debug"}),
136 }136 }
137- if port, tok := j.cacheFor(&Runner{Cache: svc}); port != 0 || tok != "" {137+ if port, tok, _ := j.cacheFor(&Runner{Cache: svc}); port != 0 || tok != "" {
138 t.Fatalf("cache for a mismatched scope: %d %q", port, tok)138 t.Fatalf("cache for a mismatched scope: %d %q", port, tok)
139 }139 }
140 if !strings.Contains(logs.String(), "cache disabled for the job") || !strings.Contains(logs.String(), "o/other") {140 if !strings.Contains(logs.String(), "cache disabled for the job") || !strings.Contains(logs.String(), "o/other") {
@@ -144,7 +144,7 @@ func TestCacheForMismatchDisablesCache(t *testing.T) {
144 144
145 func TestCacheForNeedsServiceAndScope(t *testing.T) {145 func TestCacheForNeedsServiceAndScope(t *testing.T) {
146 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}146 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}
147- if port, tok := j.cacheFor(&Runner{}); port != 0 || tok != "" {147+ if port, tok, _ := j.cacheFor(&Runner{}); port != 0 || tok != "" {
148 t.Fatalf("no service: %d %q", port, tok)148 t.Fatalf("no service: %d %q", port, tok)
149 }149 }
150 }150 }
@@ -161,11 +161,15 @@ func TestCacheForToken(t *testing.T) {
161 defer srv.Close()161 defer srv.Close()
162 rt := &Runner{Cache: svc}162 rt := &Runner{Cache: svc}
163 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}163 j := &jobRun{job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", TimeoutMinutes: 5}, contexts: `{"github":{"repository":"o/r"}}`}
164- port, tok := j.cacheFor(rt)164+ port, tok, mode := j.cacheFor(rt)
165 // The port is the cache's loopback port in the guest, not a host port.165 // The port is the cache's loopback port in the guest, not a host port.
166 if port != 9089 || tok == "" {166 if port != 9089 || tok == "" {
167 t.Fatalf("%d %q", port, tok)167 t.Fatalf("%d %q", port, tok)
168 }168 }
169+ // No Job.cache: the narrowest scope writes the job's own ref.
170+ if mode != "write" {
171+ t.Fatalf("mode %q", mode)
172+ }
169 do := func(token string) int {173 do := func(token string) int {
170 req, _ := http.NewRequest("POST", srv.URL+cachesvc.APIPath+"caches", strings.NewReader(`{"key":"k","version":"v","cacheSize":1}`))174 req, _ := http.NewRequest("POST", srv.URL+cachesvc.APIPath+"caches", strings.NewReader(`{"key":"k","version":"v","cacheSize":1}`))
171 if token != "" {175 if token != "" {
@@ -186,3 +190,22 @@ func TestCacheForToken(t *testing.T) {
186 t.Fatalf("foreign token: %d", c)190 t.Fatalf("foreign token: %d", c)
187 }191 }
188 }192 }
193+
194+// ACTIONS_CACHE_MODE follows the write ref of the scope: none makes the job
195+// read-only.
196+func TestCacheForMode(t *testing.T) {
197+ svc, err := cachesvc.New(cachesvc.Config{Dir: filepath.Join(t.TempDir(), "cache")})
198+ if err != nil {
199+ t.Fatal(err)
200+ }
201+ t.Cleanup(func() { _ = svc.CloseAll() })
202+ for _, tc := range []struct{ write, want string }{{"refs/heads/main", "write"}, {"", "read"}} {
203+ j := &jobRun{
204+ job: &ermite.Job{JobId: "j1", TimeoutMinutes: 5, Cache: &ermite.JobCache{Repository: "o/r", WriteRef: tc.write, ReadRefs: []string{"refs/heads/main"}}},
205+ contexts: `{"github":{"repository":"o/r"}}`,
206+ }
207+ if _, _, mode := j.cacheFor(&Runner{Cache: svc}); mode != tc.want {
208+ t.Errorf("write ref %q: mode %q, want %q", tc.write, mode, tc.want)
209+ }
210+ }
211+}
M internal/host/job.go +1 -1
@@ -700,7 +700,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
700700 if p := j.job.Permissions; p != nil && j.features.secrets {
701701 run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}
702702 }
703- run.Job.CachePort, run.Job.CacheToken = j.cacheFor(rt)
703+ run.Job.CachePort, run.Job.CacheToken, run.Job.CacheMode = j.cacheFor(rt)
704704 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
705705 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
706706 Log: j.logLine,
@@ -700,7 +700,7 @@ func (j *jobRun) drive(conn sandbox.Conn, rt *Runner) (*execpb.Exit, error) {
700 if p := j.job.Permissions; p != nil && j.features.secrets {700 if p := j.job.Permissions; p != nil && j.features.secrets {
701 run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}701 run.Job.TokenPermissions = &execpb.TokenPermissions{Levels: p.GetLevels()}
702 }702 }
703- run.Job.CachePort, run.Job.CacheToken = j.cacheFor(rt)703+ run.Job.CachePort, run.Job.CacheToken, run.Job.CacheMode = j.cacheFor(rt)
704 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{704 _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{
705 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,705 StepStarted: j.stepStarted, StepCompleted: j.stepCompleted,
706 Log: j.logLine,706 Log: j.logLine,

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