mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
211 lines
9.5 KiB
Python
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 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
|