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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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 \ | |||
| 40 | 40 | docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \ | |
| 41 | 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 | 68 | # Like GitHub-hosted runners: user "runner" (uid 1001), passwordless sudo, | |
| 44 | 69 | # member of "docker", with the job layout directories. | |
| 45 | 70 | 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 ( | |||
| 6 | 6 | "bufio" | |
| 7 | 7 | "bytes" | |
| 8 | 8 | "fmt" | |
| 9 | - | "github.com/bnema/zerowrap" | |
| 10 | 9 | "io" | |
| 11 | 10 | "net" | |
| 12 | 11 | "net/http" | |
| @@ -17,6 +16,8 @@ import ( | |||
| 17 | 16 | "sync" | |
| 18 | 17 | "testing" | |
| 19 | 18 | "time" | |
| 19 | + | ||
| 20 | + | "github.com/bnema/zerowrap" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| 22 | 23 | // 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 { | |||
| 115 | 115 | // server sent none (an older server): the executor then reads them from the | |
| 116 | 116 | // workflow. | |
| 117 | 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 | 124 | // 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 the | 115 | // 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", ...) to | 124 | // 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: | |||
| 21 | 21 | 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 | 26 | // NodeScriptActionHandler.cs:55-72: a node action gets the runtime variables. The | |
| 27 | 27 | // URL is the base the toolkit appends "_apis/artifactcache/…" to (it ends with a | |
| @@ -34,7 +34,7 @@ func TestNodeActionGetsCacheEnv(t *testing.T) { | |||
| 34 | 34 | t.Fatalf("%+v\n%s", res, logOf(rec.steps[1])) | |
| 35 | 35 | } | |
| 36 | 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 | 38 | if !strings.Contains(got, want) { | |
| 39 | 39 | t.Errorf("want %q in\n%s", want, got) | |
| 40 | 40 | } | |
| @@ -58,7 +58,7 @@ func TestCacheEnvOnlyForActions(t *testing.T) { | |||
| 58 | 58 | if res.Conclusion != ConclusionSuccess { | |
| 59 | 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 | 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 | 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 | + | } | |
| @@ -21,7 +21,7 @@ runs: | |||||
| 21 | main: main.sh | 21 | 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. The | 26 | // 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 a | 27 | // 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) { | |||
| 168 | 168 | env.Set("ACTIONS_RUNTIME_URL", j.cfg.CacheURL) | |
| 169 | 169 | env.Set("ACTIONS_RUNTIME_TOKEN", j.cfg.CacheToken) | |
| 170 | 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 | 178 | // 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) { | |||
| 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 | 754 | func TestDockerPrePostEntrypoints(t *testing.T) { | |
| 723 | 755 | forge := newForge(t, map[string][]byte{"/tarball/o/dk/v1": tarOf(t, "o-dk-x", files{ | |
| 724 | 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"})}) | |
| @@ -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 { | |||
| 78 | 78 | // ACTIONS_RUNTIME_TOKEN of the node and docker actions of the job: the cache | |
| 79 | 79 | // service of the runner. The token is masked in the log. | |
| 80 | 80 | 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 | 84 | // Secrets and Vars are Job.secrets and Job.vars. Secrets are registered in | |
| 82 | 85 | // the masker before the first step and form the secrets context; they are | |
| 83 | 86 | // 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 cache | 78 | // 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 string | 80 | 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 in | 84 | // 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 are | 85 | // 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 ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | ||
| 11 | - | "bnema.dev/ermite-actions/expr" | |
| 12 | 11 | "encoding/base64" | |
| 13 | 12 | "encoding/json" | |
| 14 | - | "github.com/bnema/zerowrap" | |
| 15 | 13 | "net/url" | |
| 16 | 14 | "strings" | |
| 17 | 15 | "testing" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite-actions/expr" | |
| 18 | + | "github.com/bnema/zerowrap" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | // 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 way | 21 | // 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) { | |||
| 58 | 58 | t.Cleanup(func() { _ = srv.Close() }) | |
| 59 | 59 | ||
| 60 | 60 | port := freePort(t) | |
| 61 | - | spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok"} | |
| 61 | + | spec := &execpb.JobSpec{CachePort: port, CacheToken: "tok", CacheMode: "read"} | |
| 62 | 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 | 64 | t.Fatalf("relay %+v", got) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| @@ -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 | |||
| 259 | 259 | Runner: executor.RunnerInfo{Name: spec.GetRunnerName(), Environment: env}, | |
| 260 | 260 | RunnerVersion: spec.GetRunnerVersion(), | |
| 261 | 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 | 263 | Debug: spec.GetDebug(), Reporter: rep, Actions: executor.DefaultActionsConfig(res.resolve), Log: zerowrap.FromCtx(ctx), | |
| 264 | 264 | Secrets: vals.secrets, Vars: vals.vars, SecretsWithheld: spec.GetSecretsWithheld(), TokenPermissions: tokenPermissions(spec), | |
| 265 | 265 | SendAnnotations: spec.GetSendAnnotations(), SendSummaries: spec.GetSendSummaries(), | |
| @@ -286,8 +286,8 @@ func cacheURL(port uint32) string { | |||
| 286 | 286 | // loopback it listens on and the token of the job. All are empty without a cache | |
| 287 | 287 | // service. | |
| 288 | 288 | type cacheRelay struct { | |
| 289 | - | relay *Relay | |
| 290 | - | url, token string | |
| 289 | + | relay *Relay | |
| 290 | + | url, token, mode string | |
| 291 | 291 | } | |
| 292 | 292 | ||
| 293 | 293 | func (c cacheRelay) close() { | |
| @@ -313,7 +313,7 @@ func startCacheRelay(ctx context.Context, target Target, spec *execpb.JobSpec) c | |||
| 313 | 313 | log.Warn().Err(err).Msg("job runs without the cache") | |
| 314 | 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 | 319 | // 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 cache | 286 | // 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 *Relay | 289 | + | relay *Relay |
| 290 | - | url, token string | 290 | + | 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 1 | 319 | // 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 { | |||
| 108 | 108 | ||
| 109 | 109 | // cacheFor returns the loopback port the cache has in the guest and the token | |
| 110 | 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 | 112 | if rt.Cache == nil { | |
| 113 | - | return 0, "" | |
| 113 | + | return 0, "", "" | |
| 114 | 114 | } | |
| 115 | 115 | sc, ok, err := cacheScope(j.job, j.contexts) | |
| 116 | 116 | if err != nil { | |
| 117 | 117 | // A bad scope from the server is not the job's failure: it runs without a | |
| 118 | 118 | // cache. | |
| 119 | 119 | j.log.Warn().Err(err).Msg("cache disabled for the job") | |
| 120 | - | return 0, "" | |
| 120 | + | return 0, "", "" | |
| 121 | 121 | } | |
| 122 | 122 | if !ok { | |
| 123 | - | return 0, "" | |
| 123 | + | return 0, "", "" | |
| 124 | 124 | } | |
| 125 | 125 | 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 | } | |
| @@ -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 token | 109 | // 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 a | 117 | // 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 + cacheTokenMargin | 125 | 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) { | |||
| 134 | 134 | job: &ermite.Job{JobId: "j1", Ref: "refs/heads/main", Cache: &ermite.JobCache{Repository: "o/other", WriteRef: "refs/heads/main"}}, | |
| 135 | 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 | 138 | t.Fatalf("cache for a mismatched scope: %d %q", port, tok) | |
| 139 | 139 | } | |
| 140 | 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 | 145 | func TestCacheForNeedsServiceAndScope(t *testing.T) { | |
| 146 | 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 | 148 | t.Fatalf("no service: %d %q", port, tok) | |
| 149 | 149 | } | |
| 150 | 150 | } | |
| @@ -161,11 +161,15 @@ func TestCacheForToken(t *testing.T) { | |||
| 161 | 161 | defer srv.Close() | |
| 162 | 162 | rt := &Runner{Cache: svc} | |
| 163 | 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 | 165 | // The port is the cache's loopback port in the guest, not a host port. | |
| 166 | 166 | if port != 9089 || tok == "" { | |
| 167 | 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 | 173 | do := func(token string) int { | |
| 170 | 174 | req, _ := http.NewRequest("POST", srv.URL+cachesvc.APIPath+"caches", strings.NewReader(`{"key":"k","version":"v","cacheSize":1}`)) | |
| 171 | 175 | if token != "" { | |
| @@ -186,3 +190,22 @@ func TestCacheForToken(t *testing.T) { | |||
| 186 | 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 | + | } | |
| @@ -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) { | |||
| 700 | 700 | if p := j.job.Permissions; p != nil && j.features.secrets { | |
| 701 | 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 | 704 | _, exit, err := execstream.DriveJob(conn, run, j.cancelCh, execstream.JobHandler{ | |
| 705 | 705 | StepStarted: j.stepStarted, StepCompleted: j.stepCompleted, | |
| 706 | 706 | 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, | ||