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
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
13 changes: 12 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 24 additions & 0 deletions tests/test_repository_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
14 changes: 9 additions & 5 deletions tools/check_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
51 changes: 51 additions & 0 deletions tools/npm_dist_tag.py
Original file line number Diff line number Diff line change
@@ -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())