8000 build(release): changelog fragments in changelog.d/ instead of one Unreleased section by adeelahmad · Pull Request #581 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
55 changes: 47 additions & 8 deletions .github/scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
pre-commit hook; needs only Python and git.

release.py cut auto|patch|minor|major|X.Y.Z [--date YYYY-MM-DD]
Writes the new version's CHANGELOG.md section: the "## Unreleased" text, when there is any, then t 8000 he changes
merged since the last release, listed from their Conventional Commit titles (except those that wrote their
own Unreleased entry, unless breaking). Sets the version in pyproject.toml, uv.lock, package.json, the API
Writes the new version's CHANGELOG.md section: the "## Unreleased" text, when there is any, then the
changelog.d/*.md fragments in the order they were committed (and deletes them), then the changes merged since
the last release, listed from their Conventional Commit titles (except those that wrote a fragment or an
Unreleased entry, unless breaking). Sets the version in pyproject.toml, uv.lock, package.json, the API
schema (openapi.json) and CloudronManifest.json (when present) and prints it. `auto` picks the bump from those titles and any
BREAKING CHANGE footers: a breaking change is major (minor before 1.0), a feat minor, anything else patch.
release.py notes X.Y.Z
Expand All @@ -26,6 +27,8 @@

ROOT = Path(__file__).resolve().parents[2]
CHANGELOG = ROOT / "CHANGELOG.md"
# one Markdown file per pull request, folded into the next version's section; its README.md explains and stays
FRAGMENTS = ROOT / "changelog.d"
SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
HEADING = re.compile(r"^## (\S+)", re.M)

Expand Down Expand Up @@ -229,12 +232,19 @@ def changes_since(base: str | None) -> list[dict]:
else:
kind = "deps" if DEPENDABOT.match(subject) else "other"
scope, description = None, subject
# a change that wrote its own CHANGELOG.md entry is described there, not listed again
# a change that wrote its own CHANGELOG.md entry or changelog.d fragment is described there, not listed again
first_parent = parents[0] if parents else None
documented = bool(
first_parent
and git(
"diff", "--numstat", first_parent, commit, "--", "CHANGELOG.md"
"diff",
"--numstat",
first_parent,
commit,
"--",
"CHANGELOG.md",
"changelog.d/*.md",
":(exclude)changelog.d/README.md",
).strip()
)
changes.append(
Expand Down Expand Up @@ -287,20 +297,47 @@ def sections(text: str) -> dict[str, str]:
return found


def fragments() -> list[Path]:
"""The changelog.d/*.md fragments, oldest commit first (one not committed yet last), then by name."""
if not FRAGMENTS.is_dir():
return []

def added(path: Path) -> int:
when = git(
"log",
"-1",
"--format=%ct",
"--diff-filter=A",
"--",
str(path.relative_to(ROOT)),
).strip()
return int(when) if when else 2**62

paths = [
path
for path in FRAGMENTS.glob("*.md")
if path.name != "README.md" and not path.name.startswith(".")
]
return sorted(paths, key=lambda path: (added(path), path.name))


def cut(bump: str, date: datetime.date) -> str:
old = current_version()
changes = changes_since(last_release(old))
text = CHANGELOG.read_text()
unreleased = sections(text).get("Unreleased", "").strip()
if not changes and not unreleased:
pieces = fragments()
folded = [piece.read_text().strip() for piece in pieces]
if not changes and not unreleased and not any(folded):
fail("Nothing to release: no changes since the last release")
new = next_version(old, auto_bump(old, changes) if bump == "auto" else bump)
if new in sections(text):
fail(f"CHANGELOG.md already has a {new} section")

listed = changes_markdown(changes)
body = "\n\n".join(part for part in (unreleased, listed) if part) or (
"Maintenance only: nothing users see changed."
body = (
"\n\n".join(part for part in (unreleased, *folded, listed) if part)
or "Maintenance only: nothing users see changed."
)
day = f"{date:%B} {date.day}, {date.year}"
section = (
Expand All @@ -315,6 +352,8 @@ def cut(bump: str, date: datetime.date) -> str:
flags=re.M | re.S,
)
CHANGELOG.write_text(text.rstrip("\n") + "\n")
for piece in pieces: # folded in; `git commit -a` records the deletions
piece.unlink()

for name, pattern in VERSION_FILES.items():
path = ROOT / name
Expand Down
151 changes: 151 additions & 0 deletions .github/scripts/test_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Tests for release.py's changelog cut: python3 .github/scripts/test_release.py"""

from __future__ import annotations

import datetime
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import release # noqa: E402

CHANGELOG = """# Changelog

## Unreleased

## 0.1.0 <small>October 1, 2026</small> {id="0.1.0"}

- Old.
"""
PYPROJECT = '[project]\nname = "lens"\nversion = "0.1.0"\n'


class Repo:
"""A throwaway git repository release.py works in."""

def __init__(self, root: Path):
self.root = root
self.git("init", "-q", "-b", "main")
self.git("config", "user.email", "t@example.com")
self.git("config", "user.name", "t")
self.git("config", "commit.gpgsign", "false")
(root / "fastapi_backend").mkdir()
(root / "fastapi_backend/pyproject.toml").write_text(PYPROJECT)
(root / "CHANGELOG.md").write_text(CHANGELOG)
(root / "changelog.d").mkdir()
(root / "changelog.d/README.md").write_text("# Fragments\n")
self.commit("chore: start")
self.git("tag", "v0.1.0")
release.ROOT = root
release.CHANGELOG = root / "CHANGELOG.md"
release.FRAGMENTS = root / "changelog.d"

def git(self, *args: str) -> str:
env = {
**os.environ,
"GIT_AUTHOR_DATE": self.when(),
"GIT_COMMITTER_DATE": self.when(),
}
return subprocess.run(
["git", *args],
cwd=self.root,
check=True,
capture_output=True,
text=True,
env=env,
).stdout

clock = 1_700_000_000

def when(self) -> str:
Repo.clock += 60 # each commit a minute later, so "oldest first" is testable
return f"{Repo.clock} +0000"

def commit(self, title: str, **files: str) -> None:
for name, text in files.items():
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
self.git("add", "-A")
self.git("commit", "-q", "--allow-empty", "-m", title)

def cut(self) -> str:
release.cut("auto", datetime.date(2026, 10, 18))
return (self.root / "CHANGELOG.md").read_text()


class CutTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.repo = Repo(Path(self.tmp.name))

def tearDown(self):
self.tmp.cleanup()

def test_fragments_fold_in_commit_order_and_go(self):
self.repo.commit(
"feat(feeds): a Feeds page (#555)",
**{"changelog.d/feeds.md": "**Feeds.** A page.\n"},
)
self.repo.commit("fix(chat): keep the cursor (#561)")
self.repo.commit(
"feat(voice): read aloud (#538)",
**{"changelog.d/aloud.md": "**Aloud.** Kokoro.\n"},
)
text = self.repo.cut()
section = release.sections(text)["0.2.0"]
self.assertEqual(
section.split("\n\n"),
[
"**Feeds.** A page.",
"**Aloud.** Kokoro.",
"### Fixes",
"- **chat:** keep the cursor ([#561](https://github.com/adeelahmad/lense/pull/561))",
],
)
self.assertEqual(release.sections(text)["Unreleased"], "")
self.assertEqual(
sorted(p.name for p in (self.repo.root / "changelog.d").iterdir()),
["README.md"],
)
self.assertIn(
'version = "0.2.0"',
(self.repo.root / "fastapi_backend/pyproject.toml").read_text(),
)

def test_unreleased_text_comes_first_and_both_are_not_listed_again(self):
self.repo.commit(
"feat(a): one (#1)",
**{
"CHANGELOG.md": CHANGELOG.replace(
"## Unreleased\n", "## Unreleased\n\n**One.** Written in place.\n"
)
},
)
self.repo.commit(
"feat(b): two (#2)", **{"changelog.d/two.md": "**Two.** A fragment.\n"}
)
section = release.sections(self.repo.cut())["0.2.0"]
self.assertEqual(section, "**One.** Written in place.\n\n**Two.** A fragment.")

def test_a_fragment_alone_is_a_release(self):
self.repo.commit(
"docs: explain (#3)", **{"changelog.d/explain.md": "**Explained.**\n"}
)
self.assertEqual(release.sections(self.repo.cut())["0.1.1"], "**Explained.**")

def test_readme_and_dotfiles_stay(self):
self.repo.commit("feat: thing (#4)", **{"changelog.d/.gitkeep": ""})
text = self.repo.cut()
self.assertIn("- thing ([#4]", release.sections(text)["0.2.0"])
self.assertTrue((self.repo.root / "changelog.d/README.md").exists())
self.assertTrue((self.repo.root / "changelog.d/.gitkeep").exists())


if __name__ == "__main__":
unittest.main()
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ name: Release
# Cuts and publishes a release in one go, from the Actions tab (Run workflow) or `gh workflow run release.yml`. The
# version follows the Conventional Commit titles merged since the last release (a breaking change is major, minor
# before 1.0; a feat minor; anything else patch) unless you give a bump. The new CHANGELOG.md section is the
# "Unreleased" text, if any, then those changes listed by type; the version is set in the backend, frontend and
# "Unreleased" text, if any, then the changelog.d/*.md fragments (deleted as they're folded in), then those changes
# listed by type; the version is set in the backend, frontend and
# Cloudron manifest, committed to the default branch, tagged vX.Y.Z and published as the GitHub release with that
# section as its notes (.github/scripts/release.py). Pushing a vX.Y.Z tag yourself publishes that tag the same way,
# without the commit.
Expand Down
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
10000
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,9 @@ repos:
language: system
files: ^ci/(check|test_check)_requirements\.py$
pass_filenames: false
- id: release-tests
name: tests of the release script (changelog fragments)
entry: python3 .github/scripts/test_release.py
language: system
files: ^\.github/scripts/(release|test_release)\.py$
pass_filenames: false
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ requirements and this file as you learn. Loosening a requirement, a test or a ru
- **Generated files come from tooling** (`make openapi`), never from hand-merging.
- **Docs move with the code.** Update `docs/` when behaviour or configuration changes; `mkdocs build --strict` stays
clean.
- **Changelog text is a fragment.** A pull request's changelog paragraph is a file in `changelog.d/`, never a line
under `## Unreleased` in `CHANGELOG.md`: parallel pull requests editing that one spot conflicted on every merge.
- **Requirements move with the code.** A pull request that builds something adds it to the README's built list with
its checks in [docs/requirements.md](docs/requirements.md), and takes its own line out of In progress. `make
requirements` (also a pre-commit hook) fails when a named check is gone.
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ minor, and anything else bumps patch.
## Changelog and releases

You don't have to edit `CHANGELOG.md`: each release lists the PR titles merged since the last one. When a change needs
more than its title (what users notice, what to do when upgrading), write it under `## Unreleased`, and it goes at the
top of the next version's section.
more than its title (what users notice, what to do when upgrading), add a Markdown file under `changelog.d/`, named
after your branch ([how](changelog.d/README.md)); the release folds the fragments, oldest first, into the top of the
next version's section and deletes them. Don't write under `## Unreleased` in `CHANGELOG.md`: every PR editing that
one spot conflicts with every other.

Maintainers release by running the Release workflow on `main`. It sets the version, writes the changelog, tags
`vX.Y.Z`, publishes the GitHub release and starts the package builds; see [Release](docs/contributing.md#release).
Expand Down
13 changes: 13 additions & 0 deletions changelog.d/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Changelog fragments

A pull request that deserves more than its title in the changelog (what users notice, what to do when upgrading) adds
one Markdown file here, named after its branch or number, such as `feeds-page.md` or `555.md`. Write the text as it
should read under the next version's heading in [CHANGELOG.md](../CHANGELOG.md): a bold lead, then what changed, with
links to the docs.

The Release workflow folds the fragments into the new version's section, oldest commit first, and deletes them
(`.github/scripts/release.py cut`). A change with a fragment isn't listed again by its title.

Fragments replace writing under `## Unreleased` in `CHANGELOG.md`: every pull request editing that one spot
conflicted with every other, so each had to merge `main` again after each merge. One file per change never
conflicts. This README stays.
14 changes: 9 additions & 5 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,21 +133,25 @@ request) fails when a check a row names is gone or a measured number is no longe

The backend and frontend share a [semantic version](https://semver.org/). The changelog is written from the PR titles,
so a PR needs no `CHANGELOG.md` edit. When a change deserves more than its title (what users notice, what to do when
upgrading), write it under `## Unreleased`; that text goes at the top of the next version's section.
upgrading), add a Markdown fragment under `changelog.d/` named after the branch (its README says how); the release
folds the fragments, oldest commit first, into the top of the next version's section and deletes them. Fragments
replaced writing under `## Unreleased`, where every pull request conflicted with every other; that text, when any,
still goes first.

To release, run the Release workflow on `main` (Actions > Release > Run workflow, or `gh workflow run release.yml`).
It first upgrades the last release, holding data, to `main` and checks that nothing was lost
([Checked before every release](database.md#checked-before-every-release)); when that fails, or `main`'s backend
changed after it ran, nothing is cut.
With `auto` it picks the version from the PR titles merged since the last release: a breaking change bumps major
(minor before 1.0), a `feat` minor, anything else patch; give `patch`, `minor`, `major` or an exact version to
override. It writes the new section (the Unreleased text, then the changes grouped into features, fixes,
performance, reverts, dependencies and other changes; `docs`, `refactor`, `test`, `build`, `ci`, `chore` and
`style` are left out, and so is a PR that wrote its own Unreleased entry, unless it's breaking), sets the version in `fastapi_backend/pyproject.toml`, `uv.lock`,
override. It writes the new section (the Unreleased text, the `changelog.d/` fragments, then the changes grouped into
features, fixes, performance, reverts, dependencies and other changes; `docs`, `refactor`, `test`, `build`, `ci`,
`chore` and `style` are left out, and so is a PR that wrote a fragment or an Unreleased entry, unless it's
breaking), sets the version in `fastapi_backend/pyproject.toml`, `uv.lock`,
`nextjs-frontend/package.json`, the API schema `nextjs-frontend/openapi.json`, `app/domain/__init__.py` and
`CloudronManifest.json`, commits that to `main` as `chore(release): vX.Y.Z`, tags
it and publishes the GitHub release with that section as its notes. Pushing a `vX.Y.Z` tag yourself publishes it the
same way, without the commit. `python3 .github/scripts/release.py cut auto` shows locally what it would write.
same way, without the commit. `python3 .github/scripts/release.py cut auto` shows locally what it would write (undo with `git checkout -- .`).
An exact version must be newer than the current one, and only the newest version is marked Latest. If a run pushed its
commit and tag but failed before publishing, run it again (or re-run it): it publishes that version instead of
cutting another.
Expand Down
Loading
0