Simplify desktop release to tag-and-push (#185)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-03-25 15:29:04 -07:00
committed by GitHub
parent 7e6f799acb
commit 77493aa684
7 changed files with 137 additions and 183 deletions

View File

@@ -15,6 +15,8 @@ jobs:
permissions:
id-token: write
contents: write
env:
RELEASE_VERSION: '' # set in the "Extract version from tag" step
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -27,16 +29,19 @@ jobs:
workspaces: desktop/src-tauri
key: desktop-release-aarch64-apple-darwin
- name: Validate tag matches desktop versions
- name: Extract version from tag
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
RELEASE_VERSION="${GITHUB_REF#refs/tags/desktop/v}"
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_ENV"
echo "Release version: ${RELEASE_VERSION}"
- name: Set version from tag
working-directory: desktop
run: node scripts/set-version-from-tag.mjs "$RELEASE_VERSION"
- name: Regenerate Cargo lockfile
working-directory: desktop/src-tauri
run: cargo generate-lockfile
- name: Validate release secrets
env:
@@ -97,14 +102,13 @@ jobs:
- 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"
"${DMG_DIR}/Sprout_${RELEASE_VERSION}_aarch64.dmg"
- name: Create updater archive from signed app
env:
@@ -120,10 +124,9 @@ jobs:
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}" \
--title "Sprout Desktop v${RELEASE_VERSION}" \
--notes "See the assets to download and install this version."
- name: Publish updater alias
@@ -131,6 +134,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
VERSION: ${{ env.RELEASE_VERSION }}
run: pnpm run release:updater:publish
- name: Publish DMG alias
@@ -138,4 +142,5 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
VERSION: ${{ env.RELEASE_VERSION }}
run: pnpm run release:dmg:publish

View File

@@ -38,42 +38,29 @@ Store the password you chose in `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`.
## Cutting a Release
### 1. Prepare
From `main`, run:
From any branch (typically `main`):
```bash
just desktop-prepare <version>
just desktop-release 0.3.0
```
For example:
Or equivalently:
```bash
just desktop-prepare 0.2.0
git tag desktop/v0.3.0
git push origin desktop/v0.3.0
```
This creates a release branch, bumps versions in `package.json`,
`tauri.conf.json`, and `Cargo.toml`, and opens a PR.
That's it. CI extracts the version from the tag and writes it into
`package.json`, `tauri.conf.json`, and `Cargo.toml` at build time. The
versions checked into the repo are not used for releases — the tag is the
source of truth.
### 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
### Verify
Check GitHub Releases for:
- The **versioned release** (e.g. `sprout-desktop-v0.2.0`)
- The **versioned release** (e.g. `desktop/v0.3.0`)
- The **`sprout-desktop-latest` rolling release** (updated with every release)
---
@@ -82,17 +69,19 @@ Check GitHub Releases for:
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.
1. **Extracts the version** from the git tag once into `RELEASE_VERSION`.
2. **Sets the version** into `package.json`, `tauri.conf.json`, and
`Cargo.toml` using `set-version-from-tag.mjs`.
3. **Regenerates `Cargo.lock`** to match the patched `Cargo.toml`.
4. **Validates** all required secrets are present.
5. **Builds** the release config with signing and updater settings.
6. **Builds** the Tauri app (unsigned).
7. **Signs and notarizes** the macOS bundle via `block/apple-codesign-action`.
8. **Re-packages** the signed app into a DMG and updater archive.
9. **Signs** the updater archive with the Tauri updater key.
10. **Publishes** the updater manifest (`latest.json`) to the rolling
`sprout-desktop-latest` release.
11. **Publishes** the DMG to both the versioned and rolling releases.
---
@@ -111,8 +100,14 @@ export SPROUT_UPDATER_ENDPOINT="https://github.com/block/sprout/releases/downloa
cd desktop
pnpm run tauri:release:config
# Build (unsigned)
just desktop-release-build
# Build (unsigned) — pass a version to set it before building
just desktop-release-build version=0.3.0
```
You can also set the version separately without building:
```bash
just desktop-set-version 0.3.0
```
---
@@ -142,6 +137,20 @@ The app connects to the relay via the `SPROUT_RELAY_URL` environment variable.
---
## How Versioning Works
The git tag is the single source of truth for the release version. The version
fields in `package.json`, `tauri.conf.json`, and `Cargo.toml` on `main` are
**not** used for releases — CI overwrites them at build time from the tag.
This means the tagged commit will show a different version in its source files
than what the release actually contains. This is an accepted tradeoff — the tag
is the canonical version, the commit is just the code state at release time.
This is standard practice in ecosystems like Docker, Go, and Rust where the
tag drives the version.
---
## Troubleshooting
- **"Missing required desktop release secrets"**: Ensure all secrets listed in
@@ -152,6 +161,6 @@ The app connects to the relay via the `SPROUT_RELAY_URL` environment variable.
`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.
- **Build failures**: If versions are wrong, check that the tag follows the
format `desktop/v<semver>` (e.g. `desktop/v0.3.0`). CI extracts the version
from the tag automatically.

View File

@@ -1,54 +0,0 @@
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}`);
}

View File

@@ -1,10 +1,10 @@
import { execFileSync } from "node:child_process";
import { join, resolve } from "node:path";
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
const repo = process.env.GITHUB_REPOSITORY ?? "block/sprout";
const version = process.env.VERSION ?? readVersionFromConfig();
const version = requireVersionEnv();
const versionTag = `desktop/v${version}`;
const latestTag = "sprout-desktop-latest";
const dryRun = process.env.DRY_RUN === "true" || process.env.DRY_RUN === "1";
@@ -26,14 +26,14 @@ const dmgDir = resolve(
);
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}`);
function requireVersionEnv() {
const v = process.env.VERSION;
if (!v || !v.trim()) {
throw new Error(
"VERSION env var is required. CI sets this from the git tag; for local use, run: VERSION=x.y.z pnpm run release:dmg:publish",
);
}
return configVersion;
return v.trim();
}
function quote(arg) {

View File

@@ -12,8 +12,7 @@ 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 version = requireVersionEnv();
const latestTag = "sprout-desktop-latest";
const tauriTarget = process.env.TAURI_TARGET ?? "aarch64-apple-darwin";
const updaterPlatform = process.env.UPDATER_PLATFORM ?? "darwin-aarch64";
@@ -31,13 +30,14 @@ const bundleDir = resolve(
);
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}`);
function requireVersionEnv() {
const v = process.env.VERSION;
if (!v || !v.trim()) {
throw new Error(
"VERSION env var is required. CI sets this from the git tag; for local use, run: VERSION=x.y.z pnpm run release:updater:publish",
);
}
return configVersion;
return v.trim();
}
function requirePath(path) {

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}`);

View File

@@ -153,72 +153,28 @@ desktop-app *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
# Tag and push to trigger the desktop release workflow (CI handles version bumping)
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 tag "desktop/v{{version}}"
git push origin "desktop/v{{version}}"
echo "Pushed tag desktop/v{{version}} — CI will build and publish the release."
echo "Pushed tag desktop/v{{version}} — CI will set the version, build, and publish the release."
# Build a signed desktop release locally (for testing)
desktop-release-build target="aarch64-apple-darwin" *args:
# Set the desktop app version (patches package.json, tauri.conf.json, Cargo.toml)
desktop-set-version version:
cd {{desktop_dir}} && node scripts/set-version-from-tag.mjs "{{version}}"
cd {{desktop_dir}}/src-tauri && cargo generate-lockfile
# Build a local desktop release (for testing). Optionally set a version first.
desktop-release-build version="" target="aarch64-apple-darwin" *args:
#!/usr/bin/env bash
set -euo pipefail
if [ -n "{{version}}" ]; then
just desktop-set-version "{{version}}"
fi
cd {{desktop_dir}} && pnpm exec tauri build --target {{target}} --config src-tauri/tauri.release.conf.json {{args}}
# ─── Database ─────────────────────────────────────────────────────────────────