ci: standardize release process#7
Conversation
Add scripts/release.sh for version bumps, changelog updates, tagging, and GitHub Release creation via CI. Enforce changelog checks on version PRs.
| else: | ||
| insert = ( | ||
| f"## [Unreleased]\n\n" | ||
| f"## [{ver}] - {date}\n\n" | ||
| "### Changed\n\n" | ||
| f"- Release {ver}\n\n" | ||
| ) | ||
| if "## [Unreleased]" in text: | ||
| text = text.replace("## [Unreleased]", insert.strip() + "\n\n## [Unreleased]", 1) | ||
| else: | ||
| text = re.sub(r"(^# Changelog.*?)(\n\n|\Z)", r"\1\n\n" + insert, text, count=1, flags=re.S) |
There was a problem hiding this comment.
Blocking: this branch produces a CHANGELOG with two ## [Unreleased] headings.
insert.strip() already begins with ## [Unreleased], then the replacement string appends another \n\n## [Unreleased]. Running this against the CHANGELOG in this PR to release 0.1.2 yields:
## [Unreleased]
## [0.1.2] - 2026-05-20
### Changed
- Release 0.1.2
## [Unreleased] ← duplicate
## [0.1.1] - 2026-05-19
...
check-release.py won't catch this because it only looks for the new version heading.
Fix: replace once with just the new version section, leaving the existing ## [Unreleased] heading in place — e.g.:
| else: | |
| insert = ( | |
| f"## [Unreleased]\n\n" | |
| f"## [{ver}] - {date}\n\n" | |
| "### Changed\n\n" | |
| f"- Release {ver}\n\n" | |
| ) | |
| if "## [Unreleased]" in text: | |
| text = text.replace("## [Unreleased]", insert.strip() + "\n\n## [Unreleased]", 1) | |
| else: | |
| text = re.sub(r"(^# Changelog.*?)(\n\n|\Z)", r"\1\n\n" + insert, text, count=1, flags=re.S) | |
| else: | |
| new_section = ( | |
| f"## [{ver}] - {date}\n\n" | |
| "### Changed\n\n" | |
| f"- Release {ver}\n\n" | |
| ) | |
| if "## [Unreleased]" in text: | |
| text = text.replace("## [Unreleased]\n\n", f"## [Unreleased]\n\n{new_section}", 1) | |
| else: | |
| text = re.sub(r"(^# Changelog.*?)(\n\n|\Z)", r"\1\n\n## [Unreleased]\n\n" + new_section, text, count=1, flags=re.S) |
| if unreleased and unreleased.group(1).strip(): | ||
| body = unreleased.group(1).rstrip() + "\n" | ||
| text = re.sub(r"^## \[Unreleased\]\s*\n.*?(?=^## \[|\Z)", "", text, count=1, flags=re.M | re.S) | ||
| insert = f"## [Unreleased]\n\n## [{ver}] - {date}\n{body}\n" | ||
| if text.startswith("# Changelog"): | ||
| parts = text.split("\n\n", 2) | ||
| if len(parts) >= 2: | ||
| text = parts[0] + "\n\n" + parts[1] + "\n\n" + insert + (parts[2] if len(parts) > 2 else "") | ||
| else: | ||
| text = insert + text | ||
| else: | ||
| text = insert + text |
There was a problem hiding this comment.
Blocking: when [Unreleased] has content, this branch misplaces the Keep-a-Changelog header text.
After re.sub removes the Unreleased section, text is:
# Changelog\n\nAll notable changes...\n\nThe format is based on...\nand this project adheres to...\n\n## [0.1.1] - ...
text.split("\n\n", 2) yields three parts where parts[2] begins with "The format is based on...". The reassembly inserts the new version block between parts[1] and parts[2], leaving the format/semver header paragraph stranded below the new release section:
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [0.1.2] - 2026-05-20
### Added
- foo
The format is based on [Keep a Changelog]...
and this project adheres to...
## [0.1.1] - ...
Fix: don't try to split-and-reinsert around the preamble. Match the existing ## [Unreleased] section directly and replace it, keeping all preceding text untouched — same approach as the suggestion below on the else branch.
| fi | ||
| { | ||
| echo "body<<EOF" | ||
| echo "$body" | ||
| echo "EOF" | ||
| } >> "$GITHUB_OUTPUT" |
There was a problem hiding this comment.
nit: (not blocking) the EOF heredoc delimiter is collidable. If a future changelog entry contains a line that's exactly EOF, parsing of $GITHUB_OUTPUT will terminate early and corrupt the body. GitHub's docs recommend a random delimiter, e.g.:
delim="EOF_$(openssl rand -hex 8)"
{
echo "body<<${delim}"
echo "$body"
echo "${delim}"
} >> "$GITHUB_OUTPUT"Unlikely to bite, but trivially cheap to harden.
| default_branch() { | ||
| local remote="${1:-origin}" | ||
| git symbolic-ref --quiet "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s|refs/remotes/${remote}/||" \ | ||
| || { git branch -r | sed -n "s|^ ${remote}/\\(main\\|master\\)$|\\1|p" | head -1; } \ | ||
| || echo main | ||
| } |
There was a problem hiding this comment.
nit: (not blocking) the pipe to sed masks the exit status of git symbolic-ref. When refs/remotes/origin/HEAD is unset, symbolic-ref writes nothing to stdout but sed still succeeds with empty output, so the || fallbacks never run and default_branch returns "". Downstream git fetch origin "" then fails with a confusing error.
Capture the symbolic-ref output first, then fall back:
default_branch() {
local remote="${1:-origin}" ref
if ref=$(git symbolic-ref --quiet "refs/remotes/${remote}/HEAD" 2>/dev/null); then
echo "${ref#refs/remotes/${remote}/}"
return
fi
git branch -r | sed -n "s|^ ${remote}/\\(main\\|master\\)$|\\1|p" | head -1 | grep . \
|| echo main
}|
|
||
| import re | ||
| import subprocess | ||
| import sys |
There was a problem hiding this comment.
super nit: (not blocking) sys is imported but never used.
There was a problem hiding this comment.
Review
Blocking Issues
scripts/release.sh:135-145— the empty-[Unreleased]branch ofupdate_changelogproduces a CHANGELOG with two## [Unreleased]headings (the replacement string already contains## [Unreleased]frominsert.strip()and then appends another). Runningprepare patchagainst the CHANGELOG in this PR demonstrates the duplicate.check-release.pydoesn't catch it because it only validates the new version heading.scripts/release.sh:123-134— the non-empty-[Unreleased]branch splits on\n\nand reassembles around the preamble, leaving the "The format is based on Keep a Changelog…" paragraph stranded below the new release section instead of in the header.
Both paths are reachable on the very next ./scripts/release.sh prepare invocation against the post-merge CHANGELOG, so the release automation will produce a malformed file the first time it's used.
Action Required
Rewrite update_changelog to insert the new version section directly after the existing ## [Unreleased] heading without disturbing the preamble or duplicating headings. See the suggestion on line 145 for one shape. Nits/super nits left inline can be addressed at your discretion.
Avoid duplicate [Unreleased] headings and keep the preamble intact when preparing releases. Add unit tests and harden release workflow output.
Summary
scripts/release.sh(prepare + publish)release.yml)check-release.yml)RELEASING.mdandCHANGELOG.mdTest plan
./scripts/release.sh prepare patchon next release (post-merge)