From e238037f426e12d9b5754a5f485807903d4e37d5 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 2 Sep 2026 17:52:43 +1000 Subject: [PATCH 1/3] ci(release): drop token-era registry-url, persist-credentials parity, shrink OIDC exposure (LAB-1387) release-please.yml is the only npm publisher for @cachekit-io/cachekit and @cachekit-io/cachekit-core-wasm; auth is pnpm 11's native OIDC exchange. - Remove `registry-url` from both setup-node steps. It wrote a .npmrc with an `_authToken=${NODE_AUTH_TOKEN}` placeholder that has had no env to resolve since #96 (pnpm envReplaceLossy -> empty + `[WARN] Failed to replace env in config`). pnpm resolves the publish registry from its own default (registry.npmjs.org) and a successful OIDC exchange overrides any static token, so the file contributed nothing but the warning. - `persist-credentials: false` on publish-cachekit's checkout, matching the wasm job: publish-only jobs don't leave the GitHub token on disk. - `pnpm install --frozen-lockfile --ignore-scripts` in both id-token:write jobs. --ignore-scripts closes the install-time lifecycle-script vector, including pnpm-workspace.yaml's allowBuilds allowlist (cpu-features, ssh2, protobufjs, esbuild, workerd) - none are needed: esbuild loads its binary from @esbuild/linux-x64, the rest only serve the integration/workers lanes, and build.sh is pure cargo/wasm-bindgen/wasm-opt. - Drop the Test step from publish-cachekit. ci.yml runs build+test on the same commit (required "CI Success" check, squash-only main), so the step only executed the whole devDependency tree beside the OIDC token. Build still runs dependency code in the token-holding job; that residual is LAB-2731 (build/publish job separation). Verified locally with the same flags: install, `pnpm build --force` (napi native build), `pnpm test` -> 766 passed. actionlint clean. --- .github/workflows/release-please.yml | 29 +++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f78aad1..50b4e1c 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -78,25 +78,37 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.release-please.outputs.cachekit--tag_name || github.sha }} + # Publish-only job (no git writes); don't leave the token on disk. + persist-credentials: false + # No `registry-url`: it writes a token-era .npmrc whose + # `_authToken=${NODE_AUTH_TOKEN}` placeholder has no env to resolve + # (pnpm: `[WARN] Failed to replace env in config`). pnpm publishes to its + # default registry (registry.npmjs.org) and OIDC supplies the auth. - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: '22' - registry-url: 'https://registry.npmjs.org' - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v2 + # This job can mint the npm OIDC publish token. --ignore-scripts closes + # the wormable install-time vector (Shai-Hulud-class lifecycle scripts, + # including pnpm-workspace.yaml's allowBuilds allowlist — nothing here + # needs them); --frozen-lockfile pins the graph to what CI verified. + # The build below still executes dependency code beside the token, so + # exposure is narrowed, not closed — the structural fix is build/publish + # job separation (LAB-2731). - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile --ignore-scripts + # No test step: ci.yml already ran build+test on this exact commit + # (required "CI Success" check on main), and every extra step here runs + # dependency code beside the OIDC token. - name: Build run: pnpm build - - name: Test - run: pnpm test - # Auth is npm OIDC trusted publishing via a package-level trusted # publisher on npmjs.com — the repo has NO npm token secret (LAB-831). - name: Publish to npm @@ -125,11 +137,11 @@ jobs: # Publish-only job (no git writes); don't leave the token on disk. persist-credentials: false + # No `registry-url` — same reason as publish-cachekit. - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: '22' - registry-url: 'https://registry.npmjs.org' - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v2 @@ -158,8 +170,11 @@ jobs: cp "/tmp/binaryen-version_${BINARYEN_VERSION}/bin/wasm-opt" "$HOME/.local/bin/" echo "$HOME/.local/bin" >> "$GITHUB_PATH" + # --ignore-scripts: same OIDC-exposure rule as publish-cachekit; build.sh + # is pure cargo/wasm-bindgen/wasm-opt and needs no dependency scripts. + # cargo build.rs/proc-macros still run beside the token — LAB-2731. - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Build wasm artifact run: pnpm --filter @cachekit-io/cachekit-core-wasm build:wasm From c06b6370d5dbcff67416997328f71921669e8a17 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 2 Sep 2026 18:07:50 +1000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20gate=20forced=20publish=20on=20CI=20Success?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the inline `pnpm test` step narrowed the safety envelope on a path its justifying comment did not cover. `publish-cachekit` admits `force_release == 'true'`, and with no release created the checkout ref falls back to `github.sha` — the tip of whatever branch or tag was selected at dispatch. The comment claimed "ci.yml already ran build+test on this exact commit (required CI Success check on main)", which is true of the release-please tag path and false of a dispatch against any other ref. Before this PR the inline test step was that path's only gate; after it, a forced dispatch published untested code. Verify the "CI Success" check-run for the commit instead of re-running the suite: it enforces the invariant the comment already asserted, and being an API query it runs no dependency code beside the OIDC token, which is the reason the test step was dropped. Placed first so it fails before install/build can execute anything. Gated on an empty `cachekit--tag_name` rather than on `force_release`, because those differ when a dispatch coincides with a real release — the tag path resolves to a main commit and is safe, and keying off the empty tag targets exactly the `|| github.sha` fallback. `publish-cachekit-core-wasm` is deliberately untouched: its forced path takes an explicitly named existing tag, not `github.sha`. CodeRabbit-Resolved: release-please.yml:108:Retain a validation gate --- .github/workflows/release-please.yml | 32 +++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 50b4e1c..c62054d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -72,9 +72,34 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + checks: read id-token: write steps: + # The tag path needs no check: release-please only tags commits on main, + # where "CI Success" is a required check. A force_release dispatch + # produces no tag, so the checkout below falls back to github.sha — the + # tip of whatever ref was selected at dispatch, which may never have + # passed CI. Verify that commit before any step can mint the OIDC token. + # Keying off the empty tag (not the force_release input) targets exactly + # the case where the fallback is taken. This is an API query, so unlike + # the test step it replaces, it runs no dependency code beside the token. + - name: Verify CI passed for forced publish + if: needs.release-please.outputs.cachekit--tag_name == '' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + conclusion=$(gh api "repos/$REPO/commits/$SHA/check-runs" \ + --jq '[.check_runs[] | select(.name == "CI Success")] + | sort_by(.started_at) | last | .conclusion // "missing"') + if [ "$conclusion" != "success" ]; then + echo "::error::Refusing to publish $SHA — 'CI Success' is '$conclusion', not 'success'." + exit 1 + fi + echo "CI Success verified for $SHA" + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ needs.release-please.outputs.cachekit--tag_name || github.sha }} @@ -103,9 +128,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts - # No test step: ci.yml already ran build+test on this exact commit - # (required "CI Success" check on main), and every extra step here runs - # dependency code beside the OIDC token. + # No test step: ci.yml already ran build+test on this exact commit, and + # every extra step here runs dependency code beside the OIDC token. That + # holds on the tag path via the required "CI Success" check on main, and + # on the forced path via the verification step above. - name: Build run: pnpm build From fd03e101caae68eeea735074823d79cfa7d97cce Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 2 Sep 2026 20:23:15 +1000 Subject: [PATCH 3/3] fix: paginate the forced-publish CI check lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kody flagged that the check-runs query reads only the default first page of 30, so on a commit with more check-runs than that the "CI Success" run can fall outside the page, $conclusion becomes "missing", and the step refuses a legitimate forced publish. Real on main, where 5 workflows plus the ci.yml node matrix plus re-runs clear 30 easily. Fixed differently from the suggestion, because the suggested form has its own bug: `gh api --paginate --jq` applies the filter once per page and concatenates, so a multi-page result yields a multi-line $conclusion that can never equal "success" — the same false refusal, just later. --slurp merges the pages into one array first, and since gh rejects --slurp alongside --jq the filter runs in a piped jq (preinstalled on ubuntu-latest). Also filters by check_name server-side so pagination rarely engages at all. Verified against a real commit: returns exactly one line, "success". Kody-Resolved: .github/workflows/release-please.yml:96:check-runs call has no --paginate --- .github/workflows/release-please.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index c62054d..819771f 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -91,9 +91,18 @@ jobs: REPO: ${{ github.repository }} SHA: ${{ github.sha }} run: | - conclusion=$(gh api "repos/$REPO/commits/$SHA/check-runs" \ - --jq '[.check_runs[] | select(.name == "CI Success")] - | sort_by(.started_at) | last | .conclusion // "missing"') + # check_name filters server-side so the default 30-item page cannot + # hide the run we need — a commit on main accumulates check-runs from + # every workflow plus the ci.yml node matrix and any re-runs. + # --paginate --slurp keeps it correct even past 100 re-runs of that + # one check. Both halves are load-bearing: --paginate alone applies + # gh's own --jq once per page and concatenates the results, which + # would make $conclusion multi-line and never equal "success"; and + # --slurp is rejected by gh when combined with --jq, so the filter + # has to run in a piped jq (preinstalled on ubuntu-latest). + conclusion=$(gh api --paginate --slurp \ + "repos/$REPO/commits/$SHA/check-runs?check_name=CI+Success&per_page=100" \ + | jq -r '[.[].check_runs[]] | sort_by(.started_at) | last | .conclusion // "missing"') if [ "$conclusion" != "success" ]; then echo "::error::Refusing to publish $SHA — 'CI Success' is '$conclusion', not 'success'." exit 1