diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 2c69f08..9266550 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 0000000..c5acde6 --- /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 8f6427c..f943eba 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 437f006..6d5246f 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()