Skip to content

Commit 57d77eb

Browse files
authored
fix(release): make registry publishing re-runnable and independent (#4)
The v0.1.2 release published five of six npm platform packages, tripped npm's spam heuristic on the sixth, and took PyPI down with it. Three separate faults, all in how the publish steps were wired rather than in the packages themselves. npm publishing moves into packaging/npm/publish.sh - npm's spam detection refused the sixth similarly-named package in a row with `403 Package name triggered spam detection`. It is rate-shaped, not permanent, so publishes are paced and that specific failure is retried with backoff. - npm refuses to republish an existing version, so a naive retry of a partial release fails on everything that already succeeded and never reaches what did not. Versions already on the registry are skipped, which makes re-running the release job the correct recovery. - A conflict raised between the check and the publish is treated as success rather than an error; it means a concurrent run got there first. - Platform-before-entry ordering is unchanged and now enforced in one place. PyPI no longer depends on npm succeeding - They are independent registries. `if: always()` on the upload, and `--skip-existing` so a re-run after a partial upload is safe rather than a guaranteed failure on the versions that already landed. - Also fixes a repeat of an earlier mistake: `secrets` is not an available context in a step-level `if`, so the guard reads the mapped env var. Verified the skip and retry logic against the live registry: it identifies exactly the two packages v0.1.2 left unpublished, classifies spam and 429 as retryable, publish-conflict as already-done, and everything else as fatal.
1 parent 6cb4915 commit 57d77eb

3 files changed

Lines changed: 110 additions & 15 deletions

File tree

.github/workflows/release.yml

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,7 @@ jobs:
8282
env:
8383
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
8484
if: ${{ env.NODE_AUTH_TOKEN != '' }}
85-
run: |
86-
set -euo pipefail
87-
# Platform packages first: the entry package depends on them, and an
88-
# entry published against versions that do not exist yet is an install
89-
# that fails for everyone until the next step lands.
90-
for pkg in dist/npm/modelslab-cli-*; do
91-
npm publish "$pkg" --access public --provenance
92-
done
93-
npm publish dist/npm/modelslab-cli --access public --provenance
85+
run: bash packaging/npm/publish.sh dist/npm
9486

9587
- uses: actions/setup-python@v5
9688
with:
@@ -99,13 +91,20 @@ jobs:
9991
- name: Build PyPI wheels
10092
run: python3 packaging/pypi/build.py "${GITHUB_REF_NAME}" artifacts dist/pypi
10193

94+
# `if: always()` because npm and PyPI are independent registries and a
95+
# failure at one is not a reason to skip the other. v0.1.2 published five
96+
# npm packages, tripped npm's spam heuristic on the sixth, and PyPI never
97+
# ran at all — one registry's flakiness took the whole release with it.
10298
- name: Publish to PyPI
10399
env:
104100
TWINE_USERNAME: __token__
105101
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
106-
if: ${{ env.TWINE_PASSWORD != '' }}
102+
# `secrets` is not an available context in a step-level `if` — hence env.
103+
if: ${{ always() && env.TWINE_PASSWORD != '' }}
107104
run: |
108105
set -euo pipefail
109106
python3 -m pip install --quiet twine
110107
python3 -m twine check dist/pypi/*.whl
111-
python3 -m twine upload dist/pypi/*.whl
108+
# --skip-existing so a re-run after a partial failure is safe; PyPI
109+
# rejects a repeated version outright and would fail the retry.
110+
python3 -m twine upload --skip-existing dist/pypi/*.whl

packaging/README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,20 @@ rejected deliberately. It needs network at install time and produces a silently
3434
broken install under `npm ci --ignore-scripts`, which many CI and agent sandboxes
3535
set. Six small packages buy an install that cannot half-work.
3636

37-
**Publish platform packages before the entry package.** The entry package pins
38-
exact versions of all six; publishing it first leaves a window where every
39-
install fails.
37+
Publishing goes through `packaging/npm/publish.sh`, which exists because two
38+
things bit the v0.1.2 release:
39+
40+
- **Order.** The entry package pins exact versions of all six platform packages,
41+
so publishing it first leaves a window where every install fails. Platform
42+
packages go first.
43+
- **npm's spam heuristic.** Six similarly-named packages published back to back
44+
tripped it on the sixth with `403 Package name triggered spam detection`. It is
45+
rate-shaped rather than permanent, so the script paces publishes and retries
46+
that specific failure with backoff.
47+
- **Re-running.** npm refuses to republish an existing version, so a naive retry
48+
of a half-finished release fails on the packages that succeeded and never
49+
reaches the ones that did not. The script skips versions already on the
50+
registry, which makes a re-run the correct recovery for a partial publish.
4051

4152
## PyPI — `packaging/pypi/build.py`
4253

@@ -67,7 +78,11 @@ Two things that are easy to get wrong and are covered by CI:
6778

6879
`.github/workflows/release.yml` runs both builders on a tag and publishes if the
6980
corresponding token is configured. Missing tokens skip that registry rather than
70-
failing the release.
81+
failing the release, and the PyPI step runs even when npm fails — they are
82+
independent registries, and in v0.1.2 an npm failure meant PyPI never ran at all.
83+
84+
Re-running the release job is the supported recovery for a partial publish: both
85+
publishers skip what is already on their registry.
7186

7287
| Secret | Registry |
7388
| --- | --- |

packaging/npm/publish.sh

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Publishes the built npm packages, safe to re-run.
4+
#
5+
# Two things the naive `for pkg in *; do npm publish; done` gets wrong, both of
6+
# which bit the v0.1.2 release:
7+
#
8+
# 1. npm's spam heuristic. Publishing six similarly-named packages back to back
9+
# tripped it on the sixth with a 403 "Package name triggered spam
10+
# detection". It is rate-shaped, not permanent, so a paced retry clears it.
11+
#
12+
# 2. Re-running after a partial failure. npm refuses to republish a version
13+
# that already exists, so a retry of a half-finished release fails on the
14+
# packages that DID succeed and never reaches the ones that did not.
15+
#
16+
# Ordering still matters: platform packages before the entry package, which pins
17+
# exact versions of all of them. Publishing the entry first leaves a window where
18+
# every install fails.
19+
set -euo pipefail
20+
21+
DIST="${1:?usage: publish.sh <dist/npm dir>}"
22+
ENTRY="modelslab-cli"
23+
MAX_ATTEMPTS=5
24+
25+
already_published() {
26+
local name="$1" version="$2"
27+
npm view "${name}@${version}" version >/dev/null 2>&1
28+
}
29+
30+
publish_one() {
31+
local dir="$1"
32+
local name version attempt delay output
33+
name=$(node -p "require('${dir}/package.json').name")
34+
version=$(node -p "require('${dir}/package.json').version")
35+
36+
if already_published "$name" "$version"; then
37+
echo "skip ${name}@${version} (already on the registry)"
38+
return 0
39+
fi
40+
41+
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
42+
if output=$(npm publish "$dir" --access public --provenance 2>&1); then
43+
echo "$output"
44+
echo "publish ${name}@${version}"
45+
return 0
46+
fi
47+
48+
echo "$output"
49+
50+
# A version that appeared between the check and the publish is a success
51+
# for our purposes — most likely a concurrent or retried run.
52+
if grep -qi "cannot publish over\|EPUBLISHCONFLICT" <<<"$output"; then
53+
echo "skip ${name}@${version} (published concurrently)"
54+
return 0
55+
fi
56+
57+
if ! grep -qi "spam detection\|429\|rate.limit" <<<"$output"; then
58+
echo "fatal ${name}@${version}: not a retryable error" >&2
59+
return 1
60+
fi
61+
62+
delay=$((attempt * 30))
63+
echo "retry ${name}@${version} in ${delay}s (attempt ${attempt}/${MAX_ATTEMPTS}, spam/rate heuristic)" >&2
64+
sleep "$delay"
65+
done
66+
67+
echo "fatal ${name}@${version}: still refused after ${MAX_ATTEMPTS} attempts" >&2
68+
return 1
69+
}
70+
71+
for dir in "${DIST}"/${ENTRY}-*; do
72+
[ -d "$dir" ] || continue
73+
publish_one "$dir"
74+
# Pace the platform packages. Publishing them as fast as the API allows is
75+
# what looks like spam in the first place.
76+
sleep 10
77+
done
78+
79+
publish_one "${DIST}/${ENTRY}"
80+
81+
echo "npm publish complete"

0 commit comments

Comments
 (0)