From 317ebd51ee1b981bb5db99364e494610e06d0d3d Mon Sep 17 00:00:00 2001 From: Simon Young <40179067+Kastier1@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:30:19 -0700 Subject: [PATCH] Stop gating releases on a dated changelog entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changelog leg of the release gate could only be satisfied by the tagged tree, so a missing or undated `## [X.Y.Z]` heading did not mean "write it up before publishing" — it meant deleting the tag, committing a documentation edit, and re-cutting the tag on the new commit. That is a bad trade for a file a follow-up commit fixes, and it blocked both the PyPI publish and the docs deploy on it. What survives is the leg a tag genuinely can get wrong on its own: the shape. uv-dynamic-versioning derives the distribution version from the tag, so a `.postN`, `.devN`, local, or non-canonical (`-alpha1`) tag can never match the version its own artifacts are built under, and is still refused before anything builds. Writing up the release stays a release-checklist item in `spec/process/production-readiness.md`, now marked as deliberately unenforced. --- .github/workflows/deploy-docs-stg.yml | 30 +++---- .github/workflows/release.yml | 10 ++- scripts/check_release_version.py | 68 +++++----------- scripts/verify_ci_workflow.py | 2 +- spec/process/production-readiness.md | 11 ++- tests/test_check_release_version.py | 110 ++++++++------------------ 6 files changed, 81 insertions(+), 150 deletions(-) diff --git a/.github/workflows/deploy-docs-stg.yml b/.github/workflows/deploy-docs-stg.yml index fb98c67f..2d43af94 100644 --- a/.github/workflows/deploy-docs-stg.yml +++ b/.github/workflows/deploy-docs-stg.yml @@ -42,16 +42,17 @@ jobs: EVENT_SHA: ${{ github.sha }} REPO: ${{ github.repository }} run: | - # Shape check only — the real gate (tag == pyproject == CHANGELOG) - # runs below against the tagged tree. Deliberately PEP 440, not - # strict SemVer: release.yml requires tag == "v" + the pyproject - # version, so an rc tags as `v0.2.0rc1` (no dash) and a post-release - # as `v0.2.0.post1`. Local versions are excluded because `+` is not a - # legal Docker tag character and this value becomes the image tag, - # and the trailing class cannot end in a separator. 128 chars is the - # ceiling the reusable build/helm workflows enforce. + # Loose shape check only: this runs before the repo is on disk, so all + # it has to establish is that the value can resolve to a ref and is a + # legal image tag. check_release_version.py below is the authority on + # what a release tag is. Deliberately PEP 440, not strict SemVer — a + # pre-release tags as `v0.2.0rc1`, no dash. Local versions are + # excluded because `+` is not a legal Docker tag character and this + # value becomes the image tag, and the trailing class cannot end in a + # separator. 128 chars is the ceiling the reusable build/helm + # workflows enforce. if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-]?[A-Za-z0-9]+)*$ ]]; then - echo "::error::version must be a release tag like v0.1.0, v0.2.0rc1 or v0.2.0.post1" + echo "::error::version must be a release tag like v0.1.0 or v0.2.0rc1" exit 1 fi if (( ${#VERSION} > 128 )); then @@ -71,13 +72,14 @@ jobs: echo "source_sha=$SHA" >> "$GITHUB_OUTPUT" # A semver-shaped tag is not necessarily a releasable one. Run the same - # gate release.yml runs, against the tagged tree, so a stray `v9.9.9` - # cannot deploy docs for a library version that can never be published. + # gate release.yml runs, from the tagged tree, so a tag the version + # derivation cannot read cannot deploy docs for a library version that can + # never be published. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ steps.resolve.outputs.source_sha }} persist-credentials: false - - name: Release version gate (tag == pyproject == CHANGELOG) + - name: Release version gate (tag shape) env: VERSION: ${{ steps.resolve.outputs.version }} run: python3 scripts/check_release_version.py --tag "$VERSION" @@ -118,8 +120,8 @@ jobs: - run: echo "Approved for production promotion" # No GitHub Release job here: on a `v*` tag release.yml owns the release - # (it gates tag == pyproject == CHANGELOG and attaches the Pyodide wheel with - # generated notes). A second creator would race it and could win, stamping a + # (it gates the tag shape and attaches the Pyodide wheel with generated + # notes). A second creator would race it and could win, stamping a # library release with docs-deployment notes. The flip side of not owning it # is that this workflow must confirm it happened before promoting prod. verify-library-release: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d04b68ac..3f829dcf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -293,10 +293,12 @@ jobs: # from the latest `v*` tag, and a shallow clone has none. fetch-depth: 0 # The tag now *is* the version (pyproject derives it), so there is no - # second number left to disagree with it. What a tag can't vouch for is - # that the release was written up: this gate requires the tag to be shaped - # like a release tag and CHANGELOG.md to carry a dated entry for it. - - name: Release version gate (tag == CHANGELOG) + # second number left to disagree with it. What a tag can still get wrong + # is its shape: this gate refuses one the derivation cannot turn into the + # version these artifacts were built and published under. Writing up the + # release in CHANGELOG.md is a checklist item, not a gate — a doc edit + # should never cost a deleted and re-cut tag. + - name: Release version gate (tag shape) if: github.event_name == 'push' run: python3 scripts/check_release_version.py - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/scripts/check_release_version.py b/scripts/check_release_version.py index 50a11888..8f04efe6 100644 --- a/scripts/check_release_version.py +++ b/scripts/check_release_version.py @@ -1,22 +1,26 @@ #!/usr/bin/env python3 -"""Release gate: the pushed tag and the package's CHANGELOG must agree. +"""Release gate: the pushed tag must be a shape the version derivation accepts. The tag *is* the version now — each pyproject derives it via uv-dynamic-versioning — so the old tag-vs-pyproject leg of this gate is gone -along with the drift it existed to catch. What a tag cannot vouch for is that -anyone wrote down what changed, so this runs first in the publish job on tag -pushes: the package's changelog must carry a dated entry for the tagged -version (an "unreleased" heading fails — date it as part of cutting the -release). +along with the drift it existed to catch. What remains is the one thing a tag +can still get wrong on its own: a shape the derivation cannot turn into the +version the artifacts are published under. This runs first in the publish job +on tag pushes. -The ``xy`` distribution uses bare `vX.Y.Z` tags and `CHANGELOG.md`. Its bundled -Reflex integration and `reflex` extra ship on the same version line. +Writing up the release in `CHANGELOG.md` is a checklist item +(`spec/process/production-readiness.md`), deliberately not a gate: a missing or +undated entry is fixed with a commit, and blocking the publish on it only ever +forced a tag to be deleted and re-cut for a documentation edit. + +The ``xy`` distribution uses bare `vX.Y.Z` tags. Its bundled Reflex integration +and `reflex` extra ship on the same version line. Tags accept an optional PEP 440 pre-release suffix in its canonical spelling (`a1`/`b2`/`rc3`). PyPI accepts pre-releases but does not serve them to default `pip install`. Only the canonical spelling passes: `-alpha1`-style tags would be normalized by the derivation (`0.0.1a1`) and could never match their own -artifacts. A pre-release still needs its own dated changelog entry. +artifacts. Docs-deploy CalVer tags (2026.WW.N) do not match the release shape. Dev/post/local shapes stay rejected: dev versions are the between-tags marker @@ -29,11 +33,8 @@ import os import re import sys -from pathlib import Path from typing import NamedTuple, Optional -ROOT = Path(__file__).resolve().parents[1] - class _Package(NamedTuple): # The release tag shape uv-dynamic-versioning derives this package's @@ -41,7 +42,6 @@ class _Package(NamedTuple): # optionally a canonical pre-release suffix (aN/bN/rcN), nothing else. tag_re: re.Pattern[str] tag_shape: str - changelog: Path # Canonical PEP 440 spellings only: the derivation would normalize `alpha1` @@ -53,45 +53,20 @@ class _Package(NamedTuple): "xy": _Package( tag_re=re.compile(rf"^v(?P{_RELEASE})$"), tag_shape=_SHAPE, - changelog=ROOT / "CHANGELOG.md", ), } -DEFAULT_CHANGELOG = PACKAGES["xy"].changelog # Backward-compatible alias for the pre-adapter, xy-only gate. TAG_RE = PACKAGES["xy"].tag_re -def check_release(tag: str, changelog: Path, package: str = "xy") -> list[str]: - errors: list[str] = [] +def check_release(tag: str, package: str = "xy") -> list[str]: spec = PACKAGES[package] - match = spec.tag_re.match(tag) - if match is None: + if spec.tag_re.match(tag) is None: return [ f"tag {tag!r} is not a release tag for {package} — expected " f"{spec.tag_shape!r}, the shape the distribution version is derived from" ] - version = match.group("version") - try: - text = changelog.read_text(encoding="utf-8") - except OSError as exc: - errors.append(f"cannot read {changelog}: {exc}") - return errors - # A heading with a real date. The gate checks substance (this version has - # dated notes), not heading style: both spellings used in this repo pass — - # Keep-a-Changelog brackets (`## [0.0.2] — 2026-07-24`) and the plain - # v-prefixed form v0.0.2 was actually documented with - # (`## v0.0.2 - 2026-07-24`); em dash or hyphen either way. - dated = re.compile( - rf"^## (?:\[{re.escape(version)}\]|v?{re.escape(version)}) [—-] " - rf"\d{{4}}-\d{{2}}-\d{{2}}\s*$", - re.M, - ) - if not dated.search(text): - errors.append( - f"{changelog.name} has no dated '## [{version}]' entry — date the " - "release section before tagging" - ) - return errors + return [] def main(argv: Optional[list[str]] = None) -> int: @@ -107,25 +82,18 @@ def main(argv: Optional[list[str]] = None) -> int: default="xy", help="which release line the tag belongs to (default: xy)", ) - parser.add_argument( - "--changelog", - type=Path, - default=None, - help="changelog to gate against (defaults to the package's own)", - ) args = parser.parse_args(argv) if not args.tag: print("release version gate: no tag provided (--tag or GITHUB_REF_NAME)", file=sys.stderr) return 1 - changelog = args.changelog if args.changelog is not None else PACKAGES[args.package].changelog - errors = check_release(args.tag, changelog, args.package) + errors = check_release(args.tag, args.package) if errors: print("release version gate failed:", file=sys.stderr) for error in errors: print(f"- {error}", file=sys.stderr) return 1 - print(f"release version gate OK: {args.tag} has a dated {changelog.name} entry") + print(f"release version gate OK: {args.tag} is a release tag for {args.package}") return 0 diff --git a/scripts/verify_ci_workflow.py b/scripts/verify_ci_workflow.py index 2fe0cfd4..c3bf4b5e 100644 --- a/scripts/verify_ci_workflow.py +++ b/scripts/verify_ci_workflow.py @@ -1353,7 +1353,7 @@ def validate_release_workflow(path: Path = DEFAULT_RELEASE_WORKFLOW) -> list[str "publish", "release", "trusted PyPI publishing from downloaded artifacts, gated by a dry-run switch " - "and a tag/version/CHANGELOG agreement gate", + "and a release-tag shape gate", "needs: [wheels, sdist, wasm]", "environment: pypi", "id-token: write", diff --git a/spec/process/production-readiness.md b/spec/process/production-readiness.md index 0a2f00e7..1e835260 100644 --- a/spec/process/production-readiness.md +++ b/spec/process/production-readiness.md @@ -346,9 +346,16 @@ artifacts, not another package or release. Before tagging a release: +- Tag as `vX.Y.Z`, optionally with a canonical PEP 440 pre-release suffix + (`v0.2.0a1`/`b1`/`rc1`). That shape is the whole release gate + (`scripts/check_release_version.py`), because it is the shape + uv-dynamic-versioning derives the published version from; `.postN`, `.devN`, + local (`+…`), and non-canonical (`-alpha1`) spellings are refused before + anything builds. - Add a dated `## [X.Y.Z] — YYYY-MM-DD` heading to `CHANGELOG.md` for the - version being tagged. This is the one thing the tag cannot vouch for, and the - release gate blocks the publish without it. + version being tagged. Deliberately not gated: a missing or undated entry is + fixed with a follow-up commit, and blocking the publish on it only ever forced + a tag to be deleted and re-cut over a documentation edit. - Refresh benchmark reports or explicitly document why the previous report still applies. - Run `make check-full` locally or confirm the equivalent diff --git a/tests/test_check_release_version.py b/tests/test_check_release_version.py index eb5b1e49..2d224bd9 100644 --- a/tests/test_check_release_version.py +++ b/tests/test_check_release_version.py @@ -18,65 +18,58 @@ def _load_module(): check_release_version = _load_module() -def _changelog(tmp_path: Path, changelog_heading: str) -> Path: - changelog = tmp_path / "CHANGELOG.md" - changelog.write_text(f"# Changelog\n\n{changelog_heading}\n\n- Something.\n") - return changelog +def test_gate_passes_a_release_tag() -> None: + assert check_release_version.check_release("v0.2.0") == [] -def test_gate_passes_when_tag_and_changelog_agree(tmp_path: Path) -> None: - changelog = _changelog(tmp_path, "## [0.2.0] — 2026-07-09") - - assert check_release_version.check_release("v0.2.0", changelog) == [] - - -def test_gate_accepts_plain_hyphen_date_separator(tmp_path: Path) -> None: - changelog = _changelog(tmp_path, "## [0.2.0] - 2026-07-09") - - assert check_release_version.check_release("v0.2.0", changelog) == [] - +def test_gate_rejects_a_tag_that_is_not_a_release_tag() -> None: + # The docs site deploys on CalVer tags (2026.WW.N) that the version + # derivation deliberately ignores; one must never publish a release. + errors = check_release_version.check_release("2026.30.1") -def test_gate_rejects_undated_changelog_entry(tmp_path: Path) -> None: - changelog = _changelog(tmp_path, "## [0.1.0] — unreleased development line") + assert any("is not a release tag" in e for e in errors) - errors = check_release_version.check_release("v0.1.0", changelog) - assert any("no dated" in e for e in errors) +def test_gate_rejects_a_derived_development_version_tag() -> None: + errors = check_release_version.check_release("v0.0.3.dev4+g63c0697") + assert any("is not a release tag" in e for e in errors) -def test_gate_rejects_missing_changelog_entry(tmp_path: Path) -> None: - changelog = _changelog(tmp_path, "## [0.2.0] — 2026-07-09") - errors = check_release_version.check_release("v0.3.0", changelog) +def test_gate_passes_a_canonical_prerelease_tag() -> None: + assert check_release_version.check_release("v0.0.1a1") == [] - assert any("no dated" in e for e in errors) +def test_gate_passes_prerelease_tags_for_the_core_too() -> None: + assert check_release_version.check_release("v1.0.0rc2") == [] -def test_gate_rejects_a_tag_that_is_not_a_release_tag(tmp_path: Path) -> None: - # The docs site deploys on CalVer tags (2026.WW.N) that the version - # derivation deliberately ignores; one must never publish a release. - changelog = _changelog(tmp_path, "## [2026.30.1] — 2026-07-24") - errors = check_release_version.check_release("2026.30.1", changelog) +def test_gate_rejects_non_canonical_prerelease_spellings() -> None: + # The derivation normalizes `alpha1` to `a1`, so a non-canonical tag can + # never equal its own built version — refuse it before it builds anything. + for tag in ("v0.0.1-alpha1", "v0.0.1alpha1", "v0.0.1a"): + errors = check_release_version.check_release(tag) + assert any("is not a release tag" in e for e in errors), tag - assert any("is not a release tag" in e for e in errors) +def test_gate_ignores_the_changelog() -> None: + # Writing up the release is a checklist item, not a gate: a tag no longer + # has to be deleted and re-cut because CHANGELOG.md was edited afterwards. + assert check_release_version.check_release("v9.9.9") == [] -def test_gate_rejects_a_derived_development_version_tag(tmp_path: Path) -> None: - changelog = _changelog(tmp_path, "## [0.0.3.dev4] — 2026-07-24") - errors = check_release_version.check_release("v0.0.3.dev4+g63c0697", changelog) +def test_main_requires_a_tag(monkeypatch) -> None: + monkeypatch.delenv("GITHUB_REF_NAME", raising=False) - assert any("is not a release tag" in e for e in errors) + assert check_release_version.main([]) == 1 -def test_main_requires_a_tag(tmp_path: Path, monkeypatch) -> None: - monkeypatch.delenv("GITHUB_REF_NAME", raising=False) - changelog = _changelog(tmp_path, "## [0.1.0] — 2026-07-09") +def test_main_reports_a_bad_tag_shape() -> None: + assert check_release_version.main(["--tag", "v0.0.6.post1"]) == 1 - rc = check_release_version.main(["--changelog", str(changelog)]) - assert rc == 1 +def test_main_accepts_a_release_tag() -> None: + assert check_release_version.main(["--tag", "v0.0.6"]) == 0 def test_release_workflow_wires_the_gate() -> None: @@ -86,44 +79,3 @@ def test_release_workflow_wires_the_gate() -> None: assert "scripts/check_release_version.py" in workflow assert "if: github.event_name == 'push'" in workflow - - -def test_gate_passes_a_canonical_prerelease_tag(tmp_path: Path) -> None: - changelog = _changelog(tmp_path, "## [0.0.1a1] — 2026-07-25") - - errors = check_release_version.check_release("v0.0.1a1", changelog) - - assert errors == [] - - -def test_gate_passes_prerelease_tags_for_the_core_too(tmp_path: Path) -> None: - changelog = _changelog(tmp_path, "## [1.0.0rc2] — 2026-07-25") - - assert check_release_version.check_release("v1.0.0rc2", changelog) == [] - - -def test_gate_rejects_non_canonical_prerelease_spellings(tmp_path: Path) -> None: - # The derivation normalizes `alpha1` to `a1`, so a non-canonical tag can - # never equal its own built version — refuse it before it builds anything. - changelog = _changelog(tmp_path, "## [0.0.1a1] — 2026-07-25") - - for tag in ("v0.0.1-alpha1", "v0.0.1alpha1", "v0.0.1a"): - errors = check_release_version.check_release(tag, changelog) - assert any("is not a release tag" in e for e in errors), tag - - -def test_a_prerelease_needs_its_own_dated_entry(tmp_path: Path) -> None: - # An entry for the final 0.0.1 must not vouch for 0.0.1a1 (or vice versa). - changelog = _changelog(tmp_path, "## [0.0.1] — 2026-07-25") - - errors = check_release_version.check_release("v0.0.1a1", changelog) - - assert any("no dated" in e for e in errors) - - -def test_gate_accepts_the_unbracketed_v_heading_style(tmp_path: Path) -> None: - # v0.0.2 was documented as `## v0.0.2 - 2026-07-24` (no brackets, leading - # v). The gate checks that dated notes exist, not heading punctuation. - changelog = _changelog(tmp_path, "## v0.0.2 - 2026-07-24") - - assert check_release_version.check_release("v0.0.2", changelog) == []