@@ -335,8 +335,17 @@ jobs:
335335 # A prerelease must never become npm `latest` — plain `npm install`
336336 # would serve the RC to everyone. Publish prereleases under the
337337 # `next` dist-tag; testers opt in via `npm i codebase-memory-mcp@next`.
338+ #
339+ # Idempotency: npm versions are immutable, so a re-run of this job
340+ # after a partial success (v0.10.1: npm published, then the twine
341+ # step died) can never republish — it 403s on its own earlier
342+ # success and the release wedges in draft forever. If the exact
343+ # version already exists on the registry, publishing is DONE; skip.
338344 run : |
339- if [[ "$RELEASE_VERSION" == *-* ]]; then
345+ V="${RELEASE_VERSION#v}"
346+ if npm view "codebase-memory-mcp@$V" version >/dev/null 2>&1; then
347+ echo "npm already has codebase-memory-mcp@$V — skipping publish (idempotent re-run)"
348+ elif [[ "$RELEASE_VERSION" == *-* ]]; then
340349 npm publish --access public --provenance --tag next
341350 else
342351 npm publish --access public --provenance
@@ -354,15 +363,25 @@ jobs:
354363 # Pin to specific versions (typosquatting / supply-chain mitigation).
355364 # OSSF scorecard prefers --require-hashes, but that needs full
356365 # transitive deps; version pinning is the practical compromise.
357- python -m pip install --upgrade 'build==1.3.0' 'twine==6.2.0'
366+ #
367+ # twine >= 7 is required: `python -m build` resolves the UNPINNED
368+ # hatchling backend fresh in its isolated env, and current hatchling
369+ # emits Metadata-Version 2.5, which twine 6.2.0 rejects as invalid
370+ # ("'2.5' is not a valid metadata version" — the v0.10.1 failure).
371+ # Verified locally: identical artifacts, 6.2.0 rejects / 7.0.0 passes.
372+ python -m pip install --upgrade 'build==1.3.0' 'twine==7.0.0'
358373 python -m build
374+ twine check dist/*
359375
360376 - name : Publish to PyPI
361377 working-directory : pkg/pypi
362378 env :
363379 TWINE_USERNAME : __token__
364380 TWINE_PASSWORD : ${{ secrets.PYPI_TOKEN }}
365- run : twine upload --non-interactive dist/*
381+ # --skip-existing: PyPI files are immutable like npm versions; a
382+ # re-run after partial success must treat already-uploaded files
383+ # as done, not as a fatal collision.
384+ run : twine upload --non-interactive --skip-existing dist/*
366385
367386 # ── 8b. Publish server.json to the official MCP Registry ──────
368387 # Runs after npm + PyPI so the registry can verify package ownership
0 commit comments