Skip to content

Commit 6bf083f

Browse files
committed
chore(changelog): address review feedback
1 parent e86e809 commit 6bf083f

File tree

3 files changed

+187
-75
lines changed

3 files changed

+187
-75
lines changed

scripts/check-changelog.sh

Lines changed: 3 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,68 +3,6 @@ set -euo pipefail
33

44
FRAGMENT_GLOB=':(glob).changes/unreleased/*.md'
55

6-
validate_fragment() {
7-
local fragment="$1"
8-
local first_delim second_delim kind="" pr_url="" crate="" body=""
9-
10-
if [[ ! -f "$fragment" ]]; then
11-
echo "Fragment file does not exist: $fragment" >&2
12-
return 1
13-
fi
14-
15-
first_delim="$(awk 'NR == 1 && $0 == "---" { print NR; exit }' "$fragment")"
16-
second_delim="$(awk 'NR > 1 && $0 == "---" { print NR; exit }' "$fragment")"
17-
18-
if [[ -z "$first_delim" || -z "$second_delim" ]]; then
19-
echo "Fragment $fragment must start with YAML front matter delimited by '---'" >&2
20-
return 1
21-
fi
22-
23-
while IFS= read -r line; do
24-
[[ -z "$line" ]] && continue
25-
case "$line" in
26-
kind:\ *)
27-
kind="${line#kind: }"
28-
;;
29-
pr:\ *)
30-
pr_url="${line#pr: }"
31-
;;
32-
crate:\ *)
33-
crate="${line#crate: }"
34-
;;
35-
*)
36-
echo "Fragment $fragment has an unsupported front matter line: $line" >&2
37-
return 1
38-
;;
39-
esac
40-
done < <(sed -n "2,$((second_delim - 1))p" "$fragment")
41-
42-
body="$(sed -n "$((second_delim + 1)),\$p" "$fragment" | sed '/^[[:space:]]*$/d')"
43-
44-
case "$kind" in
45-
breaking|change|enhancement|fix) ;;
46-
*)
47-
echo "Fragment $fragment has invalid kind '$kind'. Expected one of: breaking, change, enhancement, fix" >&2
48-
return 1
49-
;;
50-
esac
51-
52-
if [[ ! "$pr_url" =~ ^https://github\.com/[^/]+/[^/]+/pull/[0-9]+$ ]]; then
53-
echo "Fragment $fragment must include a GitHub pull request URL in 'pr:'" >&2
54-
return 1
55-
fi
56-
57-
if [[ -n "$crate" && ! "$crate" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
58-
echo "Fragment $fragment has invalid crate '$crate'" >&2
59-
return 1
60-
fi
61-
62-
if [[ -z "$body" ]]; then
63-
echo "Fragment $fragment must contain a non-empty summary body" >&2
64-
return 1
65-
fi
66-
}
67-
686
if [[ "${NO_CHANGELOG_LABEL:-false}" == "true" ]]; then
697
echo "\"no changelog\" label has been set"
708
exit 0
@@ -89,8 +27,9 @@ EOF
8927
exit 1
9028
fi
9129

30+
validate_args=()
9231
for fragment in "${fragments[@]}"; do
93-
validate_fragment "$fragment"
32+
validate_args+=(--validate-fragment "$fragment")
9433
done
9534

96-
echo "Validated ${#fragments[@]} changelog fragment(s)."
35+
./scripts/prepare-changelog-release.py "${validate_args[@]}"

scripts/prepare-changelog-release.py

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
UNRELEASED_DIR = ROOT / ".changes" / "unreleased"
1515
ARCHIVE_DIR = ROOT / ".changes" / "archive"
1616
SECTION_ORDER = ["enhancement", "change", "fix"]
17+
ALLOWED_KINDS = {"breaking", "change", "enhancement", "fix"}
1718
SECTION_HEADERS = {
1819
"enhancement": "Enhancements",
1920
"change": "Changes",
@@ -29,19 +30,37 @@ def parse_args() -> argparse.Namespace:
2930
parser = argparse.ArgumentParser(
3031
description="Batch changelog fragments into CHANGELOG.md and archive them."
3132
)
32-
parser.add_argument("--version", required=True, help="Release version, e.g. 0.23.0")
33+
parser.add_argument("--version", help="Release version, e.g. 0.23.0")
3334
parser.add_argument(
3435
"--date",
3536
default=dt.date.today().isoformat(),
3637
help="Release date in YYYY-MM-DD format",
3738
)
38-
return parser.parse_args()
39+
parser.add_argument(
40+
"--validate-fragment",
41+
action="append",
42+
default=[],
43+
metavar="PATH",
44+
help="Validate one or more fragment files and exit without updating the changelog.",
45+
)
46+
args = parser.parse_args()
47+
if args.validate_fragment and args.version:
48+
parser.error("--validate-fragment cannot be used together with --version")
49+
if not args.validate_fragment and not args.version:
50+
parser.error("either --version or --validate-fragment is required")
51+
return args
3952

4053

41-
def parse_fragment(path: pathlib.Path) -> dict[str, str]:
42-
text = path.read_text(encoding="utf-8")
43-
lines = text.splitlines()
54+
def normalize_front_matter_value(raw: str) -> str:
55+
value = raw.strip()
56+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
57+
return value[1:-1]
58+
if value in {"null", "~"}:
59+
return ""
60+
return value
61+
4462

63+
def parse_front_matter(path: pathlib.Path, lines: list[str]) -> tuple[dict[str, str], int]:
4564
if len(lines) < 4 or lines[0] != "---":
4665
raise FragmentError(f"{path} must start with YAML front matter delimited by '---'")
4766

@@ -52,15 +71,29 @@ def parse_fragment(path: pathlib.Path) -> dict[str, str]:
5271

5372
front_matter: dict[str, str] = {}
5473
for line in lines[1:closing]:
55-
if not line.strip():
74+
stripped = line.strip()
75+
if not stripped or stripped.startswith("#"):
5676
continue
57-
if ":" not in line:
77+
if ":" not in stripped:
5878
raise FragmentError(f"{path} has invalid front matter line: {line}")
59-
key, value = line.split(":", 1)
60-
front_matter[key.strip()] = value.strip()
79+
key, value = stripped.split(":", 1)
80+
key = key.strip()
81+
if not key:
82+
raise FragmentError(f"{path} has invalid front matter line: {line}")
83+
if key in front_matter:
84+
raise FragmentError(f"{path} defines front matter key '{key}' more than once")
85+
front_matter[key] = normalize_front_matter_value(value)
86+
87+
return front_matter, closing
88+
89+
90+
def parse_fragment(path: pathlib.Path) -> dict[str, str]:
91+
text = path.read_text(encoding="utf-8")
92+
lines = text.splitlines()
93+
front_matter, closing = parse_front_matter(path, lines)
6194

6295
kind = front_matter.get("kind", "")
63-
if kind not in {"breaking", "change", "enhancement", "fix"}:
96+
if kind not in ALLOWED_KINDS:
6497
raise FragmentError(f"{path} has invalid kind '{kind}'")
6598

6699
pr_url = front_matter.get("pr", "")
@@ -105,15 +138,26 @@ def make_bullet(fragment: dict[str, str]) -> tuple[str, str]:
105138
return section, f"- {summary} ([#{pr_number}]({pr_url}))."
106139

107140

141+
def fragment_sort_key(fragment: dict[str, str]) -> tuple[int, str]:
142+
# Keep batched changelog entries deterministic across repeated release-prep runs.
143+
return fragment["pr_number"], fragment["filename"]
144+
145+
108146
def collect_fragments() -> list[dict[str, str]]:
109147
paths = sorted(UNRELEASED_DIR.glob("*.md"))
110148
if not paths:
111149
raise FragmentError(f"No fragments found in {UNRELEASED_DIR}")
112150
fragments = [parse_fragment(path) for path in paths]
113-
fragments.sort(key=lambda item: (item["pr_number"], item["filename"]))
151+
fragments.sort(key=fragment_sort_key)
114152
return fragments
115153

116154

155+
def validate_fragments(paths: list[pathlib.Path]) -> list[dict[str, str]]:
156+
if not paths:
157+
raise FragmentError("No fragments were provided for validation")
158+
return [parse_fragment(path) for path in paths]
159+
160+
117161
def build_generated_sections(fragments: list[dict[str, str]]) -> dict[str, list[str]]:
118162
sections: dict[str, list[str]] = {key: [] for key in SECTION_ORDER}
119163
for fragment in fragments:
@@ -210,7 +254,23 @@ def update_changelog(version: str, date: str, generated: dict[str, list[str]]) -
210254
CHANGELOG_PATH.write_text(new_changelog, encoding="utf-8")
211255

212256

257+
def flush_archive() -> None:
258+
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
259+
260+
for path in ARCHIVE_DIR.iterdir():
261+
if path.name.startswith("."):
262+
continue
263+
if path.is_symlink() or path.is_file():
264+
path.unlink()
265+
elif path.is_dir():
266+
shutil.rmtree(path)
267+
else:
268+
raise FragmentError(f"Unsupported archive entry: {path}")
269+
270+
213271
def archive_fragments(version: str, fragments: list[dict[str, str]]) -> None:
272+
# Keep only the current release batch under .changes/archive/.
273+
flush_archive()
214274
destination = ARCHIVE_DIR / version
215275
destination.mkdir(parents=True, exist_ok=True)
216276

@@ -225,6 +285,11 @@ def archive_fragments(version: str, fragments: list[dict[str, str]]) -> None:
225285
def main() -> int:
226286
args = parse_args()
227287
try:
288+
if args.validate_fragment:
289+
fragments = validate_fragments([pathlib.Path(path) for path in args.validate_fragment])
290+
print(f"Validated {len(fragments)} changelog fragment(s).")
291+
return 0
292+
228293
fragments = collect_fragments()
229294
generated = build_generated_sections(fragments)
230295
update_changelog(args.version, args.date, generated)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
import pathlib
5+
import tempfile
6+
import unittest
7+
8+
9+
SCRIPT_PATH = pathlib.Path(__file__).resolve().parents[1] / "prepare-changelog-release.py"
10+
SPEC = importlib.util.spec_from_file_location("prepare_changelog_release", SCRIPT_PATH)
11+
assert SPEC is not None and SPEC.loader is not None
12+
MODULE = importlib.util.module_from_spec(SPEC)
13+
SPEC.loader.exec_module(MODULE)
14+
15+
16+
def write_fragment(path: pathlib.Path, front_matter: str, body: str) -> None:
17+
path.write_text(f"---\n{front_matter}\n---\n\n{body}\n", encoding="utf-8")
18+
19+
20+
class PrepareChangelogReleaseTests(unittest.TestCase):
21+
def test_parse_fragment_accepts_quotes_comments_and_extra_keys(self) -> None:
22+
with tempfile.TemporaryDirectory() as tmp_dir:
23+
fragment = pathlib.Path(tmp_dir) / "123.fix.md"
24+
write_fragment(
25+
fragment,
26+
"\n".join(
27+
[
28+
"# generated by a helper",
29+
'kind: "fix"',
30+
"note: internal metadata",
31+
"pr: 'https://github.com/0xMiden/miden-vm/pull/123'",
32+
"crate: miden-processor",
33+
]
34+
),
35+
"Fix a user-facing behavior in one concise sentence.",
36+
)
37+
38+
parsed = MODULE.parse_fragment(fragment)
39+
40+
self.assertEqual(parsed["kind"], "fix")
41+
self.assertEqual(parsed["crate"], "miden-processor")
42+
self.assertEqual(parsed["pr_number"], 123)
43+
self.assertEqual(
44+
parsed["body"], "Fix a user-facing behavior in one concise sentence."
45+
)
46+
47+
def test_collect_fragments_sorts_by_pr_number_then_filename(self) -> None:
48+
with tempfile.TemporaryDirectory() as tmp_dir:
49+
unreleased = pathlib.Path(tmp_dir)
50+
write_fragment(
51+
unreleased / "10.fix.md",
52+
"kind: fix\npr: https://github.com/0xMiden/miden-vm/pull/10",
53+
"Third in sort order.",
54+
)
55+
write_fragment(
56+
unreleased / "2.change.md",
57+
"kind: change\npr: https://github.com/0xMiden/miden-vm/pull/2",
58+
"First in sort order.",
59+
)
60+
write_fragment(
61+
unreleased / "2.fix.md",
62+
"kind: fix\npr: https://github.com/0xMiden/miden-vm/pull/2",
63+
"Second in sort order.",
64+
)
65+
66+
original_unreleased_dir = MODULE.UNRELEASED_DIR
67+
MODULE.UNRELEASED_DIR = unreleased
68+
try:
69+
fragments = MODULE.collect_fragments()
70+
finally:
71+
MODULE.UNRELEASED_DIR = original_unreleased_dir
72+
73+
self.assertEqual(
74+
[fragment["filename"] for fragment in fragments],
75+
["2.change.md", "2.fix.md", "10.fix.md"],
76+
)
77+
78+
def test_archive_fragments_flushes_old_batches_and_keeps_gitkeep(self) -> None:
79+
with tempfile.TemporaryDirectory() as tmp_dir:
80+
root = pathlib.Path(tmp_dir)
81+
archive_root = root / "archive"
82+
archive_root.mkdir()
83+
(archive_root / ".gitkeep").write_text("", encoding="utf-8")
84+
(archive_root / "0.22.0").mkdir()
85+
(archive_root / "0.22.0" / "stale.fix.md").write_text("stale", encoding="utf-8")
86+
source = root / "12.fix.md"
87+
write_fragment(
88+
source,
89+
"kind: fix\npr: https://github.com/0xMiden/miden-vm/pull/12",
90+
"Archived after release prep.",
91+
)
92+
fragments = [MODULE.parse_fragment(source)]
93+
94+
original_archive_dir = MODULE.ARCHIVE_DIR
95+
MODULE.ARCHIVE_DIR = archive_root
96+
try:
97+
MODULE.archive_fragments("0.23.0", fragments)
98+
finally:
99+
MODULE.ARCHIVE_DIR = original_archive_dir
100+
101+
self.assertFalse(source.exists())
102+
self.assertTrue((archive_root / ".gitkeep").exists())
103+
self.assertFalse((archive_root / "0.22.0").exists())
104+
self.assertTrue((archive_root / "0.23.0" / "12.fix.md").exists())
105+
106+
107+
if __name__ == "__main__":
108+
unittest.main()

0 commit comments

Comments
 (0)