From f3f6b6b72709e28dc6310d2e015b980f94634917 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 00:41:31 +0700 Subject: [PATCH] build(linux): towncrier fragments for the Linux changelog --- .claude/rules/changelog.md | 10 +- .github/workflows/release-linux.yml | 16 +- AGENTS.md | 2 +- linux/AGENTS.md | 2 +- linux/CHANGELOG.md | 22 +-- linux/CONTRIBUTING.md | 8 +- linux/build-aux/packaging/check_packaging.py | 12 +- linux/build-aux/tests/test_check_packaging.py | 18 +- linux/changelog.d/.gitkeep | 0 linux/changelog.d/3413.added.md | 1 + linux/changelog.d/3464.added.1.md | 1 + linux/changelog.d/3464.added.10.md | 1 + linux/changelog.d/3464.added.11.md | 1 + linux/changelog.d/3464.added.2.md | 1 + linux/changelog.d/3464.added.3.md | 1 + linux/changelog.d/3464.added.4.md | 1 + linux/changelog.d/3464.added.5.md | 1 + linux/changelog.d/3464.added.6.md | 1 + linux/changelog.d/3464.added.7.md | 1 + linux/changelog.d/3464.added.8.md | 1 + linux/changelog.d/3464.added.9.md | 1 + linux/changelog.d/3464.added.md | 1 + linux/docs/releasing.md | 10 +- linux/towncrier.toml | 27 +++ scripts/ci/check-changelog.py | 154 +++++++++++++----- scripts/ci/test_check_changelog.py | 122 +++++++++++++- 26 files changed, 324 insertions(+), 92 deletions(-) create mode 100644 linux/changelog.d/.gitkeep create mode 100644 linux/changelog.d/3413.added.md create mode 100644 linux/changelog.d/3464.added.1.md create mode 100644 linux/changelog.d/3464.added.10.md create mode 100644 linux/changelog.d/3464.added.11.md create mode 100644 linux/changelog.d/3464.added.2.md create mode 100644 linux/changelog.d/3464.added.3.md create mode 100644 linux/changelog.d/3464.added.4.md create mode 100644 linux/changelog.d/3464.added.5.md create mode 100644 linux/changelog.d/3464.added.6.md create mode 100644 linux/changelog.d/3464.added.7.md create mode 100644 linux/changelog.d/3464.added.8.md create mode 100644 linux/changelog.d/3464.added.9.md create mode 100644 linux/changelog.d/3464.added.md create mode 100644 linux/towncrier.toml diff --git a/.claude/rules/changelog.md b/.claude/rules/changelog.md index 25734f6f74..f82caf2992 100644 --- a/.claude/rules/changelog.md +++ b/.claude/rules/changelog.md @@ -1,19 +1,19 @@ --- paths: - - "CHANGELOG.md" - - "changelog.d/**/*" + - "**/CHANGELOG.md" + - "**/changelog.d/**/*" - "towncrier.toml" --- # Changelog -Unreleased changes are towncrier fragments in `changelog.d/`. A release turns them into a `CHANGELOG.md` section with `scripts/release/notes.py assemble` (`RELEASING.md`, Notes). +Unreleased changes are towncrier fragments in `changelog.d/`. A release turns them into a `CHANGELOG.md` section with `scripts/release/notes.py assemble` (`RELEASING.md`, Notes). The Linux app is a second towncrier project under the same rules, in `linux/changelog.d/` and `linux/CHANGELOG.md`; its release runs `towncrier build --dir linux` (`linux/docs/releasing.md`). - **One file per user-visible change**, named `changelog.d/..md`: `` is the pull request that adds it, `` is `added`, `changed`, `deprecated`, `removed`, `fixed` or `security`. A second entry of the same type in one PR adds a counter, as `towncrier create` does: `3452.fixed.1.md`, then `.2`. The counter does not order entries. - **One line, at most 120 characters, ending with a period.** Added and Changed name the new thing (`Open in Window for row inspector text fields.`); Fixed names the bug, not the fix (`Empty context menu when right-clicking a read-only inspector field.`). No file paths or type names. - **No refs, no credit, no `- ` prefix.** towncrier links the PR named by the file and adds the bullet; `notes.py` adds ` by @login` when the PR's author is not a maintainer. - **One entry per thing a user would notice.** Defects with the same visible symptom share an entry. - **No fragment** for a fix to something still unreleased (edit that fragment instead) or for a docs-only change. -- **Only a `chore(release): vX.Y.Z` PR edits `CHANGELOG.md`.** +- **Only a `chore(release): vX.Y.Z` PR edits `CHANGELOG.md`**, and only a `chore(release): linux-vX.Y.Z` PR edits `linux/CHANGELOG.md`. - **The lead block and `[CRITICAL]`**: `RELEASING.md`, Notes and Channels. -- `scripts/ci/check-changelog.py` checks the fragments' text, the headings and the lead blocks in Repo Hygiene, then `towncrier build --draft --version 0.0.0` renders the fragments. towncrier refuses an unknown type and a non-numeric PR number (`ignore`, `issue_pattern`). In PR title, `--pr` checks that a PR into main names each new fragment after itself, that only a release PR edits `CHANGELOG.md`, and that a release PR changes nothing else. +- `scripts/ci/check-changelog.py` checks both projects' fragment text, headings and lead blocks in Repo Hygiene, then `towncrier build --draft --version 0.0.0` renders the root fragments; `--dir linux` renders the Linux ones. towncrier refuses an unknown type and a non-numeric PR number (`ignore`, `issue_pattern`). In PR title, `--pr` checks that a PR into main names each new fragment after itself, that only a release PR edits a `CHANGELOG.md`, and that a release PR changes nothing else; a Linux one also sets the version. diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index ce9f18d4a0..dbd860a0d4 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -109,6 +109,13 @@ jobs: with: name: linux-sources path: release + # A pre-release has no CHANGELOG.md section; its notes are a draft of the fragments at the tag. + - name: Install towncrier + if: needs.verify.outputs.prerelease == 'true' + run: | + python3 -m venv "$RUNNER_TEMP/release-venv" + "$RUNNER_TEMP/release-venv/bin/pip" install --quiet --disable-pip-version-check --require-hashes \ + -r scripts/release/requirements.txt - name: Publish the release env: GH_TOKEN: ${{ github.token }} @@ -117,10 +124,15 @@ jobs: run: | version="${TAG#linux-v}" (cd release && sha256sum -- * > SHA256SUMS) - python3 linux/build-aux/packaging/check_packaging.py --source-root linux \ - --version "$version" --tag "$TAG" --notes notes.md if [ "$PRERELEASE" = true ]; then + python3 linux/build-aux/packaging/check_packaging.py --source-root linux \ + --version "$version" --tag "$TAG" + "$RUNNER_TEMP/release-venv/bin/towncrier" build --draft --dir linux --version "$version" > draft.md + sed 1d draft.md > notes.md set -- --prerelease + else + python3 linux/build-aux/packaging/check_packaging.py --source-root linux \ + --version "$version" --tag "$TAG" --notes notes.md fi # The repository's latest release stays the macOS one. gh release create "$TAG" release/* --repo "$GITHUB_REPOSITORY" --verify-tag --latest=false \ diff --git a/AGENTS.md b/AGENTS.md index 153adc2a36..fe84403c98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,7 @@ Claude Code loads a rule when it reads or edits a file the rule covers. Under Co | Rule | Read before editing | | --- | --- | | `ai-mcp-security.md` | `TablePro/Core/AI`, `TablePro/Core/MCP`, `Packages/TableProMacCore/Sources/TableProMCP` | -| `changelog.md` | `CHANGELOG.md`, `changelog.d`, `towncrier.toml` | +| `changelog.md` | `**/CHANGELOG.md`, `**/changelog.d`, `towncrier.toml` | | `ci.md` | `.github/workflows`, `.github/actions`, `.github/actionlint.yaml`, `.github/*baseline*`, `scripts/ci`, `scripts/release`, `.swiftlint.yml`, `**/.swiftlint.yml` | | `connection-window.md` | `TablePro/Core/Services/Infrastructure`, `TablePro/Models/Connection`, `TablePro/Core/Database/DatabaseManager*.swift`, `Packages/TableProCore/Sources/TableProDatabase`, `TablePro/Views/Main` | | `data-grid.md` | `TablePro/Views/Results`, `TablePro/Core/DataGrid`, `TableProTests/Views/Results` | diff --git a/linux/AGENTS.md b/linux/AGENTS.md index 2268c798bb..92bccb661c 100644 --- a/linux/AGENTS.md +++ b/linux/AGENTS.md @@ -44,7 +44,7 @@ From the repository root, through the verification wrapper, which keeps the full ## Every change 1. **Tests.** A unit test for testable behavior, a `#[gtk4::test]` widget test for UI behavior, and a test against the real engine, ignored with `requires docker`, for driver behavior. No mocks. Details: `docs/testing.md`. -2. **CHANGELOG.** A user-visible change gets an entry under `[Unreleased]` in `linux/CHANGELOG.md`, never the root one or `changelog.d/`. `CONTRIBUTING.md`, Changelog, has the rule. +2. **Changelog.** A user-visible change adds `linux/changelog.d/..md`, never the root `changelog.d/`; only a release PR edits `linux/CHANGELOG.md`. Format: the root `.claude/rules/changelog.md`. 3. **Translations.** User-facing strings go through `crate::i18n` (`gettext`, `gettext_f` with named placeholders, `ngettext_f`, `pgettext`). A new or removed file under `crates/app/src` or `data/resources` regenerates `po/POTFILES.in`. See `po/README.md`. 4. **Lint** the workspace: rustfmt, then clippy with and without default features. 5. **Commits and pull requests** follow the root rules: Conventional Commits, at most 72 characters, scope `linux` or `linux-` (for example `linux-grid`, `linux-storage`), and the root `.github/pull_request_template.md`. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index f694298d8c..f06ad894dc 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -5,24 +5,6 @@ All notable changes to TablePro for Linux will be documented in this file. The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +Unreleased changes are files in `changelog.d/`. -The first release of TablePro for Linux, a GTK 4 and libadwaita app for GNOME 50 and later. -It connects to PostgreSQL, MySQL and MariaDB, SQL Server, ClickHouse and SQLite, directly or through an SSH tunnel. -Ubuntu 26.04 and 26.10 install it from a Launchpad PPA, and Fedora 44 and later from COPR. - -### Added - -- Saved connections with groups, colours, TLS options and read-only mode, and passwords kept in the system keyring. -- SSH tunnels through the system OpenSSH client, with your agent, `~/.ssh/config`, jump hosts and host key checks. -- SQL Server sign-in with Kerberos, using the ticket from `kinit`. -- A window per connection, with its table, structure and SQL editor tabs reopened at the next launch. -- Table grid with filters, sorting and paging, editing in place, new and deleted rows, and undo until you save. -- Structure tab to change columns, indexes and foreign keys, with the SQL shown before it runs. -- SQL editor with completion, formatting, find and replace, a result per statement, and Stop. -- Query history with full-text search, and saved queries. -- Export to CSV, JSON, SQL, Excel, Markdown, HTML and XML, CSV import, and Copy As. -- Open SQLite files from the file manager's Open With, or with `tablepro file.sqlite3`. -- Keyboard navigation of the grid, and names and states for screen readers. -- Desktop notification when a query, save or export finishes in a tab you are not looking at. -- Troubleshooting in About TablePro, with the versions to paste into a bug report. + diff --git a/linux/CONTRIBUTING.md b/linux/CONTRIBUTING.md index 6ca2dc2b5c..34021a136c 100644 --- a/linux/CONTRIBUTING.md +++ b/linux/CONTRIBUTING.md @@ -127,13 +127,7 @@ docs(linux): clarify the TLS step for a new driver ## Changelog -A user-visible change gets one entry under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md), which follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). The repository-level `CHANGELOG.md` and its `changelog.d/` fragments are the macOS release notes; a Linux change never goes there. - -- Sections, each at most once per version and in this order: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. -- An entry is one line naming the change, under about 120 characters. Added and Changed name the new thing; Fixed names the bug, not the fix. No file paths or type names; an issue reference goes at the end, `(#1234)`. -- One entry per thing a user would notice. Defects with the same visible symptom share an entry. -- No entry for a fix to something still unreleased (fold it into its entry) or for a docs-only change. -- A version may open with a lead block before its first `###`. +A user-visible change adds one towncrier fragment, `changelog.d/..md` in this directory, which the release turns into a [CHANGELOG.md](CHANGELOG.md) section ([docs/releasing.md](docs/releasing.md)). The repository-level `CHANGELOG.md` and `changelog.d/` are the macOS release notes; a Linux change never goes there. [`.claude/rules/changelog.md`](../.claude/rules/changelog.md) has the rules for both. ## Pull requests diff --git a/linux/build-aux/packaging/check_packaging.py b/linux/build-aux/packaging/check_packaging.py index 6621efffca..fa6cfe11db 100644 --- a/linux/build-aux/packaging/check_packaging.py +++ b/linux/build-aux/packaging/check_packaging.py @@ -4,8 +4,8 @@ # translations exactly when po/LINGUAS ships one, and that the Debian # package builds with the Rust the workspace names as its rust-version. The # release workflow adds the tag and its date, and then CHANGELOG.md must -# carry that version's notes and the spec must be the version's first -# package release. +# carry that version's notes, unless it is a pre-release, and the spec must +# be the version's first package release. # # Once CHANGELOG.md has a heading for the version, the release commit has # set its dates, and the heading, the spec's %changelog entry and the man @@ -77,7 +77,10 @@ def release_notes(changelog, version): def release_heading_date(changelog, version): """The date on the version's heading: None without a heading, "" when it has no date.""" - heading = re.compile(rf"^## \[{re.escape(version)}\](?: - (\d{{4}}-\d{{2}}-\d{{2}}))?\s*$") + # towncrier links the version to its GitHub release. + heading = re.compile( + rf"^## \[{re.escape(version)}\](?:\([^)\s]*\))?(?: - (\d{{4}}-\d{{2}}-\d{{2}}))?\s*$" + ) for line in changelog.read_text(encoding="utf-8").splitlines(): match = heading.match(line) if match: @@ -163,7 +166,8 @@ def problems(root, version, tag=None, tag_date=None): if tag is not None: if tag != f"linux-v{version}": found.append(f"tag {tag} does not name version {version}; expected linux-v{version}") - if not release_notes(root / CHANGELOG, version): + # A pre-release keeps the fragments; its notes are a towncrier draft of them. + if "-" not in version and not release_notes(root / CHANGELOG, version): found.append(f"{CHANGELOG} has no notes under ## [{version}]") packaged = package_version(version) diff --git a/linux/build-aux/tests/test_check_packaging.py b/linux/build-aux/tests/test_check_packaging.py index ad5ed6df09..13e1d5789b 100644 --- a/linux/build-aux/tests/test_check_packaging.py +++ b/linux/build-aux/tests/test_check_packaging.py @@ -76,6 +76,8 @@ - Older notes. """ +LINKED = "## [1.2.0](https://github.com/TableProApp/TablePro/releases/tag/linux-v1.2.0) - 2026-10-14" + class ProblemsTests(unittest.TestCase): def tree( @@ -210,7 +212,6 @@ def test_a_pre_release_tag_passes_when_the_spec_packages_the_tilde_version(self) releases='', version="1.2.0~rc.1", changelog="1.2.0~rc.1", - notes=NOTES.replace("## [1.2.0]", "## [1.2.0-rc.1]"), man_version="1.2.0-rc.1", ) @@ -262,6 +263,21 @@ def test_a_release_needs_its_changelog_notes(self): self.assertEqual(len(found), 1) self.assertIn("no notes under ## [1.2.0]", found[0]) + def test_a_towncrier_heading_carries_the_release_date(self): + root = self.tree(notes=NOTES.replace("## [1.2.0] - 2026-10-14", LINKED)) + + self.assertEqual(check_packaging.problems(root, "1.2.0", "linux-v1.2.0", "2026-10-14"), []) + self.assertEqual( + check_packaging.release_notes(root / check_packaging.CHANGELOG, "1.2.0"), + "### Added\n\n- Packages for Ubuntu and Fedora.", + ) + + root = self.tree(notes=NOTES.replace("## [1.2.0] - 2026-10-14", LINKED.replace("10-14", "10-13"))) + + self.assertEqual( + check_packaging.problems(root, "1.2.0"), ["CHANGELOG.md dates 1.2.0 2026-10-13, the metainfo 2026-10-14"] + ) + def test_the_changelog_is_only_required_for_a_release(self): self.assertEqual(check_packaging.problems(self.tree(notes="# Changelog\n"), "1.2.0"), []) diff --git a/linux/changelog.d/.gitkeep b/linux/changelog.d/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/linux/changelog.d/3413.added.md b/linux/changelog.d/3413.added.md new file mode 100644 index 0000000000..f9f732d9b9 --- /dev/null +++ b/linux/changelog.d/3413.added.md @@ -0,0 +1 @@ +Open SQLite files from the file manager's Open With, or with `tablepro file.sqlite3`. diff --git a/linux/changelog.d/3464.added.1.md b/linux/changelog.d/3464.added.1.md new file mode 100644 index 0000000000..06e0ef581e --- /dev/null +++ b/linux/changelog.d/3464.added.1.md @@ -0,0 +1 @@ +SSH tunnels through the system OpenSSH client, with your agent, `~/.ssh/config`, jump hosts and host key checks. diff --git a/linux/changelog.d/3464.added.10.md b/linux/changelog.d/3464.added.10.md new file mode 100644 index 0000000000..c3bbfb59c6 --- /dev/null +++ b/linux/changelog.d/3464.added.10.md @@ -0,0 +1 @@ +Desktop notification when a query, save or export finishes in a tab you are not looking at. diff --git a/linux/changelog.d/3464.added.11.md b/linux/changelog.d/3464.added.11.md new file mode 100644 index 0000000000..fcdfe9f7f7 --- /dev/null +++ b/linux/changelog.d/3464.added.11.md @@ -0,0 +1 @@ +Troubleshooting in About TablePro, with the versions to paste into a bug report. diff --git a/linux/changelog.d/3464.added.2.md b/linux/changelog.d/3464.added.2.md new file mode 100644 index 0000000000..a57354fd0e --- /dev/null +++ b/linux/changelog.d/3464.added.2.md @@ -0,0 +1 @@ +SQL Server sign-in with Kerberos, using the ticket from `kinit`. diff --git a/linux/changelog.d/3464.added.3.md b/linux/changelog.d/3464.added.3.md new file mode 100644 index 0000000000..28943d8c1c --- /dev/null +++ b/linux/changelog.d/3464.added.3.md @@ -0,0 +1 @@ +A window per connection, with its table, structure and SQL editor tabs reopened at the next launch. diff --git a/linux/changelog.d/3464.added.4.md b/linux/changelog.d/3464.added.4.md new file mode 100644 index 0000000000..b7a2517226 --- /dev/null +++ b/linux/changelog.d/3464.added.4.md @@ -0,0 +1 @@ +Table grid with filters, sorting and paging, editing in place, new and deleted rows, and undo until you save. diff --git a/linux/changelog.d/3464.added.5.md b/linux/changelog.d/3464.added.5.md new file mode 100644 index 0000000000..b7ba3acb5f --- /dev/null +++ b/linux/changelog.d/3464.added.5.md @@ -0,0 +1 @@ +Structure tab to change columns, indexes and foreign keys, with the SQL shown before it runs. diff --git a/linux/changelog.d/3464.added.6.md b/linux/changelog.d/3464.added.6.md new file mode 100644 index 0000000000..478656f59f --- /dev/null +++ b/linux/changelog.d/3464.added.6.md @@ -0,0 +1 @@ +SQL editor with completion, formatting, find and replace, a result per statement, and Stop. diff --git a/linux/changelog.d/3464.added.7.md b/linux/changelog.d/3464.added.7.md new file mode 100644 index 0000000000..bd30e1e035 --- /dev/null +++ b/linux/changelog.d/3464.added.7.md @@ -0,0 +1 @@ +Query history with full-text search, and saved queries. diff --git a/linux/changelog.d/3464.added.8.md b/linux/changelog.d/3464.added.8.md new file mode 100644 index 0000000000..28c0a01bda --- /dev/null +++ b/linux/changelog.d/3464.added.8.md @@ -0,0 +1 @@ +Export to CSV, JSON, SQL, Excel, Markdown, HTML and XML, CSV import, and Copy As. diff --git a/linux/changelog.d/3464.added.9.md b/linux/changelog.d/3464.added.9.md new file mode 100644 index 0000000000..eb6705103d --- /dev/null +++ b/linux/changelog.d/3464.added.9.md @@ -0,0 +1 @@ +Keyboard navigation of the grid, and names and states for screen readers. diff --git a/linux/changelog.d/3464.added.md b/linux/changelog.d/3464.added.md new file mode 100644 index 0000000000..4249dd51f2 --- /dev/null +++ b/linux/changelog.d/3464.added.md @@ -0,0 +1 @@ +Saved connections with groups, colours, TLS options and read-only mode, and passwords kept in the system keyring. diff --git a/linux/docs/releasing.md b/linux/docs/releasing.md index eca5389863..42cdeb86b0 100644 --- a/linux/docs/releasing.md +++ b/linux/docs/releasing.md @@ -19,19 +19,19 @@ A repository admin does these once. The workflow's header names the same secrets ## A release -1. **The release commit**, on `linux`, sets one version and one date (UTC) everywhere: +1. **The release commit**, a pull request titled `chore(release): linux-vX.Y.Z`, sets one version and one date (UTC) everywhere: - `version` in `meson.build`. - A new first `` in `data/app.tablepro.TablePro.metainfo.xml.in.in`, with the date and a short description. - `Version`, `Release: 1%{?dist}` and a new first `%changelog` entry in `build-aux/rpm/tablepro.spec`, with the same date. - - `## [Unreleased]` in `CHANGELOG.md` becomes `## [X.Y.Z] - YYYY-MM-DD`, and a new empty `## [Unreleased]` goes above it. + - The version's section in `CHANGELOG.md`, from the fragments in `changelog.d/`. From the repository root, with the towncrier venv of the root `RELEASING.md`, Notes: `.venv/bin/towncrier build --dir linux --version X.Y.Z --date YYYY-MM-DD --yes`. It deletes the fragments and stages `CHANGELOG.md`. An X.Y.0 types its lead under the heading, at most 6 plain lines naming what a reader would change their behavior over. - The `.TH` line of `data/tablepro.1`: the date and `TablePro X.Y.Z`. - The `release-metadata` Meson test checks that they agree once the changelog heading exists, so the pull request fails before the tag does. + The pull request changes only these files and `changelog.d/`; PR title refuses anything else. The `release-metadata` Meson test checks that they agree once the changelog heading exists, so the pull request fails before the tag does. 2. **The tag**: `linux-vX.Y.Z` on the merged commit, pushed the same UTC day. The workflow compares the metainfo date with the commit date. 3. **The workflow** checks the tag, builds and tests both packages on amd64 and arm64, publishes the GitHub release with the tarballs and the version's changelog section as notes, uploads the signed source package to the PPA, and posts the tag to COPR. A reviewer approves each step that uses the environment. 4. **Afterwards**: watch the Launchpad build page of each series and the COPR build the workflow prints. A failed build there does not fail the workflow. -A tag such as `linux-v0.2.0-rc.1` publishes a GitHub pre-release with the tarballs only, and skips the PPA and COPR. +A tag such as `linux-v0.2.0-rc.1` publishes a GitHub pre-release with the tarballs only, and skips the PPA and COPR. Its commit sets the version and the dates but adds no `CHANGELOG.md` section and keeps the fragments, so the stable release lists them; the pre-release notes are a towncrier draft of the fragments at the tag. ## A packaging fix @@ -57,7 +57,7 @@ The next version's release commit sets `Release` back to 1. ## A fix release -0.1.1 is a release like any other, from `linux`. When `linux` already holds work that should wait for 0.2.0, branch `linux-0.1` from the `linux-v0.1.0` tag, cherry-pick the fixes and the release commit onto it, set `LINUX_BRANCH` to `linux-0.1` and allow that branch in the environment, push the tag, then set both back. Merge the release commit's version, dates and changelog section into `linux` afterwards. +0.1.1 is a release like any other, from `linux`. When `linux` already holds work that should wait for 0.2.0, branch `linux-0.1` from the `linux-v0.1.0` tag, cherry-pick the fixes onto it with their fragments, and open the release pull request into `linux-0.1`, so towncrier builds the section from those fragments only. Set `LINUX_BRANCH` to `linux-0.1` and allow that branch in the environment, push the tag, then set both back. Afterwards cherry-pick the release commit onto `linux` in a pull request with the same title: it brings the version, the dates and the section, and deletes the fragments 0.1.1 shipped. ## Downgrades diff --git a/linux/towncrier.toml b/linux/towncrier.toml new file mode 100644 index 0000000000..1f2204d520 --- /dev/null +++ b/linux/towncrier.toml @@ -0,0 +1,27 @@ +[tool.towncrier] +filename = "CHANGELOG.md" +directory = "changelog.d" +start_string = "\n" +title_format = "## [{version}](https://github.com/TableProApp/TablePro/releases/tag/linux-v{version}) - {project_date}" +issue_format = "[#{issue}](https://github.com/TableProApp/TablePro/pull/{issue})" +underlines = ["", "", ""] +ignore = [] +issue_pattern = "\\d+" + +[[tool.towncrier.type]] +name = "Added" + +[[tool.towncrier.type]] +name = "Changed" + +[[tool.towncrier.type]] +name = "Deprecated" + +[[tool.towncrier.type]] +name = "Removed" + +[[tool.towncrier.type]] +name = "Fixed" + +[[tool.towncrier.type]] +name = "Security" diff --git a/scripts/ci/check-changelog.py b/scripts/ci/check-changelog.py index e2cb467253..9d48c9513d 100755 --- a/scripts/ci/check-changelog.py +++ b/scripts/ci/check-changelog.py @@ -16,6 +16,8 @@ REPOSITORY = "TableProApp/TablePro" MARKER = "" +CHANGELOG = "CHANGELOG.md" +CONFIG = "towncrier.toml" FRAGMENTS = "changelog.d" IGNORED = {".gitkeep"} MAX_LINE = 120 @@ -23,15 +25,47 @@ VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" DATE = r"(?P[0-9]{4}-[0-9]{2}-[0-9]{2})" OLD_HEADING = re.compile(rf"## \[(?P{VERSION})\] - {DATE}") -NEW_HEADING = re.compile( - rf"## \[(?P{VERSION})\]\(https://github\.com/{re.escape(REPOSITORY)}/releases/tag/v(?P=version)\)" - rf" - {DATE}(?: \[CRITICAL\])?" -) -# The forward-port to main keeps the release commit's subject, which ends in its (#N). -RELEASE_TITLE = re.compile(rf"chore\(release\): v{VERSION}(?: \(#[0-9]+\))?") GIT_STATUSES = {"A": "added", "D": "removed"} +class Project(NamedTuple): + directory: str + tag: str + release_files: Tuple[str, ...] = () + + def path(self, name: str) -> str: + return f"{self.directory}/{name}" if self.directory else name + + @property + def changelog(self) -> str: + return self.path(CHANGELOG) + + @property + def fragments(self) -> str: + return self.path(FRAGMENTS) + + @property + def new_heading(self) -> "re.Pattern[str]": + return re.compile( + rf"## \[(?P{VERSION})\]\(https://github\.com/{re.escape(REPOSITORY)}/releases/tag/" + rf"{re.escape(self.tag)}(?P=version)\) - {DATE}(?: \[CRITICAL\])?" + ) + + @property + def release_title(self) -> "re.Pattern[str]": + # The forward-port keeps the release commit's subject, which ends in its (#N). + return re.compile(rf"chore\(release\): {re.escape(self.tag)}{VERSION}(?: \(#[0-9]+\))?") + + +# Each directory with a towncrier.toml, and the tag its title_format links. The Linux release +# commit also sets the version and date that linux/build-aux/packaging/check_packaging.py compares. +PROJECTS = ( + Project("", "v"), + Project("linux", "linux-v", ("meson.build", "data/app.tablepro.TablePro.metainfo.xml.in.in", + "build-aux/rpm/tablepro.spec", "data/tablepro.1")), +) + + class ChangedFile(NamedTuple): filename: str status: str @@ -41,12 +75,12 @@ class CheckError(Exception): pass -def fragment_problems(directory: Path) -> List[str]: +def fragment_problems(directory: Path, fragments: str = FRAGMENTS) -> List[str]: if not directory.is_dir(): return [] problems = [] for path in sorted(directory.iterdir()): - name = f"{FRAGMENTS}/{path.name}" + name = f"{fragments}/{path.name}" if path.name in IGNORED: continue if not path.is_file(): @@ -79,18 +113,20 @@ def _is_date(text: str) -> bool: return True -def changelog_problems(changelog: str) -> List[str]: +def changelog_problems(changelog: str, project: Project = PROJECTS[0]) -> List[str]: + name = project.changelog lines = changelog.split("\n") markers = [index for index, line in enumerate(lines) if line == MARKER] if len(markers) != 1: - return [f"CHANGELOG.md has {len(markers)} `{MARKER}` lines, expected 1"] + return [f"{name} has {len(markers)} `{MARKER}` lines, expected 1"] marker = markers[0] problems = [] first = next((index for index in range(marker + 1, len(lines)) if lines[index].startswith("## [")), None) if first is not None and lines[marker + 1:first] != [""]: - problems.append(f"CHANGELOG.md:{marker + 1}: only one blank line goes between the marker and the first ## [") + problems.append(f"{name}:{marker + 1}: only one blank line goes between the marker and the first ## [") + new_heading = project.new_heading versions: List[Tuple[int, ...]] = [] seen: Dict[str, int] = {} for index, line in enumerate(lines): @@ -98,86 +134,111 @@ def changelog_problems(changelog: str) -> List[str]: continue number = index + 1 if index < marker: - problems.append(f"CHANGELOG.md:{number}: `{line}` is above the marker") + problems.append(f"{name}:{number}: `{line}` is above the marker") continue - old, new = OLD_HEADING.fullmatch(line), NEW_HEADING.fullmatch(line) + old, new = OLD_HEADING.fullmatch(line), new_heading.fullmatch(line) heading = old or new if heading is None: - problems.append(f"CHANGELOG.md:{number}: `{line}` is not `## [X.Y.Z]() - YYYY-MM-DD`") + problems.append(f"{name}:{number}: `{line}` is not `## [X.Y.Z]() - YYYY-MM-DD`") continue if not _is_date(heading.group("date")): - problems.append(f"CHANGELOG.md:{number}: {heading.group('date')} is not a date") + problems.append(f"{name}:{number}: {heading.group('date')} is not a date") version = heading.group("version") if version in seen: - problems.append(f"CHANGELOG.md:{number}: ## [{version}] repeats line {seen[version]}") + problems.append(f"{name}:{number}: ## [{version}] repeats line {seen[version]}") continue seen[version] = number parsed = tuple(int(part) for part in version.split(".")) if versions and parsed >= versions[-1]: - problems.append(f"CHANGELOG.md:{number}: ## [{version}] is not below the version above it") + problems.append(f"{name}:{number}: ## [{version}] is not below the version above it") versions.append(parsed) if new is not None: - problems += _new_section_problems(changelog, version, number) + problems += _new_section_problems(changelog, version, f"{name}:{number}") return problems -def _new_section_problems(changelog: str, version: str, number: int) -> List[str]: +def _new_section_problems(changelog: str, version: str, where: str) -> List[str]: section = notes.find_section(changelog, version) if section is None: - return [f"CHANGELOG.md:{number}: notes.py cannot find ## [{version}]"] + return [f"{where}: notes.py cannot find ## [{version}]"] if not section.lead: return [] problems = [] if not version.endswith(".0"): - problems.append(f"CHANGELOG.md:{number}: {version} has a lead block; only an X.Y.0 takes one") + problems.append(f"{where}: {version} has a lead block; only an X.Y.0 takes one") if len(section.lead) > MAX_LEAD_LINES: - problems.append(f"CHANGELOG.md:{number}: the {version} lead block has {len(section.lead)} lines, " - f"over {MAX_LEAD_LINES}") + problems.append(f"{where}: the {version} lead block has {len(section.lead)} lines, over {MAX_LEAD_LINES}") for line in section.lead: if line.startswith(("#", "- ")): - problems.append(f"CHANGELOG.md:{number}: the {version} lead line `{line}` is not plain text") + problems.append(f"{where}: the {version} lead line `{line}` is not plain text") return problems -def repository_problems(root: Path) -> List[str]: - return (changelog_problems((root / "CHANGELOG.md").read_text(encoding="utf-8")) - + fragment_problems(root / FRAGMENTS)) +def repository_problems(root: Path, projects: Tuple[Project, ...] = PROJECTS) -> List[str]: + problems = [] + for project in projects: + problems += changelog_problems((root / project.changelog).read_text(encoding="utf-8"), project) + problems += fragment_problems(root / project.fragments, project.fragments) + return problems + + +def _fragment_name(project: Project, path: str) -> Optional[str]: + prefix = f"{project.fragments}/" + return path[len(prefix):] if path.startswith(prefix) else None + + +def _owner(project: Project, path: str) -> Optional[str]: + name = _fragment_name(project, path) + return None if name is None else name.partition(".")[0] -def _owner(path: str) -> Optional[str]: - if not path.startswith(f"{FRAGMENTS}/"): - return None - return path[len(FRAGMENTS) + 1:].partition(".")[0] +def _listing(items: List[str]) -> str: + return items[0] if len(items) == 1 else f"{', '.join(items[:-1])} and {items[-1]}" -def pull_request_problems(number: int, title: str, base: str, files: List[ChangedFile]) -> List[str]: +def pull_request_problems(number: int, title: str, base: str, files: List[ChangedFile], + projects: Tuple[Project, ...] = PROJECTS) -> List[str]: + problems = [] + for project in projects: + problems += _project_pull_request_problems(project, number, title, base, files) + return problems + + +def _project_pull_request_problems(project: Project, number: int, title: str, base: str, + files: List[ChangedFile]) -> List[str]: + # The pull request that adds a project's towncrier.toml brings its changelog and fragments as they are. + if ChangedFile(project.path(CONFIG), "added") in files: + return [] problems = [] - if RELEASE_TITLE.fullmatch(title): + if project.release_title.fullmatch(title): + release_files = [project.changelog, *(project.path(name) for name in project.release_files)] others = [changed.filename for changed in files - if changed.filename != "CHANGELOG.md" and _owner(changed.filename) is None] + if changed.filename not in release_files and _fragment_name(project, changed.filename) is None] if others: more = f" and {len(others) - 1} more" if len(others) > 1 else "" - problems.append(f"a release pull request changes only CHANGELOG.md and {FRAGMENTS}/; " + allowed = _listing([release_files[0], f"{project.fragments}/", *release_files[1:]]) + problems.append(f"a release pull request changes only {allowed}; " f"this one also changes {others[0]}{more}") - elif any(changed.filename == "CHANGELOG.md" for changed in files): - problems.append(f"only a `chore(release): vX.Y.Z` pull request edits CHANGELOG.md. Put the entry in " - f"{FRAGMENTS}/{number}..md (.claude/rules/changelog.md)") + elif any(changed.filename == project.changelog for changed in files): + problems.append(f"only a `chore(release): {project.tag}X.Y.Z` pull request edits {project.changelog}. " + f"Put the entry in {project.fragments}/{number}..md (.claude/rules/changelog.md)") if base != "main": return problems # A fragment deleted and added again under a new type keeps the pull request it is named for. - removed = {_owner(changed.filename) for changed in files if changed.status == "removed"} + removed = {_owner(project, changed.filename) for changed in files if changed.status == "removed"} for changed in files: - owner = _owner(changed.filename) - if changed.status != "added" or owner is None or changed.filename[len(FRAGMENTS) + 1:] in IGNORED: + name = _fragment_name(project, changed.filename) + if changed.status != "added" or name is None or name in IGNORED: continue + owner = name.partition(".")[0] if owner != str(number) and owner not in removed: - rest = changed.filename[len(FRAGMENTS) + 1:].partition(".")[2] + rest = name.partition(".")[2] # A rest with no dot holds no type, so towncrier would refuse it as a suffix. if "." not in rest: rest = ".md" problems.append(f"{changed.filename} is not named for this pull request. " - f"Name it {FRAGMENTS}/{number}.{rest}") + f"Name it {project.fragments}/{number}.{rest}") return problems @@ -196,7 +257,7 @@ def workflow_command_data(text: str) -> str: def main(argv: Optional[List[str]] = None) -> int: - parser = argparse.ArgumentParser(description="Check changelog.d/ and CHANGELOG.md.") + parser = argparse.ArgumentParser(description="Check each towncrier project's changelog.d/ and CHANGELOG.md.") parser.add_argument("--pr", type=int, help="check the files this pull request changes instead, from its merge commit at HEAD") parser.add_argument("--title", help="the pull request's title, with --pr") @@ -218,7 +279,10 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"::error title=Changelog::{workflow_command_data(problem)}") if problems: return 1 - print("OK: changelog.d/ and CHANGELOG.md" if args.pr is None else f"OK: the changelog files of #{args.pr}") + if args.pr is None: + print("OK: " + ", ".join(f"{project.fragments}/ and {project.changelog}" for project in PROJECTS)) + else: + print(f"OK: the changelog files of #{args.pr}") return 0 diff --git a/scripts/ci/test_check_changelog.py b/scripts/ci/test_check_changelog.py index 2004f7b499..4b1e26bfa2 100644 --- a/scripts/ci/test_check_changelog.py +++ b/scripts/ci/test_check_changelog.py @@ -18,6 +18,8 @@ HEADER = "# Changelog\n\nUnreleased changes are files in `changelog.d/`.\n\n\n\n" NEW = f"## [0.82.0]({LINK}/v0.82.0) - 2026-10-25\n\nOne lead line.\n\n### Added\n\n- A thing. ([#1](x))\n\n" OLD = "## [0.81.0] - 2026-10-16\n\n### Fixed\n\n- A bug.\n\n## [0.80.0] - 2026-10-11\n\n### Added\n\n- Another.\n" +LINUX = check.PROJECTS[1] +LINUX_NEW = f"## [0.1.0]({LINK}/linux-v0.1.0) - 2026-10-25\n\nOne lead line.\n\n### Added\n\n- A thing. ([#1](x))\n" GIT_ENV = { "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", @@ -74,6 +76,12 @@ def test_a_directory_inside_is_refused(self): (self.directory / "added").mkdir() self.assertEqual(check.fragment_problems(self.directory), ["changelog.d/added is not a file"]) + def test_a_problem_names_the_project_directory(self): + (self.directory / "1.added.md").write_text("- A thing.\n", encoding="utf-8") + problems = check.fragment_problems(self.directory, "linux/changelog.d") + self.assertEqual(len(problems), 1) + self.assertTrue(problems[0].startswith("linux/changelog.d/1.added.md starts with")) + class ChangelogTests(unittest.TestCase): def test_old_and_new_headings_pass(self): @@ -164,6 +172,28 @@ def test_an_old_lead_block_is_not_limited(self): self.assertEqual(check.changelog_problems(HEADER + OLD.replace("### Fixed", lead + "\n\n### Fixed")), []) +class ProjectChangelogTests(unittest.TestCase): + def test_a_project_heading_links_its_own_tag(self): + self.assertEqual(check.changelog_problems(HEADER + LINUX_NEW, LINUX), []) + self.assertEqual(check.changelog_problems(HEADER, LINUX), []) + + def test_a_heading_linking_the_other_projects_tag_is_refused(self): + problems = check.changelog_problems(HEADER + LINUX_NEW.replace("linux-v0.1.0", "v0.1.0"), LINUX) + self.assertEqual(len(problems), 1) + self.assertTrue(problems[0].startswith("linux/CHANGELOG.md:7: `## [0.1.0]")) + self.assertEqual(len(check.changelog_problems(HEADER + LINUX_NEW)), 1) + + def test_a_project_keeps_the_lead_block_rules(self): + patch = LINUX_NEW.replace("0.1.0", "0.1.1") + problems = check.changelog_problems(HEADER + patch, LINUX) + self.assertEqual(problems, ["linux/CHANGELOG.md:7: 0.1.1 has a lead block; only an X.Y.0 takes one"]) + + def test_a_pre_release_takes_no_section(self): + problems = check.changelog_problems(HEADER + LINUX_NEW.replace("0.1.0", "0.1.0-rc.1"), LINUX) + self.assertEqual(len(problems), 1) + self.assertIn("is not `## [X.Y.Z]", problems[0]) + + class RepositoryTests(unittest.TestCase): def setUp(self): self.scratch = tempfile.TemporaryDirectory() @@ -172,6 +202,9 @@ def setUp(self): (self.root / "CHANGELOG.md").write_text(HEADER + NEW + OLD, encoding="utf-8") (self.root / "changelog.d").mkdir() (self.root / "changelog.d" / "3446.added.md").write_text("A thing.\n", encoding="utf-8") + (self.root / "linux" / "changelog.d").mkdir(parents=True) + (self.root / "linux" / "CHANGELOG.md").write_text(HEADER + LINUX_NEW, encoding="utf-8") + (self.root / "linux" / "changelog.d" / "3464.added.md").write_text("A thing.\n", encoding="utf-8") def run_main(self, *argv): captured = io.StringIO() @@ -180,7 +213,21 @@ def run_main(self, *argv): return status, captured.getvalue() def test_a_good_tree_passes(self): - self.assertEqual(self.run_main(), (0, "OK: changelog.d/ and CHANGELOG.md\n")) + self.assertEqual(self.run_main(), + (0, "OK: changelog.d/ and CHANGELOG.md, linux/changelog.d/ and linux/CHANGELOG.md\n")) + + def test_every_project_is_checked(self): + (self.root / "linux" / "changelog.d" / "3464.added.md").write_text("- A thing.\n", encoding="utf-8") + (self.root / "linux" / "CHANGELOG.md").write_text(HEADER + "## [Unreleased]\n", encoding="utf-8") + status, out = self.run_main() + self.assertEqual(status, 1) + self.assertEqual(out.count("::error title=Changelog::linux/"), 2) + + def test_a_project_without_its_changelog_fails(self): + (self.root / "linux" / "CHANGELOG.md").unlink() + status, out = self.run_main() + self.assertEqual(status, 1) + self.assertIn("linux/CHANGELOG.md", out) def test_problems_from_both_files_are_annotations(self): (self.root / "changelog.d" / "3446.added.md").write_text("- A thing.\n", encoding="utf-8") @@ -260,6 +307,79 @@ def test_retyping_another_pull_requests_fragment_keeps_its_number(self): self.assertEqual(self.problems(files), []) +class ProjectPullRequestTests(unittest.TestCase): + VERSION_FILES = ["linux/meson.build", "linux/data/app.tablepro.TablePro.metainfo.xml.in.in", + "linux/build-aux/rpm/tablepro.spec", "linux/data/tablepro.1"] + + def problems(self, files, title="fix(linux): keep the caret", base="main", number=3600): + return check.pull_request_problems(number, title, base, files) + + def test_a_fragment_named_for_the_pull_request_passes(self): + files = [changed("linux/changelog.d/3600.fixed.md"), changed("linux/crates/app/src/x.rs", "modified")] + self.assertEqual(self.problems(files), []) + + def test_a_fragment_named_for_another_pull_request_prints_the_expected_name(self): + problems = self.problems([changed("linux/changelog.d/3599.fixed.1.md")]) + self.assertEqual(problems, ["linux/changelog.d/3599.fixed.1.md is not named for this pull request. " + "Name it linux/changelog.d/3600.fixed.1.md"]) + + def test_a_pick_into_a_maintenance_branch_keeps_the_original_name(self): + self.assertEqual(self.problems([changed("linux/changelog.d/3446.fixed.md")], base="linux-0.1"), []) + + def test_a_fragment_of_each_project_is_named_for_the_pull_request(self): + problems = self.problems([changed("changelog.d/3599.fixed.md"), changed("linux/changelog.d/3599.added.md")]) + self.assertEqual(len(problems), 2) + self.assertIn("Name it changelog.d/3600.fixed.md", problems[0]) + self.assertIn("Name it linux/changelog.d/3600.added.md", problems[1]) + + def test_editing_the_project_changelog_outside_a_release_names_its_title_and_fragment(self): + problems = self.problems([changed("linux/CHANGELOG.md", "modified")]) + self.assertEqual(problems, ["only a `chore(release): linux-vX.Y.Z` pull request edits linux/CHANGELOG.md. " + "Put the entry in linux/changelog.d/3600..md (.claude/rules/changelog.md)"]) + + def test_a_release_pull_request_sets_the_version_and_edits_the_changelog(self): + files = [changed("linux/CHANGELOG.md", "modified"), changed("linux/changelog.d/3464.added.md", "removed"), + *(changed(name, "modified") for name in self.VERSION_FILES)] + self.assertEqual(self.problems(files, "chore(release): linux-v0.1.0"), []) + self.assertEqual(self.problems(files, "chore(release): linux-v0.1.1 (#3601)", "linux-0.1"), []) + + def test_a_release_pull_request_changes_nothing_else(self): + files = [changed("linux/CHANGELOG.md", "modified"), changed("linux/crates/app/src/x.rs", "modified")] + problems = self.problems(files, "chore(release): linux-v0.1.0") + self.assertEqual(problems, ["a release pull request changes only linux/CHANGELOG.md, linux/changelog.d/, " + + ", ".join(self.VERSION_FILES[:-1]) + " and linux/data/tablepro.1; " + "this one also changes linux/crates/app/src/x.rs"]) + + def test_a_pre_release_pull_request_cannot_edit_the_changelog(self): + files = [changed("linux/CHANGELOG.md", "modified"), *(changed(name, "modified") for name in self.VERSION_FILES)] + self.assertEqual(len(self.problems(files, "chore(release): linux-v0.2.0-rc.1")), 1) + self.assertEqual(self.problems(files[1:], "chore(release): linux-v0.2.0-rc.1"), []) + + def test_the_pull_request_adding_a_project_brings_its_changelog_and_fragments(self): + files = [changed("linux/towncrier.toml"), changed("linux/CHANGELOG.md", "modified"), + changed("linux/changelog.d/3464.added.md"), changed("linux/changelog.d/3413.added.md")] + self.assertEqual(self.problems(files), []) + self.assertEqual(len(self.problems(files[1:])), 3) + self.assertEqual(len(self.problems([changed("linux/towncrier.toml", "modified"), *files[1:]])), 3) + + def test_adding_one_project_leaves_the_other_checked(self): + files = [changed("linux/towncrier.toml"), changed("CHANGELOG.md", "modified")] + self.assertEqual(len(self.problems(files)), 1) + self.assertIn("edits CHANGELOG.md", self.problems(files)[0]) + + def test_a_release_of_one_project_cannot_edit_the_other(self): + root = self.problems([changed("CHANGELOG.md", "modified"), changed("linux/CHANGELOG.md", "modified")], + "chore(release): v0.82.0", "release/0.82") + self.assertEqual(len(root), 2) + self.assertIn("also changes linux/CHANGELOG.md", root[0]) + self.assertIn("edits linux/CHANGELOG.md", root[1]) + linux = self.problems([changed("CHANGELOG.md", "modified"), changed("linux/CHANGELOG.md", "modified")], + "chore(release): linux-v0.1.0") + self.assertEqual(len(linux), 2) + self.assertIn("edits CHANGELOG.md", linux[0]) + self.assertIn("also changes CHANGELOG.md", linux[1]) + + class MergeCommitTests(unittest.TestCase): def setUp(self): environment = mock.patch.dict(os.environ, GIT_ENV)