Feat/app release setup (#164)

This commit is contained in:
Wes
2026-03-24 13:33:45 -07:00
committed by GitHub
parent bf6de08906
commit 52bd65a759
16 changed files with 1284 additions and 7 deletions
@@ -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
+157
View File
@@ -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 <version>
```
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 <version>
```
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.
+7 -1
View File
@@ -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",
+20
View File
@@ -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
+43
View File
@@ -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}`);
+54
View File
@@ -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 <version>");
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}`);
}
@@ -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();
@@ -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();
+195 -3
View File
@@ -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"
+2
View File
@@ -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"] }
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Sprout</string>
<key>CFBundleName</key>
<string>Sprout</string>
</dict>
</plist>
+5 -1
View File
@@ -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"
]
}
+2
View File
@@ -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 {
+5 -2
View File
@@ -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"
}
}
}
@@ -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<UpdateStatus>({ 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 (
<div className="rounded-lg bg-zinc-900 p-4">
<h3 className="mb-3 text-sm font-medium text-zinc-200">
Software Updates
</h3>
{status.state === "idle" && (
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-400">
Check if a new version is available.
</p>
<button
type="button"
onClick={checkForUpdate}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Check for Updates
</button>
</div>
)}
{status.state === "checking" && (
<p className="text-sm text-zinc-400">Checking for updates</p>
)}
{status.state === "up-to-date" && (
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-300">
You're on the latest version.
</p>
<button
type="button"
onClick={checkForUpdate}
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
Check Again
</button>
</div>
)}
{status.state === "available" && (
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-300">
Version <span className="font-semibold">{status.version}</span> is
available.
</p>
<button
type="button"
onClick={downloadAndInstall}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Download &amp; Install
</button>
</div>
)}
{status.state === "downloading" && (
<p className="text-sm text-zinc-400">Downloading update</p>
)}
{status.state === "installing" && (
<p className="text-sm text-zinc-400">Installing update</p>
)}
{status.state === "ready" && (
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-300">
Update installed. Restart to apply.
</p>
<button
type="button"
onClick={handleRelaunch}
className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Restart Now
</button>
</div>
)}
{status.state === "error" && (
<div className="flex items-center justify-between">
<p className="text-sm text-red-400">
Update failed: {status.message}
</p>
<button
type="button"
onClick={checkForUpdate}
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-300 hover:bg-zinc-700"
>
Retry
</button>
</div>
)}
</div>
);
}
+70
View File
@@ -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