diff --git a/scripts/ci/release.py b/scripts/ci/release.py index 1c83963..1a5009c 100755 --- a/scripts/ci/release.py +++ b/scripts/ci/release.py @@ -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('-') @@ -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())] @@ -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)]) diff --git a/scripts/ci/test_gates.py b/scripts/ci/test_gates.py index 95836b2..064b4ba 100644 --- a/scripts/ci/test_gates.py +++ b/scripts/ci/test_gates.py @@ -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]}