A release is one person choosing a version, and a reviewed pull request doing everything else. No step involves a terminal, a tag pushed by hand, or an image built on somebody's laptop.
- Before cutting, rebuild the changelog from the code.
- Check
## Unreleasedin CHANGELOG.md reads the way you want it to. It is the release notes. Nothing is generated from commit subjects, because a commit subject is written for the person reading the diff and these notes are for the person deciding whether to upgrade. - Run Create release PR from the Actions tab, choosing
patch,minorormajor. Leavekindasstable. Usedry_runfirst if you want to see the version and the notes without opening anything. - Review the pull request it opens. It updates
package.json, promotes the changelog's## Unreleasedheading to## X.Y.Z, and moves the Helm chart'sappVersionand the desktop Cargo package/lockfile to the same version. Tauri reads its version directly from the rootpackage.json. - Merge it. That is the publish.
Merging is the trigger, so a release is always a reviewed commit: on main for a stable release,
and on release/X.Y for a candidate, beta or final.
Do not push a v* tag by hand to release. A tag pushed by a person builds nothing and publishes
nothing. It only starts Desktop macOS signing for that tag,
which then fails to attach anything because the tag has no release.
kind chooses what the run releases: stable (the default), rc, beta or final. A plain
patch, minor or major from main is a stable release and works as described above. The
other three are for a release candidate or beta, and are described under
Release candidates and betas and
Finishing a candidate.
Entries added to ## Unreleased since the last release are claims, not evidence, so none is
trusted as written. Before cutting a release, and before the first candidate or beta of one, go
through every change since the last release tag (git log vX.Y.Z..origin/main and each one's diff),
check it in the code, and write ## Unreleased from what the code shows: keep an entry only where
the code proves it, rewrite one that overstates, understates or misdescribes the change, drop one
the code does not do, and add one for any change no entry mentions. Land it on main as a pull
request like any other.
The root package.json is the release version source. CI rejects drift in the desktop Cargo
manifest or lockfile. The release workflow updates those files automatically; after a manual
root version change, run bun desktop/scripts/desktop-version.ts sync.
Internal desktop artifacts are numbered after the release the root package.json names, which is
the last one published. After the release X.Y.Z they use X.Y.(Z+1)-internal.<commits>.g<commit>,
for example 0.3.4-internal.863.gb5d03d1f4e43 after 0.3.3, so testers can identify the source
revision. After a candidate or beta, X.Y.Z-rc.N, they use X.Y.Z-rc.N.internal.<commits>.g<commit>.
The Windows installer and the app compare versions by
semver precedence, with the same semver crate. A prerelease
orders before its own release, so an internal build named 0.3.3-internal.…, built after 0.3.3 was
released, read as older than 0.3.3, and the installer said a newer version was already installed and
chose Uninstall before installing. As a prerelease of the next patch, an internal build orders
after the release it was built from and before the next release, whether that is X.Y.(Z+1) or a
minor or major one, so the next release still installs over it as an upgrade. A candidate's internal
build orders after rc.N and before rc.(N+1). The release PR's own build is the one exception: its
package.json already names the release it is about to publish, so its build is numbered after it.
<commits> is the number of commits in the build's history. Semver compares a number as a number
and the hash as text, so the count is what orders a later build after an earlier one; without it,
the Windows installer took a newer internal build for an older one. Counting needs the whole history,
so the workflows that prepare this version check out with fetch-depth: 0, and
desktop-version.ts internal refuses a shallow checkout.
The in-app updater never offers anything to an internal build, whatever the feed holds: an internal build can be made from any branch, so no release is sure to contain what it runs. A tester moves on by installing the next internal build, or a release, by hand.
Both desktop CI and protected Windows signing prepare this version before compilation and
packaging. A signing run on a release tag, and the release's own build, use the plain release
version instead. The artifact includes build-version.json with the full commit and release version.
To build locally, from the repository root:
bun desktop/scripts/desktop-version.ts internal
cd desktop
APPLE_SIGNING_IDENTITY=- bun run tauri build --config src-tauri/tauri.build-version.conf.json --bundles dmgUse release instead of internal to prepare the plain release version. This only builds an
artifact; it does not publish a release. macOS keeps numeric system version fields
(CFBundleShortVersionString and CFBundleVersion) and stores the full internal identifier in
OpenBotBuildVersion inside the app's Info.plist. Windows retains the full identifier in
ProductVersion and FileVersion; Tauri fills its fixed numeric fields from the same version's
major.minor.patch, with 0 for the fourth part. Both platforms' numbers are the build version's
major.minor.patch: 0.3.4 (Windows 0.3.4.0) for an internal build after 0.3.3, the same as the
0.3.4 release, so neither goes backwards from one build to the next. The protected signing job checks
both embedded string versions.
Ad-hoc signed Mac builds require the first-open exception described in Apple's instructions. They are for internal testing and are not Apple-notarized. See signed Mac builds for the ones people install, and Windows signing for signed NSIS builds.
Desktop macOS signing builds OpenBot with the
Developer ID certificate, notarizes and staples both the .app and the .dmg, and produces the
archive the in-app updater installs. It builds both architectures on the Apple silicon runner
(macos-15), one job each, the Intel one for the Rust target x86_64-apple-darwin as
Tauri documents. The Intel runner it used
before took 22 minutes for what macos-15 builds in 12. It fails unless each build passes all three
of:
codesign --verify --deep --strict --verbose=2 OpenBot.app
spctl -a -t exec -vv OpenBot.app
xcrun stapler validate OpenBot.appAn ad-hoc build fails every one of them, which is why it needs "Open Anyway".
Each build also has to be its own architecture all the way down.
desktop/scripts/verify-mac-arch.sh runs lipo -archs on
every Mach-O file in the app and in the app inside the .dmg: the app itself, its frameworks, the
bundled Node and every native module of the Intelligence CLI. Each must carry the job's
architecture. The one exception is a package that ships a copy per architecture and picks one at run
time, such as @azure/msal-node-runtime's dist/macos/arm64/ beside dist/macos/x64/: the other
architecture's copy is accepted when this one's is beside it. On the Intel build it also starts the
bundled Node under Rosetta once, to show it runs.
When it runs:
- pull requests and pushes to
mainthat touch the desktop run only Updater regressions (no Apple access). It holds no secrets. - every release starts it. As soon as
publish-release.ymlhas created the tag and the release, right after the images, it dispatches this workflow on the tag withsigning-mode: developer-idandrelease-tagset to that version, so signing builds while the rest of the publish run finishes. The tag alone would not start it, because GitHub starts no workflow from a push made withGITHUB_TOKEN; aworkflow_dispatchis the exception. The run still waits for a required reviewer of themacos-signingenvironment to approve it. - Actions → Desktop macOS signing → Run workflow with
signing-mode: developer-idsigns any ref and keeps the results as Actions artifacts for 14 days. Setrelease-tagto an existing release, e.g.v0.2.1, to also attach the.dmgfiles, the updater archives and their.sigfiles, andlatest.jsonto it. Run it on the release tag (Use workflow from → the tag) so the build carries the release version. A build of any other version fails before it attaches anything. This is how to re-sign a release by hand. It leaves Latest where it is unlessmake-latestis set; see Which release is Latest. - a
v*tag pushed by a person starts it too. That tag is not a release: see Cutting one.
Each signed .dmg is attached twice: under the name Tauri gives it, replacing a candidate's ad-hoc
.dmg of the same name, and under its fixed download name. Both are attached
before the release can become Latest.
Signing imports the certificate into a temporary keychain made for the run, and always deletes that keychain afterwards, even when an earlier step failed.
desktop/scripts/signed-build.ts builds the signed app, the
same product a candidate's ad-hoc installer is:
- a candidate or beta is pinned to its release's own
container-images.json, as its ad-hoc installer is, and refuses one built from another commit. It has no updater, so it makes no updater archive and nolatest.json, and the updater key never reaches its build; - a stable release is not pinned, and carries the updater;
- either bundles the Intelligence CLI whenever the tree has
tauri.bundled-cli.conf.json, its dependencies installed for the installer's platform (npm install --os --cpu), so the Intel app built on the Apple silicon runner gets Intel binaries; - a Mac build names its Rust target,
x86_64-apple-darwinoraarch64-apple-darwin, and Tauri leaves it indesktop/src-tauri/target/<target>/release/bundle.
Only the signing job produces Mac updater artifacts. bundle.createUpdaterArtifacts lives in
desktop/src-tauri/tauri.macos-signing.conf.json, which that job alone passes to Tauri, because
Tauri refuses to finish any bundle that asks for updater artifacts without
TAURI_SIGNING_PRIVATE_KEY. In the base config it would break ordinary desktop CI and the local
build above. Do not rename the overlay tauri.macos.conf.json: Tauri merges that filename into
every Mac build automatically.
The overlay also holds the updater itself, plugins.updater with its endpoints and public key.
The Windows signing overlay, tauri.windows-signing.conf.json, holds the same block, with the same
key and endpoints (see Windows updates). The app registers the updater plugin
only when its config has that block, so desktop CI, local builds and the unsigned release installers
have no updater. They never offer an update and never show an update error, because they could not
verify one.
A signed app reads https://downloads.copilotkit.ai/latest/latest.json at launch and every six
hours while it runs. When that address does not answer, it reads
https://github.com/CopilotKit/OpenBot/releases/latest/download/latest.json, the file
Downloads sync copies there. Both addresses are plugins.updater.endpoints
in tauri.macos-signing.conf.json and tauri.windows-signing.conf.json, in that order, and the
install reads them the same way, so what the app offers and what it installs come from one file. GitHub redirects its address to the
latest.json attached to the release marked Latest, so the newest non-draft, non-prerelease release
is always the one offered.
When the feed lists a newer stable version, OpenBot's own window shows a bar, "OpenBot 0.3.2 is
available.", with Update and restart and a close button, and the setup window offers the same.
Nothing installs without that click. Closing either hides both until a newer version comes out,
through reloads and restarts. The click downloads the archive and verifies its signature while
OpenBot keeps running, installs it, stops OpenBot (the local Intelligence keeps running), and
restarts the app; that Start moves the installation to the app's release.
On Windows the order differs, because Windows will not replace a running app: once the installer
is downloaded and verified, OpenBot is stopped, the installer runs in update mode with a small
progress window and no questions, and it starts the new version when it is done.
The shell does all of it (desktop/src-tauri/src/app_update.rs); neither page reaches the feed or
holds the updater's permissions. A self-hosted OpenBot in a browser has no shell, and never reads
the feed.
To see the bar without a release, build the desktop in debug with TAURI_CONFIG set to an overlay
that gives it its own identifier and a plugins.updater with the real pubkey,
"endpoints": ["http://127.0.0.1:<port>/latest.json"] and
"dangerousInsecureTransportProtocol": true. Serve a latest.json there with a newer version,
start an OpenBot of your own (server and app), and run the binary with
OPENBOT_DESKTOP_TEST_APP_URL set to that app's address: a debug build shows it in its window as
its own OpenBot. Give the run its own HOME as well, so nothing it does can reach an installed
OpenBot in ~/OpenBot. Release builds never read the variable.
After it attaches latest.json, and marks the release Latest when it should (see below), the
signing workflow fetches the GitHub address the way the app does, following redirects, and fails
unless the answer is JSON whose version is the release version and which lists darwin-aarch64,
darwin-x86_64 and windows-x86_64, each with a URL and signature. Downloads sync, which that same step starts, then puts a copy of that file,
checked against GitHub's digest, at the downloads address within minutes. A release that is not
Latest is not checked, because the apps never read its feed: a candidate, a patch to an older line,
or a release signed again by hand.
releases/latest, the download links under it and the update feed all follow the release marked
Latest. A stable release is published not Latest, and becomes Latest only once both signing
runs have attached their signed installers and listed them in latest.json: Desktop macOS signing
its .dmg files and both Mac archives, and Desktop Windows signing its -setup.exe, each under its
fixed download name too. Whichever
finishes second marks it (scripts/release-make-latest.sh).
The two attach jobs share the concurrency group release-attach-<tag>, so they run one after the
other and the second always sees what the first attached.
Until then the previous release stays Latest, with its signed installers, so releases/latest never
serves an unsigned Windows installer, or an ad-hoc installer that
macOS refuses, or a release with no Intel installer.
Whether a release becomes Latest is decided once, in verify, by
scripts/release-becomes-latest.sh: never for a candidate or
beta, and for a stable release only when no newer release is Latest. The publish workflow hands
that decision to both signing runs as make-latest, and the run that marks the release asks the
script again just before it does, so a newer release published meanwhile keeps Latest. A re-run of
the publish workflow does not touch Latest.
On a stable release only the signing runs attach the fixed download names, so a fixed name on it
means its installer is signed. The release becomes Latest once every fixed name is on it and its
latest.json lists every platform, so no app is left out of a Latest release's feed.
Making a release Latest also starts Downloads sync, so
downloads.copilotkit.ai serves the same signed installers. Both attach jobs
have actions: write for that.
If either signing run is never approved, or fails, the release stays not Latest. Sign it again from
its tag with make-latest set, or mark it Latest by hand on its release page.
latest.json names a version and, for darwin-aarch64 and darwin-x86_64, an archive URL and
that archive's minisign signature, and for windows-x86_64 the signed installer's URL and its
signature. Both signing runs write into the one file
(scripts/release-update-feed.sh): each keeps the platforms the
other already listed for the same version, and their attach jobs never run at once. The app verifies
the signature against the public key in plugins.updater.pubkey and refuses any file that does not
match. That key pair is the Tauri updater key. It is not the Developer ID certificate, and it is
not the Windows Key Vault certificate. Losing the private key means installed apps can never update
again, so keep it in 1Password as well as in the environment.
On Windows the updater installs the NSIS installer itself, so the file a person downloads and the
file the updater installs are the same signed -setup.exe. Its updater signature is made after
Authenticode signing, over exactly those bytes, by the Sign the installer for the updater job
of Desktop Windows signing. The updater key lives only in the
macos-signing environment, so that job runs there: a Windows release run asks for a
windows-signing approval to build and sign, then a macos-signing approval to sign for the
updater. The job signs with tauri signer sign, the same signing tauri build does for the Mac
archives, checks the signature against plugins.updater.pubkey with minisign, and the attach job
uploads OpenBot_<version>_x64-setup.exe.sig and lists the installer in latest.json.
The Windows app installs per user, so the update needs no administrator prompt. It runs the
installer in update mode (installMode: passive), which never runs the uninstaller: the
installation in ~/OpenBot, the app's settings and data under %APPDATA% and %LOCALAPPDATA%, and
the Recorder's registration all stay as they were. Windows apps from
before this have no updater, so each Windows installation updates by hand once, to the first
release that has it.
A failed check says nothing, because an offline laptop is not a problem to report. A failed install shows its error.
Create the protected GitHub environment macos-signing with required reviewers, and add these
environment secrets:
| Secret | Value |
|---|---|
APPLE_CERTIFICATE |
The Developer ID Application certificate and private key, exported as .p12, base64-encoded |
APPLE_CERTIFICATE_PASSWORD |
The .p12 export password |
APPLE_SIGNING_IDENTITY |
The identity name, e.g. Developer ID Application: <Company> (<TEAMID>) |
APPLE_API_ISSUER |
App Store Connect API issuer ID |
APPLE_API_KEY |
App Store Connect API key ID |
APPLE_API_KEY_P8 |
The contents of that key's AuthKey_<id>.p8. The job writes it to a temporary file and sets APPLE_API_KEY_PATH |
TAURI_SIGNING_PRIVATE_KEY |
The Tauri updater private key (tauri signer generate) |
TAURI_SIGNING_PRIVATE_KEY_PASSWORD |
That key's password |
The job also reads the existing OPENBOT_GOOGLE_MODEL_OAUTH_* variables and secret, as the other
desktop builds do. Desktop Windows signing's Sign the installer for the updater job reads the
two TAURI_SIGNING_* secrets from this environment too; nothing else outside it holds them.
Put the matching updater public key in plugins.updater.pubkey in
desktop/src-tauri/tauri.macos-signing.conf.json. The signing job refuses to run while that field
still holds REPLACE_WITH_UPDATER_PUBKEY.
The desktop installer bundles the Intelligence CLI and the Node runtime that runs it.
desktop/intelligence-cli.json chooses both:
{
"cli": "latest",
"node": {
"version": "26.13.0",
"sha256": {
"darwin-arm64": "d38bd5e5a2ddce6bbb5ce45e46e208337da4d51fffe611e567fd32073d6439f6",
"darwin-x64": "525c9b29bb0263d687de60a4cec0e2a6db876a8eb032369e5795eca71e7c358e",
"win32-x64": "14319838b3f355e0398c13569a90270bce4fc68f25189f58c59b03dadae0e6d5"
}
}
}cli is one of:
- the
copilotkitnpm dist-taglatest, which is the default - any other npm version or dist-tag of
copilotkit, such as1.2.3ornext - a pkg.pr.new preview of one Intelligence commit:
https://pkg.pr.new/CopilotKit/Intelligence/<package>@<commit>, where<package>iscopilotkit,copilotkit-prorcopilotkit-pushand<commit>is 7 to 40 lowercase hex characters, such ashttps://pkg.pr.new/CopilotKit/Intelligence/copilotkit-pr@4dd2119
pkg.pr.new also serves a preview by PR number (copilotkit-pr@123) or by branch
(copilotkit-pr@main), and the bot comment on a PR links the PR-number form. Those move with every
push, so they are refused: copy the commit form instead. Any other URL is refused too.
node is the Node runtime: an exact version, which is the current LTS (Node 26), and the SHA-256
of its download for each platform the installers are built for. The download must match both the
pinned SHA-256 and the one nodejs.org lists in SHASUMS256.txt. Any difference fails the build, and
so does a platform with no pinned SHA-256. Because the pin is reviewed here, a nodejs.org that
served a different binary with a matching SHASUMS256.txt would still be refused.
To move to another Node version, change version, open
https://nodejs.org/dist/v<version>/SHASUMS256.txt, and copy the hashes on three of its lines:
node-v<version>-darwin-arm64.tar.gz for darwin-arm64, node-v<version>-darwin-x64.tar.gz for
darwin-x64, and win-x64/node.exe for win32-x64.
A version or URL that does not exist fails the build, because npm pack cannot fetch it. So does a
package that does not ship index.js as its bin. Nothing falls back to a different CLI.
The build records what it actually bundled in build-version.json: the CLI's source, package name,
exact version and integrity hash, and Node's exact version and SHA-256. A latest or a preview URL
is therefore readable afterwards as the version it turned into.
The CLI ships no lockfile, so npm install picks its dependencies when the installer is built, and
two builds of the same choice can carry different dependency versions. The package-lock.json that
npm writes stays in the bundled cli/ folder, and build-version.json records its SHA-256 as
lockfileSha256 and the number of packages installed from it as dependencies. Two builds with the
same lockfileSha256 carry the same dependency tree.
Cutting a release candidate or beta pins cli to an exact version when it names an npm dist-tag,
whether latest, next or another. The tag is replaced with the version it resolves to at that
moment. An exact version and a pkg.pr.new URL are left as they are: a version cannot move, and the
only pkg.pr.new URL a build accepts names a commit. So the candidate and the release built from it
carry the same CLI. A final release cannot change the file. The pin stays on the release branch: the
merge-back PR keeps main's own choice, as described under
Finishing a candidate.
To assemble the bundle locally, from the repository root:
bun desktop/scripts/cli-bundle.ts darwin arm64The platform is darwin or win32, and the architecture is arm64 or x64. The bundle is written
to desktop/src-tauri/cli-bundle.
On a tree without desktop/src-tauri/tauri.bundled-cli.conf.json, which is main today, CI
bundles nothing and the installer carries no CLI.
For the step-by-step procedure, with what to check at each step, see the installer runbook.
An internal build is an installer for the team to try, made from any branch. Nothing is published: no release, no tag, and nothing on the Releases page.
Run Internal build from the Actions tab and choose the branch. Once the workflow is on the default branch, the same run starts from a terminal:
gh workflow run internal-build.yml --ref my-branchThe run builds the images for that commit, then a Mac (Apple Silicon) installer and a Windows (x64)
installer. The images are pushed tagged sha-<commit> and never latest, so the release images
stay where they are.
Each installer is pinned to what the run built:
- its deployment version is
internal-<sha12>, the first twelve characters of the commit - its images are the
sha-<commit>images of that commit, by digest
A pinned installer never asks for releases/latest. Opening it installs that exact build, and it
replaces whatever deployment is installed on the machine, keeping its data. OpenBot's database does
not migrate backwards, so switching to a build with older migrations is the tester's risk.
The installers are run artifacts, kept 30 days. Share them by linking to them from the run summary, which lists one link per installer. The summary names the commit and the manifest version, and each installer's own section there names its CLI and Node versions. Mac builds are ad-hoc signed and Windows builds are unsigned, so the first open needs an exception on both. On a Mac, use the exception described under Desktop build versions. On Windows, SmartScreen warns about an unknown publisher: choose More info, then Run anyway. For a signed Windows build, see Windows signing.
A CLI is bundled only on a tree that has desktop/src-tauri/tauri.bundled-cli.conf.json. On a
branch without it the installer carries no CLI, and the summary says "no CLI bundled".
Opening a customer installer afterwards moves the deployment to the latest release, keeping its data. A customer build leaves a deployment alone only when that deployment was not pinned.
A release candidate or beta is a version people can try before it is the release. It is published where testers can find it, and nowhere that would hand it to everybody else.
Run Create release PR with kind set to rc or beta. bump matters only for the first one,
because it chooses the version the candidate is heading for. After that the version keeps counting,
so 0.3.0-rc.1 is followed by 0.3.0-rc.2.
The first run, started from main, cuts the branch release/X.Y and opens a release PR into it.
from is optional and only applies to this first run. It names the commit on main to cut from, as
a full commit SHA or a branch name, and the default is the tip of main. A short SHA does not work.
The run refuses a commit that is not on main.
Every later candidate runs from release/X.Y, by choosing that branch in Use workflow from. A
second run from main is refused, because the branch already exists. A from on a later run is
refused too.
A fix goes onto release/X.Y through its own pull request, and then becomes the next candidate.
Merging the release PR is the publish, the same as for a release from main. The publish workflow
also runs on pushes to release/**, and decides from the merged pull request that a push is a
candidate, as it does for main. A stable release is never cut from a release branch.
Each candidate or beta is published as a GitHub prerelease. It is never Latest, so customers who
check for the latest release do not see it. Its images are tagged with the version and
sha-<commit>, and never latest. A Mac (Apple Silicon) and a Windows (x64) installer are attached
to it, pinned to that version and to those images, the same as an internal build is. They are built
beside the release rather than before it, so the signing runs start as soon as the release and its
container-images.json exist, and they are attached when they are built, unless a signed one is
already there. The Mac one is ad-hoc signed until Mac signing
is approved and replaces it with a signed one, pinned the same way. Mac signing also adds the Intel
installer. The Windows one is unsigned until Windows signing is approved and
replaces it with a signed one, pinned the same way. Until then the first open needs the exceptions
described under Internal builds. Link a candidate's installers by its version,
as described under Download links.
A candidate does not promote the changelog. Its notes are what is under ## Unreleased at that
moment, and the final promotes them, so
rebuild the changelog from the code on main before the
first candidate is cut. A candidate also fixes the CLI choice, as described under
Which Intelligence CLI an installer carries.
release-policy.json describes one release: the versions it upgrades from, its database transition,
downtime and recovery, and the version it was written for. Publishing refuses a policy that names
another version. Its candidates, betas and final share it, so Create release PR moves its
version along within one release, v0.3.0 to v0.3.0-rc.1 to v0.3.0, and changes nothing
else. A policy written for an earlier release is left alone: write the new release's policy into
the release PR before merging it. A final may not change the policy beyond its version.
Run Create release PR with kind set to final, from release/X.Y. A final started from
main is refused, with a message telling you to run it from the release branch. bump has no
effect, because the version is the candidate's version without its suffix.
The tip of release/X.Y must carry a candidate tag. If the branch has moved on since its last
candidate, the run stops with "Cut a new candidate first". A final ships what a candidate was
tested as, so it is cut from the commit a candidate was published from and nothing newer.
The final release PR promotes the changelog and moves the same version files as any release. When it merges, the publish workflow compares the release against its candidate before it builds anything, and fails if anything else changed. The allowed difference is:
- the
versioninpackage.json CHANGELOG.mdappVersionincharts/openbot/Chart.yaml- the
openbot-desktopversion indesktop/src-tauri/Cargo.tomlandCargo.lock
A final also cannot change desktop/intelligence-cli.json, so it bundles the CLI the candidate
pinned.
The final is a normal release: its installers are attached, and it becomes Latest, with its images
tagged latest, only when no newer release already is, and only once its signed Mac and Windows
installers are attached (see Which release is Latest). A patch from main can be
released while a newer final is out, and that does not move Latest back.
The smoke journey is still required before a final. See The one thing CI cannot do. The PR body asks for the result, as it does for any stable release.
Afterwards the publish workflow opens a PR titled "Merge release/X.Y back into main". The version, the changelog section and every fix made on the release branch exist only there until it merges.
The PR comes from merge-back/vX.Y.Z, not from release/X.Y. The workflow makes that branch at the
tip of release/X.Y, and puts main's desktop/intelligence-cli.json back on it when the two
differ. The candidate pinned the CLI to an exact version for the release only, so main keeps its
own choice, such as latest. A push to merge-back/vX.Y.Z is not a release.
A PR opened by a workflow does not start CI, so push an empty commit to merge-back/vX.Y.Z or close
and reopen the PR before merging. If the merge conflicts, a person resolves it.
Merge the merge-back PR with a merge commit, not a squash, so main keeps the release branch's history and the next merge-back does not conflict.
Every release carries each installer twice: under the name Tauri gives it, which contains the version, and under a fixed name that is the same in every release. A web page links to the fixed name. On a candidate or beta the publish workflow attaches both for its ad-hoc and unsigned installers, and the signing runs replace both with the signed ones. On a stable release the publish workflow attaches no installer at all: the signing runs attach both names from the signed installers, which are the only installers a stable release carries:
| Platform | Address |
|---|---|
| Mac, Apple silicon | https://github.com/CopilotKit/OpenBot/releases/latest/download/OpenBot-mac-arm64.dmg |
| Mac, Intel | https://github.com/CopilotKit/OpenBot/releases/latest/download/OpenBot-mac-x64.dmg |
| Windows, x64 | https://github.com/CopilotKit/OpenBot/releases/latest/download/OpenBot-windows-x64-setup.exe |
These names are a public address. Do not change them: every page that links to one breaks.
scripts/release-download-names.sh maps the Tauri names to
them, and the publish and signing workflows fail on an installer it does not know.
releases/latest/download goes to the release marked Latest. A candidate or beta is never Latest,
so these addresses never give one. Link a prerelease by its version instead, for example
https://github.com/CopilotKit/OpenBot/releases/download/v0.3.0-rc.1/OpenBot-mac-arm64.dmg.
A stable release becomes Latest only once both signing runs have attached their signed installers under both names (see Which release is Latest), so these addresses always give signed builds, and the Intel one always exists. Until then they give the previous release's. Each signing run replaces its installers under both names, and running it again replaces them again.
On a candidate or beta, the publish workflow's installers job runs while the signing runs may be
attaching, so it never replaces anything
(scripts/release-attach-installers.sh): it uploads an
installer, and its fixed-name copy, only when the release has no asset of that name, without
--clobber, and when a signing run attached that name meanwhile it keeps the signed one. The
fixed-name copy is made from the installer as the release has it, so when the signed one is already
there the copy is signed too. A signing run always attaches with --clobber, so whichever runs
first, the signed build is what stays, under both names. A re-run of the publish workflow does the
same: it adds what is missing and keeps everything else, signed or not, so it never puts an unsigned
build back over a signed one. A fixed name left stale by an interrupted signing upload is put right
by running that signing run again. To replace an attached installer with a new build, delete it from
the release first, then re-run.
Linux installers are not attached to a release yet.
The website links to downloads.copilotkit.ai, which serves a copy of the Latest stable release's
signed installers:
| Platform | Address |
|---|---|
| Mac, Apple silicon | https://downloads.copilotkit.ai/latest/OpenBot-mac-arm64.dmg |
| Mac, Intel | https://downloads.copilotkit.ai/latest/OpenBot-mac-x64.dmg |
| Windows, x64 | https://downloads.copilotkit.ai/latest/OpenBot-windows-x64-setup.exe |
| Update feed | https://downloads.copilotkit.ai/latest/latest.json |
Each stable release's installers also stay under their versioned names, for example
https://downloads.copilotkit.ai/releases/v0.3.0/OpenBot_0.3.0_aarch64.dmg. Those never change, so
they are cached for a year; the latest/ addresses are cached for five minutes. Signed apps read
their update feed here, and GitHub's copy only when this address does not answer.
Downloads sync (downloads-sync.yml, running
scripts/downloads-sync.sh) keeps it in step. The signing run that
makes a stable release Latest starts it (scripts/release-make-latest.sh),
so the addresses follow within minutes; it also runs every six hours in case anything drifted, and
by hand. Each run:
- reads the release GitHub marks Latest, which is never a candidate, a beta or a draft;
- checks it carries
latest.jsonand every installer under its fixed download name, and that each versioned installer is the same file as its fixed-name copy, so both are the signed build; - compares each file's SHA-256 with what the bucket holds, uploads only what differs, and
invalidates
/latest/*only when something underlatest/changed.
When the Latest release lacks some of those files, the run fails and changes nothing, so the addresses keep serving the previous signed installers. A Latest release with none of them was published before signing attached any, as v0.2.0 was: that is not drift, so the run succeeds with "no signed installers yet; nothing to sync" and does not touch AWS. A release asked for by tag must have them all. Nothing is ever deleted.
To run it by hand, from main:
gh workflow run downloads-sync.yml --ref main # the Latest release
gh workflow run downloads-sync.yml --ref main -f tag=v0.3.0 # releases/v0.3.0/ onlyBehind the address, in AWS account 604598162673: a private, versioned S3 bucket
copilotkit-openbot-downloads (us-east-1), read only by CloudFront distribution EQ4EH8L79LPGX
through an origin access control, with an ACM certificate for downloads.copilotkit.ai and alias
records in the copilotkit.ai zone. The workflow signs in as the role openbot-release-downloads
with GitHub's OIDC token, so there is no stored key. The role trusts only downloads-sync.yml on
main, and may only read and write the bucket's objects and invalidate that distribution. The role
ARN, bucket and distribution are written in the workflow; none is a secret.
publish-release.yml runs on every push to main and to release/**, and starts by deciding
whether the commit is a release at all. It asks the API for the pull request that produced the
commit, and requires that the head branch matches release/publish/vX.Y.Z, with a -rc.N or
-beta.N suffix for a candidate or beta, that the branch is in this repository, and that the pull
request carries the release label. A fork can name a branch anything; it cannot add a label.
A version without a suffix merges into main, as a stable release, or into release/X.Y, as a
final. A candidate or beta merges only into the release/X.Y its version names. A labelled release
PR merged anywhere else fails the run. Cutting a plain patch, minor or major from main is
unchanged. What it publishes now also includes the installers and the sha-<commit> image tag, as
listed below.
Then, in order:
- the version in the tree is checked against the branch that is publishing it, and the changelog is checked for a section with that number
- the commit is checked, as described under What has to be green: the full CI suite, or, when the release PR changed only the release files on a commit main's CI passed, that run and the checks that read the release files
- one image is built and pushed to
ghcr.io/copilotkit/openbot, tagged with the version, the commit assha-<commit>andlatestonly when the release becomes Latest (a stable release with no newer release already out). A candidate or beta is never taggedlatest - the services
docker-compose.ymlcan build are published too, one image each, atghcr.io/copilotkit/openbot-<service>. Those arelinux/amd64andlinux/arm64, built on native runners of each architecture and joined into one manifest list, because the machines pulling them are laptops as well as servers..github/published-images.jsonis the list, and CI fails if it stops matching the Dockerfiles in the tree - a build provenance attestation is signed with the workflow's OIDC identity and pushed alongside every one of them
- the commit is tagged and a GitHub Release is created, carrying the changelog section as its notes
(for a candidate or beta, what is under
## Unreleased),container-images.json, the source archive, the chart and the release manifest as assets. A candidate or beta is marked as a prerelease and never Latest. A stable release is published not Latest, with no installers yet - Desktop macOS signing is dispatched on the tag straight
away. It waits for a
macos-signingreviewer, then builds both Macs and attaches the signed.dmgfiles to the release, under both names. For a stable release it also attaches the updater archives and lists them inlatest.json, and then marks the release Latest whenverifydecided it becomes Latest and Windows signing has already attached and listed its installer (see Which release is Latest). A candidate or beta's signed.dmgis pinned to its images, and has no updater archive and nolatest.json - Desktop Windows signing is dispatched on the tag at the same moment. It
waits for a
windows-signingreviewer, then attaches the signed-setup.exeunder both names. A candidate or beta's is pinned to its images. For a stable release it then waits for amacos-signingreviewer to sign the installer for the updater, attaches that signature, lists the installer inlatest.json, and marks the release Latest whenverifydecided it becomes Latest and Mac signing has already attached and listed its installers - for a candidate or beta only, its macOS (Apple silicon) and Windows installers are built from the
commit while signing runs, ad-hoc signed and unsigned, with the CLI bundled when the tree has
desktop/src-tauri/tauri.bundled-cli.conf.jsonand pinned to the images above. They are attached under both names wherever the release does not already have a signed one (see Download links). A stable release builds none here: the signing runs' builds are the only installers it carries, and the Desktop workflow builds and tests the app on the release commit's push tomainanyway - for a final cut from
release/X.Y, the merge-back PR opens as described under Finishing a candidate
Until signing is approved, a stable release has no installers and is not Latest, so
releases/latest keeps serving the previous release's signed ones. A candidate or beta has its
ad-hoc Mac installer and unsigned Windows installer, like every ordinary CI build, once they are
built.
container-images.json pins a digest for every image the release published. Deploy those, not tags:
gh release download v0.1.0 --pattern container-images.json
docker run -p 3001:3001 --env-file .env \
"$(jq -r .images.openbot.reference container-images.json)"The same file is how a machine runs the stack without building any of it. Each key under images
is a service, so the references can be read straight out of it:
export COMPUTER_IMAGE="$(jq -r '.images["agent-computer"].reference' container-images.json)"
export SUPERVISOR_IMAGE="$(jq -r .images.supervisor.reference container-images.json)"
export BOT_IMAGE="$(jq -r '.images["agent-bot"].reference' container-images.json)"
export LANGGRAPH_IMAGE="$(jq -r '.images["agent-langgraph"].reference' container-images.json)"
export SERVER_IMAGE="$(jq -r .images.server.reference container-images.json)"
export IMAGE_PULL_POLICY=missing
docker compose up -dIMAGE_PULL_POLICY=missing is what turns those names into pulls; without it every one of those
services builds from source, because a service with a build section builds by default whatever
its image is called. A pull that fails still falls back to building, so a machine with no toolchain
wants the build sections overridden away rather than this variable alone.
A tag can be moved to point at a different image; a digest cannot. The same digest that CI smoke tested is the one that runs, and rolling back is the same command with an earlier version.
On Kubernetes the chart takes the same digest as image.digest; see
charts/openbot/README.md.
Before deploying, you can check an image is the one this repository built. Every published image carries its own attestation:
gh attestation verify oci://ghcr.io/copilotkit/openbot:v0.1.0 -R CopilotKit/OpenBot
gh attestation verify oci://ghcr.io/copilotkit/openbot-supervisor:v0.1.0 -R CopilotKit/OpenBotBranch protection should require one check, verify, which fails unless every other job succeeded.
A job added to ci.yml is covered by it without anybody updating a list.
| check | what it would catch |
|---|---|
format, lint, types |
the ordinary things, across every workspace including agent-computer and the supervisor |
types (agent-computer), types (supervisor) |
a type error in either deployable package, each checked on its own install |
native types |
a type error in the mobile app, which installs from its own npm lockfile |
computer (real browser) |
the agent-computer behaviour only a real Chromium shows, such as a password reaching the snapshot a model reads, a session cookie lost on restart, or WebRTC leaving around the egress filter |
tests |
a decision made wrongly, in isolation |
chart |
a Helm values file that renders a server which cannot start, across the self-hosted, EKS, EKS sandbox, GKE and AKS targets |
python harness regressions |
a provider-boundary regression in the Python Bot harnesses |
startup (macos-latest), startup (windows-latest) |
the app's serve-or-build cache deciding wrongly on macOS or Windows |
build |
the app not compiling |
migrations |
a schema change with no migration, or a snapshot that has drifted |
image |
an image that builds but does not boot, or a supervised service that respawns |
component dockerfiles |
a Dockerfile a release would publish that no longer builds, or one the publish list has stopped covering |
image matters more than its position suggests. Everything above it can pass on a tree whose image
never starts, because nothing else here runs the thing it ships. It builds the container, boots it
with embedded PostgreSQL, waits for /api/capabilities, and fails if a supervised service is
respawning.
These checks gate the publish rather than the proposal, which is why the release PR arriving
without its own checks does not matter: a pull request opened by a workflow does not trigger them.
When the release PR merges, the publish workflow's checks needed job
(scripts/release-ci-gate.ts) decides whether they run again on the
release commit:
- When the release commit changes nothing but the release files from the commit it merged onto
(its first parent), and main's CI passed on that commit, they do not. That commit's tree has been
through all of them already. The release files are
CHANGELOG.mdandrelease-policy.json, which may change freely, and theversioninpackage.json, theopenbot-desktopversion line indesktop/src-tauri/Cargo.tomlandCargo.lockandappVersionincharts/openbot/Chart.yaml, which may change nothing else. Only a run ofci.ymlfor a push to this repository'smain, at exactly that commit, counts; a pull request's run tests a merge of the PR, not the commit, and skipsimageunless it was labelled. A run still in progress is waited for, up to 40 minutes. In their place the job re-runs the checks that read the files the release did change: formatting and lint, the desktop and chart versions againstpackage.json, and every test that namesCHANGELOG.mdorrelease-policy.json. - Otherwise they all run again, as before: when any other path is added, removed, renamed or changed, when a version file changes more than its version, or when main's CI on that commit failed, was cancelled, never ran, could not be read, or was still running after the wait.
The diff is read with renames turned off, so a source file moved onto a release file's name is still
seen as the source file it removed. Every commit on main keeps its own CI run for this (the
concurrency group in ci.yml is per commit on main), so a commit pushed while another's run was
in progress still gets a result. A final's parent is its candidate on release/X.Y, which main's CI
never ran on, so a final always runs the full suite.
No secrets are required to release. CI and Create release PR use only the built-in
GITHUB_TOKEN. Publish release, Internal build and the desktop build workflows also pass
OPENBOT_GOOGLE_MODEL_OAUTH_CLIENT_SECRET to the installer build when it is set, and build without
it when it is not. Signed desktop builds are the exception: they read the macos-signing and
windows-signing environments described above and in Windows signing.
Downloads sync signs in to AWS with GitHub's OIDC token rather than a stored key.
The smoke journey in tests/smoke is the only check that proves the parts are wired to each other:
the server reaches the supervisor, the supervisor builds a computer, the gateway decides before the
browser acts, and the trail records it. It cannot run in CI, and this is not a gap to be closed
later.
OpenBot only runs in Intelligence mode, and loadConfig refuses to start without the project's
Intelligence values, which a hosted runner has no business holding. The image check gets around
this with placeholder values, because nothing is contacted at start-up, but the journey asserts
licenseStatus is valid, and no placeholder can make that true.
So it is a step a person takes, on a machine with real Intelligence credentials, before merging the release PR:
bash scripts/start.sh
export OPENBOT_SMOKE_COOKIE='better-auth.session_token=...' # from a signed-in browser
bun run test:smokeThe session is the second half of "a machine with real credentials": the routes the journey proves
are behind requireUser, so a run without one answers 401 three times and says nothing about the
release. See development.md for where the cookie comes from.
The release PR asks for the result in a comment. That is deliberately a person rather than a robot: it is the one gate that cannot be automated, so it is the one gate worth naming.