Files
craft-common-actions/tests/test_cut_release.py
T
FredApps 840a417e3d Release: scripts/cut_release.py cuts a release from green main without a bump pull request (#207)
* Release: scripts/cut_release.py cuts a release from green main without a bump pull request

On 11 Nov 2026 the release of 14 apps took most of the day. A large part went to version-bump pull
requests that changed only version strings yet waited on the full CI of a pull request (1-2 h of
FreeBSD, sharded SonarCloud coverage), and to rerunning it. The script commits the bump on the
newest main commit whose CI passed, merges it into release (which starts release.yml), opens the
same bump for main as its own pull request, and with --publish publishes the draft once the run
passed and has assets. Tests cover the bump: workspace and path-dependency versions, only the
workspace's own Cargo.lock entries, CRLF files, the repository hook.

* cut_release: review fixes

- A trailing comment after the workspace version stays; the value alone is replaced.
- Cargo.lock: only the workspace members' entries (cargo metadata --no-deps) change, not a local
  path dependency outside the workspace that shares the version.
- Cargo.lock is checked with a resolving cargo metadata --locked, on the bump, on release after
  the merge and on the sync branch; --no-deps never compared it with path dependency versions.
- --publish refuses a draft with fewer assets than the last release (a skipped build job leaves
  the run green), unless --allow-fewer-assets.
- The sync PR never moves main backwards: versions compare as numbers, and a main at or past the
  release gets none.
- A failed auto-merge says so, with the PR to merge by hand.
- Arguments that reach git or gh are checked first (owner/name, version, commit hash, workflow,
  title), so none can be read as an option. git, gh and cargo are required up front.

* Release: scripts/release_train.py releases many apps at once with cut_release.py

Every app in parallel, a log per app, and one summary line each (published, pushed, or stopped
and why); one app stopping doesn't stop the rest. Tested on figcraft and soundcraft with
--dry-run (both done in under a minute).

* tests: release_train options before -- in the test

* release_train: only owner/name repos and known cut_release.py options reach the subprocess
2026-11-11 23:02:43 +01:00

157 lines
6.1 KiB
Python

"""scripts/cut_release.py: the version bump a release commits, without git or gh."""
import importlib.util
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("cut_release", ROOT / "scripts/cut_release.py")
cut_release = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cut_release)
CARGO_TOML = """[workspace]
members = ["crates/*", "apps/demo"]
[workspace.package]
version = "0.18.1"
edition = "2024"
[workspace.dependencies]
demo-geom = { path = "crates/geom", version = "0.18.1" }
demo-io = { path = "crates/io", version = "0.18.1" }
serde = { version = "0.18.1" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "abc" }
"""
CARGO_LOCK = """# This file is automatically @generated by Cargo.
version = 4
[[package]]
name = "demo"
version = "0.18.1"
dependencies = [
"demo-geom",
]
[[package]]
name = "demo-geom"
version = "0.18.1"
[[package]]
name = "serde"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00"
[[package]]
name = "xtask"
version = "0.1.0"
"""
class NextVersion(unittest.TestCase):
def test_minor_and_patch(self):
self.assertEqual(cut_release.next_version("v0.20.0", "minor"), "0.21.0")
self.assertEqual(cut_release.next_version("0.23.1", "minor"), "0.24.0")
self.assertEqual(cut_release.next_version("v0.23.1", "patch"), "0.23.2")
def test_a_prerelease_patch_is_the_release_itself(self):
self.assertEqual(cut_release.next_version("v0.3.0-rc.1", "patch"), "0.3.0")
def test_rejects_what_is_not_a_version(self):
with self.assertRaises(ValueError):
cut_release.next_version("nightly", "minor")
class Versions(unittest.TestCase):
def test_compared_as_numbers_with_prereleases_first(self):
key = cut_release.version_key
self.assertGreater(key("0.10.0"), key("0.9.0"))
self.assertGreater(key("v0.22.0"), key("0.21.0"))
self.assertLess(key("0.21.0-rc.1"), key("0.21.0"))
self.assertEqual(key("0.21.0"), key("v0.21.0"))
class Arguments(unittest.TestCase):
def test_what_could_reach_git_or_gh_as_an_option_is_refused(self):
ok = dict(repo="storytold/pdfcraft", version="0.21.0", commit="5ff567a4a2", workflow="CI", title="PdfCraft")
cut_release.check_args(**ok)
for field, bad in [("repo", "--upload-pack=x"), ("repo", "storytold"), ("version", "--force"),
("version", "0.21"), ("commit", "--output=/tmp/x"), ("commit", "HEAD~1"),
("workflow", "-x"), ("title", "-m evil"), ("title", "two\nlines")]:
with self.assertRaises(SystemExit, msg=f"{field}={bad!r}"):
cut_release.check_args(**{**ok, field: bad})
class Bump(unittest.TestCase):
def repo(self, toml=CARGO_TOML, lock=CARGO_LOCK, crlf=False):
d = Path(tempfile.mkdtemp())
nl = "\r\n" if crlf else "\n"
(d / "Cargo.toml").write_bytes(toml.replace("\n", nl).encode())
(d / "Cargo.lock").write_bytes(lock.replace("\n", nl).encode())
return d
def test_bumps_the_workspace_its_path_dependencies_and_its_own_lock_entries(self):
d = self.repo()
old, lines, packages = cut_release.bump_cargo(d, "0.21.0")
self.assertEqual((old, lines, packages), ("0.18.1", 3, 2))
toml = (d / "Cargo.toml").read_text()
self.assertIn('[workspace.package]\nversion = "0.21.0"', toml)
self.assertIn('demo-geom = { path = "crates/geom", version = "0.21.0" }', toml)
# A registry dependency that happens to share the version is not the workspace's.
self.assertIn('serde = { version = "0.18.1" }', toml)
lock = (d / "Cargo.lock").read_text()
self.assertIn('name = "demo"\nversion = "0.21.0"', lock)
self.assertIn('name = "demo-geom"\nversion = "0.21.0"', lock)
self.assertIn('name = "serde"\nversion = "0.18.1"\nsource', lock)
self.assertIn('name = "xtask"\nversion = "0.1.0"', lock)
self.assertEqual(cut_release.workspace_version(d), "0.21.0")
def test_keeps_crlf_files_crlf(self):
d = self.repo(crlf=True)
cut_release.bump_cargo(d, "0.21.0")
for name in ("Cargo.toml", "Cargo.lock"):
raw = (d / name).read_bytes()
self.assertNotIn(b"\n", raw.replace(b"\r\n", b""), name)
def test_refuses_a_bump_to_the_same_version_or_a_bad_one(self):
d = self.repo()
with self.assertRaises(ValueError):
cut_release.bump_cargo(d, "0.18.1")
with self.assertRaises(ValueError):
cut_release.bump_cargo(d, "0.21")
def test_runs_the_repositorys_own_hook_with_old_and_new(self):
d = self.repo()
(d / "packaging").mkdir()
(d / cut_release.HOOK).write_text("#!/bin/bash\n")
calls = []
old = cut_release.bump(d, "0.21.0", run=lambda cmd, **kw: calls.append((cmd, kw["cwd"])))
self.assertEqual(old, "0.18.1")
self.assertEqual(calls, [(["bash", cut_release.HOOK, "0.18.1", "0.21.0"], d)])
def test_a_comment_after_the_workspace_version_stays(self):
d = self.repo(toml=CARGO_TOML.replace('version = "0.18.1"\nedition', 'version = "0.18.1" # the release\nedition'))
cut_release.bump_cargo(d, "0.21.0")
self.assertIn('version = "0.21.0" # the release', (d / "Cargo.toml").read_text())
self.assertEqual(cut_release.workspace_version(d), "0.21.0")
def test_given_the_members_only_their_lock_entries_change(self):
# A local path dependency outside the workspace keeps its version even when it matches.
d = self.repo()
_, _, packages = cut_release.bump_cargo(d, "0.21.0", members={"demo"})
lock = (d / "Cargo.lock").read_text()
self.assertEqual(packages, 1)
self.assertIn('name = "demo"\nversion = "0.21.0"', lock)
self.assertIn('name = "demo-geom"\nversion = "0.18.1"', lock)
def test_no_hook_no_call(self):
d = self.repo()
calls = []
cut_release.bump(d, "0.21.0", run=lambda *a, **kw: calls.append(a))
self.assertEqual(calls, [])
if __name__ == "__main__":
unittest.main()