diff --git a/.github/workflows/build-tauri.yml b/.github/workflows/build-tauri.yml
new file mode 100644
index 00000000..0de0405e
--- /dev/null
+++ b/.github/workflows/build-tauri.yml
@@ -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 }}
diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml
new file mode 100644
index 00000000..400eff6a
--- /dev/null
+++ b/.github/workflows/promote.yml
@@ -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 }}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ff8f361f..8b05ea02 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,122 +69,116 @@ jobs:
});
# ─────────────────────────────────────────────────────────────
- # Build Release: Build Tauri app when release is created
+ # Build Release: Build Tauri app when a stable release is created.
+ # Uploads to the pre-created draft release with full Apple signing.
# ─────────────────────────────────────────────────────────────
build-release:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
+ permissions:
+ contents: write
+ uses: ./.github/workflows/build-tauri.yml
+ secrets: inherit
+ with:
+ release_id: ${{ needs.release-please.outputs.release_id }}
+ apple_full_signing: true
- 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 }}
+ # ─────────────────────────────────────────────────────────────
+ # Pre-release: every non-release merge to main ships an automated
+ # pre-release so the Pre-release update channel always tracks main.
+ #
+ # The version is release-please's *pending* next stable version
+ # (X.Y.Z) plus a -pre. suffix, so SemVer ordering holds:
+ # 0.3.0 < 0.4.0-pre.317 < 0.4.0-pre.318 < 0.4.0
+ # release-please runs in manifest mode, so these v*-pre.* tags are
+ # invisible to its version tracking and never interfere.
+ # ─────────────────────────────────────────────────────────────
+ prerelease-version:
+ needs: release-please
+ if: needs.release-please.outputs.release_created == 'false' && github.event_name == 'push'
+ runs-on: ubuntu-latest
permissions:
contents: write
+ outputs:
+ version: ${{ steps.compute.outputs.version }}
+ release_id: ${{ steps.draft.outputs.release_id }}
steps:
- uses: actions/checkout@v4
- - 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.
- - name: Import Apple certificate
- if: runner.os == 'macOS'
- id: apple-cert
+ - name: Compute pre-release version
+ id: compute
env:
- APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
- APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
+ GH_TOKEN: ${{ github.token }}
run: |
- 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
+ set -euo pipefail
+ BOT_BRANCH="release-please--branches--main--components--mcpmux"
+ BASE=""
+ # Prefer release-please's pending next version (held in the manifest
+ # on its release-PR branch). This is exactly the X.Y.Z that merging
+ # the release PR will cut, so the pre-releases preview that version.
+ if CONTENT=$(gh api "repos/${{ github.repository }}/contents/.release-please-manifest.json?ref=${BOT_BRANCH}" --jq '.content' 2>/dev/null); then
+ BASE=$(printf '%s' "$CONTENT" | base64 -d \
+ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s)["."]))')
+ echo "Pending stable version (from release PR): $BASE"
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
+ # Fallback (no open release PR): patch-bump the current stable version.
+ if [ -z "$BASE" ]; then
+ CUR=$(node -e 'process.stdout.write(require("./.release-please-manifest.json")["."])')
+ BASE=$(printf '%s' "$CUR" | awk -F. '{printf "%d.%d.%d", $1, $2, $3 + 1}')
+ echo "No open release PR; patch-bumping current ($CUR) -> $BASE"
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.
- # When a valid Apple Developer certificate is configured, add notarization
- # env vars back here (APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID).
- - name: Build Tauri app
- uses: tauri-apps/tauri-action@v0
+ PRE="${BASE}-pre.${{ github.run_number }}"
+ echo "version=$PRE" >> "$GITHUB_OUTPUT"
+ echo "Pre-release version: $PRE"
+
+ # Pre-create a DRAFT pre-release so all matrix build jobs upload to it by
+ # id (no create-by-tag race), and it stays hidden until artifacts +
+ # latest.json are complete. prerelease=true keeps it out of /releases/latest.
+ - name: Create draft pre-release
+ id: draft
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: ${{ steps.apple-cert.outputs.identity }}
- VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
- VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
- with:
- projectPath: apps/desktop
- # Upload to the existing draft release
- releaseId: ${{ needs.release-please.outputs.release_id }}
- updaterJsonKeepUniversal: true
- args: --target ${{ matrix.target }}
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ TAG="v${{ steps.compute.outputs.version }}"
+ RELEASE_ID=$(gh api --method POST "repos/${{ github.repository }}/releases" \
+ -f tag_name="$TAG" \
+ -f target_commitish="${{ github.sha }}" \
+ -f name="McpMux $TAG" \
+ -f body="Automated pre-release built from main (${{ github.sha }}). Early build — may be unstable. Use the Stable channel in Settings → Software Updates for published releases." \
+ -F draft=true -F prerelease=true \
+ --jq '.id')
+ echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
+ echo "Created draft pre-release $TAG (id=$RELEASE_ID)"
+
+ prerelease-build:
+ needs: prerelease-version
+ if: needs.prerelease-version.outputs.release_id != ''
+ permissions:
+ contents: write
+ uses: ./.github/workflows/build-tauri.yml
+ secrets: inherit
+ with:
+ set_version: ${{ needs.prerelease-version.outputs.version }}
+ release_id: ${{ needs.prerelease-version.outputs.release_id }}
+ apple_full_signing: false
+
+ # Flip the pre-release draft → published once every platform is attached, so
+ # the Pre-release channel only ever sees a complete manifest.
+ prerelease-publish:
+ needs: [prerelease-version, prerelease-build]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Publish pre-release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release edit "v${{ needs.prerelease-version.outputs.version }}" \
+ --draft=false \
+ --prerelease \
+ --repo "${{ github.repository }}"
# ─────────────────────────────────────────────────────────────
# Publish Release: Flip draft → published after all artifacts
diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs
index 003a4abc..b8c9cd3a 100644
--- a/apps/desktop/src-tauri/src/commands/settings.rs
+++ b/apps/desktop/src-tauri/src/commands/settings.rs
@@ -153,6 +153,56 @@ pub async fn set_auto_install_updates(
Ok(enabled)
}
+/// App-settings key for the update channel ("stable" | "prerelease").
+const UPDATE_CHANNEL_KEY: &str = "updates.channel";
+/// Default update channel when the setting is missing.
+const UPDATE_CHANNEL_STABLE: &str = "stable";
+const UPDATE_CHANNEL_PRERELEASE: &str = "prerelease";
+
+/// Normalize an arbitrary stored/incoming value to a known channel, defaulting
+/// to "stable". Keeps the gateway between the frontend and the updater header
+/// strict so a corrupt setting can never select an unknown channel.
+fn normalize_channel(raw: &str) -> &'static str {
+ if raw.eq_ignore_ascii_case(UPDATE_CHANNEL_PRERELEASE) {
+ UPDATE_CHANNEL_PRERELEASE
+ } else {
+ UPDATE_CHANNEL_STABLE
+ }
+}
+
+/// Which update channel the app follows. The frontend sends this as the
+/// `X-Mcpmux-Channel` header on update checks so the resolver returns the
+/// newest stable or pre-release manifest. Default **stable** — a missing
+/// setting means the stable channel.
+#[tauri::command]
+pub async fn get_update_channel(app_state: State<'_, AppState>) -> Result {
+ let stored = app_state
+ .settings_repository
+ .get(UPDATE_CHANNEL_KEY)
+ .await
+ .map_err(|e| e.to_string())?;
+ Ok(stored
+ .map(|v| normalize_channel(&v).to_string())
+ .unwrap_or_else(|| UPDATE_CHANNEL_STABLE.to_string()))
+}
+
+/// Set the update channel ("stable" | "prerelease"). Unknown values are
+/// coerced to "stable". Persisted; returns the normalized value actually saved.
+#[tauri::command]
+pub async fn set_update_channel(
+ channel: String,
+ app_state: State<'_, AppState>,
+) -> Result {
+ let normalized = normalize_channel(&channel);
+ app_state
+ .settings_repository
+ .set(UPDATE_CHANNEL_KEY, normalized)
+ .await
+ .map_err(|e| e.to_string())?;
+ info!("[Settings] Update channel set to {}", normalized);
+ Ok(normalized.to_string())
+}
+
/// Check if app should start hidden (for auto-launch with --hidden flag)
pub fn should_start_hidden() -> bool {
let args: Vec = std::env::args().collect();
@@ -258,4 +308,28 @@ mod tests {
assert!(!settings.start_minimized);
assert!(!settings.close_to_tray);
}
+
+ #[test]
+ fn test_normalize_channel_prerelease_variants() {
+ assert_eq!(normalize_channel("prerelease"), UPDATE_CHANNEL_PRERELEASE);
+ assert_eq!(normalize_channel("Prerelease"), UPDATE_CHANNEL_PRERELEASE);
+ assert_eq!(normalize_channel("PRERELEASE"), UPDATE_CHANNEL_PRERELEASE);
+ }
+
+ #[test]
+ fn test_normalize_channel_defaults_to_stable() {
+ assert_eq!(normalize_channel("stable"), UPDATE_CHANNEL_STABLE);
+ assert_eq!(normalize_channel(""), UPDATE_CHANNEL_STABLE);
+ assert_eq!(normalize_channel("beta"), UPDATE_CHANNEL_STABLE);
+ assert_eq!(normalize_channel("garbage"), UPDATE_CHANNEL_STABLE);
+ }
+
+ #[test]
+ fn test_normalize_channel_returns_canonical_static() {
+ // Always returns one of the two canonical lowercase tokens.
+ for input in ["StAbLe", "pre", "prerelease", "x"] {
+ let out = normalize_channel(input);
+ assert!(out == UPDATE_CHANNEL_STABLE || out == UPDATE_CHANNEL_PRERELEASE);
+ }
+ }
}
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index 64ce0f39..bab8479c 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -978,6 +978,8 @@ pub fn run() {
commands::update_startup_settings,
commands::get_auto_install_updates,
commands::set_auto_install_updates,
+ commands::get_update_channel,
+ commands::set_update_channel,
])
.build(tauri::generate_context!())
.expect("error while building McpMux application")
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index d3e1ab02..465cff8e 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -21,6 +21,7 @@
},
"updater": {
"endpoints": [
+ "https://api.mcpmux.com/v1/update/latest.json",
"https://github.com/mcpmux/mcp-mux/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdCQUZGMEVCMEZBOTk5RTcKUldUbm1ha1A2L0N2ZTlOZjN5T3pGOHBHRUlibytMY2tPeWJkQ01heDJzdTJqK3B3a2lBdDZ1T1oK"
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index e0cdaa97..3741edb4 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -106,8 +106,8 @@ function AppContent() {
useEffect(() => {
const checkForUpdates = async () => {
try {
- const { check } = await import('@tauri-apps/plugin-updater');
- const update = await check();
+ const { checkForUpdate } = await import('@/lib/updates');
+ const update = await checkForUpdate();
if (!update) return;
console.log(`[Auto-Update] Update available: ${update.version}`);
diff --git a/apps/desktop/src/features/settings/UpdateChecker.tsx b/apps/desktop/src/features/settings/UpdateChecker.tsx
index 5aad9a30..3180b314 100644
--- a/apps/desktop/src/features/settings/UpdateChecker.tsx
+++ b/apps/desktop/src/features/settings/UpdateChecker.tsx
@@ -1,6 +1,12 @@
import { useState, useEffect } from 'react';
-import { check, Update } from '@tauri-apps/plugin-updater';
+import { Update } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
+import {
+ checkForUpdate,
+ getUpdateChannel,
+ setUpdateChannel,
+ type UpdateChannel,
+} from '@/lib/updates';
import {
Button,
Card,
@@ -30,6 +36,7 @@ export function UpdateChecker() {
const [currentVersion, setCurrentVersion] = useState('');
const [bundleVersionMismatch, setBundleVersionMismatch] = useState(null);
const [autoInstall, setAutoInstall] = useState(null);
+ const [channel, setChannel] = useState(null);
// Load current version on mount
useState(() => {
@@ -56,6 +63,28 @@ export function UpdateChecker() {
}
};
+ // Load the current update channel (default stable).
+ useEffect(() => {
+ getUpdateChannel()
+ .then(setChannel)
+ .catch(() => setChannel('stable'));
+ }, []);
+
+ const handleSelectChannel = async (next: UpdateChannel) => {
+ if (next === channel) return;
+ const prev = channel;
+ setChannel(next);
+ // A channel switch invalidates any update found on the previous channel.
+ setUpdateInfo(null);
+ setMessage(null);
+ try {
+ await setUpdateChannel(next);
+ } catch (err) {
+ setChannel(prev);
+ setMessage({ type: 'error', text: `Failed to switch channel: ${err}` });
+ }
+ };
+
// Check if the on-disk bundle version differs from the running version (Homebrew Cask upgrades)
useEffect(() => {
if (!currentVersion) return;
@@ -79,8 +108,8 @@ export function UpdateChecker() {
setUpdateInfo(null);
try {
- console.log('[Updater] Checking for updates...');
- const update = await check();
+ console.log(`[Updater] Checking for updates (channel: ${channel ?? 'stable'})...`);
+ const update = await checkForUpdate();
if (update) {
console.log(
@@ -187,6 +216,42 @@ export function UpdateChecker() {
+ {/* Update channel */}
+
+
+
Update channel
+
+ {channel === 'prerelease'
+ ? 'Pre-release: early builds from every change merged to main. Newer, but may be unstable.'
+ : 'Stable: published releases only. Recommended for most users.'}
+