Skip to content

Commit 31ee818

Browse files
committed
fix(release): address Copilot review feedback
1 parent e785aaf commit 31ee818

3 files changed

Lines changed: 126 additions & 36 deletions

File tree

.github/workflows/publish-release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ permissions:
1212

1313
jobs:
1414
publish-release:
15-
if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'release/next'
15+
if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'release/next' && github.event.pull_request.head.repo.full_name == github.repository
1616
runs-on: ubuntu-latest
1717

1818
steps:

.github/workflows/release-pr.yml

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,15 @@ jobs:
6666
const base = 'development';
6767
const title = `chore(release): prepare v${process.env.RELEASE_VERSION}`;
6868
const marker = '<!-- st-lib-release-pr -->';
69-
const body = `${marker}
70-
71-
This PR was prepared automatically from the pending ST-LIB changesets.
72-
73-
- Version: \`v${process.env.RELEASE_VERSION}\`
74-
- Source of truth: \`VERSION\`
75-
- Release notes source: \`CHANGELOG.md\``;
69+
const body = [
70+
marker,
71+
'',
72+
'This PR was prepared automatically from the pending ST-LIB changesets.',
73+
'',
74+
`- Version: \`v${process.env.RELEASE_VERSION}\``,
75+
'- Source of truth: `VERSION`',
76+
'- Release notes source: `CHANGELOG.md`',
77+
].join('\n');
7678
7779
const { data: pulls } = await github.rest.pulls.list({
7880
owner,

tools/release.py

Lines changed: 116 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -219,29 +219,69 @@ def build_preview_markdown(root: Path) -> str:
219219
return "\n".join(lines).strip() + "\n"
220220

221221

222-
def git_changed_changesets(root: Path, base: str, head: str) -> list[Path]:
222+
def relevant_changeset_path(root: Path, path_str: str) -> Path | None:
223+
path = Path(path_str)
224+
if (
225+
len(path.parts) == 2
226+
and path.parts[0] == ".changesets"
227+
and path.suffix == ".md"
228+
and path.name not in IGNORED_CHANGESET_FILES
229+
):
230+
return root / path
231+
return None
232+
233+
234+
def git_changed_changesets(root: Path, base: str, head: str) -> tuple[list[Path], list[Path]]:
223235
result = subprocess.run(
224-
["git", "diff", "--name-only", "--diff-filter=AMRT", f"{base}...{head}", "--"],
236+
["git", "-c", "diff.renames=false", "diff", "--name-status", f"{base}...{head}", "--"],
225237
cwd=root,
226238
check=True,
227239
capture_output=True,
228240
text=True,
229241
)
230-
changed_files = []
231-
for raw_path in result.stdout.splitlines():
232-
path = Path(raw_path)
233-
if (
234-
len(path.parts) == 2
235-
and path.parts[0] == ".changesets"
236-
and path.suffix == ".md"
237-
and path.name not in IGNORED_CHANGESET_FILES
238-
):
239-
changed_files.append(root / path)
240-
return sorted(changed_files)
242+
changed_files: list[Path] = []
243+
deleted_files: list[Path] = []
244+
245+
for raw_line in result.stdout.splitlines():
246+
if not raw_line.strip():
247+
continue
248+
249+
parts = raw_line.split("\t")
250+
status = parts[0]
251+
252+
if status.startswith(("R", "C")):
253+
if len(parts) < 3:
254+
continue
255+
old_path = relevant_changeset_path(root, parts[1])
256+
new_path = relevant_changeset_path(root, parts[2])
257+
if old_path is not None and new_path is None:
258+
deleted_files.append(old_path)
259+
elif new_path is not None:
260+
changed_files.append(new_path)
261+
continue
262+
263+
if len(parts) < 2:
264+
continue
265+
266+
path = relevant_changeset_path(root, parts[1])
267+
if path is None:
268+
continue
269+
if status == "D":
270+
deleted_files.append(path)
271+
else:
272+
changed_files.append(path)
273+
274+
return sorted(set(changed_files)), sorted(set(deleted_files))
241275

242276

243277
def validate_pr_changeset(root: Path, base: str, head: str) -> int:
244-
changed_changesets = git_changed_changesets(root, base, head)
278+
changed_changesets, deleted_changesets = git_changed_changesets(root, base, head)
279+
if deleted_changesets:
280+
joined = ", ".join(str(path.relative_to(root)) for path in deleted_changesets)
281+
raise ValueError(
282+
"PRs must not delete changeset files under .changesets/. "
283+
f"Deleted changesets: {joined}"
284+
)
245285
if len(changed_changesets) != 1:
246286
joined = ", ".join(str(path.relative_to(root)) for path in changed_changesets) or "none"
247287
raise ValueError(
@@ -268,32 +308,64 @@ def build_changelog_entry(version: str, changesets: list[Changeset]) -> str:
268308
for item in items:
269309
lines.append(f"- {item.summary}")
270310
if item.details:
271-
lines.append(f" {item.details}")
311+
for detail_line in item.details.splitlines():
312+
lines.append(f" {detail_line}" if detail_line else " ")
272313
lines.append("")
273314

274315
return "\n".join(lines).rstrip()
275316

276317

277-
def prepend_changelog_entry(root: Path, entry: str) -> None:
278-
changelog_file = changelog_path(root)
279-
existing = changelog_file.read_text(encoding="utf-8")
318+
def render_changelog_with_entry(existing: str, entry: str) -> str:
280319
heading_match = re.search(r"^## ", existing, flags=re.MULTILINE)
281320
if heading_match:
282321
insertion_point = heading_match.start()
283-
new_text = existing[:insertion_point].rstrip() + "\n\n" + entry + "\n\n" + existing[insertion_point:].lstrip()
322+
return (
323+
existing[:insertion_point].rstrip()
324+
+ "\n\n"
325+
+ entry
326+
+ "\n\n"
327+
+ existing[insertion_point:].lstrip()
328+
)
284329
else:
285-
new_text = existing.rstrip() + "\n\n" + entry + "\n"
286-
changelog_file.write_text(new_text, encoding="utf-8")
330+
return existing.rstrip() + "\n\n" + entry + "\n"
287331

288332

289-
def archive_changesets(root: Path, version: str, changesets: list[Changeset]) -> None:
333+
def archive_changesets(root: Path, version: str, changesets: list[Changeset]) -> list[tuple[Path, Path]]:
290334
archive_directory = changeset_dir(root) / "archive" / f"v{version}"
291335
archive_directory.mkdir(parents=True, exist_ok=True)
292-
for changeset in changesets:
293-
destination = archive_directory / changeset.path.name
336+
destinations = [(changeset.path, archive_directory / changeset.path.name) for changeset in changesets]
337+
338+
for _, destination in destinations:
294339
if destination.exists():
295340
raise FileExistsError(f"Archive destination already exists: {destination}")
296-
shutil.move(str(changeset.path), destination)
341+
342+
moved_changesets: list[tuple[Path, Path]] = []
343+
try:
344+
for changeset in changesets:
345+
destination = archive_directory / changeset.path.name
346+
shutil.move(str(changeset.path), destination)
347+
moved_changesets.append((changeset.path, destination))
348+
except Exception:
349+
rollback_archived_changesets(root, moved_changesets)
350+
raise
351+
return moved_changesets
352+
353+
354+
def rollback_archived_changesets(root: Path, moved_changesets: list[tuple[Path, Path]]) -> None:
355+
archive_root = changeset_dir(root) / "archive"
356+
for source, destination in reversed(moved_changesets):
357+
if destination.exists():
358+
shutil.move(str(destination), source)
359+
360+
current = destination.parent
361+
while current != archive_root.parent:
362+
try:
363+
current.rmdir()
364+
except OSError:
365+
break
366+
current = current.parent
367+
if current == archive_root.parent:
368+
break
297369

298370

299371
def apply_release(root: Path) -> str:
@@ -309,9 +381,25 @@ def apply_release(root: Path) -> str:
309381
"Pending changesets exist, but all are marked 'none'; nothing to release yet"
310382
)
311383

312-
version_path(root).write_text(computed_next_version + "\n", encoding="utf-8")
313-
prepend_changelog_entry(root, build_changelog_entry(computed_next_version, changesets))
314-
archive_changesets(root, computed_next_version, changesets)
384+
version_file = version_path(root)
385+
changelog_file = changelog_path(root)
386+
original_version = version_file.read_text(encoding="utf-8")
387+
original_changelog = changelog_file.read_text(encoding="utf-8")
388+
updated_changelog = render_changelog_with_entry(
389+
original_changelog, build_changelog_entry(computed_next_version, changesets)
390+
)
391+
392+
moved_changesets: list[tuple[Path, Path]] = []
393+
try:
394+
moved_changesets = archive_changesets(root, computed_next_version, changesets)
395+
changelog_file.write_text(updated_changelog, encoding="utf-8")
396+
version_file.write_text(computed_next_version + "\n", encoding="utf-8")
397+
except Exception:
398+
if moved_changesets:
399+
rollback_archived_changesets(root, moved_changesets)
400+
changelog_file.write_text(original_changelog, encoding="utf-8")
401+
version_file.write_text(original_version, encoding="utf-8")
402+
raise
315403
return computed_next_version
316404

317405

0 commit comments

Comments
 (0)