diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-16 18:25:59 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-16 18:25:59 +0300 |
| commit | 36db8ff3f4bee21b06f3208422be7cf91c7d3e40 (patch) | |
| tree | 539cb0114feb45aeb8d483a29cba6e7912f77483 /scripts | |
| parent | aff99878d3467e809e18f07fb338c78dea50e558 (diff) | |
ci: promote stable releases from protected previews (#4241)
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/changelog.py | 6 | ||||
| -rw-r--r-- | scripts/preview.py | 114 | ||||
| -rw-r--r-- | scripts/release-workflows.test.ts | 64 | ||||
| -rw-r--r-- | scripts/release.py | 210 | ||||
| -rw-r--r-- | scripts/test_changelog.py | 8 | ||||
| -rw-r--r-- | scripts/test_preview.py | 58 | ||||
| -rw-r--r-- | scripts/test_release.py | 190 |
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() |
