ci: promote stable releases from protected previews (#4241)

This commit is contained in:
Can Celik
2026-09-16 18:25:59 +03:00
committed by GitHub
parent aff99878d3
commit 36db8ff3f4
12 changed files with 644 additions and 238 deletions
+5 -1
View File
@@ -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(
+10 -104
View File
@@ -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)
+64
View File
@@ -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 });
}
});
});
+210
View File
@@ -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
+8
View File
@@ -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(
+20 -38
View File
@@ -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",
+190
View File
@@ -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()