-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrelease_notes.py
More file actions
124 lines (106 loc) · 4.58 KB
/
Copy pathrelease_notes.py
File metadata and controls
124 lines (106 loc) · 4.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
#!/usr/bin/env python3
"""Validate Boatstack's append-only release-note contract."""
from __future__ import annotations
import argparse
import re
import subprocess
from pathlib import Path
NAME_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$")
HEADING_PATTERN = re.compile(r"^### [^\s].+$")
RELEASE_NOTES = Path("release-notes")
def validate_release_note(path: Path) -> None:
if not NAME_PATTERN.fullmatch(path.name):
raise ValueError(f"{path}: name must match YYYY-MM-DD-<slug>.md")
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError as error:
raise ValueError(f"{path}: release note must be UTF-8") from error
if not content.endswith("\n"):
raise ValueError(f"{path}: release note must end with a newline")
lines = content.splitlines()
if not lines or not HEADING_PATTERN.fullmatch(lines[0]):
raise ValueError(f"{path}: first line must be a level-three Markdown heading")
if not any(line.strip() for line in lines[1:]):
raise ValueError(f"{path}: release note must describe user impact")
def validate_directory(repo: Path) -> None:
root = repo / RELEASE_NOTES
if not root.is_dir():
raise ValueError(f"{root}: release-note directory is missing")
unexpected = sorted(
path for path in root.iterdir()
if not path.is_file() or path.is_symlink() or path.suffix != ".md"
)
if unexpected:
raise ValueError(f"{unexpected[0]}: only direct Markdown files are allowed")
notes = sorted(root.glob("*.md"))
if not notes:
raise ValueError(f"{root}: at least one release note is required")
for note in notes:
validate_release_note(note)
def git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", *args], cwd=repo, text=True, capture_output=True, check=False
)
if result.returncode != 0:
raise ValueError(result.stderr.strip() or f"git {' '.join(args)} failed")
return result.stdout.strip()
def check_policy(repo: Path, base: str, head: str) -> None:
output = git(repo, "diff", "--name-status", "--no-renames", base, head)
changes: list[tuple[str, Path]] = []
for line in output.splitlines():
if line:
status, value = line.split("\t", 1)
changes.append((status, Path(value)))
if not changes:
return
note_changes = [
(status, path)
for status, path in changes
if path.is_relative_to(RELEASE_NOTES)
]
rewritten = [f"{status}\t{path}" for status, path in note_changes if status != "A"]
if rewritten:
raise ValueError(
"release notes are append-only; add a correction fragment instead:\n "
+ "\n ".join(rewritten)
)
added = [repo / path for status, path in note_changes if status == "A"]
if not added:
raise ValueError("Boatstack changes require a new file under release-notes/")
for note in sorted(added):
validate_release_note(note)
def preflight(repo: Path, remote: str, base_branch: str, head: str) -> None:
dirty = git(repo, "status", "--porcelain", "--untracked-files=all")
if dirty:
raise ValueError("commit or remove uncommitted changes before preflight")
git(repo, "fetch", "--quiet", remote, base_branch)
check_policy(repo, f"refs/remotes/{remote}/{base_branch}", head)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
validate = subparsers.add_parser("validate")
validate.add_argument("--repo", type=Path, default=Path("."))
check = subparsers.add_parser("check-policy")
check.add_argument("--repo", type=Path, required=True)
check.add_argument("--base", required=True)
check.add_argument("--head", required=True)
before = subparsers.add_parser("preflight")
before.add_argument("--repo", type=Path, required=True)
before.add_argument("--remote", default="origin")
before.add_argument("--base-branch", default="main")
before.add_argument("--head", default="HEAD")
args = parser.parse_args()
try:
repo = args.repo.resolve()
validate_directory(repo)
if args.command == "check-policy":
check_policy(repo, args.base, args.head)
elif args.command == "preflight":
preflight(repo, args.remote, args.base_branch, args.head)
except ValueError as error:
print(f"BLOCKED: {error}")
return 1
print("PASS: Boatstack release-note contract is satisfied")
return 0
if __name__ == "__main__":
raise SystemExit(main())