# Releasing OpenBot For iOS builds uploaded to TestFlight through GitHub Actions, see [the mobile release guide](../apps/mobile/README.md#github-actions-testflight-release). For Android builds uploaded to Google Play, see [the Google Play release guide](../apps/mobile/README.md#github-actions-google-play-release). The mobile workflows are separate from the desktop tag release described below. OpenBot updates are published through GitHub Releases and installed with `electron-updater`. macOS requires every auto-updatable build to be signed with a Developer ID Application certificate. The release workflow also notarizes and staples the macOS application before publishing it. A Windows x64 tag release is signed with Azure Artifact Signing (see [Windows signing](#windows-signing)). Linux releases are unsigned, and the Linux AppImage carries no signature. All three platforms must pass before one release is published. A release also requires the pinned Sunshine and Moonlight Web runtime artifacts. GitHub Actions downloads those artifacts, checks SHA-256, and verifies their native executables as part of the final OpenBot package. Release packages are not built on a developer machine. Codex, Claude, and Grok runtimes are optional downloads. They are not part of the application package. Release CI downloads the pinned macOS, Windows, and Linux provider artifacts as control artifacts. It checks their SHA-256 values, versions, licenses, and vendor signatures without copying them into OpenBot. Linux has no code-signature contract to check, so its provider artifacts are verified by SHA-256 and version only. Installed apps do not wait for a release to get a new provider CLI: they offer the latest upstream release (see [Provider CLI updates](architecture/providers.md#provider-cli-updates)). The pinned version is the first-install fallback. To stop a broken upstream release, add its version to the provider's list in `provider-runtime-blocklist.json` and merge it to `main`. Apps read the list at their next check. A blocked version is no longer offered, but it stays on the computers that already installed it. ## One-time GitHub setup Create the `release` environment in `nightly-labs/openbot`, then add these environment secrets: - `CSC_LINK` — a base64-encoded Developer ID Application `.p12` file. - `MAC_PROVISIONING_PROFILE` — the base64-encoded Developer ID provisioning profile for `app.openbot.desktop`, with the `applinks:openbot.run` entitlement. - `CSC_KEY_PASSWORD` — the application `.p12` export password. - `CSC_INSTALLER_LINK` — a base64-encoded **Developer ID Installer** `.p12` file for team `ZTRDTUL87R`. - `CSC_INSTALLER_KEY_PASSWORD` — the installer `.p12` export password. - `APPLE_ID` — the Apple Account used for notarization. - `APPLE_APP_SPECIFIC_PASSWORD` — a dedicated app-specific password for `notarytool`. - `APPLE_TEAM_ID` — the Apple Developer team ID. Do not use an Apple Development certificate. Direct distribution and native macOS updates require a Developer ID Application certificate. Never commit signing credentials to the repository. ### Docker package access The Docker image needs no secret: the `docker-publish` job pushes with `GITHUB_TOKEN`. [GHCR makes new packages private](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry), even when the source repository is public. A successful push does not confirm public access. After the first push, a package administrator must open the [OpenBot package](https://github.com/orgs/nightly-labs/packages/container/package/openbot): 1. Open **Package settings** and set **Change visibility** to **Public**. 2. Check that the package is linked to `nightly-labs/openbot`. The Dockerfile sets the `org.opencontainers.image.source` label for this link. 3. Under **Manage Actions access**, check that `nightly-labs/openbot` has write access. If **Public** is disabled by organization administrators, an organization owner must enable **Public** under **Package creation** in the [organization package settings](https://github.com/organizations/nightly-labs/settings/packages). Make OpenBot public, then restore the previous organization policy. Existing public packages stay public when this creation permission is disabled again. If a release pushed its image but users cannot pull it, check these settings first. Changing visibility makes the existing tags public; no rebuild is needed. Tags `0.30.0` and `0.31.0` have no `v` prefix. Later releases also publish `v` as an alias. Check access with an empty Docker configuration, so a saved login cannot hide the fault: ```sh anonymous_config=$(mktemp -d) docker --config "$anonymous_config" manifest inspect ghcr.io/nightly-labs/openbot:latest docker --config "$anonymous_config" manifest inspect ghcr.io/nightly-labs/openbot:0.30.0 rm -rf "$anonymous_config" ``` The release workflow checks anonymous access to the version, `v` and `latest` tags, including both Linux architectures. A failure leaves the GitHub Release and pushed images in place. Correct the package settings, then run the failed job again. ## Hosted-server snapshots After GitHub publication, `Publish boat server snapshot` builds the Linux x64 release into a boat named snapshot (`openbot-server-production-` with dots replaced by hyphens), then selects it for new production servers. It uses the tagged hosting scripts, the published `SHA256SUMS-linux.txt`, and `https://api.openbot.run`. The builder checks the AppImage checksum and installed version and requires an empty host profile before saving the snapshot. Existing servers and the test Worker do not change. Before the first release with this job, configure the `cloudflare-production` GitHub Environment: - Add `BOAT_TEMPLATE_API_KEY`, a separate boat key with sandbox, file, command and named snapshot access. Keep the limited `BOAT_API_KEY` on the Worker. - Keep `CLOUDFLARE_PRODUCTION_DEPLOY_TOKEN` and the `CLOUDFLARE_ACCOUNT_ID` variable used by deployment. - Permit the tag pattern `v*.*.*` in addition to the `main` branch. The release job runs on a tag. `release:preflight` checks these settings. The hosted job and production Worker deployment share one concurrency group. The job checks GitHub's latest stable release before building and again before selection. It writes only the Worker's `HOSTED_SERVER_TEMPLATE` secret. Normal CI and local production deployments preserve it; the old GitHub variable is ignored. `hosting:setup` now sets up billing and webhooks only and no longer accepts `--template`. A failed hosted job leaves the GitHub Release published and the previous template selected. Run the failed job again after correcting the cause. A ready snapshot is reused; a save in progress is polled; a failed snapshot requires operator inspection. The builder never replaces an existing snapshot. It stops at 10 snapshots even if boat permits paid storage above that count. Before the build, the job removes production snapshots of older releases. It keeps the 2 newest ready ones, so 3 releases remain with the new one; the newest of them stays selected until the new release replaces it. A failed snapshot of an older release is removed too. It keeps a snapshot that is saving or has an unknown status, any snapshot newer than the release, and each `provider_template` of a production server that is not deleted and has no sandbox yet, because a create retry sends its first request again. The job reads these from the production D1 database and stops if it cannot. Servers already made from a removed snapshot keep running. Names without the `openbot-server-production-` prefix are never removed; confirm that neither Worker nor any pending create needs one before removing it by hand. A successful job reports the selected version in its Actions summary. Verify the first rollout by creating a temporary production server and checking its initial installed version. Record the result under `.openbot-build/`; remove only that temporary server after the check. This remote check is separate from local checks and requires production access. ## Windows signing A tag build signs the Windows release with Azure Artifact Signing. It needs no secret: - The Artifact Signing account `synthetifyartifactsign` (East US, `https://eus.codesigning.azure.net/`) holds the Public Trust certificate profile `SYNTHETIFY`, issued to `SYNTHETIFY LABS SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ`. Microsoft issues a new short-lived certificate each day with the same subject. - The Entra app registration `openbot-release-signing` has the **Artifact Signing Certificate Profile Signer** role on that profile only. Its federated credential trusts the subject `repo:nightly-labs@100160810/openbot@1332461149:environment:release`, because this repository uses GitHub's immutable OIDC subject format. A dry run has no environment, so it cannot sign. - The `windows` job logs in with `azure/login`, caches a token for the signing service, and runs `bun run dist:win --config electron-builder.windows-signing.yml`, then runs `az account clear`, so the steps after the build have no Azure session. That overlay adds `win.azureSignOptions` and `forceCodeSigning`. electron-builder signs `OpenBot.exe`, every `.exe` from `extraResources` (the remote desktop runtime and the Computer Use driver), the NSIS installer, and its uninstaller. It does not sign the `.node` and `.dll` files of the voice runtime, because the overlay sets no `win.signExts`. The provider CLIs are not packaged on Windows, so their vendor signatures do not change. - `verify:package:win --require-signature` requires a valid signature with the `app-update.yml` publisher on each of those executables, and the next step requires the same signer on the installer. A dry run requires `NotSigned` instead. - `-f mode=windows-signing` checks the login and the signing service without a release (see [Publish a version](#publish-a-version) for the command). electron-builder writes `publisherName` to `app-update.yml`. An installed signed build accepts an update only when its installer certificate has that CN. A change of the legal name or of the identity validation therefore stops auto-updates for every signed install: ship a release that lists both names in `publisherName` before the certificate changes. An unsigned install has no `publisherName` and accepts the first signed update. The signature removes the Unknown publisher warning. SmartScreen can still warn until the new certificate builds download reputation. ## Build the remote desktop runtime `native-runtime.lock.json` pins the upstream source for Sunshine `v2026.516.143833` and Moonlight Web `v2.10.0` by full commit and source archive SHA-256. Each entry also records the reviewable OpenBot patch applied to that source. Build on the target platform: ```bash bun run build:remote-desktop-runtime bun run verify:remote-desktop-runtime ``` The command writes binaries, the static Moonlight viewer, GPL-3.0 licenses, corresponding-source metadata, and SHA-256 checksums under `build/remote-desktop-runtime//`. Publish the exact corresponding source for both GPL components with every binary release. A release must stop if a binary, license, source manifest, checksum, platform signature, or notarization result is missing. Use this source build only to make or reproduce a runtime version. The `.github/workflows/remote-desktop-runtime.yml` workflow builds macOS ARM64, macOS x64, Windows x64 and Linux x64 when the recipe or a pinned input changes. It publishes an immutable GitHub prerelease named `remote-desktop-runtime-`. The prerelease contains the four deterministic archives, SPDX SBOMs, build provenance, and `remote-desktop-runtime-manifest.json`. It is not an OpenBot application update and it must never contain `latest.yml`. PR pushes do not cancel an active runtime build. The next run reuses a successful native build from the same PR and platform when its native inputs and build tools are unchanged. It still runs verification and the macOS or Linux smoke test against the current checkout. A cache miss rebuilds the runtime. Pushes to `main` and manual dispatches do not use the PR build cache. After publication, the workflow opens a draft PR that adds the release tag and SHA-256 values to `native-runtime.lock.json`. That job runs only from `main`, because it pins against the lock it checks out: the input digest is derived from the recipe on disk, and a manifest built from a different recipe is refused. So a branch that changes the recipe pins by hand, and must, or merging it leaves `main` with an unpinned lock -- which is not a degraded state but a broken one, because `prepare-remote-desktop-runtime.ts` throws and takes every `package`, `dist:mac`, `dist:win` and `dist:linux` run with it. From the branch: ```bash gh workflow run remote-desktop-runtime.yml --ref # once Publish and Verify published are green: gh release download remote-desktop-runtime- --pattern remote-desktop-runtime-manifest.json bun scripts/pin-remote-desktop-runtime.ts remote-desktop-runtime-manifest.json ``` To repeat verification after a download or CI setup failure, without replacing the published artifacts, run `gh workflow run remote-desktop-runtime.yml --ref -f verify_only=true`. This mode requires an existing release for the current input digest and runs installation, runtime verification, the macOS and Linux smoke tests, and application packaging. It does not build or publish. Commit the rewritten `native-runtime.lock.json` to the branch and merge it with the recipe, so `main` never sees the two apart. The pin does not change the input digest -- it covers `recipeVersion`, both source entries and `targets`, not the artifacts -- so it cannot invalidate the release it just pinned. Normal CI and application release jobs then use: ```bash bun run install:remote-desktop-runtime bun run verify:remote-desktop-runtime ``` The installer accepts only the exact prerelease and assets in the lock file. It rejects a changed manifest, a changed archive, an unsafe archive path, and a mismatched source manifest. Do not replace assets in an existing runtime prerelease. Increase `recipeVersion` when the build process changes. ### Sunshine security backports (runtime recipe 12) The Sunshine upstream base remains `v2026.516.143833` to keep the tested macOS input backend. The OpenBot patch includes these upstream security changes and regression tests: - `1583e7c4a7e99538c7700315a1d2a2101c6d2812`: validate input packets before queueing and dispatch (GHSA-26q2-58j6-qmvv and GHSA-6w33-pjh7-p77c). - `82bccdf69894ee03ac422cc787f1ac9654da359d`: reject short ENet control packets (GHSA-c428-87f8-rrv5). - `ccf97e38796be6cfcbff0ef248a684d39e181eba`: bind pairing approval to an explicit, expiring request ID (GHSA-36ff-frg7-492f). - `4d768847fcd88cc94ac745c4611715c67d7d67e1`: require the exact enabled client certificate and canonicalize stored certificate identities (GHSA-6jvv-jqr7-m6m3). Backport adaptations retain the older platform APIs and test fixtures. Native CI builds and runs only the relevant packet, pairing, REST authorization, and certificate regression tests. The local Moonlight client uses a random pairing name and approves only its matching loopback request ID. Moonlight now builds from the same upstream commit with the existing OpenBot patch and a fix that sends the configured pairing name instead of the upstream hard-coded name. The published runtime uses a new recipe/input digest; no existing release assets are replaced. The Linux GUI capability advisory GHSA-fp6g-27w5-489j does not apply: the Linux runtime is built with the tray off, and OpenBot gives Sunshine no file capabilities. ### Linux runtime (recipe 16) The `linux-x64` target builds on Ubuntu 24.04 with GCC 14, X11 capture and software encoding only (CUDA, DRM, KWin, VAAPI, Vulkan, Wayland, portal and tray are off). The OpenBot patch adds an XTest input backend, because Xvfb does not read uinput devices. It also links libcap when DRM is off and adds a `vaMapBuffer2` shim, because the prebuilt FFmpeg needs libva 2.21 and Ubuntu 24.04 has 2.20. The runtime uses the system libraries of Ubuntu 24.04 that Ubuntu 26.04 also has; the hosted server template installs them (the remote desktop part of `scripts/hosting/packages.txt`). Since recipe 16, Boost.Locale has no ICU backend, and the runtime carries `lib/libminiupnpc.so.17` with its Debian licence, because Ubuntu 26.04 has only `libminiupnpc21`. Sunshine finds it through its RUNPATH `$ORIGIN/lib`. The build job checks with `ldd` that each executable finds all its libraries on Ubuntu 26.04. Linux arm64 builds only for local development and has no release artifact. ## Pin the Gemini server `native-runtime.lock.json` pins Google's Antigravity ACP server for the Gemini provider by hand: the version from the ACP registry entry `antigravity-acp`, and for each target the zip name, its SHA-256 and size, and the SHA-256 of `agy_acp_server` and `localharness_external` in it. Download each zip from the registry `archive` URL, hash the zip and the two files, and set `installedBytes` above the extracted size. Do not commit the zip: Google's license does not allow redistribution. ## Pin the Cursor CLI `native-runtime.lock.json` pins the Cursor CLI by hand. Read the ACP registry entry `cursor`: its `version` is the date, and each `archive` URL has the build, a date and a commit such as `2026.09.28-64d2043`. Set `version` to the build. For each target, download `downloads.cursor.com/lab////agent-cli-package.tar.gz` (`.zip` on Windows), and set the archive SHA-256 and size, `installedBytes` above the extracted size, and the SHA-256 of each file in `files`. The paths in `files` are relative to `dist-package/`. Do not commit the archives: Cursor's terms do not allow redistribution. ## Pin the Cline CLI `native-runtime.lock.json` pins the Cline CLI by hand. Read the npm `latest` version of `@cline/cli-darwin-arm64` and set `version`. For each target, download the platform package `@cline/cli--` (`windows-x64` for Windows) from `registry.npmjs.org`, and set the tarball SHA-256 and size, the SHA-256 of `bin/cline` (`bin/cline.exe` on Windows) and of `extensions/plugin-sandbox-bootstrap.js`, and `installedBytes` above the extracted size. The npm packages have no license file, so set `licenseSha256` to the SHA-256 of `LICENSE` at the `cli-v` tag of `github.com/cline/cline`. ## Pin the OpenCode CLI `native-runtime.lock.json` also pins the OpenCode CLI that OpenBot downloads for the OpenCode provider before its first update check answers, by `@opencode/cli-*` npm platform package (OpenCode 2), asset SHA-256, extracted binary SHA-256, byte counts, and the MIT license file. The OpenCode 2 tarballs carry no license, so the lock names an `opencode-ai` (OpenCode 1) release in `licensePackage` and the app installs the same MIT file from it. Codex, Claude, and Grok are pinned in the same file by hand; OpenCode has a script, because the version, all five platform packages, and the license have to agree: ```bash bun run pin:opencode-runtime # the newest published release bun run pin:opencode-runtime 2.1.1 # one exact version bun run pin:opencode-runtime 2.1.1 1.19.3 # and one exact `opencode-ai` release for the license ``` The script downloads all five platform tarballs, checks that `package/package.json` names the package and version the lock claims, hashes the extracted binary and the license, and runs the extracted binary of the target that matches the host with `--version` and refuses a value that is not the pinned one. That last check is what protects `verifyInstalledRuntime`, which compares the installed version for exact equality. The script prints the block for review instead of rewriting the lock, so paste it over the `opencode` entry and re-run it with that exact version: the command reports `already pins OpenCode ` when the committed block matches byte for byte. Run it on a version bump only. A bump also needs the Windows checks in [the OpenCode notes](architecture/providers.md#opencode-and-acp): the `win32-x64` values come from the published tarball read on macOS, so a staged `opencode.exe --version` must be confirmed on Windows before release. ## Pin the Computer Use driver `native-runtime.lock.json` pins `cua-driver`, the third-party binary that gives every provider Computer Use, by release tag, asset SHA-256, and one SHA-256 for each file OpenBot ships. Unlike the provider CLIs, the driver is packaged rather than downloaded on demand, so the release carries it and the user installs nothing. ```bash bun run pin:cua-driver 0.34.0 ``` The script downloads all four `-binary` release assets, hashes each shipped file, and refuses a release that renamed an asset or dropped a file. It prints the block for review instead of rewriting the lock, so paste it over the `cuaDriver` entry. Use the versioned `cua-driver-rs-v*` tags; the `nightly-cua-driver-rs-v*` tags are rebuilt daily and are not a pin. `bun run prepare:cua-driver` then writes `build/cua-driver//` from the pin, verifying every digest before and after it installs, and `electron-builder.yml` copies that directory to `resources/cua-driver//`. Every `package`, `package:*`, `dist:*` and `dist:release` run does this first. Each installer carries only its own target's driver, and the package verifiers check both that the driver is present and that no other platform's is. The macOS release job calls `electron-builder` directly rather than through `dist:mac`, so it installs the driver in its own `Install and verify native runtimes` step; the Windows and Linux jobs get it from `dist:win` and `dist:linux`. On macOS the driver arrives signed by Cua AI with the hardened runtime, a secure timestamp, and the Automation entitlement. `mac.signIgnore` keeps that signature: re-signing it under OpenBot's inherited entitlements would drop the entitlement and break the driver's Automation route. Notarization accepts a nested binary signed by another Developer ID team, and `verify-macos-package.ts` fails if the Cua AI authority or the hardened runtime flag is ever lost. ## Pin the Bun tool runtime `native-runtime.lock.json` also pins Bun, which is not a provider CLI: it is the runtime a STDIO MCP server is started with on a computer that has no Node, and the staged layout puts a second name `bunx` beside it so that `npx -y ` has something to answer it. The pin has its own script for the same reason OpenCode does - the version, five platform packages, and the MIT license have to agree: ```bash bun run pin:bun-runtime # the newest published release bun run pin:bun-runtime 1.4.2 # one exact version ``` It works like the OpenCode script: it downloads all five platform tarballs, checks `package/package.json` against the registry metadata, hashes the extracted binary and `LICENSE.md`, runs `--version` on the target that matches the host, and prints the block for review instead of rewriting the lock. Paste it over the `bun` entry and re-run it with that exact version to get `already pins Bun `. The x64 entries are the `baseline` builds. Bun's plain x64 build needs AVX2 and answers a spawn on an older machine with an illegal instruction and no message, which would reach the user as an MCP server that never starts. Move this pin at release preparation, with the release-upgrade-safety audit, and not on a schedule: a pinned runtime is OpenBot's supply chain, and a Bun security release only reaches users through an OpenBot release. One reviewed commit per release. ## Release notes `CHANGELOG.md` is the text of the `/changelog` page on the public site. Each pull request writes its notes in its own file, `changelog.d/.md` with `-` for `/`, so two pull requests never change the same lines. The version bump moves the items of every file into `CHANGELOG.md`, in the order that the pull requests merged, and deletes the files. Items that are still under `## [Unreleased]` in `CHANGELOG.md` go first. - Put each change that a user can see in the file, in one of these groups: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, `### Security`. - Write one `- ` item for each change, for a user and not for a developer. Say what changed. For a fix, say what happened before. - Write a step that the user must do after the upgrade as a bold sentence that ends with a full stop, for example `**Sign in again after you upgrade.**`. The page shows these items under "Action needed after you upgrade". Do not use bold for other sentences. The iPhone app has its own versions and its own notes, in the Mobile tab of `/changelog`. Write the notes of a change to the iPhone app in `apps/mobile/changelog.d/.md`, with the same rules. A change to the desktop app or the web client and the iPhone app gets one file in each folder. `bun run mobile:release:patch`, `mobile:release:minor` and `mobile:release:major` move these files into `apps/mobile/CHANGELOG.md` and set the version in `apps/mobile/app.json`, `apps/mobile/package.json` and the copy of it in `bun.lock`. The pre-commit hook checks the new section when a commit changes the `app.json` version. The new section is `## [x.y.z] - In review`, and `/changelog` does not show it until a store makes the build available. Each store reviews the build on its own. When one store has it, `bun run mobile:release:published ios` (or `android`) writes the date of that store, such as `## [x.y.z] - 2026-10-14 (Android)`. Run it again for the other store: `## [x.y.z] - 2026-10-14 (Android), 2026-10-16 (iOS)`. A date with no store is the date of both stores. `/changelog` shows each store, and a store that does not have the version as "In review". `scripts/check-release-notes.ts` stops a release when the section is missing, empty or appears two times, or when it has an unknown group, a group with no items, an item with no text or outside a group, a placeholder such as `TODO`, or a heading with no real date. These steps run it: - `bun run release:patch`, `release:minor` and `release:major` check the new section before they write it. They stop, and delete no file, when a line is not in an item. - The pre-commit hook checks each staged `changelog.d` file with `--fragments`, and the staged `CHANGELOG.md` when a commit changes the `package.json` version. - The `Changelog` workflow checks each `changelog.d` file. It fails a pull request that adds no `changelog.d` file and does not change `CHANGELOG.md`, unless the pull request has the `no-changelog` label. Use the label when a user sees no change. - `bun run release:preflight` and the tag workflow check the section of the `package.json` version. ### Post-update notes The desktop dialog uses edited excerpts of the published `CHANGELOG.md` in `src/renderer/src/features/updates/whats-new-releases.json`. Before publishing a desktop release, add its version, date, and selected notes there. Keep earlier entries for users who skip releases. Use short, plain sentences that explain what the user can do. Put required upgrade steps in `notices`. Use `added`, `changed`, and `fixed` for the other entries. Do not copy commit messages, developer setup, internal paths, credentials, or unpublished plans. Review this text with the release notes before tagging. A release can have no dialog entry when it has nothing to show. The app bundles this text and loads it when needed, so reading it does not send data or require network access. The full changelog button opens the existing public page. Changing the bundled text requires a new app release. Missing entries do not cause a network fallback. The installed desktop version comes from `AppInfo`, not an available update or a remote server. In development, `AppInfo` uses `package.json`; Electron otherwise reports its own version. The local app profile keeps the highest consumed version in `openbot:whats-new-version`. On the first launch of this feature, an existing `openbot:analytics-app-version` can supply the previous version; the dialog does not enable analytics. A profile with no previous version gets a silent baseline, so onboarding is not covered by release notes. Stable `x.y.z` releases participate; unknown versions and prerelease builds do not advance the marker. Downgrades do not lower it. The marker is saved before automatic display, including a load error; Settings > Updates can open the notes again. Empty updates are consumed without opening. If the marker cannot be saved, automatic display is disabled; manual opening still works. Mobile has its own store version and `apps/mobile/CHANGELOG.md`. It must use a native screen and its own local marker when post-update notes are added there. Desktop notes must not use a mobile version or the version of a connected server. This desktop feature does not change mobile, public web, Signal, database schemas, or Team API contracts. ### Discord announcement Each desktop release posts a message in the OpenBot Discord. Before publishing a desktop release, select the 3 to 5 changes in its `CHANGELOG.md` section that have the largest effect on users, and write them with the version in `.github/release-announcement.json`: ```json { "version": "x.y.z", "highlights": ["...", "...", "..."] } ``` Write each highlight as one short, plain sentence that tells the user what they can do now. Put a required upgrade step first. Use the rules of the post-update notes: do not copy commit messages, developer setup, internal paths, credentials, or unpublished plans. Replace the highlights of the previous release; the file holds only one version. `scripts/discord-release-announcement.ts` builds the message: a mention of the `Release Pings` role, the title `OpenBot vx.y.z is out`, the highlights, and a link to the version on `/changelog`. It notifies only that role, not `@everyone` or `@here`. Run `bun scripts/discord-release-announcement.ts check x.y.z` to see it. The tag workflow and `bun run release:preflight` stop when the file is not for the release version, has fewer than 3 or more than 5 highlights, has a highlight that is empty or has a placeholder, a line break or a mention, or makes a message longer than 2000 characters. They also stop when the `DISCORD_RELEASE_ROLE_ID` repository variable is not set or is not a Discord role ID. After the GitHub Release, the Docker image and the boat snapshot are published, the `discord-announce` job posts the message as OpenBot through the channel webhook in the `DISCORD_RELEASE_WEBHOOK_URL` secret of the `release` environment. The job fails, and posts nothing, when `DISCORD_RELEASE_ROLE_ID` is not set. A failed post does not undo the release: re-run the job. A re-run after a successful post posts the message again. One-time setup: 1. In Discord, open the settings of the announcement channel, then Integrations > Webhooks. Make a webhook named `OpenBot` with the OpenBot icon. Add its URL as the secret `DISCORD_RELEASE_WEBHOOK_URL` of the `release` environment. 2. In Discord, turn on Developer Mode, open Server Settings > Roles, right-click `Release Pings` and select Copy Role ID. Add the ID as the repository variable `DISCORD_RELEASE_ROLE_ID`. The mobile app has its own post, file, webhook and workflow. It waits until both stores have the version. See [the mobile Discord announcement](../apps/mobile/README.md#discord-announcement). ## Publish a version Start from a clean, up-to-date `main` branch. For the first release, `package.json` and `CHANGELOG.md` are already prepared as `0.1.0`; after CI passes, create its annotated tag directly: ```bash git tag -a v0.1.0 -m "OpenBot v0.1.0" git push origin v0.1.0 ``` For later releases, the release notes are in `changelog.d/`. Choose the appropriate semantic version bump: ```bash bun run release:patch # or: bun run release:minor # or: bun run release:major ``` The command updates `package.json`, moves the `changelog.d` items and the items under `Unreleased` under the new dated version heading, and deletes the `changelog.d` files. Review and publish that preparation before creating the tag: ```bash git add package.json CHANGELOG.md changelog.d .github/release-announcement.json git commit -m "release: prepare vX.Y.Z" git push origin main bun run release:preflight git tag -a vX.Y.Z -m "OpenBot vX.Y.Z" # When Signal changed since the previous tag, deploy and check Signal from # vX.Y.Z now (preflight item 16). Push the tag only after that. git push origin vX.Y.Z ``` Pushing the version tag runs `.github/workflows/release.yml`. The tag workflow builds nothing until `https://openbot.run/join` and the Apple association file return direct `200` responses: `/join` with `Cache-Control: no-store`, and the association file as `application/json` with the exact app IDs and paths: `/join` for the desktop app, and `/join` and `/agents/*` for the mobile app. The workflow: 1. verifies the tag matches `package.json`; 2. installs and verifies the pinned remote desktop runtime without CMake or Cargo; 3. runs the complete offline repository check; 4. builds signed and notarized ARM64 DMG and ZIP artifacts plus a separately signed/notarized Host PKG on a GitHub Apple silicon runner, and signed and notarized x64 DMG and ZIP artifacts on a GitHub Intel runner (`macos-15-intel`). Each architecture builds on its own runner, because the voice runtime and the remote desktop runtime are prepared for the architecture of the build machine. The Host PKG is ARM64 only. When `hdiutil create` fails with "Device not configured" or "Resource busy", or a network connection drops, the app build of each architecture starts again, up to 3 attempts, because these runner errors are not caused by the app; 5. builds a signed Windows x64 NSIS installer on a GitHub Windows runner (unsigned in a dry run); 6. builds unsigned Linux x64 and arm64 AppImages on GitHub Ubuntu 24.04 runners of each architecture, with the launch check under `xvfb-run`. A further check mounts each AppImage with only `fusermount3`, because its static runtime must start on systems with no libfuse2, such as Arch Linux and Omarchy; 7. verifies all three unpacked applications, update metadata, included runtimes, provider control artifacts, licenses, checksums, platform signing contracts, launch behavior, and update artifact size limits; 8. generates SPDX SBOMs and GitHub build-provenance attestations for all three platforms; 9. publishes one non-draft GitHub Release only after all platform jobs pass. It joins the ARM64 and x64 `latest-mac.yml` files with `scripts/merge-mac-update-manifests.ts`: electron-updater selects the ZIP whose name contains `arm64` on Apple silicon and the other ZIP on Intel. 10. builds a Docker image (`docker/Dockerfile`) from each Linux AppImage on a runner of that architecture, after it checks the AppImage against its `SHA256SUMS` file. It starts each image with `docker/seccomp.json`, waits for `openbot status`, and stops it, which must exit with 0; 11. after the GitHub Release is published, pushes both images to `ghcr.io/nightly-labs/openbot` with the tags `-amd64` and `-arm64`, joins them under ``, `v`, `latest` and `sha-`, and attests the build provenance of that image. It then checks anonymous access to both architectures. See [Docker](docker.md). Users can verify a downloaded artifact with `gh attestation verify --repo nightly-labs/openbot`. Before a tag, run the release path without publishing: ```sh gh workflow run release.yml --ref -f mode=dry-run ``` The dry run runs the tag validation (without the tag and `main` checks), the Windows, Linux and Docker builds, and all their verification steps. It does not run the macOS job or the publish jobs, and it makes no attestation: the macOS job needs the `release` secrets, which only tag runs receive, and an attestation of this public repository is a public Sigstore record. Use `-f mode=host-signing` on a tag ref to check the macOS Host signing keychain, and `-f mode=windows-signing` on a tag ref to check Windows signing. `--ref` selects the workflow and the overlay, so the tag must point to the commit to test. Use a temporary `v0.0.0-*` tag. It matches the `release` environment policy. Its push starts a release run, but `Validate release tag` stops that run, because the tag never matches the `package.json` version: ```sh git tag v0.0.0-windows-signing-test git push origin v0.0.0-windows-signing-test gh workflow run release.yml --ref v0.0.0-windows-signing-test -f mode=windows-signing # After the run: git tag -d v0.0.0-windows-signing-test git push origin :refs/tags/v0.0.0-windows-signing-test ``` It logs in to Azure, then signs a throwaway executable once immediately and once 20 minutes later, the delay between login and signing in a release build. It uploads and publishes nothing. Run it once after a change to the Azure setup or to `electron-builder.windows-signing.yml`, before the next tag. Installed OpenBot builds check for updates shortly after launch and every four minutes. New versions download automatically while **Automatically download updates** is on, which is the default and is persisted per user in `openbot-update-preference-v1.json`; with the setting off, a download starts only on a user action. The update notice in the sidebar and Settings > Updates show the current state and let the user download an available version, then restart into it. The restart action appears as soon as the download completes, and no platform installs without that explicit action, because `autoInstallOnAppQuit` stays off so shutdown preparation always runs. Every stage the user waits on is bounded by a timeout and recorded in `logs/update/update.log`, so a failed check, download, or restart reports an actionable error and can be retried in place. The voice runtime (the sherpa-onnx N-API addon and ONNX Runtime, from the pinned `sherpa-onnx-*` npm packages) is part of the macOS, Windows and Linux applications, in `voice/runtime`. Each Linux AppImage carries the runtime of its architecture. The Linux arm64 AppImage ships no remote desktop runtime. The NVIDIA Parakeet TDT 0.6B v3 int8 model is not part of an application or update artifact. OpenBot downloads its four pinned files on first voice use, checks the size and SHA-256 of each, and keeps the verified files in `runtimes/parakeet-tdt-0.6b-v3-int8` in the user data directory for later offline use. The optional Whisper large-v3 turbo int8 model, which takes a fixed dictation language, is downloaded the same way only when the user selects Download in Settings > Voice. Its three pinned files go in `runtimes/whisper-turbo-int8`. The optional Underdog Bark 3.0 Plus int8 model, which recognizes English only, is downloaded the same way when the user selects Download in Settings > Voice. Its four pinned files go in `runtimes/bark-3.0-plus-int8`. They come from an unofficial sherpa-onnx conversion; the license is in `build/licenses/Underdog-Bark-3.0-Plus-LICENSE`. At startup, a packaged application removes the old `runtimes/whisper` directory if it exists. The Supertonic 3 int8 voice model, which reads replies aloud, is downloaded the same way only when the user turns on Read replies aloud or selects Download in the Spoken replies section. Its eight pinned files go in `runtimes/supertonic-3-int8`. The speech host, `voice-speech-host.js`, is a separate utility process that loads the same addon. Its weights are under OpenRAIL-M; the license is in `build/licenses/Supertone-Supertonic-3-LICENSE`. The release workflow stops if the macOS update ZIP, the Windows NSIS installer, or the Linux AppImage is larger than 700 MiB, or if the DMG is larger than 750 MiB. It also stops if update metadata has a wrong size or SHA-512, if a voice model is present, or if the application contains a second native Claude runtime. If a release is bad, publish a newer patch version. Do not replace an already published version with different binaries. Before working this checklist, audit the changes since the last released tag with the `.agents/skills/release-upgrade-safety/` skill: it covers the upgrade and data-loss hazards an installed user cannot undo — the in-place database migration, the `userData` files nothing backs up, the frozen Team API adapters, and the D1 migrations that race their own deploy. ## Preflight checklist Before creating the first tag or any later release: 0. run the Team API compatibility matrix for every protocol that remains in the adapter registry. The matrix must cover an older client with the new host, the new client with an older host, matching versions, no shared protocol, capability omission, unknown optional events, and malformed known events. Do not reduce this matrix because a protocol is old or because many application versions separate the peers. Confirm that each supported protocol still has unchanged client and host fixtures; 1. run `bun run release:preflight` and resolve every reported release-secret or repository gate; 2. confirm the `release` environment contains all eight macOS secrets above; Windows signs through the Azure federated credential and needs no secret; Linux remains unsigned; 3. confirm the production `/join` page and Apple association file pass the deployment checks in CI; 4. run `bun install --frozen-lockfile` and `bun run check` from a clean clone; 5. run `bun run package:verify` on macOS; Windows and Linux packaging and launch verification run on the release runners; 6. confirm that the lock file contains all six provider artifacts, their download and install sizes, and that their install checks pass; 7. smoke-test sign-in/setup, chat streaming, queues, attachments, agent messaging, browser control, context compaction, and the update popover; 8. on macOS ARM64, macOS x64, Windows x64, and Linux x64, update from the last public version and confirm check, download, preparation, explicit restart, new version, local agents, conversations, and queues. A Linux build only auto-updates when it runs from the AppImage, which the runtime reports through `APPIMAGE`; 9. test first voice use, download progress, retry after a stopped download, transcription, and cached offline use; 10. build a signed and notarized canary and test an update from the official `0.1.21` application on macOS 26 with a separate `userData` directory; 11. confirm the canary update does not crash in `CFURLConnectionSynchronous`, preserves data, starts the new version, and can run three provider downloads with restricted memory; 12. on Windows 10 and 11 x64, confirm that normal exit, restart, and sign-out do not start NSIS, while `Restart and install` does start it, shows no installer page, and opens OpenBot again. With OpenBot closed and with it open, confirm that Open OpenBot on a `/join` page opens OpenBot and shows the invitation once, and that uninstall removes `HKCU\Software\Classes\openbot`. Update from the previous release, then install to a folder on a second disk. Confirm that the installer has no "All users" page, that an update installs to the same folder, that `icacls` on that folder lists only the user, `SYSTEM` and `Administrators`, and that the uninstaller removes only that folder. Choose `%USERPROFILE%\OpenBot` with an agent workspace in it, and confirm that OpenBot installs to `%USERPROFILE%\OpenBot\OpenBot` and that uninstall keeps the workspace. As a second account, make an empty `D:\OpenBot`. As the first account, choose it, and confirm that OpenBot installs to `D:\OpenBot\OpenBot` and that the second account cannot write there; 13. on Ubuntu 24.04 x64 with the AppArmor profile from `build/linux/openbot.apparmor` installed, confirm the AppImage starts with the sandbox on, that `xdg-open 'openbot://join?...'` focuses the running application, that a provider downloads in-app, that the page rail is drawn, and that the microphone control is absent; 14. confirm `CHANGELOG.md` and `.github/release-announcement.json` describe the version and the working tree is clean; 15. create and push the version commit, and create the tag locally, only after CI passes on `main`; 16. read the running Signal commit from `curl -fsS https://signal.openbot.run/health/live`. If it is missing or `unknown`, or if `git diff --quiet -- remote packages/contracts/src/signal-protocol` finds changes, deploy Signal from the local tag before you push the tag. Compare with the running commit, not with the previous tag: an earlier release can have left Signal behind. Pushing the tag starts the release workflow, and nothing in it waits for Signal. Use the Signal-only procedure in [remote-session-deployment.md](remote-session-deployment.md#deployment-procedure--requires-separate-approval); the `.agents/skills/signal-deploy/` skill adds the checks before and after it. Confirm that `curl -fsS https://signal.openbot.run/health/live` shows the tag commit. A client that needs a newer Signal fails until then: v0.33.0 shipped webhook routines while Signal was older than #1520, so every webhook route answered 404 (#1661); 17. push the tag. The macOS ZIP must be smaller than 800,000,000 bytes and smaller than the official `0.1.21` ZIP. Do not publish when either size gate fails, the `0.1.21` canary update crashes, or Windows starts NSIS during shutdown or sign-out. The unsigned local macOS package is a development artifact. It does not prove Gatekeeper, notarization, or auto-update readiness. Those are proven only by the signed release workflow's `codesign`, `spctl`, and `stapler` checks. After publishing `v0.1.0`, keep one installed copy and use the first signed patch (`v0.1.1`) as the end-to-end updater acceptance test: check, download, restart, and confirm the version changed without losing local agents or queues. This cannot be proven with an unsigned development build because macOS updaters require both versions to share a valid Developer ID signature. ## Managed-host release package The normal DMG remains the desktop application. The optional Host PKG installs managed-host infrastructure only. Both use the validated `v` tag and the exact same source commit. The macOS job fails if Host compilation, signing, payload verification, notarization, stapling, or checksum generation fails; it never publishes a release with the Host package silently omitted. Expected macOS assets: ```text OpenBot--arm64.dmg OpenBot--arm64.zip OpenBot-Host--arm64.pkg latest-mac.yml SHA256SUMS-macos.txt OpenBot--macos.spdx.json OpenBot-Host--macos.spdx.json OpenBot--macos.sigstore.json OpenBot--x64.dmg OpenBot--x64.zip SHA256SUMS-macos-x64.txt OpenBot--macos-x64.spdx.json OpenBot--macos-x64.sigstore.json ``` The macOS checksum file covers the DMG, ZIP, and Host PKG. The existing provenance step consumes that file, so all three artifacts are attested against the same tag, commit, and release run. The publish job downloads `release-macos` and `release-macos-x64` and publishes the PKG with the other assets. `SHA256SUMS-macos-x64.txt` covers the x64 DMG and ZIP. `latest-mac.yml` still describes only Electron application updates; a `.pkg` reference is rejected. After verifying OpenBot.app, the macOS job: 1. runs native account tests without creating users; 2. imports the application and installer certificates into a temporary, isolated keychain; 3. compiles standalone ARM64 `host-manager` and `openbot-host` executables with Bun, and the native account helper with Swift; no target-machine runtime or compiler is required; 4. signs all executables with **Developer ID Application**, hardened runtime and timestamp, and checks their fixed identifiers and team `ZTRDTUL87R`; 5. creates the fixed root:wheel package payload and signs the PKG with **Developer ID Installer**; 6. expands it and rejects extra files, symlinks, unsafe modes/owners, version mismatches, changed installer scripts, unexpected destinations, or non-system dynamic runtime dependencies; 7. submits the PKG through `notarytool`, waits for `Accepted`, staples it, then requires successful `pkgutil --check-signature`, `spctl --assess --type install`, and `stapler validate`; 8. generates checksums and the Host payload SBOM, attests provenance, and uploads the PKG. The Bun executables need only `com.apple.security.cs.allow-jit` for JavaScriptCore's ARM64 JIT. The native account helper has no special entitlements. Library validation, executable-page protection, and the hardened runtime remain enabled. CI launches the signed standalone binaries with a system-only PATH and exercises a hot JavaScript loop before publication. Do not copy broad example Bun entitlements that disable these protections. If the pinned Bun version cannot pass these checks, stop the release and investigate; do not weaken the flags to obtain a signature. The release keychain and imported `.p12` files are deleted at step exit. The installer identity is mandatory and distinct from the application identity; add both new secrets before tagging. Never publish an unsigned Host package. The test fixture package is temporary, unsigned, never installed, and never uploaded as a release asset. CI does not install the root daemon or create tenant accounts on the runner. Package expansion, BOM ownership/mode verification, plist/signature checks, standalone smoke checks, and mocked CLI checks run there. Actual signed PKG installation/upgrade, new-account login, private-home isolation, and two-user Aqua relaunch remain the [target-host acceptance gate](multi-tenant-hosting.md#target-host-acceptance-required-before-paying-client-use). No tenant-data backup is made. Infrastructure updates require administrator installation of a newer signed Host PKG; the daemon never replaces itself.