mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-15 15:02:24 +00:00
* 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
340 lines
16 KiB
Python
340 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Cut a Craft app release without a version-bump pull request.
|
|
|
|
The release builds from the newest commit on main whose CI passed; the version bump is a commit
|
|
on top of it that goes straight to `release`, whose push starts the app's release.yml. The same
|
|
bump goes back to main as a pull request that nothing waits for, so main carries the released
|
|
version too. With --publish the script waits for release.yml and publishes the draft when every
|
|
job passed and the draft has assets.
|
|
|
|
scripts/cut_release.py storytold/pdfcraft # next minor, from green main
|
|
scripts/cut_release.py storytold/pdfcraft --version 0.22.0-rc.1 --publish
|
|
scripts/cut_release.py storytold/pdfcraft --dry-run # shows what it would push
|
|
|
|
Needs git, an authenticated `gh` whose account may update `release`, and (to check Cargo.lock)
|
|
cargo. Only the Python standard library.
|
|
|
|
What the bump changes, as the release commits before it did: `[workspace.package] version` in
|
|
the root Cargo.toml, `version = "<old>"` of the internal path dependencies there, and Cargo.lock's
|
|
entries for the workspace's own packages. A repository with other files that name the version
|
|
adds `packaging/set-version.sh`, run as `packaging/set-version.sh <old> <new>` after the Cargo edits.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$")
|
|
HOOK = "packaging/set-version.sh"
|
|
|
|
|
|
# ------------------------------------------------------------------------------- the bump itself
|
|
|
|
|
|
def next_version(last: str, part: str) -> str:
|
|
"""The version after `last` (a release tag with or without its v): the next minor or patch."""
|
|
m = SEMVER.match(last.removeprefix("v"))
|
|
if not m:
|
|
raise ValueError(f"{last!r} is not a version like 1.2.3")
|
|
major, minor, patch = int(m[1]), int(m[2]), int(m[3])
|
|
if part == "minor":
|
|
return f"{major}.{minor + 1}.0"
|
|
if part == "patch":
|
|
# A prerelease's patch release is the release itself: 0.3.0-rc.1 -> 0.3.0.
|
|
return f"{major}.{minor}.{patch}" if m[4] else f"{major}.{minor}.{patch + 1}"
|
|
raise ValueError(f"unknown part {part!r}")
|
|
|
|
|
|
def version_key(v: str) -> tuple:
|
|
"""Sort key for versions: numbers compared as numbers, a prerelease before its release."""
|
|
m = SEMVER.match(v.removeprefix("v"))
|
|
if not m:
|
|
raise ValueError(f"{v!r} is not a version like 1.2.3")
|
|
return (int(m[1]), int(m[2]), int(m[3]), 0 if m[4] else 1, m[4] or "")
|
|
|
|
|
|
REPO = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
SHA = re.compile(r"^[0-9a-f]{7,40}$")
|
|
|
|
|
|
def check_args(repo: str, version: str | None, commit: str | None, workflow: str, title: str | None) -> None:
|
|
"""Refuse values that git or gh could read as options, or that break a commit message,
|
|
before any command runs."""
|
|
if not REPO.match(repo):
|
|
raise SystemExit(f"{repo!r} is not owner/name")
|
|
if version is not None and not SEMVER.match(version):
|
|
raise SystemExit(f"{version!r} is not a version like 1.2.3 or 1.2.3-rc.1")
|
|
if commit is not None and not SHA.match(commit):
|
|
raise SystemExit(f"{commit!r} is not a commit hash")
|
|
if not workflow or workflow.startswith("-") or "\n" in workflow:
|
|
raise SystemExit(f"{workflow!r} is not a workflow name")
|
|
if title is not None and (not title.strip() or title.startswith("-") or "\n" in title):
|
|
raise SystemExit(f"{title!r} is not an app title")
|
|
|
|
|
|
def _read(path: Path) -> tuple[str, bool]:
|
|
raw = path.read_bytes().decode("utf-8")
|
|
return raw.replace("\r\n", "\n"), raw.count("\r\n") > raw.count("\n") // 2
|
|
|
|
|
|
def _write(path: Path, text: str, crlf: bool) -> None:
|
|
path.write_bytes((text.replace("\n", "\r\n") if crlf else text).encode("utf-8"))
|
|
|
|
|
|
def workspace_version(root: Path) -> str:
|
|
text, _ = _read(root / "Cargo.toml")
|
|
section = None
|
|
for line in text.split("\n"):
|
|
header = re.match(r"^\[([^\]]+)\]", line)
|
|
if header:
|
|
section = header[1].strip()
|
|
continue
|
|
if section == "workspace.package":
|
|
m = re.match(r'^version\s*=\s*"([^"]+)"', line)
|
|
if m:
|
|
return m[1]
|
|
raise ValueError("Cargo.toml has no [workspace.package] version")
|
|
|
|
|
|
def bump_cargo(root: Path, new: str, members: set[str] | None = None) -> tuple[str, int, int]:
|
|
"""Set the workspace version to `new`. Returns (old version, Cargo.toml lines, lock packages).
|
|
|
|
`members` names the workspace's own packages (`cargo metadata --no-deps`): only their
|
|
Cargo.lock entries change, not a local path dependency's outside the workspace that happens
|
|
to share the version. Without it every source-less entry at the old version counts as one."""
|
|
if not SEMVER.match(new):
|
|
raise ValueError(f"{new!r} is not a version like 1.2.3 or 1.2.3-rc.1")
|
|
old = workspace_version(root)
|
|
if old == new:
|
|
raise ValueError(f"the workspace is already at {new}")
|
|
text, crlf = _read(root / "Cargo.toml")
|
|
lines, section, changed = text.split("\n"), None, 0
|
|
for i, line in enumerate(lines):
|
|
header = re.match(r"^\[([^\]]+)\]", line)
|
|
if header:
|
|
section = header[1].strip()
|
|
continue
|
|
if section == "workspace.package" and re.match(rf'^version\s*=\s*"{re.escape(old)}"', line):
|
|
# Only the value: a trailing comment stays.
|
|
lines[i] = re.sub(rf'^(version\s*=\s*"){re.escape(old)}"', lambda m: f'{m[1]}{new}"', line)
|
|
changed += 1
|
|
elif "path = " in line and f'version = "{old}"' in line:
|
|
lines[i] = line.replace(f'version = "{old}"', f'version = "{new}"')
|
|
changed += 1
|
|
_write(root / "Cargo.toml", "\n".join(lines), crlf)
|
|
|
|
lock_path, packages = root / "Cargo.lock", 0
|
|
if lock_path.exists():
|
|
lock, lcrlf = _read(lock_path)
|
|
blocks = lock.split("\n[[package]]\n")
|
|
for b, block in enumerate(blocks[1:], 1):
|
|
fields = block.split("\n")
|
|
# Registry and git packages carry a source; the workspace's own packages don't.
|
|
if any(f.startswith("source = ") for f in fields):
|
|
continue
|
|
name = next((f[len('name = "'):-1] for f in fields if f.startswith('name = "')), None)
|
|
if members is not None and name not in members:
|
|
continue
|
|
for j, f in enumerate(fields):
|
|
if f == f'version = "{old}"':
|
|
fields[j] = f'version = "{new}"'
|
|
packages += 1
|
|
break
|
|
blocks[b] = "\n".join(fields)
|
|
_write(lock_path, "\n[[package]]\n".join(blocks), lcrlf)
|
|
return old, changed, packages
|
|
|
|
|
|
def workspace_members(root: Path) -> set[str]:
|
|
"""The workspace's own package names."""
|
|
meta = json.loads(sh("cargo", "metadata", "--no-deps", "--format-version", "1", cwd=root))
|
|
return {p["name"] for p in meta["packages"]}
|
|
|
|
|
|
def bump(root: Path, new: str, run=subprocess.run, members: set[str] | None = None) -> str:
|
|
"""The whole bump: Cargo files, then the repository's own hook. Returns the old version."""
|
|
old, _, _ = bump_cargo(root, new, members)
|
|
hook = root / HOOK
|
|
if hook.exists():
|
|
run(["bash", HOOK, old, new], cwd=root, check=True)
|
|
return old
|
|
|
|
|
|
# ------------------------------------------------------------------------------------ git and gh
|
|
|
|
|
|
def sh(*cmd: str, cwd: Path | None = None, capture: bool = True) -> str:
|
|
out = subprocess.run(cmd, cwd=cwd, check=True, text=True, capture_output=capture)
|
|
return (out.stdout or "").strip()
|
|
|
|
|
|
def say(msg: str) -> None:
|
|
print(f"{time.strftime('%H:%M:%S')} {msg}", flush=True)
|
|
|
|
|
|
def gh_json(*args: str):
|
|
return json.loads(sh("gh", *args) or "null")
|
|
|
|
|
|
def last_release(repo: str) -> tuple[str, str]:
|
|
"""(tag, title) of the newest published, non-prerelease release; the title without its version."""
|
|
rows = gh_json("release", "list", "-R", repo, "--exclude-drafts", "--exclude-pre-releases",
|
|
"--limit", "1", "--json", "tagName,name")
|
|
if not rows:
|
|
raise SystemExit(f"{repo} has no published release: pass --version and --title")
|
|
tag, name = rows[0]["tagName"], rows[0]["name"] or ""
|
|
return tag, re.sub(r"\s+v?\d+\.\d+\.\d+\S*$", "", name) or repo.split("/")[-1]
|
|
|
|
|
|
def green_main(repo: str, workflow: str) -> str:
|
|
"""The newest commit on main whose `workflow` run for a push passed."""
|
|
rows = gh_json("run", "list", "-R", repo, "--workflow", workflow, "--branch", "main", "--event", "push",
|
|
"--status", "success", "--limit", "1", "--json", "headSha")
|
|
if not rows:
|
|
raise SystemExit(f"no passing {workflow!r} run on main in {repo}")
|
|
return rows[0]["headSha"]
|
|
|
|
|
|
def cargo_check(root: Path) -> None:
|
|
"""Cargo.lock still matches the manifests. Resolving, without --no-deps: only resolution
|
|
checks the lock file against the path dependencies' versions."""
|
|
sh("cargo", "metadata", "--locked", "--format-version", "1", cwd=root)
|
|
|
|
|
|
def commit(root: Path, message: str) -> str:
|
|
sh("git", "commit", "-q", "-am", message, cwd=root)
|
|
return sh("git", "rev-parse", "HEAD", cwd=root)
|
|
|
|
|
|
def wait_and_publish(repo: str, sha: str, tag: str, previous: str = "", allow_fewer: bool = False) -> None:
|
|
rid = None
|
|
for _ in range(40):
|
|
rows = gh_json("run", "list", "-R", repo, "--workflow", "release.yml", "--branch", "release",
|
|
"--json", "databaseId,headSha", "--limit", "20")
|
|
rid = next((r["databaseId"] for r in rows if r["headSha"] == sha), None)
|
|
if rid:
|
|
break
|
|
time.sleep(15)
|
|
if not rid:
|
|
raise SystemExit(f"no release.yml run started for {sha}")
|
|
say(f"release.yml https://github.com/{repo}/actions/runs/{rid}")
|
|
while gh_json("run", "view", str(rid), "-R", repo, "--json", "status")["status"] != "completed":
|
|
time.sleep(120)
|
|
run = gh_json("run", "view", str(rid), "-R", repo, "--json", "conclusion,jobs")
|
|
if run["conclusion"] != "success":
|
|
bad = [j["name"] for j in run["jobs"] if j["conclusion"] not in ("success", "skipped")]
|
|
raise SystemExit(f"release.yml {run['conclusion']}: {', '.join(bad)}. Rerun those jobs, then publish "
|
|
f"with: gh release edit {tag} -R {repo} --draft=false")
|
|
draft = gh_json("release", "view", tag, "-R", repo, "--json", "isDraft,assets")
|
|
if not draft["isDraft"] or not draft["assets"]:
|
|
raise SystemExit(f"{tag} is not a draft with assets; not publishing")
|
|
# A build job that is skipped (an app turning a platform off, or anything else) leaves the
|
|
# run green, so the draft must carry at least as many assets as the last release.
|
|
if previous:
|
|
before = len(gh_json("release", "view", previous, "-R", repo, "--json", "assets")["assets"])
|
|
if len(draft["assets"]) < before and not allow_fewer:
|
|
raise SystemExit(f"{tag} has {len(draft['assets'])} assets where {previous} had {before}: not publishing. "
|
|
f"Check the run, then publish by hand or rerun with --allow-fewer-assets.")
|
|
sh("gh", "release", "edit", tag, "-R", repo, "--draft=false")
|
|
say(f"published https://github.com/{repo}/releases/tag/{tag} ({len(draft['assets'])} assets)")
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------ main
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
p.add_argument("repo", help="owner/name, e.g. storytold/pdfcraft")
|
|
p.add_argument("--version", help="the version to release (default: next --part of the last release)")
|
|
p.add_argument("--part", choices=["minor", "patch"], default="minor")
|
|
p.add_argument("--gate-workflow", default="CI", help="workflow whose passing push run marks a releasable commit")
|
|
p.add_argument("--commit", help="release this main commit instead of the newest green one")
|
|
p.add_argument("--title", help="the app's name in commit and PR titles (default: from the last release)")
|
|
p.add_argument("--no-sync", action="store_true", help="don't open the pull request that brings the bump to main")
|
|
p.add_argument("--publish", action="store_true", help="wait for release.yml and publish the draft")
|
|
p.add_argument("--allow-fewer-assets", action="store_true",
|
|
help="publish a draft with fewer assets than the last release")
|
|
p.add_argument("--dry-run", action="store_true", help="prepare everything, push nothing")
|
|
a = p.parse_args(argv)
|
|
check_args(a.repo, a.version, a.commit, a.gate_workflow, a.title)
|
|
for tool in ("git", "gh", "cargo"):
|
|
if shutil.which(tool) is None:
|
|
raise SystemExit(f"{tool} is needed")
|
|
|
|
last, title = last_release(a.repo) if not (a.version and a.title) else ("", a.title)
|
|
title = a.title or title
|
|
version = a.version or next_version(last, a.part)
|
|
tag = f"v{version}"
|
|
sha = a.commit or green_main(a.repo, a.gate_workflow)
|
|
say(f"{title} {tag} from main {sha[:10]} (last release {last or '-'})")
|
|
|
|
work = Path(tempfile.mkdtemp(prefix="cut-release-"))
|
|
try:
|
|
sh("git", "clone", "-q", "--filter=blob:none", "--no-checkout", f"https://github.com/{a.repo}", str(work))
|
|
sh("git", "fetch", "-q", "origin", "main", "release", cwd=work)
|
|
if subprocess.run(["git", "merge-base", "--is-ancestor", sha, "origin/main"], cwd=work).returncode:
|
|
raise SystemExit(f"{sha} is not on main")
|
|
sh("git", "checkout", "-q", "--detach", sha, cwd=work)
|
|
old = bump(work, version, members=workspace_members(work))
|
|
cargo_check(work)
|
|
bumped = commit(work, f"Release: {title} {tag}")
|
|
say(f"bumped {old} -> {version}")
|
|
|
|
sh("git", "checkout", "-q", "-B", "release", "origin/release", cwd=work)
|
|
sh("git", "merge", "-q", "--no-edit", "-X", "theirs", "-m", f"Release: {title} {tag} (main {sha[:10]})",
|
|
bumped, cwd=work)
|
|
if workspace_version(work) != version:
|
|
raise SystemExit("release is not at the new version after the merge")
|
|
cargo_check(work)
|
|
left = sh("git", "diff", "--stat", bumped, "HEAD", cwd=work)
|
|
if left:
|
|
say(f"release keeps changes main doesn't have:\n{left}")
|
|
head = sh("git", "rev-parse", "HEAD", cwd=work)
|
|
|
|
sync = None
|
|
if not a.no_sync:
|
|
sh("git", "checkout", "-q", "-B", f"release-sync-{version}", "origin/main", cwd=work)
|
|
on_main = workspace_version(work)
|
|
if version_key(on_main) >= version_key(version):
|
|
say(f"main is at {on_main}, not behind {version}: no sync pull request")
|
|
else:
|
|
bump(work, version, members=workspace_members(work))
|
|
cargo_check(work)
|
|
sync = commit(work, f"Release: {title} {tag} on main")
|
|
|
|
if a.dry_run:
|
|
say(f"dry run: would push release {head[:10]}" + (f" and open a sync PR from {sync[:10]}" if sync else ""))
|
|
return 0
|
|
sh("git", "push", "-q", "origin", "release", cwd=work)
|
|
say(f"pushed release {head[:10]}: release.yml builds the {tag} draft")
|
|
if sync:
|
|
sh("git", "push", "-q", "-f", "origin", f"release-sync-{version}", cwd=work)
|
|
url = sh("gh", "pr", "create", "-R", a.repo, "--base", "main", "--head", f"release-sync-{version}",
|
|
"--title", f"Release: {title} {tag} on main", "--body",
|
|
f"Brings the {tag} version bump to main. The release was cut from {sha} with "
|
|
f"`scripts/cut_release.py` (storytold/craft-common-actions); this changes only version strings.")
|
|
say(f"sync PR {url}")
|
|
merge = subprocess.run(["gh", "pr", "merge", url, "--auto", "--squash"], capture_output=True, text=True)
|
|
if merge.returncode == 0:
|
|
say("auto-merge is on for the sync PR")
|
|
else:
|
|
say(f"WARNING: auto-merge could not be turned on ({merge.stderr.strip()}). Merge {url} by hand "
|
|
f"once its checks pass, or main stays behind the release.")
|
|
if a.publish:
|
|
wait_and_publish(a.repo, head, tag, last, a.allow_fewer_assets)
|
|
return 0
|
|
finally:
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|