feat(desktop): add OSS release workflow and auto-updater support (#461)

This commit is contained in:
Wes
2026-05-03 16:21:58 +00:00
committed by GitHub
parent 0aa39df061
commit 22cb8129e5
5 changed files with 348 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Semver version (e.g. 0.4.0)"
required: true
ref:
description: "Branch, tag, or SHA to build"
default: main
required: true
jobs:
release:
name: Release
runs-on: macos-latest
timeout-minutes: 60
permissions:
contents: write
env:
VERSION: ${{ inputs.version }}
steps:
- name: Validate version
run: |
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Invalid version '$VERSION'. Expected semver (e.g. 0.4.0 or 1.0.0-beta.1)"
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
- name: Install desktop dependencies
run: just desktop-install-ci
- name: Patch version
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo generate-lockfile
- name: Generate release config
run: cd desktop && node scripts/build-release-config.mjs
env:
SPROUT_UPDATER_PUBLIC_KEY: ${{ secrets.SPROUT_UPDATER_PUBLIC_KEY }}
SPROUT_UPDATER_ENDPOINT: https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json
- name: Build sidecars
run: |
cargo build --release -p sprout-acp -p sprout-mcp -p git-credential-nostr
./scripts/bundle-sidecars.sh
- name: Build Tauri app
run: cd desktop && pnpm tauri build --config src-tauri/tauri.release.conf.json
env:
SPROUT_UPDATER_PUBLIC_KEY: ${{ secrets.SPROUT_UPDATER_PUBLIC_KEY }}
SPROUT_UPDATER_ENDPOINT: https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Locate build artifacts
id: artifacts
run: |
BUNDLE_DIR="desktop/src-tauri/target/release/bundle"
# Find the DMG (Tauri names it Sprout_<version>_<arch>.dmg)
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1)
if [[ -z "$DMG" ]]; then
echo "::error::No DMG found in $BUNDLE_DIR/dmg"
exit 1
fi
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
# Find the updater .tar.gz and .sig
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
SIG="${ARCHIVE}.sig"
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
exit 1
fi
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
- name: Generate latest.json
run: |
bash desktop/scripts/generate-oss-latest-json.sh \
"$VERSION" \
"$SIG_PATH" \
"https://github.com/block/sprout/releases/download/sprout-desktop-latest/$ARCHIVE_NAME" \
> latest.json
cat latest.json
env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
ARCHIVE_NAME: ${{ steps.artifacts.outputs.archive_name }}
- name: Create versioned GitHub release
run: |
RELEASE_SHA=$(git rev-parse HEAD)
gh release create "v${VERSION}" \
--target "$RELEASE_SHA" \
--title "Sprout Desktop v${VERSION}" \
--notes "Sprout Desktop v${VERSION}" \
"$DMG_PATH"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DMG_PATH: ${{ steps.artifacts.outputs.dmg }}
- name: Update rolling release for auto-updater
run: |
gh release create sprout-desktop-latest \
--prerelease \
--title "Sprout Desktop Auto-Update" \
--notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \
2>/dev/null || true
gh release upload sprout-desktop-latest \
latest.json \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
+104
View File
@@ -0,0 +1,104 @@
# Releasing Sprout Desktop
This document describes how to create a new OSS release of the Sprout
desktop app.
---
## Prerequisites
- **Write access** to the `block/sprout` GitHub repository. Only
collaborators with push permissions can trigger the release workflow.
- The following **GitHub Actions secrets** must be configured on the repo
(Settings > Secrets and variables > Actions):
| Secret | Purpose |
|--------|---------|
| `SPROUT_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) |
| `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key (used to sign the update archive) |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key |
---
## Creating a Release
1. Go to **Actions > Release** in the GitHub UI:\
`https://github.com/block/sprout/actions/workflows/release.yml`
2. Click **"Run workflow"**.
3. Fill in the inputs:
- **version** — a semver version string, e.g. `0.4.0` or `1.0.0-beta.1`.
Do not include a `v` prefix.
- **ref** — the branch, tag, or commit SHA to build from. Defaults to
`main`.
4. Click **"Run workflow"** to start the build.
The workflow will:
- Validate the version string
- Check out the specified ref
- Patch the version into `package.json`, `tauri.conf.json`, and `Cargo.toml`
- Build all sidecar binaries (`sprout-acp`, `sprout-mcp`,
`git-credential-nostr`)
- Build the Tauri desktop app with updater signing enabled
- Create a versioned GitHub release (`v0.4.0`) with the `.dmg` installer
- Update the rolling `sprout-desktop-latest` release with the signed
update archive and `latest.json` manifest for the auto-updater
---
## What Gets Published
Each release produces two GitHub releases:
1. **`v<version>`** (e.g. `v0.4.0`) — the user-facing release with the
`.dmg` installer. This is what users download manually.
2. **`sprout-desktop-latest`** — a rolling pre-release used by the Tauri
auto-updater. Contains `latest.json`, the signed `.tar.gz` archive,
and its `.sig` signature. Users should not download from this release
directly.
---
## Platform Support
The release workflow currently builds for **macOS ARM64 only**
(`darwin-aarch64`). Intel Mac (`darwin-x86_64`) support would require
adding a matrix build to the workflow.
---
## Auto-Updater
The desktop app checks for updates by fetching `latest.json` from the
`sprout-desktop-latest` release:
```
https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json
```
When a new version is available, the app downloads the signed archive,
verifies the signature against the embedded public key, and applies the
update.
---
## Troubleshooting
### Build fails at "Validate version"
The version string must be valid semver: `MAJOR.MINOR.PATCH` with an
optional pre-release suffix (e.g. `1.0.0-beta.1`). Do not include a `v`
prefix.
### Build fails at "Build Tauri app"
Check that the signing secrets are configured correctly. The build
requires `TAURI_SIGNING_PRIVATE_KEY` and
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` to be set.
### Auto-updater reports "no update available"
Verify that the `sprout-desktop-latest` release exists and contains a
valid `latest.json`. If the user is on Intel Mac, no update will be
found (ARM64 only).
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
// Write a tauri.release.conf.json alongside sprout's base tauri.conf.json.
//
// For OSS release builds this script:
// 1. Sets bundle.macOS.minimumSystemVersion = "10.15" for broad compatibility.
// 2. Configures plugins.updater with the public key and endpoint from env vars.
// Both SPROUT_UPDATER_PUBLIC_KEY and SPROUT_UPDATER_ENDPOINT are required —
// the script fails if either is missing (OSS builds always ship with updater).
// 3. Sets bundle.createUpdaterArtifacts = true so Tauri automatically produces
// the .tar.gz archive and .sig signature during the build.
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 };
const updaterPubkey = process.env.SPROUT_UPDATER_PUBLIC_KEY;
const updaterEndpoint = process.env.SPROUT_UPDATER_ENDPOINT;
const missing = [];
if (!updaterPubkey) missing.push("SPROUT_UPDATER_PUBLIC_KEY");
if (!updaterEndpoint) missing.push("SPROUT_UPDATER_ENDPOINT");
if (missing.length > 0) {
console.error(
`Error: required environment variable(s) missing: ${missing.join(", ")}`,
);
process.exit(1);
}
releaseConfig.bundle = {
...(releaseConfig.bundle ?? {}),
macOS: {
...(releaseConfig.bundle?.macOS ?? {}),
minimumSystemVersion: "10.15",
},
createUpdaterArtifacts: true,
};
releaseConfig.plugins = {
...(releaseConfig.plugins ?? {}),
updater: {
pubkey: updaterPubkey,
endpoints: [updaterEndpoint],
},
};
console.log(`Updater enabled -> ${updaterEndpoint}`);
writeFileSync(outputConfigPath, `${JSON.stringify(releaseConfig, null, 2)}\n`);
console.log(`Wrote ${outputConfigPath}`);
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 3 ]]; then
echo "Usage: generate-oss-latest-json.sh <version> <sig-file> <archive-url>" >&2
exit 1
fi
VERSION="$1"
SIG_FILE="$2"
ARCHIVE_URL="$3"
# Only darwin-aarch64 is included because the workflow builds on ARM64 runners
# only. Supporting Intel Macs (darwin-x86_64) would require a matrix build.
jq -n \
--arg version "$VERSION" \
--arg notes "Sprout v$VERSION" \
--arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg signature "$(cat "$SIG_FILE")" \
--arg url "$ARCHIVE_URL" \
'{ version: $version, notes: $notes, pub_date: $pub_date, platforms: { "darwin-aarch64": { signature: $signature, url: $url } } }'
+38
View File
@@ -0,0 +1,38 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const version = process.argv[2];
if (!version) {
console.error("Usage: node scripts/set-version-from-tag.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"));
packageJson.version = version;
writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
console.log(`Set package.json to ${version}`);
const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8"));
tauriConfig.version = version;
writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`);
console.log(`Set tauri.conf.json to ${version}`);
const cargoToml = readFileSync(cargoTomlPath, "utf8");
const updatedCargoToml = cargoToml.replace(
/^version = ".*"$/m,
`version = "${version}"`,
);
writeFileSync(cargoTomlPath, updatedCargoToml);
console.log(`Set Cargo.toml to ${version}`);