diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1d47346..62a7d0e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,8 +2,9 @@ # # SPDX-License-Identifier: MIT -# Weekly updates of the GitHub Actions the workflows use. Python dependencies are -# updated deliberately, not here. +# Weekly updates of the GitHub Actions the workflows pin by commit (the trailing comment +# names the release). Python dependencies are updated deliberately through uv.lock, not +# here. version: 2 updates: - package-ecosystem: github-actions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 360b807..8892b20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,14 @@ on: branches: [main] pull_request: +permissions: + contents: read + +# A newer push to the same ref replaces a run in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: gate: name: gate (${{ matrix.os }}) @@ -18,10 +26,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # hatch-vcs derives the version from tags - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: enable-cache: true - run: uv python install 3.14 @@ -29,7 +37,7 @@ jobs: - run: uv sync --locked # Exactly the definition-of-done gate from AGENTS.md. - run: uv run poe check - - uses: codecov/codecov-action@v5 + - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 if: matrix.os == 'ubuntu-latest' with: files: build/coverage/coverage.xml @@ -43,10 +51,10 @@ jobs: name: tests (Windows) runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: enable-cache: true - run: uv python install 3.14 @@ -60,10 +68,10 @@ jobs: name: tests (Python 3.12, lowest dependencies) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - run: uv venv --python 3.12 - run: uv pip install --resolution lowest-direct -e ".[gas]" - run: uv pip install pytest pytest-xdist pytest-randomly hypothesis beartype matplotlib sphinx diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 0000000..6c999cc --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: MIT + +name: Release notes + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-notes + cancel-in-progress: false + +jobs: + collect: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - run: uv python install 3.14 + - name: Refresh the rolling draft + env: + GH_TOKEN: ${{ github.token }} + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + run: uv run --no-project --python 3.14 python tools/update_release_notes.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a21172..ae00808 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,10 @@ jobs: id-token: write # the OIDC token that PyPI's trusted publishing exchanges contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # hatch-vcs derives the version from the tag - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - run: uv python install 3.14 - name: Build the sdist and the wheel run: uv build diff --git a/AGENTS.md b/AGENTS.md index 0cb5760..46b33e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ concerns physics or the reading of a paper rather than style, ask rather than gu | Tests with the 100% statement and branch coverage gate | `uv run poe coverage-check` | | Copyright and licence of every file | `uv run poe reuse` | | Preview provisional register renumbering against main | `uv run poe register-sync` | +| Check the pending release notes, or print the changelog they would make | `uv run poe changelog-check`, `uv run poe changelog-preview` | | Rewrite the catalogue index after adding or editing a source record | `uv run python tools/catalogue_index.py` | | Re-run the extraction against the paper library | `PAPER_LIBRARY= uv run poe test-library` | | Re-run every stored fit and compare | `uv run poe test-fits` | @@ -80,13 +81,15 @@ Environment variables used by the tools only (the package itself needs none): 5. `numpydoc lint`; 6. `codespell`; 7. `reuse lint`: every file states its copyright and licence; -8. `poe coverage-check`: `pytest` (doctests included) on every core +8. `poe changelog-check`: the release-note fragments and the changelog's structure + validate; +9. `poe coverage-check`: `pytest` (doctests included) on every core (`pytest-xdist`, `-n auto --dist loadfile`) under `pytest-cov`, requiring **100% statement and branch coverage** across `src/optmat/`, including the workers and ordinary-Python subprocesses (coverage's subprocess patch); -9. `sphinx-build -W -n --keep-going` (the gallery executes). +10. `sphinx-build -W -n --keep-going` (the gallery executes). -All nine must pass. CI runs exactly this sequence on every push and pull request; if +All ten must pass. CI runs exactly this sequence on every push and pull request; if you change one, change the other in the same commit. There is no other safety net: CI runs only what `poe check` runs, so the lanes below are still run by hand. Do not declare work finished while a step fails, and never suppress a check to make it pass. @@ -127,7 +130,9 @@ Also, before merging: changes. New functionality is not done until it is **tested**, **documented in the manual** as -well as in docstrings, **recorded in the register** (below) and noted in `CHANGELOG.md`. +well as in docstrings, **recorded in the register** (below) and noted in a `changelog.d/` fragment +([format](changelog.d/README.md)). The rolling release pull request collects the fragments +into `CHANGELOG.md`; other pull requests do not edit that shared file. Functionality built from an enhancement proposal is also not done until its phase report is appended to the OEP (below). In your summary, say what you verified against a primary source and what you could not. @@ -302,7 +307,7 @@ The long form is [OEP 0](docs/oeps/oep-0000-process.md); the index is - each manual page the OEP produced ends with a link to it; - its open questions move to issues or to `open` register entries; - its tracking issue closes; - - the `CHANGELOG.md` line cites it. + - its release-note fragment cites it. A final OEP is history: the manual describes current behaviour. @@ -426,7 +431,8 @@ The manual's conventions pages are the long form. - **Dependencies:** managed with uv (`uv add`; never `pip install` into the environment). Ask before adding one. The floors in `pyproject.toml` are the oldest versions the code needs, not the newest available: do not raise one without a reason (an API, a fixed - bug, a value the tests reproduce), recorded in `CHANGELOG.md`. CI tests the floors. + bug, a value the tests reproduce), recorded in a release-note fragment. CI tests the + floors. - **Text files are UTF-8, said explicitly:** every text-mode `open`, `Path.open`, `gzip.open`, `read_text` and `write_text` passes `encoding="utf-8"` ⚙ (`tests/test_encoding.py`); Windows otherwise decodes with its code page. @@ -484,7 +490,7 @@ The manual's conventions pages are the long form. - **Commits:** small, logical, with descriptive messages. Stage deliberately (`git add `). - **Versions:** never tag, release or change a version by hand; versions come from git - tags. + tags, and a release follows [*Releasing*](docs/developers/releasing.md). - **Coding agents:** - merge, push, tag or release only when asked or authorised, and do not ask again for an already authorised action; diff --git a/CHANGELOG.md b/CHANGELOG.md index 0877020..1eec5e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ All notable changes are recorded here, in the style of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The review register (`REGISTER.md`) records the changes to data and models in detail. Versions before -0.8.0 were internal and were not published. +0.8.0 were internal and were not published. A change's note starts as a fragment under +`changelog.d/`, and the rolling release pull request collects them here. ## Unreleased diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b07ca9..f75ae05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -206,7 +206,9 @@ motivated it in the register. standards (for people and coding agents alike). 4. Keep the change to the task. Add tests with the code, document new functionality in the manual as well as in docstrings, add register entries for data and model - changes, and add a line to `CHANGELOG.md` under `Unreleased`. + changes, and add a release-note fragment under `changelog.d/` for a notable change, + following [its format](changelog.d/README.md). Leave `CHANGELOG.md` to the rolling + release pull request, which collects the fragments after merges. 5. Run `uv run poe check`; with data or fit changes also `poe test-library` (if you have the library) and `poe test-fits`. A change to the documentation alone, including a new or edited OEP, runs the shorter checks listed in [`AGENTS.md`](AGENTS.md), @@ -231,7 +233,7 @@ A pull request is ready to merge when: available; - a changed fit states why, with the numbers, and its robustness record and tested documentation tables are regenerated; -- the manual, docstrings and changelog line are present; +- the manual, docstrings and release-note fragment are present; - a pull request that implements a phase of an OEP appends the phase report to the OEP and updates its header, index entry and history. The one that completes it archives the OEP and links it from the manual pages it produced; diff --git a/REUSE.toml b/REUSE.toml index c35176d..62969bc 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -15,10 +15,10 @@ path = "**" SPDX-FileCopyrightText = "2026 John C. Travers and contributors" SPDX-License-Identifier = "MIT" -# Prose documentation and figures: the manual, the register, the changelog and the -# repository's guides. +# Prose documentation and figures: the manual, the register, the changelog and its +# pending notes, the repository's guides and the issue templates. [[annotations]] -path = ["*.md", "docs/**/*.md", "docs/**/*.rst", "docs/**/*.svg", "docs/**/*.png", "docs/_static/**", "docs/_templates/**"] +path = ["*.md", "changelog.d/*.md", ".github/ISSUE_TEMPLATE/*.md", "docs/**/*.md", "docs/**/*.rst", "docs/**/*.svg", "docs/**/*.png", "docs/_static/**", "docs/_templates/**"] SPDX-FileCopyrightText = "2026 John C. Travers and contributors" SPDX-License-Identifier = "CC-BY-4.0" diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000..a1be0a2 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,39 @@ +# Pending release notes + +Add one Markdown file for each notable change instead of editing `CHANGELOG.md` in an +implementation PR. Name it `..md`; a PR number can be the slug. +No separate issue is required for a fragment. Categories are `added`, `changed`, +`breaking`, `deprecated`, `removed`, `fixed` and `security`. `breaking` becomes +**Changed (breaking)** in the changelog. + +For example, `helium-matched.added.md` might contain: + +```text +Helium on matched measures (TC-1092): `gas.ionisation_validation.helium_matched_ratios`. +``` + +Write one entry without a heading or leading bullet. Describe the observable change, +including migration instructions for a break, and say whether a recommended value moved. +Cite register entries as `TC-n` and OEPs by number; `poe register-sync` renumbers a +fragment's citations with the rest of the branch, and `tests/test_register.py` checks +them. Markdown links are relative to the root `CHANGELOG.md`, where the text will appear. +Use a unique slug to avoid conflicts. A later correction can edit the original fragment +while it is still pending. + +```sh +uv run poe changelog-check # validate fragments and the Unreleased structure +uv run poe changelog-preview # print the assembled changelog without changing files +``` + +After merges to `main`, the **Release notes** workflow creates or updates one draft PR +on `chore/release-after-`. It collects every fragment into `CHANGELOG.md` +and removes those fragments on that branch. Existing Unreleased notes and released +history are retained. Edit source fragments in ordinary PRs; the rolling draft is +generated. The workflow stops on manual edits, a closed PR, or a PR marked ready. + +Only the rolling release PR normally changes `CHANGELOG.md`. Mark it ready when release +preparation starts, then follow the +[release checklist](../docs/developers/releasing.md). `uv run poe changelog-collect` is +the manual equivalent: it updates the changelog and deletes the collected fragments in +the current checkout. Use it only in the release worktree, after bringing in any final +changes. There are no automatic merges, tags or publications. diff --git a/changelog.d/changelog-fragments.changed.md b/changelog.d/changelog-fragments.changed.md new file mode 100644 index 0000000..1328585 --- /dev/null +++ b/changelog.d/changelog-fragments.changed.md @@ -0,0 +1,4 @@ +Contributors add a release note as a fragment under `changelog.d/` instead of editing +this file; a rolling draft pull request collects them, and +[*Releasing*](docs/developers/releasing.md) describes a release from it. No value, model +or public name changed. diff --git a/docs/developers/index.md b/docs/developers/index.md index 07b8440..c979f95 100644 --- a/docs/developers/index.md +++ b/docs/developers/index.md @@ -42,6 +42,7 @@ data fitting documentation register +releasing ../oeps/index ../roadmap ``` diff --git a/docs/developers/releasing.md b/docs/developers/releasing.md new file mode 100644 index 0000000..135e80c --- /dev/null +++ b/docs/developers/releasing.md @@ -0,0 +1,86 @@ +# Releasing + +A release publishes one source distribution and one wheel of `optmat` to PyPI, archives +the version on Zenodo and updates the manual. The version comes from the `v*` git tag. + +## Release notes between releases + +Pull requests add independent Markdown fragments under `changelog.d/`, named +`..md` +([format](https://github.com/LupoLab/optmat/blob/main/changelog.d/README.md)), instead of +editing `CHANGELOG.md`. After each push to `main`, the workflow +`.github/workflows/release-notes.yml` creates or refreshes one draft pull request, +**Prepare the next release notes**, on `chore/release-after-`. The latest +released version comes from `CITATION.cff`. The draft groups every pending fragment under +`Unreleased` in `CHANGELOG.md` and removes the collected fragments on its branch. Released +sections are copied unchanged. + +Edit pending fragments in ordinary pull requests, not in the generated draft. A manual +refresh is available with: + +```sh +gh workflow run release-notes.yml --ref main +``` + +The updater checks that its branch is exactly what it generated before writing another +commit, so manual edits stop it without being overwritten; a closed draft, or one marked +ready, pauses it. It never force-pushes, approves, merges, tags or publishes. + +Its pushes come from the workflow's own token, which starts no other workflow, so CI runs +on the draft only once someone pushes a commit to it: the release commit below. + +## Making a release + +1. Mark the rolling pull request ready, **before editing it**, which freezes the automatic + refreshes. If more changes reach `main` afterwards, merge `main` into the release + branch and run `uv run poe changelog-collect`, which appends only the fragments still + present. With no fragments pending there is no rolling pull request: make a release + branch from `main` instead. +2. Make the release commit on that branch: + - rename `Unreleased` in `CHANGELOG.md` to `## X.Y.Z (YYYY-MM-DD)`, above an empty + `## Unreleased`, and give it a short summary of the release, saying whether any + recommended value or model changed; + - if the curated records changed, state their counts there, with the sentence that + `tests/test_counts.py` keeps current in `README.md`; + - give `CITATION.cff` the same `version` and `date-released` + (`tests/test_release_metadata.py` checks that they agree). + + A minor version may break the public surface; every break is listed under + *Changed (breaking)*. +3. Run `uv run poe test-release`, which builds and installs the distributions offline. CI + runs on the commit. +4. Merge it once CI passes. +5. Tag the merge on `main` and push the tag on its own: + + ```sh + git tag -a vX.Y.Z COMMIT -m "optmat X.Y.Z" + git push origin vX.Y.Z + ``` + + The repository's rules let only administrators create a `v*` tag. +6. Publish a GitHub release from the tag, with the version's changelog section as its + notes: + + ```sh + gh release create vX.Y.Z --verify-tag --title "optmat X.Y.Z" --notes-file NOTES.md + ``` + + The release starts `.github/workflows/release.yml`, which builds the distributions, + checks that the built version matches the tag and that the wheel installs and imports, + and publishes to PyPI through trusted publishing. Zenodo archives the release with the + metadata of `.zenodo.json`. The concept DOI in `CITATION.cff` and the README names every + version, so it does not change. +7. Tell solprop if it is waiting on the release: comment on the issue or SEP that asked + for the change. + +## One-time setup + +These are done, and recorded here for a rebuild of the repository's settings: + +- On PyPI, a trusted publisher for `LupoLab/optmat`, the workflow `release.yml` and the + environment `pypi`, and a GitHub environment named `pypi`. +- On Zenodo, the repository switched on under *GitHub*. +- Under GitHub's *Settings → Actions → General → Workflow permissions*, *Allow GitHub + Actions to create and approve pull requests* enabled, with the default token permissions + read-only. Only the release-notes job asks for `contents: write` and + `pull-requests: write`. The workflow runs only from `main`. diff --git a/docs/developers/testing.md b/docs/developers/testing.md index 7e1c495..ac30746 100644 --- a/docs/developers/testing.md +++ b/docs/developers/testing.md @@ -7,7 +7,8 @@ uv run poe check ``` It runs formatting and lint checks, basedpyright, numpydoc, spelling, the copyright and -licence check (`reuse lint`), the tests under the coverage gate, and Sphinx with +licence check (`reuse lint`), the release-note check, the tests under the coverage gate, +and Sphinx with warnings treated as errors. The gallery executes during the build. CI (`.github/workflows/ci.yml`) runs the same gate on Linux and macOS for every push and pull request, the tests on Windows, and the tests on Python 3.12 with the lowest @@ -56,7 +57,7 @@ Regenerate both golden files after a reviewed model change: uv run python tools/golden_models.py ``` -Review the numerical and identity changes together, update the changelog and raise +Review the numerical and identity changes together, add a release-note fragment and raise the relevant `EVALUATION_VERSION` when evaluation changes. A failed digest check requires diagnosis: it does not by itself establish a scientific change. diff --git a/docs/oeps/oep-0000-process.md b/docs/oeps/oep-0000-process.md index d36a78d..58a2c8d 100644 --- a/docs/oeps/oep-0000-process.md +++ b/docs/oeps/oep-0000-process.md @@ -274,7 +274,7 @@ In the pull request that sets `Final`: - fix the relative links the move breaks; - remove its roadmap link; - close its tracking issue; -- cite the OEP in the `CHANGELOG.md` line. +- cite the OEP in its release-note fragment. A Final OEP is a historical record. It is not updated to follow later changes to the code; the manual describes current behaviour. Only errata and a "Superseded-by" note are @@ -369,7 +369,7 @@ an assumption that had to be invented is said plainly. | Manual (`docs/`) | How the functionality works and how to use it, as it is now | | {doc}`Roadmap <../roadmap>` | The status lists of accepted and draft OEPs | | [Issues](https://github.com/LupoLab/optmat/issues) | Planned work: ideas and known gaps, and each OEP's tracking issue | -| `CHANGELOG.md` | What changed in each release, citing the OEP | +| `CHANGELOG.md` | What changed in each release, collected from `changelog.d/` fragments, citing the OEP | The theory of an OEP moves into the manual when it is implemented. The manual page carries the derivation in its current form, and the OEP remains the record of the @@ -392,3 +392,5 @@ pull request that edits this document and adds a line to its history. - 2026-11-04: The solprop fixtures under `tests/data/` are frozen history, since solprop takes these values from optmat; the *Downstream* rule now links the solprop issue or SEP instead. +- 2026-11-04: A change's release note starts as a `changelog.d/` fragment, as in solprop; + the rolling release pull request collects them into `CHANGELOG.md`. diff --git a/pyproject.toml b/pyproject.toml index 579b57d..1adcf76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,9 +100,10 @@ packages = ["src/optmat"] # The sdist is the repository's tracked tree. hatchling reads only this repository's # .gitignore, so tools' local state that a global ignore hides (agents' worktrees under -# .claude/) is excluded here (OEP 1, Phase 5; TC-789). +# .claude/) is excluded here (OEP 1, Phase 5; TC-789). Pending release notes are not part +# of a release: its changelog holds them. [tool.hatch.build.targets.sdist] -exclude = ["/.claude", "/.codex"] +exclude = ["/.claude", "/.codex", "/changelog.d"] # ------------------------------------------------------------------------------ ruff [tool.ruff] @@ -213,9 +214,12 @@ coverage-fits = "pytest -m fits -n auto --dist load --cov --cov-report=term-miss coverage-library = "pytest -m library -n auto --dist load --cov --cov-report=term-missing:skip-covered --cov-report=html:build/coverage/library --cov-fail-under=0" reuse = "reuse lint" register-sync = "python tools/register_sync.py" +changelog-check = "python tools/release_notes.py check" +changelog-preview = "python tools/release_notes.py preview" +changelog-collect = "python tools/release_notes.py collect" docs = "sphinx-build -b html -W -n --keep-going docs docs/_build/html" docs-clean = "rm -rf docs/_build docs/auto_gallery docs/api/generated docs/generated" [tool.poe.tasks.check] -sequence = ["lint-fix", "fmt", "lint", "types", "docstrings", "spell", "reuse", "coverage-check", "docs"] +sequence = ["lint-fix", "fmt", "lint", "types", "docstrings", "spell", "reuse", "changelog-check", "coverage-check", "docs"] help = "The definition-of-done gate, in order." diff --git a/tests/test_counts.py b/tests/test_counts.py index 77fb83b..bd486b9 100644 --- a/tests/test_counts.py +++ b/tests/test_counts.py @@ -4,9 +4,10 @@ """The counts of curated records on the front pages are today's. -``README.md``, the functionality overview and the changelog state how many source -records, datasets, values, errata and published formulas there are, and how many of -them hold data of solids. When a record is added, update the numbers there. +``README.md`` and the functionality overview state how many source records, datasets, +values, errata and published formulas there are, and how many of them hold data of +solids. When a record is added, update the numbers there. A release states them in the +changelog (``docs/developers/releasing.md``); ordinary changes do not edit that file. """ import re @@ -18,7 +19,6 @@ from optmat.materials import material ROOT = Path(__file__).parents[1] -PAGES = ("README.md", "docs/introduction/overview.md", "CHANGELOG.md") def _text(page: str) -> str: @@ -53,7 +53,3 @@ def test_the_front_pages_counts_are_current(page): text = _text(page) for statement in _counts().values(): assert statement in text, (page, statement) - - -def test_the_changelogs_counts_are_current(): - assert _counts()["records"] in _text("CHANGELOG.md") diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py new file mode 100644 index 0000000..29ec8f6 --- /dev/null +++ b/tests/test_release_metadata.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: MIT + +"""The release records agree: the authors, and the citation's version and date. + +``CITATION.cff``, ``.zenodo.json`` and ``pyproject.toml`` each name the authors, and a +release commit moves the changelog's ``Unreleased`` section under its version and date +(``docs/developers/releasing.md``); the citation must then carry the same two. The files +are read as text: the citation's few keys need no YAML parser. +""" + +import json +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +RELEASED = re.compile(r"^## (\d+\.\d+\.\d+) \((\d{4}-\d{2}-\d{2})\)\s*$", re.M) +"""A released changelog section: ``## 0.9.1 (2026-10-24)``.""" + + +def _citation() -> str: + return (ROOT / "CITATION.cff").read_text(encoding="utf-8") + + +def _scalar(text: str, key: str) -> str | None: + """Return a top-level scalar of the citation, unquoted, or ``None``.""" + found = re.search(rf"^{key}: *(.+?) *$", text, re.M) + return None if found is None else found[1].strip("\"'") + + +def test_every_record_names_the_same_authors(): + """The citation, the archive record and the distribution agree, in order.""" + citation = [ + f"{given} {family}" + for family, given in re.findall( + r"- family-names: *(.+?)\n +given-names: *(.+?)\n", _citation() + ) + ] + zenodo = json.loads((ROOT / ".zenodo.json").read_text(encoding="utf-8")) + archive = [ + " ".join(reversed(creator["name"].split(", "))) + for creator in zenodo["creators"] + ] + with (ROOT / "pyproject.toml").open("rb") as handle: + project = [a["name"] for a in tomllib.load(handle)["project"]["authors"]] + assert citation + assert archive == citation + assert project == citation + + +def test_the_citation_names_the_latest_release(): + """The citation carries the version and date of the changelog's newest section.""" + released = RELEASED.findall((ROOT / "CHANGELOG.md").read_text(encoding="utf-8")) + assert released + citation = _citation() + assert ( + _scalar(citation, "version"), + _scalar(citation, "date-released"), + ) == released[0] diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 0000000..4935bac --- /dev/null +++ b/tests/test_release_notes.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: MIT + +"""Release fragments collect once without changing any released history.""" + +import importlib.util +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[1] / "tools" / "release_notes.py" +SPEC = importlib.util.spec_from_file_location("release_notes", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +release_notes = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release_notes) + +CHANGELOG = """# Changelog + +## Unreleased + +### Fixed + +- An existing correction. + +## 0.1.0 (2026-11-01) + +A released summary. + +### Added + +- Released prose, with deliberate trailing spaces. + +[0.1.0]: https://example.org/release +""".replace("trailing spaces.", "trailing spaces. ") + + +def test_groups_notes_and_preserves_released_history(): + files = { + "README.md": "Instructions, not a note.", + "second.fixed.md": "Another correction.\nWith a continuation.", + "first.fixed.md": "The first correction.", + "interface.breaking.md": "Rename a keyword.\n\nUse its new name.", + "feature.added.md": "A new feature.", + } + result = release_notes._render(CHANGELOG, release_notes._fragments(files)) + assert ( + result[result.index("## 0.1.0") :] == CHANGELOG[CHANGELOG.index("## 0.1.0") :] + ) + assert result.count("### Fixed") == 1 + assert "- Rename a keyword.\n\n Use its new name." in result + assert result.index("### Added") < result.index("### Changed (breaking)") + assert result.index("existing correction") < result.index("first correction") + assert result.index("first correction") < result.index("Another correction") + assert "Instructions, not a note" not in result + + +@pytest.mark.parametrize( + "name,text", + [ + ("wrong.md", "A correction."), + ("note.unknown.md", "A correction."), + ("../note.fixed.md", "A correction."), + ("note.fixed.md", " \n"), + ("note.fixed.md", "# A heading"), + ("note.fixed.md", "A correction.\n<<<<<<< branch"), + ("note.fixed.md", "- A bullet"), + ], +) +def test_rejects_invalid_fragments(name, text): + with pytest.raises(ValueError): + release_notes._fragments({name: text}) + + +@pytest.mark.parametrize( + "text", + [ + "# No Unreleased section\n", + "## Unreleased\n\n## Unreleased\n", + "## Unreleased\n\n### Unknown\n", + "## Unreleased\n\n### Fixed\n\n### Fixed\n", + ], +) +def test_rejects_ambiguous_changelog(text): + with pytest.raises(ValueError): + release_notes._render(text, ()) + + +def test_collect_is_idempotent_and_check_and_preview_do_not_write( + tmp_path, monkeypatch, capsys +): + monkeypatch.chdir(tmp_path) + path = tmp_path / "CHANGELOG.md" + path.write_text(CHANGELOG, encoding="utf-8") + folder = tmp_path / "changelog.d" + folder.mkdir() + note = folder / "one.fixed.md" + note.write_text("A new correction.\n", encoding="utf-8") + instructions = folder / "README.md" + instructions.write_text("Instructions\n", encoding="utf-8") + for command in ("check", "preview"): + monkeypatch.setattr("sys.argv", ["release_notes", command]) + release_notes.main() + assert path.read_text(encoding="utf-8") == CHANGELOG + assert note.exists() + assert "A new correction." in capsys.readouterr().out + monkeypatch.setattr("sys.argv", ["release_notes", "collect"]) + release_notes.main() + collected = path.read_bytes() + assert not note.exists() + assert instructions.exists() + release_notes.main() + assert path.read_bytes() == collected + assert collected.count(b"A new correction.") == 1 + + +def test_invalid_fragment_leaves_all_inputs_unchanged(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "CHANGELOG.md").write_text(CHANGELOG, encoding="utf-8") + (tmp_path / "changelog.d").mkdir() + (tmp_path / "changelog.d" / "invalid.md").write_text("Text", encoding="utf-8") + before = {path: path.read_bytes() for path in tmp_path.rglob("*.md")} + monkeypatch.setattr("sys.argv", ["release_notes", "collect"]) + with pytest.raises(SystemExit, match="2"): + release_notes.main() + assert {path: path.read_bytes() for path in tmp_path.rglob("*.md")} == before + + +def test_current_repository_notes_validate(): + root = Path(__file__).resolve().parents[1] + release_notes._render( + (root / "CHANGELOG.md").read_text(encoding="utf-8"), + release_notes._load(root), + ) diff --git a/tests/test_release_notes_pr.py b/tests/test_release_notes_pr.py new file mode 100644 index 0000000..f71292d --- /dev/null +++ b/tests/test_release_notes_pr.py @@ -0,0 +1,198 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: MIT + +"""Exercise the rolling PR against isolated local Git repositories, without a token.""" + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +TOOLS = Path(__file__).parents[1] / "tools" +CHANGELOG = """# Changelog + +## Unreleased + +### Fixed + +- An existing correction. + +## 0.1.0 (2026-11-01) + +### Added + +- Released prose, with deliberate trailing spaces. \n""" + +# The updater imports its sibling as a script does, so the folder must be importable +# while it loads, and for no test after that. +sys.path.insert(0, str(TOOLS)) +try: + SPEC = importlib.util.spec_from_file_location( + "update_release_notes", TOOLS / "update_release_notes.py" + ) + assert SPEC is not None and SPEC.loader is not None + updater = importlib.util.module_from_spec(SPEC) + SPEC.loader.exec_module(updater) +finally: + sys.path.remove(str(TOOLS)) + + +@pytest.fixture +def repository(tmp_path, monkeypatch): + remote = tmp_path / "remote.git" + checkout = tmp_path / "checkout" + checkout.mkdir() + monkeypatch.chdir(checkout) + updater._git("init", "--bare", str(remote)) + updater._git("init", "-b", "main") + updater._git("config", "user.name", "Release test") + updater._git("config", "user.email", "release@example.org") + updater._git("config", "commit.gpgsign", "false") + updater._git("config", "core.autocrlf", "false") + updater._git("remote", "add", "origin", str(remote)) + (checkout / "CHANGELOG.md").write_text(CHANGELOG, encoding="utf-8") + (checkout / "CITATION.cff").write_text("version: 0.1.0\n", encoding="utf-8") + (checkout / "changelog.d").mkdir() + (checkout / "changelog.d" / "README.md").write_text("Notes\n", encoding="utf-8") + (checkout / "changelog.d" / "one.fixed.md").write_text( + "Correction.\n", encoding="utf-8" + ) + _save_main() + return checkout + + +def _save_main(): + updater._git("add", ".") + updater._git("commit", "-m", "Implementation change") + updater._git("push", "origin", "main") + return updater._git("rev-parse", "HEAD") + + +def test_refresh_tracks_main_and_preserves_history_and_checkout(repository): + base = updater._git("rev-parse", "HEAD") + first = updater._commit(base, None) + assert first is not None + assert updater._git("rev-list", "--parents", "-1", first).split() == [first, base] + assert updater._commit(base, first) is None + assert updater._show(first, "CHANGELOG.md").count("- Correction.") == 1 + assert ( + updater._git("ls-tree", "--name-only", first, "changelog.d/one.fixed.md") == "" + ) + # A corrected old fragment, a new note and new code all come from current main. + (repository / "changelog.d" / "one.fixed.md").write_text( + "Corrected text.\n", encoding="utf-8" + ) + (repository / "changelog.d" / "two.added.md").write_text( + "New feature.\n", encoding="utf-8" + ) + (repository / "code.py").write_text("answer = 42\n", encoding="utf-8") + latest = _save_main() + second = updater._commit(latest, first) + assert second is not None + assert updater._git("rev-list", "--parents", "-1", second).split() == [ + second, + first, + latest, + ] + notes = updater._show(second, "CHANGELOG.md") + assert notes.count("Corrected text.") == 1 + assert "Correction." not in notes + assert notes.count("New feature.") == 1 + assert notes[notes.index("## 0.1.0") :] == CHANGELOG[CHANGELOG.index("## 0.1.0") :] + assert updater._show(second, "code.py") == "answer = 42\n" + assert updater._git("status", "--porcelain") == "" + assert updater._git("rev-parse", "HEAD") == latest + # Both pushes succeed without force, including the update of an existing branch. + updater._git("push", "origin", f"{first}:refs/heads/release") + updater._git("push", "origin", f"{second}:refs/heads/release") + + +@pytest.mark.parametrize("preserve_message", [False, True]) +def test_manual_branch_edits_are_not_overwritten(repository, preserve_message): + base = updater._git("rev-parse", "HEAD") + generated = updater._commit(base, None) + assert generated is not None + # Even a human commit that preserves the generator's trailer must match its tree. + message = ( + updater._git("show", "-s", "--format=%B", generated) + if preserve_message + else "Prepare release" + ) + manual = updater._run( + "git", + "commit-tree", + updater._git("rev-parse", "HEAD^{tree}"), + "-p", + generated, + input_text=message, + ) + with pytest.raises(ValueError, match="manual edits"): + updater._commit(base, manual) + assert updater._git("status", "--porcelain") == "" + + +@pytest.mark.parametrize( + "state,draft", [("CLOSED", True), ("MERGED", False), ("OPEN", False)] +) +def test_closed_or_ready_pr_pauses_before_pushing( + repository, monkeypatch, state, draft +): + original_run = updater._run + + def run(*args, input_text=None): + if args[:3] == ("gh", "pr", "list"): + return json.dumps([{"number": 1, "state": state, "isDraft": draft}]) + assert "push" not in args + return original_run(*args, input_text=input_text) + + monkeypatch.setattr(updater, "_run", run) + monkeypatch.setenv("GITHUB_REPOSITORY", "example/project") + updater.main() + + +def test_create_retry_and_refresh_keep_one_draft(repository, monkeypatch): + original_run = updater._run + pulls = [] + created = [] + + def run(*args, input_text=None): + if args[:3] == ("gh", "pr", "list"): + return json.dumps(pulls) + if args[:3] == ("gh", "pr", "create"): + assert "--draft" in args + assert "--body-file" in args + created.append(input_text) + return "https://example.org/pull/1" + return original_run(*args, input_text=input_text) + + monkeypatch.setattr(updater, "_run", run) + monkeypatch.setenv("GITHUB_REPOSITORY", "example/project") + # Retry after a pushed branch but failed PR creation must still create the PR. + base = updater._git("rev-parse", "HEAD") + commit = updater._commit(base, None) + assert commit is not None + updater._git("push", "origin", f"{commit}:refs/heads/chore/release-after-0.1.0") + updater.main() + assert len(created) == 1 + pulls.append({"number": 1, "state": "OPEN", "isDraft": True}) + updater.main() + assert len(created) == 1 + (repository / "changelog.d" / "two.fixed.md").write_text( + "Another correction.\n", encoding="utf-8" + ) + _save_main() + updater.main() + head = updater._git( + "ls-remote", "--heads", "origin", "chore/release-after-0.1.0" + ).split()[0] + assert updater._show(head, "CHANGELOG.md").count("Another correction.") == 1 + assert len(created) == 1 + + +def test_no_pending_notes_creates_no_commit(repository): + (repository / "changelog.d" / "one.fixed.md").unlink() + base = _save_main() + assert updater._commit(base, None) is None diff --git a/tools/release_notes.py b/tools/release_notes.py new file mode 100644 index 0000000..57b7361 --- /dev/null +++ b/tools/release_notes.py @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: MIT + +"""Validate and collect independent Markdown release-note fragments.""" + +import argparse +import re +import sys +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + +__all__ = ["main"] + +_CATEGORIES = { + "added": "Added", + "changed": "Changed", + "breaking": "Changed (breaking)", + "deprecated": "Deprecated", + "removed": "Removed", + "fixed": "Fixed", + "security": "Security", +} +_NAME = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\.([a-z]+)\.md") + + +@dataclass(frozen=True) +class _Fragment: + name: str + category: str + text: str + + +def _fragments(files: Mapping[str, str]) -> tuple[_Fragment, ...]: + fragments = [] + for name, content in sorted(files.items()): + if name == "README.md": + continue + match = _NAME.fullmatch(name) + if match is None or match[1] not in _CATEGORIES: + raise ValueError(f"Invalid release-note filename: {name}") + text = content.strip() + if not text or re.search(r"^(?:#|<<<<<<<|=======|>>>>>>>)", text, re.M): + raise ValueError(f"Empty note, heading or conflict marker in {name}") + if re.match(r"(?:[-*+] |\d+\. )", text): + raise ValueError(f"Write one entry without its leading bullet in {name}") + fragments.append(_Fragment(name, _CATEGORIES[match[1]], text)) + return tuple(fragments) + + +def _render(changelog: str, fragments: Sequence[_Fragment]) -> str: + headings = list(re.finditer(r"^## Unreleased\n", changelog, re.M)) + if len(headings) != 1: + raise ValueError("CHANGELOG.md must have exactly one '## Unreleased' heading") + start = headings[0].end() + following = re.search(r"^## ", changelog[start:], re.M) + end = len(changelog) if following is None else start + following.start() + pending = changelog[start:end] + sections: dict[str, str] = {} + parts = re.split(r"^### (.+)\n", pending, flags=re.M) + for title, body in zip(parts[1::2], parts[2::2], strict=True): + if title not in _CATEGORIES.values() or title in sections: + raise ValueError(f"Unknown or repeated Unreleased category: {title}") + sections[title] = body.strip() + if not fragments: + return changelog + for fragment in fragments: + lines = fragment.text.splitlines() + entry = ( + "- " + + lines[0] + + "".join(f"\n {line}" if line else "\n" for line in lines[1:]) + ) + sections[fragment.category] = ( + sections.get(fragment.category, "") + "\n" + entry + ).strip() + blocks = [parts[0].strip()] if parts[0].strip() else [] + blocks.extend( + f"### {title}\n\n{sections[title]}" + for title in _CATEGORIES.values() + if title in sections + ) + # The released suffix is copied verbatim, including its whitespace and links. + return changelog[:start] + "\n" + "\n\n".join(blocks) + "\n\n" + changelog[end:] + + +def _load(root: Path) -> tuple[_Fragment, ...]: + folder = root / "changelog.d" + files = {} + for path in folder.iterdir(): + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Release notes must be regular files: {path.name}") + files[path.name] = path.read_text(encoding="utf-8") + return _fragments(files) + + +def main() -> None: + """Check, preview or collect the current checkout's pending release notes.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("check", "preview", "collect")) + args = parser.parse_args() + root = Path.cwd() + try: + fragments = _load(root) + path = root / "CHANGELOG.md" + rendered = _render(path.read_text(encoding="utf-8"), fragments) + except (OSError, ValueError) as error: + parser.error(str(error)) + if args.command == "preview": + sys.stdout.write(rendered) + elif args.command == "collect": + path.write_text(rendered, encoding="utf-8") + for fragment in fragments: + (root / "changelog.d" / fragment.name).unlink() + + +if __name__ == "__main__": + main() diff --git a/tools/update_release_notes.py b/tools/update_release_notes.py new file mode 100644 index 0000000..a79200d --- /dev/null +++ b/tools/update_release_notes.py @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: MIT + +"""Refresh the one draft release-notes PR from trusted main-branch contents.""" + +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +# Run by path (`python tools/update_release_notes.py`), so its sibling is importable. +from release_notes import _fragments, _render + +__all__ = ["main"] + +_TRAILER = "Release-Notes-Base: " +_BODY = """Collect pending release notes into `CHANGELOG.md`. + +This draft is refreshed from `main` after merges. Each update collects the Markdown +fragments and removes the collected files in this branch. Released history is preserved. +No issue is required for a fragment; link the implementation PR or issue in its text +when useful, and cite register entries as `TC-n`. Edit fragments in implementation PRs +rather than this generated draft. + +To prepare the release, mark this PR ready to freeze automatic updates, then follow +[`docs/developers/releasing.md`](https://github.com/LupoLab/optmat/blob/main/docs/developers/releasing.md). +Review the notes, choose the version and date, and update `CITATION.cff` in this PR. +Tagging and publishing remain manual. Required checks still apply; approve any pending +workflow runs before merging. Nothing in this workflow approves or merges a PR. +""" + + +def _run(*args: str, input_text: str | None = None) -> str: + return subprocess.run( + args, + input=input_text, + text=True, + encoding="utf-8", + check=True, + stdout=subprocess.PIPE, + ).stdout.strip() + + +def _git(*args: str) -> str: + return _run("git", *args) + + +def _show(ref: str, path: str) -> str: + # Keep trailing newlines: released changelog history is immutable. + return subprocess.run( + ["git", "show", f"{ref}:{path}"], + text=True, + encoding="utf-8", + check=True, + stdout=subprocess.PIPE, + ).stdout + + +def _tree(base: str) -> str: + files = {} + entries = _git("ls-tree", "-r", "-z", base, "--", "changelog.d") + for entry in entries.split("\0"): + if not entry: + continue + metadata, path = entry.split("\t", 1) + if not metadata.startswith("100644 blob "): + raise ValueError(f"Release notes must be regular files: {path}") + files[path.removeprefix("changelog.d/")] = _show(base, path) + fragments = _fragments(files) + rendered = _render(_show(base, "CHANGELOG.md"), fragments) + # A private index builds the generated tree without touching the checkout or + # its index. All other files come from main, including concurrent code changes. + with tempfile.TemporaryDirectory() as directory: + env = {**os.environ, "GIT_INDEX_FILE": str(Path(directory) / "index")} + + def index(*args: str) -> str: + return subprocess.run( + ["git", *args], + env=env, + text=True, + encoding="utf-8", + check=True, + stdout=subprocess.PIPE, + ).stdout.strip() + + index("read-tree", base) + blob = _run("git", "hash-object", "-w", "--stdin", input_text=rendered) + index("update-index", "--add", "--cacheinfo", f"100644,{blob},CHANGELOG.md") + for fragment in fragments: + index("update-index", "--force-remove", f"changelog.d/{fragment.name}") + return index("write-tree") + + +def _commit(base: str, previous: str | None) -> str | None: + tree = _tree(base) + if previous is not None: + message = _git("show", "-s", "--format=%B", previous) + match = re.fullmatch( + rf"Collect release notes\n\n{_TRAILER}([0-9a-f]{{40}})", message + ) + if match is None or _tree(match[1]) != _git( + "rev-parse", f"{previous}^{{tree}}" + ): + raise ValueError("Release branch has manual edits; leave it unchanged") + if tree == _git("rev-parse", f"{previous}^{{tree}}"): + return None + elif tree == _git("rev-parse", f"{base}^{{tree}}"): + return None + parents = ["-p", base] if previous is None else ["-p", previous, "-p", base] + # Retaining the previous head as a parent makes every update a fast-forward. + # The generated tree was verified above; no human edits or history are replaced. + return _run( + "git", + "commit-tree", + tree, + *parents, + input_text=f"Collect release notes\n\n{_TRAILER}{base}\n", + ) + + +def main() -> None: + """Create or refresh the rolling draft without force pushes or auto-merging.""" + repository = os.environ["GITHUB_REPOSITORY"] + base = _git("rev-parse", "origin/main") + citation = _show(base, "CITATION.cff") + version = re.search(r"^version: ['\"]?(\d+\.\d+\.\d+)['\"]?$", citation, re.M) + if version is None: + raise ValueError("CITATION.cff must name the latest released version") + branch = f"chore/release-after-{version[1]}" + pulls = json.loads( + _run( + "gh", + "pr", + "list", + "--repo", + repository, + "--head", + branch, + "--state", + "all", + "--json", + "number,state,isDraft", + "--limit", + "2", + ) + ) + if pulls and ( + len(pulls) != 1 or pulls[0]["state"] != "OPEN" or not pulls[0]["isDraft"] + ): + sys.stdout.write( + "Release PR is closed or ready for review; refresh is paused.\n" + ) + return + previous = None + if _git("ls-remote", "--heads", "origin", f"refs/heads/{branch}"): + _git("fetch", "origin", f"refs/heads/{branch}") + previous = _git("rev-parse", "FETCH_HEAD") + commit = _commit(base, previous) + if commit is not None: + _git("push", "origin", f"{commit}:refs/heads/{branch}") + if not pulls and (commit is not None or previous is not None): + sys.stdout.write( + _run( + "gh", + "pr", + "create", + "--repo", + repository, + "--base", + "main", + "--head", + branch, + "--draft", + "--title", + "Prepare the next release notes", + "--body-file", + "-", + input_text=_BODY, + ) + + "\n" + ) + + +if __name__ == "__main__": + main()