From 2b190bf8391d16b5754025f1bb107e1c0fc0edf6 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Tue, 1 Sep 2026 09:02:35 +0900 Subject: [PATCH] ci: give each job its own pnpm install dir, keep the store shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #130, which did not fix the race. Part of #125. #130 added `standalone: true` on the theory that the node_modules layout was what raced. It is not. pnpm/action-setup clears its install directory before writing to it regardless of install mode, so with the default ~/setup-pnpm that `rmdir` is shared by every job on a self-hosted box. #130's own run passed only because nothing else was running beside it; the moment two jobs started together again, both #127 and #129 reproduced the identical error on branches that already contained the fix: ENOTEMPTY: directory not empty, rmdir '~/setup-pnpm/node_modules/.bin/store/v11/files/03' `dest: ${{ runner.temp }}/setup-pnpm` is the actual fix: runner.temp is per-job and cleaned up by the runner, so no two jobs share a directory to race on. That alone would regress something worth keeping. The pnpm store lived under the install dir, so making the install dir disposable would make the store disposable too, and every job would re-download ~1100 packages — contradicting the reason this workflow deliberately skips `cache: pnpm`. So the store is pinned to a stable path with `--store-dir ~/.pnpm-store`. That separation is also what makes the original failure impossible to repeat: a shared content-addressed store is pnpm's normal mode and is safe for concurrent readers. It was only fragile here because it happened to sit inside the one directory the action deletes. --- .github/workflows/ci.yml | 64 ++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fc95aa..a0e5fab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,19 +22,26 @@ jobs: - uses: pnpm/action-setup@v6 with: version: 11.5.2 - # Self-hosted runners share one $HOME, so the default install path - # (~/setup-pnpm/node_modules) is shared by every job on the box. Two - # jobs from different refs starting together race on it and leave it - # half-removed, after which every later job fails at setup — first as - # `ENOTEMPTY ... rmdir`, then permanently as `self-installer exits - # with code 254`, before a single test runs. + # Self-hosted runners share one $HOME, and the action clears its + # install dir before writing to it. With the default ~/setup-pnpm that + # `rmdir` is shared by every job on the box, so two jobs from + # different refs starting together race and leave it half-removed — + # `ENOTEMPTY ... rmdir .../store/v11/files/03` — after which later + # jobs fail at setup or, once store files are gone, at `pnpm install` + # with ERR_PNPM_ENOENT. All of it before a single test runs. # - # `standalone` fetches a self-contained pnpm binary instead, skipping - # the node_modules layout that races. See #125. + # The workflow's concurrency group is keyed on github.ref, so it + # serialises one branch and does nothing across branches — exactly the + # case that collides. + # + # runner.temp is per-job and cleaned up by the runner, so there is no + # shared directory left to race on. `standalone` additionally avoids + # building the node_modules layout. See #125. + dest: ${{ runner.temp }}/setup-pnpm standalone: true # No `cache: pnpm` on purpose. The runner is self-hosted, so the pnpm - # store already persists in $HOME between jobs — actions/cache would + # store already persists in $HOME between jobs (see --store-dir below) — actions/cache would # round-trip a tarball over the network for zero benefit. Measured cost # when it was enabled: up to 79s in the post-job cache save, longer than # the test step it was meant to speed up. @@ -42,7 +49,13 @@ jobs: with: node-version: 24 - - run: pnpm install --frozen-lockfile + # --store-dir keeps the package cache on a stable path. The install dir + # above is per-job and disposable; the store must not be, or every job + # re-downloads ~1100 packages. A shared content-addressed store is + # pnpm's normal mode and is safe for concurrent readers — it was only + # fragile here because it happened to sit inside the directory the + # action clears. + - run: pnpm install --frozen-lockfile --store-dir ~/.pnpm-store - run: pnpm typecheck @@ -79,15 +92,22 @@ jobs: - uses: pnpm/action-setup@v6 with: version: 11.5.2 - # Self-hosted runners share one $HOME, so the default install path - # (~/setup-pnpm/node_modules) is shared by every job on the box. Two - # jobs from different refs starting together race on it and leave it - # half-removed, after which every later job fails at setup — first as - # `ENOTEMPTY ... rmdir`, then permanently as `self-installer exits - # with code 254`, before a single test runs. + # Self-hosted runners share one $HOME, and the action clears its + # install dir before writing to it. With the default ~/setup-pnpm that + # `rmdir` is shared by every job on the box, so two jobs from + # different refs starting together race and leave it half-removed — + # `ENOTEMPTY ... rmdir .../store/v11/files/03` — after which later + # jobs fail at setup or, once store files are gone, at `pnpm install` + # with ERR_PNPM_ENOENT. All of it before a single test runs. + # + # The workflow's concurrency group is keyed on github.ref, so it + # serialises one branch and does nothing across branches — exactly the + # case that collides. # - # `standalone` fetches a self-contained pnpm binary instead, skipping - # the node_modules layout that races. See #125. + # runner.temp is per-job and cleaned up by the runner, so there is no + # shared directory left to race on. `standalone` additionally avoids + # building the node_modules layout. See #125. + dest: ${{ runner.temp }}/setup-pnpm standalone: true # No `cache: pnpm` — see the test job for why. @@ -95,7 +115,13 @@ jobs: with: node-version: 24 - - run: pnpm install --frozen-lockfile + # --store-dir keeps the package cache on a stable path. The install dir + # above is per-job and disposable; the store must not be, or every job + # re-downloads ~1100 packages. A shared content-addressed store is + # pnpm's normal mode and is safe for concurrent readers — it was only + # fragile here because it happened to sit inside the directory the + # action clears. + - run: pnpm install --frozen-lockfile --store-dir ~/.pnpm-store - name: Mutation test changed files env: