From 1b0dbced70de319ba0468cd6f9bd1b813dd5fe3e Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Thu, 24 Sep 2026 01:21:04 -0700 Subject: [PATCH 1/7] Date the changelog's release headings Add each release's tag date to its heading, describe the Unreleased convention in the intro, and record the changelog mechanism. --- docs/changelog.md | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 663e7dc..687ec43 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,9 @@ # Changelog -Release notes are also published on the +User-visible changes to pamica, newest first. +Changes merged to `dev` since the last release collect under **Unreleased**; +at release time that heading becomes the version and its date. +Each release's section is also its note on the [GitHub releases page](https://github.com/sccn/pAMICA/releases). ## Unreleased @@ -664,8 +667,23 @@ and every backend's fitting follows the Fortran reference more closely. Python files under `.context/` still run the full CI. - **The rebuilt `paper.pdf` is committed back on `dev` and `main` only.** Feature branches build it as an artifact, so a PR's head is never a bot commit whose checks wait for approval. + The commit rebases onto the branch head before pushing, because the version bump can land while the PDF builds. +- **A changelog check runs on every pull request.** + A change to package code into `dev` must add its entry to this changelog, or carry the `skip-changelog` label when it has no user-visible effect. + A release into `main` must carry its dated section. +- **Release notes come from this changelog.** + `auto-tag.yml` publishes the release's section, extracted by `scripts/changelog_section.py` with its links made absolute, and appends GitHub's generated pull-request list. + The notes of the earlier releases were refreshed the same way. -## 0.3.3 +### Project + +- **A root `CHANGELOG.md` points to this changelog,** + and the package metadata links it and the documentation site, so PyPI shows both. +- **Release headings carry their dates** (`## X.Y.Z - YYYY-MM-DD`), taken from the release tags; + `pamica/tests/test_changelog.py` checks the format. +- **`.rules/changelog.md` records the practice:** what an entry says, when the `skip-changelog` label applies, and the release-prep steps. + +## 0.3.3 - 2026-09-01 MLX fitting parity (convergence stops, component sharing, Newton, all five source-density families), best-of-N restarts on every backend, the @@ -1043,7 +1061,7 @@ external tester (#221). bundled sample reproduces its previous `comp_list` and log-likelihood bit for bit. -## 0.3.2 +## 0.3.2 - 2026-08-15 Rank-deficient input support across every backend, a much faster default block size, and a reproducible Fortran reference for parity runs. @@ -1103,7 +1121,7 @@ size, and a reproducible Fortran reference for parity runs. (missing these keys) still load, falling back to the Fortran-faithful defaults. -## 0.3.1 +## 0.3.1 - 2026-07-19 Rho-rate schedule fixes across all backends and a reproducible-seed option in the native binary build. @@ -1126,7 +1144,7 @@ native binary build. components, not a dynamics bug (identical init gives matching results); the optional init-robustness enhancement is tracked in #198. -## 0.3.0 +## 0.3.0 - 2026-07-18 MNE-Python compatibility layer (epic #139), additive: the scikit-learn-style `AMICA` API and the byte-identical EEGLAB I/O are unchanged. @@ -1180,7 +1198,7 @@ MNE-Python compatibility layer (epic #139), additive: the scikit-learn-style `AMICA.mir`/`pmi` (#133); the results match the array API exactly (phase 4, #143). -## 0.2.2 +## 0.2.2 - 2026-07-18 GitHub repository rename to pAMICA and a `__version__` fix. @@ -1197,7 +1215,7 @@ GitHub repository rename to pAMICA and a `__version__` fix. snippets are updated to match. GitHub redirects the old repo URLs, and the package/import name stays lowercase `pamica` (#184). -## 0.2.1 +## 0.2.1 - 2026-07-18 PyPI publishing, release-metadata sync, the pAMICA display title, and native-engine documentation. @@ -1218,7 +1236,7 @@ native-engine documentation. backend on any platform, not only through the bundled macOS `amica15mac` fixture (#147 phase 5, #179). -## 0.2.0 +## 0.2.0 - 2026-07-17 Package rename to align with the reserved PyPI name. @@ -1229,7 +1247,7 @@ Package rename to align with the reserved PyPI name. domain (`eeglab.org/pyAMICA`), and the release-asset repository are unchanged (#176). -## 0.1.3 +## 0.1.3 - 2026-07-17 Native Fortran run engine, separation-quality metrics, LLt output parity, and the `loadmodout` byte-order fix. @@ -1309,7 +1327,7 @@ the `loadmodout` byte-order fix. `rho0=1.5`). Affected `numpy_impl.viz.plot_pdf_fits`; the fit path was never affected, as it uses its own log-space implementation (#136). -## 0.1.2 +## 0.1.2 - 2026-07-14 Outlier-rejection parity in the NumPy backend, repo-wide type-checking, and the full validation-evidence documentation. @@ -1328,7 +1346,7 @@ full validation-evidence documentation. (cross-backend equivalence matrix and IC topomaps), the EEGLAB drop-in round-trip, and the other validated behaviors (#108). -## 0.1.1 +## 0.1.1 - 2026-07-13 Validation-methodology and correctness fixes since 0.1.0. @@ -1347,7 +1365,7 @@ Validation-methodology and correctness fixes since 0.1.0. - Corrected a stale float32-speedup claim and added a funding acknowledgment (#114). -## 0.1.0 +## 0.1.0 - 2026-07-11 First public release. From a9e234d5ad75fa9b103ad11d2dd41c48c4ac046d Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Thu, 24 Sep 2026 01:21:05 -0700 Subject: [PATCH 2/7] Point a root CHANGELOG.md and PyPI at the changelog --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 ++ 2 files changed, 10 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ac4b544 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +pamica's changelog is [`docs/changelog.md`](docs/changelog.md), +published at . +It lists the user-visible changes in every release since 0.1.0, newest first, +with the changes merged since the last release under **Unreleased**. +Each release's section is also its note on the +[GitHub releases page](https://github.com/sccn/pAMICA/releases). diff --git a/pyproject.toml b/pyproject.toml index 559a3c6..b16ae53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,8 @@ mne = ["mne>=1.6"] [project.urls] Homepage = "https://github.com/sccn/pAMICA" Repository = "https://github.com/sccn/pAMICA" +Documentation = "https://eeglab.org/pAMICA/" +Changelog = "https://eeglab.org/pAMICA/changelog/" [tool.setuptools] # List subpackages explicitly so the wheel is deterministic across platforms From b47f3c4400e0ccedc0e41b2917395a2b9661ed6c Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Thu, 24 Sep 2026 01:21:05 -0700 Subject: [PATCH 3/7] Extract a release's changelog section as release notes scripts/changelog_section.py prints one release's section with its relative docs links made absolute, and --check gates a release. Tested on the real changelog (11 tests). --- pamica/tests/test_changelog.py | 105 +++++++++++++++++++++++++++++++++ scripts/changelog_section.py | 104 ++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 pamica/tests/test_changelog.py create mode 100644 scripts/changelog_section.py diff --git a/pamica/tests/test_changelog.py b/pamica/tests/test_changelog.py new file mode 100644 index 0000000..972b12a --- /dev/null +++ b/pamica/tests/test_changelog.py @@ -0,0 +1,105 @@ +"""The changelog's release format and the release-notes extractor. + +``docs/changelog.md`` is the project's only changelog, and the release chain +reads it: ``scripts/changelog_section.py`` turns one release's section into the +GitHub release notes, and the changelog workflow refuses a release into ``main`` +without that section (``.rules/changelog.md``). These tests hold the file to the +format the extractor expects and run the extractor on the real file. +""" + +import importlib.util +import re +from datetime import date +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[2] +_CHANGELOG = (_REPO / "docs" / "changelog.md").read_text(encoding="utf-8") + + +def _load(): + path = _REPO / "scripts" / "changelog_section.py" + spec = importlib.util.spec_from_file_location("changelog_section_script", path) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +script = _load() +_RELEASE = re.compile(r"^(\d+)\.(\d+)\.(\d+) - (\d{4}-\d{2}-\d{2})$") + + +def test_every_release_heading_is_versioned_and_dated_newest_first(): + titles = script.release_titles(_CHANGELOG) + releases = [t for t in titles if t != "Unreleased"] + assert titles.count("Unreleased") <= 1 + if "Unreleased" in titles: + assert titles[0] == "Unreleased", "Unreleased belongs above every release" + parsed = [] + for title in releases: + m = _RELEASE.match(title) + assert m, f"release heading {title!r} is not 'X.Y.Z - YYYY-MM-DD'" + parsed.append((tuple(int(x) for x in m.groups()[:3]), date.fromisoformat(m[4]))) + versions = [v for v, _ in parsed] + assert versions == sorted(versions, reverse=True), "releases are newest first" + assert len(set(versions)) == len(versions) + dates = [d for _, d in parsed] + assert dates == sorted(dates, reverse=True) + + +def test_section_returns_one_release_and_stops_at_the_next(): + body = script.section(_CHANGELOG, "0.3.2") + assert body is not None and body.strip() + assert "## " not in body.splitlines()[0] + older = script.section(_CHANGELOG, "0.3.1") + assert older is not None + assert older.strip() not in body + + +@pytest.mark.parametrize("version", ["9.9.9", "0.3", "Unreleased"]) +def test_missing_release_has_no_section(version): + assert script.section(_CHANGELOG, version) is None + assert script.main([version, "--check"]) == 1 + + +def test_newest_section_gets_absolute_links(): + # The newest section (Unreleased, or the release it became) carries relative + # links into the docs, which must resolve on the GitHub release page. An + # Unreleased heading is renamed the way release prep renames it. + newest = script.release_titles(_CHANGELOG)[0] + if newest == "Unreleased": + text = _CHANGELOG.replace("## Unreleased", "## 9.9.9 - 2099-01-01", 1) + version = "9.9.9" + else: + text, version = _CHANGELOG, newest.split(" - ")[0] + body = script.section(text, version) + assert body is not None + assert "https://eeglab.org/pAMICA/" in body, "a relative docs link was rewritten" + for target in re.findall(r"\]\(([^)\s]+)\)", body): + assert target.startswith(("https://", "http://", "mailto:")), target + + +@pytest.mark.parametrize( + ("target", "url"), + [ + ( + "guides/amica-differences.md#unmapped-fortran-keywords", + "https://eeglab.org/pAMICA/guides/amica-differences/#unmapped-fortran-keywords", + ), + ( + "#persistence-and-exports", + "https://eeglab.org/pAMICA/changelog/#persistence-and-exports", + ), + ("index.md", "https://eeglab.org/pAMICA/"), + ("guides/index.md", "https://eeglab.org/pAMICA/guides/"), + ], +) +def test_site_url(target, url): + assert script._site_url(target) == url + + +def test_check_passes_for_a_released_version(capsys): + assert script.main(["0.3.3", "--check"]) == 0 + assert capsys.readouterr().out == "" diff --git a/scripts/changelog_section.py b/scripts/changelog_section.py new file mode 100644 index 0000000..1e208ba --- /dev/null +++ b/scripts/changelog_section.py @@ -0,0 +1,104 @@ +"""Print one release's section of the changelog, ready to use as release notes. + +``docs/changelog.md`` is the single changelog. Each release is a level-2 heading +``## X.Y.Z - YYYY-MM-DD``; changes merged since the last release collect under +``## Unreleased`` until release prep renames that heading. This script feeds the +release chain and its gate: + + python scripts/changelog_section.py 0.4.0 # print the section body + python scripts/changelog_section.py 0.4.0 --check # exit 1 if it is missing + +``auto-tag.yml`` uses the printed section as the GitHub release notes, and the +changelog workflow runs ``--check`` on pull requests into ``main`` so a release +cannot merge without its section. The changelog's relative links resolve on the +documentation site but not on GitHub, so the printed section rewrites them to +absolute URLs on the published site. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CHANGELOG = ROOT / "docs" / "changelog.md" +SITE_URL = "https://eeglab.org/pAMICA/" + +# "## 0.3.3 - 2026-09-01" (release) or "## Unreleased". +_HEADING = re.compile(r"^## (?P.+?)\s*$") +_RELEASE_TITLE = re.compile( + r"^(?P<version>\d+\.\d+\.\d+\S*) - (?P<date>\d{4}-\d{2}-\d{2})$" +) +# A Markdown link target that is neither absolute nor an e-mail address. +_RELATIVE_LINK = re.compile(r"\]\((?!https?://|mailto:)(?P<target>[^)\s]+)\)") + + +def _site_url(target: str) -> str: + """Absolute site URL for a link target written relative to ``docs/changelog.md``.""" + path, _, anchor = target.partition("#") + if not path: # an in-page anchor of the changelog itself + page = "changelog/" + elif path.endswith(".md"): + page = path[: -len(".md")] + page = "" if page == "index" else page.removesuffix("/index") + "/" + else: # an asset, such as a figure + page = path + return SITE_URL + page + (f"#{anchor}" if anchor else "") + + +def release_titles(text: str) -> list[str]: + """Every level-2 heading title, in file order.""" + return [m["title"] for line in text.splitlines() if (m := _HEADING.match(line))] + + +def section(text: str, version: str) -> str | None: + """The body of ``version``'s section, links made absolute, or ``None``.""" + lines = text.splitlines() + start = None + for i, line in enumerate(lines): + m = _HEADING.match(line) + if m is None: + continue + if start is not None: + end = i + break + title = _RELEASE_TITLE.match(m["title"]) + if title is not None and title["version"] == version: + start = i + 1 + else: + end = len(lines) + if start is None: + return None + body = "\n".join(lines[start:end]).strip() + if not body: + return None + return _RELATIVE_LINK.sub(lambda m: f"]({_site_url(m['target'])})", body) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Print one release's changelog section as release notes." + ) + parser.add_argument("version", help="release version, for example 0.4.0") + parser.add_argument("--changelog", type=Path, default=CHANGELOG) + parser.add_argument( + "--check", action="store_true", help="only verify that the section exists" + ) + args = parser.parse_args(argv) + body = section(args.changelog.read_text(encoding="utf-8"), args.version) + if body is None: + print( + f"{args.changelog}: no non-empty '## {args.version} - YYYY-MM-DD' section. " + "Release prep renames '## Unreleased' to the version and its date.", + file=sys.stderr, + ) + return 1 + if not args.check: + sys.stdout.write(body) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From f6ce3b555b820ccea3ce07b1d56f01dbad9132da Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi <shirazi@ieee.org> Date: Thu, 24 Sep 2026 01:21:06 -0700 Subject: [PATCH 4/7] Publish release notes from the changelog and check entries auto-tag.yml uses the release's changelog section as the notes, with generated PR notes appended. changelog.yml requires an entry for package-code PRs into dev (skip-changelog label to opt out) and the release section for PRs into main. Checked with actionlint. --- .github/workflows/auto-tag.yml | 10 +++++ .github/workflows/changelog.yml | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 .github/workflows/changelog.yml diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 45cf829..985a0bb 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -135,6 +135,16 @@ jobs: set -euo pipefail TAG="v${{ steps.final.outputs.version }}" ARGS=("$TAG" --title "$TAG" --generate-notes) + # The release's section of docs/changelog.md is the release notes, + # with GitHub's generated pull-request list appended (--generate-notes + # appends to --notes-file). The changelog workflow refuses a pull + # request into main without that section, so the fallback to generated + # notes alone only covers a release that bypassed the pull request. + if uv run python scripts/changelog_section.py "${{ steps.final.outputs.version }}" > release-notes.md; then + ARGS+=(--notes-file release-notes.md) + else + echo "::warning::docs/changelog.md has no section for ${{ steps.final.outputs.version }}; using generated notes only." + fi if [ "${{ steps.final.outputs.prerelease }}" = "true" ]; then ARGS+=(--prerelease) else diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..fc52c58 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,65 @@ +name: Changelog + +# docs/changelog.md is the project's changelog (.rules/changelog.md). +# +# - A pull request into dev that changes package code (pamica/ outside its +# tests, validate_implementations.py, pyproject.toml) must also change +# docs/changelog.md, adding its entry under "## Unreleased". A change with no +# user-visible effect carries the `skip-changelog` label instead. +# - A pull request into main is a release: the merged tree must carry the +# release's "## X.Y.Z - YYYY-MM-DD" section, which auto-tag.yml publishes as +# the GitHub release notes, and no leftover "## Unreleased" section. +# +# The version bots (auto-bump-dev.yml, sync-dev.yml) push to dev directly and +# open no pull requests, so this check never gates them. +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +concurrency: + group: changelog-${{ github.ref }} + cancel-in-progress: true + +jobs: + changelog: + name: Changelog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Entry for a change into dev + if: github.base_ref != 'main' + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} + run: | + set -euo pipefail + changed=$(git diff --name-only "$BASE...$HEAD") + code=$(grep -E '^(pamica/|validate_implementations\.py$|pyproject\.toml$)' <<<"$changed" | grep -v '^pamica/tests/' || true) + if [ -z "$code" ]; then + echo "No package code changed; no entry required." + elif grep -qx 'docs/changelog.md' <<<"$changed"; then + echo "docs/changelog.md is updated." + elif [ "$SKIP" = "true" ]; then + echo "The skip-changelog label is set." + else + echo "::error::This pull request changes package code but not docs/changelog.md. Add an entry under '## Unreleased' (.rules/changelog.md), or add the skip-changelog label when the change has no user-visible effect." + printf 'Changed code:\n%s\n' "$code" + exit 1 + fi + + - name: Release section for a release into main + if: github.base_ref == 'main' + run: | + set -euo pipefail + version=$(grep -m1 '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + release="${version%.dev*}" + python3 scripts/changelog_section.py "$release" --check + if grep -q '^## Unreleased' docs/changelog.md; then + echo "::error::docs/changelog.md still has an '## Unreleased' section. Release prep renames it to '## $release - YYYY-MM-DD'; changes merged to dev afterward belong in that section or in the next release." + exit 1 + fi + echo "docs/changelog.md carries the $release section." From 86e5f5a621f11fcf59fbd0fe58fbdd154aa02fda Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi <shirazi@ieee.org> Date: Thu, 24 Sep 2026 01:21:06 -0700 Subject: [PATCH 5/7] Record the changelog practice in the rules --- .rules/changelog.md | 52 +++++++++++++++++++++++++++++++++++++++++++ .rules/ci_cd.md | 15 +++++++++++++ .rules/code_review.md | 1 + .rules/git.md | 4 ++++ AGENTS.md | 7 ++++-- 5 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 .rules/changelog.md diff --git a/.rules/changelog.md b/.rules/changelog.md new file mode 100644 index 0000000..dfc8303 --- /dev/null +++ b/.rules/changelog.md @@ -0,0 +1,52 @@ +# Changelog Standards + +## One changelog +- **`docs/changelog.md` is the changelog.** It is published at + <https://eeglab.org/pAMICA/changelog/>, linked from PyPI (`[project.urls]`), + and its release sections are the GitHub release notes. +- **The root `CHANGELOG.md` only points to it.** Never add entries there. + +## Every user-visible change gets an entry, in the same pull request +- A pull request into `dev` that changes user-visible behavior adds its entry under `## Unreleased`. + User-visible behavior covers results, defaults, parameters, errors, saved formats, supported data, dependencies and installation. +- **`.github/workflows/changelog.yml` enforces it for package code:** + `pamica/` outside its tests, `validate_implementations.py` and `pyproject.toml`. +- **The `skip-changelog` label is for changes with no user-visible effect:** + a refactor with byte-identical behavior, or a test-only or tooling-only change. + When in doubt, write the entry. +- CI, documentation and paper changes may add entries (under `### Continuous integration` or `### Documentation`); the check does not require them. + +## What an entry says +- **Lead with the effect on the user,** as a bold one-line summary. + Then say what changed and why, with the issue or pull-request number. + Then say what a user has to do, for example refit, pass a setting explicitly, or re-export. +- **Changes to default results go into the release's opening warning** as well + (the `!!! warning` block at the top of the section), so a user comparing against an earlier version sees them first. +- **Deliberate divergences from the Fortran reference** also get a row in `docs/guides/amica-differences.md`. +- **Keep one coherent account.** + - File each entry under the existing topic subheadings of `## Unreleased` (`### Fitting follows the reference`, `### Persistence and exports`, ...) rather than one section per pull request. + - When a later change supersedes an earlier entry in the same release, edit that entry rather than appending a contradiction. +- **Quote measured figures with their conditions:** data, iterations, backend and the reference build. +- **Style:** + - American English. No em-dashes. Semantic line breaks. Define an abbreviation on first use. + - Honest, nuanced wording: state what was measured and what it implies, without negation-contrast pairs ("X, not Y") or candor announcements ("plainly"). +- **Links:** relative links to docs pages are fine. `scripts/changelog_section.py` rewrites them to site URLs for the release notes. + +## Format +- Release headings are `## X.Y.Z - YYYY-MM-DD`, newest first, with at most one `## Unreleased` section above them. +- `pamica/tests/test_changelog.py` enforces the format and runs the release-notes extractor on the real file. + +## At release time +1. **Release-prep pull request into `dev`:** + - Rename `## Unreleased` to `## X.Y.Z - YYYY-MM-DD` (the planned release date). + - Re-read the section as one release note. + - For a minor or major release, set the version with `uv run python scripts/sync_version.py sync X.Y.0.dev0` and `uv lock`. Patch releases need no version change; the chain strips `.devN`. + Avoid the commit subject prefix "Bump version to", which the CI guards reserve for the bot. +2. **Merge `dev` into `main`** with a regular merge commit (`.rules/git.md`). + The changelog check on that pull request requires the release's section and no leftover `## Unreleased`. +3. **`auto-tag.yml` publishes the section as the GitHub release notes,** with GitHub's generated pull-request list appended. +4. **After the release,** the next change into `dev` starts a fresh `## Unreleased` section above the release. + +## Refreshing an existing release's notes +`gh release edit vX.Y.Z --notes-file <(uv run python scripts/changelog_section.py X.Y.Z)`. +Editing a release fires only the `edited` event, which neither `publish.yml` nor `release-binaries.yml` listens to. diff --git a/.rules/ci_cd.md b/.rules/ci_cd.md index d7a0871..4a8d207 100644 --- a/.rules/ci_cd.md +++ b/.rules/ci_cd.md @@ -56,6 +56,21 @@ recording: is a first estimate pending an actual run. GitHub auto-disables a schedule trigger after 60 days of repo inactivity; re-enable via the Actions tab or `gh workflow enable weekly-macos-slow.yml` if Sunday runs stop appearing. +- **Code-path filter:** `ci.yml`'s Python jobs run only when a change touches + code. Markdown, the docs site, the paper (with the rebuilt `paper.pdf`), + citation metadata and the non-Python records under `.context/` skip them; + Python files under `.context/` and the test-loaded + `.context/issue-27/ensemble.npz` still run them. `typos.yml` runs on every + change. `draft-pdf.yml` commits the rebuilt PDF back on `dev` and `main` + only, rebasing onto the branch head first because `auto-bump-dev.yml` can + push while it builds. +- **`changelog.yml`** -- on every pull request: into `dev`, a change to + package code (`pamica/` outside its tests, `validate_implementations.py`, + `pyproject.toml`) must also change `docs/changelog.md`, unless the PR carries + the `skip-changelog` label; into `main` (a release), the merged tree must + carry the release's `## X.Y.Z - YYYY-MM-DD` section and no `## Unreleased`. + `auto-tag.yml` publishes that section as the GitHub release notes, through + `scripts/changelog_section.py` (`.rules/changelog.md`). - **`release-binaries.yml`**, **`publish.yml`**, **`auto-tag.yml`**, **`auto-bump-dev.yml`**, **`sync-dev.yml`**, **`docs.yml`**, **`typos.yml`**, **`draft-pdf.yml`** each own one concern (native-binary release assets, PyPI diff --git a/.rules/code_review.md b/.rules/code_review.md index 5568913..d8db396 100644 --- a/.rules/code_review.md +++ b/.rules/code_review.md @@ -24,6 +24,7 @@ When the `pr-review-toolkit` plugin is available, use it after creating PRs to c ### Before Committing - [ ] Code compiles/runs without warnings - [ ] Tests pass (real tests, no mocks) +- [ ] A user-visible change has its entry under `## Unreleased` in `docs/changelog.md` (`.rules/changelog.md`) - [ ] No debug code left (print statements, TODO hacks) - [ ] No sensitive data in code or logs diff --git a/.rules/git.md b/.rules/git.md index 87b9863..e7b4cb0 100644 --- a/.rules/git.md +++ b/.rules/git.md @@ -26,6 +26,10 @@ ## Versioning (automated) Versions are managed by CI; do not hand-edit `pyproject.toml` to bump. +The one manual step is choosing a minor or major release: release prep sets +`X.Y.0.dev0` with `scripts/sync_version.py` in a pull request into `dev`, and +renames the changelog's `## Unreleased` section to the release +(`.rules/changelog.md`). | Event | Version effect | Workflow | |---|---|---| diff --git a/AGENTS.md b/AGENTS.md index 864f635..896d78f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,8 +216,9 @@ update from a merged state seeded through `load_comp_list` matches it to float64 5. **Test:** Real data only (sample EEG + Fortran binary); see `.rules/testing.md`. 6. **Document failures:** Log dead ends in `.context/scratch_history.md`. 7. **Commit:** Atomic, <50 chars, no emojis, no AI attribution. -8. **PR + review:** Run `/review-pr` and address all findings (`.rules/code_review.md`). -9. **Merge:** CI green first (see below), then **squash merge** (`gh pr merge <n> --squash --delete-branch`). +8. **Changelog:** A user-visible change adds its entry under `## Unreleased` in `docs/changelog.md`, in the same PR (`.rules/changelog.md`; the `Changelog` check enforces it for package code). +9. **PR + review:** Run `/review-pr` and address all findings (`.rules/code_review.md`). +10. **Merge:** CI green first (see below), then **squash merge** (`gh pr merge <n> --squash --delete-branch`). ## [CRITICAL] Core Principles - **NO MOCKS:** Validate against real sample data and the Fortran binary, never fabricated data. Details: `.rules/testing.md`. @@ -242,6 +243,7 @@ update from a merged state seeded through `load_comp_list` matches it to float64 - `.rules/backend_parity.md` - No one-off backends; shared decisions, cross-backend tests - `.rules/python.md` - UV, ruff, ty - `.rules/git.md` - Commit/branch conventions +- `.rules/changelog.md` - Changelog entries, format, and the release-notes mechanism - `.rules/code_review.md` - PR review toolkit and checklist - `.rules/ci_cd.md` - GitHub Actions setup - `.rules/documentation.md` - Docs conventions @@ -259,6 +261,7 @@ update from a merged state seeded through `load_comp_list` matches it to float64 ## Project Docs (top-level) - `README.md` - Overview and quick start +- `CHANGELOG.md` - Pointer to the changelog, `docs/changelog.md` --- Remember: parity with the Fortran reference is the definition of done. Check `.rules/` for detailed guidance. From c1e7a53f785fe1f1c579a328adc13c41fcdb9c9e Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi <shirazi@ieee.org> Date: Thu, 24 Sep 2026 01:30:38 -0700 Subject: [PATCH 6/7] Date releases by their UTC publication date v0.1.3, v0.2.0 and v0.3.2 carried their tagged commit's date; use the GitHub release date (or the tag date where no release exists), and state the convention in the rule. --- .rules/changelog.md | 4 +++- docs/changelog.md | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.rules/changelog.md b/.rules/changelog.md index dfc8303..08cd601 100644 --- a/.rules/changelog.md +++ b/.rules/changelog.md @@ -30,10 +30,12 @@ - **Style:** - American English. No em-dashes. Semantic line breaks. Define an abbreviation on first use. - Honest, nuanced wording: state what was measured and what it implies, without negation-contrast pairs ("X, not Y") or candor announcements ("plainly"). -- **Links:** relative links to docs pages are fine. `scripts/changelog_section.py` rewrites them to site URLs for the release notes. +- **Links:** relative links to docs pages are fine, inline or reference-style. + `scripts/changelog_section.py` rewrites them to site URLs for the release notes and leaves code spans and fenced code untouched. ## Format - Release headings are `## X.Y.Z - YYYY-MM-DD`, newest first, with at most one `## Unreleased` section above them. + The date is the release's publication date in UTC, the date GitHub and PyPI show. - `pamica/tests/test_changelog.py` enforces the format and runs the release-notes extractor on the real file. ## At release time diff --git a/docs/changelog.md b/docs/changelog.md index 687ec43..a5e4a26 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -679,7 +679,7 @@ and every backend's fitting follows the Fortran reference more closely. - **A root `CHANGELOG.md` points to this changelog,** and the package metadata links it and the documentation site, so PyPI shows both. -- **Release headings carry their dates** (`## X.Y.Z - YYYY-MM-DD`), taken from the release tags; +- **Release headings carry their dates** (`## X.Y.Z - YYYY-MM-DD`): each release's publication date in UTC, or its tag date where no GitHub release exists; `pamica/tests/test_changelog.py` checks the format. - **`.rules/changelog.md` records the practice:** what an entry says, when the `skip-changelog` label applies, and the release-prep steps. @@ -1061,7 +1061,7 @@ external tester (#221). bundled sample reproduces its previous `comp_list` and log-likelihood bit for bit. -## 0.3.2 - 2026-08-15 +## 0.3.2 - 2026-08-16 Rank-deficient input support across every backend, a much faster default block size, and a reproducible Fortran reference for parity runs. @@ -1236,7 +1236,7 @@ native-engine documentation. backend on any platform, not only through the bundled macOS `amica15mac` fixture (#147 phase 5, #179). -## 0.2.0 - 2026-07-17 +## 0.2.0 - 2026-07-18 Package rename to align with the reserved PyPI name. @@ -1247,7 +1247,7 @@ Package rename to align with the reserved PyPI name. domain (`eeglab.org/pyAMICA`), and the release-asset repository are unchanged (#176). -## 0.1.3 - 2026-07-17 +## 0.1.3 - 2026-07-18 Native Fortran run engine, separation-quality metrics, LLt output parity, and the `loadmodout` byte-order fix. From 0977919380429bf56ba8707e1b5a5f143b08831d Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi <shirazi@ieee.org> Date: Thu, 24 Sep 2026 01:30:38 -0700 Subject: [PATCH 7/7] Leave code untouched and rewrite reference links in release notes The extractor skips code spans and fenced blocks, ignores headings inside fences, and rewrites reference-style link definitions. Seven edge-case tests (18 total) cover them. --- pamica/tests/test_changelog.py | 78 ++++++++++++++++++++++++++++++++++ scripts/changelog_section.py | 55 +++++++++++++++++++----- 2 files changed, 122 insertions(+), 11 deletions(-) diff --git a/pamica/tests/test_changelog.py b/pamica/tests/test_changelog.py index 972b12a..63b21f2 100644 --- a/pamica/tests/test_changelog.py +++ b/pamica/tests/test_changelog.py @@ -103,3 +103,81 @@ def test_site_url(target, url): def test_check_passes_for_a_released_version(capsys): assert script.main(["0.3.3", "--check"]) == 0 assert capsys.readouterr().out == "" + + +# Edge cases of the extractor, on small Markdown snippets written in the +# changelog's own format. +_SNIPPET = """# Changelog + +## Unreleased + +- Next change. + +## 0.3.10 - 2026-09-10 + +- **A tenth patch.** See [the guide](guides/backends.md#devices). + +## 0.3.1 - 2026-07-19 \n +- Inline code keeps `[text](guides/backends.md)` as written. +- Reference link to [the guide][guide]. + +[guide]: guides/amica-differences.md#default-settings + +```markdown +## 9.9.9 - 2099-01-01 +[fenced](guides/backends.md) +``` + +## 0.3.0 - 2026-07-18 + +- The last section. +""" + + +def test_version_prefix_does_not_match_a_longer_version(): + tenth = script.section(_SNIPPET, "0.3.10") + first = script.section(_SNIPPET, "0.3.1") + assert tenth is not None and "A tenth patch" in tenth + assert first is not None and "A tenth patch" not in first + assert "Inline code" in first and "Inline code" not in tenth + + +def test_heading_with_trailing_whitespace_is_found(): + assert "0.3.1 - 2026-07-19" in script.release_titles(_SNIPPET) + + +def test_code_spans_and_fences_are_left_as_written(): + body = script.section(_SNIPPET, "0.3.1") + assert body is not None + assert "`[text](guides/backends.md)`" in body + assert "[fenced](guides/backends.md)" in body + + +def test_heading_inside_a_fence_is_not_a_release(): + assert "9.9.9 - 2099-01-01" not in script.release_titles(_SNIPPET) + assert script.section(_SNIPPET, "9.9.9") is None + body = script.section(_SNIPPET, "0.3.1") + assert body is not None and body.rstrip().endswith("```") + + +def test_reference_definitions_are_rewritten(): + body = script.section(_SNIPPET, "0.3.1") + assert body is not None + assert ( + "[guide]: https://eeglab.org/pAMICA/guides/amica-differences/#default-settings" + in body + ) + + +def test_inline_links_are_rewritten_and_the_last_section_is_found(): + tenth = script.section(_SNIPPET, "0.3.10") + assert tenth is not None + assert "](https://eeglab.org/pAMICA/guides/backends/#devices)" in tenth + last = script.section(_SNIPPET, "0.3.0") + assert last == "- The last section.\n" + + +def test_empty_section_is_missing(): + empty = "## 0.2.0 - 2026-07-18\n\n## 0.1.0 - 2026-07-11\n\n- First.\n" + assert script.section(empty, "0.2.0") is None + assert script.section(empty, "0.1.0") == "- First.\n" diff --git a/scripts/changelog_section.py b/scripts/changelog_section.py index 1e208ba..d9a52b2 100644 --- a/scripts/changelog_section.py +++ b/scripts/changelog_section.py @@ -31,8 +31,16 @@ _RELEASE_TITLE = re.compile( r"^(?P<version>\d+\.\d+\.\d+\S*) - (?P<date>\d{4}-\d{2}-\d{2})$" ) -# A Markdown link target that is neither absolute nor an e-mail address. -_RELATIVE_LINK = re.compile(r"\]\((?!https?://|mailto:)(?P<target>[^)\s]+)\)") +_FENCE = re.compile(r"^\s*(```|~~~)") +_ABSOLUTE = r"(?!https?://|mailto:)" +# An inline code span (left as written) or an inline link with a relative target. +_INLINE = re.compile( + r"(?P<code>`+[^`]*?`+)|\]\(" + _ABSOLUTE + r"(?P<target>[^)\s]+)\)" +) +# A reference-style link definition, "[label]: target", with a relative target. +_REFERENCE = re.compile( + r"^(?P<lead>\s{0,3}\[[^\]]+\]:\s*)" + _ABSOLUTE + r"(?P<target>\S+)(?P<rest>.*)$" +) def _site_url(target: str) -> str: @@ -48,33 +56,58 @@ def _site_url(target: str) -> str: return SITE_URL + page + (f"#{anchor}" if anchor else "") +def _headings(lines: list[str]): + """``(index, title)`` of each level-2 heading outside fenced code.""" + fenced = False + for i, line in enumerate(lines): + if _FENCE.match(line): + fenced = not fenced + elif not fenced and (m := _HEADING.match(line)): + yield i, m["title"] + + def release_titles(text: str) -> list[str]: """Every level-2 heading title, in file order.""" - return [m["title"] for line in text.splitlines() if (m := _HEADING.match(line))] + return [title for _, title in _headings(text.splitlines())] + + +def _absolute_links(body: str) -> str: + """Rewrite relative link targets to site URLs, outside code spans and fences.""" + out = [] + fenced = False + for line in body.split("\n"): + if _FENCE.match(line): + fenced = not fenced + elif not fenced: + if m := _REFERENCE.match(line): + line = m["lead"] + _site_url(m["target"]) + m["rest"] + else: + line = _INLINE.sub( + lambda m: m[0] if m["code"] else f"]({_site_url(m['target'])})", + line, + ) + out.append(line) + return "\n".join(out) def section(text: str, version: str) -> str | None: """The body of ``version``'s section, links made absolute, or ``None``.""" lines = text.splitlines() start = None - for i, line in enumerate(lines): - m = _HEADING.match(line) - if m is None: - continue + end = len(lines) + for i, heading in _headings(lines): if start is not None: end = i break - title = _RELEASE_TITLE.match(m["title"]) + title = _RELEASE_TITLE.match(heading) if title is not None and title["version"] == version: start = i + 1 - else: - end = len(lines) if start is None: return None body = "\n".join(lines[start:end]).strip() if not body: return None - return _RELATIVE_LINK.sub(lambda m: f"]({_site_url(m['target'])})", body) + "\n" + return _absolute_links(body) + "\n" def main(argv: list[str] | None = None) -> int: