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
35 changes: 7 additions & 28 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/release-version-tests.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 17 additions & 3 deletions scripts/check-release-version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}
Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand Down
57 changes: 57 additions & 0 deletions scripts/test-release-version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading