its-magic 0.1.2 → 0.1.3-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/postinstall.js +72 -0
- package/installer.py +55 -0
- package/package.json +1 -1
- package/scripts/check_intake_template_parity.py +28 -0
- package/template/.cursor/commands/release.md +18 -0
- package/template/CHANGELOG.md +11 -0
- package/template/docs/engineering/runbook.md +77 -6
- package/template/handoffs/releases/vX.Y.Z-release-notes.md.example +21 -0
- package/template/scripts/check_intake_template_parity.py +28 -0
- package/template/scripts/dev_environment_lib.py +138 -0
- package/template/scripts/release-all.sh +249 -0
- package/template/scripts/release_changelog_backfill.py +153 -0
- package/template/scripts/release_changelog_lib.py +544 -0
- package/template/scripts/release_changelog_validate.py +134 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Release changelog derivation, coalesce, promote, fingerprint idempotency (US-0100 / DEC-0085).
|
|
3
|
+
|
|
4
|
+
Derivation precedence (L4):
|
|
5
|
+
1. Sprint note ``## What's new`` bullets + inline US-xxxx / BUG-xxxx refs
|
|
6
|
+
2. Backlog ``## US-xxxx`` / ``### BUG-xxxx`` title + summary (one-liner)
|
|
7
|
+
3. Queue row ``story_refs`` (fallback when sprint note sparse)
|
|
8
|
+
|
|
9
|
+
Category map: US→Added, BUG→Fixed, user_visible:false→Changed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
# Derivation precedence (documented L4 order — do not reorder without DEC revision)
|
|
23
|
+
DERIVATION_PRECEDENCE: Tuple[str, ...] = (
|
|
24
|
+
"sprint_notes_whats_new",
|
|
25
|
+
"backlog_title_summary",
|
|
26
|
+
"queue_story_refs",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
US_ID = re.compile(r"\bUS-\d{4}\b")
|
|
30
|
+
BUG_ID = re.compile(r"\bBUG-\d{4}\b")
|
|
31
|
+
SEMVER_SECTION = re.compile(r"^##\s+\[(.+?)\]\s*(?:-\s*(\d{4}-\d{2}-\d{2}))?\s*$", re.MULTILINE)
|
|
32
|
+
USER_VISIBLE = re.compile(r"^-\s*user_visible:\s*(true|false)\s*$", re.IGNORECASE | re.MULTILINE)
|
|
33
|
+
SEMVER_VALID = re.compile(
|
|
34
|
+
r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Fail-closed reason codes (RELEASE_CHANGELOG_* family)
|
|
38
|
+
RELEASE_CHANGELOG_VERSION_MISSING = "RELEASE_CHANGELOG_VERSION_MISSING"
|
|
39
|
+
RELEASE_CHANGELOG_DUPLICATE_VERSION = "RELEASE_CHANGELOG_DUPLICATE_VERSION"
|
|
40
|
+
RELEASE_CHANGELOG_WORK_ITEM_GAP = "RELEASE_CHANGELOG_WORK_ITEM_GAP"
|
|
41
|
+
RELEASE_CHANGELOG_ORDER_INVALID = "RELEASE_CHANGELOG_ORDER_INVALID"
|
|
42
|
+
RELEASE_CHANGELOG_UNRELEASED_MISSING = "RELEASE_CHANGELOG_UNRELEASED_MISSING"
|
|
43
|
+
RELEASE_CHANGELOG_QUEUE_DRIFT = "RELEASE_CHANGELOG_QUEUE_DRIFT"
|
|
44
|
+
RELEASE_CHANGELOG_VERSION_DOC_MISSING = "RELEASE_CHANGELOG_VERSION_DOC_MISSING"
|
|
45
|
+
RELEASE_CHANGELOG_SPRINT_ORPHAN = "RELEASE_CHANGELOG_SPRINT_ORPHAN"
|
|
46
|
+
RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS = "RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS"
|
|
47
|
+
RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION = "RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION"
|
|
48
|
+
RELEASE_CHANGELOG_IDEMPOTENCY_OK = "RELEASE_CHANGELOG_IDEMPOTENCY_OK"
|
|
49
|
+
|
|
50
|
+
FAIL_CODES = (
|
|
51
|
+
RELEASE_CHANGELOG_VERSION_MISSING,
|
|
52
|
+
RELEASE_CHANGELOG_DUPLICATE_VERSION,
|
|
53
|
+
RELEASE_CHANGELOG_WORK_ITEM_GAP,
|
|
54
|
+
RELEASE_CHANGELOG_ORDER_INVALID,
|
|
55
|
+
RELEASE_CHANGELOG_UNRELEASED_MISSING,
|
|
56
|
+
RELEASE_CHANGELOG_QUEUE_DRIFT,
|
|
57
|
+
RELEASE_CHANGELOG_VERSION_DOC_MISSING,
|
|
58
|
+
RELEASE_CHANGELOG_SPRINT_ORPHAN,
|
|
59
|
+
RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS,
|
|
60
|
+
RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ReleaseChangelogError(Exception):
|
|
65
|
+
def __init__(self, code: str, message: str = "") -> None:
|
|
66
|
+
self.code = code
|
|
67
|
+
super().__init__(message or code)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class WorkItem:
|
|
72
|
+
item_id: str
|
|
73
|
+
title: str
|
|
74
|
+
summary: str
|
|
75
|
+
category: str # Added | Fixed | Changed
|
|
76
|
+
source: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class QueueRow:
|
|
81
|
+
sprint_id: str
|
|
82
|
+
story_refs: str
|
|
83
|
+
status: str
|
|
84
|
+
last_updated: str
|
|
85
|
+
release_notes_ref: str
|
|
86
|
+
release_version: str
|
|
87
|
+
raw_line: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_utf8(path: str) -> str:
|
|
91
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
92
|
+
return f.read()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_utf8(path: str, content: str) -> None:
|
|
96
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
97
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
98
|
+
f.write(content)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def normalize_semver(raw: str) -> str:
|
|
102
|
+
"""Strip leading v; semver-parse; raise VERSION_MISSING on empty/invalid."""
|
|
103
|
+
if raw is None:
|
|
104
|
+
raise ReleaseChangelogError(RELEASE_CHANGELOG_VERSION_MISSING, "version is None")
|
|
105
|
+
s = raw.strip()
|
|
106
|
+
if s.lower().startswith("v"):
|
|
107
|
+
s = s[1:]
|
|
108
|
+
if not s:
|
|
109
|
+
raise ReleaseChangelogError(RELEASE_CHANGELOG_VERSION_MISSING, "empty version")
|
|
110
|
+
if not SEMVER_VALID.match(s):
|
|
111
|
+
raise ReleaseChangelogError(
|
|
112
|
+
RELEASE_CHANGELOG_VERSION_MISSING, f"invalid semver: {raw!r}"
|
|
113
|
+
)
|
|
114
|
+
return s
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def version_fingerprint(semver: str, work_item_ids: Sequence[str]) -> str:
|
|
118
|
+
"""Idempotency key: semver + sorted work_item_ids."""
|
|
119
|
+
norm = normalize_semver(semver)
|
|
120
|
+
ids = sorted(set(work_item_ids))
|
|
121
|
+
return f"{norm}|{','.join(ids)}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _one_liner(text: str, max_len: int = 160) -> str:
|
|
125
|
+
line = " ".join(text.split())
|
|
126
|
+
if len(line) > max_len:
|
|
127
|
+
return line[: max_len - 3] + "..."
|
|
128
|
+
return line
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_queue_rows(repo_root: str) -> List[QueueRow]:
|
|
132
|
+
path = os.path.join(repo_root, "handoffs", "release_queue.md")
|
|
133
|
+
if not os.path.isfile(path):
|
|
134
|
+
return []
|
|
135
|
+
rows: List[QueueRow] = []
|
|
136
|
+
for line in read_utf8(path).splitlines():
|
|
137
|
+
if not line.startswith("| S"):
|
|
138
|
+
continue
|
|
139
|
+
parts = [p.strip() for p in line.split("|")]
|
|
140
|
+
if len(parts) < 9:
|
|
141
|
+
continue
|
|
142
|
+
rows.append(
|
|
143
|
+
QueueRow(
|
|
144
|
+
sprint_id=parts[1],
|
|
145
|
+
story_refs=parts[2],
|
|
146
|
+
status=parts[3],
|
|
147
|
+
last_updated=parts[4],
|
|
148
|
+
release_notes_ref=parts[5],
|
|
149
|
+
release_version=parts[7] if len(parts) > 7 else "",
|
|
150
|
+
raw_line=line,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return rows
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _queue_row_map(repo_root: str) -> Dict[str, QueueRow]:
|
|
157
|
+
return {r.sprint_id: r for r in parse_queue_rows(repo_root)}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_backlog_items(repo_root: str) -> Dict[str, WorkItem]:
|
|
161
|
+
backlog_path = os.path.join(repo_root, "docs", "product", "backlog.md")
|
|
162
|
+
if not os.path.isfile(backlog_path):
|
|
163
|
+
return {}
|
|
164
|
+
text = read_utf8(backlog_path)
|
|
165
|
+
items: Dict[str, WorkItem] = {}
|
|
166
|
+
for m in re.finditer(r"^##\s+(US-\d{4})\s*[—\-]\s*(.+)$", text, re.MULTILINE):
|
|
167
|
+
item_id, title = m.group(1), m.group(2).strip()
|
|
168
|
+
block = text[m.end() : text.find("\n## ", m.end())]
|
|
169
|
+
uv = USER_VISIBLE.search(block)
|
|
170
|
+
user_vis = uv.group(1).lower() == "true" if uv else True
|
|
171
|
+
sm = re.search(r"^-\s*Summary:\s*(.+)$", block, re.MULTILINE)
|
|
172
|
+
summary = _one_liner(sm.group(1)) if sm else title
|
|
173
|
+
if not user_vis:
|
|
174
|
+
cat = "Changed"
|
|
175
|
+
else:
|
|
176
|
+
cat = "Added"
|
|
177
|
+
items[item_id] = WorkItem(item_id, title, summary, cat, "backlog_title_summary")
|
|
178
|
+
for m in re.finditer(r"^###\s+(BUG-\d{4})\s*[—\-]\s*(.+)$", text, re.MULTILINE):
|
|
179
|
+
item_id, title = m.group(1), m.group(2).strip()
|
|
180
|
+
block = text[m.end() : text.find("\n### ", m.end())]
|
|
181
|
+
block = block.split("\n## ")[0]
|
|
182
|
+
sm = re.search(r"^-\s*Summary:\s*(.+)$", block, re.MULTILINE)
|
|
183
|
+
summary = _one_liner(sm.group(1)) if sm else title
|
|
184
|
+
items[item_id] = WorkItem(item_id, title, summary, "Fixed", "backlog_title_summary")
|
|
185
|
+
return items
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _extract_whats_new(repo_root: str, sprint_id: str) -> List[str]:
|
|
189
|
+
notes_path = os.path.join(
|
|
190
|
+
repo_root, "handoffs", "releases", f"{sprint_id}-release-notes.md"
|
|
191
|
+
)
|
|
192
|
+
if not os.path.isfile(notes_path):
|
|
193
|
+
return []
|
|
194
|
+
text = read_utf8(notes_path)
|
|
195
|
+
m = re.search(r"^##\s+What's new\s*$", text, re.MULTILINE | re.IGNORECASE)
|
|
196
|
+
if not m:
|
|
197
|
+
return []
|
|
198
|
+
rest = text[m.end() :]
|
|
199
|
+
end = re.search(r"^##\s+", rest, re.MULTILINE)
|
|
200
|
+
section = rest[: end.start()] if end else rest
|
|
201
|
+
bullets: List[str] = []
|
|
202
|
+
for line in section.splitlines():
|
|
203
|
+
s = line.strip()
|
|
204
|
+
if s.startswith("- "):
|
|
205
|
+
bullets.append(s[2:].strip())
|
|
206
|
+
return bullets
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _category_for_id(item_id: str, backlog: Dict[str, WorkItem]) -> str:
|
|
210
|
+
if item_id in backlog:
|
|
211
|
+
return backlog[item_id].category
|
|
212
|
+
if item_id.startswith("BUG-"):
|
|
213
|
+
return "Fixed"
|
|
214
|
+
return "Added"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def derive_work_items(sprint_ids: Sequence[str], repo_root: str) -> List[WorkItem]:
|
|
218
|
+
"""L4 precedence: sprint notes → backlog → queue story_refs."""
|
|
219
|
+
backlog = _parse_backlog_items(repo_root)
|
|
220
|
+
qmap = _queue_row_map(repo_root)
|
|
221
|
+
seen: Dict[str, WorkItem] = {}
|
|
222
|
+
|
|
223
|
+
for sprint_id in sprint_ids:
|
|
224
|
+
# 1. Sprint notes What's new
|
|
225
|
+
for bullet in _extract_whats_new(repo_root, sprint_id):
|
|
226
|
+
for pat in (US_ID, BUG_ID):
|
|
227
|
+
for item_id in pat.findall(bullet):
|
|
228
|
+
if item_id in seen:
|
|
229
|
+
continue
|
|
230
|
+
title = backlog[item_id].title if item_id in backlog else item_id
|
|
231
|
+
summary = _one_liner(bullet) if bullet else title
|
|
232
|
+
seen[item_id] = WorkItem(
|
|
233
|
+
item_id,
|
|
234
|
+
title,
|
|
235
|
+
summary,
|
|
236
|
+
_category_for_id(item_id, backlog),
|
|
237
|
+
"sprint_notes_whats_new",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# 2. Backlog for refs mentioned in sprint notes full text
|
|
241
|
+
notes_path = os.path.join(
|
|
242
|
+
repo_root, "handoffs", "releases", f"{sprint_id}-release-notes.md"
|
|
243
|
+
)
|
|
244
|
+
if os.path.isfile(notes_path):
|
|
245
|
+
note_text = read_utf8(notes_path)
|
|
246
|
+
for item_id in US_ID.findall(note_text) + BUG_ID.findall(note_text):
|
|
247
|
+
if item_id in seen:
|
|
248
|
+
continue
|
|
249
|
+
if item_id in backlog:
|
|
250
|
+
wi = backlog[item_id]
|
|
251
|
+
seen[item_id] = WorkItem(
|
|
252
|
+
wi.item_id, wi.title, wi.summary, wi.category, wi.source
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# 3. Queue story_refs fallback
|
|
256
|
+
row = qmap.get(sprint_id)
|
|
257
|
+
if row:
|
|
258
|
+
for token in re.split(r"[,;\s]+", row.story_refs):
|
|
259
|
+
token = token.strip()
|
|
260
|
+
if not token or token in seen:
|
|
261
|
+
continue
|
|
262
|
+
if token in backlog:
|
|
263
|
+
wi = backlog[token]
|
|
264
|
+
seen[token] = WorkItem(
|
|
265
|
+
wi.item_id, wi.title, wi.summary, wi.category, "queue_story_refs"
|
|
266
|
+
)
|
|
267
|
+
elif US_ID.fullmatch(token) or BUG_ID.fullmatch(token):
|
|
268
|
+
seen[token] = WorkItem(
|
|
269
|
+
token,
|
|
270
|
+
token,
|
|
271
|
+
token,
|
|
272
|
+
_category_for_id(token, backlog),
|
|
273
|
+
"queue_story_refs",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def sort_key(w: WorkItem) -> Tuple[int, int]:
|
|
277
|
+
if w.item_id.startswith("US-"):
|
|
278
|
+
return (0, int(w.item_id.split("-")[1]))
|
|
279
|
+
return (1, int(w.item_id.split("-")[1]))
|
|
280
|
+
|
|
281
|
+
return sorted(seen.values(), key=sort_key)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def coalesce_sprints_by_semver(
|
|
285
|
+
rows: Optional[Sequence[QueueRow]], repo_root: str
|
|
286
|
+
) -> Dict[str, List[str]]:
|
|
287
|
+
"""Group released queue rows by normalized semver (non-empty release_version)."""
|
|
288
|
+
if rows is None:
|
|
289
|
+
rows = parse_queue_rows(repo_root)
|
|
290
|
+
groups: Dict[str, List[str]] = {}
|
|
291
|
+
for row in rows:
|
|
292
|
+
if row.status != "released":
|
|
293
|
+
continue
|
|
294
|
+
if not row.release_version.strip():
|
|
295
|
+
continue
|
|
296
|
+
try:
|
|
297
|
+
key = normalize_semver(row.release_version)
|
|
298
|
+
except ReleaseChangelogError:
|
|
299
|
+
continue
|
|
300
|
+
groups.setdefault(key, []).append(row.sprint_id)
|
|
301
|
+
for key in groups:
|
|
302
|
+
groups[key] = sorted(set(groups[key]))
|
|
303
|
+
return groups
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def version_doc_path(repo_root: str, semver: str) -> str:
|
|
307
|
+
norm = normalize_semver(semver)
|
|
308
|
+
return os.path.join(repo_root, "handoffs", "releases", f"{norm}-release-notes.md")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _format_version_doc(
|
|
312
|
+
semver: str,
|
|
313
|
+
work_items: Sequence[WorkItem],
|
|
314
|
+
sprint_ids: Sequence[str],
|
|
315
|
+
fingerprint: str,
|
|
316
|
+
) -> str:
|
|
317
|
+
lines = [
|
|
318
|
+
f"# Release notes — {normalize_semver(semver)}",
|
|
319
|
+
"",
|
|
320
|
+
f"<!-- release_changelog_fingerprint: {fingerprint} -->",
|
|
321
|
+
"",
|
|
322
|
+
"> Per-version GitHub `-F` SOT (**US-0100**). Sprint-scoped evidence remains in "
|
|
323
|
+
"`handoffs/releases/Sxxxx-release-notes.md` — do not overwrite unrelated version files.",
|
|
324
|
+
"",
|
|
325
|
+
"## Work items",
|
|
326
|
+
"",
|
|
327
|
+
]
|
|
328
|
+
for wi in work_items:
|
|
329
|
+
lines.append(f"- **{wi.item_id}** — {wi.summary}")
|
|
330
|
+
lines.extend(["", "## Sprint evidence", ""])
|
|
331
|
+
for sid in sorted(set(sprint_ids)):
|
|
332
|
+
lines.append(f"- [`{sid}`](handoffs/releases/{sid}-release-notes.md)")
|
|
333
|
+
lines.append("")
|
|
334
|
+
return "\n".join(lines)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def build_version_doc(semver: str, sprint_ids: Sequence[str], repo_root: str) -> str:
|
|
338
|
+
"""Write per-version doc; return path. Idempotent on matching fingerprint."""
|
|
339
|
+
norm = normalize_semver(semver)
|
|
340
|
+
work_items = derive_work_items(sprint_ids, repo_root)
|
|
341
|
+
fp = version_fingerprint(norm, [w.item_id for w in work_items])
|
|
342
|
+
path = version_doc_path(repo_root, norm)
|
|
343
|
+
|
|
344
|
+
if os.path.isfile(path):
|
|
345
|
+
existing = read_utf8(path)
|
|
346
|
+
m = re.search(r"<!-- release_changelog_fingerprint:\s*(.+?)\s*-->", existing)
|
|
347
|
+
if m and m.group(1).strip() == fp:
|
|
348
|
+
print(RELEASE_CHANGELOG_IDEMPOTENCY_OK, file=__import__("sys").stderr)
|
|
349
|
+
return path
|
|
350
|
+
if m and m.group(1).strip() != fp:
|
|
351
|
+
raise ReleaseChangelogError(
|
|
352
|
+
RELEASE_CHANGELOG_DUPLICATE_VERSION,
|
|
353
|
+
f"fingerprint mismatch for {norm}",
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
content = _format_version_doc(norm, work_items, sprint_ids, fp)
|
|
357
|
+
write_utf8(path, content)
|
|
358
|
+
return path
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def changelog_path(repo_root: str) -> str:
|
|
362
|
+
return os.path.join(repo_root, "CHANGELOG.md")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def ensure_changelog_stub(repo_root: str) -> None:
|
|
366
|
+
path = changelog_path(repo_root)
|
|
367
|
+
if os.path.isfile(path):
|
|
368
|
+
return
|
|
369
|
+
stub = (
|
|
370
|
+
"# Changelog\n\n"
|
|
371
|
+
"All notable changes to this project will be documented in this file.\n\n"
|
|
372
|
+
"The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n"
|
|
373
|
+
"and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n"
|
|
374
|
+
"<!-- semver-sections-newest-first -->\n\n"
|
|
375
|
+
"## [Unreleased]\n\n"
|
|
376
|
+
)
|
|
377
|
+
write_utf8(path, stub)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def extract_changelog_section(semver: str, repo_root: str) -> Optional[str]:
|
|
381
|
+
"""Read cumulative section text for semver (body only, no heading)."""
|
|
382
|
+
path = changelog_path(repo_root)
|
|
383
|
+
if not os.path.isfile(path):
|
|
384
|
+
return None
|
|
385
|
+
norm = normalize_semver(semver)
|
|
386
|
+
text = read_utf8(path)
|
|
387
|
+
pattern = re.compile(
|
|
388
|
+
rf"^##\s+\[{re.escape(norm)}\]\s*(?:-\s*\d{{4}}-\d{{2}}-\d{{2}})?\s*$",
|
|
389
|
+
re.MULTILINE,
|
|
390
|
+
)
|
|
391
|
+
m = pattern.search(text)
|
|
392
|
+
if not m:
|
|
393
|
+
return None
|
|
394
|
+
rest = text[m.end() :]
|
|
395
|
+
nxt = re.search(r"^##\s+\[", rest, re.MULTILINE)
|
|
396
|
+
body = rest[: nxt.start()] if nxt else rest
|
|
397
|
+
return body.strip()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _split_changelog_sections(text: str) -> Tuple[str, str, List[Tuple[str, str, str]]]:
|
|
401
|
+
"""Return (header, unreleased_body, [(semver, date, body), ...])."""
|
|
402
|
+
m = re.search(r"^##\s+\[Unreleased\]\s*$", text, re.MULTILINE)
|
|
403
|
+
if not m:
|
|
404
|
+
raise ReleaseChangelogError(RELEASE_CHANGELOG_UNRELEASED_MISSING)
|
|
405
|
+
header = text[: m.start()]
|
|
406
|
+
rest = text[m.end() :]
|
|
407
|
+
sections: List[Tuple[str, str, str]] = []
|
|
408
|
+
pos = 0
|
|
409
|
+
for sm in SEMVER_SECTION.finditer(rest):
|
|
410
|
+
if sm.start() > pos:
|
|
411
|
+
# content before first semver section is unreleased body
|
|
412
|
+
pass
|
|
413
|
+
semver_raw = sm.group(1)
|
|
414
|
+
if semver_raw.lower() == "unreleased":
|
|
415
|
+
continue
|
|
416
|
+
date = sm.group(2) or ""
|
|
417
|
+
start = sm.end()
|
|
418
|
+
nxt = SEMVER_SECTION.search(rest, start)
|
|
419
|
+
body = rest[start : nxt.start() if nxt else len(rest)]
|
|
420
|
+
sections.append((semver_raw, date, body.strip()))
|
|
421
|
+
unreleased_end = SEMVER_SECTION.search(rest)
|
|
422
|
+
unreleased_body = rest[: unreleased_end.start()].strip() if unreleased_end else rest.strip()
|
|
423
|
+
return header, unreleased_body, sections
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _format_work_items_changelog(work_items: Sequence[WorkItem]) -> str:
|
|
427
|
+
by_cat: Dict[str, List[WorkItem]] = {"Added": [], "Fixed": [], "Changed": []}
|
|
428
|
+
for wi in work_items:
|
|
429
|
+
by_cat.setdefault(wi.category, []).append(wi)
|
|
430
|
+
parts: List[str] = []
|
|
431
|
+
for cat in ("Added", "Fixed", "Changed"):
|
|
432
|
+
items = by_cat.get(cat) or []
|
|
433
|
+
if not items:
|
|
434
|
+
continue
|
|
435
|
+
parts.append(f"### {cat}")
|
|
436
|
+
parts.append("")
|
|
437
|
+
for wi in items:
|
|
438
|
+
parts.append(f"- **{wi.item_id}** — {wi.summary}")
|
|
439
|
+
parts.append("")
|
|
440
|
+
return "\n".join(parts).strip()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def append_unreleased(work_items: Sequence[WorkItem], repo_root: str) -> None:
|
|
444
|
+
"""Append categorized bullets under top [Unreleased] only."""
|
|
445
|
+
ensure_changelog_stub(repo_root)
|
|
446
|
+
path = changelog_path(repo_root)
|
|
447
|
+
text = read_utf8(path)
|
|
448
|
+
header, unreleased_body, sections = _split_changelog_sections(text)
|
|
449
|
+
new_block = _format_work_items_changelog(work_items)
|
|
450
|
+
if new_block:
|
|
451
|
+
if unreleased_body:
|
|
452
|
+
unreleased_body = unreleased_body + "\n\n" + new_block
|
|
453
|
+
else:
|
|
454
|
+
unreleased_body = new_block
|
|
455
|
+
out = header + "## [Unreleased]\n\n"
|
|
456
|
+
if unreleased_body:
|
|
457
|
+
out += unreleased_body + "\n\n"
|
|
458
|
+
for semver, date, body in sections:
|
|
459
|
+
out += f"## [{semver}]"
|
|
460
|
+
if date:
|
|
461
|
+
out += f" - {date}"
|
|
462
|
+
out += "\n\n" + body + "\n\n"
|
|
463
|
+
write_utf8(path, out.rstrip() + "\n")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def promote_unreleased(
|
|
467
|
+
semver: str,
|
|
468
|
+
sprint_ids: Sequence[str],
|
|
469
|
+
repo_root: str,
|
|
470
|
+
release_date: Optional[str] = None,
|
|
471
|
+
) -> None:
|
|
472
|
+
"""Move [Unreleased] items into ## [semver] - date; recreate empty [Unreleased]."""
|
|
473
|
+
ensure_changelog_stub(repo_root)
|
|
474
|
+
path = changelog_path(repo_root)
|
|
475
|
+
text = read_utf8(path)
|
|
476
|
+
header, unreleased_body, sections = _split_changelog_sections(text)
|
|
477
|
+
norm = normalize_semver(semver)
|
|
478
|
+
work_items = derive_work_items(sprint_ids, repo_root)
|
|
479
|
+
promoted = _format_work_items_changelog(work_items)
|
|
480
|
+
if unreleased_body:
|
|
481
|
+
promoted = (unreleased_body + "\n\n" + promoted).strip() if promoted else unreleased_body
|
|
482
|
+
date = release_date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
483
|
+
# Remove existing section for same semver
|
|
484
|
+
sections = [(s, d, b) for s, d, b in sections if normalize_semver(s) != norm]
|
|
485
|
+
sections.insert(0, (norm, date, promoted))
|
|
486
|
+
out = header + "## [Unreleased]\n\n\n"
|
|
487
|
+
for s, d, b in sections:
|
|
488
|
+
out += f"## [{s}]"
|
|
489
|
+
if d:
|
|
490
|
+
out += f" - {d}"
|
|
491
|
+
out += "\n\n" + b + "\n\n"
|
|
492
|
+
write_utf8(path, out.rstrip() + "\n")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def bind_queue_release_version(
|
|
496
|
+
sprint_ids: Sequence[str], semver: str, repo_root: str
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Mutate only specified sprint rows in handoffs/release_queue.md."""
|
|
499
|
+
path = os.path.join(repo_root, "handoffs", "release_queue.md")
|
|
500
|
+
if not os.path.isfile(path):
|
|
501
|
+
raise ReleaseChangelogError(
|
|
502
|
+
RELEASE_CHANGELOG_QUEUE_DRIFT, "release_queue.md missing"
|
|
503
|
+
)
|
|
504
|
+
norm = normalize_semver(semver)
|
|
505
|
+
targets = set(sprint_ids)
|
|
506
|
+
lines = read_utf8(path).splitlines()
|
|
507
|
+
out: List[str] = []
|
|
508
|
+
for line in lines:
|
|
509
|
+
if not line.startswith("| S"):
|
|
510
|
+
out.append(line)
|
|
511
|
+
continue
|
|
512
|
+
cols = [p.strip() for p in line.split("|")[1:-1]]
|
|
513
|
+
if len(cols) < 7:
|
|
514
|
+
out.append(line)
|
|
515
|
+
continue
|
|
516
|
+
if cols[0] in targets:
|
|
517
|
+
cols[6] = norm
|
|
518
|
+
out.append("| " + " | ".join(cols) + " |")
|
|
519
|
+
else:
|
|
520
|
+
out.append(line)
|
|
521
|
+
write_utf8(path, "\n".join(out) + "\n")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def ensure_version_doc_for_release(semver: str, repo_root: str) -> str:
|
|
525
|
+
"""Coalesce released rows for semver (or all unbound since tag) and build doc."""
|
|
526
|
+
norm = normalize_semver(semver)
|
|
527
|
+
rows = parse_queue_rows(repo_root)
|
|
528
|
+
sprint_ids: List[str] = []
|
|
529
|
+
for row in rows:
|
|
530
|
+
if row.status != "released":
|
|
531
|
+
continue
|
|
532
|
+
rv = row.release_version.strip()
|
|
533
|
+
if rv and normalize_semver(rv) == norm:
|
|
534
|
+
sprint_ids.append(row.sprint_id)
|
|
535
|
+
elif not rv:
|
|
536
|
+
sprint_ids.append(row.sprint_id)
|
|
537
|
+
if not sprint_ids:
|
|
538
|
+
sprint_ids = [r.sprint_id for r in rows if r.status == "released"][-1:]
|
|
539
|
+
return build_version_doc(norm, sprint_ids, repo_root)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def runtime_proof_hash(payload: Dict[str, object]) -> str:
|
|
543
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
544
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Validate release changelog / version-doc consistency (US-0100 / DEC-0085).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
_REPO_ROOT = os.path.normpath(os.path.join(_SCRIPT_DIR, ".."))
|
|
15
|
+
if _REPO_ROOT not in sys.path:
|
|
16
|
+
sys.path.insert(0, _REPO_ROOT)
|
|
17
|
+
if _SCRIPT_DIR not in sys.path:
|
|
18
|
+
sys.path.insert(0, _SCRIPT_DIR)
|
|
19
|
+
|
|
20
|
+
import release_changelog_lib as rcl # noqa: E402
|
|
21
|
+
|
|
22
|
+
REMEDIATION = {
|
|
23
|
+
rcl.RELEASE_CHANGELOG_VERSION_MISSING: "Set explicit semver on queue row or pass valid --semver.",
|
|
24
|
+
rcl.RELEASE_CHANGELOG_DUPLICATE_VERSION: "Resolve fingerprint mismatch; do not reuse semver for different work items.",
|
|
25
|
+
rcl.RELEASE_CHANGELOG_WORK_ITEM_GAP: "Run derive/build_version_doc or complete sprint notes + backlog refs.",
|
|
26
|
+
rcl.RELEASE_CHANGELOG_ORDER_INVALID: "Reorder CHANGELOG.md semver sections newest-first.",
|
|
27
|
+
rcl.RELEASE_CHANGELOG_UNRELEASED_MISSING: "Add mandatory ## [Unreleased] header at top of CHANGELOG.md.",
|
|
28
|
+
rcl.RELEASE_CHANGELOG_QUEUE_DRIFT: "Re-run bind_queue_release_version or reconcile queue release_version.",
|
|
29
|
+
rcl.RELEASE_CHANGELOG_VERSION_DOC_MISSING: "Run build_version_doc or release_changelog_backfill before gh -F attach.",
|
|
30
|
+
rcl.RELEASE_CHANGELOG_SPRINT_ORPHAN: "Include released sprint in semver doc or [Unreleased] via backfill.",
|
|
31
|
+
rcl.RELEASE_CHANGELOG_BACKFILL_AMBIGUOUS: "Fix manifest collision; one semver intent per coalesce group.",
|
|
32
|
+
rcl.RELEASE_CHANGELOG_IDEMPOTENCY_VIOLATION: "Remove duplicate work-item bullets for same version; re-run derive.",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _emit(code: str, detail: str = "") -> None:
|
|
37
|
+
msg = code if not detail else f"{code}: {detail}"
|
|
38
|
+
print(msg, file=sys.stderr)
|
|
39
|
+
rem = REMEDIATION.get(code, "See docs/engineering/runbook.md § Version-scoped release docs.")
|
|
40
|
+
print(f"Remediation: {rem}", file=sys.stderr)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate(repo_root: str, enforce: bool) -> int:
|
|
44
|
+
errors: list[str] = []
|
|
45
|
+
cl_path = rcl.changelog_path(repo_root)
|
|
46
|
+
if not os.path.isfile(cl_path):
|
|
47
|
+
errors.append(rcl.RELEASE_CHANGELOG_UNRELEASED_MISSING)
|
|
48
|
+
else:
|
|
49
|
+
text = rcl.read_utf8(cl_path)
|
|
50
|
+
if "## [Unreleased]" not in text:
|
|
51
|
+
errors.append(rcl.RELEASE_CHANGELOG_UNRELEASED_MISSING)
|
|
52
|
+
# semver order newest-first (skip Unreleased)
|
|
53
|
+
versions: list[str] = []
|
|
54
|
+
for m in re.finditer(r"^##\s+\[(.+?)\]", text, re.MULTILINE):
|
|
55
|
+
v = m.group(1)
|
|
56
|
+
if v.lower() == "unreleased":
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
versions.append(rcl.normalize_semver(v))
|
|
60
|
+
except rcl.ReleaseChangelogError:
|
|
61
|
+
errors.append(rcl.RELEASE_CHANGELOG_VERSION_MISSING)
|
|
62
|
+
# Simple order check: compare as strings after normalization (pre-release aware minimal)
|
|
63
|
+
if len(versions) > 1:
|
|
64
|
+
for i in range(len(versions) - 1):
|
|
65
|
+
if versions[i] < versions[i + 1]:
|
|
66
|
+
errors.append(rcl.RELEASE_CHANGELOG_ORDER_INVALID)
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
rows = rcl.parse_queue_rows(repo_root)
|
|
70
|
+
for row in rows:
|
|
71
|
+
if row.status != "released":
|
|
72
|
+
continue
|
|
73
|
+
rv = row.release_version.strip()
|
|
74
|
+
if not rv:
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
norm = rcl.normalize_semver(rv)
|
|
78
|
+
except rcl.ReleaseChangelogError:
|
|
79
|
+
errors.append(rcl.RELEASE_CHANGELOG_VERSION_MISSING)
|
|
80
|
+
continue
|
|
81
|
+
vdoc = rcl.version_doc_path(repo_root, norm)
|
|
82
|
+
if not os.path.isfile(vdoc):
|
|
83
|
+
errors.append(rcl.RELEASE_CHANGELOG_VERSION_DOC_MISSING)
|
|
84
|
+
section = rcl.extract_changelog_section(norm, repo_root)
|
|
85
|
+
if section is None:
|
|
86
|
+
errors.append(rcl.RELEASE_CHANGELOG_VERSION_MISSING)
|
|
87
|
+
# work item gap: story_refs should appear in version doc
|
|
88
|
+
if os.path.isfile(vdoc):
|
|
89
|
+
doc_text = rcl.read_utf8(vdoc)
|
|
90
|
+
for token in re.split(r"[,;\s]+", row.story_refs):
|
|
91
|
+
token = token.strip()
|
|
92
|
+
if token and token not in doc_text:
|
|
93
|
+
errors.append(rcl.RELEASE_CHANGELOG_WORK_ITEM_GAP)
|
|
94
|
+
|
|
95
|
+
# queue drift: coalesce groups vs bound semver
|
|
96
|
+
groups = rcl.coalesce_sprints_by_semver(rows, repo_root)
|
|
97
|
+
for semver, sids in groups.items():
|
|
98
|
+
for sid in sids:
|
|
99
|
+
row = next((r for r in rows if r.sprint_id == sid), None)
|
|
100
|
+
if row and row.release_version.strip():
|
|
101
|
+
try:
|
|
102
|
+
if rcl.normalize_semver(row.release_version) != semver:
|
|
103
|
+
errors.append(rcl.RELEASE_CHANGELOG_QUEUE_DRIFT)
|
|
104
|
+
except rcl.ReleaseChangelogError:
|
|
105
|
+
errors.append(rcl.RELEASE_CHANGELOG_QUEUE_DRIFT)
|
|
106
|
+
|
|
107
|
+
seen_errors = sorted(set(errors))
|
|
108
|
+
for code in seen_errors:
|
|
109
|
+
_emit(code)
|
|
110
|
+
if seen_errors and enforce:
|
|
111
|
+
return 1
|
|
112
|
+
if not seen_errors:
|
|
113
|
+
print("[RELEASE_CHANGELOG_VALIDATE_OK]")
|
|
114
|
+
return 0
|
|
115
|
+
if not enforce:
|
|
116
|
+
print("[RELEASE_CHANGELOG_VALIDATE_WARN]")
|
|
117
|
+
return 0
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main() -> int:
|
|
122
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
123
|
+
parser.add_argument("--repo", default=_REPO_ROOT, help="Repository root")
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--enforce",
|
|
126
|
+
action="store_true",
|
|
127
|
+
help="Exit non-zero on any fail code (release gate / release-all.sh).",
|
|
128
|
+
)
|
|
129
|
+
args = parser.parse_args()
|
|
130
|
+
return validate(os.path.abspath(args.repo), args.enforce)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
raise SystemExit(main())
|