Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 0 additions & 33 deletions .github/changelog-ci-config.json

This file was deleted.

28 changes: 28 additions & 0 deletions .github/changelog-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"template": "#{{CHANGELOG}}\n\n#### Other Changes\n\n#{{UNCATEGORIZED}}",
"pr_template": "- #{{TITLE}} ([##{{NUMBER}}](#{{URL}}))",
"empty_template": "- No pull requests were merged since the last release.",
"ignore_labels": ["dependabot", "bot", "ci"],
"categories": [
{
"title": "#### New Features",
"labels": ["feature", "enhancement"]
},
{
"title": "#### Bug Fixes",
"labels": ["bug", "bugfix"]
},
{
"title": "#### Code Improvements",
"labels": ["improvements", "refactor", "refactoring"]
},
{
"title": "#### Documentation",
"labels": ["documentation", "docs", "doc"]
},
{
"title": "#### Tests",
"labels": ["tests", "testing", "test"]
}
]
}
129 changes: 129 additions & 0 deletions .github/scripts/update_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Insert a generated release section into .github/CHANGELOG.md.

Usage: update_changelog.py <version> <generated-file> <changelog-file>

The generated file holds the label-grouped list of pull requests produced by
release-changelog-builder-action. It is turned into a section

# Version <version>

#### <group>

- <pull request>

which is written to the top of the changelog. Group headings without any
entries below them are dropped, so an empty "Other Changes" group does not
leave a dangling heading behind.

The generated file is rewritten to hold the final section, so the same text
can be reused as the pull request comment body.

Running the workflow more than once for the same version is safe: a section
that is already present is replaced in place instead of being duplicated, and
identical content leaves the file untouched.
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

SECTION_RE = re.compile(r"^#\s+Version\s+(?P<version>\S+)\s*$")
GROUP_RE = re.compile(r"^#{2,6}\s")


def normalise_version(version: str) -> str:
"""Drop a leading ``v`` so tag names and headers compare equal."""
return version.strip().lstrip("vV")


def tidy(text: str) -> str:
"""Trim trailing whitespace and collapse runs of blank lines."""
lines = [line.rstrip() for line in text.splitlines()]
collapsed: list[str] = []
for line in lines:
if not line and collapsed and not collapsed[-1]:
continue
collapsed.append(line)
while collapsed and not collapsed[0]:
collapsed.pop(0)
while collapsed and not collapsed[-1]:
collapsed.pop()
return "\n".join(collapsed)


def drop_empty_groups(text: str) -> str:
"""Remove group headings that have no entries below them."""
lines = text.splitlines()
kept: list[str] = []
index = 0
while index < len(lines):
if GROUP_RE.match(lines[index]):
end = index + 1
while end < len(lines) and not GROUP_RE.match(lines[end]):
end += 1
if any(line.strip() for line in lines[index + 1 : end]):
kept.extend(lines[index:end])
index = end
else:
kept.append(lines[index])
index += 1
return tidy("\n".join(kept))


def replace_section(changelog: str, version: str, section: str) -> str:
"""Replace the section of ``version``, or prepend it when absent."""
lines = changelog.splitlines()
start = None
for index, line in enumerate(lines):
match = SECTION_RE.match(line)
if match and normalise_version(match.group("version")) == version:
start = index
break

if start is None:
return tidy(f"{section}\n\n{changelog}") + "\n"

end = len(lines)
for index in range(start + 1, len(lines)):
if SECTION_RE.match(lines[index]):
end = index
break

merged = "\n\n".join(["\n".join(lines[:start]), section, "\n".join(lines[end:])])
return tidy(merged) + "\n"


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version", help="release version, e.g. 0.2.0b1")
parser.add_argument("generated", type=Path, help="changelog built by the action")
parser.add_argument("changelog", type=Path, help="changelog file to update")
args = parser.parse_args()

if not args.generated.is_file():
sys.stderr.write(f"error: {args.generated} was not generated\n")
return 1

body = drop_empty_groups(args.generated.read_text(encoding="utf-8"))
if not body:
sys.stderr.write(f"error: {args.generated} is empty\n")
return 1

version = normalise_version(args.version)
section = f"# Version {version}\n\n{body}"

existing = args.changelog.read_text(encoding="utf-8") if args.changelog.is_file() else ""
args.changelog.write_text(replace_section(existing, version, section), encoding="utf-8")
# Reuse the section as the pull request comment body.
args.generated.write_text(section + "\n", encoding="utf-8")

sys.stdout.write(f"updated {args.changelog} for version {version}\n")
return 0


if __name__ == "__main__":
raise SystemExit(main())
119 changes: 105 additions & 14 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -1,31 +1,122 @@
# This workflow will generate a log of changes automatically upon a new release.
# See https://github.com/marketplace/actions/changelog-ci
# Generates the changelog for release pull requests.
#
# A pull request whose title mentions a release and carries a version, e.g.
# "Prepare the 0.2.0b1 release of kalelinear", gets a label-grouped changelog
# of every pull request merged since the previous tag. The section is written
# to .github/CHANGELOG.md, committed to the pull request branch, and posted as
# a pull request comment.
#
# See https://github.com/mikepenz/release-changelog-builder-action

name: changelog

on:
pull_request:
types: [opened]
# "opened"/"reopened" cover the usual flow, and "edited" picks up a pull
# request that is retitled into a release. "synchronize" is deliberately
# left out: the changelog commit below would trigger it again.
types: [opened, reopened, edited]
Comment thread
shuo-zhou marked this conversation as resolved.

# Writing the changelog commit and the pull request comment needs more than
# the default read-only token. Without these permissions both operations fail
# and the action only reports a warning, leaving a green but empty run.
permissions:
contents: write
pull-requests: write

# A newer run of the same pull request supersedes the one in flight.
concurrency:
group: changelog-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
log-changes:
name: Log changes
runs-on: ubuntu-latest
# Pull requests from forks receive a read-only token, which can neither
# push nor comment. Only branches of this repository are supported.
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Checkout code
- name: Checkout pull request branch
uses: actions/checkout@v4
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
# Keep checkout shallow; changelog-ci handles unshallow internally.
# Using fetch-depth: 0 causes changelog-ci to fail with:
# "fatal: --unshallow on a complete repository does not make sense"
fetch-depth: 1
# The full history is needed to locate the previous release tag.
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Run changelog
uses: saadmk11/changelog-ci@v1.2.0

- name: Get release version from pull request title
id: version
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
version=""
if printf '%s' "$PR_TITLE" | grep -qi 'release'; then
version=$(printf '%s' "$PR_TITLE" \
| grep -oEi 'v?[0-9]+\.[0-9]+\.[0-9]+([ab][0-9]+)?' \
| head -n1 || true)
Comment thread
shuo-zhou marked this conversation as resolved.
fi
if [ -z "$version" ]; then
echo "::notice::Not a release pull request, skipping changelog generation."
fi
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Find previous release tag
id: previous
if: steps.version.outputs.version != ''
run: |
tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Previous release tag: ${tag:-none}"

- name: Build changelog
if: steps.version.outputs.version != ''
# v6.3.0, pinned to a commit so an upstream tag move cannot change the
# code that runs here. Dependabot can bump this.
uses: mikepenz/release-changelog-builder-action@cb021f9b36a51a7c6f18e4679b6fa2cb77a1260c
with:
changelog_filename: .github/CHANGELOG.md
config_file: .github/changelog-ci-config.json
configuration: .github/changelog-config.json
fromTag: ${{ steps.previous.outputs.tag }}
toTag: ${{ github.event.pull_request.head.sha }}
outputFile: .github/changelog.generated.md
token: ${{ secrets.GITHUB_TOKEN }}

Comment thread
shuo-zhou marked this conversation as resolved.
- name: Update changelog
if: steps.version.outputs.version != ''
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
git show "${{ github.event.pull_request.base.sha }}:.github/scripts/update_changelog.py" > "$RUNNER_TEMP/update_changelog.py"
python3 "$RUNNER_TEMP/update_changelog.py" \
"$VERSION" \
.github/changelog.generated.md \
.github/CHANGELOG.md

- name: Commit changelog
id: commit
if: steps.version.outputs.version != ''
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
if git diff --quiet -- .github/CHANGELOG.md; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "::notice::CHANGELOG.md already lists this release, nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .github/CHANGELOG.md
git commit -m "Update CHANGELOG.md for version ${VERSION#v}"
git push
echo "changed=true" >> "$GITHUB_OUTPUT"

- name: Comment changelog on pull request
if: steps.version.outputs.version != '' && steps.commit.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr comment "$PR_NUMBER" --body-file .github/changelog.generated.md

Comment on lines +113 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If the push succeeds but commenting fails or the run is cancelled between them, the next run finds identical changelog content, sets changed=false, and skips commenting again.
Could we make comment creation independent of changed?

- name: Remove generated file
if: always()
run: rm -f .github/changelog.generated.md
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ venv/
*.zip
*.ckpt
*.json
!.github/changelog-ci-config.json
!.github/changelog-config.json
.github/changelog.generated.md

# Distribution / packaging
build/
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ Because the package is distributed on PyPI, the actual version strings must be v
#### Release checklist

- Bump the version in `setup.py` and `kalelinear/__init__.py`.
- Update [`.github/CHANGELOG.md`](.github/CHANGELOG.md) with a summary of changes since the last release.
- Update [`.github/CHANGELOG.md`](.github/CHANGELOG.md) with a summary of changes since the last release. The [changelog workflow](.github/workflows/changelog.yml) opens a grouped list of the merged pull requests at the top of the file when a pull request is titled with the version (e.g. `Prepare the 0.2.0b1 release of kalelinear`); reword those entries as needed before merging.
- Create a GitHub release for the new version (e.g. `0.1.0b1`), marking prereleases appropriately.
- The [release workflow](.github/workflows/release.yml) builds the wheel and source distribution and publishes them to Test PyPI (prerelease) or PyPI (final release).

Expand Down
Loading