From e58f2c15d127cf6c521535a1567a41e804a233eb Mon Sep 17 00:00:00 2001 From: T Date: Thu, 11 Jun 2026 10:51:49 -0500 Subject: [PATCH] fix(release): make npm publish idempotent on stuck-release re-runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a release gets stuck and is re-driven (e.g. the automated release-please → workflow_call → release.yml chain failed mid-publish and is re-run, or a tag-scheme migration leaves a version tagged-but-unpublished), `pnpm publish` hard-fails because the version already exists on npm. Add a preflight `npm view @` guard that skips the publish step when the registry already serves the exact version, emitting a ::notice:: instead of erroring. Makes the publish job safe to retry. Also documents the no-recovery-path trap in the trusted-publisher lesson: npm allows only ONE trusted publisher per package, so with only release-please.yml registered there is no working MANUAL publish recovery (both `gh release create` and `workflow_dispatch release.yml` carry release.yml as the OIDC entry → token-exchange 404). Recovery must go through the automated chain. --- ...isher-matches-entry-workflow-not-reusable.md | 1 + .github/workflows/release.yml | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.erpaval/solutions/conventions/npm-trusted-publisher-matches-entry-workflow-not-reusable.md b/.erpaval/solutions/conventions/npm-trusted-publisher-matches-entry-workflow-not-reusable.md index a44edf82..ffd7d71e 100644 --- a/.erpaval/solutions/conventions/npm-trusted-publisher-matches-entry-workflow-not-reusable.md +++ b/.erpaval/solutions/conventions/npm-trusted-publisher-matches-entry-workflow-not-reusable.md @@ -61,6 +61,7 @@ Also required (npm enforces it): `id-token: write` on BOTH the parent job (the ` - Trusted-publisher config is **web-UI only**, no API/CLI, and **passkey/2FA-gated per save**. With N packages it's N manual saves. This monorepo has **17 publishable packages** (`packages/*` minus `docs`, which is `private: true`), so it's 17 saves. Do them back-to-back to ride the authenticator's warm-credential window. - **Each package has exactly one trusted publisher** — no org-level or account-level setting applies to all at once. Changing the filename means re-saving all 17. - Tradeoff: registering `release-please.yml` means a manual `workflow_dispatch` of `release.yml` (entry = release.yml) will STOP matching. The automated flow is the one that matters, so that's the right trade; treat manual dispatch as admin-only. +- **Corollary discovered 2026-06-11 (the no-recovery-path trap):** registering ONLY `release-please.yml` means there is **no working manual recovery** when the automated chain fails to publish. If release-please's `release_created` comes back false (e.g. it aborts with "untagged, merged release PRs outstanding" after a tag-scheme change), you cannot rescue the release by `gh release create` (entry = release.yml → `release: published` → OIDC 404) NOR by `workflow_dispatch` of release.yml (entry = release.yml → 404). Both manual paths carry `release.yml` as the entry and fail the trusted-publisher match. The fix: **register BOTH `release-please.yml` AND `release.yml`** as trusted publishers for the package. After collapse this is only ONE package (`@opencodehub/cli`), so it's 2 saves total — cheap insurance that makes `workflow_dispatch`-based recovery work. Symptom that you're in this trap: tag + GitHub Release exist for the version, but `npm view version` is behind and every manual republish 404s on the OIDC token exchange. ## Why not just add a PAT instead? diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2474f21e..e3554db6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -383,5 +383,22 @@ jobs: - uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 - run: pnpm install --frozen-lockfile - run: pnpm --filter '!@opencodehub/docs' -r build + # Idempotency guard: a stuck/retried release (e.g. the automated chain + # failed mid-publish and is re-run) must not hard-fail because the version + # already exists on npm. `pnpm publish` errors on a duplicate version, so + # short-circuit when the registry already serves this exact version. + - name: Skip if version already published + id: published + run: | + set -euo pipefail + NAME=$(node -p "require('./packages/cli/package.json').name") + VER=$(node -p "require('./packages/cli/package.json').version") + if npm view "${NAME}@${VER}" version >/dev/null 2>&1; then + echo "already=true" >> "$GITHUB_OUTPUT" + echo "::notice::${NAME}@${VER} already on npm — skipping publish (idempotent re-run)." + else + echo "already=false" >> "$GITHUB_OUTPUT" + fi - name: Publish to npm + if: steps.published.outputs.already == 'false' run: pnpm --filter '!@opencodehub/docs' -r publish --provenance --access public --no-git-checks