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
39 changes: 29 additions & 10 deletions scripts/ci/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,30 @@
from common import PIN, ROOT, cargo, entrypoint, fail, get_json, metadata, publish_order, run


def release_notes(package):
"""Release body for one crate: its own changelog section, or a stated fallback.

A workspace release bumps every crate, but release-plz only writes a section
for the crates whose own sources changed. Failing on the others used to abort
the publish step *after* crates.io uploads had succeeded, leaving the release
half-tagged (alpha.4: 12 crates published, 1 tag created). The requirement is
kept where it can still block a release - preflight, before any upload - and a
version-only bump gets an explicit body instead of stopping a live publish.
"""
tag = package['name'] + '-v' + package['version']
changelog = Path(package['manifest_path']).parent / 'CHANGELOG.md'
if not changelog.is_file():
fail(f'Missing release-plz changelog for {tag}')
lines = changelog.read_text().splitlines(keepends=True)
start = next((i for i, line in enumerate(lines)
if line.startswith('## ') and package['version'] in line), None)
if start is None:
return (f'{package["name"]} {package["version"]}\n\n'
'No crate-specific changes; released with the workspace.\n')
end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith('## ')), len(lines))
return ''.join(lines[start:end])


def semver_key(number):
"""SemVer 2.0.0 precedence; build metadata is ignored."""
core, _, pre = number.split('+', 1)[0].partition('-')
Expand Down Expand Up @@ -126,6 +150,9 @@ def main():
fail(f'{package["name"]}: no earlier published version for semver-checks')
run(cargo(PIN) + ['semver-checks', '--manifest-path', package['manifest_path'],
*baseline_source(package['name'], baseline), '--all-features'], cwd=data['workspace_root'])
for package in packages:
if package['name'] in pending:
release_notes(package) # fail here, not after uploading to crates.io
# One invocation checks EACH crate and stages unpublished siblings in a
# temporary registry. Separate invocations fail for new dependency versions.
command = cargo(PIN) + ['publish', '--registry', 'crates-io', '--locked', '--dry-run', '--manifest-path', str(Path(args.manifest_path).resolve())]
Expand Down Expand Up @@ -163,17 +190,9 @@ def main():
fail(f'{package["name"]}: uploaded version is not visible in the crates.io API after 120s')
verify_existing(package, version, sha)
tag = package['name'] + '-v' + package['version']
changelog = Path(package['manifest_path']).parent / 'CHANGELOG.md'
if not changelog.is_file():
fail(f'Missing release-plz changelog for {tag}')
# Release notes are the first version section, without unreleased history.
lines = changelog.read_text().splitlines(keepends=True)
start = next((i for i, line in enumerate(lines) if line.startswith('## ') and package['version'] in line), None)
if start is None:
fail(f'No changelog entry for {tag}')
end = next((i for i in range(start + 1, len(lines)) if lines[i].startswith('## ')), len(lines))
notes = ROOT / '.tools/release-notes.md'
notes.write_text(''.join(lines[start:end]))
notes.parent.mkdir(parents=True, exist_ok=True)
notes.write_text(release_notes(package))
run(['gh', 'release', 'create', tag, '--repo', 'PerryTS/turnloop', '--target', sha,
'--title', tag, '--notes-file', str(notes)])

Expand Down
19 changes: 19 additions & 0 deletions scripts/ci/test_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ def test_publish_order_renames_and_cycle(self):
with self.assertRaises(RuntimeError):
publish_order(data)

def test_release_notes_falls_back_for_a_version_only_bump(self):
release = module('release')
with tempfile.TemporaryDirectory() as d:
manifest = Path(d) / 'Cargo.toml'
manifest.write_text('')
package = {'name': 'turnloop-io', 'version': '0.1.0-alpha.4', 'manifest_path': str(manifest)}
# No changelog file at all is still a hard failure.
with self.assertRaises(RuntimeError):
release.release_notes(package)
(Path(d) / 'CHANGELOG.md').write_text(
'# Changelog\n\n## [Unreleased]\n\n## [0.1.0-alpha.3] - 2026-09-15\n\n- something\n')
# A crate with no section for this version gets a stated body, not a failure.
notes = release.release_notes(package)
self.assertIn('released with the workspace', notes)
self.assertIn('0.1.0-alpha.4', notes)
# A crate whose own section exists gets exactly that section.
package['version'] = '0.1.0-alpha.3'
self.assertEqual(release.release_notes(package).strip().splitlines()[-1], '- something')

def test_semver_baseline_prefers_stable_then_earlier_prerelease(self):
release = module('release')
record = lambda *nums, yanked=(): {'versions': [{'num': n, 'yanked': n in yanked} for n in nums]}
Expand Down