From 903452e8e099cee10e23e0179e553bef3d503373 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 15 Jun 2026 08:00:43 +0800 Subject: [PATCH] feat: add pre-release update channel and automated pre-releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every merge to main now publishes a signed pre-release (X.Y.Z-pre.), and users can switch the update channel to Pre-release in Settings → Software Updates. Channel selection rides an X-Mcpmux-Channel header resolved by api.mcpmux.com/v1/update/latest.json, keeping the proven JS check/install flow untouched; the GitHub stable URL stays as a fallback endpoint. - settings: get_update_channel / set_update_channel commands (+ unit tests) - ui: Stable / Pre-release selector; both check() sites send the channel header - ci: gated prerelease jobs (pre-create draft -> build by id -> publish), reusable build-tauri.yml, promote.yml escape hatch, scripts/set-version.mjs - release-please remains the stable changelog/version source (manifest mode, so the v*-pre.* tags don't interfere) The Cloudflare Worker resolver ships separately in mcpmux.serverhub.api. Signed-off-by: Mohammod Al Amin Ashik --- .github/workflows/build-tauri.yml | 200 ++++++++++++++++++ .github/workflows/promote.yml | 129 +++++++++++ .github/workflows/release.yml | 196 +++++++++-------- .../src-tauri/src/commands/settings.rs | 74 +++++++ apps/desktop/src-tauri/src/lib.rs | 2 + apps/desktop/src-tauri/tauri.conf.json | 1 + apps/desktop/src/App.tsx | 4 +- .../src/features/settings/UpdateChecker.tsx | 71 ++++++- apps/desktop/src/lib/updates.ts | 39 ++++ scripts/set-version.mjs | 71 +++++++ tests/ts/lib/updates.test.ts | 70 ++++++ 11 files changed, 751 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/build-tauri.yml create mode 100644 .github/workflows/promote.yml create mode 100644 apps/desktop/src/lib/updates.ts create mode 100644 scripts/set-version.mjs create mode 100644 tests/ts/lib/updates.test.ts 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.'} +

+
+
+ {(['stable', 'prerelease'] as const).map((value) => ( + + ))} +
+
+ {/* Auto-install preference */}
diff --git a/apps/desktop/src/lib/updates.ts b/apps/desktop/src/lib/updates.ts new file mode 100644 index 00000000..8cd623ab --- /dev/null +++ b/apps/desktop/src/lib/updates.ts @@ -0,0 +1,39 @@ +import { invoke } from '@tauri-apps/api/core'; +import { check, Update } from '@tauri-apps/plugin-updater'; + +/** + * Update channels. "stable" follows published GitHub releases; "prerelease" + * follows the newest pre-release (and any newer stable). The selection is sent + * to the update resolver as a request header — Tauri's updater can't template + * a channel into the endpoint URL, but it does forward custom headers. + */ +export type UpdateChannel = 'stable' | 'prerelease'; + +/** Header the update resolver reads to pick which channel's manifest to serve. */ +export const UPDATE_CHANNEL_HEADER = 'X-Mcpmux-Channel'; + +/** Read the persisted update channel, defaulting to "stable" if unavailable. */ +export async function getUpdateChannel(): Promise { + try { + const channel = await invoke('get_update_channel'); + return channel === 'prerelease' ? 'prerelease' : 'stable'; + } catch { + return 'stable'; + } +} + +/** Persist the update channel. Returns the normalized value actually saved. */ +export async function setUpdateChannel(channel: UpdateChannel): Promise { + const saved = await invoke('set_update_channel', { channel }); + return saved === 'prerelease' ? 'prerelease' : 'stable'; +} + +/** + * Check for an update on the user's selected channel. Reads the persisted + * channel and forwards it to the resolver via the {@link UPDATE_CHANNEL_HEADER} + * header so the same `check()`/`downloadAndInstall()` flow serves both channels. + */ +export async function checkForUpdate(): Promise { + const channel = await getUpdateChannel(); + return check({ headers: { [UPDATE_CHANNEL_HEADER]: channel } }); +} diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs new file mode 100644 index 00000000..83d6e6a7 --- /dev/null +++ b/scripts/set-version.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Set the app version across the three files release-please keeps in sync. + * Used by CI to stamp a pre-release / promote version onto the working tree + * before a Tauri build (these edits are transient and never committed). + * + * Patches: + * - apps/desktop/src-tauri/tauri.conf.json $.version + * - Cargo.toml [workspace.package] version + * - apps/desktop/src-tauri/Cargo.toml [package] version + * + * Usage: node scripts/set-version.mjs + * e.g. node scripts/set-version.mjs 0.4.0-pre.318 + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const version = process.argv[2]; +if (!version) { + console.error('error: missing argument'); + process.exit(1); +} +// Accept SemVer X.Y.Z with an optional pre-release/build suffix. +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) { + console.error(`error: '${version}' is not a valid semver version`); + process.exit(1); +} + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/** Replace the first `version = "…"` line that appears under `[table]`. */ +function setTomlTableVersion(path, table) { + const src = readFileSync(path, 'utf8'); + const lines = src.split('\n'); + let inTable = false; + let patched = false; + for (let i = 0; i < lines.length; i++) { + const header = lines[i].match(/^\s*\[([^\]]+)\]\s*$/); + if (header) { + inTable = header[1].trim() === table; + continue; + } + if (inTable && /^\s*version\s*=/.test(lines[i])) { + lines[i] = lines[i].replace(/version\s*=\s*"[^"]*"/, `version = "${version}"`); + patched = true; + break; + } + } + if (!patched) throw new Error(`${path}: no version found under [${table}]`); + writeFileSync(path, lines.join('\n')); + console.log(` ${path} [${table}] -> ${version}`); +} + +/** Replace the top-level `"version"` key in a JSON file (text-level, format-preserving). */ +function setJsonVersion(path) { + const src = readFileSync(path, 'utf8'); + let patched = false; + const out = src.replace(/("version"\s*:\s*")[^"]*(")/, (_, a, b) => { + patched = true; + return `${a}${version}${b}`; + }); + if (!patched) throw new Error(`${path}: no "version" key found`); + writeFileSync(path, out); + console.log(` ${path} $.version -> ${version}`); +} + +console.log(`Setting version to ${version}`); +setJsonVersion(join(repoRoot, 'apps/desktop/src-tauri/tauri.conf.json')); +setTomlTableVersion(join(repoRoot, 'Cargo.toml'), 'workspace.package'); +setTomlTableVersion(join(repoRoot, 'apps/desktop/src-tauri/Cargo.toml'), 'package'); diff --git a/tests/ts/lib/updates.test.ts b/tests/ts/lib/updates.test.ts new file mode 100644 index 00000000..74699f09 --- /dev/null +++ b/tests/ts/lib/updates.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; +import { check, type Update } from '@tauri-apps/plugin-updater'; +import { + getUpdateChannel, + setUpdateChannel, + checkForUpdate, + UPDATE_CHANNEL_HEADER, +} from '@/lib/updates'; + +const invokeMock = vi.mocked(invoke); +const checkMock = vi.mocked(check); + +beforeEach(() => { + invokeMock.mockReset(); + checkMock.mockReset(); +}); + +describe('getUpdateChannel', () => { + it('returns the stored channel', async () => { + invokeMock.mockResolvedValueOnce('prerelease'); + await expect(getUpdateChannel()).resolves.toBe('prerelease'); + expect(invokeMock).toHaveBeenCalledWith('get_update_channel'); + }); + + it('defaults to stable for unknown or missing values', async () => { + invokeMock.mockResolvedValueOnce('garbage'); + await expect(getUpdateChannel()).resolves.toBe('stable'); + }); + + it('defaults to stable when the command throws', async () => { + invokeMock.mockRejectedValueOnce(new Error('command unavailable')); + await expect(getUpdateChannel()).resolves.toBe('stable'); + }); +}); + +describe('setUpdateChannel', () => { + it('persists and returns the normalized value', async () => { + invokeMock.mockResolvedValueOnce('prerelease'); + await expect(setUpdateChannel('prerelease')).resolves.toBe('prerelease'); + expect(invokeMock).toHaveBeenCalledWith('set_update_channel', { channel: 'prerelease' }); + }); +}); + +describe('checkForUpdate', () => { + it('forwards the stored channel as the update header (prerelease)', async () => { + invokeMock.mockResolvedValueOnce('prerelease'); + checkMock.mockResolvedValueOnce(null); + await checkForUpdate(); + expect(checkMock).toHaveBeenCalledWith({ + headers: { [UPDATE_CHANNEL_HEADER]: 'prerelease' }, + }); + }); + + it('falls back to the stable header when the channel is unavailable', async () => { + invokeMock.mockRejectedValueOnce(new Error('no setting')); + checkMock.mockResolvedValueOnce(null); + await checkForUpdate(); + expect(checkMock).toHaveBeenCalledWith({ + headers: { [UPDATE_CHANNEL_HEADER]: 'stable' }, + }); + }); + + it('returns the update handle from check()', async () => { + invokeMock.mockResolvedValueOnce('stable'); + const handle = { version: '1.2.3' } as unknown as Update; + checkMock.mockResolvedValueOnce(handle); + await expect(checkForUpdate()).resolves.toBe(handle); + }); +});