aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-16 18:25:59 +0300
committerGitHub <noreply@github.com>2026-09-16 18:25:59 +0300
commit36db8ff3f4bee21b06f3208422be7cf91c7d3e40 (patch)
tree539cb0114feb45aeb8d483a29cba6e7912f77483 /scripts
parentaff99878d3467e809e18f07fb338c78dea50e558 (diff)
ci: promote stable releases from protected previews (#4241)
Diffstat (limited to 'scripts')
-rw-r--r--scripts/changelog.py6
-rw-r--r--scripts/preview.py114
-rw-r--r--scripts/release-workflows.test.ts64
-rw-r--r--scripts/release.py210
-rw-r--r--scripts/test_changelog.py8
-rw-r--r--scripts/test_preview.py58
-rw-r--r--scripts/test_release.py190
7 files changed, 507 insertions, 143 deletions
diff --git a/scripts/changelog.py b/scripts/changelog.py
index 46c3688d..cc41c4f3 100644
--- a/scripts/changelog.py
+++ b/scripts/changelog.py
@@ -317,6 +317,7 @@ def build_latest_json(
protocol: int | None = None,
announcement: dict[str, str] | None = None,
releases: dict[str, Any] | None = None,
+ endpoint_generation: int | None = None,
) -> str:
normalized_version = normalize_version(version)
normalized_notes = notes.strip()
@@ -330,7 +331,8 @@ def build_latest_json(
ordered_sha256 = normalize_sha256(sha256, "sha256")
normalized_announcement = normalize_announcement(announcement, "root")
archived_releases = normalize_releases(releases)
- endpoint_generation = read_endpoint_protocol_generation()
+ if endpoint_generation is None:
+ endpoint_generation = read_endpoint_protocol_generation()
current_metadata: dict[str, Any] = {
"notes": normalized_notes,
"protocol": protocol,
@@ -714,6 +716,7 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int:
protocol=int(new_manifest["protocol"]),
announcement=announcement,
releases=archived_releases_from_current_manifest(current_manifest),
+ endpoint_generation=args.endpoint_generation,
)
write_text(manifest_path, output)
if announcement is not None:
@@ -808,6 +811,7 @@ def build_parser() -> argparse.ArgumentParser:
sync_latest_json.add_argument("--output", default=str(DEFAULT_LATEST_JSON_PATH))
sync_latest_json.add_argument("--announcement", default=str(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH))
sync_latest_json.add_argument("--protocol", type=int)
+ sync_latest_json.add_argument("--endpoint-generation", type=int)
sync_latest_json.set_defaults(func=cmd_sync_latest_json)
validate_product_announcement = subparsers.add_parser(
diff --git a/scripts/preview.py b/scripts/preview.py
index 935ee9b7..feb062cb 100644
--- a/scripts/preview.py
+++ b/scripts/preview.py
@@ -19,25 +19,6 @@ EXPECTED_ASSET_NAMES = {
**{target: f"herdr-{target}" for target in ASSET_TARGETS},
"windows-x86_64": "herdr-windows-x86_64.zip",
}
-HIDDEN_SUBJECTS = (
- "docs: publish release distribution",
- "docs: update website manifest",
- "docs: update preview manifest",
- "chore: approve contributor",
- "chore: approve merged contributor",
-)
-TYPE_HEADINGS = {
- "feat": "Added",
- "fix": "Fixed",
- "perf": "Performance",
- "docs": "Maintenance",
- "ci": "Maintenance",
- "test": "Maintenance",
- "refactor": "Maintenance",
- "chore": "Maintenance",
-}
-TYPE_ORDER = ("Added", "Fixed", "Performance", "Maintenance", "Other")
-COMMIT_RE = re.compile(r"^(?P<kind>[a-z]+)(?:\([^)]+\))?!?:\s+(?P<body>.+)$")
ENDPOINT_PROTOCOL_SOURCE_PATH = Path("src/protocol/endpoint.rs")
@@ -92,90 +73,21 @@ def previous_preview_commit(path: Path) -> str | None:
return commit if isinstance(commit, str) and commit.strip() else None
-def hidden_subject(subject: str) -> bool:
- lowered = subject.strip().lower()
- return any(lowered.startswith(prefix) for prefix in HIDDEN_SUBJECTS)
-
-
-def latest_publishable_commit(ref: str) -> str:
- output = run_git(["log", "--pretty=format:%H%x00%s", ref])
- for line in output.splitlines():
- commit, _, subject = line.partition("\x00")
- if commit and not hidden_subject(subject):
- return commit
- raise SystemExit(f"no publishable commit found in {ref}")
-
-
-def commit_subjects(previous: str, commit: str) -> list[str]:
- output = run_git(["log", "--pretty=format:%s", f"{previous}..{commit}"])
- if not output:
- return []
- subjects = []
- for line in output.splitlines():
- stripped = line.strip()
- if not stripped:
- continue
- if hidden_subject(stripped):
- continue
- subjects.append(stripped)
- return subjects
-
-
def preview_range_base(previous: str, commit: str) -> str:
try:
stable = latest_stable_tag(commit)
except subprocess.CalledProcessError:
return previous
+ if not git_is_ancestor(previous, commit):
+ return stable
if git_is_ancestor(previous, stable) and git_is_ancestor(stable, commit):
return stable
return previous
-def humanize_subject(subject: str) -> tuple[str, str]:
- match = COMMIT_RE.match(subject)
- if not match:
- return "Other", subject[0].upper() + subject[1:]
- kind = match.group("kind")
- body = match.group("body").strip()
- heading = TYPE_HEADINGS.get(kind, "Other")
- if body:
- body = body[0].upper() + body[1:]
- else:
- body = subject
- return heading, body
-
-
-def build_notes(previous: str, commit: str, build_id: str, base_version: str, repo: str) -> str:
- short = commit[:12]
+def build_notes(previous: str, commit: str, build_id: str, repo: str) -> str:
compare = f"https://github.com/{repo}/compare/{previous}...{commit}"
- lines = [
- f"Preview build {build_id}",
- "",
- f"Built from `{short}` on `master`.",
- f"Base stable: v{normalize_version(base_version)}",
- f"Compare: {compare}",
- "",
- ]
- grouped: dict[str, list[str]] = {heading: [] for heading in TYPE_ORDER}
- for subject in commit_subjects(previous, commit):
- heading, body = humanize_subject(subject)
- grouped.setdefault(heading, []).append(body)
-
- wrote = False
- for heading in TYPE_ORDER:
- items = grouped.get(heading, [])
- if not items:
- continue
- wrote = True
- lines.append(f"### {heading}")
- for item in items:
- lines.append(f"- {item}")
- lines.append("")
-
- if not wrote:
- lines.extend(["### Changed", "- Rebuilt preview from the current master branch.", ""])
-
- return "\n".join(lines).rstrip() + "\n"
+ return f"Preview build {build_id}\n\n[View changes]({compare})\n"
def default_asset_urls(repo: str, tag: str) -> dict[str, str]:
@@ -222,13 +134,15 @@ def build_manifest(
notes: str,
shas: dict[str, str],
retain: int,
+ endpoint_generation: int | None = None,
) -> str:
urls = default_asset_urls(repo, tag)
assets = asset_objects(urls, shas)
current = read_json(output) or {}
builds = current.get("builds") if isinstance(current.get("builds"), dict) else {}
builds = dict(builds)
- endpoint_generation = read_endpoint_protocol_generation()
+ if endpoint_generation is None:
+ endpoint_generation = read_endpoint_protocol_generation()
builds[build_id] = {
"base_version": normalize_version(base_version),
"commit": commit,
@@ -264,7 +178,7 @@ def build_manifest(
def cmd_notes(args: argparse.Namespace) -> int:
previous = args.previous or previous_preview_commit(Path(args.manifest)) or latest_stable_tag()
- notes = build_notes(previous, args.commit, args.build_id, args.base_version, args.repo)
+ notes = build_notes(previous, args.commit, args.build_id, args.repo)
Path(args.output).write_text(notes, encoding="utf-8")
return 0
@@ -284,6 +198,7 @@ def cmd_manifest(args: argparse.Namespace) -> int:
notes=notes,
shas=shas,
retain=args.retain,
+ endpoint_generation=args.endpoint_generation,
)
Path(args.output).write_text(content, encoding="utf-8")
return 0
@@ -296,11 +211,6 @@ def cmd_current_commit(args: argparse.Namespace) -> int:
return 0
-def cmd_select_commit(args: argparse.Namespace) -> int:
- print(latest_publishable_commit(args.ref))
- return 0
-
-
def cmd_range_base(args: argparse.Namespace) -> int:
print(preview_range_base(args.previous, args.commit))
return 0
@@ -315,7 +225,6 @@ def main() -> int:
notes.add_argument("--previous")
notes.add_argument("--commit", required=True)
notes.add_argument("--build-id", required=True)
- notes.add_argument("--base-version", required=True)
notes.add_argument("--repo", default="herdrdev/herdr")
notes.add_argument("--output", required=True)
notes.set_defaults(func=cmd_notes)
@@ -329,6 +238,7 @@ def main() -> int:
manifest.add_argument("--built-at", required=True)
manifest.add_argument("--base-version", required=True)
manifest.add_argument("--protocol", required=True, type=int)
+ manifest.add_argument("--endpoint-generation", required=True, type=int)
manifest.add_argument("--notes", required=True)
manifest.add_argument("--sha-file")
manifest.add_argument("--retain", type=int, default=30)
@@ -338,10 +248,6 @@ def main() -> int:
current.add_argument("--manifest", default="distribution/preview.json")
current.set_defaults(func=cmd_current_commit)
- select = sub.add_parser("select-commit")
- select.add_argument("--ref", default="origin/master")
- select.set_defaults(func=cmd_select_commit)
-
range_base = sub.add_parser("range-base")
range_base.add_argument("--previous", required=True)
range_base.add_argument("--commit", required=True)
diff --git a/scripts/release-workflows.test.ts b/scripts/release-workflows.test.ts
new file mode 100644
index 00000000..a169204f
--- /dev/null
+++ b/scripts/release-workflows.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, test } from "bun:test";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { spawnSync } from "node:child_process";
+import { join } from "node:path";
+
+const load = (name: string): any =>
+ Bun.YAML.parse(readFileSync(new URL(`../.github/workflows/${name}.yml`, import.meta.url), "utf8"));
+const preview = load("preview");
+const release = load("release");
+const adminGate = release.jobs["validate-release-source"].steps[0];
+
+describe("official publishing workflow boundaries", () => {
+ test("publishing is tag-only while normal PR CI remains enabled", () => {
+ expect(preview.on).toEqual({ push: { tags: ["preview-*"] } });
+ expect(release.on).toEqual({ push: { tags: ["v*"] } });
+ expect(load("ci").on.pull_request).toBeDefined();
+ });
+
+ test("each publishing job rechecks both actors before using credentials", () => {
+ for (const [workflow, names] of [
+ [preview, ["preflight", "publish"]],
+ [release, ["validate-release-source", "release", "update-nix-package", "close-released-issues", "update-latest-json"]],
+ ] as const) {
+ for (const name of names) {
+ const job = workflow.jobs[name];
+ expect(job.if).toContain("github.event_name == 'push'");
+ expect(job.if).toContain("startsWith(github.ref, 'refs/tags/");
+ expect(job.steps[0]).toEqual(adminGate);
+ }
+ }
+ expect(adminGate.run).toContain('"$GITHUB_ACTOR" "$GITHUB_TRIGGERING_ACTOR"');
+ expect(adminGate.env.GH_TOKEN).toBe("${{ github.token }}");
+ expect(adminGate.run).not.toContain("ogulcancelik");
+ });
+
+ test.skipIf(process.platform === "win32")("admin gate permits admins and fails closed for other roles or API errors", () => {
+ const dir = mkdtempSync("/var/tmp/herdr-admin-gate-");
+ try {
+ writeFileSync(join(dir, "gh"), `#!/bin/sh
+case "$2" in
+ */collaborators/admin-*/permission) echo admin ;;
+ */collaborators/maintainer/permission) echo maintain ;;
+ */collaborators/writer/permission) echo write ;;
+ *) exit 1 ;;
+esac
+`, { mode: 0o755 });
+ for (const [actor, trigger, succeeds] of [
+ ["admin-one", "admin-two", true],
+ ["writer", "admin-two", false],
+ ["admin-one", "writer", false],
+ ["admin-one", "maintainer", false],
+ ["admin-one", "api-error", false],
+ ] as const) {
+ const result = spawnSync("bash", ["-c", adminGate.run], {
+ env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, GITHUB_REPOSITORY: "example/test", GITHUB_ACTOR: actor, GITHUB_TRIGGERING_ACTOR: trigger },
+ encoding: "utf8",
+ });
+ expect(result.status === 0).toBe(succeeds);
+ }
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
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
diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py
index f08088a3..07897c25 100644
--- a/scripts/test_changelog.py
+++ b/scripts/test_changelog.py
@@ -102,6 +102,14 @@ class ChangelogScriptTests(unittest.TestCase):
self.assertEqual(manifest["protocol"], read_protocol_version())
self.assertEqual(manifest["notes"], "### Fixed\n- One")
+ def test_build_latest_json_uses_selected_release_endpoint_generation(self) -> None:
+ manifest = json.loads(build_latest_json(
+ "0.1.1", "Release notes", release_assets("0.1.1"), release_sha256(),
+ endpoint_generation=7,
+ ))
+ self.assertEqual(manifest["endpoint_generation"], 7)
+ self.assertEqual(manifest["releases"]["0.1.1"]["endpoint_generation"], 7)
+
def test_build_latest_json_embeds_notes_and_release_assets(self) -> None:
manifest = json.loads(
build_latest_json(
diff --git a/scripts/test_preview.py b/scripts/test_preview.py
index d3613d83..36dae3b4 100644
--- a/scripts/test_preview.py
+++ b/scripts/test_preview.py
@@ -11,21 +11,14 @@ import scripts.preview as preview
class PreviewNotesTests(unittest.TestCase):
- def test_humanize_groups_conventional_subjects(self):
+ def test_notes_contain_only_build_and_comparison_link(self):
self.assertEqual(
- preview.humanize_subject("feat(update): add preview channel"),
- ("Added", "Add preview channel"),
- )
- self.assertEqual(
- preview.humanize_subject("fix: handle preview manifest"),
- ("Fixed", "Handle preview manifest"),
- )
- self.assertEqual(
- preview.humanize_subject("not conventional"),
- ("Other", "Not conventional"),
+ preview.build_notes("previous-sha", "current-sha", "2026-09-16-abcdef123456", "herdrdev/herdr"),
+ "Preview build 2026-09-16-abcdef123456\n\n"
+ "[View changes](https://github.com/herdrdev/herdr/compare/previous-sha...current-sha)\n",
)
- def test_build_manifest_archives_current_assets(self):
+ def test_build_manifest_archives_assets_with_selected_source_generation(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "preview.json"
notes = "Preview notes\n"
@@ -44,13 +37,14 @@ class PreviewNotesTests(unittest.TestCase):
"windows-x86_64": "a" * 64,
},
retain=30,
+ endpoint_generation=77,
)
data = json.loads(content)
self.assertEqual(data["channel"], "preview")
self.assertEqual(data["build_id"], "2026-06-02-abcdef123456")
self.assertEqual(
data["endpoint_generation"],
- preview.read_endpoint_protocol_generation(),
+ 77,
)
self.assertEqual(
data["assets"]["linux-x86_64"]["sha256"],
@@ -68,7 +62,7 @@ class PreviewNotesTests(unittest.TestCase):
self.assertIn("2026-06-02-abcdef123456", data["builds"])
self.assertEqual(
data["builds"]["2026-06-02-abcdef123456"]["endpoint_generation"],
- preview.read_endpoint_protocol_generation(),
+ 77,
)
def test_windows_preview_asset_requires_sha256(self):
@@ -88,24 +82,6 @@ class PreviewNotesTests(unittest.TestCase):
retain=1,
)
- def test_hidden_subjects_include_preview_manifest_commits(self):
- self.assertTrue(preview.hidden_subject("docs: update preview manifest"))
- self.assertTrue(preview.hidden_subject("docs: update website manifest"))
- self.assertTrue(preview.hidden_subject("docs: publish release distribution"))
- self.assertFalse(preview.hidden_subject("release: v0.7.0"))
- self.assertFalse(preview.hidden_subject("fix: repair preview manifest"))
-
- def test_latest_publishable_commit_keeps_release_commits(self):
- output = "\n".join(
- [
- "manifest\x00docs: update website manifest for v0.7.0",
- "release\x00release: v0.7.0",
- "feature\x00feat: add plugin v1 system",
- ]
- )
- with mock.patch.object(preview, "run_git", return_value=output):
- self.assertEqual(preview.latest_publishable_commit("origin/master"), "release")
-
def test_preview_range_base_advances_to_stable_tag(self):
with (
mock.patch.object(preview, "latest_stable_tag", return_value="v0.7.0"),
@@ -118,7 +94,10 @@ class PreviewNotesTests(unittest.TestCase):
def test_preview_range_base_keeps_previous_preview_for_unreleased_work(self):
def is_ancestor(ancestor: str, descendant: str) -> bool:
- return (ancestor, descendant) == ("v0.7.0", "new-feature")
+ return (ancestor, descendant) in {
+ ("v0.7.0", "new-feature"),
+ ("previous-preview", "new-feature"),
+ }
with (
mock.patch.object(preview, "latest_stable_tag", return_value="v0.7.0"),
@@ -129,7 +108,14 @@ class PreviewNotesTests(unittest.TestCase):
"previous-preview",
)
- def test_post_stable_history_selects_release_and_bases_range_on_stable_tag(self):
+ def test_hotfix_preview_uses_stable_base_instead_of_newer_master_preview(self):
+ with (
+ mock.patch.object(preview, "latest_stable_tag", return_value="v0.7.0"),
+ mock.patch.object(preview, "git_is_ancestor", return_value=False),
+ ):
+ self.assertEqual(preview.preview_range_base("newer-master", "hotfix"), "v0.7.0")
+
+ def test_post_stable_history_bases_range_on_stable_tag(self):
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp)
@@ -156,13 +142,9 @@ class PreviewNotesTests(unittest.TestCase):
release = git("rev-parse", "HEAD")
git("tag", "v0.7.0")
- marker.write_text("manifest\n", encoding="utf-8")
- git("commit", "-am", "docs: update website manifest for v0.7.0")
-
original_cwd = os.getcwd()
try:
os.chdir(repo)
- self.assertEqual(preview.latest_publishable_commit("HEAD"), release)
self.assertEqual(
preview.preview_range_base(previous_preview, release),
"v0.7.0",
diff --git a/scripts/test_release.py b/scripts/test_release.py
new file mode 100644
index 00000000..ba5cfb85
--- /dev/null
+++ b/scripts/test_release.py
@@ -0,0 +1,190 @@
+import json
+import os
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+from scripts import release
+
+
+class ReleaseTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory(prefix="herdr-release-", dir="/var/tmp" if os.name != "nt" else None)
+ self.addCleanup(self.temp.cleanup)
+ self.previous_cwd = Path.cwd()
+ os.chdir(self.temp.name)
+ self.addCleanup(os.chdir, self.previous_cwd)
+ self.git("init", "-q", "-b", "master")
+ self.git("config", "user.name", "Release Test")
+ self.git("config", "user.email", "release@example.invalid")
+ self.put("Cargo.toml", '[package]\nname = "herdr"\nversion = "1.0.0"\n[dependencies]\nserde = "1"\n')
+ self.put("Cargo.lock", 'version = 4\n[[package]]\nname = "herdr"\nversion = "1.0.0"\n[[package]]\nname = "serde"\nversion = "1.0.0"\n')
+ self.put("src/main.rs", "fn main() {}\n")
+ self.put("distribution/latest.json", json.dumps({"version": "1.0.0"}))
+ self.put("scripts/release.py", "# promotion tooling\n")
+ self.put(".github/workflows/release.yml", "run: python3 scripts/release.py check-tag\n")
+ self.put(".github/workflows/preview.yml", 'on:\n push:\n tags:\n - "preview-*"\n')
+ self.commit("stable")
+ self.git("tag", "v1.0.0")
+ self.put("src/main.rs", "fn main() { println!(\"preview\"); }\n")
+ self.preview = self.commit("preview")
+ self.git("tag", "preview-test")
+ self.git("update-ref", "refs/remotes/origin/master", "HEAD")
+
+ def git(self, *args):
+ return subprocess.check_output(["git", *args], text=True, stderr=subprocess.STDOUT).strip()
+
+ def put(self, path, text):
+ target = Path(path)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(text, encoding="utf-8")
+
+ def commit(self, message):
+ self.git("add", ".")
+ self.git("commit", "-qm", message)
+ return self.git("rev-parse", "HEAD")
+
+ def prepare(self):
+ for path in ("Cargo.toml", "Cargo.lock"):
+ self.put(path, Path(path).read_text().replace('version = "1.0.0"', 'version = "1.0.1"', 1))
+ self.put("CHANGELOG.md", "# 1.0.1\nFixed the bug.\n")
+ return self.commit("release preparation")
+
+ def test_promotion_excludes_newer_master_and_records_explicit_previous_stable(self):
+ self.put("src/new-feature.rs", "untested feature\n")
+ master = self.commit("newer master")
+ self.git("update-ref", "refs/remotes/origin/master", master)
+ self.git("checkout", "-q", "-b", "release/1.0.1", self.preview)
+ candidate = self.prepare()
+ self.git("tag", "-a", "v1.0.1", "-m", "v1.0.1\n\nPreview: preview-test\nPrevious-Stable: v1.0.0")
+ self.assertEqual(release.tag_metadata("v1.0.1"), ("preview-test", "v1.0.0"))
+ with mock.patch.object(release, "published_preview", return_value=self.preview):
+ self.assertEqual(release.validate_release("preview-test", candidate, "1.0.1", "v1.0.0", "test/repo"), self.preview)
+ self.assertFalse(Path("src/new-feature.rs").exists())
+ self.git("checkout", "-q", "master")
+ with self.assertRaisesRegex(ValueError, "unpreviewed change"):
+ release.validate_diff(self.preview, master)
+
+ def test_code_dependencies_schema_and_build_changes_are_rejected(self):
+ cases = {
+ "src/main.rs": "fn main() { panic!(); }\n",
+ "Cargo.toml": '[package]\nname = "herdr"\nversion = "1.0.1"\n[dependencies]\nserde = "2"\n',
+ "Cargo.lock": Path("Cargo.lock").read_text().replace('name = "serde"\nversion = "1.0.0"', 'name = "serde"\nversion = "2.0.0"'),
+ "docs/next/api/herdr-api.schema.json": "{}\n",
+ ".github/workflows/release.yml": "changed\n",
+ }
+ for path, content in cases.items():
+ with self.subTest(path=path):
+ self.git("reset", "--hard", self.preview)
+ self.put(path, content)
+ candidate = self.commit("not release preparation")
+ with self.assertRaises(ValueError):
+ release.validate_diff(self.preview, candidate)
+
+ def test_release_docs_and_skill_are_allowed_but_symlinks_are_not(self):
+ for path in release.RELEASE_FILES | {"docs/next/website/src/content/docs/index.mdx"}:
+ self.put(path, "release prose\n")
+ candidate = self.prepare()
+ release.validate_diff(self.preview, candidate, "1.0.1")
+ if os.name != "nt":
+ Path("CHANGELOG.md").unlink()
+ Path("CHANGELOG.md").symlink_to("src/main.rs")
+ with self.assertRaisesRegex(ValueError, "regular files"):
+ release.validate_diff(self.preview, self.commit("symlink"))
+
+ def test_preview_source_accepts_master_history_and_rejects_unpublished_branch(self):
+ self.assertEqual(release.select_preview(self.preview), self.preview)
+ self.assertEqual(release.select_preview("v1.0.0"), self.git("rev-parse", "v1.0.0"))
+ self.put("src/main.rs", "unpublished work\n")
+ candidate = self.commit("unpublished")
+ with self.assertRaisesRegex(ValueError, "preview source must"):
+ release.select_preview(candidate)
+
+ def test_preview_source_rejects_legacy_dispatch_even_on_master(self):
+ self.put(".github/workflows/preview.yml", "on:\n workflow_dispatch:\n")
+ legacy = self.commit("legacy preview workflow")
+ self.git("update-ref", "refs/remotes/origin/master", legacy)
+ with self.assertRaisesRegex(ValueError, "predates tag-triggered previews"):
+ release.select_preview(legacy)
+
+ def test_wrong_ancestry_and_mismatched_version_fail(self):
+ candidate = self.prepare()
+ with self.assertRaisesRegex(ValueError, "must descend"):
+ release.validate_diff(candidate, self.preview)
+ with self.assertRaisesRegex(ValueError, "version must match"):
+ release.validate_diff(self.preview, candidate, "1.0.2")
+
+ def test_published_preview_requires_immutable_complete_release_and_matching_remote_tag(self):
+ payload = {"tag_name": "preview-test", "draft": False, "prerelease": True,
+ "immutable": True, "assets": [{"name": name} for name in release.ASSETS]}
+ real_git = release.git
+
+ def git(*args):
+ if args[0] == "ls-remote":
+ return f"{self.preview}\trefs/tags/preview-test"
+ return real_git(*args)
+
+ real_output = subprocess.check_output
+
+ def output(args, **kwargs):
+ return json.dumps(payload) if args[0] == "gh" else real_output(args, **kwargs)
+
+ with mock.patch.object(release, "git", side_effect=git), mock.patch.object(release.subprocess, "check_output", side_effect=output):
+ self.assertEqual(release.published_preview("preview-test", "test/repo"), self.preview)
+ for key, value in (("draft", True), ("prerelease", False), ("immutable", False), ("assets", [])):
+ with self.subTest(key=key), mock.patch.dict(payload, {key: value}):
+ with self.assertRaisesRegex(ValueError, "immutable published preview"):
+ release.published_preview("preview-test", "test/repo")
+ self.git("tag", "-f", "preview-test", "v1.0.0")
+ with self.assertRaisesRegex(ValueError, "does not match"):
+ release.published_preview("preview-test", "test/repo")
+
+ def test_missing_provenance_or_stale_previous_release_fails(self):
+ self.git("tag", "v1.0.1")
+ with self.assertRaisesRegex(ValueError, "annotated"):
+ release.tag_metadata("v1.0.1")
+ self.git("tag", "-af", "v1.0.1", "-m", "v1.0.1")
+ with self.assertRaisesRegex(ValueError, "Preview"):
+ release.tag_metadata("v1.0.1")
+ candidate = self.prepare()
+ with mock.patch.object(release, "published_preview", return_value=self.preview):
+ with self.assertRaisesRegex(ValueError, "currently published"):
+ release.validate_release("preview-test", candidate, "1.0.1", "v0.9.0", "test/repo")
+ with self.assertRaisesRegex(ValueError, "must increase"):
+ release.validate_release("preview-test", candidate, "1.0.1", "v1.0.1", "test/repo")
+
+ def test_hotfix_isolated_from_master_and_metadata_sync_preserves_master_code(self):
+ self.put("src/feature.rs", "b and c\n")
+ master = self.commit("features b and c")
+ self.git("checkout", "-q", "-b", "release/hotfix", "v1.0.0")
+ self.put("src/main.rs", "fn main() { /* fix d */ }\n")
+ hotfix = self.commit("fix d")
+ self.git("update-ref", "refs/remotes/origin/release/hotfix", hotfix)
+ self.assertEqual(release.select_hotfix("release/hotfix", "v1.0.0"), hotfix)
+ self.assertEqual(release.select_preview(hotfix), hotfix)
+ self.git("rm", "scripts/release.py")
+ legacy = self.commit("legacy release tooling")
+ self.git("update-ref", "refs/remotes/origin/release/hotfix", legacy)
+ with self.assertRaisesRegex(ValueError, "predates preview promotion"):
+ release.select_hotfix("release/hotfix", "v1.0.0")
+ self.git("reset", "--hard", hotfix)
+ self.git("update-ref", "refs/remotes/origin/release/hotfix", hotfix)
+ with self.assertRaisesRegex(ValueError, "release/\\*"):
+ release.select_hotfix("feature/unreviewed", "v1.0.0")
+ self.git("tag", "v2.0.0", master)
+ with self.assertRaisesRegex(ValueError, "must descend"):
+ release.select_hotfix("release/hotfix", "v2.0.0")
+ candidate = self.prepare()
+ release.validate_diff(hotfix, candidate, "1.0.1")
+ patch = subprocess.check_output(["git", "diff", "--binary", hotfix, candidate])
+ self.git("checkout", "-q", "master")
+ subprocess.run(["git", "apply", "--3way", "--index"], input=patch, check=True)
+ self.assertEqual(Path("src/feature.rs").read_text(), "b and c\n")
+ self.assertNotIn("fix d", Path("src/main.rs").read_text())
+ self.assertEqual(release.normalized_cargo(Path("Cargo.toml").read_text(), "Cargo.toml")[1], "1.0.1")
+
+
+if __name__ == "__main__":
+ unittest.main()