Releasing pal
A release is a v* tag. .github/workflows/release.yml bundles it for
every platform onto a draft GitHub release and publishes it, as the latest
release, once every bundle is on it. Nothing is done by hand after the tag.
Steps
- Write the release's section at the top of
docs/changelog.md,## x.y.z · yyyy-mm-ddand a bullet per change a user would notice, and commit it.make releaserefuses a version without one; the section is the GitHub release's notes and what pal.cagdas.io/changelog and pal's What's New show. - On a clean tree:
make release VERSION=x.y.z DRY_RUN=1prints the steps; withoutDRY_RUNit sets the version inapp/src-tauri/tauri.conf.json,app/src-tauri/Cargo.toml,app/package.json,core/Cargo.toml,sdk/package.jsonand the three lockfiles, commits that alone asvx.y.z, tagsvx.y.z, pushes the branch and the tag, and creates the draft release with its notes (app/scripts/release-notes.sh: the changelog section, then the commits since the previous tag, folded). The workflow's token was refused creating a release for v0.4.4 (a 403 withcontents: write) while uploading to an existing draft worked, so the draft is made here and the workflow only uploads. It refuses a dirty tree and a tag that already exists here or on origin (the previous pal's tagsv0.1.2tov0.2.1are on origin). - The tag push starts
release.yml, which refuses a tag that does not matchtauri.conf.json's version. - First a job
extensionsrunsextensions.ymlwithpromote=all(Extensions) and waits for it: everything on edge goes to stable. Then three jobs run (macos-latestfor aarch64 and, cross-compiled, x86_64;ubuntu-24.04for x86_64) and upload to that draft. Each bundles the extensions inapp/bundled.txtas stable serves them (app/scripts/build-extensions.sh: the index checked withminisignagainstPAL_KEYS, each package against its tree hash), so the jobs installminisign. ~13 min each without a cache, about 4 of it the dependencies: a push tomainthat changesCargo.lockor aCargo.tomlruns the same build without bundling (warm) and saves the Rust cache, which a tag run restores (a tag sees only its own caches andmain's). The rest is pal's crates under fat LTO with one codegen unit, kept for the app's speed. A fourth job,manifest, then readslatest.jsonback from the draft and fails if any of the three platforms or any bundle is missing: the three jobs merge into that one file in parallel, and a platform can be dropped when two finish at once. Re-run the missing platform's job; it merges its entry in again. manifestalso checks that every extension the aarch64 bundle ships (its.pal-build.jsonhash) is a build stable lists, not yanked, and fails otherwise: an app compares its bundled copies with the registry by hash, so a mismatch would offer an "update" to what it already runs. Taking them from stable makes it hold by construction; the check stays. A failedextensionsstops the bundles and leaves the draft unpublished: re-run the workflow.- When every bundle is there (with the updater key set: both dmgs, the
AppImage and the deb, the
.app.tar.gzand AppImage.sigs, andlatest.json),manifestpublishes the draft with--latest. The repo's older releases of the previous pal sort by version on their own, andhttps://github.com/zcag/pal/releases/latest/download/latest.json(the in-app updater) and pal.cagdas.io's Download buttons both follow whatever GitHub calls latest; every running pal finds the release on its next check, the site within its 10-minute cache. A failedmanifestleaves the draft unpublished: re-run the missing job, thenmanifest.
The updater
tauri-plugin-updater (app/src-tauri/src/updater.rs) checks for a newer
release once 20 s after startup and then daily while general.check_updates
is on (default), and on demand from the menu bar's "Check for updates…",
Settings › About and the "Check for Updates" row, each of which fetches
every extension registry in the same go. A found release is
offered in three places and installed only on a click: the plugin
downloads the signed bundle (the progress on the HUD and on the About
row), verifies it against the public key, replaces the .app or the
AppImage in place and relaunches pal (Getting
started). A debug build skips the periodic
check and cannot be installed over.
It asks the endpoints in plugins.updater.endpoints (tauri.conf.json) in
order, and takes the first answer:
https://pal.cagdas.io/update/{{target}}/{{arch}}/{{current_version}}(the plugin fills indarwinorlinux,aarch64orx86_64, and the running version). The site answers the latest release'slatest.jsonand counts the check (the version spread, usage). A 204 means no update and ends the check.https://github.com/zcag/pal/releases/latest/download/latest.json, when the site fails: a connection error, a status other than 2xx, or JSON that is not a release. A 200 whose body is not JSON at all is an error that ends the check without trying GitHub, so the site must never answer one.
Released apps before 0.8 know only the GitHub URL, which stays.
latest.json is written by tauri-action from the .sig files of every
job (uploadUpdaterJson), platform keys darwin-aarch64, darwin-x86_64,
linux-x86_64, each with the asset URL and its minisign signature. The
signature is checked against plugins.updater.pubkey in tauri.conf.json
at update time, so the key pair is load-bearing: lose the private key and
no installed pal can update to a build signed with a new one (they would
need a manual reinstall).
The Linux updater only handles AppImages; a .deb or .rpm install sees
the update and is pointed at pal.cagdas.io's download section instead (the manifest
carries the AppImage only; the package manager updates those).
Secrets
Repository secrets (Settings, Secrets and variables, Actions):
| secret | what | required |
|---|---|---|
TAURI_SIGNING_PRIVATE_KEY |
the minisign private key, file contents (tauri signer generate) |
no: without it the release builds with createUpdaterArtifacts off and has no latest.json, so the updater never sees it |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
its password | empty for the current key |
GITHUB_TOKEN |
provided by Actions | yes |
The matching public key is committed in tauri.conf.json. A new pair
(cd app && npx tauri signer generate -w <path>) means replacing both, and
every installed pal older than that release updating by hand once.
Local npm run tauri build needs the key in the environment for the same
reason (TAURI_SIGNING_PRIVATE_KEY=<contents or path>); without one, build
with the updater artifacts off:
npm run tauri build -- --config '{"bundle":{"createUpdaterArtifacts":false}}'.
Extensions
The extensions live in zcag/pal-extensions and the games in
zcag/pal-games; their packages and the indexes are published by this
repo's .github/workflows/extensions.yml to pal.cagdas.io, the only
holder of the signing key (formats, URLs and how it polls the repos:
registry.md). Two indexes: edge, what the extension
repos' main builds, and stable, what every pal follows unless a
registry is set to channel = "edge".
- Edge, within about 15 minutes of a green push to an extension repo's
main.
extensions.ymlruns every 15 minutes (schedule; run it withpublishfor at once). It reads each repo's main head and itscirun there; a green head it has not taken yet is checked out, every extension in it built withpal-pack(--prefix extensions, so an unchanged extension keeps its hash), and those whose hash is not edge's newest build are signed and published to edge. A head that is pending or red waits for a later run, and only a main's head is ever built. A taken head is remembered as a cache entry (polled-<repo>-<commit>), so a run with nothing new is a few API calls and ends green. A build yanked in edge is never published again, even when the tree comes back to it. GitHub turns a schedule off after 60 days without activity in this repo; turn it back on in the Actions tab. - Stable, on
make ext-release [NAMES="a b"]. It dispatches the workflow withpromote(every extension, or those names) and watches the run: edge's newest builds of those go to stable. Like an app release, a promotion is a decision, taken after trying edge. - Stable, at every app release.
release.ymldispatchesextensions.ymlwithpromote=alland waits for it before bundling; a called workflow would not get theregistryenvironment's secrets. Everything on edge goes to stable, the bundles take stable's builds, so the registry never offers anything older than what the app bundles, and themanifestjob checks the bundled hashes against stable. - By hand (Actions › extensions › Run workflow):
refbuilds another commit into edge (it does not wait forci, so pick a green one);yankandreindexare below.
Each run has three jobs:
buildrunsbun install(dependency scripts) and holds no secret.signholds the extension key and installs nothing but Ubuntu'sminisign; bun runspal-pack, which has no dependencies, with auto-install off. It checks the live indexes against the public keys before building on them, and its output against them before anything goes out.publishholds only the site token. It uploads the packages, then edge, then stable, reads each index back from its public URL and compares it byte for byte. The run's summary lists what was published and promoted, with hashes.
Runs go one at a time (concurrency group registry, never cancelled
halfway). GitHub keeps only one run waiting: a third arriving cancels the
waiting one. For edge that loses nothing, as the newer run builds a newer
main; a cancelled promotion shows as a failed make ext-release or
release job, to run again.
The environment. Settings › Environments › registry, with deployment
branches and tags limited to main and v* (the workflow runs on main,
and on a tag when a release calls it):
| name | kind | what |
|---|---|---|
PAL_REGISTRY_KEY |
secret | the extension key: the minisign secret key file's text, made without a password (minisign -G -W) |
PAL_PUBLISH_TOKEN |
secret | the bearer token pal.cagdas.io's /api/registry accepts |
PAL_REGISTRY_PUBKEYS |
variable | the public keys the live indexes may be signed with, space separated: the current key first, then an old one during a rotation |
PAL_REGISTRY_NEXT_KEY |
variable | empty, or the public key the registry is moving to (announced in both indexes as next_key) |
The extension repos (zcag/pal-extensions, zcag/pal-games) hold no
secret: their CI only tests, and this repo's extensions.yml reads them
(public) with its own GITHUB_TOKEN.
The extension key is not the updater key and never reaches release.yml:
the call passes no secrets, and only the jobs that name the registry
environment see it. The key pair's backup is
~/Sync/.secrets/pal/pal-registry.key (and .pub beside it); the public
key is also built into the app (pal_core::registry::PAL_KEYS).
Yanking a build. Run the workflow with yank: name@hash: both indexes
are published again with that build marked yanked in each that lists it (a
build only on edge is yanked there alone; one in neither fails the run). Apps never offer it
and replace an installed copy with the newest good build. A later push that
builds the same tree does not bring it back; fix forward with a change.
Rotating the key.
minisign -G -W -p pal-registry-2.pub -s pal-registry-2.key, both into~/Sync/.secrets/pal/.- Add the new public key to
PAL_KEYSin the app and release it, so apps that update trust it from the start. - Set
PAL_REGISTRY_NEXT_KEYto the new public key and run the workflow withreindex: both indexes, still signed by the old key, announce it. Every later run carries it. - After enough time for apps to have fetched an index since (they check
every 6 hours; allow weeks for machines that are off), set
PAL_REGISTRY_KEYto the new secret,PAL_REGISTRY_PUBKEYSto<new> <old>, clearPAL_REGISTRY_NEXT_KEY, and run withreindex. The run finds the indexes signed by the old key, re-signs every build in them and the indexes with the new one, and publishes both. - Set
PAL_REGISTRY_PUBKEYSto the new key alone, and drop the old key fromPAL_KEYSin a later release.
macOS signing
Every macOS pal, a release or a local make app, is signed with one
self-signed identity, pal-dev. macOS ties Accessibility, Input Monitoring
and Full Disk Access to the signature's designated requirement: for pal that
is identifier "io.cagdas.pal" and certificate leaf = H"b4a22fbb…", the same
across builds, so an update keeps its grants. An ad-hoc signature (what
releases had up to v0.4.3) is the build's own hash, so each update dropped
them and the switch in the list stayed on doing nothing.
- The identity is a
.p12in the Syncthing secrets (~/Sync/.secrets/pal/pal-dev.p12, its password inpal-dev.envthere; valid to 2036). CI has it as thePAL_DEV_P12(base64) andPAL_DEV_P12_PASSWORDsecrets;release.ymlfails a release without them rather than ship an ad-hoc one. app/scripts/signing-key.shimports it: in CI into a keychain of its own on the search list, locally into the login keychain. tauri's ownAPPLE_CERTIFICATEimport is not used: it only resolves Apple's certificate names (Developer ID Application:and the like).tauri.conf.jsonkeepssigningIdentity: "-", so a checkout without the key still builds;release.ymlandmake apppasspal-devover it.make appimports the key when the keychain lacks it and refuses to build when there is none.
It is not notarised: Gatekeeper still refuses the first launch of a downloaded copy once per install (the dialogs and the ways past them: Getting started). For a Developer ID build, signed and notarised:
- A Developer ID Application certificate (Apple Developer Program). Export
it as
.p12; base64 of the file isAPPLE_CERTIFICATE, its passwordAPPLE_CERTIFICATE_PASSWORD, and its name (Developer ID Application: Name (TEAMID))APPLE_SIGNING_IDENTITY. - Notarisation credentials:
APPLE_ID,APPLE_PASSWORD(an app-specific password),APPLE_TEAM_ID. - In
tauri.conf.json:signingIdentityto the Developer ID name (theAPPLE_SIGNING_IDENTITYenv only applies when the config leaves it unset),hardenedRuntime: true, and anentitlementsplist. The bun sidecar is a JIT (JavaScriptCore), so hardened runtime needs at leastcom.apple.security.cs.allow-jit; Bun's own signing notes (docs forbun build --compile, "Codesigning on macOS") also listcom.apple.security.cs.allow-unsigned-executable-memoryandcom.apple.security.cs.disable-executable-page-protection. Start with all three and drop what a notarised build still runs without. - Uncomment the
APPLE_*lines inrelease.yml. tauri-bundler then signs the sidecar and the app inside out, submits to notarytool and staples.
Cost: the Apple Developer Program is USD 99 a year
(developer.apple.com/programs); notarisation itself is free within it, takes a
few minutes per build in notarytool, and needs the sidecar's entitlements to
hold up under the hardened runtime, which is the part to test first with a local
npm run tauri build and spctl -a -vv on the result.
Until then, the dmg is what README.md says: signed with pal-dev, not
notarised, refused once by Gatekeeper on first launch.
CI
ci.yml runs on every push and pull request on the same two runners:
clippy with -D warnings, cargo test --workspace (tests that need a
pasteboard, an unlocked keychain or the fixture corpus are #[ignore]d and
run by hand with --ignored), a cargo build of the app crate (build.rs,
tauri-build, no bundle), tsc and vitest in app/, tsc and bun test
in host/ over checkouts of zcag/pal-extensions and zcag/pal-games at
main (PAL_EXTENSION_REPOS points the harness, the gallery and the Rust
tests at them), with the bundled extensions' tests from pal-extensions, the
declaration build and a
npm pack --dry-run in sdk/. Caches: cargo (rust-cache), npm
(setup-node), bun's package cache and the fetched bun sidecar
(app/src-tauri/binaries, keyed on fetch-bun.sh).
Rendered from docs/releasing.md in the pal repo, 2026-11-02.