From 52bd65a759154fe1505f003b5144cc6b54853fe9 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 24 Mar 2026 13:33:45 -0700 Subject: [PATCH] Feat/app release setup (#164) --- .github/workflows/sprout-desktop-release.yml | 141 +++++++++++ desktop/RELEASING.md | 157 ++++++++++++ desktop/package.json | 8 +- desktop/pnpm-lock.yaml | 20 ++ desktop/scripts/build-release-config.mjs | 43 ++++ desktop/scripts/bump-version.mjs | 54 +++++ .../scripts/publish-dmg-to-github-release.mjs | 187 +++++++++++++++ .../publish-updater-to-github-release.mjs | 223 ++++++++++++++++++ desktop/src-tauri/Cargo.lock | 198 +++++++++++++++- desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/Info.plist | 10 + desktop/src-tauri/capabilities/default.json | 6 +- desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/tauri.conf.json | 7 +- .../src/features/settings/UpdateChecker.tsx | 163 +++++++++++++ justfile | 70 ++++++ 16 files changed, 1284 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/sprout-desktop-release.yml create mode 100644 desktop/RELEASING.md create mode 100644 desktop/scripts/build-release-config.mjs create mode 100644 desktop/scripts/bump-version.mjs create mode 100644 desktop/scripts/publish-dmg-to-github-release.mjs create mode 100644 desktop/scripts/publish-updater-to-github-release.mjs create mode 100644 desktop/src-tauri/Info.plist create mode 100644 desktop/src/features/settings/UpdateChecker.tsx diff --git a/.github/workflows/sprout-desktop-release.yml b/.github/workflows/sprout-desktop-release.yml new file mode 100644 index 000000000..ebd35e9e8 --- /dev/null +++ b/.github/workflows/sprout-desktop-release.yml @@ -0,0 +1,141 @@ +name: Desktop Release + +on: + push: + tags: + - 'desktop/v*' + +env: + CARGO_TERM_COLOR: always + +jobs: + release: + name: Desktop Release + runs-on: macos-latest + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + + - run: corepack enable pnpm + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: desktop/src-tauri + key: desktop-release-aarch64-apple-darwin + + - name: Validate tag matches desktop versions + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/desktop/v}" + PACKAGE_VERSION="$(node -p "require('./desktop/package.json').version")" + TAURI_VERSION="$(node -p "require('./desktop/src-tauri/tauri.conf.json').version")" + CARGO_VERSION="$(grep '^version' desktop/src-tauri/Cargo.toml | head -1 | sed 's/version = "//;s/"//')" + if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ] || [ "$TAG_VERSION" != "$TAURI_VERSION" ] || [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) must match package.json ($PACKAGE_VERSION), tauri.conf.json ($TAURI_VERSION), and Cargo.toml ($CARGO_VERSION)" + exit 1 + fi + + - name: Validate release secrets + env: + SPROUT_UPDATER_PUBLIC_KEY: ${{ secrets.SPROUT_UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + OSX_CODESIGN_ROLE: ${{ secrets.OSX_CODESIGN_ROLE }} + CODESIGN_S3_BUCKET: ${{ secrets.CODESIGN_S3_BUCKET }} + run: | + missing=() + for name in \ + SPROUT_UPDATER_PUBLIC_KEY \ + TAURI_SIGNING_PRIVATE_KEY \ + TAURI_SIGNING_PRIVATE_KEY_PASSWORD \ + OSX_CODESIGN_ROLE \ + CODESIGN_S3_BUCKET; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::Missing required desktop release secrets: ${missing[*]}" + exit 1 + fi + + - name: Install dependencies + run: | + cd desktop && pnpm install --frozen-lockfile + cd src-tauri && cargo fetch + + - name: Build release config + working-directory: desktop + env: + SPROUT_UPDATER_PUBLIC_KEY: ${{ secrets.SPROUT_UPDATER_PUBLIC_KEY }} + SPROUT_UPDATER_ENDPOINT: https://github.com/${{ github.repository }}/releases/download/sprout-desktop-latest/latest.json + run: pnpm run tauri:release:config + + - name: Build Tauri app + working-directory: desktop + run: pnpm tauri build --config src-tauri/tauri.release.conf.json + + - name: Codesign and Notarize + id: codesign + uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0 + with: + osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }} + codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }} + unsigned-artifact-path: desktop/src-tauri/target/release/bundle/macos/Sprout.app + artifact-name: sprout-${{ github.sha }}-${{ github.run_id }}-arm64 + + - name: Replace with signed app + env: + SIGNED_PATH: ${{ steps.codesign.outputs.signed-artifact-path }} + run: | + BUNDLE_DIR="desktop/src-tauri/target/release/bundle/macos" + rm -rf "${BUNDLE_DIR}/Sprout.app" + unzip -o "$SIGNED_PATH" -d "${BUNDLE_DIR}" + + - name: Create DMG from signed app + run: | + VERSION="$(node -p "require('./desktop/package.json').version")" + DMG_DIR="desktop/src-tauri/target/release/bundle/dmg" + mkdir -p "${DMG_DIR}" + rm -f "${DMG_DIR}"/*.dmg + hdiutil create -volname "Sprout" \ + -srcfolder desktop/src-tauri/target/release/bundle/macos/Sprout.app \ + -ov -format UDZO \ + "${DMG_DIR}/Sprout_${VERSION}_aarch64.dmg" + + - name: Create updater archive from signed app + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + BUNDLE_DIR="desktop/src-tauri/target/release/bundle/macos" + rm -f "${BUNDLE_DIR}/Sprout.app.tar.gz" "${BUNDLE_DIR}/Sprout.app.tar.gz.sig" + tar czf "${BUNDLE_DIR}/Sprout.app.tar.gz" -C "${BUNDLE_DIR}" Sprout.app + cd desktop && pnpm tauri signer sign -k "$TAURI_SIGNING_PRIVATE_KEY" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "src-tauri/target/release/bundle/macos/Sprout.app.tar.gz" + + - name: Create versioned release + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${GITHUB_REF#refs/tags/desktop/v}" + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Sprout Desktop v${VERSION}" \ + --notes "See the assets to download and install this version." + + - name: Publish updater alias + working-directory: desktop + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: pnpm run release:updater:publish + + - name: Publish DMG alias + working-directory: desktop + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: pnpm run release:dmg:publish diff --git a/desktop/RELEASING.md b/desktop/RELEASING.md new file mode 100644 index 000000000..2295190f8 --- /dev/null +++ b/desktop/RELEASING.md @@ -0,0 +1,157 @@ +# Releasing the Sprout Desktop App + +This guide covers the end-to-end process for releasing the Sprout desktop app, +including secrets setup, cutting releases, and troubleshooting. + +--- + +## Prerequisites / Secrets Setup + +The following GitHub repository secrets must be configured before the first +release: + +| Secret | Description | +| ---------------------------------- | ------------------------------------------------------------------ | +| `SPROUT_UPDATER_PUBLIC_KEY` | Tauri updater public key (generate with `pnpm tauri signer generate`) | +| `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key | +| `OSX_CODESIGN_ROLE` | IAM role ARN for Block's Apple codesigning service | +| `CODESIGN_S3_BUCKET` | S3 bucket for codesigning artifacts | + +--- + +## Generating Tauri Updater Keys + +```bash +cd desktop +pnpm tauri signer generate -w ~/.tauri/sprout.key +``` + +This generates a keypair: + +- The **public key** goes in the `SPROUT_UPDATER_PUBLIC_KEY` secret. +- The **private key** goes in the `TAURI_SIGNING_PRIVATE_KEY` secret. + +Store the password you chose in `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. + +--- + +## Cutting a Release + +### 1. Prepare + +From `main`, run: + +```bash +just desktop-prepare +``` + +For example: + +```bash +just desktop-prepare 0.2.0 +``` + +This creates a release branch, bumps versions in `package.json`, +`tauri.conf.json`, and `Cargo.toml`, and opens a PR. + +### 2. Review & Merge + +Review the PR, ensure CI passes, then merge to `main`. + +### 3. Release + +From `main` (after pulling the merged changes), run: + +```bash +just desktop-release +``` + +This tags the commit and pushes the tag — CI handles the rest. + +### 4. Verify + +Check GitHub Releases for: + +- The **versioned release** (e.g. `sprout-desktop-v0.2.0`) +- The **`sprout-desktop-latest` rolling release** (updated with every release) + +--- + +## What CI Does + +The `sprout-desktop-release.yml` workflow: + +1. **Validates** the tag version matches the version in `package.json`, + `tauri.conf.json`, and `Cargo.toml`. +2. **Validates** all required secrets are present. +3. **Builds** the release config with signing and updater settings. +4. **Builds** the Tauri app (unsigned). +5. **Signs and notarizes** the macOS bundle via `block/apple-codesign-action`. +6. **Re-packages** the signed app into a DMG and updater archive. +7. **Signs** the updater archive with the Tauri updater key. +8. **Publishes** the updater manifest (`latest.json`) to the rolling + `sprout-desktop-latest` release. +9. **Publishes** the DMG to both the versioned and rolling releases. + +--- + +## Local Release Build (Testing) + +Local builds will not be codesigned or notarized — that only happens in CI +via `block/apple-codesign-action`. Local builds are useful for testing the +updater config and DMG packaging. + +```bash +# Set updater env vars +export SPROUT_UPDATER_PUBLIC_KEY="your-public-key" +export SPROUT_UPDATER_ENDPOINT="https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json" + +# Generate release config +cd desktop +pnpm run tauri:release:config + +# Build (unsigned) +just desktop-release-build +``` + +--- + +## Auto-Updates + +The app uses `tauri-plugin-updater` to check for updates. The updater endpoint +is: + +``` +https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json +``` + +This `latest.json` is updated on every release and contains the download URL +and signature for the latest version. + +--- + +## Relay URL Configuration + +The app connects to the relay via the `SPROUT_RELAY_URL` environment variable. + +- **Release builds**: Set this to the production relay URL (e.g. + `wss://relay.sprout.example.com`). Configure it in the environment before + building, or set it in the CI workflow. +- **Development**: If not set, it defaults to `ws://localhost:3000`. + +--- + +## Troubleshooting + +- **"Missing required desktop release secrets"**: Ensure all secrets listed in + [Prerequisites](#prerequisites--secrets-setup) are configured in GitHub repo + settings. + +- **Codesigning failures**: Verify `OSX_CODESIGN_ROLE` and + `CODESIGN_S3_BUCKET` are configured correctly. Check the + `block/apple-codesign-action` step logs for details. + +- **Version mismatch**: The tag version must exactly match all three version + files (`package.json`, `tauri.conf.json`, `Cargo.toml`). Use + `just desktop-prepare` to ensure consistency. diff --git a/desktop/package.json b/desktop/package.json index 35de06da7..a556f5396 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -17,7 +17,11 @@ "test:e2e": "pnpm build && playwright test", "test:e2e:smoke": "pnpm build && playwright test --project=smoke", "test:e2e:integration": "pnpm build && playwright test --project=integration", - "test:e2e:report": "playwright show-report" + "test:e2e:report": "playwright show-report", + "tauri:build": "tauri build", + "tauri:release:config": "node scripts/build-release-config.mjs", + "release:dmg:publish": "node scripts/publish-dmg-to-github-release.mjs", + "release:updater:publish": "node scripts/publish-updater-to-github-release.mjs" }, "dependencies": { "@emoji-mart/data": "^1.2.1", @@ -35,6 +39,8 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 0c9d1c2e3..4d2cf0c48 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -53,6 +53,12 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.3 + '@tauri-apps/plugin-process': + specifier: ^2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.0 + version: 2.10.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1128,6 +1134,12 @@ packages: '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.0': + resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2857,6 +2869,14 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.10.1 + + '@tauri-apps/plugin-updater@2.10.0': + dependencies: + '@tauri-apps/api': 2.10.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 diff --git a/desktop/scripts/build-release-config.mjs b/desktop/scripts/build-release-config.mjs new file mode 100644 index 000000000..2a15673f0 --- /dev/null +++ b/desktop/scripts/build-release-config.mjs @@ -0,0 +1,43 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const publicKey = process.env.SPROUT_UPDATER_PUBLIC_KEY; +const endpoint = process.env.SPROUT_UPDATER_ENDPOINT; + +const baseConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); +const outputConfigPath = resolve( + process.cwd(), + "src-tauri/tauri.release.conf.json", +); +const baseConfig = JSON.parse(readFileSync(baseConfigPath, "utf-8")); + +const releaseConfig = { ...baseConfig }; + +releaseConfig.bundle = { + ...(releaseConfig.bundle ?? baseConfig.bundle ?? {}), + createUpdaterArtifacts: "v1Compatible", +}; + +releaseConfig.bundle.macOS = { + ...(releaseConfig.bundle?.macOS ?? baseConfig.bundle?.macOS ?? {}), + minimumSystemVersion: "10.15", +}; + +if (publicKey && endpoint) { + releaseConfig.plugins = { + ...(baseConfig.plugins ?? {}), + updater: { + pubkey: publicKey, + endpoints: [endpoint], + }, + }; + console.log(`Updater config enabled (${endpoint})`); +} else { + const missing = []; + if (!publicKey) missing.push("SPROUT_UPDATER_PUBLIC_KEY"); + if (!endpoint) missing.push("SPROUT_UPDATER_ENDPOINT"); + console.log(`Updater config skipped (missing: ${missing.join(", ")})`); +} + +writeFileSync(outputConfigPath, `${JSON.stringify(releaseConfig, null, 2)}\n`); +console.log(`Wrote ${outputConfigPath}`); diff --git a/desktop/scripts/bump-version.mjs b/desktop/scripts/bump-version.mjs new file mode 100644 index 000000000..3be4be0a5 --- /dev/null +++ b/desktop/scripts/bump-version.mjs @@ -0,0 +1,54 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const version = process.argv[2]; + +if (!version) { + console.error("Usage: node scripts/bump-version.mjs "); + process.exit(1); +} + +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + console.error( + `Invalid version "${version}". Expected semver format (e.g. 1.2.3 or 1.2.3-beta.1)`, + ); + process.exit(1); +} + +const packageJsonPath = resolve(process.cwd(), "package.json"); +const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); +const cargoTomlPath = resolve(process.cwd(), "src-tauri/Cargo.toml"); + +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); +if (packageJson.version !== version) { + packageJson.version = version; + writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + console.log(`Updated package.json to ${version}`); +} else { + console.log(`package.json already at ${version}`); +} + +const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8")); +if (tauriConfig.version !== version) { + tauriConfig.version = version; + writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`); + console.log(`Updated tauri.conf.json to ${version}`); +} else { + console.log(`tauri.conf.json already at ${version}`); +} + +const cargoToml = readFileSync(cargoTomlPath, "utf8"); +const currentCargoVersion = cargoToml.match(/^version = "(.*)"$/m)?.[1]; +if (!currentCargoVersion) { + throw new Error(`Could not find version field in ${cargoTomlPath}`); +} +if (currentCargoVersion !== version) { + const updatedCargoToml = cargoToml.replace( + /^version = ".*"$/m, + `version = "${version}"`, + ); + writeFileSync(cargoTomlPath, updatedCargoToml); + console.log(`Updated Cargo.toml to ${version}`); +} else { + console.log(`Cargo.toml already at ${version}`); +} diff --git a/desktop/scripts/publish-dmg-to-github-release.mjs b/desktop/scripts/publish-dmg-to-github-release.mjs new file mode 100644 index 000000000..3ddaeac9d --- /dev/null +++ b/desktop/scripts/publish-dmg-to-github-release.mjs @@ -0,0 +1,187 @@ +import { execFileSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const repo = process.env.GITHUB_REPOSITORY ?? "block/sprout"; +const version = process.env.VERSION ?? readVersionFromConfig(); +const versionTag = `desktop/v${version}`; +const latestTag = "sprout-desktop-latest"; +const dryRun = process.env.DRY_RUN === "true" || process.env.DRY_RUN === "1"; + +const dmgName = `Sprout_${version}_aarch64.dmg`; +const latestAssetName = "Sprout-latest-aarch64.dmg"; + +const defaultDmgDirs = [ + "src-tauri/target/aarch64-apple-darwin/release/bundle/dmg", + "src-tauri/target/release/bundle/dmg", +]; +const dmgDir = resolve( + process.cwd(), + process.env.DMG_BUNDLE_DIR ?? + defaultDmgDirs.find((dir) => + existsSync(resolve(process.cwd(), dir, dmgName)), + ) ?? + defaultDmgDirs[0], +); +const dmgPath = join(dmgDir, dmgName); + +function readVersionFromConfig() { + const configPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); + const config = JSON.parse(readFileSync(configPath, "utf-8")); + const configVersion = config?.version; + if (typeof configVersion !== "string" || !configVersion.trim()) { + throw new Error(`Could not determine version from ${configPath}`); + } + return configVersion; +} + +function quote(arg) { + if (/^[a-zA-Z0-9._:/=-]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +function runGh(args, options = {}) { + return execFileSync("gh", args, options); +} + +function releaseExists(tag) { + try { + runGh(["release", "view", tag, "--repo", repo], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function ensureRelease(tag, { title, prerelease }) { + if (releaseExists(tag)) { + return; + } + + const args = [ + "release", + "create", + tag, + "--repo", + repo, + "--title", + title, + "--notes", + "Automated release placeholder.", + ]; + if (prerelease) { + args.push("--prerelease"); + } + + runGh(args, { stdio: "inherit" }); +} + +function readReleaseAssets(tag) { + const assetsRaw = runGh( + [ + "release", + "view", + tag, + "--repo", + repo, + "--json", + "assets", + "--jq", + ".assets[].name", + ], + { encoding: "utf-8" }, + ); + return assetsRaw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function assetUrl(tag, name) { + return `https://github.com/${repo}/releases/download/${tag}/${encodeURIComponent(name)}`; +} + +function main() { + if (!existsSync(dmgPath)) { + throw new Error(`Missing DMG artifact: ${dmgPath}`); + } + + const stageDir = mkdtempSync(join(tmpdir(), "sprout-release-dmg-")); + const latestAliasPath = join(stageDir, latestAssetName); + + try { + cpSync(dmgPath, latestAliasPath); + + const uploadVersionedArgs = [ + "release", + "upload", + versionTag, + dmgPath, + "--repo", + repo, + "--clobber", + ]; + const uploadLatestArgs = [ + "release", + "upload", + latestTag, + latestAliasPath, + "--repo", + repo, + "--clobber", + ]; + + console.log(`Preparing DMG upload for ${repo}`); + console.log(`- version tag: ${versionTag}`); + console.log(`- latest tag: ${latestTag}`); + console.log(`- versioned asset: ${dmgName}`); + console.log(`- latest alias: ${latestAssetName}`); + + if (dryRun) { + console.log("DRY_RUN enabled. Skipping upload."); + console.log(`gh release view ${quote(versionTag)} --repo ${quote(repo)}`); + console.log( + `gh release view ${quote(latestTag)} --repo ${quote(repo)} || gh release create ${quote(latestTag)} --repo ${quote(repo)} --title ${quote("Sprout Desktop Latest")} --notes ${quote("Automated release placeholder.")}`, + ); + console.log(`gh ${uploadVersionedArgs.map(quote).join(" ")}`); + console.log(`gh ${uploadLatestArgs.map(quote).join(" ")}`); + console.log(`Versioned URL: ${assetUrl(versionTag, dmgName)}`); + console.log(`Channel URL: ${assetUrl(latestTag, latestAssetName)}`); + return; + } + + ensureRelease(versionTag, { + title: `Sprout v${version}`, + prerelease: false, + }); + ensureRelease(latestTag, { + title: "Sprout Desktop Latest", + prerelease: false, + }); + + runGh(uploadVersionedArgs, { stdio: "inherit" }); + runGh(uploadLatestArgs, { stdio: "inherit" }); + + const versionAssets = readReleaseAssets(versionTag); + const latestAssets = readReleaseAssets(latestTag); + if (!versionAssets.includes(dmgName)) { + throw new Error( + `Release ${versionTag} is missing versioned asset ${dmgName} after upload`, + ); + } + if (!latestAssets.includes(latestAssetName)) { + throw new Error( + `Release ${latestTag} is missing latest alias asset ${latestAssetName} after upload`, + ); + } + + console.log("GitHub DMG assets verified."); + console.log(`Versioned URL: ${assetUrl(versionTag, dmgName)}`); + console.log(`Channel URL: ${assetUrl(latestTag, latestAssetName)}`); + } finally { + rmSync(stageDir, { recursive: true, force: true }); + } +} + +main(); diff --git a/desktop/scripts/publish-updater-to-github-release.mjs b/desktop/scripts/publish-updater-to-github-release.mjs new file mode 100644 index 000000000..ed777dd99 --- /dev/null +++ b/desktop/scripts/publish-updater-to-github-release.mjs @@ -0,0 +1,223 @@ +import { execFileSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; + +const repo = process.env.GITHUB_REPOSITORY ?? "block/sprout"; +const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); +const version = process.env.VERSION ?? readVersionFromConfig(); +const latestTag = "sprout-desktop-latest"; +const tauriTarget = process.env.TAURI_TARGET ?? "aarch64-apple-darwin"; +const updaterPlatform = process.env.UPDATER_PLATFORM ?? "darwin-aarch64"; +const dryRun = process.env.DRY_RUN === "true" || process.env.DRY_RUN === "1"; + +const defaultBundleDirs = [ + `src-tauri/target/${tauriTarget}/release/bundle/macos`, + "src-tauri/target/release/bundle/macos", +]; +const bundleDir = resolve( + process.cwd(), + process.env.UPDATER_BUNDLE_DIR ?? + defaultBundleDirs.find((dir) => existsSync(resolve(process.cwd(), dir))) ?? + defaultBundleDirs[0], +); +const latestPath = join(bundleDir, "latest.json"); + +function readVersionFromConfig() { + const config = JSON.parse(readFileSync(tauriConfigPath, "utf-8")); + const configVersion = config?.version; + if (typeof configVersion !== "string" || !configVersion.trim()) { + throw new Error(`Could not determine version from ${tauriConfigPath}`); + } + return configVersion; +} + +function requirePath(path) { + if (!existsSync(path)) { + throw new Error(`Missing required file: ${path}`); + } +} + +function runGh(args, options = {}) { + return execFileSync("gh", args, options); +} + +function releaseExists(tag) { + try { + runGh(["release", "view", tag, "--repo", repo], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function ensureRelease(tag, title) { + if (releaseExists(tag)) { + return; + } + + const args = [ + "release", + "create", + tag, + "--repo", + repo, + "--title", + title, + "--notes", + "Automated release placeholder.", + ]; + + runGh(args, { stdio: "inherit" }); +} + +function readReleaseAssets(tag) { + const assetsRaw = runGh( + [ + "release", + "view", + tag, + "--repo", + repo, + "--json", + "assets", + "--jq", + ".assets[].name", + ], + { encoding: "utf-8" }, + ); + return assetsRaw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function downloadUrl(name) { + return `https://github.com/${repo}/releases/download/${latestTag}/${encodeURIComponent(name)}`; +} + +function resolveArchivePath() { + const canonicalArchiveName = "Sprout.app.tar.gz"; + const canonicalPath = join(bundleDir, canonicalArchiveName); + if (existsSync(canonicalPath)) { + return canonicalPath; + } + + const candidates = readdirSync(bundleDir).filter((entry) => + entry.endsWith(".app.tar.gz"), + ); + if (candidates.length === 1) { + return join(bundleDir, candidates[0]); + } + if (candidates.length === 0) { + throw new Error( + `Could not find updater archive in ${bundleDir}. Expected ${canonicalArchiveName}.`, + ); + } + throw new Error( + `Found multiple updater archives in ${bundleDir}: ${candidates.join(", ")}. Cannot determine which to use.`, + ); +} + +function buildLatestJson(signaturePath) { + const signature = readFileSync(signaturePath, "utf-8").trim(); + return { + version, + notes: `Release v${version}.`, + pub_date: new Date().toISOString(), + platforms: { + [updaterPlatform]: { + signature, + url: "", + }, + }, + }; +} + +function main() { + const archivePath = resolveArchivePath(); + const signaturePath = `${archivePath}.sig`; + requirePath(archivePath); + requirePath(signaturePath); + + const latest = existsSync(latestPath) + ? JSON.parse(readFileSync(latestPath, "utf-8")) + : buildLatestJson(signaturePath); + latest.version = version; + latest.pub_date = new Date().toISOString(); + + const platformRecord = latest?.platforms?.[updaterPlatform]; + if (!platformRecord) { + const available = Object.keys(latest?.platforms ?? {}); + throw new Error( + `Platform "${updaterPlatform}" missing in latest.json. Available: ${available.join(", ") || "(none)"}`, + ); + } + + const archiveName = basename(archivePath); + const signatureName = basename(signaturePath); + platformRecord.signature = readFileSync(signaturePath, "utf-8").trim(); + platformRecord.url = downloadUrl(archiveName); + + const stageDir = mkdtempSync(join(tmpdir(), "sprout-github-updater-")); + const stagedLatestPath = join(stageDir, "latest.json"); + + try { + cpSync(archivePath, join(stageDir, archiveName)); + cpSync(signaturePath, join(stageDir, signatureName)); + writeFileSync(stagedLatestPath, `${JSON.stringify(latest, null, 2)}\n`); + + const uploadArgs = [ + "release", + "upload", + latestTag, + stagedLatestPath, + join(stageDir, archiveName), + join(stageDir, signatureName), + "--repo", + repo, + "--clobber", + ]; + + console.log(`Preparing updater upload for ${repo}`); + console.log(`- latest tag: ${latestTag}`); + console.log(`- updater archive: ${archiveName}`); + console.log(`- updater endpoint: ${downloadUrl("latest.json")}`); + + if (dryRun) { + console.log("DRY_RUN enabled. Skipping upload."); + console.log( + `gh release view ${latestTag} --repo ${repo} || gh release create ${latestTag} --repo ${repo} --title "Sprout Desktop Latest" --notes "Automated release placeholder."`, + ); + console.log(`gh ${uploadArgs.join(" ")}`); + return; + } + + ensureRelease(latestTag, "Sprout Desktop Latest"); + runGh(uploadArgs, { stdio: "inherit" }); + + const latestAssets = readReleaseAssets(latestTag); + for (const expected of ["latest.json", archiveName, signatureName]) { + if (!latestAssets.includes(expected)) { + throw new Error( + `Release ${latestTag} is missing ${expected} after upload`, + ); + } + } + + console.log("GitHub updater assets verified."); + console.log(`Updater endpoint: ${downloadUrl("latest.json")}`); + } finally { + rmSync(stageDir, { recursive: true, force: true }); + } +} + +main(); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 1c17f8e13..ab174ad90 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1167,6 +1167,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2290,7 +2301,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -2417,6 +2431,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2737,6 +2757,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2859,6 +2891,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2908,7 +2954,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -3109,6 +3155,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -3500,6 +3552,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3618,15 +3679,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3718,6 +3784,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3728,6 +3806,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.9" @@ -4183,7 +4288,7 @@ dependencies = [ "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.18", "tracing", "wasm-bindgen", "web-sys", @@ -4238,6 +4343,8 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", "tauri-plugin-websocket", "tauri-plugin-window-state", "tempfile", @@ -4245,7 +4352,7 @@ dependencies = [ "url", "uuid", "windows-sys 0.59.0", - "zip", + "zip 2.4.2", ] [[package]] @@ -4440,6 +4547,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4658,6 +4776,49 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer 0.19.0", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + [[package]] name = "tauri-plugin-websocket" version = "2.4.2" @@ -5629,6 +5790,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -6316,6 +6486,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xz2" version = "0.1.7" @@ -6533,6 +6713,18 @@ dependencies = [ "zstd", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 273ec8536..8618eb075 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -30,6 +30,8 @@ tauri-plugin-opener = "2" tauri-plugin-window-state = "2" tauri-plugin-websocket = "2" tauri-plugin-dialog = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" infer = "0.16" hex = "0.4" tokio = { version = "1", features = ["fs", "sync"] } diff --git a/desktop/src-tauri/Info.plist b/desktop/src-tauri/Info.plist new file mode 100644 index 000000000..41531bc3a --- /dev/null +++ b/desktop/src-tauri/Info.plist @@ -0,0 +1,10 @@ + + + + + CFBundleDisplayName + Sprout + CFBundleName + Sprout + + diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 914dafe5b..6adc5dcbf 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -12,6 +12,10 @@ "opener:default", "websocket:default", "window-state:default", - "dialog:default" + "dialog:default", + "updater:default", + "updater:allow-check", + "updater:allow-download-and-install", + "process:allow-restart" ] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bc6406cde..3544a31a5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -168,6 +168,8 @@ pub fn run() { ) .plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .register_asynchronous_uri_scheme_protocol("sprout-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); tauri::async_runtime::spawn(async move { diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b2782da04..5bdfc39f1 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Sprout", "version": "0.1.0", - "identifier": "com.wesb.sprout", + "identifier": "xyz.block.sprout.app", "build": { "beforeDevCommand": { "script": "exec ./node_modules/.bin/vite", @@ -39,6 +39,9 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "macOS": { + "infoPlist": "Info.plist" + } } } diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx new file mode 100644 index 000000000..b1e17d55d --- /dev/null +++ b/desktop/src/features/settings/UpdateChecker.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { check } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; + +type UpdateStatus = + | { state: "idle" } + | { state: "checking" } + | { state: "up-to-date" } + | { state: "available"; version: string } + | { state: "downloading"; progress?: number } + | { state: "installing" } + | { state: "ready" } + | { state: "error"; message: string }; + +export function UpdateChecker() { + const [status, setStatus] = useState({ state: "idle" }); + + async function checkForUpdate() { + try { + setStatus({ state: "checking" }); + const update = await check(); + + if (update) { + setStatus({ state: "available", version: update.version }); + } else { + setStatus({ state: "up-to-date" }); + } + } catch (err) { + setStatus({ + state: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + } + + async function downloadAndInstall() { + try { + setStatus({ state: "downloading" }); + const update = await check(); + if (!update) { + setStatus({ state: "up-to-date" }); + return; + } + + await update.downloadAndInstall((event) => { + if (event.event === "Started" && event.data.contentLength) { + setStatus({ state: "downloading", progress: 0 }); + } else if (event.event === "Progress") { + // Could track progress here + } else if (event.event === "Finished") { + setStatus({ state: "installing" }); + } + }); + + setStatus({ state: "ready" }); + } catch (err) { + setStatus({ + state: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + } + + async function handleRelaunch() { + await relaunch(); + } + + return ( +
+

+ Software Updates +

+ + {status.state === "idle" && ( +
+

+ Check if a new version is available. +

+ +
+ )} + + {status.state === "checking" && ( +

Checking for updates…

+ )} + + {status.state === "up-to-date" && ( +
+

+ ✓ You're on the latest version. +

+ +
+ )} + + {status.state === "available" && ( +
+

+ Version {status.version} is + available. +

+ +
+ )} + + {status.state === "downloading" && ( +

Downloading update…

+ )} + + {status.state === "installing" && ( +

Installing update…

+ )} + + {status.state === "ready" && ( +
+

+ Update installed. Restart to apply. +

+ +
+ )} + + {status.state === "error" && ( +
+

+ Update failed: {status.message} +

+ +
+ )} +
+ ); +} diff --git a/justfile b/justfile index d5fa67480..0feb09eca 100644 --- a/justfile +++ b/justfile @@ -143,6 +143,76 @@ desktop-dev: desktop-app *ARGS: cd {{desktop_dir}} && pnpm tauri dev {{ARGS}} +# ─── Desktop Release ────────────────────────────────────────────────────────── + +# Create a release branch, bump desktop versions, and open a release PR +desktop-prepare version: + #!/usr/bin/env bash + set -euo pipefail + + current_branch=$(git rev-parse --abbrev-ref HEAD) + if [ "$current_branch" != "main" ]; then + echo "Error: desktop-prepare must be run from the main branch (currently on '$current_branch')" >&2 + exit 1 + fi + + if [ -n "$(git status --short)" ]; then + echo "Error: working tree must be clean before preparing a release." >&2 + exit 1 + fi + + branch="release/desktop-v{{version}}" + + git pull --ff-only origin main + git switch -c "$branch" + + cd desktop + node scripts/bump-version.mjs "{{version}}" + cd src-tauri && cargo generate-lockfile && cd .. + + git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml src-tauri/Cargo.lock + git commit -m "release: desktop v{{version}}" + git push --set-upstream origin "$branch" + + gh pr create \ + --title "release: desktop v{{version}}" \ + --body "Release desktop v{{version}}. Merge this PR, then run \`just desktop-release {{version}}\` from main to tag and publish." \ + --base main \ + --head "$branch" + + echo "" + echo "Release PR created. Once merged, run 'just desktop-release {{version}}' on main." + +# Tag and push the merged release commit to trigger the desktop release workflow +desktop-release version: + #!/usr/bin/env bash + set -euo pipefail + + current_branch=$(git rev-parse --abbrev-ref HEAD) + if [ "$current_branch" != "main" ]; then + echo "Error: desktop-release must be run from the main branch (currently on '$current_branch')" >&2 + exit 1 + fi + + git pull --ff-only origin main + + expected_msg="release: desktop v{{version}}" + release_sha=$(git log --format="%H %s" main | grep -F "$expected_msg" | head -1 | cut -d' ' -f1 || true) + if [ -z "$release_sha" ]; then + echo "Error: could not find commit '$expected_msg' on main." >&2 + echo "Make sure the release PR has been merged and you've pulled latest main." >&2 + exit 1 + fi + + git tag "desktop/v{{version}}" "$release_sha" + git push origin "desktop/v{{version}}" + + echo "Pushed tag desktop/v{{version}} — CI will build and publish the release." + +# Build a signed desktop release locally (for testing) +desktop-release-build target="aarch64-apple-darwin" *args: + cd {{desktop_dir}} && pnpm exec tauri build --target {{target}} --config src-tauri/tauri.release.conf.json {{args}} + # ─── Database ───────────────────────────────────────────────────────────────── # Apply schema migrations via pgschema