From 709f9df19643978384c8dd507af755b2e1d44546 Mon Sep 17 00:00:00 2001 From: "Nabheet S. Sandhu" Date: Fri, 30 Oct 2026 21:39:26 -0600 Subject: [PATCH] ci: derive the release version from tag + registry, bump on conflict Same defect as nabheet/pcbway-mcp-server#71. The `latest` version came from `npm view @latest`, and that packument read is CDN-cached for a minute or two. A second shipping merge inside the window computed an already-published version and the publish died with E409. - Baseline is now max(newest stable `vX.Y.Z` tag, registry `latest`). The tag is pushed right after a successful publish and does not lag; the registry stays because it also records a publish whose tag push failed. An ahead `package.json` is still honoured. - The publish job checkout gets `fetch-depth: 0`, otherwise the tag list is whatever the shallow clone happened to include. - A conflict on `latest` bumps a patch and republishes (bounded at two bumps) instead of retrying a version that can never succeed. Beta channels still fail fast. - Conflict detection is case-insensitive and matches npm's actual lines ("Cannot publish over ...", "previously staged", "npm error code E409"). It was case-sensitive, so the old loop retried a pinned version three times. - Tag candidates are filtered to plain `vX.Y.Z`: `sort -V` is not semver-aware and ranks `1.0.0-rc.1` above `1.0.0`. --- .github/workflows/ci.yml | 106 +++++++++++++++++++++++++++++++-------- README.md | 8 +-- 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afd784b..8c55bfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,16 +4,20 @@ name: CI # configured on npm (ci.yml). CI on PRs, publish on push to main (stable), # v*-beta* tag push (beta prerelease), or manual dispatch (staging beta). # -# Stable version comes from the npm registry (last published `latest` + -# patch), not from a possibly-stale package.json, so repeated merges never -# collide. An intentional minor/major bump in package.json is honored: if the -# working tree version is ahead of the registry, it is published as-is. +# Stable version = max(newest stable `vX.Y.Z` tag, registry `latest`) + patch, +# not a possibly-stale package.json, so repeated merges never collide. Two +# sources because each covers the other's failure: the registry's CDN read lags +# a fresh publish by a minute or two, and a publish can succeed while its tag +# push fails. An intentional minor/major bump in package.json is honored: if +# the working tree version is ahead of both, it is published as-is. # # npm trusted publishing (OIDC) auto-generates provenance — no token needed. # -# Publish failures are retried 3x for transient issues only — a version -# conflict (already published) is NEVER retried. Publish serialized via -# concurrency; CI runs on the same ref are cancelled when superseded. +# Publish failures are retried up to 5x. A version conflict is not retried +# as-is: on `latest` CI bumps a patch and republishes (bounded at two bumps); +# on the beta channels it fails fast, because those versions are already +# unique. Publish is serialized via concurrency; CI runs on the same ref are +# cancelled when superseded. on: push: @@ -53,6 +57,11 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v7 + with: + # The version baseline reads the newest stable `v*` tag, so the + # publish job needs every tag, not just the ones on this shallow + # commit. + fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: 24 @@ -83,13 +92,45 @@ jobs: fi echo "tag=beta" >> "$GITHUB_OUTPUT" else - # merge to main -> next patch after the last published `latest` - LAST=$(npm view opencode-rtk-plugin@latest version 2>/dev/null || true) + # merge to main -> next patch after the last published release. + # + # The baseline is the HIGHEST of two sources: + # - the newest stable `vX.Y.Z` tag, which the publish job pushes + # immediately after a successful publish; and + # - the registry's `latest`. + # + # Registry reads go through a CDN with a couple of minutes of + # cache, so a second shipping merge inside that window still sees + # the PREVIOUS version and computes one that is already published + # (that is the E409 race). The tag is refreshed with the push and + # does not lag, so it wins there. The registry is still consulted + # because it also covers the reverse failure: a publish that + # succeeded while its tag push did not. + # + # `git tag --list` (not `git describe`) is deliberate: describe + # reports the newest tag REACHABLE from HEAD, so a tag created on + # a branch that was later rewound would be missed. Both the tag + # list and the registry are read as an unordered set of candidate + # baselines; only the numeric maximum matters. + # + # Only plain `vX.Y.Z` tags count. `sort -V` is not semver-aware + # (it sorts `1.0.0-rc.1` above `1.0.0` and any word above a + # number), and this job only ever publishes plain versions, so + # pre-release and non-numeric tags are excluded by the pattern + # rather than trusted to sort correctly. `git tag` has no + # --exclude, so this is a filter, not a flag. + LAST_TAG=$(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/^v//' | sort -V | tail -n1 || true) + LAST_REG=$(npm view opencode-rtk-plugin@latest version 2>/dev/null || true) + if [ -z "$LAST_REG" ]; then + echo "::warning::could not read the registry's latest version; deriving from tags only" + fi + LAST=$(printf '%s\n%s\n' "$LAST_TAG" "$LAST_REG" | sed '/^$/d' | sort -V | tail -n1) CUR=$(npm pkg get version | tr -d '"') + echo "Baseline candidates: tag=${LAST_TAG:-none} registry=${LAST_REG:-none} -> ${LAST:-none}" if [ -n "$LAST" ] && [ "$(printf '%s\n%s' "$CUR" "$LAST" | sort -V | tail -1)" = "$LAST" ]; then - # tree at/below registry -> align to registry (unless equal), - # then bump patch; npm errors on "Version not changed" if the - # align target equals the current version + # tree at/below the baseline -> align to it (unless equal), then + # bump patch; npm errors on "Version not changed" if the align + # target equals the current version if [ "$CUR" != "$LAST" ]; then npm version "$LAST" --no-git-tag-version --no-commit-hooks fi @@ -100,20 +141,45 @@ jobs: fi echo "tag=latest" >> "$GITHUB_OUTPUT" fi - - name: Show publishing target - run: 'echo "Publishing $(npm pkg get version) to tag ${{ steps.channel.outputs.tag }}"' - - name: Publish (retry transient only) + - name: Show derived publishing target + run: 'echo "Derived target: $(npm pkg get version) to tag ${{ steps.channel.outputs.tag }} (a version conflict bumps the patch and republishes)"' + - name: Publish (bump on version conflict, retry transient) timeout-minutes: 5 run: | - for i in $(seq 1 3); do - OUT=$(npm publish --tag ${{ steps.channel.outputs.tag }} --provenance --access public 2>&1) && { echo "$OUT"; exit 0; } - if echo "$OUT" | grep -qE "cannot publish over|previously published"; then + # A version conflict cannot be retried as-is: npm keeps rejecting the + # same version. On the `latest` channel a conflict means the registry + # is ahead of the derived baseline (CDN lag, or a publish from a + # parallel run), so bump a patch and try again — the tag pushed below + # is read from package.json, so it follows the bump. Every other + # failure is retried as a transient network/registry error. The beta + # channels derive unique versions, so a conflict there is a re-run + # and still fails fast. + # + # npm prints "Cannot publish over ..." and "npm error code E409"; the + # match is case-insensitive on purpose. + bumps=0 + attempts=0 + while [ "$attempts" -lt 5 ]; do + attempts=$((attempts + 1)) + if OUT=$(npm publish --tag ${{ steps.channel.outputs.tag }} --provenance --access public 2>&1); then + echo "$OUT" + echo "Published $(npm pkg get version | tr -d '"')" + exit 0 + fi + if echo "$OUT" | grep -qiE "cannot publish over|previously published|previously staged|npm error code E409"; then + if [ "${{ steps.channel.outputs.tag }}" = "latest" ] && [ "$bumps" -lt 2 ]; then + bumps=$((bumps + 1)) + echo "$OUT" >&2 + echo "Version conflict — bumping patch and retrying ($bumps/2)" >&2 + npm version patch --no-git-tag-version --no-commit-hooks + continue + fi echo "$OUT" >&2 echo "Version conflict — not retrying" >&2 exit 1 fi echo "$OUT" - echo "Publish failed (transient?), retrying ($i/3)..." + echo "Publish failed (transient?), retrying ($attempts/5)..." sleep 3 done exit 1 @@ -178,7 +244,7 @@ jobs: run: | for i in $(seq 1 3); do OUT=$(npm publish --tag beta --provenance --access public 2>&1) && { echo "$OUT"; exit 0; } - if echo "$OUT" | grep -qE "cannot publish over|previously published"; then + if echo "$OUT" | grep -qiE "cannot publish over|previously published|previously staged|npm error code E409"; then echo "$OUT" >&2 echo "Version conflict (workflow re-run) — deduped" >&2 exit 0 diff --git a/README.md b/README.md index 1564321..8dcb771 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,11 @@ publisher per package; the workflow filename must stay `ci.yml`): - `test` — lint + plugin-export smoke check on `main` and PRs (Node 24). - push to `main` → `publish` job publishes the next patch to `latest`, pushes a - `vX.Y.Z` tag, and opens a GitHub release. The version is derived from the - last published `latest`, so repeated merges never collide; an intentional - minor/major bump in `package.json` is honored. + `vX.Y.Z` tag, and opens a GitHub release. The version is the highest of the + newest stable `vX.Y.Z` tag and the registry's `latest`, plus a patch, so + repeated merges never collide; an intentional minor/major bump in + `package.json` is honored. A publish that hits `E409` bumps a patch and + retries (bounded), because the registry read can lag a fresh publish. - tag `v*-beta*` → `publish` job publishes `beta` + a prerelease GitHub release. - every PR push → `prerelease` job publishes `-beta.` to `beta` (same-repo, non-draft, non-dependabot PRs; re-runs deduped).