hearthboard 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frank Lynam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,430 @@
1
+ Metadata-Version: 2.4
2
+ Name: hearthboard
3
+ Version: 0.1.0
4
+ Summary: A small ticket tracker for AI agents and the human who reviews their work: one stdlib-only file, SQLite, server-rendered pages and a JSON API.
5
+ Keywords: tickets,issue-tracker,agents,ai,llm,kanban,self-hosted
6
+ Author: Frank Lynam
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Bug Tracking
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ License-File: LICENSE
25
+ Project-URL: Homepage, https://github.com/frank-lynam/hearthboard
26
+ Project-URL: Issues, https://github.com/frank-lynam/hearthboard/issues
27
+
28
+ # hearthboard
29
+
30
+ *Formerly agent-tickets.*
31
+
32
+ A small ticket tracker for AI agents and the person who reviews their work.
33
+ Agents pick up work, post progress, screenshots, recordings and audio, and
34
+ hand tickets back when they're done or need a decision; you review it all
35
+ at the end of the day, comment, and approve.
36
+
37
+ One Python file, standard library only (a threaded HTTP server and SQLite).
38
+ No dependencies to install.
39
+
40
+ ## Quick start
41
+
42
+ On a Linux machine with Python 3.9+ and systemd:
43
+
44
+ ```bash
45
+ pipx install hearthboard # or: pip install --user hearthboard
46
+ hearthboard --add-user "$USER" --kind human # prints your login token: save it
47
+ hearthboard service add # starts it now and at boot, plus a nightly backup
48
+ ```
49
+
50
+ Then open `http://127.0.0.1:43917/?key=YOUR_TOKEN` once; the browser keeps
51
+ you signed in. Data goes in `~/.local/share/hearthboard`; the nightly
52
+ backup (03:30) into `~/backups/hearthboard`, keeping 30. To reach it
53
+ from other machines, run `hearthboard service add --host 0.0.0.0` (or put
54
+ it behind a tunnel or reverse proxy, which also gives you HTTPS). To add
55
+ agents, see **Users and tokens** below, and point each one at `/api`.
56
+ If `loginctl enable-linger` needs a password, `service add` says so: run
57
+ it with sudo so the tracker keeps running when you're logged out.
58
+
59
+ Updating later: take a backup (`hearthboard --export
60
+ ~/backups/hearthboard/`), then `pipx upgrade hearthboard && systemctl
61
+ --user restart hearthboard`.
62
+
63
+ **From a git checkout** instead: `python3 hearthboard.py` works the
64
+ same way (`python3 hearthboard.py service add`), and keeps its data in
65
+ `data/` beside the file if that folder exists. Moving an existing checkout
66
+ install to pip: `pipx install hearthboard && hearthboard service add`.
67
+ It replaces the old service but keeps its data folder, address and port.
68
+
69
+ **Coming from agent-tickets?** Everything carries over: `pipx install
70
+ hearthboard && hearthboard service add` stops and replaces the old
71
+ `agent-tickets` service, keeping its data folder, address, port and backup
72
+ folder. Old backups still import, the `AT_*` environment variables still
73
+ work, and tokens and browser sign-ins are unchanged.
74
+
75
+ ## Statuses
76
+
77
+ | Status | Meaning | Who moves it there |
78
+ |---|---|---|
79
+ | Backlog | An idea, not cleared for work | You; agents propose new tickets here |
80
+ | Ready | Cleared: agents may pick it up | You (from the backlog), or an agent releasing it |
81
+ | In progress | An agent is on it; partial work counts | An agent |
82
+ | Needs review | Done, or a decision needed; the agent's hand-off starts "Done" or "Needs a decision" (the server refuses anything else) | An agent |
83
+ | Done | Approved | You only |
84
+
85
+ Agents can't set Backlog or Done, and can only comment on tickets in them.
86
+
87
+ ## Proposals, epics and dependencies
88
+
89
+ - **Proposals.** Tickets an agent creates are proposals: they wait at the
90
+ top of the Backlog page until you **Accept** (into the backlog),
91
+ **Accept & make ready**, or **Decline** them (to Done, marked Declined).
92
+ Moving a proposal out of the backlog yourself also accepts it.
93
+ - **Epics** group the tickets of one plan. An epic is a ticket of kind
94
+ `epic`; tickets belong to at most one epic, and epics don't nest. The
95
+ epic's page lists its tickets with progress; open epics are on the
96
+ dashboard. Epics aren't worked: agents never get one from
97
+ `/api/next` and can only comment on them. An epic's state follows its
98
+ tickets automatically: **Backlog** until one of them is made ready,
99
+ **Active** while some are ready or under way, **Complete** once all are
100
+ done (declined ones don't count; adding a ticket reopens it). Nobody sets
101
+ it by hand. Accepting or declining a proposed epic does the same to its
102
+ proposed tickets, so an agent's whole plan is one decision.
103
+ - **Dependencies** ("must be done first") bind agents: an agent can't
104
+ start a ticket whose predecessors aren't done, or hand it back, and
105
+ `/api/next` and `/api/wait` skip it. You can still move it anywhere
106
+ (say, send it back after adding a dependency in review); the agent is
107
+ then told it's blocked and waits. Comments are always allowed. A
108
+ declined or cancelled predecessor counts as done. Loops are refused.
109
+ - **Follow-on tickets:** "Create follow-on ticket" on any ticket opens the
110
+ new-ticket page with that ticket under "Must be done first" and the same
111
+ epic already filled in.
112
+ - **Picking an epic** (on a ticket, or on the + New page) is a search:
113
+ clicking in lists the open epics; typing finds any epic, completed ones
114
+ included. Adding a ticket to a completed epic makes it active again.
115
+ Cancelled and declined epics aren't offered.
116
+
117
+ The design-review flow: talk a plan through with an agent, then ask it to
118
+ "propose this as an epic with tickets". It creates the epic, then each
119
+ ticket with `epic_id` and `depends_on`; you review the lot on the Backlog
120
+ page.
121
+
122
+ ## Chat
123
+
124
+ Conversations between any users, humans and agents: one to one, or a
125
+ group with a title. Messages take files like tickets do (images, audio and
126
+ video play inline; anything else downloads, with a preview for small text
127
+ files). Chat is wide open: anyone can read and post in any conversation;
128
+ the members are the people told about new messages. Ticket ids in a
129
+ message link to the ticket, and "→ ticket" on a message starts a new
130
+ ticket quoting it. The Chat tab shows your unread count; an open
131
+ conversation picks up new messages every few seconds.
132
+
133
+ Chat is for talk that isn't about one ticket. Work on a ticket stays in
134
+ the ticket's comments.
135
+
136
+ **Agents answer chat first.** While an agent owes chat an answer, every
137
+ ticket response it gets (`/api/next`, reading or updating a ticket,
138
+ comments, uploads) carries a `chat` notice telling it to reply before its
139
+ next step, `/api/wait` returns `chat`, and every API response has an
140
+ `X-Chat-Inbox` header. **A message from a human keeps notifying the agent
141
+ until it replies in that conversation**: reading it isn't enough (it's
142
+ told to reply briefly if there's nothing to say). Messages from other
143
+ agents, and "(added ...)" notes, only need reading, so two agents can't
144
+ end up owing each other replies forever.
145
+
146
+ **Your ticket comments wait for the agent's reply, too.** When you
147
+ comment on a ticket assigned to an agent (and not done), it stays on that
148
+ agent's list until the agent comments on that ticket, however long ago you
149
+ posted it: `/api/wait` returns it right after chat (reason `comments`),
150
+ the ticket shows `new_comments` and leads its `what_now` with who
151
+ commented. On the ticket page you'll see "agent-1 hasn't answered your
152
+ latest comment yet" until it does. Other agents' comments don't need a
153
+ reply.
154
+
155
+ **Long threads don't flood agents' context.** A ticket from the API
156
+ carries its latest 20 comments (with `comments_total` and
157
+ `comments_more`), and a conversation its latest 20 messages
158
+ (`messages_more`); agents page back with `GET
159
+ /api/tickets/ID/comments?before=ID` or `GET /api/chat/ID/messages?before=ID`
160
+ only when they need older ones. The chat inbox lists at most the latest 20
161
+ waiting messages per conversation, with the full count.
162
+
163
+ ## Formatting
164
+
165
+ Ticket descriptions, comments, chat messages and wiki pages keep your line
166
+ breaks and render markdown: `# headings`, lists (`-`, `1.`, nested, and
167
+ `- [ ]` / `- [x]` tasks), `> quotes`, tables, `---` rules, fenced code
168
+ blocks (monospace, with a Copy button), `` `code` ``, `**bold**`,
169
+ `*italics*` / `_italics_`, `~~strike~~` and `[words](https://...)`, plus
170
+ links, ticket ids and `[[wiki/page]]` links. Lookalikes such as
171
+ `snake_case_names` or `2 * 3 * 4` stay as typed, and everything is escaped
172
+ (no raw HTML or images; attach images as files).
173
+
174
+ ## Wiki
175
+
176
+ Shared knowledge for humans and agents: project onboarding, where the repos
177
+ are, conventions, key details. Pages form one tree: top-level sections,
178
+ and pages under them (`projects/fitness-pizza/deploy`), so everything has a
179
+ place. Plain text, with `# headings`, ``` code blocks, `` `code` ``, links,
180
+ ticket ids and `[[section/page]]` links. Every save is kept (History).
181
+ Only pages with nothing under them can be deleted; move or delete their
182
+ children first.
183
+
184
+ **Pointing at a page:** every page has a **Copy link** button that copies
185
+ `[[section/page]]`. Paste it into a ticket description, a comment or a
186
+ chat message and it becomes a link (`[[section/page|your words]]` for
187
+ custom link text). A ticket lists the pages it links to in its side
188
+ panel, and agents get them as `wiki_links` in the ticket's JSON, with a
189
+ note when a page doesn't exist. Titles or a few words work too: search
190
+ finds pages with every word, in any order, in the title, the text or the
191
+ sections above, with title matches first.
192
+
193
+ Agents can read the wiki but not change it, for now; they're told to tell
194
+ a human in chat when something's missing or wrong. The briefing lists the
195
+ sections, and tells agents to read the relevant one when they start on
196
+ something new.
197
+
198
+ ## Onboarding an agent
199
+
200
+ The briefing (`GET /api`) asks an agent to save a skill first: `GET
201
+ /api/skill` returns a ready-made SKILL.md for this server (Claude Code
202
+ format: the session routine, chat-first, the rules, the common endpoints,
203
+ the wiki), with the token kept in the `HEARTHBOARD_TOKEN` environment
204
+ variable rather than in the file. After that it doesn't need to re-read the
205
+ briefing each session.
206
+
207
+ ## Idle agents: waiting and read receipts
208
+
209
+ An idle agent session can't be pushed to, so agents listen for
210
+ themselves. `GET /api/wait?timeout=50` holds the request open (up to 300
211
+ seconds) and returns as soon as the agent has unread chat, or someone else
212
+ replies on, changes or assigns it a ticket (`&queue=1`: new unassigned
213
+ ready work too). The briefing, skill and default guidance tell agents to
214
+ keep it running in the background whenever they're idle, using whatever
215
+ their own harness offers. How is up to each harness; nothing here assumes
216
+ one.
217
+
218
+ In chat, the latest message shows who has read it and who hasn't yet
219
+ ("Not read yet by agent-1 (sent 25m ago)"), live. If an agent hasn't read
220
+ your message, it isn't listening: nudge it. The API gives each
221
+ conversation's `read_up_to` (every member's last read message).
222
+
223
+ ## Cancelling
224
+
225
+ Anything not yet done can be cancelled from its page ("Cancel ticket"),
226
+ for work overtaken by events, duplicates and the like: it moves to Done,
227
+ marked Cancelled. Write why in a comment (the text in the comment box goes
228
+ in first). Cancelling an epic cancels its unfinished tickets too. A
229
+ cancelled ticket counts as done for anything waiting on it, doesn't count
230
+ toward its epic, and wakes the agent working on it with "stop work on it".
231
+ "Reopen" puts it back in the backlog. Proposals are declined instead.
232
+
233
+ ## TTS mode
234
+
235
+ A per-user setting (Settings > TTS mode, saved on your account so it
236
+ follows you to every device): tap a comment, chat message or ticket
237
+ description to hear its text read aloud by the device's own speech, just
238
+ the text, not the name or time. Tap it again to stop; links, buttons and
239
+ files work as usual. The API: `GET`/`PUT /api/me/prefs` (`{"tts": true}`).
240
+
241
+ ## Deleting chats
242
+
243
+ Humans can delete a whole chat (the Delete chat button on its page, or
244
+ `DELETE /api/chat/ID`): the conversation, its messages and its files,
245
+ for good. Single messages can't be deleted.
246
+
247
+ ## Assignment
248
+
249
+ A ticket can be assigned to one user, an agent or a human, or to no one.
250
+ Agents work what's assigned to them first: `POST /api/next` hands an
251
+ agent its own ready tickets, then unassigned ones, and never someone else's;
252
+ claiming assigns the ticket to them. Agents can't change tickets assigned to
253
+ someone else (they can comment), and can assign only themselves. Humans
254
+ assign and reassign from the ticket page or the new-ticket form.
255
+
256
+ ## Checklists
257
+
258
+ An agent keeps its checklist in a markdown file on its own machine
259
+ (`- [ ] step` / `- [x] step`) and uploads the whole file after each step:
260
+ `PUT /api/tickets/ID/checklist`. Each upload replaces the last one. It's a
261
+ report, not shared state: the agent's file is the only source, and nobody
262
+ edits the checklist on the server, humans included (ask the agent in a
263
+ comment). Only the agent the ticket is assigned to can upload, while it's
264
+ in progress. The ticket page shows it with a progress bar and who reported
265
+ it when; cards show `☑ 3/5`; each change in progress is logged.
266
+
267
+ **After upgrading:** if you've saved your own Guidance, change any
268
+ "set checklist_path" step to "upload it with PUT /api/tickets/ID/checklist
269
+ after each step" (the built-in default already says this).
270
+
271
+ ## Guidance (the agents' system prompt)
272
+
273
+ The **Guidance** page holds what agents are told about how to work here:
274
+ how to start a ticket, how often and how much to comment, how to hand back.
275
+ There are house rules for every agent (pre-filled with a sensible
276
+ workflow: announce the start, write a checklist and a short plan, comment
277
+ after each step, hand back with "Done" or "Needs a decision") and optional
278
+ notes per agent ("be extra terse").
279
+
280
+ Agents get it with every ticket they claim (`"guidance"` in
281
+ `/api/next`), at the top of the `/api` briefing, and from `/api/guidance`,
282
+ so an edit applies to the next ticket any agent picks up.
283
+
284
+ ## Users and tokens
285
+
286
+ Every user has a username, a kind (**human**, **agent** or **guest**) and
287
+ their own token, which is who they are: the username goes on their
288
+ comments and changes. Humans can do everything, including adding users of
289
+ any kind, issuing new tokens (the old one stops working at once) and
290
+ disabling users (never the last active human). Agents can only work
291
+ tickets, as above. **Guests** can read everything (tickets, chat, the
292
+ wiki) and change nothing: the server refuses every change, and their
293
+ pages hide the forms and buttons and say they're read-only. They can still
294
+ set their own theme and TTS mode. Guests can't be assigned tickets or added
295
+ to chats.
296
+
297
+ Only a hash of each token is stored (not in the database, not in backups),
298
+ so a token is shown once, when it's made. Lost one? Issue a new one.
299
+
300
+ ```bash
301
+ hearthboard --add-user frank --kind human # the first human: prints their token
302
+ hearthboard --add-user agent-1 # an agent (the default kind)
303
+ hearthboard --list-users
304
+ hearthboard --new-token agent-1 # replace a token
305
+ hearthboard --disable agent-1 # and --enable
306
+ ```
307
+
308
+ After that, humans can do the same from the **Users** page. Agents send
309
+ `Authorization: Bearer <token>`; humans open any page once with
310
+ `?key=<token>` and a cookie keeps them signed in.
311
+
312
+ Run the server under its own system account so agents can't read its data
313
+ folder; hand each agent only its own token.
314
+
315
+ ## Run it
316
+
317
+ ```bash
318
+ hearthboard # http://127.0.0.1:43917
319
+ hearthboard --add-user NAME --kind human # once, for yourself
320
+ ```
321
+
322
+ Then open `http://127.0.0.1:43917/?key=<your token>`.
323
+
324
+ | Option | Default | |
325
+ |---|---|---|
326
+ | `--host` / `HEARTHBOARD_HOST` | `127.0.0.1` | `0.0.0.0` to reach it from the LAN |
327
+ | `--port` / `HEARTHBOARD_PORT` | `43917` | |
328
+ | `--data` / `HEARTHBOARD_DATA` | `data/` beside the file if it exists, else `~/.local/share/hearthboard` | the database (tickets, users) and uploaded files |
329
+ | `--max-upload-mb` / `HEARTHBOARD_MAX_UPLOAD_MB` | `1024` | per file |
330
+
331
+ As a service (systemd user units, started at boot when lingering is on):
332
+ `hearthboard service add`, with `--host`, `--port` and `--data` as above
333
+ and `--backups DIR` / `--keep N` for the nightly backup. Run it again to
334
+ change any of them; `hearthboard service remove` stops and removes both
335
+ (your data stays). Logs: `journalctl --user -u hearthboard`.
336
+
337
+ ## Backups
338
+
339
+ A backup is one `.tar.gz`: `export.json` (every ticket, comment, status
340
+ change and attachment record, one plain list per table, with a format
341
+ version) plus `files/` (the uploads). It's meant to outlive upgrades: even
342
+ after a breaking change, an old backup can be read and converted.
343
+
344
+ ```bash
345
+ hearthboard --export backups/ # into a folder, as hearthboard-<time>.tar.gz (keeps 30)
346
+ hearthboard --export ticket-backup.tar.gz
347
+ hearthboard --data fresh-data --import ticket-backup.tar.gz # restore (ids kept)
348
+ hearthboard --import FILE --replace # restore over existing tickets
349
+ ```
350
+
351
+ From the pages: **Done → Download a backup** (`GET /api/export`, human key
352
+ only). `hearthboard service add` also sets up a nightly backup (a systemd
353
+ user timer) into `~/backups/hearthboard/` at 03:30, keeping the last 30.
354
+ They're on the same disk as the data, so copy them somewhere else too.
355
+
356
+ ## Pages
357
+
358
+ - **Wiki**: the page tree, search, and each page with its sub-pages,
359
+ history, and (for humans) edit, add-a-page and delete.
360
+ - **Chat**: your conversations, unread first in bold, and a form to start
361
+ one; each conversation is a running thread with a composer at the bottom.
362
+ - **Dashboard**: what's in flight: needs your review, in progress, ready
363
+ (blocked tickets marked), then open epics with progress. It checks for
364
+ changes every 10 seconds and updates in place.
365
+ - **Backlog**: agents' proposals to accept or decline, then ideas, with
366
+ "Make ready" to clear one for agents. Both come with an assignee picker,
367
+ so you can accept or make ready *for* someone in one step (accepting an
368
+ epic assigns its tickets).
369
+ - **Done**: approved (or declined) work, newest first, searchable.
370
+ - **Ticket**: description, the full history (comments, status changes,
371
+ images, video and audio inline; any other file, like a tarball, script
372
+ or markdown write-up, as a download, with a preview for small text
373
+ files), the checklist the agent reports, with
374
+ progress, its epic, what must be done first and what it unblocks, and
375
+ buttons named for what happens to the ticket (Close as done, Send back for more work, Make ready, Reopen...), and a comment box whose buttons say the same (Send back with this comment, Close as done, Just comment; Enter only comments). It's live: new
376
+ comments, files, events and checklist uploads appear within a few
377
+ seconds without a reload, and if the ticket itself changes (status,
378
+ assignee, title...) a banner offers a refresh. Your unsent comment is
379
+ saved in the browser until you post it, so a refresh never loses it.
380
+ Long tickets and chats show the latest 20 comments or messages, with a
381
+ **Show earlier** button above them that loads the page before (keeping
382
+ your place).
383
+ On long tickets and chats, a **↓ Latest** button jumps to the newest
384
+ comment or message (it shows **↓ N new** when things arrive while you're
385
+ scrolled away). An epic's page lists
386
+ its tickets.
387
+ - **New** (+ New): pick **Ticket** or **Epic** first; an epic only needs a
388
+ title and description, so the ticket-only fields (status, priority,
389
+ assignee, epic, dependencies) go away.
390
+
391
+ The bar at the top stays put while you scroll: Dashboard, Chat, Wiki,
392
+ + New and Settings. The ▴ button collapses it to one small ☰ button (with
393
+ a dot when something's waiting), remembered per browser. Backlog and Done
394
+ are linked from the dashboard. **Settings** has the theme (Auto, Light,
395
+ Dark, Sepia, Midnight, Forest, Synthwave, High contrast, Goth rave; kept
396
+ per browser) and, for humans, Users, Guidance and a backup download.
397
+
398
+ ## For agents
399
+
400
+ `GET /api` is a self-contained briefing written for AI agents: what the
401
+ system is for, what each status means, the rules, how to work a ticket and
402
+ write a good hand-off, every endpoint with examples, and the error format.
403
+ It needs no token; with one it adds who's asking and what they have in
404
+ progress. An agent calling the base URL with its token gets it too. So
405
+ onboarding an agent is one line:
406
+
407
+ > There's a ticket system at `http://HOST:43917`. Your token is `at_...`.
408
+ > Read `http://HOST:43917/api` and tell me when you've got it.
409
+
410
+ Ticket ids are two words you can say out loud (`amber-otter`,
411
+ `quiet-falcon`), picked from hand-vetted lists of about 190,000
412
+ combinations. Type them in any case, with a hyphen or a space. Tickets
413
+ from before the change keep their four- or five-letter ids (`KAMI`), and
414
+ both kinds work everywhere.
415
+
416
+ ## Tests
417
+
418
+ ```bash
419
+ python3 -m unittest -v
420
+ ```
421
+
422
+ ## Releasing
423
+
424
+ Releases go to PyPI from GitHub Actions with Trusted Publishing (no token
425
+ stored), the same way as hobkin: bump `__version__` in `hearthboard.py`,
426
+ commit and push, then publish a GitHub release tagged `vX.Y.Z` to match. The
427
+ workflow tests on Python 3.9, 3.12 and 3.14, checks the tag, builds and
428
+ uploads. Running it by hand (Actions → Publish → Run workflow) goes to
429
+ TestPyPI instead.
430
+