Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/scripts/release_candidate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Classify an exact Boatstack source for a stable patch release."""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path


STABLE_TAG = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")


class ReleaseBlocked(RuntimeError):
"""The selected source cannot produce a stable release."""


def git(repository: Path, *arguments: str) -> str:
result = subprocess.run(
["git", *arguments],
cwd=repository,
text=True,
capture_output=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip()
raise ReleaseBlocked(f"git {' '.join(arguments)} failed: {detail}")
return result.stdout.strip()


def stable_tags(repository: Path, source: str) -> list[tuple[tuple[int, int, int], str]]:
tags: list[tuple[tuple[int, int, int], str]] = []
for tag in git(repository, "tag", "--merged", source, "--list", "v*").splitlines():
match = STABLE_TAG.fullmatch(tag)
if match is not None:
tags.append((tuple(map(int, match.groups())), tag))
return sorted(tags, reverse=True)


def classify(repository: Path, source: str) -> dict[str, str]:
resolved_source = git(repository, "rev-parse", "--verify", f"{source}^{{commit}}")
if resolved_source != source:
raise ReleaseBlocked(f"release source must be an exact commit SHA: {source}")
head = git(repository, "rev-parse", "HEAD")
if head != source:
raise ReleaseBlocked(f"checked-out source {head} does not match release source {source}")

tags = stable_tags(repository, source)
if not tags:
raise ReleaseBlocked("stable patch releases require an existing vMAJOR.MINOR.PATCH tag")
version, latest_tag = tags[0]

rewritten = git(
repository,
"diff",
"--name-only",
"--diff-filter=MD",
"--no-renames",
latest_tag,
source,
"--",
"release-notes/*.md",
)
if rewritten:
raise ReleaseBlocked(f"Boatstack release notes are append-only: {rewritten.replace(chr(10), ', ')}")

added = git(
repository,
"diff",
"--name-only",
"--diff-filter=A",
"--no-renames",
latest_tag,
source,
"--",
"release-notes/*.md",
)
next_tag = f"v{version[0]}.{version[1]}.{version[2] + 1}"
if added and git(repository, "tag", "--list", next_tag):
raise ReleaseBlocked(f"next stable tag already exists: {next_tag}")

return {
"release_required": "true" if added else "false",
"latest_tag": latest_tag,
"next_tag": next_tag,
"release_source": source,
}


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", type=Path, default=Path.cwd())
parser.add_argument("--source", required=True)
parser.add_argument("--github-output", type=Path)
arguments = parser.parse_args()

try:
result = classify(arguments.repo.resolve(), arguments.source)
except ReleaseBlocked as error:
print(f"BLOCKED: {error}", file=sys.stderr)
return 2

if arguments.github_output is not None:
with arguments.github_output.open("a", encoding="utf-8") as output:
for key, value in result.items():
output.write(f"{key}={value}\n")
print(json.dumps(result, sort_keys=True))
return 0


if __name__ == "__main__":
raise SystemExit(main())
157 changes: 157 additions & 0 deletions .github/tests/test_release_candidate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
from __future__ import annotations

import json
import subprocess
import tempfile
import unittest
from pathlib import Path


REPO = Path(__file__).resolve().parents[2]
SCRIPT = REPO / ".github" / "scripts" / "release_candidate.py"


class ReleaseCandidateTest(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.repository = Path(self.temporary.name)
self.git("init", "--initial-branch=main")
self.git("config", "user.email", "release-test@example.invalid")
self.git("config", "user.name", "Release Test")
self.write("release-notes/base.md", "### Base release\n")
self.commit("Create base release")
self.git("tag", "v1.2.3")

def tearDown(self) -> None:
self.temporary.cleanup()

def git(self, *arguments: str) -> str:
result = subprocess.run(
["git", *arguments],
cwd=self.repository,
text=True,
capture_output=True,
check=True,
)
return result.stdout.strip()

def write(self, name: str, content: str) -> None:
path = self.repository / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)

def commit(self, message: str) -> str:
self.git("add", ".")
self.git("commit", "-m", message)
return self.git("rev-parse", "HEAD")

def classify(self, source: str | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"python3",
str(SCRIPT),
"--repo",
str(self.repository),
"--source",
source or self.git("rev-parse", "HEAD"),
],
text=True,
capture_output=True,
)

def classify_with_output(self, output: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"python3",
str(SCRIPT),
"--repo",
str(self.repository),
"--source",
self.git("rev-parse", "HEAD"),
"--github-output",
str(output),
],
text=True,
capture_output=True,
)

def test_no_unreleased_note_is_a_no_op(self) -> None:
result = self.classify()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["release_required"], "false")

def test_added_note_selects_next_patch_tag(self) -> None:
self.write("release-notes/change.md", "### Changed behavior\n")
source = self.commit("Add release-bearing change")
result = self.classify(source)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
json.loads(result.stdout),
{
"latest_tag": "v1.2.3",
"next_tag": "v1.2.4",
"release_required": "true",
"release_source": source,
},
)

def test_github_output_matches_json_result(self) -> None:
self.write("release-notes/change.md", "### Changed behavior\n")
self.commit("Add release-bearing change")
output = self.repository / "github-output"
result = self.classify_with_output(output)
self.assertEqual(result.returncode, 0, result.stderr)
values = dict(line.split("=", 1) for line in output.read_text().splitlines())
self.assertEqual(values, json.loads(result.stdout))

def test_modified_release_note_is_blocked(self) -> None:
self.write("release-notes/base.md", "### Rewritten release\n")
self.commit("Rewrite release note")
result = self.classify()
self.assertEqual(result.returncode, 2)
self.assertIn("release notes are append-only", result.stderr)

def test_deleted_release_note_is_blocked(self) -> None:
(self.repository / "release-notes/base.md").unlink()
self.git("add", "-u")
self.git("commit", "-m", "Delete release note")
result = self.classify()
self.assertEqual(result.returncode, 2)
self.assertIn("release notes are append-only", result.stderr)

def test_source_must_match_checked_out_head(self) -> None:
tagged_source = self.git("rev-parse", "HEAD")
self.write("release-notes/change.md", "### Changed behavior\n")
self.commit("Add release-bearing change")
result = self.classify(tagged_source)
self.assertEqual(result.returncode, 2)
self.assertIn("does not match release source", result.stderr)

def test_prerelease_and_malformed_tags_do_not_replace_latest_stable(self) -> None:
self.git("tag", "v9.0.0-rc.1")
self.git("tag", "version-nine")
self.write("release-notes/change.md", "### Changed behavior\n")
self.commit("Add release-bearing change")
result = self.classify()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["latest_tag"], "v1.2.3")

def test_existing_candidate_tag_on_unrelated_history_is_blocked(self) -> None:
unrelated = self.git("commit-tree", "HEAD^{tree}", "-m", "Unrelated release")
self.git("tag", "v1.2.4", unrelated)
self.write("release-notes/change.md", "### Changed behavior\n")
self.commit("Add release-bearing change")
result = self.classify()
self.assertEqual(result.returncode, 2)
self.assertIn("next stable tag already exists", result.stderr)

def test_repository_without_stable_tag_is_blocked(self) -> None:
self.git("tag", "-d", "v1.2.3")
self.git("tag", "v1.2.3-rc.1")
result = self.classify()
self.assertEqual(result.returncode, 2)
self.assertIn("existing vMAJOR.MINOR.PATCH tag", result.stderr)


if __name__ == "__main__":
unittest.main()
15 changes: 14 additions & 1 deletion .github/tests/test_repository_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,20 @@ def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None:
ci_name = re.search(r"(?m)^name:\s*(.+?)\s*$", ci)
self.assertIsNotNone(ci_name)
self.assertEqual(ci_name.group(1), "CI")
self.assertIn(f'workflows: ["{ci_name.group(1)}"]', automatic)
self.assertNotIn("workflow_run:", automatic)
self.assertIn('cron: "0 2 * * *"', automatic)
self.assertIn("workflow_dispatch:", automatic)
self.assertIn("actions: read", automatic)
self.assertIn('--workflow .github/workflows/ci.yml', automatic)
self.assertIn('--event push', automatic)
self.assertIn('--commit "$RELEASE_SOURCE"', automatic)
self.assertIn('git ls-remote origin refs/heads/main', automatic)
self.assertGreaterEqual(automatic.count('git ls-remote origin refs/heads/main'), 2)
self.assertIn("git fetch --force --tags origin", automatic)
self.assertIn('git ls-remote --exit-code --tags origin "refs/tags/$next_tag"', automatic)
self.assertIn("release_candidate.py", automatic)
self.assertIn("cancel-in-progress: false", automatic)
self.assertIn("no verified unreleased changes; no release was created", automatic)

def test_manual_release_is_prerelease_only_and_exact_source_bound(self) -> None:
# control-law: branch-prerelease-publishes-only-an-exact-new-rc-source
Expand Down
Loading
Loading