mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Feat/app release setup (#164)
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
Generated
+20
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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();
|
||||
Generated
+195
-3
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user