Files
herdr/scripts/release.py
T

211 lines
9.5 KiB
Python

"""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 (not entry and path in RELEASE_FILES) or (entry and not entry.startswith("100644 blob ")):
raise ValueError(f"release preparation must preserve 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