Release Process
Releasing AI Switch is fully automated. Push a conforming version tag and .github/workflows/release.yml handles validation, three-platform builds, signing, and publication.
What triggers it
The workflow listens for tag pushes only, and the tag must match v*.*.*:
on:
push:
tags:
- "v*.*.*"The actual format check inside the job is stricter than that glob. The regex requires v<major>.<minor>.<patch> with an optional -rc / -beta / -alpha prerelease suffix, which may itself carry a .<number>. Valid examples:
v0.6.7
v0.6.7-rc.1
v0.7.0-beta
v1.0.0-alpha.2The concurrency group keys on github.ref with cancel-in-progress: false, so a release in flight is never interrupted by a later push.
Three jobs
| Job | Runner | Responsibility |
|---|---|---|
prepare | ubuntu-latest | Validate the tag and version consistency, verify the tag's branch ancestry, create a draft release with generated notes |
build | matrix: windows-latest / macos-latest / ubuntu-latest | Run all checks, build the sidecar, package the Tauri installers and standalone server, upload artifacts |
publish | ubuntu-latest | Collect artifacts, generate and verify latest.json, promote the draft to a real release |
The dependency chain is prepare → build (parallel matrix) → publish. build sets fail-fast: false, so one platform failing does not cancel the others — you get to see every problem in a single run.
prepare: two gates before anything is built
1. The version must match in three places
With the v prefix stripped, the tag must be exactly identical, prerelease suffix included, to:
versioninpackage.jsonversioninsrc-tauri/tauri.conf.json
The check happens in two steps: first confirm package.json and tauri.conf.json agree with each other, then confirm the tag agrees with them. Either mismatch is an immediate exit 1.
In other words, tag v0.6.7 requires both files to say 0.6.7; tag v0.7.0-rc.1 requires both files to say 0.7.0-rc.1.
The usual mistake
Bumping package.json and forgetting tauri.conf.json (or the reverse) is by far the most common way to trip this gate. Confirm both before tagging. The project's own version in src-tauri/Cargo.toml and Cargo.lock should be kept in sync too — CI does not check it, but a mismatch leaves the build artifacts with inconsistent metadata.
2. The tag must be based on the default branch
git fetch origin "$DEFAULT_BRANCH"
tag_commit="$(git rev-list -n 1 "$GITHUB_REF_NAME")"
if ! git merge-base --is-ancestor "$tag_commit" "origin/$DEFAULT_BRANCH"; then
echo "Tag ${GITHUB_REF_NAME} is not based on ${DEFAULT_BRANCH}."
exit 1
fiThe tagged commit must be an ancestor of the default branch. Tagging on a feature branch and pushing it is rejected — this check exists to stop unmerged code from shipping by accident.
3. Create the draft release
Once both gates pass, ncipollo/release-action creates a draft release (draft: true) with automatically generated notes (generateReleaseNotes: true). Whether it is marked as a prerelease is derived from the tag name:
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}The draft state matters: users never see a half-populated release while builds run, and it only flips to published after publish succeeds.
build: the three-platform matrix
| Label | Runner | Bundle formats |
|---|---|---|
| Windows | windows-latest | nsis |
| macOS | macos-latest | app, dmg |
| Linux | ubuntu-latest | deb, appimage |
Each platform runs the same sequence:
Validate the signing secret. If
TAURI_SIGNING_PRIVATE_KEYis empty the job throws immediately — unsigned updater artifacts are never produced.Linux system dependencies. Linux only:
apt-get installforlibwebkit2gtk-4.1-dev,libayatana-appindicator3-dev,librsvg2-dev,patchelf,libgtk-3-dev.Toolchains. pnpm 10.12.4, Node 22 (with pnpm cache), stable Rust, stable Go (cached on
sidecar/ai-switch-tsnet/go.sum).Install dependencies.
pnpm install --frozen-lockfile.Compute platform variables. Reads the host triple from
rustc -vVand derives the updater platform id (windows-x86_64,darwin-aarch64,linux-x86_64, …),APP_VERSION, plus the sidecar and server binary paths.Frontend checks.
pnpm typecheck,pnpm test:run,pnpm release:manifest:test.Build the frontend.
pnpm build.Sidecar tests and build.
go test ./..., thengo build -trimpath -ldflags="-s -w"intosrc-tauri/binaries/ai-switch-tsnet-<triple><suffix>, asserting the file actually exists afterwards.Rust checks.
pnpm rust:check,pnpm rust:test.Package with Tauri.
pnpm tauri build --ci --bundles <that platform's formats>.Build the standalone server.
pnpm server:build:release.Stage assets.
scripts/stage-release-assets.mjsrenames the installers fromsrc-tauri/target/release/bundletoai-switch-<version>-<platform>(.exealways gets the-setupsuffix), names the updater-only.app.tar.gz/.nsis.zippayloadsai-switch-updater-<version>-<platform>, and keeps every.sigbeside the file it signs. The deb intermediates (control.tar.gz,data.tar.gz,debian-binary) and anything inside the built.appare skipped. Missing installers or missing signatures fail the step. The server and sidecar binaries are zipped separately asai-switch-server_<tag>_<platform>.zipandai-switch-tsnet_<tag>_<platform>.zip.The naming is not arbitrary: the GitHub release page sorts assets by name and folds all but the first few behind "Show all N assets". Putting the version right after
ai-switch-sorts the installers ahead ofai-switch-server_andai-switch-tsnet_, so the Windows.exeand the macOS.dmgpeople came for stay visible.<platform>stays the updater platform id rather than a friendlierwindows-x64because the package-manager publishing picks its installers by that token (see below).Upload artifacts. Named by updater platform id, with
if-no-files-found: error.
Note that CI reruns the full check suite on each platform. A single release therefore executes the tests three times, which is how platform-specific problems surface here rather than in the wild.
Updater signing
Tauri's updater requires minisign signatures alongside the installers. The key is injected through repository secrets:
| Secret | Required | Notes |
|---|---|---|
TAURI_SIGNING_PRIVATE_KEY | Yes | The minisign private key. Missing it fails the first step of build |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD | Optional | The key's passphrase, only needed if the key has one |
The matching public key is committed in plugins.updater.pubkey in src-tauri/tauri.conf.json, and the update endpoint points at:
https://github.com/ijry/ai-switch/releases/latest/download/latest.jsonbundle.createUpdaterArtifacts: true is what makes Tauri emit the updater artifacts and their .sig files during packaging.
publish: manifest generation and signature verification
Download every artifact.
actions/download-artifactpulls all three platforms intorelease-assets/, one subdirectory per platform.Restore the release notes. The
preparejob'srelease_notesoutput is written back torelease-notes.md. An empty file fails the job.Generate the updater manifest.
scripts/create-updater-manifest.mjsidentifies the target platform from the subdirectory name and picks the updater asset by a preference order (Windows prefers.exethen.msi; macOS prefers.tar.gzthen.dmg; Linux prefers.AppImagethen.deb), writingrelease-assets/latest.json:bashnode scripts/create-updater-manifest.mjs \ --assets-dir release-assets \ --tag "<tag>" \ --repo "<owner/repo>" \ --output release-assets/latest.json \ --notes-file release-notes.mdVerify signing key consistency.
scripts/verify-updater-signatures.mjsextracts the signer key ID from each platform's signature payload in the manifest and compares it against the key ID derived frompubkeyintauri.conf.json:bashnode scripts/verify-updater-signatures.mjs \ --manifest release-assets/latest.json \ --tauri-config src-tauri/tauri.conf.jsonThe point of this check: if the signing key is rotated without updating the public key in the config, already-installed clients fail verification and the update path breaks silently. Catching that before publishing is far cheaper than recovering afterwards.
Delete the
.sigfiles. The signatures are inlined inlatest.jsonby now, and the client only ever reads the manifest — it never fetches a sibling.sig. Sofind release-assets -name '*.sig' -deletekeeps them from spending the few asset rows the release page shows.Build the release body.
scripts/create-release-body.mjsscans the platform subdirectories for installers and prepends a bilingual download table (one row each for Windows, macOS, and Linux, plus a line for the standalone server) to the tag message, writingrelease-body.md:bashnode scripts/create-release-body.mjs \ --assets-dir release-assets \ --tag "<tag>" \ --repo "<owner/repo>" \ --output release-body.md \ --notes-file release-notes.mdThe table goes into the GitHub release body only. The manifest's
notesstay the verbatim tag message, because the desktop client splits it on the 29-hyphen separator to pick a language and a stray table would leak into the changelog.Promote to a real release.
ncipollo/release-actionruns again withdraft: false,bodyFile: release-body.md,replacesArtifacts: true, andartifactErrorsFailBuild: true, uploading everything underrelease-assets/**/*.*includinglatest.json.
Cutting a release
Stable
Confirm the working tree is clean, versions are in sync, and CI is green on main, then:
git tag -a v0.6.8 -m "Release v0.6.8"
git push origin main
git push origin v0.6.8Prerelease
A -rc / -beta / -alpha suffix in the tag name marks it as a prerelease automatically:
git tag -a v0.7.0-rc.1 -m "Release v0.7.0-rc.1"
git push origin v0.7.0-rc.1Remember that package.json and src-tauri/tauri.conf.json must both read the full 0.7.0-rc.1.
Recovering from a bad tag
If the tag has not been pushed yet, just delete and redo it:
git tag -d v0.6.8If it was pushed and the workflow failed, delete the remote tag first. Note that GitHub may still hold a draft release, which you should remove manually — otherwise allowUpdates: true makes the next run reuse it:
git push origin :refs/tags/v0.6.8
git tag -d v0.6.8The better habit is running the full check suite locally before tagging — see "Run every check at once" in local setup. A three-platform release run is not fast, and letting CI discover problems you could have caught locally wastes real time.
What a release produces
A successful run attaches the following to the GitHub Release, in the order the asset list shows them:
- Windows:
ai-switch-<version>-windows-x86_64-setup.exe - macOS:
ai-switch-<version>-darwin-aarch64.dmg - Linux:
ai-switch-<version>-linux-x86_64.AppImageandai-switch-<version>-linux-x86_64.deb - Per platform:
ai-switch-server_<tag>_<platform>.zip(standalone server) - Per platform:
ai-switch-tsnet_<tag>_<platform>.zip(Tailscale sidecar) - macOS:
ai-switch-updater-<version>-darwin-aarch64.app.tar.gz(only the auto-updater downloads it) latest.json: the Tauri updater manifest that drives desktop auto-updates
The .sig files are not published as separate assets; their signatures live inside latest.json. The release body also opens with a download table pointing straight at the first three groups above.
How users get these is covered in installation; running the server build is covered in standalone server.
Publishing to package managers (Homebrew / WinGet)
.github/workflows/package-managers.yml hands a release that is already published to the package managers. Keeping it out of release.yml is deliberate: a winget submission waits on Microsoft's review and a Homebrew push can fail on an expired token, and neither should be able to hold up or fail the release itself. The other direction matters too — re-submitting a tag from months ago needs no rebuild.
What triggers it
| Trigger | Behaviour |
|---|---|
release.yml finished publishing | Runs automatically: the last step of the publish job dispatches this workflow with the tag it just published |
| Someone published a release by hand | Runs automatically, tag taken from the event |
Manual workflow_dispatch | Pass tag to publish any past release; leave it empty for the latest one |
The release: published event does not fire for a release the pipeline published. GitHub does not start workflow runs from events created by its own GITHUB_TOKEN, and that is the token release-action publishes with — which is why v0.8.1 went out with no package-manager run at all. workflow_dispatch is one of the two documented exceptions to that rule (repository_dispatch is the other), so release.yml dispatches this workflow explicitly once the release is out, passing the tag as the ref so the manifests are rendered by the same commit that produced the installers. A dispatch that fails only logs a warning: a published release should not be marked failed because of the package managers, and the missing run can be repeated by hand with the same tag.
A manual run has three more switches: homebrew and winget can each be turned off, and dry_run renders and checks the manifests without touching any external repository.
dry_run needs no secret at all: the two steps it skips are the only two that read one (pushing the cask, opening the winget PR), so the whole rehearsal works before the tap repository and the tokens exist — including a real brew install --cask on macos-latest that asserts the quarantine attribute was cleared. For an ad-hoc signed bundle that rehearsal is how you find out whether Homebrew still accepts this channel at all, which is what decides whether creating the tap is worth it.
Drafts and prereleases (-rc / -beta / -alpha) are always skipped, with the reason in the log rather than a failure: a draft's assets have no public download URL yet, and a prerelease in the tap would reach everyone who runs brew upgrade.
What has to be configured
Both paths write to someone else's repository, so both need a repository secret. A missing secret does not fail the workflow — it logs a warning, skips that path, and lets the other one run.
| Secret / Variable | Kind | Purpose |
|---|---|---|
HOMEBREW_TAP_TOKEN | secret | PAT with contents: write on the tap repository |
HOMEBREW_TAP_REPO | variable, optional | Tap repository, defaults to ijry/homebrew-ai-switch |
WINGET_TOKEN | secret | Classic PAT, public_repo scope only |
WINGET_FORK_USER | variable, optional | Account holding the winget-pkgs fork, defaults to the repo owner |
The WinGet token has to be a classic PAT
The tool that opens the pull request is Komac, which goes through GitHub's GraphQL API — and a fine-grained token can only reach GraphQL for resources whose owner matches the token's own. The target is microsoft/winget-pkgs, so fine-grained tokens and GitHub Apps both fail here.
Homebrew: one-time setup
- Create a public repository
ijry/homebrew-ai-switch. The name has to start withhomebrew-forbrew tap ijry/ai-switchto resolve. - Generate a PAT with
contents: writeon it and store it asHOMEBREW_TAP_TOKEN.
From then on each release renders the cask on macos-latest, stages it in a local tap, actually runs brew install --cask, asserts /Applications/AI Switch.app exists with the quarantine flag cleared, and only then pushes to the tap. For an ad-hoc signed bundle that install check is the one that matters: a cask that parses fine can still install an app that refuses to open.
The cask does the Gatekeeper dance for the user
The macOS bundle is ad-hoc signed and never notarized (see the section in installation), so the cask carries a postflight that runs xattr -dr com.apple.quarantine on the copy Homebrew just placed. It touches that one path and changes no system setting, but it saves the user the manual "System Settings → Open Anyway" trip.
The cask also declares depends_on arch: :arm64 (CI only builds Apple Silicon) and auto_updates true (the app's own updater replaces the bundle, so what Homebrew recorded goes stale by itself).
WinGet: the first version has to be submitted by hand
The automation can only do version bumps, never the initial listing. winget-releaser's very first step checks whether the package exists in microsoft/winget-pkgs and errors out if it does not:
::error::Package ijry.AISwitch does not exist in the winget-pkgs repository.
Please add atleast one version of the package before using this action.So the one-time setup is three steps:
- Fork
microsoft/winget-pkgsunderijry— the tooling will not create the fork for you. - Submit the first version of
ijry.AISwitchby hand with Komac or wingetcreate and wait for a winget maintainer to merge it. The identifier is case-sensitive and has to match its directory path exactly (manifests/i/ijry/AISwitch/<version>/). - Generate a classic PAT (
public_repo) and store it asWINGET_TOKEN.
Each release then opens a pull request against microsoft/winget-pkgs. A Microsoft maintainer has to merge it before the version reaches users, and that step is outside our control.
The installer itself does not need code signing
Nothing in winget-pkgs' policy docs requires code signing, and SignatureSha256 only applies to MSIX/APPX. Its validation pipeline cares about other things: multi-engine antivirus scanning, a silent install as a non-elevated user, and post-install registry entries that agree with the manifest's Publisher and PackageName.
Tauri's NSIS installer defaults to currentUser mode, so it needs no elevation, and InstallerType: nullsoft needs no hand-written silent switches — the winget client supplies /S once it recognises nullsoft.
What users run
Once both setups above are done and each channel has its first version landed:
# macOS (Apple Silicon)
brew tap ijry/ai-switch
brew install --cask ai-switch# Windows
winget install ijry.AISwitchUntil then neither command finds the package, which is why installation still only documents downloading from Releases.
How the manifests are generated
scripts/create-package-manifests.mjs starts from the release's API response and does three things:
- Picks the installers. It matches on the updater platform token (
darwin-aarch64,windows-x86_64) plus the extension, excluding.sig,.app.tar.gzand.nsis.zip— payloads only the updater ever downloads. The repo has shipped two asset naming schemes and both match; zero matches or more than one is an error rather than a guess. - Resolves sha256. The releases API now reports a
digestper asset, so the usual path never pulls the 31 MB dmg down to hash it. Assets from older releases have no digest and fall back to a streaming download. - Renders the cask and writes winget's inputs to
summary.json. Theinstallers-regexhanded towinget-releaseris an anchored pattern built from the file name step 1 already resolved, so its own second lookup cannot disagree with step 1.
pnpm release:manifest:test covers the script — every platform in release.yml runs it — and the cases pin v0.8.0's real asset list.
See also
- Local setup — the checks to pass before tagging
- Architecture — what each packaged piece actually is
- Installation
- FAQ