diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3847b90..5c37d9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,20 +71,20 @@ jobs: with: python-version: "3.14" - - name: Rewrite README repo slug for the public PyPI long_description - run: | - # PyPI renders README.md (pyproject `readme = "README.md"`) as the project description and - # fetches it anonymously. The source README points at the PRIVATE source repo - # (MEFORORG/MessageFoundry) so the team's GitHub view shows the private repo's own CI - # badges — but anonymous fetches of a private repo's Actions badge SVG 404 (broken badge - # images), and every body link 404s on click. The public mirror (MEFORORG/MessageFoundry) - # runs the same workflows and is anonymously reachable, so rewrite the slug here (the same - # rewrite the retired scripts/publish/publish.ps1 used to apply) before `python -m build` embeds - # the README. Ephemeral runner edit only — never committed. - sed -i 's#MEFORORG/MessageFoundry#MEFORORG/MessageFoundry#g' README.md - if grep -q 'MEFORORG/MessageFoundry' README.md; then - echo '::error::README slug rewrite left private-repo links'; exit 1 - fi + # REMOVED at the MEFORORG cutover: "Rewrite README repo slug for the public PyPI + # long_description". It rewrote the private source slug to the public mirror's before `build` + # embedded README.md, because PyPI fetches the long_description anonymously and a private repo's + # badge SVGs and body links 404 for anonymous readers. + # + # There is one repo now, and README.md already names it — so there is nothing to rewrite. Worse, + # the step could no longer even fail safely: publish.ps1's slug rewrite had been applied to this + # workflow itself, collapsing BOTH sides of the substitution to the same string. The sed became a + # no-op that replaced the public slug with itself, and the guard immediately after it failed if + # that slug appeared in the README — which it always does, 19 times. So the step failed on EVERY + # tag push. That is exactly what happened: the v0.3.0 tag (2026-07-13) died here, and the repo has + # zero published releases as a result. Same root cause as the release job's inverted `if:` guard — + # mirror-era logic that the slug rewrite mangled and the cutover left behind. + # (tests/test_release_pipeline.py rejects both the step and any self-substituting sed returning.) - name: Build sdist + wheel run: | @@ -118,9 +118,32 @@ jobs: built=$(/tmp/relsmoke/bin/python -c "import messagefoundry; print(messagefoundry.__version__)") echo "built version: $built" # On a tag push, the built version MUST equal the tag (single-source check). + # + # Compared as PEP 440 VERSIONS, not as strings. A pre-release tag has to be spelled with a + # hyphen to match the `v[0-9]+.[0-9]+.[0-9]+-*` trigger above (`v0.3.0rc1` fires nothing), so + # a string compare forced __version__ to carry the non-canonical "0.3.0-rc1" purely to satisfy + # the gate — while hatchling/PyPI normalise it to 0.3.0rc1 everywhere else, meaning the tag, + # the module attribute and the wheel filename could not all be canonical at once. Version() + # normalises both sides, so canonical "0.3.0rc1" in __init__.py matches tag v0.3.0-rc1 and the + # check still fails loudly on a genuine mismatch. if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then want="${GITHUB_REF_NAME#v}" - [ "$built" = "$want" ] || { echo "::error::built version $built != tag $want"; exit 1; } + /tmp/relsmoke/bin/pip install --quiet packaging + /tmp/relsmoke/bin/python - "$built" "$want" <<'PYVER' + import sys + from packaging.version import InvalidVersion, Version + + built, want = sys.argv[1], sys.argv[2] + try: + b, w = Version(built), Version(want) + except InvalidVersion as exc: + raise SystemExit(f"::error::unparseable version ({exc}) — built={built!r} tag={want!r}") + if b != w: + raise SystemExit( + f"::error::built version {built} != tag {want} (normalised {b} != {w})" + ) + print(f"version matches tag: {b}") + PYVER # A released wheel MUST ship the PEP 561 marker so an external consumer's mypy sees the # typed surface. Enforced ONLY on a tag, so the workflow_dispatch dry-run is never blocked # before py.typed (WS-3) merges. This is the CI enforcement of the cross-workstream rule @@ -306,9 +329,30 @@ jobs: run: | built=$(python -c "import glob,re; print(re.search(r'messagefoundry_harness-([^-]+)-', glob.glob('harness-dist/*.whl')[0]).group(1))") echo "harness wheel version: $built" + # PEP 440 comparison, for a STRONGER reason than the engine's copy of this check: `built` is + # read out of the WHEEL FILENAME, which hatchling has already normalised. On tag v0.3.0-rc1 + # the file is messagefoundry_harness-0.3.0rc1-...whl, so built=0.3.0rc1 while want=0.3.0-rc1 + # and a string compare could NEVER match — this job failed on every pre-release tag by + # construction, whatever __version__ said. With PUBLISH_HARNESS=true it also runs after the + # engine has already uploaded, so the failure would land half-published. if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then want="${GITHUB_REF_NAME#v}" - [ "$built" = "$want" ] || { echo "::error::harness wheel version $built != tag $want"; exit 1; } + python -m pip install --quiet packaging + python - "$built" "$want" <<'PYVER' + import sys + from packaging.version import InvalidVersion, Version + + built, want = sys.argv[1], sys.argv[2] + try: + b, w = Version(built), Version(want) + except InvalidVersion as exc: + raise SystemExit(f"::error::unparseable version ({exc}) — built={built!r} tag={want!r}") + if b != w: + raise SystemExit( + f"::error::harness wheel version {built} != tag {want} (normalised {b} != {w})" + ) + print(f"harness version matches tag: {b}") + PYVER fi - name: Attach the harness wheel to the GitHub release diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 5e5e922..7a36684 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -133,7 +133,10 @@ def test_release_load_bearing_canaries_present() -> None: "leak gate exits nonzero": "exit 1", # version single-sourced from the package == the tag "version==tag single-source": 'want="${GITHUB_REF_NAME#v}"', - "version==tag comparison": '[ "$built" = "$want" ]', + # The comparison itself. Was the raw string test `[ "$built" = "$want" ]`; that could not + # accept a canonical pre-release (0.3.0rc1 vs tag v0.3.0-rc1), so it is now a PEP 440 + # Version() compare. The canary tracks the CHECK existing, not how it is spelled. + "version==tag comparison": "from packaging.version import InvalidVersion, Version", # py.typed (WS-3) enforced on a tag push "py.typed enforced on tag": "unzip -l dist/*.whl | grep -q 'messagefoundry/py.typed'", "py.typed only on a tag": 'GITHUB_REF_TYPE:-}" = "tag"', @@ -265,3 +268,41 @@ def test_release_jobs_are_gated_ON_the_source_repo() -> None: ) # The private vault must never be a release target either. assert "wshallwshall" not in rel, "release.yml must not reference the retired private vault" + + +def test_no_self_referential_slug_rewrite_survives() -> None: + """The README slug rewrite is GONE, and no `sed s#X#X#` may come back. + + publish.ps1 rewrote the private slug to the public one across *.yml — including this workflow — + so at the cutover both sides of the substitution collapsed to the same string, leaving a no-op + sed followed by a guard that failed if that string was present. The README names it 19 times, so + the step failed on EVERY tag push: the v0.3.0 tag died there and the repo has no releases. + """ + rel = _release() + assert "- name: Rewrite README repo slug" not in rel, ( + "the mirror-era README slug rewrite is back — there is one repo now, so it rewrites nothing, " + "and its 'left private-repo links' guard then fails on every tag" + ) + assert not re.search(r"sed[^\n]*s([#/|])([^\n#/|]+)\1\2\1", rel), ( + "a self-referential sed (s#X#X#) is present — it cannot transform anything, and paired with " + "a grep guard it fails unconditionally" + ) + + +def test_both_wheel_smokes_compare_versions_not_strings() -> None: + """Tag-vs-built comparison must normalise (PEP 440), in BOTH the engine and harness jobs. + + The trigger only fires on `vX.Y.Z` / `vX.Y.Z-*`, so a pre-release tag must carry a hyphen, while + hatchling and PyPI normalise `0.3.0-rc1` to `0.3.0rc1`. A raw string compare therefore cannot be + satisfied by a canonical version — and in the HARNESS job it can never be satisfied at all, since + its `built` is parsed out of the already-normalised wheel FILENAME. + """ + rel = _release() + assert '[ "$built" = "$want" ]' not in rel, ( + "a raw string compare of tag vs built version is back; it rejects canonical pre-release " + "versions (0.3.0rc1 != 0.3.0-rc1) and blocks every rc tag" + ) + assert rel.count("from packaging.version import") == 2, ( + "both the engine and harness wheel smokes must compare PEP 440 versions — fixing only one " + "moves the failure rather than removing it" + )