aboutsummaryrefslogtreecommitdiff
path: root/scripts/release.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/release.py')
-rw-r--r--scripts/release.py210
1 files changed, 210 insertions, 0 deletions
diff --git a/scripts/release.py b/scripts/release.py
new file mode 100644
index 00000000..b02c89cf
--- /dev/null
+++ b/scripts/release.py
@@ -0,0 +1,210 @@
+"""Validate stable source promotion; never build, tag, push, or publish a release."""
+
+import argparse
+import json
+import re
+import subprocess
+import tomllib
+from pathlib import Path
+
+
+RELEASE_FILES = {
+ "CHANGELOG.md",
+ "docs/next/CHANGELOG.md",
+ "docs/next/README.md",
+ "docs/next/README.zh-CN.md",
+ "docs/next/product-announcement.json",
+ "skills/herdr/SKILL.md",
+}
+ASSETS = {
+ "herdr-linux-x86_64",
+ "herdr-linux-aarch64",
+ "herdr-macos-x86_64",
+ "herdr-macos-aarch64",
+ "herdr-windows-x86_64.zip",
+}
+
+
+def git(*args: str) -> str:
+ return subprocess.check_output(["git", *args], text=True).strip()
+
+
+def version_tuple(version: str) -> tuple[int, ...]:
+ if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
+ raise ValueError(f"invalid stable version: {version}")
+ return tuple(map(int, version.split(".")))
+
+
+def resolve(ref: str) -> str:
+ return git("rev-parse", "--verify", f"{ref}^{{commit}}")
+
+
+def ancestor(base: str, commit: str) -> bool:
+ return subprocess.run(
+ ["git", "merge-base", "--is-ancestor", base, commit], check=False
+ ).returncode == 0
+
+
+def published_preview(tag: str, repo: str) -> str:
+ if not re.fullmatch(r"preview-[A-Za-z0-9.-]+", tag):
+ raise ValueError("select a published preview-… tag")
+ commit = resolve(f"refs/tags/{tag}")
+ payload = json.loads(subprocess.check_output(
+ ["gh", "api", f"repos/{repo}/releases/tags/{tag}"], text=True
+ ))
+ if (
+ payload.get("tag_name") != tag
+ or payload.get("draft") is not False
+ or payload.get("prerelease") is not True
+ or payload.get("immutable") is not True
+ or not ASSETS.issubset({asset["name"] for asset in payload.get("assets", [])})
+ ):
+ raise ValueError(f"{tag} must be an immutable published preview with all five assets")
+ # Resolve the remote tag too: a local tag must not substitute different source.
+ remote = git("ls-remote", f"https://github.com/{repo}.git", f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}")
+ refs = dict(line.split()[::-1] for line in remote.splitlines())
+ remote_commit = refs.get(f"refs/tags/{tag}^{{}}", refs.get(f"refs/tags/{tag}"))
+ if commit != remote_commit:
+ raise ValueError(f"local preview tag {tag} does not match its published commit")
+ return commit
+
+
+def normalized_cargo(text: str, path: str) -> tuple[dict, str]:
+ data = tomllib.loads(text)
+ if path == "Cargo.toml":
+ version = data["package"].pop("version")
+ else:
+ packages = [p for p in data["package"] if p["name"] == "herdr" and "source" not in p]
+ if len(packages) != 1:
+ raise ValueError("expected exactly one local herdr package in Cargo.lock")
+ version = packages[0].pop("version")
+ return data, version
+
+
+def validate_diff(preview: str, candidate: str, version: str | None = None) -> None:
+ if not ancestor(preview, candidate):
+ raise ValueError("release must descend from the selected preview; do not rebase onto master")
+ changed = git("diff", "--no-renames", "--name-only", preview, candidate).splitlines()
+ for path in changed:
+ if path in {"Cargo.toml", "Cargo.lock"}:
+ before, _ = normalized_cargo(git("show", f"{preview}:{path}"), path)
+ after, _ = normalized_cargo(git("show", f"{candidate}:{path}"), path)
+ if before != after:
+ raise ValueError(f"{path}: only the herdr package version may change")
+ elif path not in RELEASE_FILES and not (
+ path.startswith("docs/next/website/src/content/docs/")
+ and path.endswith((".md", ".mdx"))
+ ):
+ raise ValueError(f"unpreviewed change: {path}; publish a new preview first")
+ # Documentation exceptions must not turn into symlinks or submodules.
+ entry = git("ls-tree", candidate, "--", path)
+ if entry and not entry.startswith("100644 blob "):
+ raise ValueError(f"release preparation must use regular files: {path}")
+ versions = [normalized_cargo(git("show", f"{candidate}:{path}"), path)[1]
+ for path in ("Cargo.toml", "Cargo.lock")]
+ if versions[0] != versions[1] or (version is not None and versions[0] != version):
+ raise ValueError("release version must match Cargo.toml and Cargo.lock")
+
+
+def tag_metadata(tag: str) -> tuple[str, str]:
+ if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag):
+ raise ValueError("expected a stable vX.Y.Z tag")
+ if git("cat-file", "-t", f"refs/tags/{tag}") != "tag":
+ raise ValueError("stable releases require an annotated tag with preview provenance")
+ message = git("for-each-ref", "--format=%(contents)", f"refs/tags/{tag}")
+ fields = []
+ for key in ("Preview", "Previous-Stable"):
+ values = re.findall(rf"^{key}: (\S+)$", message, re.MULTILINE)
+ if len(values) != 1:
+ raise ValueError(f"release tag requires exactly one {key}: trailer")
+ fields.append(values[0])
+ if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", fields[1]):
+ raise ValueError("invalid Previous-Stable tag")
+ return fields[0], fields[1]
+
+
+def validate_release(preview_tag: str, candidate: str, version: str, previous: str, repo: str) -> str:
+ preview = published_preview(preview_tag, repo)
+ validate_diff(preview, candidate, version)
+ current = json.loads(git("show", "origin/master:distribution/latest.json"))["version"]
+ if version_tuple(version) <= version_tuple(previous.removeprefix("v")):
+ raise ValueError("stable version must increase from Previous-Stable")
+ if current == version:
+ # A retry after distribution publication must still validate the original boundary.
+ if resolve(f"refs/tags/v{version}") != resolve(candidate):
+ raise ValueError("published stable version points at different source")
+ elif previous != f"v{current}":
+ raise ValueError("Previous-Stable must name the currently published stable release")
+ resolve(f"refs/tags/{previous}")
+ return preview
+
+
+def select_hotfix(branch: str, base: str) -> str:
+ if not re.fullmatch(r"release/[A-Za-z0-9][A-Za-z0-9._-]*", branch):
+ raise ValueError("hotfix previews require an explicit release/* branch")
+ if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", base):
+ raise ValueError("hotfix base must be the current published stable tag")
+ commit = resolve(f"refs/remotes/origin/{branch}")
+ if not ancestor(resolve(f"refs/tags/{base}"), commit):
+ raise ValueError(f"hotfix branch must descend from {base}")
+ files = git("ls-tree", "--name-only", commit, "--", "scripts/release.py")
+ if not files or "scripts/release.py check-tag" not in git("show", f"{commit}:.github/workflows/release.yml"):
+ raise ValueError("hotfix source predates preview promotion; include the promotion tooling before previewing")
+ return commit
+
+
+def select_preview(ref: str) -> str:
+ commit = resolve(ref)
+ workflow = git("show", f"{commit}:.github/workflows/preview.yml")
+ if not re.search(r'(?m)^on:\n push:\n tags:\n - "preview-\*"$', workflow):
+ raise ValueError("preview source predates tag-triggered previews; select a commit with the new publishing workflow")
+ if ancestor(commit, "refs/remotes/origin/master"):
+ return commit
+ base = "v" + json.loads(git("show", "origin/master:distribution/latest.json"))["version"]
+ branches = git("for-each-ref", "--format=%(refname:strip=3)", "refs/remotes/origin/release/")
+ for branch in branches.splitlines():
+ if resolve(f"refs/remotes/origin/{branch}") == commit:
+ return select_hotfix(branch, base)
+ raise ValueError("preview source must be on master or the tip of a published release/* hotfix branch")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest="command", required=True)
+ prepare = commands.add_parser("check-source")
+ prepare.add_argument("--preview", required=True)
+ prepare.add_argument("--commit", default="HEAD")
+ prepare.add_argument("--repo", default="herdrdev/herdr")
+ check = commands.add_parser("check")
+ check.add_argument("--preview", required=True)
+ check.add_argument("--version", required=True)
+ check.add_argument("--previous", required=True)
+ check.add_argument("--commit", default="HEAD")
+ check.add_argument("--repo", default="herdrdev/herdr")
+ tag = commands.add_parser("check-tag")
+ tag.add_argument("--tag", required=True)
+ tag.add_argument("--repo", default="herdrdev/herdr")
+ tag.add_argument("--github-output", type=Path)
+ preview = commands.add_parser("preview-source")
+ preview.add_argument("--commit", default="HEAD")
+ args = parser.parse_args()
+ if args.command == "preview-source":
+ print(select_preview(args.commit))
+ elif args.command == "check-source":
+ validate_diff(published_preview(args.preview, args.repo), args.commit)
+ elif args.command == "check":
+ validate_release(args.preview, args.commit, args.version, args.previous, args.repo)
+ else:
+ preview, previous = tag_metadata(args.tag)
+ commit = validate_release(preview, args.tag, args.tag[1:], previous, args.repo)
+ if args.github_output:
+ with args.github_output.open("a", encoding="utf-8") as output:
+ output.write(f"preview_commit={commit}\nprevious_tag={previous}\n")
+ print(f"{args.tag} promotes {preview}; previous stable: {previous}")
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except (ValueError, subprocess.CalledProcessError, KeyError) as error:
+ raise SystemExit(f"error: {error}") from error