From 930e67ad7e71ffeb20456a7ba610ed09816b752f Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sat, 10 Oct 2026 22:51:08 +0800 Subject: [PATCH] Add the changelog and the check of its convention CHANGELOG.md holds an [Unreleased] section with what the first release v1.0.0 will contain, in the groups Breaking, Added, Changed and Fixed. tools/changelog.py checks the convention of ADR-0006 (Unreleased first, descending versions, dates, a Breaking entry needs a new major and a new major needs one) and prints the notes of a release for the release page. The release process guide uses both. Task: MIL-004#2 --- CHANGELOG.md | 50 +++++++++++ guides/release-process.md | 13 +-- tests/test_changelog.py | 134 ++++++++++++++++++++++++++++++ tools/changelog.py | 169 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/test_changelog.py create mode 100644 tools/changelog.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..82591ad --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to the shared workflows of this repository. The format and +the version rules are those of the release process (`guides/release-process.md`, +ADR-0006): one version for the whole repository, tags `vMAJOR.MINOR.PATCH` +and a moving major tag `vMAJOR`. + +Each release lists its changes in these groups, in this order: + +- **Breaking**: a renamed or removed workflow, input, secret name or output; a + raised default tool version or changed default that can turn a green + consumer red; a changed meaning of pass or fail. A release with a Breaking + entry has a new major version. +- **Added**: a new optional input, workflow or capability. +- **Changed**: a change with no visible effect for existing callers. +- **Fixed**: a fix. + +`python tools/changelog.py check` verifies the file, and +`python tools/changelog.py notes ` prints the text of a release page. + +## [Unreleased] + +Everything below is what the first release, `1.0.0`, will contain. Until it is +tagged the workflows are delivered from `main` and are not pinned. + +### Added + +- Scoped workflow `sync-github-metadata.yml`: copies the description and topics + of a Gitea repository to its GitHub push mirror on every push to `main`, by + hand and daily at 03:17 UTC. A repository without a GitHub mirror is skipped + successfully. +- A credential preflight before any change, and failure messages that name + their category (`configuration`, `credentials`, `permissions`, `api`). +- Scoped workflow `publish-wiki.yml`: publishes `wiki/` to the repository wiki + when a repository has opted in with `wiki/.publish`; the word `prune` in that + file also deletes pages that are not in `wiki/`. +- Reusable workflow `quality-checks.yml`: `ruff`, `mypy --strict`, `shellcheck` + and `shfmt` on the tracked Python and shell files, with the inputs + `languages`, `working-directory` and one pinned version per tool. +- Guides for onboarding a repository, workflow credentials, troubleshooting, + reading quality-check results and releasing, and the same pages on the wiki in + English, Danish and Bahasa Malaysia. + +### Changed + +- The scripts of the scoped workflows are files under `tools/`, fetched by a + checkout of this repository, instead of Python embedded in the YAML. +- Secrets hold the bare token as clear text: `TOKEN_FOR_GITEA`, + `PAT_FOR_GITHUB` and `TOKEN_FOR_GITEA_WIKI`. They replace the JSON secret + `CREDENTIALS_FOR_GITHUB`, which is no longer read. diff --git a/guides/release-process.md b/guides/release-process.md index dca2e05..6d0bdc6 100644 --- a/guides/release-process.md +++ b/guides/release-process.md @@ -26,9 +26,12 @@ Until the release workflow exists (MIL-004 task 5) the steps are done by hand. 2. **Check the commit.** The validation run (`validate-workflows`) on the exact commit of `main` to be released is green; do not release from a red or unchecked commit. -3. **Write the changelog entry** in `CHANGELOG.md`: the version, the date, and - the changes in the groups Breaking, Added, Changed and Fixed. A change that - touches credentials, tokens or permissions says so. +3. **Write the changelog entry** in `CHANGELOG.md`: move the items of + `[Unreleased]` under `## [x.y.z] - date` in the groups Breaking, Added, + Changed and Fixed. A change that touches credentials, tokens or permissions + says so. Check it with + `python tools/changelog.py check --version x.y.z`; a Breaking entry needs a + new major version, and a new major version needs a Breaking entry. 4. **Get the approval** of S01 for the release, in the pull request that adds the changelog entry. 5. **Tag.** Create `vMAJOR.MINOR.PATCH` on the approved commit, then move @@ -38,8 +41,8 @@ Until the release workflow exists (MIL-004 task 5) the steps are done by hand. git push origin v1.2.0 git tag -f v1 && git push -f origin v1 ``` -6. **Announce.** Create the release page for the tag with the changelog entry - as its text, and say whether consumers must act. +6. **Announce.** Create the release page for the tag with the text of + `python tools/changelog.py notes x.y.z`, and say whether consumers must act. 7. **Verify.** One repository pinned to the previous exact tag still runs the same code; one repository on the major tag receives the release. diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..c77def3 --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,134 @@ +"""Offline tests for tools/changelog.py and the CHANGELOG.md of this repository.""" + +import importlib.util +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +_SPEC = importlib.util.spec_from_file_location("changelog", REPO_ROOT / "tools" / "changelog.py") +assert _SPEC is not None and _SPEC.loader is not None +changelog = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(changelog) + +GOOD = """# Changelog + +## [Unreleased] + +### Added + +- A new optional input. + +## [2.0.0] - 2026-12-01 + +### Breaking + +- The input `languages` was renamed. + +### Fixed + +- A message. + +## [1.1.0] - 2026-11-01 + +### Added + +- A workflow. + +## [1.0.0] - 2026-10-20 + +### Added + +- The first release. +""" + + +class TestParse: + def test_reads_releases_groups_and_items(self): + releases = changelog.parse(GOOD) + assert [r.name for r in releases] == ["Unreleased", "2.0.0", "1.1.0", "1.0.0"] + assert releases[1].version == (2, 0, 0) + assert releases[1].date == "2026-12-01" + assert releases[1].groups["Breaking"] == ["The input `languages` was renamed."] + assert releases[0].is_unreleased and releases[0].version is None + + +class TestValidate: + def test_a_correct_changelog_has_no_problem(self): + assert changelog.validate(GOOD) == [] + + def test_requires_unreleased_first(self): + text = GOOD.replace("## [Unreleased]\n\n### Added\n\n- A new optional input.\n\n", "") + assert any("first section must be `## [Unreleased]`" in p for p in changelog.validate(text)) + + def test_rejects_an_unknown_group(self): + text = GOOD.replace("### Fixed", "### Misc") + assert any("group 'Misc'" in p for p in changelog.validate(text)) + + def test_rejects_an_empty_group(self): + text = GOOD.replace("- A message.\n", "") + assert any("has no item" in p for p in changelog.validate(text)) + + def test_rejects_a_release_without_a_date(self): + text = GOOD.replace("## [1.1.0] - 2026-11-01", "## [1.1.0]") + assert any("needs a date" in p for p in changelog.validate(text)) + + def test_rejects_a_name_that_is_not_a_version(self): + text = GOOD.replace("## [1.1.0] - 2026-11-01", "## [v1.1] - 2026-11-01") + assert any("not a version" in p for p in changelog.validate(text)) + + def test_versions_must_descend(self): + text = GOOD.replace("## [1.1.0]", "## [2.0.0]") + assert any("must be lower than the version above" in p for p in changelog.validate(text)) + + def test_breaking_needs_a_new_major(self): + text = GOOD.replace("## [1.1.0] - 2026-11-01\n\n### Added\n\n- A workflow.", + "## [1.1.0] - 2026-11-01\n\n### Breaking\n\n- A rename.") + assert any("lists Breaking changes but the major version does not increase" in p + for p in changelog.validate(text)) + + def test_a_new_major_needs_a_breaking_change(self): + text = GOOD.replace("### Breaking\n\n- The input `languages` was renamed.\n\n### Fixed", + "### Fixed") + assert any("raises the major version but lists no Breaking change" in p + for p in changelog.validate(text)) + + def test_an_empty_file_is_a_problem(self): + assert changelog.validate("# Changelog\n") + + +class TestNotes: + def test_prints_the_groups_in_the_fixed_order(self): + text = changelog.notes(GOOD, "2.0.0") + assert text.startswith("## 2.0.0 - 2026-12-01") + assert text.index("### Breaking") < text.index("### Fixed") + assert "The input `languages` was renamed." in text + + def test_an_unknown_version_is_an_error(self): + with pytest.raises(LookupError): + changelog.notes(GOOD, "3.0.0") + + +class TestCommandLine: + def test_check_passes_for_a_good_file(self, tmp_path, capsys): + path = tmp_path / "CHANGELOG.md" + path.write_text(GOOD, encoding="utf-8") + assert changelog.main(["check", "--file", str(path)]) == 0 + + def test_check_version_fails_without_an_entry(self, tmp_path, capsys): + path = tmp_path / "CHANGELOG.md" + path.write_text(GOOD, encoding="utf-8") + assert changelog.main(["check", "--file", str(path), "--version", "3.0.0"]) == 1 + assert "no changelog entry" in capsys.readouterr().err + + def test_notes_prints_the_entry(self, tmp_path, capsys): + path = tmp_path / "CHANGELOG.md" + path.write_text(GOOD, encoding="utf-8") + assert changelog.main(["notes", "1.1.0", "--file", str(path)]) == 0 + assert "A workflow." in capsys.readouterr().out + + +def test_the_changelog_of_this_repository_follows_the_convention(): + text = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + assert changelog.validate(text) == [] + assert changelog.parse(text)[0].is_unreleased diff --git a/tools/changelog.py b/tools/changelog.py new file mode 100644 index 0000000..e8dc413 --- /dev/null +++ b/tools/changelog.py @@ -0,0 +1,169 @@ +"""Read, check and cut release notes from CHANGELOG.md (ADR-0006). + +The changelog follows the convention of the release process: + + ## [Unreleased] + ### Breaking | Added | Changed | Fixed + ## [1.2.0] - 2026-10-10 + +Usage: + python tools/changelog.py check [--file CHANGELOG.md] + python tools/changelog.py notes 1.2.0 [--file CHANGELOG.md] + python tools/changelog.py check --version 1.2.0 # the entry must exist + +Exit codes: 0 correct, 1 a problem was found (each is printed). +""" + +import argparse +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +GROUPS = ("Breaking", "Added", "Changed", "Fixed") + +_RELEASE = re.compile(r"^## \[(?P[^\]]+)\](?: - (?P\d{4}-\d{2}-\d{2}))?\s*$") +_GROUP = re.compile(r"^### (?P.+?)\s*$") +_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +@dataclass +class Release: + """One `## [x]` section of the changelog.""" + + name: str + date: str | None + line: int + groups: dict[str, list[str]] = field(default_factory=dict) + + @property + def version(self) -> tuple[int, int, int] | None: + match = _SEMVER.match(self.name) + return tuple(int(part) for part in match.groups()) if match else None # type: ignore[return-value] + + @property + def is_unreleased(self) -> bool: + return self.name == "Unreleased" + + +def parse(text: str) -> list[Release]: + """Return the releases in file order; text before the first one is ignored.""" + releases: list[Release] = [] + group: str | None = None + for number, line in enumerate(text.splitlines(), 1): + release = _RELEASE.match(line) + if release: + releases.append(Release(release["name"], release["date"], number)) + group = None + continue + if not releases: + continue + heading = _GROUP.match(line) + if heading: + group = heading["name"] + releases[-1].groups.setdefault(group, []) + continue + if group is not None and line.startswith("- "): + releases[-1].groups[group].append(line[2:].strip()) + return releases + + +def validate(text: str) -> list[str]: + """Return one message per breach of the convention.""" + problems: list[str] = [] + releases = parse(text) + if not releases: + return ["no release section: the file needs `## [Unreleased]` and one `## [x.y.z] - date` per release"] + if not releases[0].is_unreleased: + problems.append(f"line {releases[0].line}: the first section must be `## [Unreleased]`") + + previous: tuple[int, int, int] | None = None + for release in releases: + where = f"line {release.line}" + for group, items in release.groups.items(): + if group not in GROUPS: + problems.append(f"{where}: group '{group}' is not one of {', '.join(GROUPS)}") + if not items: + problems.append(f"{where}: group '{group}' of [{release.name}] has no item") + if release.is_unreleased: + if release.date: + problems.append(f"{where}: [Unreleased] has no date") + continue + version = release.version + if version is None: + problems.append(f"{where}: '{release.name}' is not a version MAJOR.MINOR.PATCH") + continue + if not release.date: + problems.append(f"{where}: [{release.name}] needs a date, `## [{release.name}] - YYYY-MM-DD`") + if not release.groups: + problems.append(f"{where}: [{release.name}] has no change group") + if previous is not None and version >= previous: + problems.append(f"{where}: [{release.name}] must be lower than the version above it") + previous = version + + # a breaking entry needs a new major compared with the release before it + released = [r for r in releases if not r.is_unreleased and r.version is not None] + for newer, older in zip(released, released[1:]): + if newer.groups.get("Breaking") and newer.version[0] <= older.version[0]: # type: ignore[index] + problems.append( + f"line {newer.line}: [{newer.name}] lists Breaking changes but the major version " + f"does not increase over [{older.name}]" + ) + if ( + not newer.groups.get("Breaking") + and newer.version[0] > older.version[0] # type: ignore[index] + ): + problems.append( + f"line {newer.line}: [{newer.name}] raises the major version but lists no Breaking change" + ) + return problems + + +def notes(text: str, version: str) -> str: + """Return the Markdown of the release page for ``version``.""" + for release in parse(text): + if release.name == version: + if not release.groups: + break + parts = [f"## {version}" + (f" - {release.date}" if release.date else "")] + for group in GROUPS: + items = release.groups.get(group) + if items: + parts.append(f"### {group}\n" + "\n".join(f"- {item}" for item in items)) + return "\n\n".join(parts) + "\n" + raise LookupError(f"no changelog entry with changes for version {version}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("command", choices=("check", "notes")) + parser.add_argument("version", nargs="?", help="version for `notes`") + parser.add_argument("--file", default=str(REPO_ROOT / "CHANGELOG.md")) + parser.add_argument("--version", dest="must_have", help="for `check`: this version must have an entry") + args = parser.parse_args(argv) + + text = Path(args.file).read_text(encoding="utf-8") + if args.command == "notes": + if not args.version: + print("ERROR: `notes` needs a version", file=sys.stderr) + return 1 + try: + print(notes(text, args.version), end="") + except LookupError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + return 0 + + problems = validate(text) + if args.must_have and not any(r.name == args.must_have and r.groups for r in parse(text)): + problems.append(f"no changelog entry with changes for version {args.must_have}") + for problem in problems: + print(f"ERROR: {args.file}: {problem}", file=sys.stderr) + if not problems: + print("CHANGELOG.md follows the convention.") + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main()) -- 2.54.0