Skip to content

ci: standardize release process#8

Open
eddietejeda wants to merge 4 commits into
mainfrom
ci/standardize-release-process
Open

ci: standardize release process#8
eddietejeda wants to merge 4 commits into
mainfrom
ci/standardize-release-process

Conversation

@eddietejeda
Copy link
Copy Markdown
Contributor

@eddietejeda eddietejeda commented May 20, 2026

Summary

  • Add scripts/release.sh (prepare + publish)
  • Auto-create GitHub Releases on tag push (release.yml)
  • Require changelog entry when version bumps (check-release.yml)
  • Add RELEASING.md and CHANGELOG.md

Test plan

  • CI checks passing
  • Changelog and release scripts added
  • Run ./scripts/release.sh prepare patch on next release (post-merge)

Add scripts/release.sh for version bumps, changelog updates, tagging, and
GitHub Release creation via CI. Enforce changelog checks on version PRs.
Comment thread scripts/release.sh Outdated
Comment on lines +122 to +145
unreleased = re.search(r"^## \[Unreleased\]\s*\n(.*?)(?=^## \[|\Z)", text, re.M | 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
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — update_changelog corrupts the actual CHANGELOG.md format used in this PR.

Tracing both branches against the CHANGELOG.md added in this same PR (which has the standard Keep a Changelog preamble + an empty ## [Unreleased]):

Branch 1 — empty ## [Unreleased] (common: someone forgets to add notes before running prepare):

  • unreleased.group(1).strip() is empty → falls through to the else block.
  • insert.strip() already begins with ## [Unreleased], then text.replace("## [Unreleased]", insert.strip() + "\n\n## [Unreleased]", 1) substitutes the original heading with the new content followed by another ## [Unreleased] heading.
  • Result: the changelog now has two ## [Unreleased] sections, which then trips check-release.py on the next release (and any tooling that assumes a single Unreleased section).

Branch 2 — ## [Unreleased] has content:

  • After removing the Unreleased section, text.startswith("# Changelog") is true, so text.split("\n\n", 2) produces three parts. Because the current preamble has two blank-line breaks before the first version section, parts[1] is just "All notable changes…" and parts[2] starts with "The format is based on…\n\n## [0.1.1]…".
  • The new section gets inserted between parts[1] and parts[2], which shoves the "The format is based on…" paragraph after the new version section and between the new version and the older versions.
  • Result: the preamble ends up interleaved with version history.

Both bugs hit the very file shipped here. Suggested simpler replacement that operates in place on the Unreleased section without splitting on \n\n:

def update_changelog(...):
    ...
    # Replace `## [Unreleased]\n\n<body>` with
    # `## [Unreleased]\n\n## [ver] - date\n\n<body>` in a single substitution.
    pattern = r"(^## \[Unreleased\]\s*\n)(.*?)(?=^## \[|\Z)"
    def repl(m):
        body = m.group(2).strip()
        block = f"## [{ver}] - {date}\n\n"
        if body:
            block += body + "\n\n"
        else:
            block += f"### Changed\n\n- Release {ver}\n\n"
        return m.group(1) + "\n" + block
    new_text, n = re.subn(pattern, repl, text, count=1, flags=re.M | re.S)
    if n == 0:
        raise SystemExit("CHANGELOG.md is missing a '## [Unreleased]' section")
    path.write_text(new_text)

Either way, please add a unit test (or at least an examples/-style fixture run) so this regresses loudly if anyone touches it — the current logic is too easy to break silently.

Comment on lines +40 to +44
{
echo "body<<EOF"
echo "$body"
echo "EOF"
} >> "$GITHUB_OUTPUT"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: using EOF as the heredoc delimiter for multiline GITHUB_OUTPUT is fragile — if any future changelog entry contains a line that is exactly EOF, this step will produce a malformed output and the release notes will be wrong or the step will fail. GitHub's own docs recommend a random delimiter. (not blocking)

delim="EOF_$(openssl rand -hex 8)"
{
  echo "body<<$delim"
  echo "$body"
  echo "$delim"
} >> "$GITHUB_OUTPUT"

name: ${{ steps.meta.outputs.name }} ${{ steps.meta.outputs.version }}
body: ${{ steps.notes.outputs.body }}
generate_release_notes: false
make_latest: true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: make_latest: true is unconditional, but the tag pattern 'v[0-9]*' matches pre-releases like v1.0.0a1 / v2.0.0rc1. Marking a pre-release as "latest" on GitHub is usually wrong (it surfaces as the default download for users). Consider deriving this from whether the version has a PEP 440 suffix, e.g. compute is_prerelease in the meta step and pass make_latest: ${{ steps.meta.outputs.is_prerelease == 'false' }}. (not blocking)

Comment thread scripts/check-release.py
Comment on lines +34 to +38
base = "origin/main"
for candidate in ("origin/main", "origin/master"):
if subprocess.call(["git", "rev-parse", "--verify", candidate], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0:
base = candidate
break
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this hardcodes the base to origin/main (or origin/master) instead of using the PR's actual base ref. For a PR that targets a non-default branch (release branch, stacked PR), the diff is taken against the wrong baseline and could either skip the check incorrectly or fail spuriously. In pull_request events the base is available as $GITHUB_BASE_REF:

base_ref = os.environ.get("GITHUB_BASE_REF") or "main"
base = f"origin/{base_ref}"

(not blocking — fine for the standard PR-to-main flow this repo uses today.)

Comment thread scripts/release.sh
Comment on lines +198 to +213
cmd_publish() {
need gh
need python3
ensure_clean

local base ver tag
base="$(default_branch)"
git fetch origin "$base"
git checkout "$base"
git pull --ff-only origin "$base"
ensure_clean

ver="$(get_version)"
tag="v${ver}"

git rev-parse "$tag" >/dev/null 2>&1 && die "tag $tag already exists"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: cmd_publish only fetches the base branch, not tags, so git rev-parse "$tag" won't see a tag that already exists on the remote but not locally. Two engineers running publish against the same version will both pass this check; the second one will then fail at git push origin "$tag" with a less clear error. Adding git fetch --tags origin before the check would surface the conflict with the script's own die message. (not blocking)

next_heading = re.search(r"^## \[", rest, re.M)
end = match.end() + (next_heading.start() if next_heading else len(rest))
section = changelog[start:end].strip()
title, _, body = section.partition("\n")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: title is assigned but never used — _, _, body = section.partition("\n") or just slicing past the first newline reads a bit cleaner. (not blocking)

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Blocking Issues

  • scripts/release.sh update_changelog (lines 122–145) corrupts CHANGELOG.md in both branches when run against the format used in this PR. The empty-Unreleased path produces a duplicate ## [Unreleased] heading, and the non-empty path moves the preamble paragraph below the new version section. See inline comment for the traced output and a suggested rewrite.

Action Required

  • Rewrite update_changelog so that it edits the Unreleased section in place (suggestion provided inline) and add a small test/fixture run so regressions are caught.

Non-blocking nits left inline on release.yml, check-release.py, release.sh (cmd_publish), and extract-changelog.py.

Comment thread scripts/release.sh Outdated
Comment on lines +122 to +145
unreleased = re.search(r"^## \[Unreleased\]\s*\n(.*?)(?=^## \[|\Z)", text, re.M | 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
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — still unfixed from review #1. The update_changelog logic is unchanged since cycle 1, and both branches still corrupt the CHANGELOG.md shipped in this PR:

  • Empty-Unreleased path (line 143) produces two ## [Unreleased] headings after the first run, which then trips check-release.py and any other tooling assuming a single Unreleased section.
  • Non-empty-Unreleased path (line 128–130) splits the preamble on \n\n and inserts between parts[1] and parts[2], which shoves the "The format is based on…" paragraph below the new version section.

Please replace this with an in-place substitution on the Unreleased block (suggestion from the prior thread reproduced below) and add at least one fixture/unit test so this regresses loudly next time it's touched.

pattern = r"(^## \[Unreleased\]\s*\n)(.*?)(?=^## \[|\Z)"
def repl(m):
    body = m.group(2).strip()
    block = f"## [{ver}] - {date}\n\n"
    if body:
        block += body + "\n\n"
    else:
        block += f"### Changed\n\n- Release {ver}\n\n"
    return m.group(1) + "\n" + block
new_text, n = re.subn(pattern, repl, text, count=1, flags=re.M | re.S)
if n == 0:
    raise SystemExit("CHANGELOG.md is missing a '## [Unreleased]' section")
path.write_text(new_text)

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Blocking Issues

  • scripts/release.sh update_changelog (lines 122–145) is unchanged from cycle 1 and still corrupts the CHANGELOG.md shipped in this PR — see the inline thread for the two failure modes and a suggested rewrite.

Action Required

  • Rewrite update_changelog to edit the [Unreleased] block in place (suggestion provided inline).
  • Add a fixture or unit test so this regresses loudly.

Non-blocking nits from cycle 1 (release.yml heredoc delimiter, make_latest for pre-releases, check-release.py base ref, cmd_publish tag fetch, extract-changelog.py unused title) were not re-raised — leaving those to the author's discretion.

Avoid duplicate [Unreleased] headings and keep the preamble intact when
preparing releases. Add unit tests and harden release workflow output.
claude[bot]
claude Bot previously approved these changes May 20, 2026
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prior blocking issue resolved: update_changelog logic was extracted to scripts/update_changelog.py with an in-place substitution and tests/test_update_changelog.py exercises both the empty-Unreleased and populated-Unreleased paths, including the exact assertions that would have caught the cycle-1/2 regressions (single ## [Unreleased] heading; preamble stays above the new version). Traced against the in-PR CHANGELOG.md — output is well-formed in both branches.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issue from prior cycles is resolved — update_changelog is now a tested Python module using the suggested in-place substitution, and unit tests cover both the empty-Unreleased and populated-Unreleased paths. LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant