Releasing¶
Publisher documentation: cutting a release, signing, and the auto-updater. If you are here to use the runner rather than ship it, you want Quickstart instead.
Cross-platform bundles are built by .github/workflows/release.yml, which runs
only on a v* tag push or a manual dispatch.
Cutting a release¶
1. Bump the version¶
These must match, or the bundles come out with inconsistent names:
ui/package.json→versionui/src-tauri/Cargo.toml→[package] versionui/src-tauri/tauri.conf.json→versionpyproject.toml→[project] version
2. Tag and push¶
git add ui/package.json ui/src-tauri/Cargo.toml ui/src-tauri/tauri.conf.json pyproject.toml
git commit -m "release: v0.2.0"
git tag v0.2.0
git push origin main --tags
3. What CI does¶
The build job runs in parallel on four runners: macos-14 (Apple silicon),
macos-15-intel (Intel), windows-latest, ubuntu-22.04. Each one sets up
Python 3.11, Node 20 and stable Rust, builds the UI with Vite, produces the
PyInstaller sidecar, runs tauri build --target <triple>, and uploads the
artefacts.
macos-13was retired in December 2025 and its label no longer resolves to a machine. A job asking for it queues until the run is cancelled, which is how v0.1.0 first stalled.
The release job runs only on a tag push. It collects every artefact, flattens
them into release-assets/, builds the updater manifest, and publishes the
GitHub Release.
4. Check the result¶
Neither a draft nor a prerelease, and both are deliberate:
- A draft is invisible to everyone but the maintainer, so
releases/latest/download/...does not resolve to it — every download button on the site 404s while the run shows green. - GitHub's
latestpointer skips prereleases, with the same result.
"Beta, unsigned" is said in words, in the release body and on the site, which is
where that belongs. So there is nothing to press: check the published release has
all the expected assets — two .dmg, one .exe, one .AppImage, one .deb,
plus the .sig files and latest.json; the Windows .msi is optional.
Smoke-testing without releasing¶
GitHub → Actions → release → Run workflow, and pick a branch. The
release job is skipped (it is gated on startsWith(github.ref, 'refs/tags/v')),
and the bundles are downloadable from the run's artefacts for 14 days.
The Python package¶
pip install annona is advertised in the README and on the site, so it has to
work. .github/workflows/publish-pypi.yml builds the sdist and wheel and
publishes them on every published GitHub Release, keeping the desktop
bundles and the Python package on the same version by construction.
Uploads use PyPI Trusted Publishing (OIDC), not an API token: no credential is stored in repository secrets, and none can leak from a repo that never held one. It needs two things to exist, once, and neither can be created from CI:
- A pending publisher at https://pypi.org/manage/account/publishing/ —
project
annona, ownerakaion-ai, repositoryannona, workflowpublish-pypi.yml, environmentpypi. - A
pypienvironment under Settings → Environments.
What the wheel contains is runner* and nothing else, which has one consequence
worth knowing before someone reports it as a bug: the web UI is not in the
Python package. _UI_DIST resolves relative to the installed package, so a
pip installation has no ui/dist and runner/local_api.py skips the static
mount with a warning. The CLI and the daemon API are complete; the window is
what the desktop bundles are for.
Time and cost¶
A first build with a cold cargo cache takes 25–35 minutes, dominated by macOS Apple silicon; subsequent builds run 10–15 minutes. macOS runners bill at ten times the Linux rate, so do not wire this workflow to every commit.
Signing¶
macOS¶
The release pipeline signs and notarises when the secrets exist, and falls back to an ad-hoc signature when they do not. The difference is not cosmetic:
| Gatekeeper's verdict | What a user has to do | |
|---|---|---|
| Notarised | accepted | nothing |
| Ad-hoc (today) | damaged on macOS 15+ | xattr -dr com.apple.quarantine on the dmg, before opening it |
Since macOS 15 the right-click → Open bypass no longer exists; an un-notarised app carrying a quarantine flag is moved to the Trash. So on the ad-hoc path the download does not merely warn, it fails, and the release notes have to say so.
To switch the pipeline on, add these repository secrets. Absent any of them, the build takes the ad-hoc path and says which one it took.
Signing, always:
| Secret | What it is |
|---|---|
APPLE_CERTIFICATE |
base64 of the Developer ID Application .p12 |
APPLE_CERTIFICATE_PASSWORD |
the password the .p12 was built with |
APPLE_SIGNING_IDENTITY |
e.g. Developer ID Application: Name (TEAMID) |
APPLE_TEAM_ID |
the 10-character team identifier |
Notarising, one set or the other. Prefer the API key: it is not tied to anybody's personal Apple ID, it carries only the role it was granted, and it is revoked in a click without changing anyone's password.
| Secret | What it is |
|---|---|
APPLE_API_KEY_P8 |
the contents of the .p8 App Store Connect key |
APPLE_API_KEY_ID |
the key's ID, from the same page |
APPLE_API_ISSUER |
the issuer UUID, shown once above the key list |
The .p8 downloads once and Apple never shows it again. App Store Connect →
Users and Access → Integrations → App Store Connect API → +, role Developer.
The older way, if you must:
| Secret | What it is |
|---|---|
APPLE_ID |
the Apple ID that owns the membership, an email address |
APPLE_PASSWORD |
an app-specific password — xxxx-xxxx-xxxx-xxxx, from appleid.apple.com |
Apple rejects the account password here. A build configured with one fails from notarytool minutes later with a message about credentials that reads as if the account were wrong, which is how an afternoon goes into checking an account that was fine.
Creating the certificate needs the Account Holder or Admin role, and it is not
the "Apple Development" certificate Xcode makes for you — that one cannot sign
for distribution and Apple will not notarise with it. Xcode → Settings →
Accounts → Manage Certificates → + → Developer ID Application, then export
it from Keychain Access as .p12.
base64 -i DeveloperID.p12 | pbcopy # what goes in APPLE_CERTIFICATE
Build the .p12 with the old algorithms, or the runner cannot open it
OpenSSL 3 defaults to AES-256 and a SHA-256 MAC. macOS 26 imports that
happily; the macos-14 runner does not, and security import fails with
MAC verification failed during PKCS12 import (wrong password?)
which sends you to check a password that is correct. Export from Keychain
Access and this cannot happen. Building it with openssl — the route to take
when the login keychain will not release the key — needs the algorithms
spelled out:
openssl pkcs12 -export \
-inkey developerID.key -in developerID.crt -certfile DeveloperIDG2CA.pem \
-name "Developer ID Application: NAME (TEAMID)" \
-keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 \
-out DeveloperID.p12
Include the G2 intermediate (-certfile), from
https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer: a runner has
no reason to already trust it, and without it codesign produces a signature
whose chain stops short of Apple's root.
And pick G2 Sub-CA when Apple asks. The Previous Sub-CA option issues a certificate that expires on 1 February 2027 whatever the date is today.
Windows¶
Still unsigned; SmartScreen warns and a user clicks More info → Run anyway. An EV certificate is the fix and has not been bought.
Linux¶
No signing. chmod +x the AppImage; install the .deb with sudo dpkg -i.
Auto-update¶
On launch the app makes a silent GET to the GitHub manifest at
releases/latest/download/latest.json. If the published version is newer than
the installed one, a non-blocking banner appears:
┌─────────────────────────────────────────────┐
│ ^ Akaion Runner 0.2.0 available │
│ [Update now] [Later] │
└─────────────────────────────────────────────┘
- Update now — the Tauri plugin downloads the bundle, verifies its signature against the public key embedded in the app, applies it and relaunches.
- Later — dismissed for this session; it returns on the next launch.
- No network, or no manifest response within 10 seconds → silence, no banner.
- In web mode (
npm run devin a browser) the banner never appears.
One-time publisher setup¶
Auto-update needs a Tauri signing keypair, separate from Apple and Windows code signing. Once, before the first tag with auto-update enabled:
-
Generate the keys:
It asks for a password — do not skip it — and writes
~/.tauri/akaion-runner.key, printing the public key. -
In
ui/src-tauri/tauri.conf.json, setplugins.updater.pubkeyto the printed value. Commit and push: releases now verify bundles against this key. -
Repository → Settings → Secrets and variables → Actions:
TAURI_SIGNING_PRIVATE_KEY— the full contents of~/.tauri/akaion-runner.keyTAURI_SIGNING_PRIVATE_KEY_PASSWORD— the password you set
-
Back the key file up off this machine. If it is lost, every installed client rejects all future updates with a signature mismatch, and the only remedy is asking users to reinstall by hand.
-
Optionally keep a local
SIGNING.md— it is gitignored — recording where the key lives, the password-manager entry, and the rotation date.
What CI publishes¶
Into release-assets/:
*.dmg,*.exe,*.AppImage,*.deb— what a person downloads*.app.tar.gz(macOS),*-setup.exe(Windows),*.AppImage(Linux) — what the updater downloads, each with its.siglatest.json— consumed by the updater plugin
Those are not the same list, and confusing them is how the updater stayed broken
through two releases. macOS never produces a .dmg.sig: the updater replaces
an app bundle in place and a disk image is not one, so the signed artefact is the
.app.tar.gz. A manifest that looks for .dmg.sig finds nothing, skips both
Apple platforms with a warning, and publishes a manifest with no macOS entry.
.deb has no manifest entry: apt installations update through apt.
Building locally now fails without the key¶
bundle.createUpdaterArtifacts: true plus a pubkey in the config makes Tauri
fail the build when TAURI_SIGNING_PRIVATE_KEY is unset, rather than quietly
producing an unsigned artefact. That is the behaviour worth having in CI — a
release whose updater silently does not work is worse than one that stops — but
it means a local npx tauri build errors with:
A public key has been found, but no private key.
For local work, generate a throwaway key and use that. It will not match the published pubkey, and Tauri warns about exactly that, which is correct: bundles built this way must not be shipped.
npx tauri signer generate -w /tmp/local.key -p ""
export TAURI_SIGNING_PRIVATE_KEY="$(cat /tmp/local.key)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
Note the variable holds the key's contents. TAURI_SIGNING_PRIVATE_KEY_PATH
is documented by the CLI but the bundler in 2.11 asks for the contents, and the
build fails with the message above if only the path is set.
Web UI¶
The runner serves the same UI over HTTP at http://127.0.0.1:7070, in-process,
without Tauri.
- Open it after
./start.sh. - Sign-in is Firebase (Google or email/password), and entirely optional.
- The UI is built into
ui/dist/on first./start.sh(needs Node 18+). - Rebuild with
cd ui && npm install && npm run build, or./start.sh --rebuild-ui.