Merge pull request 'Add the changelog and the check of its convention (MIL-004 task 2)' (#77) from mil-004-changelog-convention into main
This commit was merged in pull request #77.
This commit is contained in:
@@ -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 <version>` 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.
|
||||||
@@ -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
|
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
|
commit of `main` to be released is green; do not release from a red or
|
||||||
unchecked commit.
|
unchecked commit.
|
||||||
3. **Write the changelog entry** in `CHANGELOG.md`: the version, the date, and
|
3. **Write the changelog entry** in `CHANGELOG.md`: move the items of
|
||||||
the changes in the groups Breaking, Added, Changed and Fixed. A change that
|
`[Unreleased]` under `## [x.y.z] - date` in the groups Breaking, Added,
|
||||||
touches credentials, tokens or permissions says so.
|
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
|
4. **Get the approval** of S01 for the release, in the pull request that adds
|
||||||
the changelog entry.
|
the changelog entry.
|
||||||
5. **Tag.** Create `vMAJOR.MINOR.PATCH` on the approved commit, then move
|
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 push origin v1.2.0
|
||||||
git tag -f v1 <commit> && git push -f origin v1
|
git tag -f v1 <commit> && git push -f origin v1
|
||||||
```
|
```
|
||||||
6. **Announce.** Create the release page for the tag with the changelog entry
|
6. **Announce.** Create the release page for the tag with the text of
|
||||||
as its text, and say whether consumers must act.
|
`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
|
7. **Verify.** One repository pinned to the previous exact tag still runs the
|
||||||
same code; one repository on the major tag receives the release.
|
same code; one repository on the major tag receives the release.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<name>[^\]]+)\](?: - (?P<date>\d{4}-\d{2}-\d{2}))?\s*$")
|
||||||
|
_GROUP = re.compile(r"^### (?P<name>.+?)\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())
|
||||||
Reference in New Issue
Block a user