From 2af09b403396b7389cc9795f5eb505d8eb59982e Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:43:19 -0500 Subject: [PATCH] fix(release): check auto-release ordering with the shared SemVer guard Auto-release's forward-only step still compared tags with sort -V against local tags, unlike manual releases, which since #200 run the SemVer preflight against origin. Surface unit 63 replaced that step with its own guard script, which collides with #200's script at the same path; and #200's script also verifies version stamps, which do not exist yet at that step (bump-version.py runs after it). Reconcile them: add a --forward-only mode to #200's check-release-version.py that checks only SemVer precedence against origin tags, and run it from auto-release before the bump. Stamps are still verified by release.yml's preflight and recheck. Unit 63's ordering cases (first release, unrelated tags, equal and lower versions, numeric components, the SemVer prerelease sequence, remote failure) move into test-release-version.py, and its lightweight workflow runs those tests when the release workflows or scripts change. Malformed version-shaped tags keep #200's fail-closed behavior. Surface unit 63. Coven-Harness: Claude Code / claude-opus-5-5 --- .github/workflows/auto-release.yml | 35 +++---------- .github/workflows/release-version-tests.yml | 29 +++++++++++ scripts/check-release-version.py | 20 ++++++-- scripts/test-release-version.py | 57 +++++++++++++++++++++ 4 files changed, 110 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/release-version-tests.yml diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 2c69f08c..92665506 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -90,36 +90,15 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} # ── 3. Forward-only enforcement ─────────────────────────────────── - # Two guards: - # a) The exact tag must not already exist (anywhere — local or remote). - # b) `sort -V` must rank the requested tag strictly above the current - # highest `vX.Y.Z` tag. This catches `v0.1.0` after `v0.1.1` has - # shipped, etc. + # The shared release preflight compares SemVer precedence against the + # authoritative origin tags (an equal, existing or lower version fails, + # and so does a malformed version-shaped tag). Stamps are checked by the + # release workflow after bump-version.py, so only ordering runs here. - name: Forward-only check if: steps.marker.outputs.found == 'true' - run: | - set -euo pipefail - TAG="${{ steps.marker.outputs.tag }}" - - git fetch --tags --force --quiet - - if git rev-parse --verify --quiet "refs/tags/$TAG" >/dev/null; then - echo "::error::Tag $TAG already exists — releases are forward-only." - exit 1 - fi - - LATEST="$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -1 || true)" - if [[ -n "$LATEST" ]]; then - HIGHER="$(printf '%s\n%s\n' "$LATEST" "$TAG" | sort -V | tail -1)" - if [[ "$HIGHER" != "$TAG" || "$LATEST" == "$TAG" ]]; then - echo "::error::Requested $TAG is not strictly newer than current latest $LATEST." - echo "::error::Releases are forward-only — bump the requested version." - exit 1 - fi - echo "Forward bump OK: $LATEST → $TAG" - else - echo "No prior tags found — treating as first release." - fi + env: + RELEASE_VERSION: ${{ steps.marker.outputs.tag }} + run: python3 scripts/check-release-version.py --forward-only "$RELEASE_VERSION" # ── 4. Run the bump script ──────────────────────────────────────── - name: Set up Python diff --git a/.github/workflows/release-version-tests.yml b/.github/workflows/release-version-tests.yml new file mode 100644 index 00000000..c5acde62 --- /dev/null +++ b/.github/workflows/release-version-tests.yml @@ -0,0 +1,29 @@ +name: Release version tests + +on: + pull_request: + paths: + - 'scripts/check-release-version.py' + - 'scripts/test-release-version.py' + - 'scripts/bump-version.py' + - '.github/workflows/release.yml' + - '.github/workflows/auto-release.yml' + - '.github/workflows/release-version-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Verify release preflight and forward-only ordering + run: python3 -B scripts/test-release-version.py -v diff --git a/scripts/check-release-version.py b/scripts/check-release-version.py index 8f6427c1..f943ebac 100644 --- a/scripts/check-release-version.py +++ b/scripts/check-release-version.py @@ -27,13 +27,17 @@ def precedence(tag): return (*(int(match[i]) for i in (1, 2, 3)), not bool(match[4]), tuple(identifiers)) -def check_release(root, tag, tags): +def check_forward(tag, tags): requested = precedence(tag) for existing in tags: # Ignore unrelated tags, but fail on malformed version-shaped tags: # silently ignoring one could defeat the forward-only guarantee. if re.match(r'^v[0-9]', existing) and requested <= precedence(existing): raise ValueError(f'{tag} must be strictly newer than {existing}') + + +def check_release(root, tag, tags): + check_forward(tag, tags) version = tag[1:] workspace = tomllib.loads((root / 'src-rust/Cargo.toml').read_text()) versions = {'Cargo workspace': workspace['workspace']['package']['version']} @@ -82,6 +86,10 @@ def check_release(root, tag, tags): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('tag') + # Auto-release checks ordering before it runs bump-version.py, when the + # canonical stamps do not exist yet; it must not verify them. + parser.add_argument('--forward-only', action='store_true', + help='check SemVer ordering against origin tags only') args = parser.parse_args() root = Path(__file__).resolve().parents[1] try: @@ -90,11 +98,17 @@ def main(): check=True, capture_output=True, text=True, timeout=60).stdout tags = {line.split('\t', 1)[1].removeprefix('refs/tags/').removesuffix('^{}') for line in refs.splitlines()} - check_release(root, args.tag, tags) + if args.forward_only: + check_forward(args.tag, tags) + else: + check_release(root, args.tag, tags) except (ValueError, KeyError, OSError, subprocess.SubprocessError) as error: print(f'error: release preflight failed: {error}', file=sys.stderr) return 1 - print(f'{args.tag}: forward version and all canonical stamps verified') + if args.forward_only: + print(f'{args.tag}: forward version verified against origin tags') + else: + print(f'{args.tag}: forward version and all canonical stamps verified') return 0 diff --git a/scripts/test-release-version.py b/scripts/test-release-version.py index 437f0060..6d5246f1 100644 --- a/scripts/test-release-version.py +++ b/scripts/test-release-version.py @@ -105,5 +105,62 @@ def test_doc_drift_rejects(self): CHECK['check_release'](self.root, 'v0.9.0', []) + +class ForwardOnly(unittest.TestCase): + """Auto-release ordering check, run before any stamp exists.""" + + def remote(self, *tags): + import subprocess + refs = ''.join(f'0000000000000000000000000000000000000000\trefs/tags/{t}\n' for t in tags) + return mock.patch('subprocess.run', return_value=subprocess.CompletedProcess( + ['git'], 0, stdout=refs, stderr='')) + + def run_cli(self, *args): + with mock.patch('sys.argv', ['check-release-version.py', *args]), \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + return CHECK['main']() + + def test_first_release_and_unrelated_tags(self): + CHECK['check_forward']('v0.1.0', []) + CHECK['check_forward']('v0.1.0', ['nightly', 'latest']) + + def test_malformed_version_shaped_tags_fail_closed(self): + for tag in ['v1.0', 'v9.0.0-01']: + with self.subTest(tag=tag), self.assertRaises(ValueError): + CHECK['check_forward']('v0.1.0', [tag]) + + def test_stable_order_equal_lower_and_numeric_components(self): + for candidate, accepted in [('v1.9.9', False), ('v1.0.0', False), + ('v1.10.0', True), ('v2.0.0', True)]: + with self.subTest(candidate=candidate): + if accepted: + CHECK['check_forward'](candidate, ['v1.9.9']) + else: + with self.assertRaises(ValueError): + CHECK['check_forward'](candidate, ['v1.9.9']) + + def test_semver_prerelease_sequence(self): + # SemVer 2.0.0 section 11 example, including numeric vs lexical IDs. + sequence = ['alpha', 'alpha.1', 'alpha.beta', 'beta', 'beta.2', 'beta.11', 'rc.1'] + tags = [f'v1.0.0-{suffix}' for suffix in sequence] + ['v1.0.0'] + for previous, candidate in zip(tags, tags[1:]): + with self.subTest(previous=previous, candidate=candidate): + CHECK['check_forward'](candidate, [previous]) + with self.assertRaises(ValueError): + CHECK['check_forward'](previous, [candidate]) + + def test_cli_forward_only_skips_unwritten_stamps(self): + # The checkout is still stamped with the previous version, as it is in + # auto-release before bump-version.py runs. + with self.remote('v0.8.0', 'v0.8.0^{}'): + self.assertEqual(self.run_cli('--forward-only', 'v0.9.0'), 0) + self.assertEqual(self.run_cli('v0.9.0'), 1) + self.assertEqual(self.run_cli('--forward-only', 'v0.8.0'), 1) + + def test_cli_forward_only_remote_failure_rejects(self): + import subprocess + with mock.patch('subprocess.run', side_effect=subprocess.CalledProcessError(128, 'git')): + self.assertEqual(self.run_cli('--forward-only', 'v0.9.0'), 1) + if __name__ == '__main__': unittest.main()