Skip to content

Commit 903452e

Browse files
committed
feat: add pre-release update channel and automated pre-releases
Every merge to main now publishes a signed pre-release (X.Y.Z-pre.<run_number>), 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 <maa.ashik00@gmail.com>
1 parent d614853 commit 903452e

11 files changed

Lines changed: 751 additions & 106 deletions

File tree

.github/workflows/build-tauri.yml

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
name: Build Tauri
2+
3+
# Reusable build for the desktop app across all platforms. Shared by the
4+
# stable release flow, the per-merge pre-release flow, and the promote flow so
5+
# every channel is built with identical logic (no drift).
6+
on:
7+
workflow_call:
8+
inputs:
9+
ref:
10+
description: 'Git ref to build (empty = the caller ref)'
11+
type: string
12+
default: ''
13+
set_version:
14+
description: 'If set, stamp this version onto the working tree before building'
15+
type: string
16+
default: ''
17+
release_id:
18+
description: 'Upload artifacts to this existing release id (stable flow)'
19+
type: string
20+
default: ''
21+
tag_name:
22+
description: 'Create/append to a release with this tag (pre-release / promote flow)'
23+
type: string
24+
default: ''
25+
release_name:
26+
description: 'Release title when creating via tag_name'
27+
type: string
28+
default: ''
29+
release_body:
30+
description: 'Release body when creating via tag_name'
31+
type: string
32+
default: ''
33+
prerelease:
34+
description: 'Mark the created release as a pre-release'
35+
type: boolean
36+
default: false
37+
draft:
38+
description: 'Create the release as a draft'
39+
type: boolean
40+
default: false
41+
apple_full_signing:
42+
description: 'Import the Apple Developer cert (true) or ad-hoc sign (false)'
43+
type: boolean
44+
default: true
45+
46+
env:
47+
CARGO_TERM_COLOR: always
48+
49+
jobs:
50+
build:
51+
strategy:
52+
fail-fast: false
53+
matrix:
54+
include:
55+
- os: ubuntu-latest
56+
target: x86_64-unknown-linux-gnu
57+
artifact: linux
58+
- os: windows-latest
59+
target: x86_64-pc-windows-msvc
60+
artifact: windows
61+
- os: macos-latest
62+
target: aarch64-apple-darwin
63+
artifact: macos-arm
64+
- os: macos-15-intel
65+
target: x86_64-apple-darwin
66+
artifact: macos-intel
67+
68+
runs-on: ${{ matrix.os }}
69+
permissions:
70+
contents: write
71+
steps:
72+
- uses: actions/checkout@v4
73+
with:
74+
ref: ${{ inputs.ref }}
75+
76+
# Stamp a pre-release / promote version onto the working tree (transient,
77+
# never committed) so the binary, bundle, and latest.json all agree.
78+
- name: Set build version
79+
if: inputs.set_version != ''
80+
shell: bash
81+
run: node scripts/set-version.mjs "${{ inputs.set_version }}"
82+
83+
- name: Install Linux deps
84+
if: matrix.os == 'ubuntu-latest'
85+
uses: ./.github/actions/install-linux-deps
86+
with:
87+
verify_glib: 'false'
88+
89+
- uses: dtolnay/rust-toolchain@stable
90+
with:
91+
targets: ${{ matrix.target }}
92+
env:
93+
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
94+
95+
- uses: Swatinem/rust-cache@v2
96+
with:
97+
key: ${{ matrix.target }}-release
98+
env:
99+
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
100+
101+
- uses: pnpm/action-setup@v4
102+
- uses: actions/setup-node@v4
103+
with:
104+
node-version: 20
105+
cache: 'pnpm'
106+
107+
- run: pnpm install --frozen-lockfile
108+
109+
# Import Apple certificate ourselves, then DON'T pass APPLE_CERTIFICATE
110+
# to tauri-action. Tauri's bundler uses var_os() which treats empty
111+
# strings as present (Some("")), so we must completely omit the env var.
112+
# Instead we import the cert here and only pass APPLE_SIGNING_IDENTITY.
113+
# When apple_full_signing is false (pre-releases), we ad-hoc sign ("-").
114+
- name: Import Apple certificate
115+
if: runner.os == 'macOS'
116+
id: apple-cert
117+
env:
118+
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
119+
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
120+
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
121+
run: |
122+
if [ "${{ inputs.apple_full_signing }}" != "true" ]; then
123+
echo "Ad-hoc signing requested (pre-release build)"
124+
echo "identity=-" >> "$GITHUB_OUTPUT"
125+
exit 0
126+
fi
127+
if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$KEYCHAIN_PASSWORD" ]; then
128+
echo "No Apple certificate configured — using ad-hoc signing"
129+
echo "identity=-" >> "$GITHUB_OUTPUT"
130+
exit 0
131+
fi
132+
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
133+
if [ ! -s certificate.p12 ]; then
134+
echo "Certificate decode produced empty file — using ad-hoc signing"
135+
rm -f certificate.p12
136+
echo "identity=-" >> "$GITHUB_OUTPUT"
137+
exit 0
138+
fi
139+
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
140+
security default-keychain -s build.keychain
141+
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
142+
if ! security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign; then
143+
echo "Certificate import failed — using ad-hoc signing"
144+
rm -f certificate.p12
145+
echo "identity=-" >> "$GITHUB_OUTPUT"
146+
exit 0
147+
fi
148+
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
149+
rm certificate.p12
150+
echo "identity=${{ secrets.APPLE_SIGNING_IDENTITY }}" >> "$GITHUB_OUTPUT"
151+
echo "cert_ok=true" >> "$GITHUB_OUTPUT"
152+
153+
# IMPORTANT: Do NOT pass APPLE_CERTIFICATE, APPLE_ID, APPLE_PASSWORD,
154+
# or APPLE_TEAM_ID to tauri-action. Tauri's bundler uses var_os() which
155+
# treats empty strings as "present" and attempts certificate import /
156+
# notarization even when values are empty, causing build failures.
157+
# We handle cert import ourselves above and only pass APPLE_SIGNING_IDENTITY.
158+
#
159+
# Two variants so each path passes EXACTLY the inputs it needs:
160+
# • release_id set → upload to the pre-created (draft) release by id.
161+
# Pass ONLY releaseId — never tagName/draft/prerelease, which could
162+
# flip the draft early or mismatch its state. (Stable + promote.)
163+
# • release_id empty → create/append to a tagged release. (Pre-release.)
164+
- name: Build Tauri app (upload to existing release)
165+
if: inputs.release_id != ''
166+
uses: tauri-apps/tauri-action@v0
167+
env:
168+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
169+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
170+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
171+
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
172+
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && steps.apple-cert.outputs.identity || '' }}
173+
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
174+
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
175+
with:
176+
projectPath: apps/desktop
177+
releaseId: ${{ inputs.release_id }}
178+
updaterJsonKeepUniversal: true
179+
args: --target ${{ matrix.target }}
180+
181+
- name: Build Tauri app (create tagged release)
182+
if: inputs.release_id == ''
183+
uses: tauri-apps/tauri-action@v0
184+
env:
185+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
186+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
187+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
188+
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
189+
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && steps.apple-cert.outputs.identity || '' }}
190+
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
191+
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
192+
with:
193+
projectPath: apps/desktop
194+
tagName: ${{ inputs.tag_name }}
195+
releaseName: ${{ inputs.release_name }}
196+
releaseBody: ${{ inputs.release_body }}
197+
releaseDraft: ${{ inputs.draft }}
198+
prerelease: ${{ inputs.prerelease }}
199+
updaterJsonKeepUniversal: true
200+
args: --target ${{ matrix.target }}

.github/workflows/promote.yml

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
name: Promote Pre-release to Stable
2+
3+
# Escape hatch: ship the *exact tested code* of a pre-release as a clean stable
4+
# release. Rebuilds the pre-release's commit at the stable version (X.Y.Z),
5+
# fully signed, then publishes — so /releases/latest and the Stable update
6+
# channel pick it up. The normal path remains merging the release-please PR.
7+
#
8+
# Note: Homebrew/APT are refreshed by the normal release-please flow, not here.
9+
on:
10+
workflow_dispatch:
11+
inputs:
12+
prerelease_tag:
13+
description: 'Pre-release tag to promote (e.g. v0.4.0-pre.318)'
14+
required: true
15+
type: string
16+
17+
concurrency:
18+
group: promote-${{ github.ref }}
19+
cancel-in-progress: false
20+
21+
env:
22+
CARGO_TERM_COLOR: always
23+
24+
jobs:
25+
prepare:
26+
runs-on: ubuntu-latest
27+
permissions:
28+
contents: write
29+
outputs:
30+
sha: ${{ steps.resolve.outputs.sha }}
31+
stable_version: ${{ steps.resolve.outputs.stable_version }}
32+
stable_tag: ${{ steps.resolve.outputs.stable_tag }}
33+
release_id: ${{ steps.draft.outputs.release_id }}
34+
steps:
35+
- name: Resolve commit + stable version
36+
id: resolve
37+
env:
38+
GH_TOKEN: ${{ github.token }}
39+
run: |
40+
set -euo pipefail
41+
PRE_TAG="${{ inputs.prerelease_tag }}"
42+
PRE_TAG="v${PRE_TAG#v}" # normalize leading v
43+
# Strip the -pre.N (and any +build) suffix to get the stable version.
44+
STABLE_VERSION=$(printf '%s' "${PRE_TAG#v}" | sed -E 's/-pre\..*$//; s/\+.*$//')
45+
STABLE_TAG="v${STABLE_VERSION}"
46+
47+
# The commit the pre-release was built from (deref annotated tags).
48+
REF=$(gh api "repos/${{ github.repository }}/git/ref/tags/${PRE_TAG}")
49+
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))')
50+
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))')
51+
if [ "$TYPE" = "tag" ]; then
52+
SHA=$(gh api "repos/${{ github.repository }}/git/tags/${SHA}" --jq '.object.sha')
53+
fi
54+
55+
# Refuse to clobber an existing stable release.
56+
if gh release view "$STABLE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
57+
echo "::error::Stable release $STABLE_TAG already exists — nothing to promote"
58+
exit 1
59+
fi
60+
61+
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
62+
echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT"
63+
echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT"
64+
echo "Promoting $PRE_TAG ($SHA) -> $STABLE_TAG"
65+
66+
- name: Create draft stable release
67+
id: draft
68+
env:
69+
GH_TOKEN: ${{ github.token }}
70+
run: |
71+
set -euo pipefail
72+
RELEASE_ID=$(gh api --method POST "repos/${{ github.repository }}/releases" \
73+
-f tag_name="${{ steps.resolve.outputs.stable_tag }}" \
74+
-f target_commitish="${{ steps.resolve.outputs.sha }}" \
75+
-f name="${{ steps.resolve.outputs.stable_tag }}" \
76+
-f body="Promoted from ${{ inputs.prerelease_tag }}." \
77+
-F draft=true -F prerelease=false \
78+
--jq '.id')
79+
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
80+
echo "Created draft release id=$RELEASE_ID"
81+
82+
build:
83+
needs: prepare
84+
permissions:
85+
contents: write
86+
uses: ./.github/workflows/build-tauri.yml
87+
secrets: inherit
88+
with:
89+
ref: ${{ needs.prepare.outputs.sha }}
90+
set_version: ${{ needs.prepare.outputs.stable_version }}
91+
release_id: ${{ needs.prepare.outputs.release_id }}
92+
apple_full_signing: true
93+
94+
publish:
95+
needs: [prepare, build]
96+
runs-on: ubuntu-latest
97+
permissions:
98+
contents: write
99+
steps:
100+
- name: Import GPG signing key
101+
run: echo "${{ secrets.APT_GPG_PRIVATE_KEY }}" | gpg --batch --import
102+
103+
- name: Sign release artifacts
104+
env:
105+
GH_TOKEN: ${{ github.token }}
106+
run: |
107+
set -euo pipefail
108+
TAG="${{ needs.prepare.outputs.stable_tag }}"
109+
REPO="${{ github.repository }}"
110+
mkdir -p artifacts sigs
111+
gh release download "$TAG" --dir artifacts --repo "$REPO" \
112+
--pattern "*.deb" --pattern "*.rpm" --pattern "*.AppImage" \
113+
--pattern "*.dmg" --pattern "*.exe" --pattern "*.msi" \
114+
--pattern "*.nsis.zip" || true
115+
for file in artifacts/*; do
116+
[ -f "$file" ] || continue
117+
gpg --batch --yes --detach-sign --armor -o "sigs/$(basename "$file").sig" "$file"
118+
done
119+
if ls sigs/*.sig >/dev/null 2>&1; then
120+
gh release upload "$TAG" sigs/*.sig --repo "$REPO" --clobber
121+
fi
122+
123+
- name: Publish release
124+
env:
125+
GH_TOKEN: ${{ github.token }}
126+
run: |
127+
gh release edit "${{ needs.prepare.outputs.stable_tag }}" \
128+
--draft=false \
129+
--repo "${{ github.repository }}"

0 commit comments

Comments
 (0)