Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions .github/workflows/build-tauri.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
name: Build Tauri

# Reusable build for the desktop app across all platforms. Shared by the
# stable release flow, the per-merge pre-release flow, and the promote flow so
# every channel is built with identical logic (no drift).
on:
workflow_call:
inputs:
ref:
description: 'Git ref to build (empty = the caller ref)'
type: string
default: ''
set_version:
description: 'If set, stamp this version onto the working tree before building'
type: string
default: ''
release_id:
description: 'Upload artifacts to this existing release id (stable flow)'
type: string
default: ''
tag_name:
description: 'Create/append to a release with this tag (pre-release / promote flow)'
type: string
default: ''
release_name:
description: 'Release title when creating via tag_name'
type: string
default: ''
release_body:
description: 'Release body when creating via tag_name'
type: string
default: ''
prerelease:
description: 'Mark the created release as a pre-release'
type: boolean
default: false
draft:
description: 'Create the release as a draft'
type: boolean
default: false
apple_full_signing:
description: 'Import the Apple Developer cert (true) or ad-hoc sign (false)'
type: boolean
default: true

env:
CARGO_TERM_COLOR: always

jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact: linux
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: windows
- os: macos-latest
target: aarch64-apple-darwin
artifact: macos-arm
- os: macos-15-intel
target: x86_64-apple-darwin
artifact: macos-intel

runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}

# Stamp a pre-release / promote version onto the working tree (transient,
# never committed) so the binary, bundle, and latest.json all agree.
- name: Set build version
if: inputs.set_version != ''
shell: bash
run: node scripts/set-version.mjs "${{ inputs.set_version }}"

- name: Install Linux deps
if: matrix.os == 'ubuntu-latest'
uses: ./.github/actions/install-linux-deps
with:
verify_glib: 'false'

- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
env:
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig

- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}-release
env:
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- run: pnpm install --frozen-lockfile

# Import Apple certificate ourselves, then DON'T pass APPLE_CERTIFICATE
# to tauri-action. Tauri's bundler uses var_os() which treats empty
# strings as present (Some("")), so we must completely omit the env var.
# Instead we import the cert here and only pass APPLE_SIGNING_IDENTITY.
# When apple_full_signing is false (pre-releases), we ad-hoc sign ("-").
- name: Import Apple certificate
if: runner.os == 'macOS'
id: apple-cert
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
if [ "${{ inputs.apple_full_signing }}" != "true" ]; then
echo "Ad-hoc signing requested (pre-release build)"
echo "identity=-" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$KEYCHAIN_PASSWORD" ]; then
echo "No Apple certificate configured — using ad-hoc signing"
echo "identity=-" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
if [ ! -s certificate.p12 ]; then
echo "Certificate decode produced empty file — using ad-hoc signing"
rm -f certificate.p12
echo "identity=-" >> "$GITHUB_OUTPUT"
exit 0
fi
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
if ! security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign; then
echo "Certificate import failed — using ad-hoc signing"
rm -f certificate.p12
echo "identity=-" >> "$GITHUB_OUTPUT"
exit 0
fi
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
rm certificate.p12
echo "identity=${{ secrets.APPLE_SIGNING_IDENTITY }}" >> "$GITHUB_OUTPUT"
echo "cert_ok=true" >> "$GITHUB_OUTPUT"

# IMPORTANT: Do NOT pass APPLE_CERTIFICATE, APPLE_ID, APPLE_PASSWORD,
# or APPLE_TEAM_ID to tauri-action. Tauri's bundler uses var_os() which
# treats empty strings as "present" and attempts certificate import /
# notarization even when values are empty, causing build failures.
# We handle cert import ourselves above and only pass APPLE_SIGNING_IDENTITY.
#
# Two variants so each path passes EXACTLY the inputs it needs:
# • release_id set → upload to the pre-created (draft) release by id.
# Pass ONLY releaseId — never tagName/draft/prerelease, which could
# flip the draft early or mismatch its state. (Stable + promote.)
# • release_id empty → create/append to a tagged release. (Pre-release.)
- name: Build Tauri app (upload to existing release)
if: inputs.release_id != ''
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && steps.apple-cert.outputs.identity || '' }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
with:
projectPath: apps/desktop
releaseId: ${{ inputs.release_id }}
updaterJsonKeepUniversal: true
args: --target ${{ matrix.target }}

- name: Build Tauri app (create tagged release)
if: inputs.release_id == ''
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && steps.apple-cert.outputs.identity || '' }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
with:
projectPath: apps/desktop
tagName: ${{ inputs.tag_name }}
releaseName: ${{ inputs.release_name }}
releaseBody: ${{ inputs.release_body }}
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ inputs.prerelease }}
updaterJsonKeepUniversal: true
args: --target ${{ matrix.target }}
129 changes: 129 additions & 0 deletions .github/workflows/promote.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: Promote Pre-release to Stable

# Escape hatch: ship the *exact tested code* of a pre-release as a clean stable
# release. Rebuilds the pre-release's commit at the stable version (X.Y.Z),
# fully signed, then publishes — so /releases/latest and the Stable update
# channel pick it up. The normal path remains merging the release-please PR.
#
# Note: Homebrew/APT are refreshed by the normal release-please flow, not here.
on:
workflow_dispatch:
inputs:
prerelease_tag:
description: 'Pre-release tag to promote (e.g. v0.4.0-pre.318)'
required: true
type: string

concurrency:
group: promote-${{ github.ref }}
cancel-in-progress: false

env:
CARGO_TERM_COLOR: always

jobs:
prepare:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
sha: ${{ steps.resolve.outputs.sha }}
stable_version: ${{ steps.resolve.outputs.stable_version }}
stable_tag: ${{ steps.resolve.outputs.stable_tag }}
release_id: ${{ steps.draft.outputs.release_id }}
steps:
- name: Resolve commit + stable version
id: resolve
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
PRE_TAG="${{ inputs.prerelease_tag }}"
PRE_TAG="v${PRE_TAG#v}" # normalize leading v
# Strip the -pre.N (and any +build) suffix to get the stable version.
STABLE_VERSION=$(printf '%s' "${PRE_TAG#v}" | sed -E 's/-pre\..*$//; s/\+.*$//')
STABLE_TAG="v${STABLE_VERSION}"

# The commit the pre-release was built from (deref annotated tags).
REF=$(gh api "repos/${{ github.repository }}/git/ref/tags/${PRE_TAG}")
SHA=$(printf '%s' "$REF" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).object.sha))')
TYPE=$(printf '%s' "$REF" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).object.type))')
if [ "$TYPE" = "tag" ]; then
SHA=$(gh api "repos/${{ github.repository }}/git/tags/${SHA}" --jq '.object.sha')
fi

# Refuse to clobber an existing stable release.
if gh release view "$STABLE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "::error::Stable release $STABLE_TAG already exists — nothing to promote"
exit 1
fi

echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT"
echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT"
echo "Promoting $PRE_TAG ($SHA) -> $STABLE_TAG"

- name: Create draft stable release
id: draft
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
RELEASE_ID=$(gh api --method POST "repos/${{ github.repository }}/releases" \
-f tag_name="${{ steps.resolve.outputs.stable_tag }}" \
-f target_commitish="${{ steps.resolve.outputs.sha }}" \
-f name="${{ steps.resolve.outputs.stable_tag }}" \
-f body="Promoted from ${{ inputs.prerelease_tag }}." \
-F draft=true -F prerelease=false \
--jq '.id')
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
echo "Created draft release id=$RELEASE_ID"

build:
needs: prepare
permissions:
contents: write
uses: ./.github/workflows/build-tauri.yml
secrets: inherit
with:
ref: ${{ needs.prepare.outputs.sha }}
set_version: ${{ needs.prepare.outputs.stable_version }}
release_id: ${{ needs.prepare.outputs.release_id }}
apple_full_signing: true

publish:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Import GPG signing key
run: echo "${{ secrets.APT_GPG_PRIVATE_KEY }}" | gpg --batch --import

- name: Sign release artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TAG="${{ needs.prepare.outputs.stable_tag }}"
REPO="${{ github.repository }}"
mkdir -p artifacts sigs
gh release download "$TAG" --dir artifacts --repo "$REPO" \
--pattern "*.deb" --pattern "*.rpm" --pattern "*.AppImage" \
--pattern "*.dmg" --pattern "*.exe" --pattern "*.msi" \
--pattern "*.nsis.zip" || true
for file in artifacts/*; do
[ -f "$file" ] || continue
gpg --batch --yes --detach-sign --armor -o "sigs/$(basename "$file").sig" "$file"
done
if ls sigs/*.sig >/dev/null 2>&1; then
gh release upload "$TAG" sigs/*.sig --repo "$REPO" --clobber
fi

- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release edit "${{ needs.prepare.outputs.stable_tag }}" \
--draft=false \
--repo "${{ github.repository }}"
Loading
Loading