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())