Skip to content

Release Workflow

This page is written for maintainers who operate Workbench releases — not end users. It documents the .github/workflows/release.yml GitHub Actions pipeline that builds, signs, and publishes Workbench to Pigeon (Cloudflare R2), and the one-time setup required before the first release runs.

Releases used to be a manual, two-machine process: run task release:cut locally to bump the version and tag it, then run task release:publish by hand on a Mac and a Linux box to build, sign, and upload artifacts. That process still exists as a fallback (see Manual fallback below), but the default path is now automated.

Pushing a merge from main into the long-lived release branch triggers a single GitHub Actions workflow that:

  1. Computes the next CalVer version and cuts the release commit + tag.
  2. Builds and signs the desktop app, CLI, and server on both supported targets in parallel.
  3. Publishes every artifact to Pigeon, promotes the stable channel, and regenerates the update feeds.
  4. Merges the release commit back into main.

Everything runs on self-hosted runners — there is no cross-compilation and no CI-hosted signing key material beyond what’s scoped to the RELEASE environment.

Terminal window
git checkout release
git merge main
git push

That’s it. The push to release is the trigger — on: push: branches: [release] in the workflow. A release always starts from a real merge of main into release; the workflow’s workflow_dispatch entry point only runs the preflight dry run and can never cut, build, or publish.

The workflow guards against re-triggering itself: the cut job pushes an automated release: <version> commit back to release, and every job checks !startsWith(github.event.head_commit.message, 'release: ') so that automated push no-ops instead of starting a second release attempt.

Seven jobs run in a dependency chain:

push release → cut → build-macos → publish-macos ─┐
└→ build-linux → publish-linux ──┤→ merge-back
│
(on failure)
│
cleanup
JobRuns onWhat it does
cutLinux self-hostedComputes the next CalVer version, stamps Cargo.toml/package.json, claims the [Unreleased] CHANGELOG section, commits as release: <version>, tags v<version>, creates the Pigeon release record, and pushes both to origin release. Publishes version, release_tag, release_sha, and pigeon_release_created as job outputs that every downstream job consumes instead of recomputing.
build-macosmacOS ARM64 self-hostedChecks out the exact release SHA, verifies the tag, clones the signing vault and decrypts Apple certs + signing keys, code-signs and notarizes the desktop app (task build:macos:signed), and builds the CLI and server. Stages artifacts to a shared directory on the runner host.
build-linuxLinux self-hostedSame shape as build-macos without Apple cert material: decrypts Tauri signing key only, builds desktop (.deb/.rpm/.AppImage), CLI, server, and the transitional metapackage. Stages artifacts to a shared directory on the runner host.
publish-linuxLinux self-hostedCollects Linux artifacts from the shared staging directory, signs every artifact for the Tauri updater and download integrity, uploads to Pigeon, and promotes the stable channel. Feed generation is attempted but tolerated if the other platform hasn’t published yet.
publish-macosmacOS ARM64 self-hostedSame as publish-linux but for macOS artifacts — runs on the same macOS runner that built them. Whichever publish job runs second successfully generates the combined feed.
merge-backLinux self-hostedRuns only if both publish-linux and publish-macos succeeded. Merges release into main with a real merge commit via r26d/merge-branch@v2 (not a rebase), so main ends up with the release commit plus a merge commit. Does not use release secrets.
cleanupLinux self-hostedRuns only when cut produced a version but a build failed and neither publish job succeeded. Deletes the git tag (only if it still points at the cut SHA) and emits a warning that the Pigeon release record for the version is still in R2 — pigeon has no release delete command, and cut.ts reuses an existing record when the same version is re-cut. Never runs once a publish job has begun uploading — at that point the release is retryable, not disposable.
preflightOne Linux + one macOS self-hostedworkflow_dispatch only. Verifies credentials and tooling on both platforms without releasing — see Preflight dry run.

Each build job checks out needs.cut.outputs.release_sha and asserts git describe --exact-match --tags HEAD matches needs.cut.outputs.release_tag before building, so a build can never silently run against the wrong commit.

One-time setup before the workflow can run for the first time:

  1. Generate the cicd-workbench age key pair. Run scripts/age-cicd-setup.sh — it generates the key pair, prints the public key, and outputs instructions for the vault setup steps.
  2. Update SOPS rules in r26d_signing_secrets/.sops.yaml to include the age public key as a recipient for the CI-authorized signing files (see Age-based access model below).
  3. Create .env.signing.ci in the signing vault with the CI-needed subset of .env.signing (R26D_TAURI_SIGNING_PASSWORD, R26D_DEBIAN_SIGNING_PASSWORD, ASC_API_KEY_ID, ASC_API_KEY_ISSUER_ID). Encrypt it with sops encrypt --input-type binary --output-type json.
  4. Re-encrypt authorized files — run sops updatekeys on the 5 authorized vault files so the age key is added as a recipient.
  5. Authorize CI for pigeon’s lanyard badges. In r26d_lanyard_secrets/.sops.yaml, add a machine age public key as a recipient of the badges/pigeon/* rule and run sops updatekeys on badges/pigeon/r2-access-key-id.enc and badges/pigeon/r2-secret-access-key.enc. The matching private key becomes the LANYARD_AGE_KEY secret. (This key may be a separate key pair from cicd-workbench; it currently is.)
  6. Set the GitHub secrets. Run scripts/setup-release-env.sh — it creates the RELEASE environment and prompts for the two age key files and the PAT. The PAT must be able to read r26d/devkit, r26d/r26d_signing_secrets, and r26d/r26d_lanyard_secrets; a fine-grained PAT with repository access to those three is enough.
  7. Let CI download @r26d/lanyard. pigeon installs it from GitHub Packages (npm.pkg.github.com), which does not accept fine-grained PATs. Pick one:
    • grant this repository Actions access on the private r26d/lanyard npm package (package settings → Manage Actions access → add r26D/workbench, read). The workflow then authenticates with its own GITHUB_TOKEN (permissions: packages: read is already declared) and no extra secret is needed; or
    • create a classic PAT with only read:packages and store it as the optional R26D_PACKAGES_TOKEN secret.
  8. Runner setup:
    • Install sops on all runners (age is not needed — sops decrypts age recipients natively, and lanyard is configured with the sops mechanism).
    • Install asdf and run asdf install in a workbench checkout on every runner to pick up the pinned Rust/bun toolchain from .tool-versions.
    • Linux runners: install Tauri’s system dependencies — libgtk-3-dev, libwebkit2gtk-4.1-dev, libappindicator3-dev, librsvg2-dev, patchelf, dpkg-dev, rpm. Also install build acceleration tools: sccache (cargo install sccache --locked) and mold (sudo apt-get install -y mold). GCC 12+ is required for -fuse-ld=mold support.
    • macOS runners: ensure Xcode Command Line Tools are installed (xcode-select --install).
  9. Create the release branch if it doesn’t already exist:
    Terminal window
    git checkout main
    git checkout -b release
    git push -u origin release
  10. Run the preflight dry run (below) and fix anything it reports before pushing to release.

The workflow can be dispatched manually to verify the whole credential and tooling chain on the actual runners without cutting a release:

Terminal window
gh workflow run release.yml --ref main -f mode=preflight
gh run watch

The preflight job runs on one Linux and one macOS runner and checks, in order: the three tool/secret repos clone with R26D_TOOLS_PAT; the CI-authorized vault files decrypt with SOPS_AGE_KEY and .env.signing carries the expected variables; pigeon’s dependencies install (proves read:packages); pigeon resolves its project root from the workbench checkout; the two pigeon badges resolve through lanyard with LANYARD_AGE_KEY and the R2 bucket is readable (a single read-only listing of _state/apps/); autopen installs and can read the vault. It never creates tags, Pigeon records, or R2 objects. Because every release job is gated on github.event_name == 'push', a dispatch cannot accidentally start a release, and a push to release never runs preflight.

scripts/setup-release-env.sh creates the RELEASE environment (idempotent — safe to rerun) and sets the secrets interactively: the signing-vault age key file, the lanyard age key file, the GitHub PAT, and optionally the classic packages token. Requires the gh CLI authenticated with repo admin access.

Terminal window
./scripts/setup-release-env.sh

Three secrets (plus one optional) are stored as environment secrets on the RELEASE GitHub Actions environment. All other signing keys and credentials live in the encrypted signing vault and the lanyard secrets repo, decrypted at runtime by the age keys.

SecretWhat it holdsUsed by
SOPS_AGE_KEYcicd-workbench age private key — decrypts the CI-authorized files in r26d/r26d_signing_secrets (autopen’s inputs)build-macos, build-linux, publish-*, preflight
LANYARD_AGE_KEYage private key that is a recipient of the pigeon badge files in r26d/r26d_lanyard_secrets — lanyard resolves pigeon’s R2 access key and secret from those badgescut, publish-*, preflight
R26D_TOOLS_PATGitHub PAT (fine-grained is fine) with read access to r26d/devkit (pigeon + autopen), r26d/r26d_signing_secrets, r26d/r26d_lanyard_secretsAll jobs that clone tool repos
R26D_PACKAGES_TOKEN (optional)Classic PAT with read:packages, used only to download @r26d/lanyard from GitHub Packages. When absent the job’s GITHUB_TOKEN is used, which works once the r26d/lanyard package grants Actions access to this repository.cut, publish-*, preflight (pigeon install)

Nothing R2-related is configured on the GitHub side: the non-secret R2_ACCOUNT_ID and R2_BUCKET come from devkit’s committed projects/pigeon/.env, exported by the .github/actions/setup-pigeon composite action, and the credentials come from lanyard.

Each job that needs secrets clones the signing vault and/or the lanyard secrets repo, decrypts only the files it’s authorized to access, and removes the checkouts, the lanyard config, and the lanyard key file in an always() cleanup step. No key material persists on the (persistent, self-hosted) runner filesystem between runs. Autopen reads the vault via R26D_SIGNING_VAULT_PATH — the vault layout matches the real repository structure, with no reconstruction from base64 secrets.

setup-pigeon clones r26d/r26d_lanyard_secrets to .lanyard-secrets, writes a lanyard config under $RUNNER_TEMP/lanyard/ with identity.class = "machine" and mechanism = "sops" (the badge files are sops-encrypted JSON), and points LANYARD_CONFIG_PATH at it. Lanyard’s sops mechanism shells out to sops decrypt, and sops loads age identities from both SOPS_AGE_KEY and SOPS_AGE_KEY_FILE, so the action writes LANYARD_AGE_KEY to a 0600 file and exports SOPS_AGE_KEY_FILE. That lets the signing-vault key stay in SOPS_AGE_KEY for the same job while lanyard decrypts the badge files with the lanyard key — LANYARD_AGE_KEY itself is only read directly by lanyard when the mechanism is age, which is not the case here.

CI uses two age key pairs. The cicd-workbench key decrypts only the files it needs from the signing vault; its public key is committed in r26d_signing_secrets/.sops.yaml and the private key is the SOPS_AGE_KEY secret. A second machine key is a recipient of the pigeon badge files in r26d_lanyard_secrets; its public key is committed in that repo’s .sops.yaml and the private key is the LANYARD_AGE_KEY secret.

Authorized CI inventory (5 files + 2 badges)

Section titled “Authorized CI inventory (5 files + 2 badges)”

r26d_signing_secrets (5 files):

FileContents
.env.signing.ci.encR26D_TAURI_SIGNING_PASSWORD, R26D_DEBIAN_SIGNING_PASSWORD, ASC_API_KEY_ID, ASC_API_KEY_ISSUER_ID
apple/certs/developer_id/W78G6V5S6B.p12.encDeveloper ID Application certificate
apple/api_key.p8.encApp Store Connect API key for notarization
debian/repo-signing-key.gpg.encDebian APT repo signing GPG private key
tauri-apps/workbench/privkey.encTauri updater signing private key

r26d_lanyard_secrets (2 badges, resolved by pigeon through lanyard):

BadgeFileContents
pigeon/r2-access-key-idbadges/pigeon/r2-access-key-id.encCloudflare R2 access key ID
pigeon/r2-secret-access-keybadges/pigeon/r2-secret-access-key.encCloudflare R2 secret access key

Everything else in the signing vault (.env.signing.enc with R26D_MATCH_PASSWORD, iOS profiles, Android keystores, development/distribution certs, the PAT backup) and every other badge in the lanyard secrets repo is GPG-only — CI cannot decrypt it.

Remove the age public keys from the SOPS rules in both repos, run sops updatekeys on the 5 vault files and the 2 pigeon badge files, commit and push, then delete the SOPS_AGE_KEY and LANYARD_AGE_KEY GitHub environment secrets.

Generate a new age key pair, add the new public key to the relevant .sops.yaml rule, run sops updatekeys on the affected files, update the matching GitHub secret, run the preflight dry run, then remove the old public key and update keys again.

The workflow is designed so a failed attempt can be retried without hand-editing version files. What to do depends on which job failed:

StateHow to tellWhat to do
cut failed before producing a version outputneeds.cut.outputs.version is empty in the failed runNothing to clean up — no tag or Pigeon record was created. Fix the underlying issue and push a new merge from main to release (or rerun the failed job if the cause was transient).
cut failed after creating the Pigeon release recordVersion output exists, no publish upload startedThe cleanup job runs automatically and deletes the tag + Pigeon release record for the failed attempt. Push a new merge from main to release to retry — cut.ts recomputes the version (or repairs the same-version tag state per its retry rules) automatically.
A build job (build-macos or build-linux) failedcut succeeded, corresponding publish was skippedSame as above — cleanup removes the tag and Pigeon record automatically. Retry with a fresh push to release.
A publish job partially failed (upload, promotion, or feed generation)The publish job started uploading artifacts before failingDo not rely on cleanup — it never runs once a publish job begins uploading, because the release is now a retryable partial release, not a disposable failed attempt. Rerun the failed publish job from the GitHub Actions UI; artifact upload, channel promotion, and feed generation are all idempotent and safe to repeat for the same version.
Both publish jobs succeeded but merge-back failedThe stable feed points at the new version, but main doesn’t contain the release commitThe release is already live — never run cleanup here. If the failure was a merge conflict, resolve main/release manually with a real merge commit (not a rebase) that preserves the published version commit. If it was a permissions/branch-protection issue, fix the repo setting and rerun the merge-back job, or merge manually.
merge-back succeededmain contains the release commit and the stable feed points at the new versionRelease complete — nothing further to do.

A few rules worth internalizing:

  • Cleanup only ever touches the tag and Pigeon record, and only for a run whose cut output SHA matches what’s actually on the tag. It never deletes uploaded artifacts.
  • Once a publish job starts uploading, the release attempt is permanent. Any further failure in a publish job is recovered by rerunning it, not by starting over.
  • Retrying always means pushing a fresh merge from main to release (or rerunning a specific failed job from the Actions UI) — never hand-edit Cargo.toml/package.json/CHANGELOG.md to force a version.

The original two-machine task flow still exists and remains a valid fallback if the GitHub Actions pipeline or its self-hosted runners are unavailable. It is intentionally still host-native and two-phase — see docs/decisions/sdr/0008-keep-desktop-release-publishing-manual-and-host-native.md in the repo for the rationale.

Terminal window
# Phase 1 — cut the release (run once, anywhere)
task release:cut
# Phase 2 — build, sign, and publish artifacts (run once per target host)
task release:publish # builds and publishes for the current platform
task release:publish:macos # macOS artifacts only
task release:publish:linux # Linux artifacts only

This path requires the operator to have the full signing vault available locally (SOPS-decrypted, not the CI-scoped ephemeral vault the GitHub Actions jobs construct), and to run the Mac and Linux publish steps separately by hand. Treat it as a fallback, not the primary path — prefer pushing to release and letting the workflow handle the full pipeline.

RunnerLabelsOSArchJobs it can run
ur238-r26d-1 … ur238-r26d-4self-hosted, Linux, X64Linuxx86_64cut, build-linux, publish-linux, merge-back, cleanup, preflight (linux)
mac-studio-1 … mac-studio-3self-hosted, macOS, ARM64macOSARM64build-macos, publish-macos, preflight (macos)

Only two build targets are supported: Linux x86_64 and macOS ARM64. There is no Intel Mac, ARM Linux, or Windows build in this workflow — Windows releases remain a separate cross-compiled process outside this pipeline.

Because these are persistent self-hosted machines (not ephemeral GitHub-hosted runners), every job clones the vault and lanyard secrets fresh per run and removes them (plus the lanyard config and key file) in an always() step so secrets never linger between release attempts.

The build-linux job uses two tools to speed up compilation on the x86_64 runners:

  • sccache (RUSTC_WRAPPER=sccache) — caches compiled crate artifacts across runs. The first release build on a runner is cold, but subsequent releases hit the cache for unchanged crates (~99% hit rate in practice).
  • mold (-C link-arg=-fuse-ld=mold) — a faster linker than the default ld or lld. Uses GCC’s -fuse-ld=mold flag (requires GCC 12+).

The workflow verifies both tools are installed and correctly configured before building. If a runner is missing either tool, the job fails early with an install instruction rather than silently falling back to slower defaults.

Runner prerequisites for these tools:

Terminal window
# sccache (compile cache)
cargo install sccache --locked
# mold (fast linker)
sudo apt-get install -y mold