Hono Short Link DO
DX-first Hono middleware for short-lived public links on Cloudflare Workers and Durable Objects.
The Worker in this repo is intentionally small: Hono mounts public link routes, the admin API, and the optional admin UI, then exports the Durable Object classes that Wrangler binds by name.
import { Hono } from 'hono'
import { secureHeaders } from 'hono/secure-headers'
import { createDoor, PublicLink, Registry, Room } from 'hono-door'
import { createDoorUi } from 'hono-door-ui'
import { renderSamplePublicPage } from './src/sample-public-page'
export { PublicLink, Registry, Room }
const app = new Hono()
const door = createDoor()
app.use('*', secureHeaders())
app.route('/l', door.public(renderSamplePublicPage))
app.route('/admin', door.adminApi())
app.route('/admin', createDoorUi(door))
export default app
Packages
This repo builds two publishable packages:
| Package | Purpose | QR dependency |
|---|---|---|
hono-door |
Core Hono routes, admin API, middleware, Durable Objects | no |
hono-door-ui |
Optional browser admin UI mounted at /admin/ui |
yes |
Install only the core package when you do not need the browser UI:
bun add hono-door
Add the UI package when you want the default admin UI:
bun add hono-door hono-door-ui
The sample app also depends on qrcode-terminal for the local CLI's terminal QR
output. That CLI dependency is separate from the hono-door core package.
Requirements
- Runtime: Cloudflare Workers with Durable Objects and SQLite-backed Durable Object classes.
- Framework: Hono 4.10 or newer.
- Package manager: Bun is used in this repository. Published packages can be installed with Bun, npm, pnpm, or another Node package manager.
- Wrangler config: bind
PUBLIC_LINKS,ROOMS, andREGISTRY, and addnew_sqlite_classesmigrations forPublicLink,Room, andRegistry. - Worker entrypoint: export
PublicLink,Registry, andRoomso Wrangler can instantiate the bound Durable Object classes. - Secrets and vars: set
ADMIN_API_TOKEN; setPUBLIC_BASE_URLto the origin that should appear in issued public URLs.
What It Does
PublicLinkis a SQLite-backed Durable Object keyed by public link ID.Roomis a SQLite-backed Durable Object keyed by room ID.Registryis a SQLite-backed Durable Object that indexes issued link candidates for server-side listing.- The package uses Durable Object SQLite storage directly with bound SQL parameters and tracks internal schema changes per object.
- A public URL can stay stable while its backing room changes.
- Tokens are short-lived access grants stored only as SHA-256 hashes.
- Raw tokens are returned once at issue time and are carried in the public URL.
- Admin JSON routes require
Authorization: Bearer <ADMIN_API_TOKEN>. - The browser admin UI validates
ADMIN_API_TOKENonce, then uses a signed HttpOnly session cookie scoped to/admin/ui. door.public()includes only a minimal fallback page. Pass a custom renderer to display application content after token validation.- The sample Worker uses
renderSamplePublicPageto show how application-owned public UI can readlink.roomId,link.label, and room state.
Stable Room IDs
Treat roomId as the stable application-owned identifier for the content behind
a public link. For custom systems such as surveys, issue a new roomId for each
public survey/event and use that value as the foreign key in your own Durable
Object or D1 schema.
hono-door keeps roomId unique as a storage address:
ROOMS.getByName(roomId)always resolves the sameRoomDurable Object.Registry.rooms.room_idis a primary key, so duplicate room rows are not created.- Public renderers receive
link.roomId,link.linkId,link.tokenHash,link.label,link.role, andlink.expiresAtafter token validation.
The package does not reject a repeated roomId. Calling /admin/rooms/:roomId
again or issuing with title/body for the same roomId updates that room. For
archive-safe custom workflows, do not reuse a roomId for another survey/event;
create a fresh ID instead.
For example, a survey app can use roomId as the stable key:
CREATE TABLE survey_rooms (
room_id TEXT PRIMARY KEY,
title TEXT NOT NULL
);
CREATE TABLE survey_responses (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL,
submitted_at INTEGER NOT NULL
);
Then render the public page by reading your application data with
link.roomId:
app.route('/l', door.public(async ({ c, link }) => {
const survey = await c.env.DB
.prepare('SELECT * FROM survey_rooms WHERE room_id = ?')
.bind(link.roomId)
.first()
return renderSurveyPage({ survey, link })
}))
Manual archive, revoke, and TTL expiry do not change roomId or tokenHash.
Reissue creates a new token and therefore a new tokenHash, but still uses the
link's current roomId.
Example issued URL:
https://example.workers.dev/l/summer-event?token=<raw-token>
On first browser access with ?token=..., the middleware validates the token,
sets an HTTP-only cookie scoped to that link path, and redirects to the same URL
without the token query string. Later requests use the cookie. Bearer tokens are
also accepted for programmatic access.
Setup Order
Install dependencies.
bun installCreate a local admin token.
printf 'ADMIN_API_TOKEN=local-admin-token\n' > .dev.varsGenerate Worker types after dependencies are installed.
bun run typesRun the local Worker.
bun run devVerify the Worker is alive.
curl http://localhost:8787/health
Expected health response:
{"ok":true,"service":"hono-short-link-do"}
Wrangler Durable Objects
wrangler.jsonc must bind the same class names exported by the Worker:
{
"durable_objects": {
"bindings": [
{ "name": "PUBLIC_LINKS", "class_name": "PublicLink" },
{ "name": "ROOMS", "class_name": "Room" },
{ "name": "REGISTRY", "class_name": "Registry" }
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["PublicLink", "Room"]
},
{
"tag": "v2",
"new_sqlite_classes": ["Registry"]
}
]
}
The Worker entrypoint must include:
export { PublicLink, Registry, Room }
Without that export, Wrangler can read the binding config but cannot instantiate the Durable Object classes.
Bindings
createDoor() uses these binding names by default:
| Binding | Purpose |
|---|---|
PUBLIC_LINKS |
Durable Object namespace for PublicLink |
ROOMS |
Durable Object namespace for Room |
REGISTRY |
Durable Object namespace for server-side list candidates |
ADMIN_API_TOKEN |
Admin API bearer token |
PUBLIC_BASE_URL |
Base URL used when issuing public links |
Override the resolvers when embedding the package in another app:
import { Hono } from 'hono'
import { PublicLink, Registry, Room, createDoor, type ShortLinkVariables } from 'hono-door'
type AppEnv = {
Bindings: {
MY_PUBLIC_LINKS: DurableObjectNamespace<PublicLink>
MY_ROOMS: DurableObjectNamespace<Room>
MY_REGISTRY: DurableObjectNamespace<Registry>
SHORT_LINK_ADMIN_TOKEN?: string
SHORT_LINK_PUBLIC_BASE_URL?: string
}
Variables: ShortLinkVariables
}
const app = new Hono<AppEnv>()
const door = createDoor<AppEnv>({
publicLinks: (c) => c.env.MY_PUBLIC_LINKS,
rooms: (c) => c.env.MY_ROOMS,
registry: (c) => c.env.MY_REGISTRY,
adminToken: (c) => c.env.SHORT_LINK_ADMIN_TOKEN,
publicBaseUrl: (c) => c.env.SHORT_LINK_PUBLIC_BASE_URL,
publicPath: '/event',
})
Routes
The repo Worker mounts:
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/health |
none | Health check |
GET |
/l/:linkId |
public token | Render the current room for a link |
GET |
/admin/ui |
none | Browser admin UI shell; actions require a UI session |
POST |
/admin/ui |
UI session or form token | Issue a link from the UI |
GET |
/admin/ui/links |
none | Server-backed active link list shell; data API requires a UI session |
GET |
/admin/ui/archive |
none | Browser archive shell for inactive link search and admin-only preview |
GET |
/admin/ui/archive/:linkId/rooms/:roomId/preview |
UI session | Admin-only archived room preview |
GET |
/admin/ui/client.js |
none | Admin UI client script |
POST |
/admin/ui/api/session |
form token | Create UI session cookie |
GET |
/admin/ui/api/session |
UI session | Check UI session state |
DELETE |
/admin/ui/api/session |
UI session | Clear UI session cookie |
GET |
/admin/ui/api/links |
UI session | UI proxy for active links |
GET |
/admin/ui/api/links/archive |
UI session | UI proxy for inactive archive search |
GET |
/admin/ui/api/links/archive/:linkId |
UI session | UI proxy for archived link detail |
GET |
/admin/ui/api/links/:linkId/tokens |
UI session | UI proxy for active token metadata |
GET |
/admin/ui/api/links/:linkId/issue-policy |
UI session | UI proxy for issue policy |
PUT |
/admin/ui/api/links/:linkId/issue-policy |
UI session | UI proxy for issue policy update |
POST |
/admin/ui/api/links/:linkId/reissue |
UI session | UI proxy for revoke-and-reissue |
POST |
/admin/ui/api/links/:linkId/archive |
UI session | UI proxy for manual archive |
GET |
/admin/links |
bearer admin token | List active links |
GET |
/admin/links/archive |
bearer admin token | Search inactive archived links |
GET |
/admin/links/archive/:linkId |
bearer admin token | Archived link detail with room snapshot and token history |
GET |
/admin/links/:linkId |
bearer admin token | Link status |
GET |
/admin/links/:linkId/tokens |
bearer admin token | List active token metadata for a link |
GET |
/admin/links/:linkId/issue-policy |
bearer admin token | Get link issue policy |
PUT |
/admin/links/:linkId/issue-policy |
bearer admin token | Update link issue policy |
POST |
/admin/links/:linkId/reissue |
bearer admin token | Revoke active tokens and issue a new token |
POST |
/admin/links/:linkId/archive |
bearer admin token | Revoke active tokens without issuing a new token |
POST |
/admin/links/:linkId/tokens |
bearer admin token | Issue a token |
POST |
/admin/links/:linkId/switch-room |
bearer admin token | Point link at another room |
POST |
/admin/links/:linkId/revoke |
bearer admin token | Revoke a token by hash |
POST |
/admin/rooms/:roomId |
bearer admin token | Set room content |
Request Requirements
- Admin JSON API requests must include
Authorization: Bearer <ADMIN_API_TOKEN>. - Requests with JSON bodies must include
Content-Type: application/json. - Public link requests require a valid raw token by
?token=...,Authorization: Bearer <raw-token>, or the link-scoped HttpOnly cookie that is set after query-token access. - Raw public tokens are returned only by issue/reissue responses. List, archive, and detail endpoints return token hashes and metadata only.
- Browser admin UI API requests under
/admin/ui/api/*use the signed UI session cookie created byPOST /admin/ui/api/session, not bearer auth.
Admin API Reference
Set these variables for examples:
export SHORT_LINK_ADMIN_BASE_URL=http://localhost:8787
export SHORT_LINK_ADMIN_TOKEN=local-admin-token
Set Room Content
curl -sS -X POST "$SHORT_LINK_ADMIN_BASE_URL/admin/rooms/room-a" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Summer Event","body":"Welcome"}'
Response:
{"title":"Summer Event","body":"Welcome","mode":"plain","updatedAt":1710000000000}
Issue Token
curl -sS -X POST "$SHORT_LINK_ADMIN_BASE_URL/admin/links/summer-event/tokens" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ttl":"1h","roomId":"room-a","label":"staff","role":"viewer","maxUses":10}'
Request fields:
| Field | Required | Notes |
|---|---|---|
ttl |
no | Defaults to 1h; accepts duration strings such as 15m, 1h, 1d, or a positive integer number of seconds |
roomId |
no | Sets the link's current room when present; use a stable, application-owned ID and do not reuse it for another survey/event |
label |
no | Token-scoped operator memo; not used for authorization and exposed to public renderers as shortLink.label |
role |
no | Defaults to viewer and is exposed in shortLink.role |
maxUses |
no | Positive integer as a number or string; omitted means unlimited until expiry/revoke |
Response:
{
"url": "http://localhost:8787/l/summer-event?token=<raw-token>",
"token": "<raw-token>",
"tokenHash": "<sha-256-hash>",
"expiresAt": "2026-06-28T12:00:00.000Z",
"roomId": "room-a"
}
Store tokenHash if you need to revoke this token later. The raw token is not
stored by the Durable Object and cannot be recovered.
List Active Links
curl -sS "$SHORT_LINK_ADMIN_BASE_URL/admin/links" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN"
Response:
{
"links": [
{
"linkId": "summer-event",
"currentRoomId": "room-a",
"activeTokenCount": 1,
"latestIssuedAt": "2026-06-28T11:00:00.000Z",
"latestExpiresAt": "2026-06-28T12:00:00.000Z"
}
]
}
This endpoint uses the REGISTRY Durable Object as the link candidate index and
then checks each candidate's PublicLink object for the current room and active
token count. It returns only links with at least one non-expired, non-revoked
token that has not reached maxUses.
Search Inactive Archive
curl -sS "$SHORT_LINK_ADMIN_BASE_URL/admin/links/archive?q=summer" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN"
The archive returns inactive links: expired, revoked, or maxUses-reached
links with no currently active tokens. Search matches linkId, roomId, token
label, room title, and room body.
Response:
{
"links": [
{
"linkId": "summer-event",
"currentRoomId": "room-a",
"latestIssuedAt": "2026-06-28T11:00:00.000Z",
"latestExpiresAt": "2026-06-28T12:00:00.000Z",
"tokenCount": 1,
"latestRoom": {
"roomId": "room-a",
"title": "Summer Event",
"body": "Welcome",
"updatedAt": "2026-06-28T10:55:00.000Z"
}
}
]
}
Archived Link Detail
curl -sS "$SHORT_LINK_ADMIN_BASE_URL/admin/links/archive/summer-event" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN"
Response:
{
"linkId": "summer-event",
"currentRoomId": "room-a",
"tokenCount": 1,
"rooms": [
{
"roomId": "room-a",
"title": "Summer Event",
"body": "Welcome",
"updatedAt": "2026-06-28T10:55:00.000Z"
}
],
"tokens": [
{
"tokenHash": "<sha-256-hash>",
"label": "staff",
"role": "viewer",
"roomId": "room-a",
"createdAt": "2026-06-28T11:00:00.000Z",
"expiresAt": "2026-06-28T12:00:00.000Z",
"maxUses": 10,
"useCount": 3,
"ttlSeconds": 3600,
"state": "expired"
}
]
}
Archive detail is intended for post-publication review. It can show room
title/body and token metadata, but it still cannot reconstruct old public URLs
because raw tokens are not stored. The browser UI can reopen archived room
content through an /admin/ui preview route that requires the signed admin
session cookie and does not create a new public token.
For custom systems, use the archived link's linkId and roomId to read the
application-owned archive, survey, response, or snapshot data from your own
Durable Object or D1 storage.
If the link has an active token again, this endpoint returns 409 and the link
belongs in the active-link list instead.
List Active Tokens For A Link
curl -sS "$SHORT_LINK_ADMIN_BASE_URL/admin/links/summer-event/tokens" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN"
Response:
{
"tokens": [
{
"tokenHash": "<sha-256-hash>",
"label": "staff",
"role": "viewer",
"roomId": "room-a",
"createdAt": "2026-06-28T11:00:00.000Z",
"expiresAt": "2026-06-28T12:00:00.000Z",
"maxUses": 10,
"useCount": 0
}
]
}
Raw tokens are not stored and are never returned by list endpoints, so list responses cannot reconstruct the public URL or QR code.
Get Issue Policy
curl -sS "$SHORT_LINK_ADMIN_BASE_URL/admin/links/summer-event/issue-policy" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN"
Response:
{"ttlSeconds":3600,"label":"staff","role":"viewer","maxUses":10}
The policy is the reusable issuing configuration for a link. roomId is not
stored in the policy; reissue uses the link's current room.
Update Issue Policy
curl -sS -X PUT "$SHORT_LINK_ADMIN_BASE_URL/admin/links/summer-event/issue-policy" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ttl":"15m","label":"reissued","role":"viewer","maxUses":3}'
Use null for label or maxUses to clear them.
Reissue URL And QR
curl -sS -X POST "$SHORT_LINK_ADMIN_BASE_URL/admin/links/summer-event/reissue" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN"
Response:
{
"url": "http://localhost:8787/l/summer-event?token=<new-raw-token>",
"token": "<new-raw-token>",
"tokenHash": "<new-sha-256-hash>",
"expiresAt": "2026-06-28T12:15:00.000Z",
"roomId": "room-a",
"reissued": true,
"revokedTokenCount": 1
}
Reissue requires an existing link. It always revokes active tokens for the link before issuing the new token. The raw token is returned only in this response and is not stored.
Archive Before TTL
curl -sS -X POST "$SHORT_LINK_ADMIN_BASE_URL/admin/links/summer-event/archive" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN"
Response:
{"linkId":"summer-event","archived":true,"revokedTokenCount":1}
Manual archive revokes currently active tokens without issuing a replacement
token. The link disappears from /admin/links immediately and can be reviewed
from /admin/links/archive or the browser archive UI. Archived room previews
remain admin-session-only and do not make the link public again.
Manual archive does not change roomId or rewrite token hashes.
Link Status
curl -sS "$SHORT_LINK_ADMIN_BASE_URL/admin/links/summer-event" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN"
Response:
{
"exists": true,
"currentRoomId": "room-a",
"activeTokenCount": 1,
"latestIssuedAt": "2026-01-01T00:00:00.000Z",
"latestExpiresAt": "2026-01-01T01:00:00.000Z"
}
activeTokenCount and latest timestamps are computed from currently active
tokens: non-revoked, non-expired tokens that have not reached maxUses.
Expired token rows are retained for archive analysis. Expired links do not
appear in /admin/links or the browser active-link list, but they can be found
from /admin/links/archive. Browser archive previews require an admin UI
session and do not make the link public again. Use
POST /admin/links/:linkId/reissue only when you intentionally want a new
public URL and QR-ready token.
Switch Link Room
curl -sS -X POST "$SHORT_LINK_ADMIN_BASE_URL/admin/links/summer-event/switch-room" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"roomId":"room-b"}'
Response:
{"roomId":"room-b"}
Existing valid tokens for the link now render room-b; the public URL path does
not change.
Revoke Token
curl -sS -X POST "$SHORT_LINK_ADMIN_BASE_URL/admin/links/summer-event/revoke" \
-H "Authorization: Bearer $SHORT_LINK_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tokenHash":"<sha-256-hash-from-issue-response>"}'
Response:
{"revoked":true}
false means no unrevoked row matched that hash.
CLI Reference
The CLI is a thin wrapper around the admin API and uses:
SHORT_LINK_ADMIN_BASE_URL, defaulting tohttp://localhost:8787SHORT_LINK_ADMIN_TOKEN, required
Issue a token and render a terminal QR code:
SHORT_LINK_ADMIN_BASE_URL=http://localhost:8787 \
SHORT_LINK_ADMIN_TOKEN=local-admin-token \
bun run token:issue --link summer-event --ttl 1h --room room-a --label staff --max-uses 10
Set room content:
SHORT_LINK_ADMIN_BASE_URL=http://localhost:8787 \
SHORT_LINK_ADMIN_TOKEN=local-admin-token \
bun run room:set --room room-a --title "Summer Event" --body "Welcome"
Switch the room behind a stable link:
SHORT_LINK_ADMIN_BASE_URL=http://localhost:8787 \
SHORT_LINK_ADMIN_TOKEN=local-admin-token \
bun run link:switch --link summer-event --room room-b
The current CLI supports issue, room update, and room switch. Use the API examples above for status and revoke until CLI wrappers are added.
Admin And Security Model
- The public route is deny-by-default; no token means
401. - Invalid tokens return
401; revoked, expired, or over-used tokens return403. - Admin JSON routes are protected only by
ADMIN_API_TOKEN. - The admin UI asks for
ADMIN_API_TOKENonce, then sets a signed HttpOnly session cookie. It does not persist the token in browser storage or a JavaScript-readable cookie. - The UI session cookie is scoped to
/admin/ui, signed with the configured admin token, and expires after 8 hours. /admin/ui/api/*is for the browser UI. CLI and direct API clients should keep using bearer-protected/admin/*JSON routes.- The admin UI issues links for an existing or application-managed
roomId; it does not edit room title/body content. Use/admin/rooms/:roomIdor a custom application UI for room content. - Treat
roomIdas a stable application key. The sameroomIdresolves to the sameRoomDurable Object and Registry room row; repeated writes update that room, so issue a freshroomIdfor each survey/event instead of reusing one. - The built-in public page is intentionally not a room content UI. Production
applications should pass
door.public(({ link }) => ...)and render their own content usinglink.roomId,link.label, and application data. - Active-link listing uses the server-side
REGISTRYDurable Object as an index andPublicLinkas the token source of truth. - Archive search uses
REGISTRYfor link, token label, and room text matching, then checksPublicLinkto include only links without active tokens. - List endpoints return token hashes and metadata only, never raw tokens.
- Reissue revokes all active tokens for the link before returning a new raw token, URL, and QR-ready URL.
- Token labels are operational notes only and are not part of authorization.
Public renderers and handlers can read the verified token label from
shortLink.label. - Public responses set
Cache-Control: no-store,Referrer-Policy: no-referrer, andX-Robots-Tag: noindex. - Put the admin routes behind Cloudflare Access or another network boundary before using this for sensitive production workflows.
Token Lifecycle
- Admin issues a token for a link with a TTL and optional
maxUses. PublicLinkgenerates a raw token, stores only its SHA-256 hash, and returns the raw token once.PublicLinkstores the link issue policy: TTL, role, label, andmaxUses.- If
roomIdis included, the link's current room is updated at issue time. Use a stable, non-reusedroomIdfor archive-safe application data. Registryrecords the link as a server-side list candidate.- Public access validates the raw token against the stored hash.
- Browser query-token access sets an HTTP-only cookie and redirects without consuming the first use; the next cookie-backed request consumes one use.
- Header-token and cookie-token access increment
use_count. - A token stops working when it expires, is revoked, or reaches
maxUses. - Reissue revokes currently active tokens and issues one replacement token using the stored issue policy and current room.
- Expired token rows remain stored so archive detail can show token history, use counts, and inactive state.
Registry is updated by issue, room switch, revoke, and room update paths and is
used as the candidate index for admin lists. Current active counts, latest active
timestamps, token detail, useCount, and maxUses checks are read from the
per-link PublicLink object so public access does not need to update a single
global object on every request.
Existing data created before adding REGISTRY cannot be backfilled automatically
because Durable Object namespaces are not globally enumerable by this package.
Deploy
Set the production admin secret.
bunx wrangler secret put ADMIN_API_TOKENConfirm
PUBLIC_BASE_URLinwrangler.jsoncmatches the deployed route or custom domain used in issued links.Run a dry run.
bun run deploy:dry-runDeploy.
bun run deployVerify the deployed Worker.
curl https://hono-short-link-do.tsapp.workers.dev/healthIssue a short-lived production token, open the returned URL, then check link status and revoke the token hash if it was only for verification.
Verification Commands
Run before handing off code changes:
bun run check
For documentation-only changes, at minimum review the changed Markdown and make
sure any command examples still match package.json, wrangler.jsonc, and the
mounted routes in src/index.tsx.
Maintainer Release Flow
Package releases are automated from main with semantic-release. The release
workflow publishes both npm packages:
hono-doorhono-door-ui
Releases stay on the 0.x line until the package is ready for a stable 1.0.
Conventional release commits such as feat:, fix:, and perf: advance the
minor version, so automated releases use 0.x.0 versions. Before the first
automated publish, configure npm trusted publishing for this GitHub repository
and both package names so the workflow can publish with provenance through
GitHub OIDC.
Normal release flow:
- Open a pull request against
main. - Wait for CI to pass.
- Merge the pull request.
- Let the
Releaseworkflow publish packages and create the GitHub Release.