diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..3930afda5 --- /dev/null +++ b/.github/workflows/release.yml @@ -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__.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 }} diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..9edc941fe --- /dev/null +++ b/RELEASING.md @@ -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`** (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). diff --git a/desktop/scripts/build-release-config.mjs b/desktop/scripts/build-release-config.mjs new file mode 100644 index 000000000..4a059d4a4 --- /dev/null +++ b/desktop/scripts/build-release-config.mjs @@ -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}`); diff --git a/desktop/scripts/generate-oss-latest-json.sh b/desktop/scripts/generate-oss-latest-json.sh new file mode 100755 index 000000000..cedde5dcf --- /dev/null +++ b/desktop/scripts/generate-oss-latest-json.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 3 ]]; then + echo "Usage: generate-oss-latest-json.sh " >&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 } } }' diff --git a/desktop/scripts/set-version-from-tag.mjs b/desktop/scripts/set-version-from-tag.mjs new file mode 100644 index 000000000..df2c49b07 --- /dev/null +++ b/desktop/scripts/set-version-from-tag.mjs @@ -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 "); + 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}`);