aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-02 21:48:02 +0300
commitb751d102f158968aab681cb663d27b4c32197f62 (patch)
tree85d03609314559df7705c2cb0ab4e37ab6d9715f /scripts
parent295b09ca25edbccac65401ee84fa420dd3888aad (diff)
feat: add preview update channel
Diffstat (limited to 'scripts')
-rw-r--r--scripts/conventional_commits.py74
-rw-r--r--scripts/preview.py298
-rw-r--r--scripts/test_preview.py94
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()