mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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/<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)
|
||||
# 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
+144
-174
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+11
-12
@@ -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/<version>` PR, bumps the desktop manifests, regenerates
|
||||
lockfiles, and updates `CHANGELOG.md`.
|
||||
2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v<version>`.
|
||||
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 <version>`). Automation records current `origin/main`, regenerates `version-bump/<version>` 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<version>`.
|
||||
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<version>` 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<version>` 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<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.
|
||||
|
||||
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<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`,
|
||||
which strips infra libraries over-bundled by linuxdeploy (they crash on
|
||||
|
||||
Executable
+214
@@ -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()
|
||||
Executable
+83
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
.reviewDecision == "APPROVED"
|
||||
Executable
+84
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Executable
+62
@@ -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"
|
||||
Reference in New Issue
Block a user