From bc61fc39fa63483782b0d757cda28043a61577ce Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 14:56:59 +0700 Subject: [PATCH 01/11] docs(linux): rewrite the unreleased changelog as the first release notes --- linux/CHANGELOG.md | 225 ++++----------------------------------------- 1 file changed, 16 insertions(+), 209 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 8c14d7c13e..f694298d8c 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -7,215 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +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 -- Packages for Ubuntu 26.04 from a Launchpad PPA and for Fedora 44 from COPR, updated with the rest of the system. -- Password prompt on connect when the keyring holds no password or the server refused it, with a choice to remember it. -- SSH tunnels through the system OpenSSH client, with your agent, host aliases and jump hosts, confirming new host keys. -- Security group in the connect dialog: encryption mode, CA certificate, client certificate and TLS server name. -- SQLite file chooser in the connect dialog, with New Database to create a file. -- Results list in the SQL editor with one entry per statement, its rows affected or error, and the open transaction. -- SQL editor completion of tables, views and columns from the database, resolving aliases and matching fuzzily. -- Stop Saving in table and structure tabs, and in the dialog that waits for a save before closing. -- Save Changes dialog on close that lists every tab with unsaved changes and saves only the ones left checked. -- Logout waits while there are unsaved changes, and logout and suspend wait while a save is running. -- Auto-increment on and off for PostgreSQL columns in the Structure tab, numbering on from the largest existing value. -- SQLite column type, nullability, default and primary key changes, and foreign keys added or removed, in the Structure tab. -- Delete button in the table grid, with the reason in its tooltip when the selected rows can't be deleted. -- Set to DEFAULT in a new row's cell menu, for columns the server fills. -- New Window (Ctrl+Shift+N, or `tablepro --new-window`) opens another window in the running TablePro. -- Connections wait offline while the network can't reach their server, and reconnect once it can or after a suspend. -- Review Key in a connection's banner when its SSH host key is refused, to trust a new key, replace a changed one, or retry once the key is fixed outside TablePro. -- Read-only or read-write choice before opening a SQLite file on a network share or FUSE filesystem. -- Locate… for a saved SQLite connection whose file was moved or renamed. +- 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`. -- Review before a structure save that drops data, rebuilds the table or can stop halfway, with the SQL it runs. -- Arrow keys move between grid cells; F2 or Return edits one, Tab carries an edit to the next cell, and Shift+F10 opens the cell menu. -- Screen readers name check box cells and say when a grid cell is modified or NULL and a row new or marked for deletion. -- Connections open when TablePro quits open again at the next launch; Restore Session in Preferences turns it off. -- Save Query History in Preferences turns the query history off and keeps the entries already saved. -- Try Again on a table tab's error page, and Retry on a banner over rows or a structure that could not be read again. -- Table and structure tabs show a spinner in the tab bar while they read. -- Count Rows in a table tab's paginator, with the reason on Try Again when a count fails. -- Exports write in the background, with progress and Stop in the header bar, and a close or quit asks to stop them first. -- Export a table's current page or every row under its filter and sort, and an editor result's shown rows or all of them. -- Banner over an editor result the row limit cut short, saying how many rows it shows, with Export All for every row. -- Sort an editor result by clicking a column header, with text in your language's alphabetical order. -- Copy As puts CSV, JSON, Markdown, SQL and tab-separated rows on the clipboard as their own type beside plain text. -- Shift+F10 opens the menu of a sidebar table or a query history entry, as the Menu key does. -- Screen readers name icon-only buttons, and announce the shortcut of the table, SQL editor and filter buttons that have one. -- An icon beside each table row with a pending change, and bold text in edited cells. -- Troubleshooting in About TablePro, with the app, toolkit and driver versions to copy into a bug report. -- Licenses of the third-party crates TablePro is built with, in About TablePro and installed with the app. -- F9 and the sidebar button show and hide the table sidebar. -- Sidebar lists the tables of every schema, a section per schema with the default one first, and a middle click opens one in a new tab. -- Connect leads a saved connection's menu, which a right click, the Menu key or Shift+F10 opens. -- Find and replace in the SQL editor with Ctrl+F and Ctrl+H, matching case, whole words or regular expressions. -- Edit…, Duplicate and Move to Group in a saved connection's menu; an edit keeps the saved passwords unless you type new ones. -- Desktop notification when a query, save or export finishes in a tab you are not looking at; clicking it opens the tab. -- SSH agent sign-in, Kerberos tickets in KCM and SQLite files on removable drives in the Flatpak build. -- Screenshots of the table grid, SQL editor, Structure tab and connect dialog in the software centre listing. - -### Changed - -- Structure tab offers only the changes the database can make, and says why a control is off. -- New rows in the table grid take a value for a primary key that is not auto-increment. -- Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. -- Structure tab sets a column NOT NULL when it joins the primary key, and gives its nullability back when it leaves. -- Auto Increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. -- SQLite foreign keys show the name their CREATE TABLE gives them. -- Delete, Duplicate and Copy As in a grid cell's menu act on every selected row when the clicked row is selected. -- Grid cells edit in place with the keyboard each type needs; dates open a calendar, and JSON, XML and long text a larger editor. -- Set Value in a grid cell's menu is Set to Empty, Set to NULL and Set to DEFAULT, each shown only where the column takes it. -- Set a grid cell to NULL with Ctrl+Backspace instead of Ctrl+Shift+N. -- Quit asks each window about its unsaved changes in turn, and waits for open connections to close. -- JSON exports and copies put one row per line, and a value TablePro couldn't read stops the export instead of becoming NULL. -- Each window holds one connection: opening one already open brings its window forward, and Disconnect returns to the list. -- Connecting shows in the window with Cancel, and a failed connect says why with Retry and Edit Connection; screen readers hear both. -- Deleting a connection asks once, offers its window when it is open, and can delete its query history too. -- SQLite files shared through the document portal show their own path instead of the portal's. -- Add Index and Add Foreign Key check the form as you type, and pick the referenced table and columns from the database. -- Denser grid rows, with numbers aligned right in even-width digits at the precision their column declares. -- A grid value too long to draw says how many bytes it leaves out, and its tooltip points to Show Row as JSON. -- Refresh keeps a table's rows, selection and focused cell on screen while it reads the table again. -- A structure tab keeps the table's structure on screen while it reads it again, a save included. -- Opening a large table no longer counts its rows: the paginator shows the server's estimate, and Last waits for a count. -- Saving rows updates a table's total without counting the table again. -- Row counts and page sizes group digits the way the system locale does. -- Query History opens with Ctrl+Shift+H. -- Query History finds any part of a query in any case, and loads more entries as you scroll. -- Query History filters by any connection with history, under its current name, and starts on the window's own. -- Deleting from Query History offers Undo instead of asking first. -- Query History's filters sit under the search bar instead of in a popover, and Today replaces the last 24 hours. -- Show in Files, in Preferences and Query History, selects the history database in its folder. -- Previous and next page are Alt+Page Up and Alt+Page Down; Page Up and Page Down move through the rows. -- Ctrl+S, Ctrl+Z, F5 and Ctrl+F act on the tab that has the focus, so a text field keeps its own undo. -- Keyboard Shortcuts lists every shortcut, grouped by where it works. -- About TablePro links to the website, documentation and issue tracker, and shows a release's notes. -- The sidebar and the tabs each have a header bar, and a narrow window folds the sidebar away and swaps the tab bar for a tab button. -- Sidebar menus offer Edit Structure, Import CSV and Drop Table only on tables, and nothing that writes on a read-only connection. -- New Connection and the main menu sit in the connection list's header bar. -- Each connection group is a section of its own in the connection list, after the connections in no group. -- A tab with unsaved changes or an open transaction shows a dot icon instead of a • in its title. -- The window title names the tab in front and its connection, and the header says when the connection is read-only. -- Tab titles name the schema once the database has two schemas of its own. - -### Removed - -- Connection buttons in a connected window's header bar; New Window opens another connection. -- Ctrl+E for a new SQL editor tab; Ctrl+T opens one. -- Close button in the sidebar and Query History search bars; the search button or Escape closes them. - -### Fixed - -- Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. -- Format could split `+=`, `b'01'` or `@@version`, or detach a MySQL function from its parenthesis, and change the statement. -- Opening a SQL, CSV or connections file from a pipe or a device never finished, or read past its size limit. -- Exporting results or query history, or opening the connect dialog, froze the window on a slow disk or network folder. -- A failed query history export said nothing. -- Copying many rows, or showing a row with a long value as JSON, froze the window. -- A query saved just before quitting could be lost. -- The sidebar's table search, the find and replace fields and the Query History search had no name for screen readers. -- The sidebar was slow to fill and to search on databases with thousands of tables, and said nothing when the list could not be read again. -- A connection added, changed or deleted in one window did not show in the other windows' connection lists. -- A connection name with & or < in it showed wrong in the connection list. -- PostgreSQL statement timeouts showed as a plain server error when the server's messages were not in English. -- A PostgreSQL query run right after a timeout or Stop could fail as canceled by that earlier stop. -- MySQL and MariaDB connections that verify the certificate did not trust certificate authorities installed on the system. -- SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. -- Saving a SQL Server row with a char or varchar key locked the whole table until the save ended. -- Stop could not end a SQLite statement waiting for another connection's lock. -- With SQLite 3.51, a file that is not a SQLite database failed to open with SQLite's raw error instead of saying so. -- With SQLite 3.51, a SQLite connection waiting for another program's lock could be taken for lost after a suspend. -- Launching TablePro while it was running did nothing instead of showing the open window. -- Deleting a connection left its saved tabs and editor drafts on disk. -- A change to the connection list made just before quitting could be lost. -- A SQLite save that failed in a way SQLite rolls back itself closed the connection and its temporary tables. -- A SQLite script ending in an unclosed comment showed later results under the first statement's columns. -- SQLite text containing a NUL character exported as SQL that SQLite cannot read. -- ClickHouse query or export with a FORMAT clause filled memory instead of failing. -- ClickHouse editor tab silently lost its temporary tables and settings when the server expired its session. -- Running a ClickHouse statement right after stopping one failed because the server still held the session. -- ClickHouse DateTime values showed as UTC text when the server profile or session set ISO output. -- ClickHouse save with an unknown outcome could read as applied because of another table's mutation. -- A new row removed with Delete stayed in the grid while the save left it out. -- Structure tab collapsed open columns on each add or remove, and lost an edit typed right after one. -- Structure tab column summary and the Default field's error did not update as the column was edited. -- SQLite `INTEGER PRIMARY KEY DESC` columns showed as auto-increment in the Structure tab. -- Undoing a delete of many rows took one Ctrl+Z per row and could not bring back more than 50. -- Editing a number in the grid could round it: integers past 2^53 and floats past six decimal places. -- Setting a new row's cell to NULL saved the column's default instead. -- Setting a NOT NULL cell to NULL was accepted and failed at save. -- The Menu key on a read-only grid cell opened a text selection menu instead of the cell's menu. -- Text with a NUL character showed cut short in the grid, copied cut short, and editing it saved the cut value. -- An edit, Set Value or row action in the table grid could land on another row once a row above it came or went. -- Editing, undoing or deleting a row in the table grid dropped it from a selection of several rows. -- Copy As INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. -- Duplicate left out the row's unsaved edits. -- Cells of a table without a primary key opened for editing, then dropped what was typed. -- A row whose primary key the driver could not read opened for editing, then failed to save. -- A save that failed on a new row did not flash that row. -- Undo after editing a cell twice restored the fetched value instead of the first edit. -- An edit made while a save ran was dropped when it set the cell back to the value it was read with. -- Refresh or a structure change while edits were pending could save an edit into another column or row. -- Editing a long text cell saved only its first 10,000 characters, and a blob in a text column saved its size label. -- A save that deleted a row and added a new one with the same unique value failed. -- The window title showed no unsaved-changes bullet for pending structure changes. -- `real` values showed extra digits in the grid, copies and exports, such as 0.10000000149011612 for 0.1. -- Excel export rounded integers past 2^53 and wrote decimals as text when their column padded the scale. -- Exported arrays wrote each row of a multi-dimensional array as a quoted string. -- Markdown export broke its table when a column name held a pipe. -- Closed tabs, earlier result pages and structure edits kept using memory until TablePro quit. -- A page read before a filter, sort or page change could replace the newer page when it arrived late. -- Closing a table tab left its query running on the server until it finished. -- Refresh did not show a column added to the table since the tab opened. -- A table tab kept the old header of a column renamed in the Structure tab. -- A table or structure tab whose read failed while the connection was lost kept its error page after the reconnect. -- A refresh that failed replaced the rows in a table tab with an error page. -- Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. -- The sidebar lost the selected table each time the table list was read again. -- A table tab with no columns the account can read showed a page error instead of saying why. -- Switching connections during a table drop could close the new connection's tabs on a table of the same name. -- Renaming a MySQL table in another database moved it to the connection's database, and renaming a ClickHouse table failed. -- Turning a column's primary key on or off in the Structure tab saved nothing. -- Dropping a SQL Server column that has a default failed. -- Creating a SQLite table with a foreign key failed. -- Changing a column in the Structure tab lost its collation, SRID, SPARSE, CHECK, storage, compression or other attributes it had. -- Saving a MySQL column whose default or generated expression held a quoted string failed. -- Save in a Structure tab's close prompt skipped the tab's checks and dropped a default that did not parse. -- The Structure tab refused every save on a SQLite table keyed by INTEGER PRIMARY KEY. -- Saved tabs and editor text were lost on a disconnect or window close right after a change. -- Every connection's saved tabs were lost when their file could not be read. -- Opening a connection emptied the editor tabs saved for other connections. -- Structure tabs did not reopen with their connection. -- Reopening a connection's tabs stopped at 32 tabs and cut table names longer than 256 bytes. -- Editor tabs reopened after a reconnect in the same window stopped saving their text. -- An editor tab whose saved text could not be read opened empty with no notice, and typing in it overwrote that text. -- Typing in a long SQL script copied the whole script on every keystroke. -- Closing the window never asked about open editor transactions, and never closed the editor tabs' connections. -- Disconnecting or switching connections offered no way to save pending changes first. -- Edits made while a switch to another connection was connecting were closed without asking. -- A save whose answer was lost with the connection showed as failed, and saving again could apply it twice. -- Edits made in a table or Structure tab while its save ran were lost when the save finished. -- Ctrl+S did nothing in a Structure tab. -- Structure tab could not save two columns swapping names, or a column taking the name of one being removed. -- Changing the type of a SQL Server column with a default failed. -- A bulk delete confirmed after the table's primary key changed marked rows by their old key. -- Ctrl+Home and Ctrl+End jumped to the first or last tab instead of the start or end of the SQL editor or table. -- Ctrl+N added a row to a read-only table or a table without a primary key. -- Two SQL editor tabs could both be named Query 3 after an earlier editor tab closed. -- After Clear All, the filter strip could show Match any while the next filter combined its rules with all. -- Drop-downs in the connect and export dialogs cut their value short beside a long description. -- PostgreSQL numbers in SQL editor results lined up on the left, unlike the table grid's. -- Grid columns cut long numbers short at their default width. -- TablePro could crash when its window took the focus just as a new page replaced the focused cell's row. - -### Security - -- CSV export did not neutralise formulas in values of a type TablePro does not recognise. -- SQL run in the editor, a typed filter or a column default, and the values in them, reached the journal at debug level. -- Column types and defaults typed in the Structure tab reached the SQL after a text filter instead of the database's own parser. -- Changing a SQL Server column's type or nullability in the Structure tab removed its data mask. -- A jump host whose ssh_config sets StrictHostKeyChecking to accept-new or no was trusted without the host key dialog. -- Editor SQL saved by an earlier build stayed on disk in a tab file nothing reads any more. -- Statements that set a password, an encryption key or a connection secret were saved in the query history. +- 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. From cd3fed8f5ad8bc172aed208b2651944bc5629261 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 14:57:37 +0700 Subject: [PATCH 02/11] fix(linux): list every export format in the metainfo description --- linux/data/app.tablepro.TablePro.metainfo.xml.in.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in index ebfe2af7ed..6f7f2c8e08 100644 --- a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in +++ b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in @@ -18,7 +18,7 @@
  • Run SQL scripts statement by statement and cancel a slow query
  • Tunnel through an SSH jump host using your own OpenSSH client
  • Verify TLS, keep connections read-only, and reconnect on drop
  • -
  • Export results to CSV, TSV, JSON and SQL
  • +
  • Export results to CSV, JSON, SQL, Excel, Markdown, HTML and XML
  • @app-id@.desktop From 9c819e286e69f21eb101c9142798ef43165bee21 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 14:59:50 +0700 Subject: [PATCH 03/11] build(linux): check the release dates and the spec Release at the tag --- linux/build-aux/packaging/check_packaging.py | 87 +++++++++++++++++-- linux/build-aux/tests/test_check_packaging.py | 86 ++++++++++++++++-- 2 files changed, 161 insertions(+), 12 deletions(-) diff --git a/linux/build-aux/packaging/check_packaging.py b/linux/build-aux/packaging/check_packaging.py index bed8aa8f16..f978237edd 100644 --- a/linux/build-aux/packaging/check_packaging.py +++ b/linux/build-aux/packaging/check_packaging.py @@ -3,14 +3,19 @@ # the RPM spec package, that the spec collects 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. +# 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. +# +# Once CHANGELOG.md has a heading for the version, the release commit has +# set its dates, and the heading and the spec's %changelog entry must +# carry the metainfo's release date. import argparse import re import sys import tomllib import xml.etree.ElementTree as ElementTree -from datetime import date +from datetime import date, datetime from pathlib import Path METAINFO = Path("data/app.tablepro.TablePro.metainfo.xml.in.in") @@ -26,6 +31,9 @@ RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$") SPEC_VERSION = re.compile(r"^Version:\s*(\S+)\s*$", re.MULTILINE) SPEC_CHANGELOG = re.compile(r"^%changelog\s*\n\*[^\n]*\s-\s(\S+)\s*$", re.MULTILINE) +SPEC_RELEASE = re.compile(r"^Release:\s*(\S+)\s*$", re.MULTILINE) +SPEC_ENTRY = re.compile(r"^\*\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{4})\s.*\s-\s(\S+)\s*$", re.MULTILINE) +FIRST_PACKAGE_RELEASE = "1%{?dist}" FIND_LANG = re.compile(r"^%find_lang\b", re.MULTILINE) CONTROL_TOOLCHAIN = re.compile(r"\b(cargo|rustc)-(\d+\.\d+)\b") RULES_TOOLCHAIN = re.compile(r"/usr/lib/rust-(\d+\.\d+)/bin\b") @@ -64,6 +72,58 @@ def release_notes(changelog, version): return "\n".join(notes).strip() +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*$") + for line in changelog.read_text(encoding="utf-8").splitlines(): + match = heading.match(line) + if match: + return match.group(1) or "" + return None + + +def spec_entry_date(spec, release): + """The date of the %changelog entry for release, None without one, "" when the date is bogus.""" + _, _, log = spec.partition("\n%changelog") + for match in SPEC_ENTRY.finditer(log): + if match.group(2) != release: + continue + written = " ".join(match.group(1).split()) + try: + parsed = datetime.strptime(written, "%a %b %d %Y").date() + except ValueError: + return "" + # rpmbuild warns about a weekday that does not match the date, + # and strptime ignores it. + if parsed.strftime("%a") != written.split()[0]: + return "" + return parsed.isoformat() + return None + + +def release_date_problems(root, version, spec, released, spec_opens_with_version): + found = [] + heading = release_heading_date(root / CHANGELOG, version) + if heading == "": + found.append(f"{CHANGELOG} has no YYYY-MM-DD date on ## [{version}]") + elif heading is not None and heading != released: + found.append(f"{CHANGELOG} dates {version} {heading}, the metainfo {released}") + + # A spec whose %changelog does not open with the version is reported + # once, by the version check. + if not spec_opens_with_version: + return found + first = f"{package_version(version)}-1" + entry = spec_entry_date(spec, first) + if entry is None: + found.append(f"{SPEC} has no %changelog entry for {first}") + elif entry == "": + found.append(f"the %changelog entry for {first} in {SPEC} has no valid date") + elif entry != released: + found.append(f"the %changelog entry for {first} in {SPEC} is dated {entry}, the metainfo {released}") + return found + + def toolchain_problems(root): package = tomllib.loads((root / CARGO).read_text(encoding="utf-8")).get("workspace", {}).get("package", {}) rust_version = package.get("rust-version") @@ -94,6 +154,10 @@ def problems(root, version, tag=None, tag_date=None): if not release_notes(root / CHANGELOG, version): found.append(f"{CHANGELOG} has no notes under ## [{version}]") + packaged = package_version(version) + spec = (root / SPEC).read_text(encoding="utf-8") + changelog = SPEC_CHANGELOG.search(spec) + spec_opens_with_version = changelog is not None and changelog.group(1).startswith(f"{packaged}-") release = newest_release(root / METAINFO) if release is None: found.append(f"{METAINFO} has no ") @@ -103,24 +167,35 @@ def problems(root, version, tag=None, tag_date=None): f"the newest release in {METAINFO} is {release.get('version')}, not {version}" ) released = release.get("date", "") + dated = True try: date.fromisoformat(released) except ValueError: + dated = False found.append(f"the newest release in {METAINFO} has no YYYY-MM-DD date") if tag_date is not None and released != tag_date: found.append( f"the newest release in {METAINFO} is dated {released or 'nothing'}, " f"the tagged commit {tag_date}" ) + released_at_commit = tag is not None or release_heading_date(root / CHANGELOG, version) is not None + if dated and release.get("version") == version and released_at_commit: + found.extend(release_date_problems(root, version, spec, released, spec_opens_with_version)) - packaged = package_version(version) - spec = (root / SPEC).read_text(encoding="utf-8") versions = SPEC_VERSION.findall(spec) if versions != [packaged]: found.append(f"{SPEC} packages version {', '.join(versions) or 'nothing'}, not {packaged}") - changelog = SPEC_CHANGELOG.search(spec) - if changelog is None or not changelog.group(1).startswith(f"{packaged}-"): + if not spec_opens_with_version: found.append(f"the first %changelog entry in {SPEC} is not for {packaged}") + # A packaging fix after the release raises Release on the branch; a + # new version starts again from 1. + if tag is not None: + releases = SPEC_RELEASE.findall(spec) + if releases != [FIRST_PACKAGE_RELEASE]: + found.append( + f"{SPEC} has Release {', '.join(releases) or 'nothing'}, not {FIRST_PACKAGE_RELEASE} " + f"for a new version" + ) if bool(linguas(root / LINGUAS)) != bool(FIND_LANG.search(spec)): found.append( diff --git a/linux/build-aux/tests/test_check_packaging.py b/linux/build-aux/tests/test_check_packaging.py index 7499dd7740..92f6530a9d 100644 --- a/linux/build-aux/tests/test_check_packaging.py +++ b/linux/build-aux/tests/test_check_packaging.py @@ -20,13 +20,18 @@ SPEC = """Name: tablepro Version: {version} -Release: 1%{{?dist}} +Release: {release} {find_lang} %changelog -* Wed Oct 14 2026 TablePro Authors - {changelog}-1 +{later}* {spec_date} TablePro Authors - {changelog}-1 - First package """ +PACKAGING_FIX = """* Mon Oct 19 2026 TablePro Authors - 1.2.0-2 +- Recommend a keyring + +""" + CARGO = """[workspace.package] edition = "2024" @@ -79,13 +84,26 @@ def tree( rust_version="1.97", toolchain="cargo-1.97,\n rustc-1.97,", rust_bin="/usr/lib/rust-1.97/bin", + release="1%{?dist}", + spec_date="Wed Oct 14 2026", + later="", ): workdir = tempfile.TemporaryDirectory() self.addCleanup(workdir.cleanup) root = Path(workdir.name) for path, content in ( (check_packaging.METAINFO, METAINFO.format(releases=releases)), - (check_packaging.SPEC, SPEC.format(version=version, changelog=changelog, find_lang=find_lang)), + ( + check_packaging.SPEC, + SPEC.format( + version=version, + changelog=changelog, + find_lang=find_lang, + release=release, + spec_date=spec_date, + later=later, + ), + ), (check_packaging.LINGUAS, linguas), (check_packaging.CHANGELOG, notes), (check_packaging.CARGO, CARGO.format(rust_version=rust_version)), @@ -125,9 +143,13 @@ def test_an_undated_release_fails(self): self.assertIn("no YYYY-MM-DD date", found[0]) def test_a_release_dated_before_the_tag_fails(self): - releases = '' + root = self.tree( + releases='', + notes=NOTES.replace("## [1.2.0] - 2026-10-14", "## [1.2.0] - 2026-10-01"), + spec_date="Thu Oct 01 2026", + ) - found = check_packaging.problems(self.tree(releases=releases), "1.2.0", "linux-v1.2.0", "2026-10-14") + found = check_packaging.problems(root, "1.2.0", "linux-v1.2.0", "2026-10-14") self.assertEqual(len(found), 1) self.assertIn("dated 2026-10-01", found[0]) @@ -219,7 +241,10 @@ def test_a_workspace_without_a_rust_version_fails(self): self.assertEqual(found, ["Cargo.toml names no workspace rust-version"]) def test_a_release_needs_its_changelog_notes(self): - for notes in (NOTES.replace("## [1.2.0] - 2026-10-14", "## [1.2.1]"), "## [1.2.0]\n\n## [1.1.0]\n- x\n"): + for notes in ( + NOTES.replace("## [1.2.0] - 2026-10-14", "## [1.2.1]"), + "## [1.2.0] - 2026-10-14\n\n## [1.1.0]\n- x\n", + ): with self.subTest(notes=notes): found = check_packaging.problems(self.tree(notes=notes), "1.2.0", "linux-v1.2.0", "2026-10-14") @@ -229,6 +254,55 @@ def test_a_release_needs_its_changelog_notes(self): def test_the_changelog_is_only_required_for_a_release(self): self.assertEqual(check_packaging.problems(self.tree(notes="# Changelog\n"), "1.2.0"), []) + def test_the_changelog_heading_carries_the_release_date(self): + cases = [ + ("## [1.2.0] - 2026-10-13", "CHANGELOG.md dates 1.2.0 2026-10-13, the metainfo 2026-10-14"), + ("## [1.2.0]", "CHANGELOG.md has no YYYY-MM-DD date on ## [1.2.0]"), + ] + for heading, expected in cases: + with self.subTest(heading=heading): + root = self.tree(notes=NOTES.replace("## [1.2.0] - 2026-10-14", heading)) + + self.assertEqual(check_packaging.problems(root, "1.2.0"), [expected]) + + def test_the_spec_first_package_carries_the_release_date(self): + cases = [ + ("Tue Oct 13 2026", "is dated 2026-10-13, the metainfo 2026-10-14"), + ("Thu Oct 14 2026", "has no valid date"), + ("Wed Oct 32 2026", "has no valid date"), + ] + for spec_date, expected in cases: + with self.subTest(spec_date=spec_date): + found = check_packaging.problems(self.tree(spec_date=spec_date), "1.2.0") + + self.assertEqual(len(found), 1, found) + self.assertIn(expected, found[0]) + + def test_the_spec_needs_an_entry_for_the_first_package(self): + found = check_packaging.problems(self.tree(changelog="1.1.0", later=PACKAGING_FIX), "1.2.0") + + self.assertEqual(found, ["build-aux/rpm/tablepro.spec has no %changelog entry for 1.2.0-1"]) + + def test_dates_wait_for_the_release_commit(self): + notes = "# Changelog\n\n## [Unreleased]\n\n- Something.\n" + + found = check_packaging.problems(self.tree(notes=notes, spec_date="Tue Oct 13 2026"), "1.2.0") + + self.assertEqual(found, []) + + def test_a_packaging_fix_after_the_release_passes(self): + root = self.tree(release="2%{?dist}", later=PACKAGING_FIX) + + self.assertEqual(check_packaging.problems(root, "1.2.0"), []) + + def test_a_release_tag_needs_the_first_package_release(self): + root = self.tree(release="2%{?dist}", later=PACKAGING_FIX) + + found = check_packaging.problems(root, "1.2.0", "linux-v1.2.0", "2026-10-14") + + self.assertEqual(len(found), 1, found) + self.assertIn("has Release 2%{?dist}, not 1%{?dist}", found[0]) + def test_main_writes_the_release_notes(self): root = self.tree() notes = root / "notes.md" From 24ae025db4269175a8f766b3b962c36a505b36c6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:00:46 +0700 Subject: [PATCH 04/11] ci(linux): build the arm64 packages on a manual run and at release --- .github/workflows/linux-packages.yml | 34 ++++++++++++++----- .github/workflows/release-linux.yml | 1 + linux/build-aux/debian/control | 2 +- .../0018-ppa-copr-and-flathub-later.md | 4 +++ linux/docs/testing.md | 2 +- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml index 0dcc0f7581..71cbb0c5f9 100644 --- a/.github/workflows/linux-packages.yml +++ b/.github/workflows/linux-packages.yml @@ -8,6 +8,7 @@ name: Linux packages # fedora:44 with only its BuildRequires. Each package is then installed in # another fresh container with only its runtime dependencies and started. # release-linux.yml calls this workflow and uploads the source packages. +# A run by hand can add arm64, which pushes and pull requests leave out. on: pull_request: @@ -31,12 +32,21 @@ on: - "linux/**" - ".github/workflows/linux-packages.yml" workflow_dispatch: + inputs: + arm64: + description: Also build, test and install the arm64 .deb and .rpm + type: boolean + default: false workflow_call: inputs: deb-runners: description: JSON list of runners; each series' .deb is built once per runner architecture. type: string default: '["ubuntu-24.04"]' + rpm-runners: + description: JSON list of runners; the .rpm is built once per runner architecture. + type: string + default: '["ubuntu-24.04"]' permissions: contents: read @@ -112,7 +122,7 @@ jobs: strategy: fail-fast: false matrix: - runner: ${{ fromJSON(inputs.deb-runners || '["ubuntu-24.04"]') }} + runner: ${{ fromJSON(inputs.deb-runners || (inputs.arm64 && '["ubuntu-24.04", "ubuntu-24.04-arm"]') || '["ubuntu-24.04"]') }} series: ${{ fromJSON(needs.source-package.outputs.series) }} runs-on: ${{ matrix.runner }} timeout-minutes: 300 @@ -151,9 +161,13 @@ jobs: if-no-files-found: error rpm: - name: RPM package + name: RPM package (${{ matrix.runner }}) needs: sources - runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + runner: ${{ fromJSON(inputs.rpm-runners || (inputs.arm64 && '["ubuntu-24.04", "ubuntu-24.04-arm"]') || '["ubuntu-24.04"]') }} + runs-on: ${{ matrix.runner }} timeout-minutes: 300 container: image: fedora:44 @@ -174,20 +188,24 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: linux-rpm-testlog + name: linux-rpm-testlog-${{ runner.arch }} path: _packages/rpm/testlog.txt if-no-files-found: ignore retention-days: 7 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: linux-rpm + name: linux-rpm-${{ runner.arch }} path: _packages/rpm if-no-files-found: error rpm-install: - name: Install the RPM package + name: Install the RPM package (${{ matrix.runner }}) needs: rpm - runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + runner: ${{ fromJSON(inputs.rpm-runners || (inputs.arm64 && '["ubuntu-24.04", "ubuntu-24.04-arm"]') || '["ubuntu-24.04"]') }} + runs-on: ${{ matrix.runner }} timeout-minutes: 20 container: image: fedora:44 @@ -197,7 +215,7 @@ jobs: persist-credentials: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: linux-rpm + name: linux-rpm-${{ runner.arch }} path: _packages/rpm - name: Install, start and validate run: linux/build-aux/packaging/smoke-test.sh "$(find _packages/rpm -name 'tablepro-[0-9]*.rpm' ! -name '*.src.rpm')" diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 12261940df..8f4c286612 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -74,6 +74,7 @@ jobs: uses: ./.github/workflows/linux-packages.yml with: deb-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' + rpm-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' github-release: name: GitHub release diff --git a/linux/build-aux/debian/control b/linux/build-aux/debian/control index 0264bdfab3..43e7bc7049 100644 --- a/linux/build-aux/debian/control +++ b/linux/build-aux/debian/control @@ -34,7 +34,7 @@ Vcs-Browser: https://github.com/TableProApp/TablePro/tree/linux/linux/build-aux/ Vcs-Git: https://github.com/TableProApp/TablePro.git -b linux [linux/build-aux] Package: tablepro -Architecture: any +Architecture: amd64 arm64 Depends: openssh-client (>= 1:8.7), ${misc:Depends}, diff --git a/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md b/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md index 935088eeb8..22c65aa095 100644 --- a/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md +++ b/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md @@ -123,6 +123,10 @@ development and CI. It does not keep a manifest for Flathub. `X.Y.Z-0ppaN~YY.MM`. - A new Ubuntu series joins `UBUNTU_SERIES` in `linux-packages.yml`, and a new Fedora release arrives through COPR's "follow Fedora branching". +- The .deb is `Architecture: amd64 arm64`, and the COPR project builds + x86_64 and aarch64: the two architectures CI builds, tests and starts + the packages on. With `any`, Launchpad would also build every other + architecture the PPA has turned on and publish what no one tested. - The vendor tarball holds every crate in the lock file, Windows-only and test-only ones included, because `cargo vendor` cannot filter. - The release profile's fat LTO link ran out of 8 GB, so both packages diff --git a/linux/docs/testing.md b/linux/docs/testing.md index bb63361a1f..0c7116e72e 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -368,7 +368,7 @@ GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runners. It runs on A `detect` job first checks that `linux/meson.build` exists, and every other job waits for it and is skipped without it, so the same file can sit on `main` before `linux/` does. GitHub runs a schedule only from the default branch: while `main` has no `linux/`, the Monday run there starts this workflow on the branch the `LINUX_BRANCH` repository variable names, `linux` when it is unset. PRs only merge when every job is green. -`.github/workflows/linux-packages.yml` builds the packages from the release source and vendor tarballs. For each Ubuntu series the PPA builds for, 26.04 and 26.10, it writes the Debian source package with `build-aux/packaging/build-source.sh`, then `build-deb.sh` builds the binaries from it the way Launchpad does: in a fresh container of that series with only the declared build dependencies, offline, as a user that is not root, running every Meson suite, then lintian. `build-rpm.sh` builds the .rpm in `fedora:44` with only its BuildRequires, running every Meson suite, and checks it with `rpmlint --strict`. Each package is then installed in a fresh container and started. It runs on a pull request that touches the packaging (`build-aux/debian`, `build-aux/rpm`, `build-aux/packaging`), `Cargo.toml`, `Cargo.lock` or a Meson file, and on every push to `linux` that touches `linux/`. `.github/workflows/release-linux.yml` calls it on a `linux-v*` tag before it publishes ([ADR 0018](decisions/0018-ppa-copr-and-flathub-later.md)). `build-aux/packaging/build-packages.sh` runs the same steps locally. +`.github/workflows/linux-packages.yml` builds the packages from the release source and vendor tarballs. For each Ubuntu series the PPA builds for, 26.04 and 26.10, it writes the Debian source package with `build-aux/packaging/build-source.sh`, then `build-deb.sh` builds the binaries from it the way Launchpad does: in a fresh container of that series with only the declared build dependencies, offline, as a user that is not root, running every Meson suite, then lintian. `build-rpm.sh` builds the .rpm in `fedora:44` with only its BuildRequires, running every Meson suite, and checks it with `rpmlint --strict`. Each package is then installed in a fresh container and started. It runs on a pull request that touches the packaging (`build-aux/debian`, `build-aux/rpm`, `build-aux/packaging`), `Cargo.toml`, `Cargo.lock` or a Meson file, and on every push to `linux` that touches `linux/`, on x86_64 only. Run by hand with `arm64` ticked, it builds, tests and installs both packages on an arm64 runner too. `.github/workflows/release-linux.yml` calls it on a `linux-v*` tag before it publishes, on both architectures ([ADR 0018](decisions/0018-ppa-copr-and-flathub-later.md)). `build-aux/packaging/build-packages.sh` runs the same steps locally. ## Coverage From 710feac153ea4e7bd6c3c33226e3a3fe7366cd15 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:05:56 +0700 Subject: [PATCH 05/11] ci(linux): upload a released version to the PPA again with a new revision --- .github/workflows/linux-packages.yml | 26 +++++++- .github/workflows/release-linux.yml | 66 ++++++++++++++++++- linux/build-aux/packaging/build-source.sh | 26 ++++++-- .../packaging/fetch-release-sources.sh | 48 ++++++++++++++ linux/docs/README.md | 1 + .../0018-ppa-copr-and-flathub-later.md | 4 +- linux/docs/releasing.md | 57 ++++++++++++++++ linux/docs/testing.md | 2 +- 8 files changed, 219 insertions(+), 11 deletions(-) create mode 100755 linux/build-aux/packaging/fetch-release-sources.sh create mode 100644 linux/docs/releasing.md diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml index 71cbb0c5f9..cfd60fbdf0 100644 --- a/.github/workflows/linux-packages.yml +++ b/.github/workflows/linux-packages.yml @@ -9,6 +9,8 @@ name: Linux packages # another fresh container with only its runtime dependencies and started. # release-linux.yml calls this workflow and uploads the source packages. # A run by hand can add arm64, which pushes and pull requests leave out. +# Given a release tag, it packages that release's tarballs instead, with +# the packaging from this checkout, for a PPA upload with a new revision. on: pull_request: @@ -47,6 +49,14 @@ on: description: JSON list of runners; the .rpm is built once per runner architecture. type: string default: '["ubuntu-24.04"]' + release-tag: + description: A published linux-vX.Y.Z whose tarballs are packaged, with the .deb packaging from this checkout. No .rpm is built. + type: string + default: "" + revision: + description: The PPA upload number, the N in X.Y.Z-0ppaN. + type: string + default: "1" permissions: contents: read @@ -69,7 +79,13 @@ jobs: with: persist-credentials: false - name: Make the source and vendor tarballs + if: inputs.release-tag == '' run: linux/build-aux/packaging/make-sources.sh --install-deps _packages/sources + - name: Download the released source and vendor tarballs + if: inputs.release-tag != '' + env: + TAG: ${{ inputs.release-tag }} + run: linux/build-aux/packaging/fetch-release-sources.sh --install-deps "$GITHUB_REPOSITORY" "$TAG" _packages/sources - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: linux-sources @@ -98,11 +114,18 @@ jobs: path: _packages/sources - name: Build an unsigned source package for each series id: build + env: + RELEASE_TAG: ${{ inputs.release-tag }} + REVISION: ${{ inputs.revision || '1' }} run: | dist="$(find _packages/sources -name 'tablepro-*.tar.xz' ! -name '*-vendor.tar.xz')" vendor="$(find _packages/sources -name 'tablepro-*-vendor.tar.xz')" + set -- --revision "$REVISION" + if [ -n "$RELEASE_TAG" ]; then + set -- "$@" --debian linux/build-aux/debian + fi # shellcheck disable=SC2086 # one word per series - linux/build-aux/packaging/build-source.sh --install-deps "$dist" "$vendor" _packages/source $UBUNTU_SERIES + linux/build-aux/packaging/build-source.sh --install-deps "$@" "$dist" "$vendor" _packages/source $UBUNTU_SERIES series="" for name in $UBUNTU_SERIES; do series="$series${series:+, }\"$name\"" @@ -163,6 +186,7 @@ jobs: rpm: name: RPM package (${{ matrix.runner }}) needs: sources + if: inputs.release-tag == '' strategy: fail-fast: false matrix: diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 8f4c286612..9f423bf82e 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -8,7 +8,13 @@ name: Release Linux # A pre-release tag such as linux-v0.2.0-rc.1 publishes a GitHub # pre-release and skips the PPA and COPR. # -# Environment linux-release, limited to linux-v* tags, holds two secrets. +# Run by hand on a branch, it uploads a published release to the PPA again +# as X.Y.Z-0ppaN, from that release's tarballs with the branch's .deb +# packaging: a packaging fix that needs no new version. linux/docs/releasing.md +# has the steps. +# +# Environment linux-release, limited to linux-v* tags and the branch a +# packaging fix runs on, with a required reviewer, holds two secrets. # LINUX_PPA_SIGNING_KEY: the ASCII-armored OpenPGP secret key, with no # passphrase, that signs the uploads. Its public key is registered on a # Launchpad account that may upload to the PPA. @@ -20,6 +26,16 @@ name: Release Linux on: push: tags: ["linux-v*"] + workflow_dispatch: + inputs: + tag: + description: The published release to upload again, such as linux-v0.1.0 + required: true + type: string + revision: + description: The PPA upload number, 2 or more (X.Y.Z-0ppaN) + required: true + type: string permissions: contents: read @@ -31,6 +47,7 @@ concurrency: jobs: verify: name: Tag, version and release notes + if: github.event_name == 'push' runs-on: ubuntu-24.04 timeout-minutes: 10 container: @@ -109,10 +126,53 @@ jobs: gh release create "$TAG" release/* --repo "$GITHUB_REPOSITORY" --verify-tag --latest=false \ --title "TablePro for Linux $version" --notes-file notes.md "$@" + revision: + name: Release and revision to upload again + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: The tag is a published release, and the revision a new upload + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + REVISION: ${{ inputs.revision }} + run: | + version="${TAG#linux-v}" + case "$version" in + "$TAG" | "" | *[!0-9.]*) + echo "::error::$TAG is not the tag of a Linux release; a pre-release is not on the PPA" + exit 1 + ;; + esac + case "$REVISION" in + "" | *[!0-9]* | 0* | 1) + echo "::error::Revision $REVISION is not 2 or more; 1 was the release's own upload" + exit 1 + ;; + esac + prerelease="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isPrerelease --jq .isPrerelease)" + if [ "$prerelease" != false ]; then + echo "::error::$TAG is not a published release" + exit 1 + fi + + revision-packages: + name: Packages + needs: revision + uses: ./.github/workflows/linux-packages.yml + with: + deb-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' + release-tag: ${{ inputs.tag }} + revision: ${{ inputs.revision }} + ppa: name: Upload to the Launchpad PPA - needs: [verify, packages] - if: needs.verify.outputs.prerelease == 'false' + needs: [verify, packages, revision-packages] + if: >- + !cancelled() && ( + (needs.packages.result == 'success' && needs.verify.outputs.prerelease == 'false') || + needs.revision-packages.result == 'success') runs-on: ubuntu-24.04 environment: linux-release timeout-minutes: 30 diff --git a/linux/build-aux/packaging/build-source.sh b/linux/build-aux/packaging/build-source.sh index 7454c0a26c..9758b9d033 100755 --- a/linux/build-aux/packaging/build-source.sh +++ b/linux/build-aux/packaging/build-source.sh @@ -6,16 +6,19 @@ # # --install-deps first installs, as root in a throwaway ubuntu:26.04, the # tools this needs. --revision counts uploads of one upstream version to -# the PPA, 1 when not given. -# Usage: build-source.sh [--install-deps] [--revision N] ... +# the PPA, 1 when not given. --debian takes the packaging from that +# directory instead of the tarball's build-aux/debian, for a packaging fix +# to a released version. +# Usage: build-source.sh [--install-deps] [--revision N] [--debian DIR] ... set -eu usage() { - echo "usage: build-source.sh [--install-deps] [--revision N] ..." >&2 + echo "usage: build-source.sh [--install-deps] [--revision N] [--debian DIR] ..." >&2 exit 2 } revision=1 +debian="" while [ $# -gt 0 ]; do case "$1" in --install-deps) @@ -29,10 +32,18 @@ while [ $# -gt 0 ]; do revision="$2" shift 2 ;; + --debian) + [ $# -ge 2 ] || usage + debian="$(cd "$2" && pwd)" + shift 2 + ;; *) break ;; esac done [ $# -ge 4 ] || usage +case "$revision" in + "" | *[!0-9]* | 0*) usage ;; +esac dist="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" vendor="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" mkdir -p "$3" @@ -56,8 +67,13 @@ src="$work/tablepro-$debian_upstream" mkdir "$src" tar -xf "$dist" -C "$src" --strip-components=1 tar -xf "$vendor" -C "$src" -cp -R "$src/build-aux/debian" "$src/debian" +cp -R "${debian:-$src/build-aux/debian}" "$src/debian" maintainer="$(sed -n 's/^Maintainer: //p' "$src/debian/control")" +if [ "$revision" = 1 ]; then + message="New upstream release. Notes: https://github.com/TableProApp/TablePro/blob/linux-v$upstream/linux/CHANGELOG.md" +else + message="Packaging update of the linux-v$upstream release, upload $revision." +fi for series in "$@"; do release="$(ubuntu-distro-info --series "$series" --release | cut -d ' ' -f 1)" @@ -67,7 +83,7 @@ for series in "$@"; do DEBEMAIL="$maintainer" dch --create --package tablepro \ --newversion "$debian_upstream-0ppa$revision~$release" \ --distribution "$series" --force-distribution --urgency medium \ - "New upstream release. Notes: https://github.com/TableProApp/TablePro/blob/linux-v$upstream/linux/CHANGELOG.md" + "$message" # -sa ships the orig tarballs with every upload; Launchpad accepts # the same files again for each series. dpkg-buildpackage -S -sa -d -nc -us -uc -I -i diff --git a/linux/build-aux/packaging/fetch-release-sources.sh b/linux/build-aux/packaging/fetch-release-sources.sh new file mode 100755 index 0000000000..1baa2a25f3 --- /dev/null +++ b/linux/build-aux/packaging/fetch-release-sources.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Downloads the source and vendor tarballs of a published Linux release into +# , checked against the release's SHA256SUMS. A packaging fix +# builds from these rather than from a new meson dist: Launchpad refuses an +# orig tarball whose bytes differ from the one it already has. +# +# --install-deps first installs curl, as root in a throwaway ubuntu:26.04. +# Usage: fetch-release-sources.sh [--install-deps] +set -eu + +usage() { + echo "usage: fetch-release-sources.sh [--install-deps] " >&2 + exit 2 +} + +if [ "${1:-}" = "--install-deps" ]; then + shift + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends ca-certificates curl +fi +[ $# -eq 3 ] || usage +repository="$1" +tag="$2" +case "$repository" in + */*/* | *[!0-9A-Za-z._/-]* | /* | */) usage ;; + */*) ;; + *) usage ;; +esac +version="${tag#linux-v}" +case "$version" in + "$tag" | "" | *[!0-9.]*) + echo "fetch-release-sources.sh: not the tag of a Linux release: $tag" >&2 + exit 2 + ;; +esac +mkdir -p "$3" +cd "$3" + +url="https://github.com/$repository/releases/download/$tag" +for file in "tablepro-$version.tar.xz" "tablepro-$version-vendor.tar.xz" SHA256SUMS; do + curl --fail --silent --show-error --location --proto '=https' --output "$file" "$url/$file" +done +# Both tarballs were just downloaded, so --ignore-missing skips only the +# release's other files. +sha256sum --check --strict --ignore-missing SHA256SUMS +rm SHA256SUMS +ls -l diff --git a/linux/docs/README.md b/linux/docs/README.md index 5d36fa5214..bd72a9312c 100644 --- a/linux/docs/README.md +++ b/linux/docs/README.md @@ -12,6 +12,7 @@ The reference for working on the Linux app. Start with [ARCHITECTURE.md](../ARCH | [storage.md](storage.md) | Which store holds which data: files, GSettings, the Secret Service and SQLite | | [testing.md](testing.md) | The test suites, the conformance and Docker tests, and the CI jobs | | [regression-tests.md](regression-tests.md) | The tests that guard each critical audit finding | +| [releasing.md](releasing.md) | The setup before the first release, a release, a packaging fix and a fix release | | [po/README.md](../po/README.md) | Marking strings for translation and keeping the template current | ## Drivers diff --git a/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md b/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md index 22c65aa095..0850aa9735 100644 --- a/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md +++ b/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md @@ -120,7 +120,9 @@ development and CI. It does not keep a manifest for Flathub. `[Unreleased]` in `CHANGELOG.md` under the version. The `release-metadata` Meson test keeps them in step. `build-source.sh` writes `debian/changelog` per series at upload, as - `X.Y.Z-0ppaN~YY.MM`. + `X.Y.Z-0ppaN~YY.MM`. A packaging fix uploads a released version again + with a higher N, from the release's own tarballs, by a manual run of + `release-linux.yml` ([releasing.md](../releasing.md#a-packaging-fix)). - A new Ubuntu series joins `UBUNTU_SERIES` in `linux-packages.yml`, and a new Fedora release arrives through COPR's "follow Fedora branching". - The .deb is `Architecture: amd64 arm64`, and the COPR project builds diff --git a/linux/docs/releasing.md b/linux/docs/releasing.md new file mode 100644 index 0000000000..fcda32caed --- /dev/null +++ b/linux/docs/releasing.md @@ -0,0 +1,57 @@ +# Releasing + +`.github/workflows/release-linux.yml` publishes a release. This page is what a maintainer does around it. [ADR 0018](decisions/0018-ppa-copr-and-flathub-later.md) says why the channels are a Launchpad PPA and a COPR project. + +## Setup before the first release + +A repository admin does these once. The workflow's header names the same secrets and variables. + +1. **Environment** `linux-release`: a required reviewer, and deployments from `linux-v*` tags and the `linux` branch only. The branch is for a [packaging fix](#a-packaging-fix). Two secrets in it: + - `LINUX_PPA_SIGNING_KEY`: the ASCII-armored OpenPGP secret key, with no passphrase, that signs the uploads. + - `LINUX_COPR_WEBHOOK`: the custom webhook URL from the COPR project's Integrations page, ending in `/tablepro/`. +2. **Variables**: `LINUX_PPA`, the PPA as `owner/name`. `LINUX_BRANCH` stays unset unless a release comes from a [maintenance branch](#a-fix-release). +3. **Tag ruleset**: add `refs/tags/linux-v*` to the ruleset that lets only admins create release tags. Today it covers `refs/tags/v*` alone, so anyone with write access could start a release. +4. **Workflows on `main`**: copy `linux-packages.yml` and `release-linux.yml` to `main`, as `build-linux.yml` already is. GitHub offers Run workflow only for a workflow on the default branch, and the arm64 build and the packaging fix both start that way. +5. **Launchpad**: a team that owns the PPA, and the PPA with a dependency on `~rust-toolchain/staging`, amd64 and arm64 turned on under its processors, and debug symbol publishing on (Launchpad's admins turn it on when the PPA's settings do not offer it). Register the signing key's public half on an account in the team. Launchpad mails a failed build to the owner of the key that signed the upload, so that account's mail has to reach someone. +6. **COPR**: a project with the Fedora 44 x86_64 and aarch64 chroots, following Fedora branching, with AppStream metadata generation on, and a package named `tablepro` with the custom source method from `build-aux/rpm/copr-sources.sh` (the command is in its header). Turn on build failure emails for the project's admins. + +## A release + +1. **The release commit**, on `linux`, 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 `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 packaging fix + +A fix to `build-aux/debian` or `build-aux/rpm` that needs no new version, such as a missing dependency. + +**PPA.** Merge the fix to `linux`, then run the workflow by hand on `linux`: + +```sh +gh workflow run release-linux.yml --ref linux -f tag=linux-v0.1.0 -f revision=2 +``` + +It downloads that release's two tarballs, checks them against its `SHA256SUMS`, builds the source package with the packaging from `linux` as `0.1.0-0ppa2~YY.MM`, builds and tests the .deb on both architectures, and uploads it once a reviewer approves. Launchpad accepts the release's own orig tarballs again, and would refuse new ones whose bytes differ. Count the revision up for each further upload of the same version. + +**COPR.** COPR builds the spec at the tag, so a fix to it goes in by hand. Raise `Release` in `build-aux/rpm/tablepro.spec` on `linux` with a `%changelog` entry, then, on Fedora with `copr-cli` signed in, build the source RPM from that spec and submit it: + +```sh +spectool --get-files --sources build-aux/rpm/tablepro.spec +rpmbuild -bs --define "_sourcedir $PWD" --define "_srcrpmdir $PWD" build-aux/rpm/tablepro.spec +copr-cli build /tablepro tablepro-0.1.0-2.*.src.rpm +``` + +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. diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 0c7116e72e..6a17f5b46d 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -368,7 +368,7 @@ GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runners. It runs on A `detect` job first checks that `linux/meson.build` exists, and every other job waits for it and is skipped without it, so the same file can sit on `main` before `linux/` does. GitHub runs a schedule only from the default branch: while `main` has no `linux/`, the Monday run there starts this workflow on the branch the `LINUX_BRANCH` repository variable names, `linux` when it is unset. PRs only merge when every job is green. -`.github/workflows/linux-packages.yml` builds the packages from the release source and vendor tarballs. For each Ubuntu series the PPA builds for, 26.04 and 26.10, it writes the Debian source package with `build-aux/packaging/build-source.sh`, then `build-deb.sh` builds the binaries from it the way Launchpad does: in a fresh container of that series with only the declared build dependencies, offline, as a user that is not root, running every Meson suite, then lintian. `build-rpm.sh` builds the .rpm in `fedora:44` with only its BuildRequires, running every Meson suite, and checks it with `rpmlint --strict`. Each package is then installed in a fresh container and started. It runs on a pull request that touches the packaging (`build-aux/debian`, `build-aux/rpm`, `build-aux/packaging`), `Cargo.toml`, `Cargo.lock` or a Meson file, and on every push to `linux` that touches `linux/`, on x86_64 only. Run by hand with `arm64` ticked, it builds, tests and installs both packages on an arm64 runner too. `.github/workflows/release-linux.yml` calls it on a `linux-v*` tag before it publishes, on both architectures ([ADR 0018](decisions/0018-ppa-copr-and-flathub-later.md)). `build-aux/packaging/build-packages.sh` runs the same steps locally. +`.github/workflows/linux-packages.yml` builds the packages from the release source and vendor tarballs. For each Ubuntu series the PPA builds for, 26.04 and 26.10, it writes the Debian source package with `build-aux/packaging/build-source.sh`, then `build-deb.sh` builds the binaries from it the way Launchpad does: in a fresh container of that series with only the declared build dependencies, offline, as a user that is not root, running every Meson suite, then lintian. `build-rpm.sh` builds the .rpm in `fedora:44` with only its BuildRequires, running every Meson suite, and checks it with `rpmlint --strict`. Each package is then installed in a fresh container and started. It runs on a pull request that touches the packaging (`build-aux/debian`, `build-aux/rpm`, `build-aux/packaging`), `Cargo.toml`, `Cargo.lock` or a Meson file, and on every push to `linux` that touches `linux/`, on x86_64 only. Run by hand with `arm64` ticked, it builds, tests and installs both packages on an arm64 runner too. `.github/workflows/release-linux.yml` calls it on a `linux-v*` tag before it publishes, on both architectures ([ADR 0018](decisions/0018-ppa-copr-and-flathub-later.md)). Run by hand on a branch with a released tag and a revision, `release-linux.yml` calls it on that release's tarballs with the branch's .deb packaging, then uploads the result to the PPA as a new revision ([releasing.md](releasing.md#a-packaging-fix)). `build-aux/packaging/build-packages.sh` runs the same steps locally. ## Coverage From 8247e9eeace14881cdaae1d091acc7aee3588d1d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:27:09 +0700 Subject: [PATCH 06/11] build(linux): recommend a keyring, suggest kinit, ship the changelog --- linux/build-aux/debian/control | 3 +++ linux/build-aux/rpm/tablepro.spec | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/linux/build-aux/debian/control b/linux/build-aux/debian/control index 43e7bc7049..72688b5a97 100644 --- a/linux/build-aux/debian/control +++ b/linux/build-aux/debian/control @@ -41,6 +41,9 @@ Depends: ${shlibs:Depends}, Recommends: ca-certificates, + gnome-keyring | kwallet6 | keepassxc, +Suggests: + krb5-user, Description: database client for GNOME TablePro connects to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse. Browse tables, edit rows in place, run SQL with syntax highlighting, and diff --git a/linux/build-aux/rpm/tablepro.spec b/linux/build-aux/rpm/tablepro.spec index b6e56be9c9..de607a433b 100644 --- a/linux/build-aux/rpm/tablepro.spec +++ b/linux/build-aux/rpm/tablepro.spec @@ -85,6 +85,11 @@ Requires: libGLESv2.so.2 # SSH tunnels run through the system OpenSSH client, and every master # passes ForkAfterAuthentication, which OpenSSH 8.7 added. Requires: openssh-clients >= 8.7 +# Saved passwords live in the Secret Service. Without a provider every +# connect asks for the password and Remember is hidden. +Recommends: (gnome-keyring or kf6-kwallet or keepassxc) +# kinit, for SQL Server sign-in with Kerberos. +Suggests: krb5-workstation %description TablePro connects to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse. @@ -136,7 +141,7 @@ export CARGO_BUILD_JOBS=%{_smp_build_ncpus} %files %license LICENSE data/licenses/THIRD-PARTY-NOTICE.md %license LICENSE.dependencies cargo-vendor.txt -%doc README.md +%doc CHANGELOG.md %{_bindir}/tablepro %{_libexecdir}/tablepro-askpass %{_datadir}/applications/app.tablepro.TablePro.desktop From 7ec40cec05b2a4670435a5f7f8a8b38ff3c07b99 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:29:23 +0700 Subject: [PATCH 07/11] docs(linux): add install, privacy and troubleshooting for users --- linux/README.md | 31 +++++++++- linux/docs/README.md | 1 + linux/docs/releasing.md | 7 +++ linux/docs/troubleshooting.md | 105 ++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 linux/docs/troubleshooting.md diff --git a/linux/README.md b/linux/README.md index bbcb230baf..95e9310fac 100644 --- a/linux/README.md +++ b/linux/README.md @@ -6,6 +6,32 @@ Native Linux database client, the Linux sibling of the macOS TablePro app. The t It connects to five engines: PostgreSQL, MySQL and MariaDB, SQL Server, ClickHouse and SQLite. Each connection session gets a window of its own. Nothing is released yet: no `linux-v*` tag exists. [ROADMAP.md](ROADMAP.md) lists what is built, what is not, and the release criteria. +## Install + +These commands work from the first release, 0.1.0, on amd64 and arm64. + +Ubuntu 26.04 and 26.10, from the Launchpad PPA: + +```bash +sudo add-apt-repository ppa:tablepro/tablepro +sudo apt install tablepro +``` + +Fedora 44 and later, from COPR: + +```bash +sudo dnf copr enable @tablepro/tablepro +sudo dnf install tablepro +``` + +apt and dnf, and Software Updater and GNOME Software with them, install the updates. Other distributions build from source ([Quickstart](#quickstart)) until Flathub has the app. + +[docs/troubleshooting.md](docs/troubleshooting.md) covers saved passwords, SSH, Kerberos, logs, crash reports and bug reports. + +## Privacy + +TablePro makes no network requests of its own: no telemetry, no update check and no crash upload. It connects to the database servers and SSH hosts you set up, and nothing else. Crash reports stay on your disk until you attach one to a bug report. Updates come from apt or dnf. + ## Stack | Layer | Pick | @@ -157,7 +183,8 @@ later. Both build offline from those tarballs, Launchpad with the Ubuntu Rust team's `rustc-1.97` and COPR with Fedora's Rust, and apt, dnf and the software centres update the installed packages ([ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md)). -The packaging is `build-aux/debian` and `build-aux/rpm/tablepro.spec`. +The packaging is `build-aux/debian` and `build-aux/rpm/tablepro.spec`, and +[docs/releasing.md](docs/releasing.md) has the release steps. To build both from the committed tree the way CI does, each step in a fresh container, with Docker or Podman: @@ -232,6 +259,8 @@ says where the realm, the KDC and the tickets come from there. | Persistence: secrets, settings, files | [docs/storage.md](docs/storage.md) | | Error handling conventions | [docs/error-handling.md](docs/error-handling.md) | | Testing conventions, suites and CI jobs | [docs/testing.md](docs/testing.md) | +| Saved passwords, SSH, Kerberos, logs and crash reports, for users | [docs/troubleshooting.md](docs/troubleshooting.md) | +| Release setup, releases and packaging fixes | [docs/releasing.md](docs/releasing.md) | | Every guide, driver page and dated audit | [docs/README.md](docs/README.md) | | Architecture decision records | [docs/decisions/](docs/decisions/) | diff --git a/linux/docs/README.md b/linux/docs/README.md index bd72a9312c..5610db4caa 100644 --- a/linux/docs/README.md +++ b/linux/docs/README.md @@ -11,6 +11,7 @@ The reference for working on the Linux app. Start with [ARCHITECTURE.md](../ARCH | [state-management.md](state-management.md) | Where the application, window, workspace and tab state lives, and the rules every view follows | | [storage.md](storage.md) | Which store holds which data: files, GSettings, the Secret Service and SQLite | | [testing.md](testing.md) | The test suites, the conformance and Docker tests, and the CI jobs | +| [troubleshooting.md](troubleshooting.md) | For users: saved passwords, SSH, Kerberos, logs, crash reports, bug reports and downgrades | | [regression-tests.md](regression-tests.md) | The tests that guard each critical audit finding | | [releasing.md](releasing.md) | The setup before the first release, a release, a packaging fix and a fix release | | [po/README.md](../po/README.md) | Marking strings for translation and keeping the template current | diff --git a/linux/docs/releasing.md b/linux/docs/releasing.md index fcda32caed..eca5389863 100644 --- a/linux/docs/releasing.md +++ b/linux/docs/releasing.md @@ -15,6 +15,8 @@ A repository admin does these once. The workflow's header names the same secrets 5. **Launchpad**: a team that owns the PPA, and the PPA with a dependency on `~rust-toolchain/staging`, amd64 and arm64 turned on under its processors, and debug symbol publishing on (Launchpad's admins turn it on when the PPA's settings do not offer it). Register the signing key's public half on an account in the team. Launchpad mails a failed build to the owner of the key that signed the upload, so that account's mail has to reach someone. 6. **COPR**: a project with the Fedora 44 x86_64 and aarch64 chroots, following Fedora branching, with AppStream metadata generation on, and a package named `tablepro` with the custom source method from `build-aux/rpm/copr-sources.sh` (the command is in its header). Turn on build failure emails for the project's admins. +[README.md](../README.md#install) gives users `ppa:tablepro/tablepro` and `@tablepro/tablepro`; change it if the PPA or the COPR project gets another name. + ## A release 1. **The release commit**, on `linux`, sets one version and one date (UTC) everywhere: @@ -22,6 +24,7 @@ A repository admin does these once. The workflow's header names the same secrets - 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 `.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. 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. @@ -55,3 +58,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. + +## Downgrades + +An older build never writes over what a newer one stored; it refuses it until the user resets it or upgrades again. [troubleshooting.md](troubleshooting.md#after-a-downgrade) tells users what they see. diff --git a/linux/docs/troubleshooting.md b/linux/docs/troubleshooting.md new file mode 100644 index 0000000000..ff441f1272 --- /dev/null +++ b/linux/docs/troubleshooting.md @@ -0,0 +1,105 @@ +# Troubleshooting + +What to check when TablePro for Linux does not behave, and what to send with a bug report. Paths are for the PPA and COPR packages. + +## Passwords + +TablePro keeps saved passwords in the Secret Service, the desktop keyring that GNOME Keyring, KWallet and KeePassXC provide. It never writes them to a file. + +### TablePro asks for the password at every connect + +No Secret Service is running, so there is nowhere to keep the password, and the prompt has no Remember switch. Check for one: + +```sh +busctl --user list | grep org.freedesktop.secrets +``` + +No line means none is installed or none can start. Install one and log in again: + +- GNOME and most other desktops: `gnome-keyring` (`sudo apt install gnome-keyring`, `sudo dnf install gnome-keyring`). +- KDE Plasma: KWallet (`kwallet6` on Ubuntu, `kf6-kwallet` on Fedora), which Plasma installs by default. +- KeePassXC: turn on Secret Service Integration in its settings, and leave it running. + +The packages recommend one of these, so a normal install has one. A minimal install or a window manager session may not. + +### TablePro asks for the password although it was saved + +The keyring is locked. The desktop asks to unlock it when TablePro reads a password; if that prompt is dismissed, TablePro asks for the password instead. A keyring that never unlocks at login usually has a different password from the account, after a password change or with automatic login. Seahorse (Passwords and Keys) or KWallet Manager can change the keyring password to the login password. + +Saved passwords are listed in Seahorse as `TablePro database password for …`, `TablePro SSH password for …` and `TablePro SSH key passphrase for …`. + +## SSH tunnels + +TablePro runs the system `ssh` and reads `~/.ssh/config`, `~/.ssh/known_hosts` and the SSH agent the same way a terminal does. When a tunnel fails, try the same hop in a terminal first: + +```sh +ssh -v -J jump.example.com user@bastion.example.com true +``` + +- **Agent sign-in fails**: TablePro uses the agent named by `SSH_AUTH_SOCK` in the desktop session. `ssh-add -l` lists the keys it holds. When it works in a terminal but not in TablePro, the desktop session has a different agent or none: `systemctl --user show-environment | grep SSH_AUTH_SOCK` shows what apps started from the desktop get. +- **Unknown host key**: TablePro shows the server's SHA256 fingerprint before it connects, even where `ssh_config` says `StrictHostKeyChecking accept-new` or `no`. Compare it with `ssh-keygen -lf` on the server's key, or with what the server's administrator gives you. +- **Changed host key**: Review Key in the connection's banner can replace the entry in `known_hosts`. Under `StrictHostKeyChecking yes` it cannot: fix `known_hosts` with `ssh-keygen -R host`, then press Retry. +- **Revoked host key**: refused, with no way past it in TablePro. + +## SQL Server with Kerberos + +Windows (Kerberos) sign-in uses the ticket `klist` shows, so get one first. `kinit` and `klist` come in `krb5-user` on Ubuntu and `krb5-workstation` on Fedora. + +```sh +kinit you@EXAMPLE.COM +klist +``` + +- **`KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`**: the server's service principal, `MSSQLSvc/:`, is built from the host and port you typed, and looked up in the default realm only (`default_realm` in `/etc/krb5.conf`). Type the host name the SPN is registered for, not an IP address or a CNAME. A server in another realm works only when your KDC answers with a referral, as Active Directory does inside a forest. +- **No ticket, or an expired one**: run `kinit` again, then Retry. + +## Logs + +Started from the desktop, TablePro writes its log to the journal: + +```sh +journalctl --user --boot | grep -i tablepro +``` + +For a more detailed log, quit TablePro (a second launch hands over to the running one and exits), then start it from a terminal: + +```sh +RUST_LOG=debug tablepro 2> tablepro.log +``` + +Read the log before you attach it. It names your hosts, databases and tables. It never holds passwords. + +## Crash reports + +TablePro writes a report of each crash to `~/.local/state/tablepro/crashes/`. Attach the newest one to the bug report, with the log from the same time. + +## Reporting a bug + +Open an issue at https://github.com/TableProApp/TablePro/issues with: + +- The text from About TablePro > Troubleshooting > Debugging Information. Copy Text puts it on the clipboard. It holds the versions and no connection details. +- Where TablePro came from (the PPA, COPR or a source build), the distribution and version, and the desktop and session (`echo $XDG_CURRENT_DESKTOP $XDG_SESSION_TYPE`). +- The log and crash report above, when there is one. + +## After a downgrade + +An older version never changes a file a newer one wrote. It refuses the file instead, until the newer version is installed again or you reset it: + +- The connection list shows "Saved connections were created by a newer version of TablePro". Reset… moves the file aside, under a name the toast shows, and starts an empty list. +- A connection's saved tabs do not reopen, and a toast says "Saved tabs could not be read" with Reset. +- The query history says it could not be read. + +Installing the newer version again brings all of it back, unless it was reset. Column widths and filters saved by the newer version are left as they are, and the older version does not save its own. + +## Where TablePro keeps its files + +| Path | What | +|---|---| +| `~/.config/tablepro/connections.json` | Saved connections, without passwords | +| `~/.config/tablepro/column_widths.json`, `filter_settings.json` | Column widths and filters per table | +| `~/.local/state/tablepro/history.db` | Query history and saved queries | +| `~/.local/state/tablepro/workspaces/` | Each connection's tabs, and the connections open at the last quit | +| `~/.local/share/tablepro/drafts/` | The text of each SQL editor tab | +| `~/.local/state/tablepro/crashes/` | Crash reports | + +Preferences are in GSettings: `gsettings list-recursively app.tablepro.TablePro` lists them. `man tablepro` has the same list. From db3e9266b43b434885e8f2163d1aed28c009f6d1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:29:23 +0700 Subject: [PATCH 08/11] docs: add the Linux app to the security policy scope --- SECURITY.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 35d0f583b9..505e8d99c3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,8 +6,8 @@ Email **hello@tablepro.app**. Do not open a public issue, discussion or pull req Include: -- What is affected and its version: the Mac app, the iPhone and iPad app, a plugin, or tablepro.app. -- The macOS or iOS version, and the database type when a driver is involved. +- What is affected and its version: the Mac app, the iPhone and iPad app, the Linux app, a plugin, or tablepro.app. +- The macOS or iOS version, or the Linux distribution and where the app came from, and the database type when a driver is involved. - Steps to reproduce, or a proof of concept. - What an attacker gains: the data or access exposed. @@ -17,9 +17,10 @@ Write in English or Vietnamese. - The Mac app. - The iPhone and iPad app. +- The Linux app, from the Launchpad PPA, the COPR project or a source build of a release. - The plugins, bundled with the app or installed from the [plugin registry](https://github.com/TableProApp/plugins). - The website, [tablepro.app](https://tablepro.app). ## Supported versions -Only the latest release of each app and plugin gets security fixes. A fix ships in a new release and is listed under **Security** in [CHANGELOG.md](CHANGELOG.md). Older releases are not patched. Check that the problem is still in the latest release before you report it. +Only the latest release of each app and plugin gets security fixes. A fix ships in a new release and is listed under **Security** in [CHANGELOG.md](CHANGELOG.md), or in `linux/CHANGELOG.md` for the Linux app. Older releases are not patched. Check that the problem is still in the latest release before you report it. From f195cc292e67bcd4e8b5f7a51d1285b85e5d6e5d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:31:30 +0700 Subject: [PATCH 09/11] chore: add Linux fields to the bug report template --- .github/ISSUE_TEMPLATE/bug_report.yml | 36 ++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6b8d6d672e..177e2acf0f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -53,7 +53,9 @@ body: id: version attributes: label: TablePro version - description: TablePro > About TablePro. Updates install in the background, so this is often not the newest. + description: >- + About TablePro shows it. On macOS, updates install in the background, so this is often not the newest. + On Linux, `tablepro --version` prints it too. placeholder: "0.74.0" validations: required: true @@ -74,7 +76,32 @@ body: id: os attributes: label: OS version & hardware - placeholder: "macOS 15.3 / Apple Silicon" + description: >- + On Linux, the distribution and its version, and the desktop and session, + which `echo $XDG_CURRENT_DESKTOP $XDG_SESSION_TYPE` prints. + placeholder: "macOS 15.3 / Apple Silicon, or Ubuntu 26.04 / GNOME, wayland" + validations: + required: false + + - type: dropdown + id: linux-install + attributes: + label: Installed from (Linux) + options: + - Ubuntu PPA + - Fedora COPR + - Built from source + validations: + required: false + + - type: textarea + id: linux-debug-info + attributes: + label: Debugging information (Linux) + description: >- + About TablePro > Troubleshooting > Debugging Information, then Copy Text. + It holds versions, not connection details. + render: text validations: required: false @@ -82,6 +109,9 @@ body: id: context attributes: label: Screenshots / Logs - description: If applicable, add screenshots, screen recordings, or crash logs. + description: >- + If applicable, add screenshots, screen recordings, or crash logs. + On Linux, `journalctl --user --boot | grep -i tablepro` shows the log, and crash reports + are in `~/.local/state/tablepro/crashes/`. Logs name your hosts and tables, so read them first. validations: required: false From 6371945c7ea4d968e14528f3f4385b0d7ae3ab64 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:45:02 +0700 Subject: [PATCH 10/11] feat(linux-storage): upgrade older JSON files and pin the v1 formats --- .../app/src/workspace/workspace_load_error.rs | 4 +- .../app/src/workspace/workspace_record.rs | 53 ++++ .../app/tests/fixtures/workspace-v1.json | 39 +++ linux/crates/storage/src/document.rs | 236 ++++++++++++++++-- linux/crates/storage/src/document_problem.rs | 2 + .../tests/fixtures/connections-v1.json | 175 +++++++++++++ linux/crates/storage/tests/stored_formats.rs | 231 +++++++++++++++++ linux/docs/storage.md | 5 +- 8 files changed, 717 insertions(+), 28 deletions(-) create mode 100644 linux/crates/app/tests/fixtures/workspace-v1.json create mode 100644 linux/crates/storage/tests/fixtures/connections-v1.json create mode 100644 linux/crates/storage/tests/stored_formats.rs diff --git a/linux/crates/app/src/workspace/workspace_load_error.rs b/linux/crates/app/src/workspace/workspace_load_error.rs index 11429de928..5c80b22114 100644 --- a/linux/crates/app/src/workspace/workspace_load_error.rs +++ b/linux/crates/app/src/workspace/workspace_load_error.rs @@ -22,7 +22,9 @@ impl From for WorkspaceLoadError { fn from(problem: DocumentProblem) -> Self { match problem.kind { DocumentProblemKind::Unreadable { detail } => Self::Io(detail), - DocumentProblemKind::Corrupt { detail, .. } => Self::Corrupt(detail), + DocumentProblemKind::Corrupt { detail, .. } | DocumentProblemKind::UpgradeFailed { detail, .. } => { + Self::Corrupt(detail) + } DocumentProblemKind::MissingVersion => Self::UnsupportedVersion { found: None, expected: WorkspaceRecord::VERSION, diff --git a/linux/crates/app/src/workspace/workspace_record.rs b/linux/crates/app/src/workspace/workspace_record.rs index 95820969ab..bcadb242b3 100644 --- a/linux/crates/app/src/workspace/workspace_record.rs +++ b/linux/crates/app/src/workspace/workspace_record.rs @@ -96,6 +96,59 @@ mod tests { assert!(!text.contains("SELECT"), "an editor record carries text: {text}"); } + /// A version 1 workspace file, the format 0.1.0 ships. Never edit the + /// fixture: a format change adds an upgrade step and updates the + /// model here. + #[test] + fn a_version_1_workspace_reads_as_it_was_written() { + let bytes = include_bytes!("../../tests/fixtures/workspace-v1.json"); + let draft = uuid::Uuid::parse_str("7d0c5b1e-9a3f-4e2d-8b6c-1f0e9d8c7b6a").expect("a uuid"); + + let decoded: WorkspaceRecord = decode_document(Path::new("/tmp/w.json"), bytes).expect("decode"); + + assert_eq!( + decoded, + WorkspaceRecord { + active_tab: Some(2), + tabs: vec![ + TabRecord::Table { + schema: Some("public".to_owned()), + table: "orders".to_owned(), + offset: 5_000, + page_size: 5_000, + sort: Some(SortRecord { + column: 2, + ascending: false, + }), + }, + TabRecord::Table { + schema: None, + table: "events".to_owned(), + offset: 0, + page_size: 1_000, + sort: None, + }, + TabRecord::Editor { + draft: DraftId::from_uuid(draft), + }, + TabRecord::Structure { + schema: Some("sales".to_owned()), + table: "customers".to_owned(), + }, + TabRecord::Structure { + schema: None, + table: "users".to_owned(), + }, + ], + } + ); + assert_eq!( + decoded.clone().clamped(), + decoded, + "the fixture holds only tabs a window can open" + ); + } + #[test] fn clamped_keeps_every_tab_however_many() { let record = WorkspaceRecord { diff --git a/linux/crates/app/tests/fixtures/workspace-v1.json b/linux/crates/app/tests/fixtures/workspace-v1.json new file mode 100644 index 0000000000..5a8764fc66 --- /dev/null +++ b/linux/crates/app/tests/fixtures/workspace-v1.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "active_tab": 2, + "tabs": [ + { + "kind": "table", + "schema": "public", + "table": "orders", + "offset": 5000, + "page_size": 5000, + "sort": { + "column": 2, + "ascending": false + } + }, + { + "kind": "table", + "schema": null, + "table": "events", + "offset": 0, + "page_size": 1000, + "sort": null + }, + { + "kind": "editor", + "draft": "7d0c5b1e-9a3f-4e2d-8b6c-1f0e9d8c7b6a" + }, + { + "kind": "structure", + "schema": "sales", + "table": "customers" + }, + { + "kind": "structure", + "schema": null, + "table": "users" + } + ] +} diff --git a/linux/crates/storage/src/document.rs b/linux/crates/storage/src/document.rs index f4e3b99fb6..55ad63551f 100644 --- a/linux/crates/storage/src/document.rs +++ b/linux/crates/storage/src/document.rs @@ -1,15 +1,28 @@ +use std::path::Path; + use serde::Serialize; use serde::de::DeserializeOwned; +use serde_json::Value; use crate::document_problem::{DocumentProblem, DocumentProblemKind}; +/// One step of a document's upgrade chain: a file at one version +/// rewritten into the shape of the next. Its `version` field is left for +/// the step to ignore, since the result is read without it. An `Err` +/// says what the step could not read. Object keys come out sorted, so a +/// document keeps anything ordered in arrays. +pub type Upgrade = fn(Value) -> Result; + /// A JSON document the app owns, carrying its own version. -/// -/// There are no upgraders: the switch is a clean break, so an older -/// version is refused rather than migrated. pub trait VersionedDocument: Serialize + DeserializeOwned { const KIND: &'static str; const VERSION: u32; + /// The steps that bring an older file up to `VERSION`, oldest first: + /// the last turns `VERSION - 1` into `VERSION`, the one before it + /// `VERSION - 2` into `VERSION - 1`. A file older than the first step + /// is refused. A user can skip any number of releases, so a format + /// bump appends a step and never drops one. + const UPGRADES: &'static [Upgrade] = &[]; } #[derive(serde::Deserialize)] @@ -19,17 +32,8 @@ struct VersionProbe { /// Read the version first, so a file from a newer build is reported as /// such instead of as a parse failure listing every unknown field. -pub fn decode_document(path: &std::path::Path, bytes: &[u8]) -> Result { - let probe: VersionProbe = serde_json::from_slice(bytes).map_err(|error| { - DocumentProblem::new( - path, - DocumentProblemKind::Corrupt { - detail: error.to_string(), - line: error.line(), - column: error.column(), - }, - ) - })?; +pub fn decode_document(path: &Path, bytes: &[u8]) -> Result { + let probe: VersionProbe = serde_json::from_slice(bytes).map_err(|error| corrupt(path, &error))?; let Some(version) = probe.version else { return Err(DocumentProblem::new(path, DocumentProblemKind::MissingVersion)); }; @@ -43,24 +47,54 @@ pub fn decode_document(path: &std::path::Path, bytes: &[u8 )); } if version < D::VERSION { - return Err(DocumentProblem::new( + return upgrade(path, version, bytes); + } + serde_json::from_slice(bytes).map_err(|error| corrupt(path, &error)) +} + +/// Runs the file through the steps it is behind, then reads the result +/// as the current version. The file on disk is left as it is: the next +/// save writes the current version. +fn upgrade(path: &Path, found: u32, bytes: &[u8]) -> Result { + let unsupported = || { + DocumentProblem::new( path, DocumentProblemKind::UnsupportedVersion { - found: version, + found, expected: D::VERSION, }, - )); - } - serde_json::from_slice(bytes).map_err(|error| { + ) + }; + let behind = usize::try_from(D::VERSION - found).map_err(|_| unsupported())?; + let Some(first) = D::UPGRADES.len().checked_sub(behind) else { + return Err(unsupported()); + }; + let failed = |detail: String| { DocumentProblem::new( path, - DocumentProblemKind::Corrupt { - detail: error.to_string(), - line: error.line(), - column: error.column(), + DocumentProblemKind::UpgradeFailed { + found, + expected: D::VERSION, + detail, }, ) - }) + }; + let mut document: Value = serde_json::from_slice(bytes).map_err(|error| corrupt(path, &error))?; + for step in D::UPGRADES.iter().skip(first) { + document = step(document).map_err(failed)?; + } + serde_json::from_value(document).map_err(|error| failed(error.to_string())) +} + +fn corrupt(path: &Path, error: &serde_json::Error) -> DocumentProblem { + DocumentProblem::new( + path, + DocumentProblemKind::Corrupt { + detail: error.to_string(), + line: error.line(), + column: error.column(), + }, + ) } pub fn encode_document(document: &D) -> Result, serde_json::Error> { @@ -79,8 +113,6 @@ pub fn encode_document(document: &D) -> Result, se #[cfg(test)] mod tests { - use std::path::Path; - use super::*; #[derive(Debug, PartialEq, Serialize, serde::Deserialize)] @@ -160,4 +192,156 @@ mod tests { "{problem:?}" ); } + + /// Version 1 held `names`, version 2 renamed it to `items`, and + /// version 3 made each item an object. + #[derive(Debug, PartialEq, Serialize, serde::Deserialize)] + struct Shelf { + items: Vec, + } + + #[derive(Debug, PartialEq, Serialize, serde::Deserialize)] + struct ShelfItem { + name: String, + } + + #[derive(serde::Deserialize)] + struct ShelfV1 { + names: Vec, + } + + /// Its items stay untyped, so a bad one reaches the final read. + #[derive(Serialize, serde::Deserialize)] + struct ShelfV2 { + items: Vec, + } + + #[derive(Serialize)] + struct ShelfV3 { + items: Vec, + } + + #[derive(Serialize)] + struct ShelfItemV3 { + name: Value, + } + + fn rename_names_to_items(document: Value) -> Result { + let shelf: ShelfV1 = serde_json::from_value(document).map_err(|error| error.to_string())?; + let items = shelf.names.into_iter().map(Value::String).collect(); + serde_json::to_value(ShelfV2 { items }).map_err(|error| error.to_string()) + } + + fn wrap_items(document: Value) -> Result { + let shelf: ShelfV2 = serde_json::from_value(document).map_err(|error| error.to_string())?; + let items = shelf.items.into_iter().map(|name| ShelfItemV3 { name }).collect(); + serde_json::to_value(ShelfV3 { items }).map_err(|error| error.to_string()) + } + + impl VersionedDocument for Shelf { + const KIND: &'static str = "shelf"; + const VERSION: u32 = 3; + const UPGRADES: &'static [Upgrade] = &[rename_names_to_items, wrap_items]; + } + + fn shelf(names: &[&str]) -> Shelf { + Shelf { + items: names + .iter() + .map(|name| ShelfItem { + name: (*name).to_owned(), + }) + .collect(), + } + } + + fn decode_shelf(bytes: &str) -> Result { + decode_document::(Path::new("/tmp/shelf.json"), bytes.as_bytes()) + } + + #[test] + fn a_file_two_versions_behind_runs_both_steps_in_order() { + let decoded = decode_shelf(r#"{"version": 1, "names": ["a", "b"]}"#).expect("upgraded"); + + assert_eq!(decoded, shelf(&["a", "b"])); + } + + #[test] + fn a_file_one_version_behind_runs_only_the_last_step() { + let decoded = decode_shelf(r#"{"version": 2, "items": ["a"]}"#).expect("upgraded"); + + assert_eq!(decoded, shelf(&["a"])); + } + + #[test] + fn the_current_version_runs_no_step() { + let decoded = decode_shelf(r#"{"version": 3, "items": [{"name": "a"}]}"#).expect("read"); + + assert_eq!(decoded, shelf(&["a"])); + } + + #[test] + fn a_file_older_than_the_first_step_is_unsupported() { + let problem = decode_shelf(r#"{"version": 0, "names": []}"#).expect_err("too old"); + + assert_eq!( + problem.kind, + DocumentProblemKind::UnsupportedVersion { found: 0, expected: 3 } + ); + } + + #[test] + fn a_chain_missing_its_early_steps_refuses_those_versions() { + #[derive(Debug, Serialize, serde::Deserialize)] + struct Short { + items: Vec, + } + + impl VersionedDocument for Short { + const KIND: &'static str = "short"; + const VERSION: u32 = 3; + const UPGRADES: &'static [Upgrade] = &[wrap_items]; + } + + let path = Path::new("/tmp/short.json"); + let problem = decode_document::(path, br#"{"version": 1, "names": ["a"]}"#).expect_err("v1"); + let upgraded = decode_document::(path, br#"{"version": 2, "items": ["a"]}"#).expect("v2"); + + assert_eq!( + problem.kind, + DocumentProblemKind::UnsupportedVersion { found: 1, expected: 3 } + ); + assert_eq!(upgraded.items.len(), 1); + } + + #[test] + fn a_step_that_cannot_read_the_body_fails_the_upgrade() { + let problem = decode_shelf(r#"{"version": 1, "items": []}"#).expect_err("no names"); + + match problem.kind { + DocumentProblemKind::UpgradeFailed { + found: 1, + expected: 3, + detail, + } => assert!(detail.contains("names"), "{detail}"), + other => panic!("expected UpgradeFailed, got {other:?}"), + } + } + + #[test] + fn an_upgraded_body_the_current_version_cannot_read_fails_the_upgrade() { + let problem = decode_shelf(r#"{"version": 2, "items": [5]}"#).expect_err("a number for a name"); + + assert!( + matches!( + problem.kind, + DocumentProblemKind::UpgradeFailed { + found: 2, + expected: 3, + .. + } + ), + "{problem:?}" + ); + } } diff --git a/linux/crates/storage/src/document_problem.rs b/linux/crates/storage/src/document_problem.rs index 20ff16249d..1c4db9dbf0 100644 --- a/linux/crates/storage/src/document_problem.rs +++ b/linux/crates/storage/src/document_problem.rs @@ -24,6 +24,8 @@ pub enum DocumentProblemKind { UnsupportedVersion { found: u32, expected: u32 }, #[error("the file is version {found}, which is newer than this version of TablePro understands")] NewerVersion { found: u32, expected: u32 }, + #[error("the file is version {found} and could not be brought up to version {expected}: {detail}")] + UpgradeFailed { found: u32, expected: u32, detail: String }, } impl DocumentProblem { diff --git a/linux/crates/storage/tests/fixtures/connections-v1.json b/linux/crates/storage/tests/fixtures/connections-v1.json new file mode 100644 index 0000000000..240e04e628 --- /dev/null +++ b/linux/crates/storage/tests/fixtures/connections-v1.json @@ -0,0 +1,175 @@ +{ + "version": 1, + "connections": [ + { + "id": "0b6a8f3e-4d1c-4c5e-9a57-1f2d3c4b5a61", + "name": "Local Postgres", + "driver_id": "postgres", + "host": "localhost", + "port": 5432, + "database": "app", + "username": "tablepro", + "tls": { + "mode": "disable", + "ca_file": null, + "client_identity": null, + "server_name": null + }, + "read_only": false, + "auth_mode": "password" + }, + { + "id": "6f1e2d3c-4b5a-4968-8776-655443322110", + "name": "Production orders", + "driver_id": "mysql", + "host": "db.internal.example", + "port": 3306, + "database": "orders", + "username": "reporting", + "tls": { + "mode": "verify_full", + "ca_file": "/home/me/certs/ca.pem", + "client_identity": { + "certificate": "/home/me/certs/client.pem", + "key": "/home/me/certs/client.key" + }, + "server_name": "mysql.example.com" + }, + "read_only": true, + "auth_mode": "password", + "ssh": { + "host": "bastion", + "port": 2222, + "username": "deploy", + "jump_hosts": [ + "jump1.example.com", + "ops@jump2.example.com:2200" + ], + "auth": { + "kind": "private_key", + "path": "/home/me/.ssh/id_ed25519", + "has_passphrase": true + } + }, + "last_opened_at": "2026-10-01T08:30:00Z", + "color": "red", + "group": "Production" + }, + { + "id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d", + "name": "Warehouse", + "driver_id": "mssql", + "host": "sql.corp.example", + "port": 1433, + "database": "warehouse", + "username": "", + "tls": { + "mode": "require", + "ca_file": null, + "client_identity": null, + "server_name": null + }, + "read_only": false, + "auth_mode": "kerberos", + "ssh": { + "host": "gateway.corp.example", + "auth": { + "kind": "agent" + } + }, + "group": "Production" + }, + { + "id": "9c8b7a6f-5e4d-4c3b-a2a1-0f9e8d7c6b5a", + "name": "Events", + "driver_id": "clickhouse", + "host": "clickhouse.example.com", + "port": 8443, + "database": "default", + "username": "analyst", + "tls": { + "mode": "verify_full", + "ca_file": null, + "client_identity": null, + "server_name": null + }, + "read_only": false, + "auth_mode": "password", + "ssh": { + "host": "10.0.0.5", + "username": "admin", + "auth": { + "kind": "private_key" + } + }, + "color": "teal" + }, + { + "id": "3d4e5f60-7182-4934-a5b6-c7d8e9f0a1b2", + "name": "Staging", + "driver_id": "postgres", + "host": "staging.example.com", + "port": 5432, + "database": "app", + "username": "app", + "tls": { + "mode": "require", + "ca_file": null, + "client_identity": null, + "server_name": null + }, + "read_only": false, + "auth_mode": "password", + "ssh": { + "host": "staging-bastion", + "auth": { + "kind": "keyboard_interactive" + } + }, + "last_opened_at": "2026-09-30T17:05:42.123Z" + }, + { + "id": "c0ffee00-1234-4abc-8def-0123456789ab", + "name": "app.sqlite3", + "driver_id": "sqlite", + "host": "", + "port": 0, + "database": "/home/me/projects/app/app.sqlite3", + "username": "", + "tls": { + "mode": "disable", + "ca_file": null, + "client_identity": null, + "server_name": null + }, + "read_only": false, + "auth_mode": "password" + }, + { + "id": "5a5b5c5d-6e6f-4071-8283-949596979899", + "name": "MariaDB legacy", + "driver_id": "mysql", + "host": "127.0.0.1", + "port": 3307, + "database": "", + "username": "root", + "tls": { + "mode": "require", + "ca_file": null, + "client_identity": null, + "server_name": null + }, + "read_only": false, + "auth_mode": "password", + "ssh": { + "host": "legacy.example.com", + "port": 22, + "username": "root", + "auth": { + "kind": "password" + } + }, + "color": "slate" + } + ] +} diff --git a/linux/crates/storage/tests/stored_formats.rs b/linux/crates/storage/tests/stored_formats.rs new file mode 100644 index 0000000000..3001cb8f1e --- /dev/null +++ b/linux/crates/storage/tests/stored_formats.rs @@ -0,0 +1,231 @@ +//! Every format a released build wrote must still read. +//! +//! The fixtures are files as a release wrote them, and a later build +//! reads them as an upgrading user's would be read. Never edit one: a +//! format change appends an upgrade step, adds a fixture for the new +//! version, and updates the expected model here. + +use std::error::Error; +use std::path::Path; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use tablepro_core::{AuthMode, ClientIdentity, TlsConfig, TlsMode}; +use tablepro_storage::{ + ConnectionColor, ConnectionGroupName, ConnectionStore, SavedConnection, SavedSshAuth, SavedSshConfig, StoragePaths, +}; +use uuid::Uuid; + +type TestResult = Result<(), Box>; + +const CONNECTIONS_V1: &[u8] = include_bytes!("fixtures/connections-v1.json"); + +fn connections_from(root: &Path, bytes: &[u8]) -> Result, Box> { + let paths = StoragePaths::under(root, "tablepro", "app.tablepro.TablePro"); + let file = paths.connections_file(); + if let Some(dir) = file.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&file, bytes)?; + Ok(ConnectionStore::new(&paths).load_blocking()?) +} + +fn tls(mode: TlsMode) -> TlsConfig { + TlsConfig { + mode, + ca_file: None, + client_identity: None, + server_name: None, + } +} + +fn opened(at: &str) -> Result, Box> { + Ok(DateTime::parse_from_rfc3339(at)?.with_timezone(&Utc)) +} + +/// A connection with every optional field at its default, for the +/// model below to fill in. +fn saved(id: &str, name: &str, driver_id: &str, host: &str, port: u16) -> Result> { + Ok(SavedConnection { + id: Uuid::parse_str(id)?, + name: name.to_owned(), + driver_id: driver_id.to_owned(), + host: host.to_owned(), + port, + database: String::new(), + username: String::new(), + tls: tls(TlsMode::Disable), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + }) +} + +fn ssh(host: &str, auth: SavedSshAuth) -> SavedSshConfig { + SavedSshConfig { + host: host.to_owned(), + port: None, + username: None, + jump_hosts: Vec::new(), + auth, + } +} + +fn connections_v1_model() -> Result, Box> { + let production = Some(ConnectionGroupName::new("Production")?); + Ok(vec![ + SavedConnection { + database: "app".to_owned(), + username: "tablepro".to_owned(), + ..saved( + "0b6a8f3e-4d1c-4c5e-9a57-1f2d3c4b5a61", + "Local Postgres", + "postgres", + "localhost", + 5432, + )? + }, + production_orders(production.clone())?, + SavedConnection { + database: "warehouse".to_owned(), + tls: tls(TlsMode::Require), + auth_mode: AuthMode::Kerberos, + ssh: Some(ssh("gateway.corp.example", SavedSshAuth::Agent)), + group: production, + ..saved( + "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d", + "Warehouse", + "mssql", + "sql.corp.example", + 1433, + )? + }, + events()?, + SavedConnection { + database: "app".to_owned(), + username: "app".to_owned(), + tls: tls(TlsMode::Require), + ssh: Some(ssh("staging-bastion", SavedSshAuth::KeyboardInteractive)), + last_opened_at: Some(opened("2026-09-30T17:05:42.123Z")?), + ..saved( + "3d4e5f60-7182-4934-a5b6-c7d8e9f0a1b2", + "Staging", + "postgres", + "staging.example.com", + 5432, + )? + }, + SavedConnection { + database: "/home/me/projects/app/app.sqlite3".to_owned(), + ..saved("c0ffee00-1234-4abc-8def-0123456789ab", "app.sqlite3", "sqlite", "", 0)? + }, + SavedConnection { + username: "root".to_owned(), + tls: tls(TlsMode::Require), + ssh: Some(SavedSshConfig { + port: Some(22), + username: Some("root".to_owned()), + ..ssh("legacy.example.com", SavedSshAuth::Password) + }), + color: Some(ConnectionColor::Slate), + ..saved( + "5a5b5c5d-6e6f-4071-8283-949596979899", + "MariaDB legacy", + "mysql", + "127.0.0.1", + 3307, + )? + }, + ]) +} + +fn production_orders(group: Option) -> Result> { + Ok(SavedConnection { + database: "orders".to_owned(), + username: "reporting".to_owned(), + tls: TlsConfig { + mode: TlsMode::VerifyFull, + ca_file: Some("/home/me/certs/ca.pem".into()), + client_identity: Some(ClientIdentity { + certificate: "/home/me/certs/client.pem".into(), + key: "/home/me/certs/client.key".into(), + }), + server_name: Some("mysql.example.com".to_owned()), + }, + read_only: true, + ssh: Some(SavedSshConfig { + port: Some(2222), + username: Some("deploy".to_owned()), + jump_hosts: vec!["jump1.example.com".to_owned(), "ops@jump2.example.com:2200".to_owned()], + ..ssh( + "bastion", + SavedSshAuth::PrivateKey { + path: Some("/home/me/.ssh/id_ed25519".into()), + has_passphrase: true, + }, + ) + }), + last_opened_at: Some(opened("2026-10-01T08:30:00Z")?), + color: Some(ConnectionColor::Red), + group, + ..saved( + "6f1e2d3c-4b5a-4968-8776-655443322110", + "Production orders", + "mysql", + "db.internal.example", + 3306, + )? + }) +} + +fn events() -> Result> { + Ok(SavedConnection { + database: "default".to_owned(), + username: "analyst".to_owned(), + tls: tls(TlsMode::VerifyFull), + ssh: Some(SavedSshConfig { + username: Some("admin".to_owned()), + ..ssh( + "10.0.0.5", + SavedSshAuth::PrivateKey { + path: None, + has_passphrase: false, + }, + ) + }), + color: Some(ConnectionColor::Teal), + ..saved( + "9c8b7a6f-5e4d-4c3b-a2a1-0f9e8d7c6b5a", + "Events", + "clickhouse", + "clickhouse.example.com", + 8443, + )? + }) +} + +#[test] +fn a_version_1_connection_list_reads_as_it_was_written() -> TestResult { + let root = tempfile::tempdir()?; + + let read = connections_from(root.path(), CONNECTIONS_V1)?; + + assert_eq!(read.as_ref(), connections_v1_model()?.as_slice()); + Ok(()) +} + +#[test] +fn a_version_1_connection_list_imports_as_it_was_written() -> TestResult { + let root = tempfile::tempdir()?; + let paths = StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro"); + let store = ConnectionStore::new(&paths); + + let report = store.import_blocking(Path::new("/tmp/connections-v1.json"), CONNECTIONS_V1)?; + + assert_eq!(report.total(), connections_v1_model()?.len()); + assert_eq!(store.load_blocking()?.as_ref(), connections_v1_model()?.as_slice()); + Ok(()) +} diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 1ac3863914..b753da20c7 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -307,6 +307,9 @@ A keyring failure is a `SecretError` from `tablepro-core`, not a `StorageError`; ## Changing a format -- A JSON document is a `VersionedDocument` with a `version` field. A new shape bumps its `VERSION`, and there are no upgraders: a file from another version is never migrated, and one from a newer build is never written over. The connection list and a workspace wait for the user's Reset, which moves the file aside; the smaller state files move an unusable file aside themselves. +- A JSON document is a `VersionedDocument` with a `version` field. A new shape bumps its `VERSION` and appends a step to its `UPGRADES`, which turns a file of the version before into the new shape. `decode_document` runs an older file through every step it is behind, oldest first, so a user who skipped releases keeps their data. A step that shipped is never changed or dropped. +- The upgrade happens in memory. The file stays as it was until the next save writes the current version. +- A file older than the first step, one a step cannot read, or one from a newer build is never written over. The connection list and a workspace wait for the user's Reset, which moves the file aside; the smaller state files move an unusable file aside themselves. +- Each released version of the connection list and the workspace file has a golden file that a test reads into the model it was written from: `crates/storage/tests/fixtures/connections-v1.json` and `crates/app/tests/fixtures/workspace-v1.json`. A committed fixture is never edited. A format change adds the new version's fixture and updates the expected models of the old ones, which now read through the upgrade. - The history database moves forward by sqlx migration: a new timestamped file under `crates/storage/migrations/` with its own fixture test, never an edit to an applied one. - Update this page, and add a fragment to the Linux changelog. From 3490b9c828738aa4eb5c749a13760b0a1172b6c8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:45:06 +0700 Subject: [PATCH 11/11] feat(linux): add --version, and files and bugs to the man page --- linux/build-aux/packaging/check_packaging.py | 34 ++++++++++--- linux/build-aux/tests/test_check_packaging.py | 29 +++++++++++ linux/crates/app/src/application/mod.rs | 3 ++ linux/crates/app/tests/startup.rs | 31 +++++++++++ linux/data/tablepro.1 | 51 ++++++++++++++++++- .../0018-ppa-copr-and-flathub-later.md | 8 +-- 6 files changed, 142 insertions(+), 14 deletions(-) diff --git a/linux/build-aux/packaging/check_packaging.py b/linux/build-aux/packaging/check_packaging.py index f978237edd..6621efffca 100644 --- a/linux/build-aux/packaging/check_packaging.py +++ b/linux/build-aux/packaging/check_packaging.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 -# Checks that the version Meson builds is the one the metainfo releases and -# the RPM spec package, that the spec collects 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. +# Checks that the version Meson builds is the one the metainfo releases, the +# RPM spec packages and the man page names, that the spec collects +# 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. # # Once CHANGELOG.md has a heading for the version, the release commit has -# set its dates, and the heading and the spec's %changelog entry must -# carry the metainfo's release date. +# set its dates, and the heading, the spec's %changelog entry and the man +# page must carry the metainfo's release date. import argparse import re @@ -25,6 +26,7 @@ CARGO = Path("Cargo.toml") CONTROL = Path("build-aux/debian/control") RULES = Path("build-aux/debian/rules") +MAN = Path("data/tablepro.1") # X.Y.Z, or a pre-release such as X.Y.Z-rc.1 with no '-' after the first, # so the package version below stays a plain substitution. @@ -34,6 +36,7 @@ SPEC_RELEASE = re.compile(r"^Release:\s*(\S+)\s*$", re.MULTILINE) SPEC_ENTRY = re.compile(r"^\*\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{4})\s.*\s-\s(\S+)\s*$", re.MULTILINE) FIRST_PACKAGE_RELEASE = "1%{?dist}" +MAN_TITLE = re.compile(r'^\.TH\s+\S+\s+\S+\s+"?([^"\s]*)"?\s+"TablePro ([^"]*)"', re.MULTILINE) FIND_LANG = re.compile(r"^%find_lang\b", re.MULTILINE) CONTROL_TOOLCHAIN = re.compile(r"\b(cargo|rustc)-(\d+\.\d+)\b") RULES_TOOLCHAIN = re.compile(r"/usr/lib/rust-(\d+\.\d+)/bin\b") @@ -101,8 +104,17 @@ def spec_entry_date(spec, release): return None +def man_title(root): + """The date and version on the man page's .TH line, or None without one.""" + match = MAN_TITLE.search((root / MAN).read_text(encoding="utf-8")) + return (match.group(1), match.group(2)) if match else None + + def release_date_problems(root, version, spec, released, spec_opens_with_version): found = [] + title = man_title(root) + if title is not None and title[0] != released: + found.append(f"{MAN} is dated {title[0] or 'nothing'}, the metainfo {released}") heading = release_heading_date(root / CHANGELOG, version) if heading == "": found.append(f"{CHANGELOG} has no YYYY-MM-DD date on ## [{version}]") @@ -197,6 +209,12 @@ def problems(root, version, tag=None, tag_date=None): f"for a new version" ) + title = man_title(root) + if title is None: + found.append(f'{MAN} has no .TH line naming "TablePro X.Y.Z"') + elif title[1] != version: + found.append(f"{MAN} names version {title[1] or 'nothing'}, not {version}") + if bool(linguas(root / LINGUAS)) != bool(FIND_LANG.search(spec)): found.append( f"{SPEC} must run %find_lang and list %{{name}}.lang exactly when {LINGUAS} names a language" diff --git a/linux/build-aux/tests/test_check_packaging.py b/linux/build-aux/tests/test_check_packaging.py index 92f6530a9d..ad5ed6df09 100644 --- a/linux/build-aux/tests/test_check_packaging.py +++ b/linux/build-aux/tests/test_check_packaging.py @@ -52,6 +52,11 @@ RULES = """export PATH := {rust_bin}:$(PATH) """ +MAN = """.TH TABLEPRO 1 {man_date} "TablePro {man_version}" "User Commands" +.SH NAME +tablepro \\- work with your databases +""" + NOTES = """# Changelog ## [Unreleased] @@ -87,6 +92,8 @@ def tree( release="1%{?dist}", spec_date="Wed Oct 14 2026", later="", + man_date="2026-10-14", + man_version="1.2.0", ): workdir = tempfile.TemporaryDirectory() self.addCleanup(workdir.cleanup) @@ -109,6 +116,7 @@ def tree( (check_packaging.CARGO, CARGO.format(rust_version=rust_version)), (check_packaging.CONTROL, CONTROL.format(toolchain=toolchain)), (check_packaging.RULES, RULES.format(rust_bin=rust_bin)), + (check_packaging.MAN, MAN.format(man_date=man_date, man_version=man_version)), ): (root / path).parent.mkdir(parents=True, exist_ok=True) (root / path).write_text(content, encoding="utf-8") @@ -147,6 +155,7 @@ def test_a_release_dated_before_the_tag_fails(self): releases='', notes=NOTES.replace("## [1.2.0] - 2026-10-14", "## [1.2.0] - 2026-10-01"), spec_date="Thu Oct 01 2026", + man_date="2026-10-01", ) found = check_packaging.problems(root, "1.2.0", "linux-v1.2.0", "2026-10-14") @@ -202,6 +211,7 @@ def test_a_pre_release_tag_passes_when_the_spec_packages_the_tilde_version(self) 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", ) self.assertEqual(check_packaging.problems(root, "1.2.0-rc.1", "linux-v1.2.0-rc.1", "2026-10-14"), []) @@ -211,6 +221,7 @@ def test_a_spec_with_the_upstream_pre_release_fails(self): releases='', version="1.2.0-rc.1", changelog="1.2.0~rc.1", + man_version="1.2.0-rc.1", ) found = check_packaging.problems(root, "1.2.0-rc.1") @@ -303,6 +314,24 @@ def test_a_release_tag_needs_the_first_package_release(self): self.assertEqual(len(found), 1, found) self.assertIn("has Release 2%{?dist}, not 1%{?dist}", found[0]) + def test_the_man_page_names_the_built_version(self): + found = check_packaging.problems(self.tree(man_version="1.1.0"), "1.2.0") + + self.assertEqual(found, ["data/tablepro.1 names version 1.1.0, not 1.2.0"]) + + def test_a_man_page_without_a_title_line_fails(self): + root = self.tree() + (root / check_packaging.MAN).write_text(".SH NAME\n", encoding="utf-8") + + found = check_packaging.problems(root, "1.2.0") + + self.assertEqual(found, ['data/tablepro.1 has no .TH line naming "TablePro X.Y.Z"']) + + def test_the_man_page_carries_the_release_date(self): + found = check_packaging.problems(self.tree(man_date="2026-09-01"), "1.2.0") + + self.assertEqual(found, ["data/tablepro.1 is dated 2026-09-01, the metainfo 2026-10-14"]) + def test_main_writes_the_release_notes(self): root = self.tree() notes = root / "notes.md" diff --git a/linux/crates/app/src/application/mod.rs b/linux/crates/app/src/application/mod.rs index 79cd75c8d5..2b2435925b 100644 --- a/linux/crates/app/src/application/mod.rs +++ b/linux/crates/app/src/application/mod.rs @@ -65,10 +65,13 @@ glib::wrapper! { impl TableProApplication { pub fn new(services: AppServices) -> Self { + // GApplication answers --version from this, before it registers + // on the bus, so the running instance never sees the call. let app: Self = glib::Object::builder() .property("application-id", config::APP_ID) .property("resource-base-path", config::RESOURCE_BASE_PATH) .property("flags", gio::ApplicationFlags::HANDLES_OPEN) + .property("version", config::VERSION) .build(); services.bind_application(&app); // Only `new` sets it, and it has just built the object. diff --git a/linux/crates/app/tests/startup.rs b/linux/crates/app/tests/startup.rs index 925622885a..97fc0dd72c 100644 --- a/linux/crates/app/tests/startup.rs +++ b/linux/crates/app/tests/startup.rs @@ -27,6 +27,9 @@ fn main() -> ExitCode { if help_lists_the_gapplication_options(&schemas, &sandbox).is_err() { return ExitCode::FAILURE; } + if version_prints_the_version(&schemas, &sandbox).is_err() { + return ExitCode::FAILURE; + } if a_missing_schema_is_reported(&sandbox).is_err() { return ExitCode::FAILURE; } @@ -166,6 +169,34 @@ fn help_lists_the_gapplication_options(schemas: &Path, sandbox: &Sandbox<'_>) -> Ok(()) } +/// `--version` prints the version and exits rather than opening a +/// window. +fn version_prints_the_version(schemas: &Path, sandbox: &Sandbox<'_>) -> Result<(), ()> { + let output = run( + tablepro(sandbox).arg("--version").env("GSETTINGS_SCHEMA_DIR", schemas), + "--version", + )?; + + if !output.status.success() { + eprintln!( + "tablepro --version exited with {}:\n{}", + output.status, + combined(&output) + ); + return Err(()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.contains(tablepro_app::config::VERSION) { + eprintln!( + "tablepro --version did not print {}:\n{}", + tablepro_app::config::VERSION, + combined(&output) + ); + return Err(()); + } + Ok(()) +} + /// Without the schema installed the app must name what is missing and /// exit, not abort inside GIO with nothing to go on. fn a_missing_schema_is_reported(sandbox: &Sandbox<'_>) -> Result<(), ()> { diff --git a/linux/data/tablepro.1 b/linux/data/tablepro.1 index f25c235540..c52743f1d8 100644 --- a/linux/data/tablepro.1 +++ b/linux/data/tablepro.1 @@ -1,4 +1,4 @@ -.TH TABLEPRO 1 "" "TablePro" "User Commands" +.TH TABLEPRO 1 2026-04-26 "TablePro 0.1.0" "User Commands" .SH NAME tablepro \- work with your databases .SH SYNOPSIS @@ -19,6 +19,9 @@ A running TablePro opens the files itself and the new launch exits. .BR \-n ", " \-\-new\-window Open a new window, in the running TablePro when there is one. .TP +.B \-\-version +Print the version and exit. +.TP .BR \-h ", " \-\-help Show the options and exit. .TP @@ -33,8 +36,52 @@ Which messages are logged to standard error, as a tracing filter such as .B TABLEPRO_LOG_FORMAT .B json logs one JSON object per line. +.SH FILES +.TP +.I $XDG_CONFIG_HOME/tablepro/connections.json +The saved connections: hosts, ports, user names and SSH settings. +Passwords are not in it; they are in the Secret Service keyring. +.TP +.I $XDG_CONFIG_HOME/tablepro/column_widths.json +.TQ +.I $XDG_CONFIG_HOME/tablepro/filter_settings.json +Column widths and filters, kept per table. +.TP +.I $XDG_STATE_HOME/tablepro/history.db +The query history and the saved queries, in a SQLite database. +.TP +.I $XDG_STATE_HOME/tablepro/workspaces/ +The tabs of each connection, and which connections were open at the last quit. +.TP +.I $XDG_DATA_HOME/tablepro/drafts/ +The text of each SQL editor tab. +.TP +.I $XDG_STATE_HOME/tablepro/crashes/ +A report of each crash. +.TP +.I $XDG_RUNTIME_DIR/app/app.tablepro.TablePro/ssh/ +The control sockets of the open SSH tunnels. +.PP +When unset, +.B XDG_CONFIG_HOME +is +.IR ~/.config , +.B XDG_STATE_HOME +is +.I ~/.local/state +and +.B XDG_DATA_HOME +is +.IR ~/.local/share . +Preferences are kept in GSettings, under the schema +.BR app.tablepro.TablePro . +.SH BUGS +Report bugs at https://github.com/TableProApp/TablePro/issues. +Include the text that Copy Text puts on the clipboard in +About TablePro > Troubleshooting > Debugging Information. .SH SEE ALSO .BR ssh (1), -.BR ssh_config (5) +.BR ssh_config (5), +.BR gsettings (1) .PP https://docs.tablepro.app diff --git a/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md b/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md index 0850aa9735..e76c2bbca4 100644 --- a/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md +++ b/linux/docs/decisions/0018-ppa-copr-and-flathub-later.md @@ -116,10 +116,10 @@ development and CI. It does not keep a manifest for Flathub. - An upstream pre-release such as `0.2.0-rc.1` packages as `0.2.0~rc.1`, as Debian and Fedora version rules expect. - Each release commit bumps `meson.build`, the newest metainfo - ``, the spec's `Version` and `%changelog`, and moves - `[Unreleased]` in `CHANGELOG.md` under the version. The - `release-metadata` Meson test keeps them in step. `build-source.sh` - writes `debian/changelog` per series at upload, as + ``, the spec's `Version` and `%changelog`, and the man page's + `.TH` line, and moves `[Unreleased]` in `CHANGELOG.md` under the + version. The `release-metadata` Meson test keeps them in step. + `build-source.sh` writes `debian/changelog` per series at upload, as `X.Y.Z-0ppaN~YY.MM`. A packaging fix uploads a released version again with a higher N, from the release's own tarballs, by a manual run of `release-linux.yml` ([releasing.md](../releasing.md#a-packaging-fix)).