From ecc59c383e5071b3ba2e0d1e37663e55ae500ce7 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Wed, 2 Sep 2026 16:23:03 -0700 Subject: [PATCH] fix(release): derive the npm dist-tag from spec/VERSION The release workflow ran `npm publish npm-dist/*.tgz --access public` with no `--tag`. npm refuses that outright for a prerelease, so the publish-npm job would have failed on any 0.x-alpha release, and on a stable one it would have moved `latest` silently. Confirmed by hand today: npm error You must specify a tag using --tag when publishing a prerelease version. Rather than hardcode a tag in the workflow or in publishConfig, where it would need editing at every phase change, this derives it from the phase already declared once in spec/VERSION. - tools/npm_dist_tag.py prints the tag: latest for a stable version, otherwise the prerelease phase (alpha, beta, rc, dev). - check_versions.py now exposes CONTRACT_PATTERN so both tools read one grammar. A second copy of that regex would be its own drift risk, which is the class of bug #41 was. - The build job publishes it as an output, so the artifact is still built once and the publish job needs no checkout. - RELEASING.md documents the derived tag and the by-hand equivalent. Also records a real npm behaviour worth knowing: npm sets `latest` on a package's first published version whatever --tag says. That happened on today's bootstrap publish of 0.1.0-alpha.1 and corrects itself at the first stable release. Gates: all four checks, release-tag gate, 16 repository-gate tests including three new ones asserting a prerelease never resolves to latest. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Xyu1wLe68MPCKaqUXeEpXn --- .github/workflows/release.yml | 7 ++++- RELEASING.md | 13 ++++++++- tests/test_repository_gates.py | 24 ++++++++++++++++ tools/check_versions.py | 14 ++++++---- tools/npm_dist_tag.py | 51 ++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 tools/npm_dist_tag.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63facdf..4515861 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,8 @@ jobs: build: if: github.event.release.target_commitish == 'main' runs-on: ubuntu-latest + outputs: + npm_tag: ${{ steps.npm_tag.outputs.tag }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -29,6 +31,9 @@ jobs: run: | python tools/check_versions.py python tools/check_release_tag.py "${{ github.event.release.tag_name }}" + - name: Derive npm dist-tag from the contract version + id: npm_tag + run: echo "tag=$(python tools/npm_dist_tag.py)" >> "$GITHUB_OUTPUT" - name: Build and test Python distributions run: | python -m pip install --upgrade pip @@ -89,7 +94,7 @@ jobs: name: npm-distribution path: npm-dist - name: Publish to npm with trusted publishing - run: npm publish npm-dist/*.tgz --access public + run: npm publish npm-dist/*.tgz --access public --tag "${{ needs.build.outputs.npm_tag }}" release-assets: needs: [publish-pypi, publish-npm] diff --git a/RELEASING.md b/RELEASING.md index 676c917..f22b60e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -44,5 +44,16 @@ subsequent release. Never store an npm publish token in GitHub. The workflow fails closed if the tag differs from the contract version. It builds each distribution once, sends the same artifacts to the registries, and -attaches them to the GitHub release. PyPI and npm create registry provenance +attaches them to the GitHub release. + +The npm dist-tag is derived from `spec/VERSION` by `tools/npm_dist_tag.py`, so a +prerelease publishes under `alpha`, `beta`, `rc` or `dev` and only a stable +version publishes under `latest`. npm refuses an untagged prerelease publish +outright, and an untagged stable publish would move `latest`, so nothing here is +left to the person running the release. If you ever must publish by hand, pass +the same tag: `npm publish --tag "$(python tools/npm_dist_tag.py)"`. + +Note that npm sets `latest` on a package's very first published version whatever +`--tag` says. That is expected on a bootstrap publish and corrects itself when +the first stable version ships. PyPI and npm create registry provenance through trusted publishing; GitHub also attests the downloadable release assets. diff --git a/tests/test_repository_gates.py b/tests/test_repository_gates.py index 0e00174..a53b50e 100644 --- a/tests/test_repository_gates.py +++ b/tests/test_repository_gates.py @@ -21,6 +21,7 @@ def load_tool(name): check_versions = load_tool("check_versions") check_release_tag = load_tool("check_release_tag") check_typescript_schemas = load_tool("check_typescript_schemas") +npm_dist_tag = load_tool("npm_dist_tag") class RepositoryGateTests(unittest.TestCase): @@ -135,5 +136,28 @@ def test_workflows_are_valid_yaml_when_parser_available(self): self.assertIn("jobs", document) + def test_npm_dist_tag_never_moves_latest_for_a_prerelease(self): + for contract, expected in ( + ("1.0.0", "latest"), + ("0.1.0-alpha.1", "alpha"), + ("0.2.0-beta.3", "beta"), + ("1.0.0-rc.1", "rc"), + ("0.1.0-dev", "dev"), + ): + with self.subTest(contract=contract): + self.assertEqual(npm_dist_tag.dist_tag(contract), expected) + + def test_npm_dist_tag_rejects_an_unsupported_version(self): + with self.assertRaises(ValueError): + npm_dist_tag.dist_tag("0.1") + + def test_npm_dist_tag_matches_the_declared_contract(self): + contract = (ROOT / "spec" / "VERSION").read_text(encoding="utf-8").strip() + _, npm_version = check_versions.ecosystem_versions(contract) + tag = npm_dist_tag.dist_tag(contract) + # A prerelease must never publish under latest, which is what an + # untagged npm publish would do. + self.assertEqual(tag == "latest", "-" not in npm_version) + if __name__ == "__main__": unittest.main() diff --git a/tools/check_versions.py b/tools/check_versions.py index 4d15b9f..d615657 100644 --- a/tools/check_versions.py +++ b/tools/check_versions.py @@ -13,14 +13,18 @@ ROOT = Path(__file__).resolve().parents[1] EXPECTED_NPM_NAME = "@agentrust-io/telemetry" +# The one grammar for a contract version. tools/npm_dist_tag.py reads the phase +# group from this same pattern, so the spellings accepted by the version gate +# and the dist-tag the release publishes under cannot diverge. +CONTRACT_PATTERN = re.compile( + r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-(dev|alpha|beta|rc)(?:\.(0|[1-9]\d*))?)?" +) + def ecosystem_versions(contract: str) -> tuple[str, str]: """Map the contract SemVer spelling to Python and npm package versions.""" - match = re.fullmatch( - r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" - r"(?:-(dev|alpha|beta|rc)(?:\.(0|[1-9]\d*))?)?", - contract, - ) + match = CONTRACT_PATTERN.fullmatch(contract) if not match: raise ValueError(f"unsupported contract version: {contract}") major, minor, patch, phase, sequence = match.groups() diff --git a/tools/npm_dist_tag.py b/tools/npm_dist_tag.py new file mode 100644 index 0000000..d44ea75 --- /dev/null +++ b/tools/npm_dist_tag.py @@ -0,0 +1,51 @@ +"""Derive the npm dist-tag for the declared contract version. + +npm refuses to publish a prerelease without an explicit `--tag`, because an +untagged publish moves `latest`. Hardcoding a tag in the workflow or in +`publishConfig` would then need editing at every phase change, and the phase is +already declared once in `spec/VERSION`. This derives the tag from that single +source so the release workflow never has to be told which phase it is in. + + python tools/npm_dist_tag.py # prints the tag for spec/VERSION + +Mapping, using the same phase grammar `check_versions.ecosystem_versions` parses: + + 1.0.0 -> latest + 0.1.0-alpha.1 -> alpha + 0.1.0-beta.2 -> beta + 0.1.0-rc.1 -> rc + 0.1.0-dev -> dev +""" + +import sys +from pathlib import Path + +try: + from check_versions import ROOT, CONTRACT_PATTERN +except ModuleNotFoundError: # Imported as a repository test module. + from tools.check_versions import ROOT, CONTRACT_PATTERN + +STABLE_TAG = "latest" + + +def dist_tag(contract: str) -> str: + """Return the npm dist-tag for a contract version, or raise on a bad one.""" + match = CONTRACT_PATTERN.fullmatch(contract) + if not match: + raise ValueError(f"unsupported contract version: {contract}") + phase = match.group(4) + return phase if phase else STABLE_TAG + + +def main() -> int: + contract = (ROOT / "spec" / "VERSION").read_text(encoding="utf-8").strip() + try: + print(dist_tag(contract)) + except ValueError as error: + print(f"FAIL {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())