feat(release): make desktop releases immutable (#3568)

## Summary
- add a manual desktop release preparer that regenerates one
version-only candidate from current `origin/main`
- validate deterministic complete changelog accounting, candidate
authorship, allowed files, exact-head approval, required checks, and
two-parent merge topology before tagging the reviewed candidate
- move desktop tags/releases from `v*` to `desktop-v*` while preserving
relay, chart, push-chart, and mobile behavior
- stage all four platform outputs in Actions artifacts and grant GitHub
release write access only to one final all-platform-gated publisher
- publish the versioned release only after complete artifact assembly;
update stable `latest.json` last; never promote prereleases or published
rebuild outputs

## Safety properties
- desktop tags point to the reviewed candidate SHA, not the merge commit
- release builds remain tag-bound and reverify tag == checked-out HEAD
- one final writer fails closed on artifact basename collisions
- per-tag concurrency serializes publication without cancellation
- published reruns do not replace immutable versioned assets or promote
signatures from a rebuild
- candidate branches use an explicit remote OID lease when regenerated

## Validation
- `scripts/test-desktop-release-candidate.sh`
- `scripts/test-release-ref-contract.sh`
- `scripts/test-mobile-release-contract.sh`
- changed workflow YAML parsing (Ruby Psych)
- changed shell syntax (`bash -n`)
- `git diff --check`
- push hooks: branch-skew, Rust workspace tests (1,853 passed), desktop
Tauri tests (3 passed)

## Coordinated companion
- squareup/buzz-releases#79 updates the manually entered desktop
source-tag contract to stable-only `desktop-v*`
- merge the private contract companion before the first namespaced
desktop release

## Rollout blockers (no settings changed here)
Before the first candidate/release:
1. enable merge commits in repository settings
2. allow `merge` in ruleset `13596885`
3. require approval after the last push in ruleset `13596885`
4. include `refs/tags/desktop-v*` explicitly in release ruleset
`14378754`
5. prove the non-publishing candidate/merge/tag/artifact validation path
before any production release

Do not test the old workflow with a prerelease: it can still mutate the
production rolling updater release.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-07-30 12:27:56 -06:00
committed by GitHub
parent cca8839034
commit 1dfd89ea67
13 changed files with 763 additions and 198 deletions

View File

@@ -4,7 +4,7 @@ name: Auto-tag on Release PR Merge
# prefix; the main chart lane also auto-detects a Chart.yaml version bump so # prefix; the main chart lane also auto-detects a Chart.yaml version bump so
# a chart feature PR can publish its own new version when merged: # a chart feature PR can publish its own new version when merged:
# #
# version-bump/<v> → tag v<v> → release.yml (desktop app) # version-bump/<v> → tag desktop-v<v> → release.yml (desktop app)
# relay-release/<v> → tag relay-v<v> → docker.yml (relay image) # relay-release/<v> → tag relay-v<v> → docker.yml (relay image)
# chart-release/<v> → tag chart-v<v> → helm-chart.yml (main helm chart) # chart-release/<v> → tag chart-v<v> → helm-chart.yml (main helm chart)
# push-chart-release/<v> → tag push-chart-v<v> → push-gateway-helm-chart.yml # push-chart-release/<v> → tag push-chart-v<v> → push-gateway-helm-chart.yml
@@ -35,6 +35,11 @@ permissions:
jobs: jobs:
auto-tag: auto-tag:
permissions:
contents: read
pull-requests: read
checks: read
statuses: read
if: > if: >
github.event.pull_request.merged == true && github.event.pull_request.merged == true &&
github.event.pull_request.head.repo.full_name == github.repository github.event.pull_request.head.repo.full_name == github.repository
@@ -57,7 +62,7 @@ jobs:
case "$BRANCH" in case "$BRANCH" in
version-bump/*) version-bump/*)
VERSION="${BRANCH#version-bump/}" VERSION="${BRANCH#version-bump/}"
TAG_PREFIX="v" ;; TAG_PREFIX="desktop-v" ;;
relay-release/*) relay-release/*)
VERSION="${BRANCH#relay-release/}" VERSION="${BRANCH#relay-release/}"
TAG_PREFIX="relay-v" ;; TAG_PREFIX="relay-v" ;;
@@ -85,9 +90,34 @@ jobs:
{ {
echo "enabled=true" echo "enabled=true"
echo "tag=${TAG_PREFIX}${VERSION}" echo "tag=${TAG_PREFIX}${VERSION}"
if [[ "$TAG_PREFIX" == desktop-v ]]; then
echo "target_sha=${{ github.event.pull_request.head.sha }}"
echo "desktop=true"
else
echo "target_sha=$GITHUB_SHA"
echo "desktop=false"
fi
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
echo "Tagging ${TAG_PREFIX}${VERSION}" echo "Tagging ${TAG_PREFIX}${VERSION}"
- name: Verify immutable reviewed desktop candidate
if: steps.release.outputs.desktop == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.release.outputs.tag }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
PR_PUSHER: ${{ github.event.pull_request.head.user.login }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
VERSION="${VERSION#desktop-v}"
export VERSION
scripts/verify-desktop-release-merge.sh
- name: Create release tagger token - name: Create release tagger token
if: steps.release.outputs.enabled == 'true' if: steps.release.outputs.enabled == 'true'
id: release-tagger id: release-tagger
@@ -102,21 +132,22 @@ jobs:
env: env:
GH_TOKEN: ${{ steps.release-tagger.outputs.token }} GH_TOKEN: ${{ steps.release-tagger.outputs.token }}
TAG: ${{ steps.release.outputs.tag }} TAG: ${{ steps.release.outputs.tag }}
TARGET_SHA: ${{ steps.release.outputs.target_sha }}
run: | run: |
set -euo pipefail set -euo pipefail
# Check gh's exit status, not its output. A missing ref returns a 404 # Check gh's exit status, not its output. A missing ref returns a 404
# JSON body on stdout, which must not be mistaken for an existing tag. # JSON body on stdout, which must not be mistaken for an existing tag.
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then
EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)"
if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation"
exit 0 exit 0
else else
echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)" echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)"
exit 1 exit 1
fi fi
fi fi
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$TAG" \ -f ref="refs/tags/$TAG" \
-f sha="$GITHUB_SHA" \ -f sha="$TARGET_SHA" \
--silent --silent

View File

@@ -76,6 +76,8 @@ jobs:
- '.github/workflows/ci.yml' - '.github/workflows/ci.yml'
- name: Release workflow source contract - name: Release workflow source contract
run: scripts/test-release-ref-contract.sh run: scripts/test-release-ref-contract.sh
- name: Desktop release candidate contract
run: scripts/test-desktop-release-candidate.sh
- name: Mobile release contract - name: Mobile release contract
run: | run: |
scripts/test-mobile-release-contract.sh scripts/test-mobile-release-contract.sh

View File

@@ -0,0 +1,38 @@
name: Prepare Desktop Release
on:
workflow_dispatch:
inputs:
version:
description: Semver to prepare (for example 0.5.1)
required: true
env:
RELEASE_AUTOMATION_NAME: Carl
RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz
jobs:
prepare:
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Create short-lived release preparer token
id: preparer
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }}
private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
token: ${{ steps.preparer.outputs.token }}
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Prepare immutable candidate and open or update PR
env:
GH_TOKEN: ${{ steps.preparer.outputs.token }}
VERSION: ${{ inputs.version }}
run: scripts/prepare-desktop-release.sh "$VERSION"

View File

@@ -1,14 +1,13 @@
name: Release name: Release
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: false
on: on:
push: push:
tags: tags:
- 'v[0-9]*' - 'desktop-v[0-9]*'
workflow_dispatch:
inputs:
version:
description: "Semver version matching the v-prefixed dispatch tag"
required: true
jobs: jobs:
# Shared setup: verify the immutable release tag, determine the version, and # Shared setup: verify the immutable release tag, determine the version, and
@@ -19,23 +18,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
permissions: permissions:
contents: write contents: read
outputs: outputs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
source_sha: ${{ steps.source.outputs.source_sha }} source_sha: ${{ steps.source.outputs.source_sha }}
steps: steps:
- name: Determine version - name: Determine version
id: version id: version
env: run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
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 - name: Validate version
env: env:
@@ -56,42 +46,9 @@ jobs:
env: env:
VERSION: ${{ steps.version.outputs.version }} VERSION: ${{ steps.version.outputs.version }}
run: | run: |
scripts/verify-release-ref.sh v "$VERSION" scripts/verify-release-ref.sh desktop-v "$VERSION"
echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT" echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT"
- name: Create versioned GitHub release
env:
VERSION: ${{ steps.version.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_SHA=$(git rev-parse HEAD)
NOTES=""
if [[ -f CHANGELOG.md ]]; then
NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md)
fi
if [[ -z "$NOTES" ]]; then
NOTES="Buzz Desktop v${VERSION}"
fi
PRERELEASE_FLAGS=()
if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then
PRERELEASE_FLAGS=(--prerelease --latest=false)
fi
gh release create "v${VERSION}" \
--target "$RELEASE_SHA" \
--title "Buzz Desktop v${VERSION}" \
--notes "$NOTES" \
"${PRERELEASE_FLAGS[@]}"
- name: Create rolling auto-update release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create buzz-desktop-latest \
--prerelease \
--title "Buzz Desktop Auto-Update" \
--notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \
2>/dev/null || true
release: release:
name: Release name: Release
if: github.repository == 'block/buzz' if: github.repository == 'block/buzz'
@@ -99,7 +56,7 @@ jobs:
needs: setup needs: setup
timeout-minutes: 60 timeout-minutes: 60
permissions: permissions:
contents: write contents: read
id-token: write # required by block/apple-codesign-action for OIDC id-token: write # required by block/apple-codesign-action for OIDC
outputs: outputs:
archive_name: ${{ steps.artifacts.outputs.archive_name }} archive_name: ${{ steps.artifacts.outputs.archive_name }}
@@ -114,7 +71,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Verify tag-bound release source - name: Verify tag-bound release source
run: scripts/verify-release-ref.sh v "$VERSION" run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -272,13 +229,19 @@ jobs:
fi fi
echo "dmg=$DMG" >> "$GITHUB_OUTPUT" echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
# Find the updater .tar.gz and .sig # Find the updater .tar.gz and .sig. Give each architecture a unique
# release basename before artifacts are merged by the final writer.
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1) ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
SIG="${ARCHIVE}.sig" SIG="${ARCHIVE}.sig"
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
exit 1 exit 1
fi fi
RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_aarch64.app.tar.gz"
mv "$ARCHIVE" "$RENAMED"
mv "$SIG" "${RENAMED}.sig"
ARCHIVE="$RENAMED"
SIG="${RENAMED}.sig"
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
echo "sig=$SIG" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT"
@@ -289,23 +252,15 @@ jobs:
env: env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }} SIG_PATH: ${{ steps.artifacts.outputs.sig }}
- name: Upload arm64 DMG to versioned GitHub release - name: Stage Apple Silicon release artifacts
env: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with:
DMG_PATH: ${{ steps.artifacts.outputs.dmg }} name: desktop-release-macos-arm64
run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber if-no-files-found: error
path: |
- name: Upload updater archive to rolling release ${{ steps.artifacts.outputs.dmg }}
if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) ${{ steps.artifacts.outputs.archive }}
run: | ${{ steps.artifacts.outputs.sig }}
gh release upload buzz-desktop-latest \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
release-macos-x64: release-macos-x64:
name: Release macOS (Intel) name: Release macOS (Intel)
@@ -314,7 +269,7 @@ jobs:
needs: setup needs: setup
timeout-minutes: 60 timeout-minutes: 60
permissions: permissions:
contents: write contents: read
id-token: write # required by block/apple-codesign-action for OIDC id-token: write # required by block/apple-codesign-action for OIDC
outputs: outputs:
archive_name: ${{ steps.artifacts.outputs.archive_name }} archive_name: ${{ steps.artifacts.outputs.archive_name }}
@@ -330,7 +285,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Verify tag-bound release source - name: Verify tag-bound release source
run: scripts/verify-release-ref.sh v "$VERSION" run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -443,6 +398,11 @@ jobs:
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
exit 1 exit 1
fi fi
RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_x64.app.tar.gz"
mv "$ARCHIVE" "$RENAMED"
mv "$SIG" "${RENAMED}.sig"
ARCHIVE="$RENAMED"
SIG="${RENAMED}.sig"
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
echo "sig=$SIG" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT"
@@ -453,23 +413,15 @@ jobs:
env: env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }} SIG_PATH: ${{ steps.artifacts.outputs.sig }}
- name: Upload Intel DMG to versioned GitHub release - name: Stage Intel macOS release artifacts
run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
env: with:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} name: desktop-release-macos-x64
DMG_PATH: ${{ steps.unsigned.outputs.dmg }} if-no-files-found: error
path: |
- name: Upload updater archive to rolling release ${{ steps.unsigned.outputs.dmg }}
if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) ${{ steps.artifacts.outputs.archive }}
run: | ${{ steps.artifacts.outputs.sig }}
gh release upload buzz-desktop-latest \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
release-linux: release-linux:
name: Release Linux name: Release Linux
@@ -480,7 +432,7 @@ jobs:
needs: setup needs: setup
timeout-minutes: 60 timeout-minutes: 60
permissions: permissions:
contents: write contents: read
env: env:
# AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages.
# Containers lack FUSE, so we must use the extract-and-run fallback. # Containers lack FUSE, so we must use the extract-and-run fallback.
@@ -555,7 +507,7 @@ jobs:
- name: Verify tag-bound release source - name: Verify tag-bound release source
env: env:
VERSION: ${{ needs.setup.outputs.version }} VERSION: ${{ needs.setup.outputs.version }}
run: scripts/verify-release-ref.sh v "$VERSION" run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -689,29 +641,16 @@ jobs:
SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }}
# NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux) # NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux)
- name: Upload Linux artifacts to versioned GitHub release - name: Stage Linux release artifacts
env: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
VERSION: ${{ needs.setup.outputs.version }} with:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} name: desktop-release-linux-x64
DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }} if-no-files-found: error
APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }} path: |
run: | ${{ steps.linux-artifacts.outputs.deb }}
gh release upload "v$VERSION" \ ${{ steps.linux-artifacts.outputs.appimage }}
"$DEB_PATH" \ ${{ steps.linux-artifacts.outputs.archive }}
"$APPIMAGE_PATH" \ ${{ steps.linux-artifacts.outputs.sig }}
--clobber
- name: Upload updater archive to rolling release
if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version)
run: |
gh release upload buzz-desktop-latest \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }}
SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }}
release-windows: release-windows:
name: Release Windows name: Release Windows
@@ -719,7 +658,7 @@ jobs:
needs: setup needs: setup
timeout-minutes: 60 timeout-minutes: 60
permissions: permissions:
contents: write contents: read
outputs: outputs:
archive_name: ${{ steps.artifacts.outputs.archive_name }} archive_name: ${{ steps.artifacts.outputs.archive_name }}
sig: ${{ steps.read-sig.outputs.sig }} sig: ${{ steps.read-sig.outputs.sig }}
@@ -735,7 +674,7 @@ jobs:
- name: Verify tag-bound release source - name: Verify tag-bound release source
shell: bash shell: bash
run: scripts/verify-release-ref.sh v "$VERSION" run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with: with:
@@ -745,7 +684,7 @@ jobs:
with: with:
node-version: 24.14.1 node-version: 24.14.1
# Disable dependency caching: a writable cache in this release workflow # Disable dependency caching: a writable cache in this release workflow
# (contents: write, feeds a signed installer) is a poisoning vector. pnpm # (contents: read, feeds a signed installer) is a poisoning vector. pnpm
# install runs uncached below. # install runs uncached below.
package-manager-cache: false package-manager-cache: false
@@ -827,25 +766,14 @@ jobs:
env: env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }} SIG_PATH: ${{ steps.artifacts.outputs.sig }}
- name: Upload Windows installer to versioned GitHub release - name: Stage Windows release artifacts
shell: bash uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber with:
env: name: desktop-release-windows-x64
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} if-no-files-found: error
EXE_PATH: ${{ steps.artifacts.outputs.exe }} path: |
${{ steps.artifacts.outputs.exe }}
- name: Upload updater archive to rolling release ${{ steps.artifacts.outputs.sig }}
if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version)
shell: bash
run: |
gh release upload buzz-desktop-latest \
"$ARCHIVE_PATH" \
"$SIG_PATH" \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
assemble-manifest: assemble-manifest:
name: Assemble multi-platform latest.json name: Assemble multi-platform latest.json
@@ -853,7 +781,11 @@ jobs:
if: | if: |
always() && always() &&
needs.setup.result == 'success' && needs.setup.result == 'success' &&
github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) needs.release.result == 'success' &&
needs.release-macos-x64.result == 'success' &&
needs.release-linux.result == 'success' &&
needs.release-windows.result == 'success' &&
github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [setup, release, release-macos-x64, release-linux, release-windows] needs: [setup, release, release-macos-x64, release-linux, release-windows]
timeout-minutes: 10 timeout-minutes: 10
@@ -870,7 +802,26 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Verify tag-bound release source - name: Verify tag-bound release source
run: scripts/verify-release-ref.sh v "$VERSION" run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- name: Download staged release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: desktop-release-*
path: staged-by-platform
- name: Flatten staged artifacts without basename collisions
run: |
set -euo pipefail
mkdir staged
while IFS= read -r -d '' file; do
name="$(basename "$file")"
[[ ! -e "staged/$name" ]] || {
echo "::error::release artifact basename collision: $name"
exit 1
}
cp "$file" "staged/$name"
done < <(find staged-by-platform -type f -print0)
- name: Write signature files - name: Write signature files
env: env:
@@ -899,7 +850,7 @@ jobs:
write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX" write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX"
write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN" write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN"
- name: Verify archive URLs are accessible - name: Verify draft release has every updater archive
env: env:
RESULT_ARM64: ${{ needs.release.result }} RESULT_ARM64: ${{ needs.release.result }}
RESULT_X64: ${{ needs.release-macos-x64.result }} RESULT_X64: ${{ needs.release-macos-x64.result }}
@@ -911,39 +862,19 @@ jobs:
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
run: | run: |
set -euo pipefail set -euo pipefail
BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" assets=$(find staged -type f -exec basename {} \;)
ARCHIVES=() for spec in \
"$RESULT_ARM64:$ARCHIVE_ARM64" \
add_archive() { "$RESULT_X64:$ARCHIVE_X64" \
local result="$1" platform="$2" archive="$3" "$RESULT_LINUX:$ARCHIVE_LINUX" \
if [[ "$result" == "success" ]]; then "$RESULT_WIN:$ARCHIVE_WIN"; do
[[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; } result="${spec%%:*}"
ARCHIVES+=("$archive") archive="${spec#*:}"
fi if [[ "$result" == success ]]; then
} [[ -n "$archive" ]] || { echo "::error::successful platform has no archive"; exit 1; }
grep -Fxq "$archive" <<<"$assets" || { echo "::error::draft release missing $archive"; exit 1; }
add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64"
add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64"
add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX"
add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN"
for name in "${ARCHIVES[@]}"; do
echo "Checking $BASE/$name ..."
success=false
for attempt in 1 2 3; do
if curl -fsI "$BASE/$name" > /dev/null 2>&1; then
success=true
break
fi
echo "Attempt $attempt failed for $name, retrying in 10s..."
sleep 10
done
if [ "$success" != "true" ]; then
echo "::error::Archive not accessible after 3 attempts: $BASE/$name"
exit 1
fi fi
done done
echo "All archive URLs verified."
- name: Generate unified latest.json - name: Generate unified latest.json
env: env:
@@ -957,7 +888,7 @@ jobs:
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
run: | run: |
set -euo pipefail set -euo pipefail
BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}"
TRIPLES=() TRIPLES=()
add_triple() { add_triple() {
@@ -977,6 +908,45 @@ jobs:
bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json
cat latest.json cat latest.json
- name: Upload latest.json to rolling release - name: Create or verify versioned draft
run: | run: |
gh release upload buzz-desktop-latest latest.json --clobber set -euo pipefail
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found" CHANGELOG.md > "$NOTES_FILE"
[[ -s "$NOTES_FILE" ]] || { echo "::error::missing non-empty changelog block for v${VERSION}"; exit 1; }
PRERELEASE_FLAGS=()
if [[ "$VERSION" == *-* ]]; then
PRERELEASE_FLAGS=(--prerelease --latest=false)
fi
if gh release view "desktop-v${VERSION}" >/dev/null 2>&1; then
EXISTING_SHA=$(gh release view "desktop-v${VERSION}" --json targetCommitish --jq .targetCommitish)
IS_DRAFT=$(gh release view "desktop-v${VERSION}" --json isDraft --jq .isDraft)
[[ "$EXISTING_SHA" == "${{ needs.setup.outputs.source_sha }}" ]] || {
echo "::error::existing release targets $EXISTING_SHA, not the immutable source"; exit 1;
}
if [[ "$IS_DRAFT" != true ]]; then
echo "already_published=true" >> "$GITHUB_ENV"
fi
else
gh release create "desktop-v${VERSION}" \
--draft \
--target "${{ needs.setup.outputs.source_sha }}" \
--title "Buzz Desktop v${VERSION}" \
--notes-file "$NOTES_FILE" \
"${PRERELEASE_FLAGS[@]}"
fi
- name: Upload complete artifact set to versioned draft
if: env.already_published != 'true'
run: |
mapfile -t files < <(find staged -type f -print)
[[ "${#files[@]}" -gt 0 ]] || { echo "::error::no staged release artifacts"; exit 1; }
gh release upload "desktop-v${VERSION}" "${files[@]}" --clobber
- name: Publish complete versioned release
if: env.already_published != 'true'
run: gh release edit "desktop-v${VERSION}" --draft=false
- name: Upload latest.json to rolling release last
if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}
run: gh release upload buzz-desktop-latest latest.json --clobber

View File

@@ -725,7 +725,7 @@ bump-relay-version version:
cargo update -p buzz-relay cargo update -p buzz-relay
echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock" echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock"
# Open or update the desktop release PR (signed desktop app) # Open or update the desktop release PR from an immutable origin/main snapshot
release-desktop *ARGS: release-desktop *ARGS:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -735,7 +735,7 @@ release-desktop *ARGS:
else else
VERSION="$ARG" VERSION="$ARG"
fi fi
just _release-pr desktop "$VERSION" scripts/prepare-desktop-release.sh "$VERSION"
# Open or update the relay release PR (ghcr.io/block/buzz image) # Open or update the relay release PR (ghcr.io/block/buzz image)
release-relay *ARGS: release-relay *ARGS:

View File

@@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`:
| Lane | Entry point | Artifact | | Lane | Entry point | Artifact |
|------|-------------|----------| |------|-------------|----------|
| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) | | Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) |
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity |
@@ -31,7 +31,7 @@ just release-relay 0.4.0
scripts/mobile-release.sh candidate 0.5.0 scripts/mobile-release.sh candidate 0.5.0
``` ```
Desktop and relay releases use metadata PRs. Mobile does not. Each Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each
`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. `mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record.
There is no mobile release branch, stable mobile tag alias, finalization step, There is no mobile release branch, stable mobile tag alias, finalization step,
or mobile GitHub Release. or mobile GitHub Release.
@@ -42,12 +42,11 @@ or mobile GitHub Release.
### Desktop ### Desktop
1. **`just release-desktop`** runs locally on `main`, creates or updates a 1. Run **Prepare Desktop Release** with a version (or `just release-desktop <version>`). Automation records current `origin/main`, regenerates `version-bump/<version>` as one deterministic candidate commit, and opens or updates the PR.
`version-bump/<version>` PR, bumps the desktop manifests, regenerates 2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval.
lockfiles, and updates `CHANGELOG.md`. 3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs.
2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v<version>`. 4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v<version>`.
3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and 5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions.
publishes the desktop app for macOS and Linux.
### Relay ### Relay
@@ -147,8 +146,8 @@ for distributable builds or builds from an immutable release tag.
## Manual Release Retry ## Manual Release Retry
The **Release** workflow's manual dispatch is only a retry mechanism for an The **Release** workflow's manual dispatch is only a retry mechanism for an
existing immutable `v<version>` tag. Select that tag in the ref picker and existing immutable `desktop-v<version>` tag. Select that tag in the ref picker and
provide the matching semver version without the `v` prefix. It cannot build provide the matching semver version without the `desktop-v` prefix. It cannot build
from `main` or another caller-selected source ref. from `main` or another caller-selected source ref.
Mobile intentionally has no branch or arbitrary-ref fallback. The private Mobile intentionally has no branch or arbitrary-ref fallback. The private
@@ -171,7 +170,7 @@ for the private pipeline contract.
Desktop publishes two GitHub releases: Desktop publishes two GitHub releases:
1. **`v<version>`**: the user-facing release with installers. 1. **`desktop-v<version>`**: the user-facing release with installers.
2. **`buzz-desktop-latest`**: the rolling auto-updater release. 2. **`buzz-desktop-latest`**: the rolling auto-updater release.
Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts
@@ -186,7 +185,7 @@ The release workflow builds **two separate macOS DMGs**: Apple
Silicon (`darwin-aarch64`, the `release` job) and Intel Silicon (`darwin-aarch64`, the `release` job) and Intel
(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and (`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and
`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to `.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to
the same `v<version>` release. Intel users download the `_x64.dmg`. the same `desktop-v<version>` release. Intel users download the `_x64.dmg`.
The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`,
which strips infra libraries over-bundled by linuxdeploy (they crash on which strips infra libraries over-bundled by linuxdeploy (they crash on

214
scripts/desktop_release.py Executable file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Generate and validate immutable desktop release candidates."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CHANGELOG = ROOT / "CHANGELOG.md"
METADATA = ROOT / ".release" / "desktop-candidate.json"
SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")
DESKTOP_PATHS = (
"desktop/",
"crates/buzz-core/",
"crates/buzz-persona/",
"crates/buzz-sdk/",
"crates/buzz-agent/",
"crates/buzz-media/",
)
CANDIDATE_FILES = {
".release/desktop-candidate.json",
"CHANGELOG.md",
"desktop/package.json",
"desktop/src-tauri/tauri.conf.json",
"desktop/src-tauri/Cargo.toml",
"desktop/src-tauri/Cargo.lock",
"pnpm-lock.yaml",
}
REQUIRED_CANDIDATE_FILES = {
".release/desktop-candidate.json",
"CHANGELOG.md",
"desktop/package.json",
"desktop/src-tauri/tauri.conf.json",
"desktop/src-tauri/Cargo.toml",
}
def git(*args: str) -> str:
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[dict[str, str]]:
args = ["log", range_spec, "--no-merges", "--format=%H%x00%s"]
if paths:
args += ["--", *paths]
out = git(*args)
if not out:
return []
return [dict(zip(("sha", "subject"), line.split("\0", 1))) for line in out.splitlines()]
def stable_tags(base_sha: str) -> list[tuple[int, str, str]]:
tags: list[tuple[int, str, str]] = []
for tag in git("tag", "--merged", base_sha, "--list").splitlines():
if not re.fullmatch(r"(?:desktop-)?v[0-9]+\.[0-9]+\.[0-9]+", tag):
continue
sha = git("rev-list", "-n", "1", tag)
distance = int(git("rev-list", "--count", f"{sha}..{base_sha}"))
tags.append((distance, tag, sha))
return tags
def previous_tag(base_sha: str) -> str:
tags = stable_tags(base_sha)
if not tags:
return ""
min_distance = min(item[0] for item in tags)
nearest = [item for item in tags if item[0] == min_distance]
commits = {item[2] for item in nearest}
if len(commits) != 1:
detail = ", ".join(f"{tag}@{sha}" for _, tag, sha in nearest)
raise SystemExit(f"ambiguous previous desktop release tags: {detail}")
# During migration, prefer the namespaced tag when aliases share a commit.
nearest.sort(key=lambda item: (not item[1].startswith("desktop-v"), item[1]))
return nearest[0][1]
def bullet(commit: dict[str, str], repo: str) -> str:
sha, subject = commit["sha"], commit["subject"]
short = sha[:12]
pr_match = re.search(r" \(#([0-9]+)\)$", subject)
if pr_match:
pr = pr_match.group(1)
subject = subject[: pr_match.start()]
return f"- {subject} ([#{pr}](https://github.com/{repo}/pull/{pr})) ([`{sha}`](https://github.com/{repo}/commit/{sha}))"
return f"- {subject} ([`{sha}`](https://github.com/{repo}/commit/{sha}))"
def expected(base_sha: str, previous: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
# With no prior desktop tag, account for the repository's root commit too.
# A ``root..base`` range silently drops that first commit.
range_spec = f"{previous}..{base_sha}" if previous else base_sha
all_commits = commit_list(range_spec)
relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)}
relevant = [c for c in all_commits if c["sha"] in relevant_shas]
other = [c for c in all_commits if c["sha"] not in relevant_shas]
return relevant, other
def render(version: str, base_sha: str, previous: str, repo: str) -> tuple[str, list[str]]:
relevant, other = expected(base_sha, previous)
lines = [f"## v{version}", "", "### Desktop and shared changes", ""]
lines += [bullet(c, repo) for c in relevant] or ["- None"]
lines += ["", "### Other repository changes", ""]
lines += [bullet(c, repo) for c in other] or ["- None"]
compare_start = previous or git("rev-list", "--max-parents=0", base_sha).splitlines()[0]
lines += ["", f"[Compare {compare_start}...desktop-v{version}](https://github.com/{repo}/compare/{compare_start}...desktop-v{version})"]
return "\n".join(lines) + "\n", [c["sha"] for c in relevant + other]
def generate(args: argparse.Namespace) -> None:
if not SEMVER.fullmatch(args.version):
raise SystemExit(f"invalid semver: {args.version}")
base_sha = git("rev-parse", args.base)
previous = previous_tag(base_sha)
repo = args.repo or re.sub(r".*github\.com[:/]", "", git("remote", "get-url", "origin")).removesuffix(".git")
block, commits = render(args.version, base_sha, previous, repo)
old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n"
if not old.startswith("# Changelog"):
raise SystemExit("CHANGELOG.md must begin with '# Changelog'")
remainder = old.split("\n", 1)[1].lstrip("\n") if "\n" in old else ""
CHANGELOG.write_text(f"# Changelog\n\n{block}\n{remainder}")
METADATA.parent.mkdir(parents=True, exist_ok=True)
METADATA.write_text(json.dumps({
"schema": 1,
"version": args.version,
"base_sha": base_sha,
"previous_tag": previous or None,
"tag": f"desktop-v{args.version}",
"commit_count": len(commits),
}, indent=2) + "\n")
def validate(args: argparse.Namespace) -> None:
data = json.loads(METADATA.read_text())
version = args.version or data["version"]
if data != {**data, "version": version}:
raise SystemExit("candidate version does not match metadata")
if data["tag"] != f"desktop-v{version}":
raise SystemExit("candidate tag does not match version")
candidate = git("rev-parse", args.candidate)
parents = git("show", "-s", "--format=%P", candidate).split()
if len(parents) != 1 or parents[0] != data["base_sha"]:
raise SystemExit("candidate must be one commit directly above recorded base_sha")
changed = set(git("diff-tree", "--no-commit-id", "--name-only", "-r", candidate).splitlines())
unexpected = changed - CANDIDATE_FILES
missing = REQUIRED_CANDIDATE_FILES - changed
if unexpected or missing:
detail = []
if unexpected:
detail.append(f"unexpected files: {', '.join(sorted(unexpected))}")
if missing:
detail.append(f"missing required files: {', '.join(sorted(missing))}")
raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")")
previous = data["previous_tag"] or ""
actual_previous = previous_tag(data["base_sha"])
if previous != actual_previous:
raise SystemExit(
f"recorded previous tag {previous or '<none>'} does not match "
f"nearest release tag {actual_previous or '<none>'}"
)
repo = args.repo or "block/buzz"
expected_block, shas = render(version, data["base_sha"], previous, repo)
text = CHANGELOG.read_text()
blocks = re.findall(rf"(?ms)^## v{re.escape(version)}\n.*?(?=^## v|\Z)", text)
if len(blocks) != 1:
raise SystemExit(f"expected exactly one changelog block for v{version}")
if blocks[0].rstrip() != expected_block.rstrip():
raise SystemExit("changelog block is not deterministic for recorded candidate base")
found = re.findall(r"\[`([0-9a-f]{40})`\]", blocks[0])
if len(found) != len(set(found)) or set(found) != set(shas) or len(found) != data["commit_count"]:
raise SystemExit("changelog does not account for every expected non-merge commit exactly once")
manifests = {
ROOT / "desktop/package.json": json.loads((ROOT / "desktop/package.json").read_text())["version"],
ROOT / "desktop/src-tauri/tauri.conf.json": json.loads((ROOT / "desktop/src-tauri/tauri.conf.json").read_text())["version"],
}
cargo = re.search(r'(?m)^version = "([^"]+)"', (ROOT / "desktop/src-tauri/Cargo.toml").read_text())
manifests[ROOT / "desktop/src-tauri/Cargo.toml"] = cargo.group(1) if cargo else ""
bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version]
if bad:
raise SystemExit(f"version mismatch in: {', '.join(bad)}")
author = git("show", "-s", "--format=%an <%ae>", candidate)
body = git("show", "-s", "--format=%B", candidate)
if author != "Wes <wesbillman@users.noreply.github.com>":
raise SystemExit(f"unexpected candidate author: {author}")
if "Signed-off-by: Wes <wesbillman@users.noreply.github.com>" not in body:
raise SystemExit("candidate is missing Wes Signed-off-by trailer")
if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body):
raise SystemExit("candidate is missing automation Co-authored-by trailer")
print(f"validated immutable desktop candidate {candidate} for desktop-v{version}")
def main() -> None:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
gen = sub.add_parser("generate")
gen.add_argument("version")
gen.add_argument("--base", required=True)
gen.add_argument("--repo")
val = sub.add_parser("validate")
val.add_argument("--candidate", default="HEAD")
val.add_argument("--version")
val.add_argument("--repo")
args = parser.parse_args()
generate(args) if args.command == "generate" else validate(args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
version="${1:-}"
mode="${2:-publish}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || {
echo "usage: $0 <semver> [publish|validate-only]" >&2
exit 1
}
remote="${RELEASE_REMOTE:-origin}"
git fetch "$remote" refs/heads/main:refs/remotes/origin/main --no-tags
git fetch "$remote" '+refs/tags/v*:refs/tags/v*' '+refs/tags/desktop-v*:refs/tags/desktop-v*'
base_sha="$(git rev-parse refs/remotes/origin/main)"
branch="version-bump/$version"
remote_branch="refs/heads/$branch"
remote_oid=""
if remote_oid="$(git ls-remote "$remote" "$remote_branch" | awk '{print $1}')" && [[ -n "$remote_oid" ]]; then
git fetch "$remote" "$remote_branch:refs/remotes/origin/$branch"
fi
git checkout -B "$branch" "$base_sha"
just bump-desktop-version "$version"
scripts/desktop_release.py generate "$version" --base "$base_sha" --repo block/buzz
git add \
.release/desktop-candidate.json \
CHANGELOG.md \
desktop/package.json \
desktop/src-tauri/tauri.conf.json \
desktop/src-tauri/Cargo.toml \
desktop/src-tauri/Cargo.lock \
pnpm-lock.yaml
agent_name="${RELEASE_AUTOMATION_NAME:-${AGENT_NAME:-Release Automation}}"
agent_email="${RELEASE_AUTOMATION_EMAIL:-${AGENT_EMAIL:-release-automation@users.noreply.github.com}}"
msg="$(mktemp)"
trap 'rm -f "$msg"' EXIT
cat >"$msg" <<EOF
chore(release): release Buzz Desktop version $version
Co-authored-by: $agent_name <$agent_email>
EOF
git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \
commit -s -F "$msg"
scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz
candidate_sha="$(git rev-parse HEAD)"
previous_tag="$(python3 -c 'import json; print(json.load(open(".release/desktop-candidate.json"))["previous_tag"] or "initial")')"
printf 'base_sha=%s\ncandidate_sha=%s\nprevious_tag=%s\ntag=desktop-v%s\n' \
"$base_sha" "$candidate_sha" "$previous_tag" "$version"
if [[ "$mode" == validate-only ]]; then
exit 0
fi
[[ "$mode" == publish ]] || { echo "unknown mode: $mode" >&2; exit 1; }
if [[ -n "$remote_oid" ]]; then
git push --force-with-lease="$remote_branch:$remote_oid" "$remote" "HEAD:$remote_branch"
else
git push --force-with-lease="$remote_branch:" "$remote" "HEAD:$remote_branch"
fi
body="$(mktemp)"
trap 'rm -f "$msg" "$body"' EXIT
cat >"$body" <<EOF
## Buzz Desktop release v$version
- **Frozen main:** \`$base_sha\`
- **Reviewed candidate:** \`$candidate_sha\`
- **Previous desktop release:** \`$previous_tag\`
- **Proposed immutable tag:** \`desktop-v$version\`
This PR must be merged with **Create a merge commit**. Squash/rebase, stale-head approval, incomplete notes, or a candidate mismatch produce no tag.
The checked-in changelog accounts for every non-merge commit in the release range. Publication remains bound to the immutable candidate tag.
EOF
if existing="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')" && [[ -n "$existing" ]]; then
gh pr edit "$existing" --title "chore(release): release Buzz Desktop version $version" --body-file "$body"
else
gh pr create --base main --head "$branch" \
--title "chore(release): release Buzz Desktop version $version" --body-file "$body"
fi

View File

@@ -0,0 +1,14 @@
# GitHub treats success, skipped, and neutral as successful conclusions for
# required checks. Evaluate the newest run so a stale pass cannot mask a rerun.
[
.[].check_runs[]
| select(.name == $name)
]
| sort_by(.started_at // .created_at // "")
| last
| .status == "completed"
and (
.conclusion == "success"
or .conclusion == "skipped"
or .conclusion == "neutral"
)

View File

@@ -0,0 +1 @@
.reviewDecision == "APPROVED"

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cp "$repo_root/scripts/desktop_release.py" "$tmp/desktop_release.py"
git -C "$tmp" init -q
git -C "$tmp" config user.name test
git -C "$tmp" config user.email test@example.com
mkdir -p "$tmp/scripts" "$tmp/desktop/src-tauri" "$tmp/crates/buzz-core" "$tmp/.release"
mv "$tmp/desktop_release.py" "$tmp/scripts/desktop_release.py"
printf '{"version":"1.0.0"}\n' > "$tmp/desktop/package.json"
printf '{"version":"1.0.0"}\n' > "$tmp/desktop/src-tauri/tauri.conf.json"
printf '[package]\nversion = "1.0.0"\n' > "$tmp/desktop/src-tauri/Cargo.toml"
echo '# Changelog' > "$tmp/CHANGELOG.md"
echo first > "$tmp/desktop/feature"
git -C "$tmp" add .
git -C "$tmp" commit -qm 'feat: first desktop change'
git -C "$tmp" -c tag.gpgSign=false tag v1.0.0
echo second >> "$tmp/desktop/feature"
git -C "$tmp" commit -qam 'fix: desktop fix'
echo policy > "$tmp/POLICY.md"
git -C "$tmp" add POLICY.md
git -C "$tmp" commit -qm 'docs: repository policy'
base=$(git -C "$tmp" rev-parse HEAD)
(
cd "$tmp"
scripts/desktop_release.py generate 1.0.1 --base "$base" --repo block/buzz
python3 - <<'PY'
import json
for path in ('desktop/package.json', 'desktop/src-tauri/tauri.conf.json'):
data=json.load(open(path)); data['version']='1.0.1'; open(path,'w').write(json.dumps(data)+'\n')
p='desktop/src-tauri/Cargo.toml'; open(p,'w').write('[package]\nversion = "1.0.1"\n')
PY
rm -f msg
git add .
cat >msg <<'EOF'
chore(release): release Buzz Desktop version 1.0.1
Co-authored-by: Test Automation <test@example.com>
EOF
git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -F msg
rm msg
scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz
grep -Fq '### Other repository changes' CHANGELOG.md
grep -Fq "$(git rev-parse HEAD~1)" CHANGELOG.md
grep -Fq "$(git rev-parse HEAD~2)" CHANGELOG.md
# Metadata cannot lie about the prior release boundary.
cp .release/desktop-candidate.json metadata.json
python3 - <<'PY'
import json
p='.release/desktop-candidate.json'; d=json.load(open(p)); d['previous_tag']=None; open(p,'w').write(json.dumps(d)+'\n')
PY
if scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then
echo "validator accepted a forged previous release tag" >&2
exit 1
fi
mv metadata.json .release/desktop-candidate.json
)
# An initial release must account for the root commit, not silently omit it.
initial=$(mktemp -d)
cp "$repo_root/scripts/desktop_release.py" "$initial/desktop_release.py"
git -C "$initial" init -q
git -C "$initial" config user.name test
git -C "$initial" config user.email test@example.com
mkdir -p "$initial/scripts" "$initial/desktop/src-tauri"
mv "$initial/desktop_release.py" "$initial/scripts/desktop_release.py"
printf '{"version":"0.1.0"}\n' > "$initial/desktop/package.json"
printf '{"version":"0.1.0"}\n' > "$initial/desktop/src-tauri/tauri.conf.json"
printf '[package]\nversion = "0.1.0"\n' > "$initial/desktop/src-tauri/Cargo.toml"
printf '# Changelog\n' > "$initial/CHANGELOG.md"
echo root > "$initial/ROOT.md"
git -C "$initial" add .
git -C "$initial" commit -qm 'feat: root release content'
root_sha=$(git -C "$initial" rev-parse HEAD)
(cd "$initial" && scripts/desktop_release.py generate 0.1.0 --base "$root_sha" --repo block/buzz)
grep -Fq "$root_sha" "$initial/CHANGELOG.md"
rm -rf "$initial"
echo "desktop release candidate contract passed"

View File

@@ -12,16 +12,16 @@ git -C "$tmp" config user.email test@example.com
echo first >"$tmp/file" echo first >"$tmp/file"
git -C "$tmp" add file git -C "$tmp" add file
git -C "$tmp" commit -qm first git -C "$tmp" commit -qm first
git -C "$tmp" tag -m "desktop release" v1.2.3 git -C "$tmp" tag -m "desktop release" desktop-v1.2.3
( (
cd "$tmp" cd "$tmp"
GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3
) )
if ( if (
cd "$tmp" cd "$tmp"
GITHUB_REF=refs/heads/main "$verify" v 1.2.3 GITHUB_REF=refs/heads/main "$verify" desktop-v 1.2.3
); then ); then
echo "branch-backed desktop release was accepted" >&2 echo "branch-backed desktop release was accepted" >&2
exit 1 exit 1
@@ -31,7 +31,7 @@ echo second >>"$tmp/file"
git -C "$tmp" commit -qam second git -C "$tmp" commit -qam second
if ( if (
cd "$tmp" cd "$tmp"
GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3
); then ); then
echo "release accepted HEAD after the tag commit" >&2 echo "release accepted HEAD after the tag commit" >&2
exit 1 exit 1
@@ -61,6 +61,73 @@ grep -q 'private-key:.*secrets\.BUZZ_RELEASE_TAGGER_PRIVATE_KEY' "$auto_tag"
grep -q 'permission-contents: write' "$auto_tag" grep -q 'permission-contents: write' "$auto_tag"
grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag" grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag"
grep -Fq 'git/refs' "$auto_tag" grep -Fq 'git/refs' "$auto_tag"
grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag"
grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag"
grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag"
review_filter="$repo_root/scripts/review-decision-approved.jq"
for fixture in \
'{"reviewDecision":"CHANGES_REQUESTED"}' \
'{"reviewDecision":"REVIEW_REQUIRED"}' \
'{"reviewDecision":null}' \
'{}'; do
if jq -e -f "$review_filter" <<<"$fixture" >/dev/null; then
echo "review-decision filter accepted non-approved fixture: $fixture" >&2
exit 1
fi
done
jq -e -f "$review_filter" >/dev/null <<'JSON' || {
{"reviewDecision":"APPROVED"}
JSON
echo "review-decision filter rejected approved GraphQL response" >&2
exit 1
}
required_check_filter="$repo_root/scripts/required-check-succeeded.jq"
check_fixture() {
local expected="$1" conclusion="$2" status="${3:-completed}"
local payload
payload=$(jq -n --arg status "$status" --arg conclusion "$conclusion" '{check_runs: [{name: "Web", status: $status, conclusion: $conclusion, started_at: "2026-01-01T00:00:00Z"}]}')
if jq -e --arg name Web -f "$required_check_filter" <<<"[$payload]" >/dev/null; then
actual=pass
else
actual=fail
fi
[[ "$actual" == "$expected" ]] || {
echo "required-check filter: expected $conclusion/$status to $expected" >&2
exit 1
}
}
check_fixture pass success
check_fixture pass skipped
check_fixture pass neutral
check_fixture fail failure
check_fixture fail success in_progress
# A newer failure must not be hidden by an older successful run of the same check.
jq -e --arg name Web -f "$required_check_filter" >/dev/null <<'JSON' && {
[{"check_runs":[
{"name":"Web","status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z"},
{"name":"Web","status":"completed","conclusion":"failure","started_at":"2026-01-02T00:00:00Z"}
]}]
JSON
echo "required-check filter accepted a stale pass over a newer failure" >&2
exit 1
}
release_workflow="$repo_root/.github/workflows/release.yml"
[[ "$(grep -c 'contents: write' "$release_workflow")" -eq 1 ]] || {
echo "desktop release must have exactly one GitHub contents writer" >&2; exit 1;
}
grep -Fq "needs.release.result == 'success'" "$release_workflow"
grep -Fq "needs.release-macos-x64.result == 'success'" "$release_workflow"
grep -Fq "needs.release-linux.result == 'success'" "$release_workflow"
grep -Fq "needs.release-windows.result == 'success'" "$release_workflow"
grep -Fq "refs/tags/desktop-v{0}" "$release_workflow"
grep -Fq "if: \${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}" "$release_workflow"
grep -Fq 'group: desktop-release-${{ github.ref }}' "$release_workflow"
grep -Fq 'cancel-in-progress: false' "$release_workflow"
grep -Fq 'release artifact basename collision' "$release_workflow"
[[ "$(grep -c 'gh release upload' "$release_workflow")" -eq 2 ]] || {
echo "only the final writer may upload versioned and rolling release assets" >&2; exit 1;
}
grep -Fq 'if: env.already_published' "$release_workflow"
grep -Fq 'if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then' "$auto_tag" grep -Fq 'if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then' "$auto_tag"
if grep -F 'git/ref/tags/$TAG' "$auto_tag" | grep -Fq '|| true'; then if grep -F 'git/ref/tags/$TAG' "$auto_tag" | grep -Fq '|| true'; then
echo "auto-tag ignores a failed tag lookup, so a 404 body can look like an existing tag" >&2 echo "auto-tag ignores a failed tag lookup, so a 404 body can look like an existing tag" >&2

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PR_HEAD_SHA:?}"
: "${MERGE_SHA:?}"
: "${VERSION:?}"
: "${PR_NUMBER:?}"
: "${GH_TOKEN:?}"
required_checks=(
"Desktop E2E Integration"
"Desktop"
"Rust Lint"
"Security"
"Unit Tests"
"Windows Rust (x86_64-pc-windows-msvc)"
"Mobile"
"Web"
"Backend Integration (relay e2e)"
"Desktop E2E Relay"
"Relay E2E"
"Desktop Build (macOS)"
"DCO Check"
)
expected_branch="version-bump/$VERSION"
[[ "${PR_HEAD_REF:-}" == "$expected_branch" ]] || { echo "unexpected release branch" >&2; exit 1; }
[[ "${PR_BASE_REF:-}" == main ]] || { echo "desktop release must target main" >&2; exit 1; }
[[ "${PR_HEAD_REPO:-}" == "$GITHUB_REPOSITORY" ]] || { echo "desktop release must be internal" >&2; exit 1; }
git fetch origin "$MERGE_SHA" "$PR_HEAD_SHA" refs/heads/main:refs/remotes/origin/main --no-tags
mapfile -t parents < <(git show -s --format='%P' "$MERGE_SHA" | tr ' ' '\n')
[[ "${#parents[@]}" -eq 2 ]] || { echo "desktop release was not merged with a true merge commit" >&2; exit 1; }
[[ "${parents[1]}" == "$PR_HEAD_SHA" ]] || { echo "merge parent 2 is not the reviewed candidate" >&2; exit 1; }
git merge-base --is-ancestor "$PR_HEAD_SHA" origin/main || { echo "candidate is not reachable from current main" >&2; exit 1; }
git checkout --detach "$PR_HEAD_SHA"
scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY"
review=$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest')
jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || {
echo "pull request effective review decision is not APPROVED" >&2
exit 1
}
reviews="$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")"
valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")"
[[ "$valid_approvals" -gt 0 ]] || { echo "candidate lacks an exact-head approval from a repository member or collaborator" >&2; exit 1; }
checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")"
for required in "${required_checks[@]}"; do
jq -e --arg name "$required" -f scripts/required-check-succeeded.jq <<<"$checks" >/dev/null || {
echo "required check is missing or unsuccessful: $required" >&2
exit 1
}
done
status="$(gh api "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/status")"
jq -e '(.total_count == 0) or (.state == "success")' <<<"$status" >/dev/null || {
echo "candidate has a failing or pending combined commit status" >&2
exit 1
}
echo "verified reviewed desktop candidate $PR_HEAD_SHA at merge $MERGE_SHA"