Files
craft-common-actions/scripts/release_train.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

103 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""Release many Craft apps at once: scripts/cut_release.py for each, in parallel.
scripts/release_train.py storytold/pdfcraft storytold/wordcraft ... # next minor each
scripts/release_train.py --all --skip figcraft,soundcraft --publish
scripts/release_train.py --all --dry-run
Each app gets its own log (--logs, default ./release-train-logs/<app>.log) and the train ends with
one line per app: published, pushed, or stopped and why. Apps don't wait for each other, and one
app stopping doesn't stop the rest. Options after `--` go to every cut_release.py run.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import re
import subprocess
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
# Every Craft app with a release.yml calling the shared workflow.
APPS = [
"anicraft", "cadcraft", "deckcraft", "designcraft", "effectcraft", "figcraft", "filmcraft", "gridcraft",
"lightcraft", "materialcraft", "pdfcraft", "photocraft", "soundcraft", "texturecraft", "vectorcraft",
"wordcraft",
]
REPO = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
# cut_release.py's options a train may pass on, and the values they may take.
FLAGS = {"--publish", "--dry-run", "--no-sync", "--allow-fewer-assets"}
VALUED = {"--part": re.compile(r"^(minor|patch)$"), "--gate-workflow": re.compile(r"^[A-Za-z0-9 ._()-]+$")}
def check(repos: list[str], extra: list[str]) -> None:
"""Only owner/name repos and known cut_release.py options reach the subprocess."""
for repo in repos:
if not REPO.match(repo):
raise SystemExit(f"{repo!r} is not owner/name")
i = 0
while i < len(extra):
opt = extra[i]
if opt in FLAGS:
i += 1
elif opt in VALUED and i + 1 < len(extra) and VALUED[opt].match(extra[i + 1]):
i += 2
else:
raise SystemExit(f"{opt!r} is not an option a train passes to cut_release.py")
def run_app(repo: str, extra: list[str], logs: Path) -> tuple[str, int, str]:
log = logs / f"{repo.split('/')[-1]}.log"
with log.open("w", encoding="utf-8") as out:
rc = subprocess.run([sys.executable, str(HERE / "cut_release.py"), repo, *extra],
stdout=out, stderr=subprocess.STDOUT).returncode
lines = [l for l in log.read_text(encoding="utf-8").splitlines() if l.strip()]
return repo, rc, lines[-1] if lines else "(no output)"
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
extra = argv[argv.index("--") + 1:] if "--" in argv else []
argv = argv[:argv.index("--")] if "--" in argv else argv
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
p.add_argument("repos", nargs="*", help="owner/name of each app")
p.add_argument("--all", action="store_true", help="every app in the list in this script")
p.add_argument("--skip", default="", help="comma-separated app names to leave out")
p.add_argument("--owner", default="storytold")
p.add_argument("--publish", action="store_true", help="pass --publish: wait and publish each draft")
p.add_argument("--dry-run", action="store_true", help="pass --dry-run: push nothing")
p.add_argument("--logs", default="release-train-logs")
a = p.parse_args(argv)
skip = {s.strip() for s in a.skip.split(",") if s.strip()}
repos = a.repos + ([f"{a.owner}/{app}" for app in APPS] if a.all else [])
repos = [r for r in dict.fromkeys(repos) if r.split("/")[-1] not in skip]
if not repos:
p.error("name the apps, or pass --all")
extra += ["--publish"] * a.publish + ["--dry-run"] * a.dry_run
check(repos, extra)
logs = Path(a.logs)
logs.mkdir(parents=True, exist_ok=True)
start = time.time()
print(f"releasing {len(repos)} apps; logs in {logs}/", flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=len(repos)) as pool:
futures = [pool.submit(run_app, r, extra, logs) for r in repos]
results = []
for f in concurrent.futures.as_completed(futures):
repo, rc, last = f.result()
results.append((repo, rc, last))
print(f"{'ok ' if rc == 0 else 'STOP'} {repo}: {last}", flush=True)
stopped = [r for r in results if r[1] != 0]
print(f"\n{len(results) - len(stopped)} of {len(results)} done in {(time.time() - start) / 60:.0f} min"
+ (f"; stopped: {', '.join(r[0].split('/')[-1] for r in stopped)}" if stopped else ""))
return 1 if stopped else 0
if __name__ == "__main__":
sys.exit(main())