feat(release): automate OSS release pipeline with just release (#757)

This commit is contained in:
Will Pfleger
2026-05-27 19:25:09 -04:00
committed by GitHub
parent af8b0b4cfc
commit bcd903edcc
10 changed files with 604 additions and 708 deletions
@@ -0,0 +1,62 @@
name: Auto-tag on Release PR Merge
on:
pull_request:
types: [closed]
branches: [main]
permissions:
contents: write
actions: write
jobs:
auto-tag:
if: >
github.event.pull_request.merged == true &&
startsWith(github.event.pull_request.head.ref, 'version-bump/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 0
- name: Extract version from branch name
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
VERSION="${BRANCH#version-bump/}"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Invalid version in branch name: '$VERSION'"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_ENV"
echo "Tagging v${VERSION}"
- name: Create and push tag
env:
VERSION: ${{ env.version }}
run: |
EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/v$VERSION" | awk '{print $1}')"
if [ -n "$EXISTING_SHA" ]; then
if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then
echo "Tag v$VERSION already exists at $GITHUB_SHA — skipping tag creation"
exit 0
else
echo "::error::Tag v$VERSION already exists at $EXISTING_SHA (expected $GITHUB_SHA)"
exit 1
fi
fi
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git tag "v$VERSION"
git push origin "v$VERSION"
- name: Trigger release build
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ env.version }}
run: |
gh workflow run release.yml \
-f version="$VERSION" \
-f ref="v$VERSION"
+44 -17
View File
@@ -1,13 +1,16 @@
name: Release
on:
push:
tags:
- 'v[0-9]*'
workflow_dispatch:
inputs:
version:
description: "Semver version (e.g. 0.4.0)"
description: "Semver version (e.g. 0.4.0) — only for manual runs"
required: true
ref:
description: "Branch, tag, or SHA to build"
description: "Branch, tag, or SHA to build — only for manual runs"
default: main
required: true
@@ -19,10 +22,25 @@ jobs:
permissions:
contents: write
id-token: write # required by block/apple-codesign-action for OIDC
env:
VERSION: ${{ inputs.version }}
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Determine version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_VERSION: ${{ inputs.version }}
run: |
if [[ "$EVENT_NAME" == "push" ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="$INPUT_VERSION"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Validate version
env:
VERSION: ${{ steps.version.outputs.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)"
@@ -31,7 +49,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref }}
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
persist-credentials: false
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
@@ -40,6 +58,8 @@ jobs:
run: just desktop-install-ci
- name: Patch version
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
@@ -153,20 +173,26 @@ jobs:
> latest.json
cat latest.json
env:
VERSION: ${{ steps.version.outputs.version }}
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
ARCHIVE_NAME: ${{ steps.artifacts.outputs.archive_name }}
- name: Create versioned GitHub release
env:
VERSION: ${{ steps.version.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DMG_PATH: ${{ steps.artifacts.outputs.dmg }}
run: |
RELEASE_SHA=$(git rev-parse HEAD)
NOTES=$(awk "/^## v${VERSION}$/,/^## v/" CHANGELOG.md | head -n -1)
if [[ -z "$NOTES" ]]; then
NOTES="Sprout Desktop v${VERSION}"
fi
gh release create "v${VERSION}" \
--target "$RELEASE_SHA" \
--title "Sprout Desktop v${VERSION}" \
--notes "Sprout Desktop v${VERSION}" \
--notes "$NOTES" \
"$DMG_PATH"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DMG_PATH: ${{ steps.artifacts.outputs.dmg }}
- name: Update rolling release for auto-updater
run: |
@@ -192,12 +218,10 @@ jobs:
timeout-minutes: 60
permissions:
contents: write
env:
VERSION: ${{ inputs.version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref }}
ref: ${{ github.event_name == 'push' && github.ref || inputs.ref }}
persist-credentials: false
- uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1
@@ -237,6 +261,8 @@ jobs:
run: just desktop-install-ci
- name: Patch version
env:
VERSION: ${{ needs.release.outputs.version }}
run: |
cd desktop && node scripts/set-version-from-tag.mjs "$VERSION"
cd src-tauri && cargo update --workspace
@@ -271,12 +297,13 @@ jobs:
echo "appimage=$APPIMAGE" >> "$GITHUB_OUTPUT"
- name: Upload Linux artifacts to versioned GitHub release
run: |
gh release upload "v${VERSION}" \
"$DEB_PATH" \
"$APPIMAGE_PATH" \
--clobber
env:
VERSION: ${{ needs.release.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }}
APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }}
run: |
gh release upload "v$VERSION" \
"$DEB_PATH" \
"$APPIMAGE_PATH" \
--clobber
+25
View File
@@ -6,6 +6,30 @@ code style, PR process, architecture), see [CONTRIBUTING.md](CONTRIBUTING.md).
---
## Ecosystem
Sprout spans five repos. This one (`block/sprout`) is the OSS source for the relay, desktop, mobile, and CLI. The others handle internal builds and deployment:
| Repo | Purpose |
|------|---------|
| [block/sprout](https://github.com/block/sprout) | OSS source — relay, desktop app, mobile app, CLI, agent harness |
| [squareup/sprout-releases](https://github.com/squareup/sprout-releases) | Buildkite pipeline producing Block-signed macOS + iOS builds with `-block` version suffix |
| [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR |
| [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster |
| [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay |
```
block/sprout (source)
├─► sprout-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
├─► sprout-oss (relay Docker image → ECR)
│ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster)
└─── sprout-backend-blox (Blox compute provider for Desktop agent launch)
```
See [RELEASING.md](RELEASING.md) for the desktop release flow across `block/sprout` and `sprout-releases`.
---
## Repo Structure
```
@@ -281,4 +305,5 @@ Or from repo root: `just mobile-fmt` (auto-fix), `just mobile-check` (lint + fmt
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / API endpoints
- [TESTING.md](TESTING.md) — multi-agent E2E test guide
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships
- [RELEASING.md](RELEASING.md) — release process: `just release`, auto-tag, internal builds
- [README.md](README.md) — project overview and quick start
+82 -78
View File
@@ -1,51 +1,82 @@
# Releasing Sprout Desktop
This document describes how to create a new OSS release of the Sprout
desktop app.
## Quick Start
```sh
# Regular release (next minor version)
just release
# Patch release
just release patch
# Explicit version
just release 1.0.0
```
This creates a `version-bump/<version>` PR that bumps all version manifests, regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger the build automatically.
---
## Prerequisites
## How It Works
- **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):
1. **`just release`** runs locally on `main` — computes the next version, creates a `version-bump/<version>` branch, bumps versions in all manifests, regenerates lockfiles, generates a changelog entry, commits, pushes, and opens a PR.
| 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 |
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the `version-bump/*` branch merge and pushes a `v<version>` tag.
3. **Tag triggers `release.yml`** — the existing release workflow builds, signs, notarizes, and publishes the desktop app for macOS and Linux.
---
## Creating a Release
## Release Types
1. Go to **Actions > Release** in the GitHub UI:\
`https://github.com/block/sprout/actions/workflows/release.yml`
| Command | Version | Example |
|---------|---------|---------|
| `just release` | Next minor | `0.3.0``0.4.0` |
| `just release patch` | Next patch | `0.3.0``0.3.1` |
| `just release 1.0.0` | Explicit | `1.0.0` |
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`.
## Version Files
4. Click **"Run workflow"** to start the build.
`just bump-version <version>` updates these files:
The workflow will:
| File | Field |
|------|-------|
| `desktop/package.json` | `"version"` |
| `desktop/src-tauri/tauri.conf.json` | `"version"` |
| `desktop/src-tauri/Cargo.toml` | `version` (under `[package]`) |
| `mobile/pubspec.yaml` | `version:` (preserves build number) |
- 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
It also regenerates `pnpm-lock.yaml`, `desktop/src-tauri/Cargo.lock`, and `mobile/pubspec.lock`.
---
## Manual Fallback
If the automated flow isn't suitable (e.g., building from a non-main ref):
1. Go to **Actions > Release** in the GitHub UI
2. Click **Run workflow**
3. Provide the semver version (no `v` prefix) and the ref to build from
---
## Internal Releases
After the OSS release ships, trigger an internal build via the **sprout-releases** Buildkite pipeline:
1. Go to the [sprout-releases pipeline](https://buildkite.com/runway/sprout-releases) and click **New Build**
2. Fill in the input fields:
| Field | Value | Notes |
|-------|-------|-------|
| `version` | `0.3.0` | Semver, no `v` prefix |
| `sprout_ref` | `v0.3.0` | The OSS git tag — use the tag, not a branch name |
| `relay_url` | *(default)* | Pre-filled with the production relay; usually leave as-is |
| `publish_latest` | `true` | Updates `latest.json` on Artifactory so installed apps auto-update. Set to `false` for test builds. |
Internal desktop builds display a `-block` suffix in the version (e.g., `v0.3.0-block` in the Settings panel). This distinguishes them from OSS builds at a glance. iOS builds and GitHub release tags use the clean version (`0.3.0`) since Apple's `CFBundleShortVersionString` rejects pre-release suffixes.
---
@@ -53,63 +84,36 @@ The workflow will:
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.
1. **`v<version>`** — the user-facing release with the `.dmg` installer (macOS) and `.deb`/`.AppImage` (Linux).
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.
2. **`sprout-desktop-latest`** — a rolling pre-release for the Tauri auto-updater containing `latest.json`, the signed `.tar.gz` archive, and its `.sig` signature.
---
## Platform Support
## Prerequisites
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.
- **Write access** to the `block/sprout` GitHub repository
- **`gh` CLI** authenticated (`gh auth status`)
- The following **GitHub Actions secrets** must be configured:
## Code Signing (macOS)
OSS release builds use **ad-hoc code signing** (`signingIdentity: "-"`)
rather than a Developer ID certificate. This means the app is not
notarized by Apple.
On first launch, macOS Gatekeeper will block the app with a "damaged" or
"unidentified developer" message. Users can bypass this by
**right-clicking the app > Open** (or via System Settings > Privacy &
Security). After the first launch the app will open normally.
---
## 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.
| Secret | Purpose |
|--------|---------|
| `SPROUT_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) |
| `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key |
---
## 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.
### `just release` fails with "must be on main branch"
Switch to `main` and pull latest before running `just release`.
### 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.
### `just release` fails with "working tree is dirty"
Commit or stash your changes before running `just release`.
### Build fails at "Validate version"
The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pre-release suffix. Do not include a `v` prefix.
### 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).
Verify that the `sprout-desktop-latest` release exists and contains a valid `latest.json`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sprout",
"private": true,
"version": "0.1.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+254 -609
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
[package]
name = "sprout-desktop"
version = "0.1.0"
version = "0.3.0"
description = "Sprout desktop app"
authors = ["you"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Sprout",
"version": "0.1.0",
"version": "0.3.0",
"identifier": "xyz.block.sprout.app",
"build": {
"beforeDevCommand": {
+133
View File
@@ -308,6 +308,139 @@ clean:
check-compile:
cargo check --workspace --all-targets
# ─── Release ─────────────────────────────────────────────────────────────────
# Read the current desktop version from package.json
get-current-version:
@node -p "require('./desktop/package.json').version"
# Compute next minor version (e.g., 0.3.0 → 0.4.0)
get-next-minor-version:
@python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{int(v[1])+1}.0')"
# Compute next patch version (e.g., 0.3.0 → 0.3.1)
get-next-patch-version:
@python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
# Update version in all package manifests and regenerate lockfiles
bump-version version:
#!/usr/bin/env bash
set -euo pipefail
# Validate semver format
if ! echo "{{ version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "Error: '{{ version }}' is not valid semver (expected X.Y.Z)"
exit 1
fi
# desktop/package.json
cd desktop && npm pkg set "version={{ version }}" && cd ..
# desktop/src-tauri/tauri.conf.json
node -e "
const fs = require('fs');
const p = 'desktop/src-tauri/tauri.conf.json';
const c = JSON.parse(fs.readFileSync(p, 'utf8'));
c.version = '{{ version }}';
fs.writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
"
# desktop/src-tauri/Cargo.toml — only first version line (under [package])
node -e "
const fs = require('fs');
const p = 'desktop/src-tauri/Cargo.toml';
let t = fs.readFileSync(p, 'utf8');
t = t.replace(/^version = \".*\"/m, 'version = \"{{ version }}\"');
fs.writeFileSync(p, t);
"
# mobile/pubspec.yaml — bump version but preserve build number
sed -i '' "s/^version: .*/version: {{ version }}+1/" mobile/pubspec.yaml
# Regenerate lockfiles
pnpm install --lockfile-only
cargo update -p sprout-desktop --manifest-path desktop/src-tauri/Cargo.toml
(unset GIT_DIR GIT_WORK_TREE; cd mobile && flutter pub get)
echo "Bumped all manifests to {{ version }} and regenerated lockfiles"
# Create a release PR that bumps version and generates changelog
release *ARGS:
#!/usr/bin/env bash
set -euo pipefail
# Determine target version
ARG="{{ ARGS }}"
if [[ -z "$ARG" ]]; then
VERSION=$(just get-next-minor-version)
elif [[ "$ARG" == "patch" ]]; then
VERSION=$(just get-next-patch-version)
else
VERSION="$ARG"
fi
echo "Preparing release v${VERSION}..."
# Ensure on main branch
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
if [[ "$CURRENT_BRANCH" != "main" ]]; then
echo "Error: must be on main branch (currently on '$CURRENT_BRANCH')"
exit 1
fi
# Ensure local main is up-to-date
git fetch origin main --quiet
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
echo "Error: local main is not up-to-date with origin/main. Run 'git pull' first."
exit 1
fi
# Ensure clean working tree
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: working tree is dirty. Commit or stash changes first."
exit 1
fi
# Create version-bump branch
BRANCH="version-bump/${VERSION}"
git switch -c "$BRANCH"
# Bump versions and lockfiles
just bump-version "$VERSION"
# Generate changelog
LAST_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "")
TMPFILE=$(mktemp)
{
echo "# Changelog"
echo ""
echo "## v${VERSION}"
echo ""
if [[ -n "$LAST_TAG" ]]; then
git log "${LAST_TAG}..HEAD" --oneline --no-merges
else
echo "Initial release"
fi
echo ""
if [[ -f CHANGELOG.md ]]; then
tail -n +2 CHANGELOG.md
fi
} > "$TMPFILE"
mv "$TMPFILE" CHANGELOG.md
# Commit
git add \
desktop/package.json \
desktop/src-tauri/tauri.conf.json \
desktop/src-tauri/Cargo.toml \
desktop/src-tauri/Cargo.lock \
mobile/pubspec.yaml \
mobile/pubspec.lock \
pnpm-lock.yaml \
CHANGELOG.md
git commit -m "chore(release): release version ${VERSION}"
# Push and open PR
git push -u origin "$BRANCH"
# Build PR body
PR_BODY="## Release v${VERSION}"$'\n\n'
if [[ -n "$LAST_TAG" ]]; then
PR_BODY+="### Changes since ${LAST_TAG}:"$'\n\n'
PR_BODY+="$(git log "${LAST_TAG}..HEAD~1" --oneline --no-merges)"$'\n\n'
else
PR_BODY+="Initial release."$'\n\n'
fi
PR_BODY+="**To release:** merge this PR. The tag and build will happen automatically."
PR_URL=$(gh pr create \
--title "chore(release): release version ${VERSION}" \
--body "$PR_BODY")
echo ""
echo "Release PR opened: ${PR_URL}"
echo "Merge it to trigger the release build."
# ─── Agent Harness ────────────────────────────────────────────────────────────
# Run a goose agent connected to a Sprout relay (foreground)
+1 -1
View File
@@ -1,7 +1,7 @@
name: sprout_mobile
description: Sprout mobile client
publish_to: 'none'
version: 0.1.0+1
version: 0.3.0+1
environment:
sdk: ^3.11.4