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
30 changes: 16 additions & 14 deletions .github/workflows/deploy-docs-stg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +45 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the pre-release sentence.

Line 49 says “a pre-release tags”. Replace it with “pre-release tags”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy-docs-stg.yml around lines 45 - 55, Correct the
comment near the VERSION validation regex by changing “a pre-release tags” to
“pre-release tags”, without altering the validation logic.

exit 1
fi
if (( ${#VERSION} > 128 )); then
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 18 additions & 50 deletions scripts/check_release_version.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -29,19 +33,15 @@
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
# distribution version from: prefix + `v` + a PEP 440 release number,
# 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`
Expand All @@ -53,45 +53,20 @@ class _Package(NamedTuple):
"xy": _Package(
tag_re=re.compile(rf"^v(?P<version>{_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"
]
Comment on lines +62 to 68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python -m pip install --quiet --disable-pip-version-check packaging

python - <<'PY'
from packaging.version import Version

for raw, expected in {
    "01.02.003": "1.2.3",
    "1.2.3rc01": "1.2.3rc1",
}.items():
    actual = str(Version(raw))
    assert actual == expected, (raw, actual, expected)
    print(f"{raw} -> {actual}")
PY

rg -n -C 3 'non_canonical|v01\.2\.3|rc01|check_release' \
  tests/test_check_release_version.py scripts/check_release_version.py

Repository: reflex-dev/xy

Length of output: 188


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "files:"
git ls-files | rg '(^|/)(scripts/check_release_version.py|tests/test_check_release_version.py|pyproject.toml|uv.lock|requirements|Pipfile|poetry.lock)$'
echo
echo "check_release_version outline:"
ast-grep outline scripts/check_release_version.py --view expanded
echo
echo "selected lines:"
cat -n scripts/check_release_version.py | sed -n '1,140p'
echo
echo "test file selected lines if present:"
if [ -f tests/test_check_release_version.py ]; then
  cat -n tests/test_check_release_version.py | sed -n '1,220p'
fi
echo
echo "version-related search:"
rg -n "check_release|PACKAGES|RELEASE|tag_re|distribution_version|release_tag|package_version|Version|tag" scripts tests pyproject.toml 2>/dev/null || true

Repository: reflex-dev/xy

Length of output: 50369


🌐 Web query:

PEP 440 canonical version format integer numbers ASCII digits leading zeroes normalization

💡 Result:

Under PEP 440, all numeric version components must be represented as sequences of ASCII digits (specifically 0-9) [1][2][3]. The specification requires that these components be interpreted as non-negative integers [1][4]. Regarding normalization and leading zeros: Integer Normalization: All numeric segments are normalized by interpreting them as integers via the Python int built-in [1][5]. Consequently, any leading zeros are stripped during the normalization process [1][6]. For example, a version segment of 00 or 09000 will normalize to 0 and 9000, respectively [1][2]. Local Versions Exception: This normalization behavior does not apply to integers contained within alphanumeric segments of a local version (e.g., 1.0+foo0100), which remain in their already normalized form [1][4]. Because the canonical format requires these components to be represented as integers, the presence of leading zeros is technically redundant and they are removed to ensure a consistent, standardized representation [1][7]. The requirement for ASCII digits specifically excludes arbitrary Unicode [Nd] code points, a clarification added to the specification to ensure consistent behavior across parsing tools [8][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import re
from scripts.check_release_version import check_release

for tag in [
    "v01.02.003",
    "v1.2.30",
    "v1.2.3rc01",
    "v1.2.3α1",
    "v1.2.3a1",
    "v0.2.0",
]:
    print(f"{tag!r}: {check_release(tag)!r}")

number = r"\d+"
release = rf"(?:{number}\.){{2}}{number}(?:(?:a|b|rc){number})?"
print("released tags accepted by current regex:", re.compile(re.escape("v") + release + r"$").match("v01.02.003") is not None)
print("release prerelease digit regex accepts non-ASCII Nd alpha:", re.search(r"[^\u0030-\u0039]", "α") is not None)
PY

Repository: reflex-dev/xy

Length of output: 514


Reject normalized numeric release aliases.

v01.02.003 and v1.2.3rc01 pass check_release() because _RELEASE uses \d+, even though PEP 440 canonicalizes release numbers and pre-release numbers with leading zeros. Match each release and pre-release component with ASCII digits, excluding leading zeros except for 0.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_release_version.py` around lines 62 - 68, Update the
release-tag regex used by check_release, specifically the _RELEASE pattern, so
every numeric release and pre-release component accepts only ASCII digits
without leading zeros, while still allowing the single value 0. Preserve valid
canonical release-tag formats and ensure aliases such as v01.02.003 and
v1.2.3rc01 are rejected.

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:
Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion scripts/verify_ci_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions spec/process/production-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines 355 to +358

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Show the pre-release heading format.

A tag such as v0.2.0rc1 needs a changelog entry for 0.2.0rc1. The shown ## [X.Y.Z] format can imply ## [0.2.0] instead. State that the heading includes the canonical pre-release suffix when present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@spec/process/production-readiness.md` around lines 355 - 358, Update the
changelog guidance to show that the dated heading uses the exact version being
tagged, including its canonical pre-release suffix when present (for example,
v0.2.0rc1 maps to ## [0.2.0rc1] — YYYY-MM-DD).

Source: Coding guidelines

- Refresh benchmark reports or explicitly document why the previous report still
applies.
- Run `make check-full` locally or confirm the equivalent
Expand Down
110 changes: 31 additions & 79 deletions tests/test_check_release_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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) == []