diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 21:48:02 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-02 21:48:02 +0300 |
| commit | b751d102f158968aab681cb663d27b4c32197f62 (patch) | |
| tree | 85d03609314559df7705c2cb0ab4e37ab6d9715f /scripts | |
| parent | 295b09ca25edbccac65401ee84fa420dd3888aad (diff) | |
feat: add preview update channel
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/conventional_commits.py | 74 | ||||
| -rw-r--r-- | scripts/preview.py | 298 | ||||
| -rw-r--r-- | scripts/test_preview.py | 94 |
3 files changed, 466 insertions, 0 deletions
diff --git a/scripts/conventional_commits.py b/scripts/conventional_commits.py new file mode 100644 index 00000000..3c61d49b --- /dev/null +++ b/scripts/conventional_commits.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import re +import subprocess +from pathlib import Path + +ALLOWED_TYPES = { + "feat", + "fix", + "perf", + "docs", + "ci", + "test", + "refactor", + "chore", + "release", +} +SUBJECT_RE = re.compile(r"^(?P<kind>[a-z]+)(?:\([^)]+\))?!?:\s+\S") + + +def git_subjects(rev_range: str) -> list[str]: + output = subprocess.check_output( + ["git", "log", "--pretty=format:%s", rev_range], text=True + ).strip() + return [line.strip() for line in output.splitlines() if line.strip()] + + +def valid_subject(subject: str) -> bool: + match = SUBJECT_RE.match(subject) + return bool(match and match.group("kind") in ALLOWED_TYPES) + + +def commit_message_subject(path: Path) -> str | None: + for line in path.read_text(encoding="utf-8").splitlines(): + subject = line.strip() + if subject and not subject.startswith("#"): + return subject + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate conventional commit subjects") + parser.add_argument("subjects", nargs="*") + parser.add_argument("--range", dest="rev_range") + parser.add_argument("--message-file") + args = parser.parse_args() + + subjects = list(args.subjects) + if args.rev_range: + subjects.extend(git_subjects(args.rev_range)) + if args.message_file: + subject = commit_message_subject(Path(args.message_file)) + if subject: + subjects.append(subject) + + invalid = [subject for subject in subjects if not valid_subject(subject)] + if invalid: + print("invalid commit subject(s):") + for subject in invalid: + print(f" {subject}") + print( + "commit subjects must use conventional commits because preview notes are generated from them." + ) + print("example: fix(update): install selected channel") + print("expected: type(optional-scope): subject") + print("allowed types: " + ", ".join(sorted(ALLOWED_TYPES))) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/preview.py b/scripts/preview.py new file mode 100644 index 00000000..df8589bc --- /dev/null +++ b/scripts/preview.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path +from typing import Any + +ASSET_TARGETS = ( + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64", +) +EXPECTED_ASSET_NAMES = {target: f"herdr-{target}" for target in ASSET_TARGETS} +HIDDEN_SUBJECTS = ( + "release:", + "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>.+)$") + + +def run_git(args: list[str]) -> str: + return subprocess.check_output(["git", *args], text=True).strip() + + +def normalize_version(version: str) -> str: + return version.strip().removeprefix("v") + + +def latest_stable_tag() -> str: + return run_git(["describe", "--tags", "--match", "v[0-9]*", "--abbrev=0"]) + + +def read_json(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def previous_preview_commit(path: Path) -> str | None: + data = read_json(path) + if not data: + return None + commit = data.get("commit") + 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 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] + 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" + + +def default_asset_urls(repo: str, tag: str) -> dict[str, str]: + return { + target: f"https://github.com/{repo}/releases/download/{tag}/{EXPECTED_ASSET_NAMES[target]}" + for target in ASSET_TARGETS + } + + +def read_sha_file(path: Path | None) -> dict[str, str]: + if path is None: + return {} + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SystemExit("sha file must be a JSON object") + return {str(key): str(value) for key, value in data.items()} + + +def asset_objects(urls: dict[str, str], shas: dict[str, str]) -> dict[str, dict[str, str]]: + assets: dict[str, dict[str, str]] = {} + for target in ASSET_TARGETS: + url = urls[target] + entry = {"url": url} + sha = shas.get(target) + if sha: + entry["sha256"] = sha + assets[target] = entry + return assets + + +def build_manifest( + output: Path, + repo: str, + tag: str, + build_id: str, + commit: str, + built_at: str, + base_version: str, + protocol: int, + notes: str, + shas: dict[str, str], + retain: int, +) -> 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) + builds[build_id] = { + "base_version": normalize_version(base_version), + "commit": commit, + "built_at": built_at, + "protocol": protocol, + "tag": tag, + "assets": assets, + } + ordered_builds = { + key: builds[key] + for key in sorted( + builds, + key=lambda key: str(builds[key].get("built_at", "")), + reverse=True, + )[:retain] + } + manifest = { + "schema_version": 1, + "channel": "preview", + "base_version": normalize_version(base_version), + "build_id": build_id, + "commit": commit, + "built_at": built_at, + "protocol": protocol, + "notes": notes.strip(), + "assets": assets, + "builds": ordered_builds, + } + return json.dumps(manifest, indent=2) + "\n" + + +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) + Path(args.output).write_text(notes, encoding="utf-8") + return 0 + + +def cmd_manifest(args: argparse.Namespace) -> int: + notes = Path(args.notes).read_text(encoding="utf-8") + shas = read_sha_file(Path(args.sha_file) if args.sha_file else None) + content = build_manifest( + output=Path(args.output), + repo=args.repo, + tag=args.tag, + build_id=args.build_id, + commit=args.commit, + built_at=args.built_at, + base_version=args.base_version, + protocol=args.protocol, + notes=notes, + shas=shas, + retain=args.retain, + ) + Path(args.output).write_text(content, encoding="utf-8") + return 0 + + +def cmd_current_commit(args: argparse.Namespace) -> int: + commit = previous_preview_commit(Path(args.manifest)) + if commit: + print(commit) + return 0 + + +def cmd_select_commit(args: argparse.Namespace) -> int: + print(latest_publishable_commit(args.ref)) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Preview channel release helpers") + sub = parser.add_subparsers(required=True) + + notes = sub.add_parser("notes") + notes.add_argument("--manifest", default="website/preview.json") + 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="ogulcancelik/herdr") + notes.add_argument("--output", required=True) + notes.set_defaults(func=cmd_notes) + + manifest = sub.add_parser("manifest") + manifest.add_argument("--output", default="website/preview.json") + manifest.add_argument("--repo", default="ogulcancelik/herdr") + manifest.add_argument("--tag", required=True) + manifest.add_argument("--build-id", required=True) + manifest.add_argument("--commit", required=True) + 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("--notes", required=True) + manifest.add_argument("--sha-file") + manifest.add_argument("--retain", type=int, default=30) + manifest.set_defaults(func=cmd_manifest) + + current = sub.add_parser("current-commit") + current.add_argument("--manifest", default="website/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) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_preview.py b/scripts/test_preview.py new file mode 100644 index 00000000..1118058b --- /dev/null +++ b/scripts/test_preview.py @@ -0,0 +1,94 @@ +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +import scripts.conventional_commits as conventional_commits +import scripts.preview as preview + + +class PreviewNotesTests(unittest.TestCase): + def test_humanize_groups_conventional_subjects(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"), + ) + + def test_build_manifest_archives_current_assets(self): + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "preview.json" + notes = "Preview notes\n" + content = preview.build_manifest( + output=output, + repo="ogulcancelik/herdr", + tag="preview-2026-06-02-abcdef123456", + build_id="2026-06-02-abcdef123456", + commit="abcdef1234567890", + built_at="2026-06-02T03:00:00Z", + base_version="0.6.6", + protocol=12, + notes=notes, + shas={"linux-x86_64": "deadbeef"}, + retain=30, + ) + data = json.loads(content) + self.assertEqual(data["channel"], "preview") + self.assertEqual(data["build_id"], "2026-06-02-abcdef123456") + self.assertEqual( + data["assets"]["linux-x86_64"]["sha256"], + "deadbeef", + ) + self.assertIn("2026-06-02-abcdef123456", data["builds"]) + + def test_hidden_subjects_include_preview_manifest_commits(self): + self.assertTrue(preview.hidden_subject("docs: update preview manifest")) + self.assertFalse(preview.hidden_subject("fix: repair preview manifest")) + + def test_preview_docs_rewrite_links_to_preview_namespace(self): + source = """--- +title: Install Herdr +--- + +[Install](/docs/install/) +file: ../../../public/assets/logo.svg +""" + output = subprocess.check_output( + ["node", "website/scripts/prepare-docs.mjs", "--rewrite-preview-doc-fixture"], + input=source, + text=True, + ) + self.assertIn("[Install](/docs/preview/install/)", output) + self.assertIn("file: ../../../../public/assets/logo.svg", output) + self.assertIn("Preview docs describe unreleased preview builds", output) + + +class ConventionalCommitTests(unittest.TestCase): + def test_valid_subjects_allow_scopes_and_bang(self): + self.assertTrue(conventional_commits.valid_subject("fix(update): handle preview")) + self.assertTrue(conventional_commits.valid_subject("feat!: change config")) + self.assertFalse(conventional_commits.valid_subject("update preview channel")) + + def test_commit_message_subject_skips_comments(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "COMMIT_EDITMSG" + path.write_text( + "\n# Please enter the commit message\n\nfix(update): switch channel\n", + encoding="utf-8", + ) + self.assertEqual( + conventional_commits.commit_message_subject(path), + "fix(update): switch channel", + ) + + +if __name__ == "__main__": + unittest.main() |
