diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index db34fddc2c..a69eafb404 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -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 # a chart feature PR can publish its own new version when merged: # -# version-bump/ → tag v → release.yml (desktop app) +# version-bump/ → tag desktop-v → release.yml (desktop app) # relay-release/ → tag relay-v → docker.yml (relay image) # chart-release/ → tag chart-v → helm-chart.yml (main helm chart) # push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml @@ -35,6 +35,11 @@ permissions: jobs: auto-tag: + permissions: + contents: read + pull-requests: read + checks: read + statuses: read if: > github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository @@ -57,7 +62,7 @@ jobs: case "$BRANCH" in version-bump/*) VERSION="${BRANCH#version-bump/}" - TAG_PREFIX="v" ;; + TAG_PREFIX="desktop-v" ;; relay-release/*) VERSION="${BRANCH#relay-release/}" TAG_PREFIX="relay-v" ;; @@ -85,9 +90,34 @@ jobs: { echo "enabled=true" 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" 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 if: steps.release.outputs.enabled == 'true' id: release-tagger @@ -102,21 +132,22 @@ jobs: env: GH_TOKEN: ${{ steps.release-tagger.outputs.token }} TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} run: | set -euo pipefail # 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. 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)" - if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then - echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation" exit 0 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 fi fi gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ - -f sha="$GITHUB_SHA" \ + -f sha="$TARGET_SHA" \ --silent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4826d985f..59d63f28da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Desktop release candidate contract + run: scripts/test-desktop-release-candidate.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml new file mode 100644 index 0000000000..7cc480b93b --- /dev/null +++ b/.github/workflows/prepare-desktop-release.yml @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b87e9c8c08..07951ef81d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,13 @@ name: Release +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + on: push: tags: - - 'v[0-9]*' - workflow_dispatch: - inputs: - version: - description: "Semver version matching the v-prefixed dispatch tag" - required: true + - 'desktop-v[0-9]*' jobs: # Shared setup: verify the immutable release tag, determine the version, and @@ -19,23 +18,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write + contents: read outputs: version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.source.outputs.source_sha }} 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" + run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT" - name: Validate version env: @@ -56,42 +46,9 @@ jobs: env: VERSION: ${{ steps.version.outputs.version }} 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" - - 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: name: Release if: github.repository == 'block/buzz' @@ -99,7 +56,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -114,7 +71,7 @@ jobs: persist-credentials: false - 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 @@ -272,13 +229,19 @@ jobs: fi 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) SIG="${ARCHIVE}.sig" if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 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_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -289,23 +252,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload arm64 DMG to versioned GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.artifacts.outputs.dmg }} - run: gh release upload "v${VERSION}" "$DMG_PATH" --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.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Apple Silicon release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-arm64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-macos-x64: name: Release macOS (Intel) @@ -314,7 +269,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -330,7 +285,7 @@ jobs: persist-credentials: false - 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 @@ -443,6 +398,11 @@ jobs: echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 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_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -453,23 +413,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Intel DMG to versioned GitHub release - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.unsigned.outputs.dmg }} - - - 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.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Intel macOS release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-x64 + if-no-files-found: error + path: | + ${{ steps.unsigned.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-linux: name: Release Linux @@ -480,7 +432,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read env: # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. # Containers lack FUSE, so we must use the extract-and-run fallback. @@ -555,7 +507,7 @@ jobs: - name: Verify tag-bound release source env: 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 @@ -689,29 +641,16 @@ jobs: SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} # NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux) - - name: Upload Linux artifacts to versioned GitHub release - env: - VERSION: ${{ needs.setup.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 - - - 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 }} + - name: Stage Linux release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-linux-x64 + if-no-files-found: error + path: | + ${{ steps.linux-artifacts.outputs.deb }} + ${{ steps.linux-artifacts.outputs.appimage }} + ${{ steps.linux-artifacts.outputs.archive }} + ${{ steps.linux-artifacts.outputs.sig }} release-windows: name: Release Windows @@ -719,7 +658,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} sig: ${{ steps.read-sig.outputs.sig }} @@ -735,7 +674,7 @@ jobs: - name: Verify tag-bound release source 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 with: @@ -745,7 +684,7 @@ jobs: with: node-version: 24.14.1 # 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. package-manager-cache: false @@ -827,25 +766,14 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Windows installer to versioned GitHub release - shell: bash - run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXE_PATH: ${{ steps.artifacts.outputs.exe }} - - - name: Upload updater archive to rolling release - 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 }} + - name: Stage Windows release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-windows-x64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.exe }} + ${{ steps.artifacts.outputs.sig }} assemble-manifest: name: Assemble multi-platform latest.json @@ -853,7 +781,11 @@ jobs: if: | always() && 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 needs: [setup, release, release-macos-x64, release-linux, release-windows] timeout-minutes: 10 @@ -870,7 +802,26 @@ jobs: persist-credentials: false - 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 env: @@ -899,7 +850,7 @@ jobs: write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX" write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN" - - name: Verify archive URLs are accessible + - name: Verify draft release has every updater archive env: RESULT_ARM64: ${{ needs.release.result }} RESULT_X64: ${{ needs.release-macos-x64.result }} @@ -911,39 +862,19 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" - ARCHIVES=() - - add_archive() { - local result="$1" platform="$2" archive="$3" - if [[ "$result" == "success" ]]; then - [[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; } - ARCHIVES+=("$archive") - fi - } - - 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 + assets=$(find staged -type f -exec basename {} \;) + for spec in \ + "$RESULT_ARM64:$ARCHIVE_ARM64" \ + "$RESULT_X64:$ARCHIVE_X64" \ + "$RESULT_LINUX:$ARCHIVE_LINUX" \ + "$RESULT_WIN:$ARCHIVE_WIN"; do + result="${spec%%:*}" + archive="${spec#*:}" + 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; } fi done - echo "All archive URLs verified." - name: Generate unified latest.json env: @@ -957,7 +888,7 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | 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=() add_triple() { @@ -977,6 +908,45 @@ jobs: bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json - - name: Upload latest.json to rolling release + - name: Create or verify versioned draft 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 diff --git a/Justfile b/Justfile index a2fa408e7f..2d76f1a7b9 100644 --- a/Justfile +++ b/Justfile @@ -725,7 +725,7 @@ bump-relay-version version: cargo update -p buzz-relay 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: #!/usr/bin/env bash set -euo pipefail @@ -735,7 +735,7 @@ release-desktop *ARGS: else VERSION="$ARG" fi - just _release-pr desktop "$VERSION" + scripts/prepare-desktop-release.sh "$VERSION" # Open or update the relay release PR (ghcr.io/block/buzz image) release-relay *ARGS: diff --git a/RELEASING.md b/RELEASING.md index 063b813e2c..45f0f8638f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | 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 | | 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 ``` -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. There is no mobile release branch, stable mobile tag alias, finalization step, or mobile GitHub Release. @@ -42,12 +42,11 @@ or mobile GitHub Release. ### Desktop -1. **`just release-desktop`** runs locally on `main`, creates or updates a - `version-bump/` PR, bumps the desktop manifests, regenerates - lockfiles, and updates `CHANGELOG.md`. -2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v`. -3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and - publishes the desktop app for macOS and Linux. +1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR. +2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval. +3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs. +4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`. +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. ### Relay @@ -147,8 +146,8 @@ for distributable builds or builds from an immutable release tag. ## Manual Release Retry The **Release** workflow's manual dispatch is only a retry mechanism for an -existing immutable `v` tag. Select that tag in the ref picker and -provide the matching semver version without the `v` prefix. It cannot build +existing immutable `desktop-v` tag. Select that tag in the ref picker and +provide the matching semver version without the `desktop-v` prefix. It cannot build from `main` or another caller-selected source ref. 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: -1. **`v`**: the user-facing release with installers. +1. **`desktop-v`**: the user-facing release with installers. 2. **`buzz-desktop-latest`**: the rolling auto-updater release. 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 (`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and `.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to -the same `v` release. Intel users download the `_x64.dmg`. +the same `desktop-v` release. Intel users download the `_x64.dmg`. The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, which strips infra libraries over-bundled by linuxdeploy (they crash on diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py new file mode 100755 index 0000000000..d6518b26b1 --- /dev/null +++ b/scripts/desktop_release.py @@ -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 ''} does not match " + f"nearest release tag {actual_previous or ''}" + ) + 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 ": + raise SystemExit(f"unexpected candidate author: {author}") + if "Signed-off-by: Wes " 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() diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh new file mode 100755 index 0000000000..c477a84cda --- /dev/null +++ b/scripts/prepare-desktop-release.sh @@ -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 [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 +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" < "$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 +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" diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 0d810819d9..bd4eb75275 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -12,16 +12,16 @@ git -C "$tmp" config user.email test@example.com echo first >"$tmp/file" git -C "$tmp" add file 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" - 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 ( cd "$tmp" - GITHUB_REF=refs/heads/main "$verify" v 1.2.3 + GITHUB_REF=refs/heads/main "$verify" desktop-v 1.2.3 ); then echo "branch-backed desktop release was accepted" >&2 exit 1 @@ -31,7 +31,7 @@ echo second >>"$tmp/file" git -C "$tmp" commit -qam second if ( 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 echo "release accepted HEAD after the tag commit" >&2 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 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$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" 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 diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh new file mode 100755 index 0000000000..17bf2f4dcb --- /dev/null +++ b/scripts/verify-desktop-release-merge.sh @@ -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"