Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,15 +82,7 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
if: ${{ env.NODE_AUTH_TOKEN != '' }}
run: |
set -euo pipefail
# Platform packages first: the entry package depends on them, and an
# entry published against versions that do not exist yet is an install
# that fails for everyone until the next step lands.
for pkg in dist/npm/modelslab-cli-*; do
npm publish "$pkg" --access public --provenance
done
npm publish dist/npm/modelslab-cli --access public --provenance
run: bash packaging/npm/publish.sh dist/npm

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

# `if: always()` because npm and PyPI are independent registries and a
# failure at one is not a reason to skip the other. v0.1.2 published five
# npm packages, tripped npm's spam heuristic on the sixth, and PyPI never
# ran at all — one registry's flakiness took the whole release with it.
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
if: ${{ env.TWINE_PASSWORD != '' }}
# `secrets` is not an available context in a step-level `if` — hence env.
if: ${{ always() && env.TWINE_PASSWORD != '' }}
run: |
set -euo pipefail
python3 -m pip install --quiet twine
python3 -m twine check dist/pypi/*.whl
python3 -m twine upload dist/pypi/*.whl
# --skip-existing so a re-run after a partial failure is safe; PyPI
# rejects a repeated version outright and would fail the retry.
python3 -m twine upload --skip-existing dist/pypi/*.whl
23 changes: 19 additions & 4 deletions packaging/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,9 +34,20 @@ rejected deliberately. It needs network at install time and produces a silently
broken install under `npm ci --ignore-scripts`, which many CI and agent sandboxes
set. Six small packages buy an install that cannot half-work.

**Publish platform packages before the entry package.** The entry package pins
exact versions of all six; publishing it first leaves a window where every
install fails.
Publishing goes through `packaging/npm/publish.sh`, which exists because two
things bit the v0.1.2 release:

- **Order.** The entry package pins exact versions of all six platform packages,
so publishing it first leaves a window where every install fails. Platform
packages go first.
- **npm's spam heuristic.** Six similarly-named packages published back to back
tripped it on the sixth with `403 Package name triggered spam detection`. It is
rate-shaped rather than permanent, so the script paces publishes and retries
that specific failure with backoff.
- **Re-running.** npm refuses to republish an existing version, so a naive retry
of a half-finished release fails on the packages that succeeded and never
reaches the ones that did not. The script skips versions already on the
registry, which makes a re-run the correct recovery for a partial publish.

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

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

`.github/workflows/release.yml` runs both builders on a tag and publishes if the
corresponding token is configured. Missing tokens skip that registry rather than
failing the release.
failing the release, and the PyPI step runs even when npm fails — they are
independent registries, and in v0.1.2 an npm failure meant PyPI never ran at all.

Re-running the release job is the supported recovery for a partial publish: both
publishers skip what is already on their registry.

| Secret | Registry |
| --- | --- |
Expand Down
81 changes: 81 additions & 0 deletions packaging/npm/publish.sh
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
#
# Publishes the built npm packages, safe to re-run.
#
# Two things the naive `for pkg in *; do npm publish; done` gets wrong, both of
# which bit the v0.1.2 release:
#
# 1. npm's spam heuristic. Publishing six similarly-named packages back to back
# tripped it on the sixth with a 403 "Package name triggered spam
# detection". It is rate-shaped, not permanent, so a paced retry clears it.
#
# 2. Re-running after a partial failure. npm refuses to republish a version
# that already exists, so a retry of a half-finished release fails on the
# packages that DID succeed and never reaches the ones that did not.
#
# Ordering still matters: platform packages before the entry package, which pins
# exact versions of all of them. Publishing the entry first leaves a window where
# every install fails.
set -euo pipefail

DIST="${1:?usage: publish.sh <dist/npm dir>}"
ENTRY="modelslab-cli"
MAX_ATTEMPTS=5

already_published() {
local name="$1" version="$2"
npm view "${name}@${version}" version >/dev/null 2>&1
}

publish_one() {
local dir="$1"
local name version attempt delay output
name=$(node -p "require('${dir}/package.json').name")
version=$(node -p "require('${dir}/package.json').version")

if already_published "$name" "$version"; then
echo "skip ${name}@${version} (already on the registry)"
return 0
fi

for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
if output=$(npm publish "$dir" --access public --provenance 2>&1); then
echo "$output"
echo "publish ${name}@${version}"
return 0
fi

echo "$output"

# A version that appeared between the check and the publish is a success
# for our purposes — most likely a concurrent or retried run.
if grep -qi "cannot publish over\|EPUBLISHCONFLICT" <<<"$output"; then
echo "skip ${name}@${version} (published concurrently)"
return 0
fi

if ! grep -qi "spam detection\|429\|rate.limit" <<<"$output"; then
echo "fatal ${name}@${version}: not a retryable error" >&2
return 1
fi

delay=$((attempt * 30))
echo "retry ${name}@${version} in ${delay}s (attempt ${attempt}/${MAX_ATTEMPTS}, spam/rate heuristic)" >&2
sleep "$delay"
done

echo "fatal ${name}@${version}: still refused after ${MAX_ATTEMPTS} attempts" >&2
return 1
}

for dir in "${DIST}"/${ENTRY}-*; do
[ -d "$dir" ] || continue
publish_one "$dir"
# Pace the platform packages. Publishing them as fast as the API allows is
# what looks like spam in the first place.
sleep 10
done

publish_one "${DIST}/${ENTRY}"

echo "npm publish complete"
Loading