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
78 changes: 78 additions & 0 deletions .github/scripts/test_verify_release_pr_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Unit tests for the release-PR version-forward guard.

Run: python3 .github/scripts/test_verify_release_pr_version.py
(the `-m unittest <path>` form fails here: the leading dot in `.github` is read
as a relative-module reference. Use the path directly, or from the script dir:
cd .github/scripts && python3 -m unittest test_verify_release_pr_version)
"""
import importlib.util
import unittest
from pathlib import Path

# Load the hyphenated module file by path (not importable as a normal name).
_spec = importlib.util.spec_from_file_location(
"verify_release_pr_version",
Path(__file__).with_name("verify-release-pr-version.py"),
)
mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(mod)
compare = mod.compare


class TestCompare(unittest.TestCase):
def test_rc_forward(self):
self.assertEqual(compare("1.0.0-rc.3", "1.0.0-rc.2"), 1) # base>head

def test_rc_backward_is_head_less(self):
# head rc.2 vs base rc.3 -> head is behind
self.assertEqual(compare("1.0.0-rc.2", "1.0.0-rc.3"), -1)

def test_rc_numeric_not_lexical(self):
# rc.10 must sort ABOVE rc.9 (numeric identifiers)
self.assertEqual(compare("1.0.0-rc.10", "1.0.0-rc.9"), 1)

def test_release_beats_prerelease(self):
# 1.0.0 (no prerelease) > 1.0.0-rc.5
self.assertEqual(compare("1.0.0", "1.0.0-rc.5"), 1)

def test_prerelease_below_release(self):
# 1.0.0-rc.2 < 1.0.0 (the post-graduation backward landmine)
self.assertEqual(compare("1.0.0-rc.2", "1.0.0"), -1)

def test_equal(self):
self.assertEqual(compare("1.0.0-rc.3", "1.0.0-rc.3"), 0)

def test_triple_bump(self):
self.assertEqual(compare("1.0.1", "1.0.0"), 1)
self.assertEqual(compare("2.0.0", "1.9.9"), 1)

def test_build_metadata_ignored(self):
self.assertEqual(compare("1.0.0+abc", "1.0.0+xyz"), 0)

def test_numeric_identifier_below_alphanumeric(self):
# semver 2.0: a numeric prerelease identifier has LOWER precedence than
# an alphanumeric one, so 1.0.0-9 < 1.0.0-a.
self.assertEqual(compare("1.0.0-9", "1.0.0-a"), -1)
self.assertEqual(compare("1.0.0-a", "1.0.0-9"), 1)

def test_longer_identifier_list_wins(self):
# semver 2.0: when the shared prefix is equal, the LONGER identifier
# list has higher precedence, so 1.0.0-rc.1.1 > 1.0.0-rc.1.
self.assertEqual(compare("1.0.0-rc.1.1", "1.0.0-rc.1"), 1)
self.assertEqual(compare("1.0.0-rc.1", "1.0.0-rc.1.1"), -1)


class TestForwardRule(unittest.TestCase):
def test_is_forward(self):
# head >= base -> not backward
self.assertTrue(mod.is_forward_or_equal("1.0.0-rc.3", "1.0.0-rc.4"))
self.assertTrue(mod.is_forward_or_equal("1.0.0-rc.3", "1.0.0-rc.3"))
self.assertTrue(mod.is_forward_or_equal("1.0.0-rc.5", "1.0.0"))

def test_is_backward(self):
self.assertFalse(mod.is_forward_or_equal("1.0.0-rc.3", "1.0.0-rc.2"))
self.assertFalse(mod.is_forward_or_equal("1.0.0", "1.0.0-rc.2"))


if __name__ == "__main__":
unittest.main()
114 changes: 114 additions & 0 deletions .github/scripts/verify-release-pr-version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Guard: a release-please PR must not move the manifest version backward.

release-please computes the next release version from the last release it can
find (a GitHub Release, then a tag, then — as a fallback — the manifest with an
empty sha). Under this repo's manual-tag flow (`skip-github-release: true`), the
release-please run that fires on the release-PR *merge* races ahead of the
hand-cut tag: the tag/Release don't exist yet, release-please takes the
full-history fallback, and a stale `Release-As:` footer left in history can
force the next PR *backward* to an already-published version (issue #308: after
`1.0.0-rc.3`, PR #306 proposed `1.0.0-rc.2`). Publishing that would be
unrecoverable — crates.io never frees a version number.

`release-please.yml` now re-runs on `release: published` so the tag is present
before the next PR is computed (the primary fix). This script is the backstop:
it fails the release-please PR whenever the proposed manifest version is behind
`main`, so a regression can't be merged silently. It survives the 1.0.0
graduation: `1.0.0 > 1.0.0-rc.N`, so graduating forward passes, while a stale
`Release-As: 1.0.0-rc.N` footer trying to drag a post-1.0.0 line back fails.

Usage: verify-release-pr-version.py <base-version> <head-version>
Exit 0 if head >= base (forward or unchanged), exit 1 if head < base.
"""

from __future__ import annotations

import re
import sys

# MAJOR.MINOR.PATCH with an optional -prerelease and +build (semver 2.0).
# Build metadata does not affect precedence and is discarded.
_SEMVER = re.compile(
r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
r"(?:-(?P<pre>[0-9A-Za-z.-]+))?"
r"(?:\+[0-9A-Za-z.-]+)?$"
)


def _parse(v: str) -> tuple[int, int, int, tuple[object, ...] | None]:
m = _SEMVER.match(v.strip())
if not m:
raise ValueError(f"not a semver string: {v!r}")
pre = m.group("pre")
if pre is None:
pre_ids: tuple[object, ...] | None = None # no prerelease sorts highest
else:
pre_ids = tuple(
int(part) if part.isdigit() else part for part in pre.split(".")
)
return (int(m.group("major")), int(m.group("minor")), int(m.group("patch")), pre_ids)


def _cmp_pre(a: tuple[object, ...] | None, b: tuple[object, ...] | None) -> int:
# A version WITHOUT a prerelease has higher precedence than one WITH.
if a is None and b is None:
return 0
if a is None:
return 1
if b is None:
return -1
for x, y in zip(a, b):
xi, yi = isinstance(x, int), isinstance(y, int)
if xi and yi:
if x != y:
return 1 if x > y else -1 # numeric compare
elif xi != yi:
return -1 if xi else 1 # numeric identifiers < alphanumeric
else:
if x != y:
return 1 if x > y else -1 # ASCII lexical
# All shared identifiers equal: the longer set has higher precedence.
if len(a) != len(b):
return 1 if len(a) > len(b) else -1
return 0


def compare(a: str, b: str) -> int:
"""Return -1/0/1 for a<b / a==b / a>b per semver 2.0 precedence."""
pa, pb = _parse(a), _parse(b)
if pa[:3] != pb[:3]:
return 1 if pa[:3] > pb[:3] else -1
return _cmp_pre(pa[3], pb[3])


def is_forward_or_equal(base: str, head: str) -> bool:
"""True when head is not behind base (head >= base)."""
return compare(head, base) >= 0


def main(argv: list[str]) -> int:
if len(argv) != 3:
print(f"usage: {argv[0]} <base-version> <head-version>", file=sys.stderr)
return 2
base, head = argv[1], argv[2]
try:
forward = is_forward_or_equal(base, head)
except ValueError as exc:
print(f"::error::release-PR version guard could not parse a version — {exc}")
return 2
if forward:
print(f"ok: proposed version {head} is not behind main ({base}).")
return 0
print(
f"::error::release-please proposed a BACKWARD version: PR wants {head}, "
f"main is already at {base}. This is the #308 regression — do NOT merge. "
"Re-run release-please after the previous release's tag exists "
"(it re-runs automatically on `release: published`), or add a "
f"`Release-As:` footer pinning the correct forward version."
)
return 1


if __name__ == "__main__":
sys.exit(main(sys.argv))
13 changes: 13 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ name: release-please
# NOT tag, because `skip-github-release` is true in release-please-config.json.
# A maintainer creates the v{X.Y.Z} tag and GitHub Release by hand afterwards,
# which is what triggers the publish workflows. See docs/GITHUB_OPERATIONS.md.
# That same `release: published` event also re-runs this workflow so it can
# anchor on the freshly created tag and correct the next release PR (see #308).
#
# Uses a fine-grained PAT (RELEASE_PLEASE_TOKEN) instead of the default
# GITHUB_TOKEN so that PRs and tags created by this workflow trigger
Expand Down Expand Up @@ -40,6 +42,17 @@ name: release-please
on:
push:
branches: [main]
# #308: the run that fires on the release-PR *merge* races ahead of the
# hand-cut tag (skip-github-release: true), so release-please can't see the
# just-released version and miscomputes the next rc backward. Re-running when
# the maintainer publishes the Release — the tag now exists — makes it anchor
# on that version and regenerate the next release PR correctly. The manual-tag
# runbook promotes the release PR's `autorelease: tagged` label BEFORE
# `gh release create` so this re-run doesn't hit the outstanding-pending-PR
# abort (see docs/GITHUB_OPERATIONS.md). The verify-release-pr-version guard
# backstops any residual lag.
release:
types: [published]
workflow_dispatch: {}

# Serialize runs so the lockfile-sync push doesn't race a concurrent
Expand Down
55 changes: 55 additions & 0 deletions .github/workflows/verify-release-pr-version.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: verify-release-pr-version

# Backstop for issue #308: a release-please PR must never move the manifest
# version backward (e.g. propose 1.0.0-rc.2 after 1.0.0-rc.3 shipped). The
# primary fix is the `release: published` re-trigger in release-please.yml;
# this guard fails loudly and pre-merge if a regression slips through anyway.
# See docs/GITHUB_OPERATIONS.md and .github/scripts/verify-release-pr-version.py.
on:
pull_request:
branches: [main]

concurrency:
# PR-only today, so this expression is effectively `true`: cancel a superseded
# run when a new commit is pushed to the same PR. Kept as an expression so that
# if a non-PR trigger is ever added, those runs won't cancel-in-progress.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
version-forward:
# The job ALWAYS runs so it reports a definite success on every PR — safe to
# mark as a required status check. The comparator's own unit tests run on
# every PR (cheap, stdlib-only); the manifest comparison is gated to the
# release-please PR, the only PR that carries a version bump worth guarding.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the compare step reads origin/main via `git show`

# Guard the guard: run the comparator's regression suite on every PR so a
# change to verify-release-pr-version.py can't silently break the semver
# logic and only surface at the high-stakes moment of a real release.
- name: Run comparator unit tests
run: python3 .github/scripts/test_verify_release_pr_version.py

- name: Compare proposed manifest version against main
if: startsWith(github.head_ref, 'release-please--branches--')
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
git fetch --no-tags origin "$BASE_REF"
# `."."` reads the single "." release-please package key — valid only
# because this repo uses one package with include-component-in-tag:
# false. A migration to component-based manifests would return null and
# fail every release PR closed; update this read if that ever changes.
BASE=$(git show "origin/${BASE_REF}:.release-please-manifest.json" | jq -r '."."')
HEAD=$(jq -r '."."' .release-please-manifest.json)
echo "main manifest: $BASE"
echo "release-PR manifest: $HEAD"
python3 .github/scripts/verify-release-pr-version.py "$BASE" "$HEAD"
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,8 +488,10 @@ by release-please**:
runs on every push to `main` and opens (or updates) a
`chore(main): release X.Y.Z` PR, driven by
[`release-please-config.json`](release-please-config.json) and
[`.release-please-manifest.json`](.release-please-manifest.json). Never
hand-edit a crate version or the root `CHANGELOG.md`. See
[`.release-please-manifest.json`](.release-please-manifest.json). It also
re-runs on the `release: published` event a hand-cut tag emits, so cutting the
tag re-anchors the next `-rc.N` correctly (#308). Never hand-edit a crate
version or the root `CHANGELOG.md`. See
[CONTRIBUTING.md](CONTRIBUTING.md#release-process) for the full flow and
[docs/GITHUB_OPERATIONS.md](docs/GITHUB_OPERATIONS.md#cutting-a-release) for the
maintainer steps.
Expand Down
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,10 @@ Summary:
flag required for `-rc.N` tags.
4. **The publish workflows fire on their own** from the `release: published`
event that `gh release create` emits — creating the Release *is* the
publish trigger. `gh workflow run release.yml -f tag=vX.Y.Z` is for
re-running a failed publish against an existing tag, not part of the
normal path.
publish trigger. That same event also re-runs release-please, which
re-anchors the next `-rc.N` on the freshly cut tag (#308).
`gh workflow run release.yml -f tag=vX.Y.Z` is for re-running a failed
publish against an existing tag, not part of the normal path.
5. Roll over the per-crate `CHANGELOG.md` files. release-please does not
manage them, so nothing else will — see
[`docs/GITHUB_OPERATIONS.md`](docs/GITHUB_OPERATIONS.md#rolling-over-the-per-crate-changelogs).
Expand Down
Loading