Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ jobs:
run: scripts/test-release-ref-contract.sh
- name: Desktop release candidate contract
run: scripts/test-desktop-release-candidate.sh
- name: OSS desktop promotion contract
run: |
scripts/test-oss-desktop-promotion.sh
scripts/test-oss-desktop-promotion-behavior.sh
- name: Mobile release contract
run: |
scripts/test-mobile-release-contract.sh
Expand Down
45 changes: 45 additions & 0 deletions .github/workflows/promote-oss-desktop-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Promote OSS Desktop Auto-Update
run-name: Promote desktop-v${{ inputs.version }} to auto-update

on:
workflow_dispatch:
inputs:
version:
description: Stable desktop version to promote (X.Y.Z)
required: true
type: string

concurrency:
group: oss-desktop-auto-update-promotion
cancel-in-progress: false

permissions:
contents: read

jobs:
promote:
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Require the reviewed workflow from main
env:
DISPATCH_REF: ${{ github.ref }}
run: |
if [ "$DISPATCH_REF" != "refs/heads/main" ]; then
echo "::error::OSS desktop promotion must be dispatched from main, not $DISPATCH_REF"
exit 1
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
persist-credentials: false

- name: Validate and promote exact release manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
run: scripts/promote-oss-desktop-release.sh "$VERSION"
5 changes: 1 addition & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,7 @@ jobs:
[ "${#TRIPLES[@]}" -ge 3 ] || { echo "::error::too few platforms (${#TRIPLES[@]})"; exit 1; }
bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json
cat latest.json
cp latest.json staged/updater-manifest.json

- name: Create or verify versioned draft
run: |
Expand Down Expand Up @@ -946,7 +947,3 @@ jobs:
- 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: ${{ !contains(needs.setup.outputs.version, '-') }}
run: gh release upload buzz-desktop-latest latest.json --clobber
29 changes: 23 additions & 6 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,10 @@ for distributable builds or builds from an immutable release tag.
`release.yml` has no manual dispatch and cannot build from `main` or another
caller-selected ref. If a run for an existing immutable
`desktop-v<version>` tag fails, rerun that failed workflow from GitHub Actions
(or use `gh run rerun <run-id> --failed --repo block/buzz`). A stable rerun also
repairs `buzz-desktop-latest/latest.json` if the original run published the
versioned release but failed during that final rolling-manifest upload. Do not
recreate, move, or push the immutable tag again.
(or use `gh run rerun <run-id> --failed --repo block/buzz`). A rerun
repairs the versioned draft if publication did not complete. It does not
promote that version to the auto-updater; promotion is a separate manual
action. Do not recreate, move, or push the immutable tag again.

Mobile intentionally has no branch or arbitrary-ref fallback. The private
Buildkite pipeline accepts only an exact candidate tag.
Expand All @@ -200,8 +200,25 @@ for the rest of the private pipeline contract.

Desktop publishes two GitHub releases:

1. **`desktop-v<version>`**: the user-facing release with installers.
2. **`buzz-desktop-latest`**: the rolling auto-updater release.
1. **`desktop-v<version>`**: the user-facing release with installers and the
exact `updater-manifest.json` promotion candidate. Publishing this release
does not expose it through in-app auto-update.
2. **`buzz-desktop-latest`**: the rolling auto-updater release. Its
`latest.json` changes only through the manual promotion workflow.

### Promote an OSS desktop release to auto-update

After installing and testing the published `desktop-v<version>` artifacts, run
**Promote OSS Desktop Auto-Update** from the `main` branch and enter the exact
stable `X.Y.Z` version. The workflow validates the immutable tag and release,
the retained manifest and every referenced updater asset, and requires the
version to be newer than the currently promoted version before replacing
`buzz-desktop-latest/latest.json`. Same-version retries succeed only when the
manifest is identical; downgrades are rejected.

Withholding promotion leaves existing clients on the previous version. If a
promoted release is bad, ship and promote a higher patch version; changing the
manifest to an older version does not downgrade clients that already updated.

Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts
and rollout records retain the exact tag they used. Mobile does not publish a
Expand Down
88 changes: 88 additions & 0 deletions scripts/promote-oss-desktop-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail

VERSION="${1:-}"
REPOSITORY="${GITHUB_REPOSITORY:-block/buzz}"
TAG="desktop-v${VERSION}"
CANDIDATE="updater-manifest.json"
ROLLING_TAG="buzz-desktop-latest"
EXPECTED_PLATFORMS='["darwin-aarch64","darwin-x86_64","linux-x86_64","windows-x86_64"]'

fail() { echo "::error::$*" >&2; exit 1; }
[[ "$REPOSITORY" == "block/buzz" ]] || fail "promotion is restricted to block/buzz"
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "version must be stable semver X.Y.Z"
command -v gh >/dev/null || fail "gh is required"
command -v jq >/dev/null || fail "jq is required"

workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
candidate="$workdir/$CANDIDATE"
current="$workdir/latest.json"

release_json="$(gh release view "$TAG" --repo "$REPOSITORY" --json isDraft,isPrerelease,targetCommitish,assets)"
[[ "$(jq -r .isDraft <<<"$release_json")" == false ]] || fail "$TAG is still a draft"
[[ "$(jq -r .isPrerelease <<<"$release_json")" == false ]] || fail "$TAG is a prerelease"

tag_sha="$(gh api "repos/$REPOSITORY/commits/$TAG" --jq .sha)"
target="$(jq -r .targetCommitish <<<"$release_json")"
target_sha="$(gh api "repos/$REPOSITORY/commits/$target" --jq .sha)"
[[ -n "$tag_sha" && "$target_sha" == "$tag_sha" ]] || fail "$TAG and its release target do not resolve to the same commit"

release_assets="$(jq -r '.assets[].name' <<<"$release_json")"
grep -Fxq "$CANDIDATE" <<<"$release_assets" || fail "$TAG has no $CANDIDATE asset"
gh release download "$TAG" --repo "$REPOSITORY" --pattern "$CANDIDATE" --dir "$workdir"

jq -e --arg version "$VERSION" --argjson expected "$EXPECTED_PLATFORMS" '
.version == $version and
(.platforms | keys == $expected) and
([.platforms[] | (.signature | type == "string" and length > 0)] | all) and
([.platforms[] | (.url | type == "string" and startswith("https://github.com/block/buzz/releases/download/desktop-v" + $version + "/"))] | all)
' "$candidate" >/dev/null || fail "$CANDIDATE failed version, platform, signature, or URL validation"

while IFS= read -r url; do
asset="${url##*/}"
[[ "$url" == "https://github.com/block/buzz/releases/download/$TAG/$asset" ]] || fail "$CANDIDATE contains non-canonical updater URL: $url"
grep -Fxq "$asset" <<<"$release_assets" || fail "$CANDIDATE references missing release asset: $asset"
done < <(jq -r '.platforms[].url' "$candidate")

gh release download "$ROLLING_TAG" --repo "$REPOSITORY" --pattern latest.json --dir "$workdir"
current_digest="$(sha256sum "$current" | awk '{print $1}')"
current_version="$(jq -er '.version | select(type == "string")' "$current")" || fail "current latest.json has no version"
[[ "$current_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "current promoted version is not stable semver: $current_version"

highest="$(printf '%s\n%s\n' "$current_version" "$VERSION" | sort -V | tail -1)"
if [[ "$VERSION" == "$current_version" ]]; then
cmp -s "$candidate" "$current" || fail "$VERSION is already promoted with different manifest content"
echo "Version $VERSION is already promoted with identical manifest content."
exit 0
fi
[[ "$highest" == "$VERSION" ]] || fail "refusing downgrade from $current_version to $VERSION"

# Re-read immediately before the only write so a stale validation cannot silently
# overwrite a promotion performed outside this workflow.
rm -f "$current"
gh release download "$ROLLING_TAG" --repo "$REPOSITORY" --pattern latest.json --dir "$workdir"
[[ "$(sha256sum "$current" | awk '{print $1}')" == "$current_digest" ]] || fail "current promotion changed during validation; retry"

promotion="$workdir/latest.json"
cp "$candidate" "$promotion"
candidate_digest="$(sha256sum "$candidate" | awk '{print $1}')"
if ! gh release upload "$ROLLING_TAG" "$promotion" --repo "$REPOSITORY" --clobber; then
fail "promotion upload failed; latest.json may be temporarily unavailable, retry the promotion"
fi
rm -f "$promotion"
if ! gh release download "$ROLLING_TAG" --repo "$REPOSITORY" --pattern latest.json --dir "$workdir"; then
fail "promotion upload returned success but latest.json could not be verified; retry the promotion"
fi
[[ "$(sha256sum "$promotion" | awk '{print $1}')" == "$candidate_digest" ]] || fail "served latest.json does not match the promoted candidate; retry the promotion"
{
echo "### OSS desktop auto-update promoted"
echo "- Version: \`$VERSION\`"
echo "- Tag commit: \`$tag_sha\`"
echo "- Previous version: \`$current_version\`"
echo "- Manifest SHA-256: \`$(sha256sum "$candidate" | awk '{print $1}')\`"
echo "- Actor: \`${GITHUB_ACTOR:-unknown}\`"
if [[ -n "${GITHUB_SERVER_URL:-}" && -n "${GITHUB_RUN_ID:-}" ]]; then
echo "- Workflow: ${GITHUB_SERVER_URL}/${REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
fi
} >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
128 changes: 128 additions & 0 deletions scripts/test-oss-desktop-promotion-behavior.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
promoter="$root/scripts/promote-oss-desktop-release.sh"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
mkdir "$tmp/bin"

cat > "$tmp/bin/gh" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$1 $2" == "release view" ]]; then
printf '%s\n' "$MOCK_RELEASE_JSON"
elif [[ "$1" == api ]]; then
[[ "$2" == *commits/desktop-v* ]] && printf '%s\n' "${MOCK_TAG_SHA:-abc123}" || printf '%s\n' "${MOCK_TARGET_SHA:-abc123}"
elif [[ "$1 $2" == "release download" ]]; then
tag="$3"; shift 3; pattern= dir=
while [[ $# -gt 0 ]]; do
case "$1" in --pattern) pattern="$2"; shift 2;; --dir) dir="$2"; shift 2;; *) shift;; esac
done
if [[ "$tag" == desktop-v* ]]; then
cp "$MOCK_CANDIDATE" "$dir/$pattern"
else
count=0; [[ -f "$MOCK_DOWNLOAD_COUNT" ]] && count="$(cat "$MOCK_DOWNLOAD_COUNT")"
count=$((count + 1)); printf '%s' "$count" > "$MOCK_DOWNLOAD_COUNT"
source="$MOCK_CURRENT"
if [[ "$count" -eq 2 && -n "${MOCK_CURRENT_SECOND:-}" ]]; then
source="$MOCK_CURRENT_SECOND"
elif [[ "$count" -gt 2 && -n "${MOCK_POST_WRITE:-}" ]]; then
source="$MOCK_POST_WRITE"
elif [[ "$count" -gt 2 ]]; then
source="$MOCK_CANDIDATE"
fi
cp "$source" "$dir/$pattern"
fi
elif [[ "$1 $2" == "release upload" ]]; then
[[ "${MOCK_UPLOAD_FAIL:-false}" != true ]] || exit 1
: > "$MOCK_UPLOAD_MARKER"
else
echo "unexpected gh invocation: $*" >&2; exit 70
fi
MOCK
chmod +x "$tmp/bin/gh"

write_manifest() {
local file="$1" version="$2" signature="${3-signed}" base_version="${4-$2}"
jq -n --arg version "$version" --arg signature "$signature" --arg base "https://github.com/block/buzz/releases/download/desktop-v${base_version}" '{
version: $version, notes: ("Buzz v" + $version), pub_date: "2026-08-09T00:00:00Z",
platforms: {
"darwin-aarch64": {signature: $signature, url: ($base + "/mac-arm.tar.gz")},
"darwin-x86_64": {signature: $signature, url: ($base + "/mac-x64.tar.gz")},
"linux-x86_64": {signature: $signature, url: ($base + "/linux.AppImage")},
"windows-x86_64": {signature: $signature, url: ($base + "/windows.exe")}
}
}' > "$file"
}

all_assets='["updater-manifest.json","mac-arm.tar.gz","mac-x64.tar.gz","linux.AppImage","windows.exe"]'
release_json() {
local draft="${1:-false}" prerelease="${2:-false}" asset_json="${3:-$all_assets}"
jq -cn --argjson draft "$draft" --argjson prerelease "$prerelease" --argjson assets "$asset_json" \
'{isDraft:$draft,isPrerelease:$prerelease,targetCommitish:"abc123",assets:[$assets[]|{name:.}]}'
}

# run_case name expected-error-or-pass expected-upload candidate current release
# [second-current] [post-write] [upload-fail] [tag-sha] [target-sha] [version]
run_case() {
local name="$1" expected="$2" upload="$3" candidate="$4" current="$5" release="$6"
local second="${7:-}" post="${8:-}" upload_fail="${9:-false}" tag_sha="${10:-abc123}" target_sha="${11:-abc123}" version="${12:-1.2.3}"
local case_dir="$tmp/$name" output status
mkdir -p "$case_dir"; : > "$case_dir/count"; rm -f "$case_dir/uploaded"
set +e
output="$(PATH="$tmp/bin:$PATH" GITHUB_REPOSITORY=block/buzz \
MOCK_RELEASE_JSON="$release" MOCK_CANDIDATE="$candidate" MOCK_CURRENT="$current" \
MOCK_CURRENT_SECOND="$second" MOCK_POST_WRITE="$post" MOCK_UPLOAD_FAIL="$upload_fail" \
MOCK_TAG_SHA="$tag_sha" MOCK_TARGET_SHA="$target_sha" MOCK_DOWNLOAD_COUNT="$case_dir/count" \
MOCK_UPLOAD_MARKER="$case_dir/uploaded" GITHUB_STEP_SUMMARY="$case_dir/summary" \
"$promoter" "$version" 2>&1)"
status=$?
set -e
if [[ "$expected" == pass ]]; then
[[ "$status" -eq 0 ]] || { echo "$name expected success: $output" >&2; exit 1; }
else
[[ "$status" -ne 0 ]] || { echo "$name expected failure" >&2; exit 1; }
grep -Fq "$expected" <<<"$output" || { echo "$name missing error '$expected': $output" >&2; exit 1; }
fi
if [[ "$upload" == yes ]]; then
[[ -f "$case_dir/uploaded" ]] || { echo "$name expected upload" >&2; exit 1; }
else
[[ ! -f "$case_dir/uploaded" ]] || { echo "$name unexpectedly uploaded" >&2; exit 1; }
fi
}

write_manifest "$tmp/candidate.json" 1.2.3
write_manifest "$tmp/current.json" 1.2.2
write_manifest "$tmp/newer.json" 1.2.4
write_manifest "$tmp/raced.json" 1.2.2 changed
write_manifest "$tmp/same-different.json" 1.2.3 changed

run_case upgrade pass yes "$tmp/candidate.json" "$tmp/current.json" "$(release_json)"
run_case identical-retry pass no "$tmp/candidate.json" "$tmp/candidate.json" "$(release_json)"
run_case same-version-mismatch 'already promoted with different manifest content' no "$tmp/candidate.json" "$tmp/same-different.json" "$(release_json)"
run_case downgrade 'refusing downgrade' no "$tmp/candidate.json" "$tmp/newer.json" "$(release_json)"
run_case stale-manifest 'current promotion changed during validation' no "$tmp/candidate.json" "$tmp/current.json" "$(release_json)" "$tmp/raced.json"
run_case draft 'is still a draft' no "$tmp/candidate.json" "$tmp/current.json" "$(release_json true false)"
run_case prerelease 'is a prerelease' no "$tmp/candidate.json" "$tmp/current.json" "$(release_json false true)"
run_case target-mismatch 'do not resolve to the same commit' no "$tmp/candidate.json" "$tmp/current.json" "$(release_json)" '' '' false abc123 different
run_case malformed-input 'version must be stable semver' no "$tmp/candidate.json" "$tmp/current.json" "$(release_json)" '' '' false abc123 abc123 '1.2.3";echo owned'

printf '{not-json' > "$tmp/malformed.json"
run_case malformed-json 'failed version, platform, signature, or URL validation' no "$tmp/malformed.json" "$tmp/current.json" "$(release_json)"
write_manifest "$tmp/wrong-version.json" 1.2.4
run_case wrong-version 'failed version, platform, signature, or URL validation' no "$tmp/wrong-version.json" "$tmp/current.json" "$(release_json)"
write_manifest "$tmp/empty-signature.json" 1.2.3 ''
run_case empty-signature 'failed version, platform, signature, or URL validation' no "$tmp/empty-signature.json" "$tmp/current.json" "$(release_json)"
write_manifest "$tmp/foreign-url.json" 1.2.3 signed 9.9.9
run_case foreign-url 'failed version, platform, signature, or URL validation' no "$tmp/foreign-url.json" "$tmp/current.json" "$(release_json)"
jq 'del(.platforms."windows-x86_64")' "$tmp/candidate.json" > "$tmp/missing-platform.json"
run_case missing-platform 'failed version, platform, signature, or URL validation' no "$tmp/missing-platform.json" "$tmp/current.json" "$(release_json)"
jq '.platforms["freebsd-x86_64"] = .platforms["linux-x86_64"]' "$tmp/candidate.json" > "$tmp/extra-platform.json"
run_case extra-platform 'failed version, platform, signature, or URL validation' no "$tmp/extra-platform.json" "$tmp/current.json" "$(release_json)"
missing_assets='["updater-manifest.json","mac-arm.tar.gz","mac-x64.tar.gz","linux.AppImage"]'
run_case missing-asset 'references missing release asset' no "$tmp/candidate.json" "$tmp/current.json" "$(release_json false false "$missing_assets")"
run_case upload-failure 'promotion upload failed' no "$tmp/candidate.json" "$tmp/current.json" "$(release_json)" '' '' true
run_case post-write-mismatch 'served latest.json does not match the promoted candidate' yes "$tmp/candidate.json" "$tmp/current.json" "$(release_json)" '' "$tmp/raced.json"

echo "OSS desktop promotion behavior passed"
37 changes: 37 additions & 0 deletions scripts/test-oss-desktop-promotion.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
workflow="$root/.github/workflows/promote-oss-desktop-release.yml"
promoter="$root/scripts/promote-oss-desktop-release.sh"
release="$root/.github/workflows/release.yml"

# Pin the separation contract: tag builds retain the exact candidate but cannot
# mutate the rolling updater release.
grep -Fq 'cp latest.json staged/updater-manifest.json' "$release"
[[ "$(grep -c 'gh release upload' "$release")" -eq 1 ]]
! grep -Fq 'gh release upload buzz-desktop-latest' "$release"

grep -Fq 'workflow_dispatch:' "$workflow"
grep -Fq 'group: oss-desktop-auto-update-promotion' "$workflow"
grep -Fq 'cancel-in-progress: false' "$workflow"
grep -Fq 'if: github.repository ==' "$workflow"
grep -Fq 'DISPATCH_REF' "$workflow"
grep -Fq 'contents: write' "$workflow"
grep -Fq 'VERSION: ${{ inputs.version }}' "$workflow"
grep -Fq 'scripts/promote-oss-desktop-release.sh "$VERSION"' "$workflow"
if grep -F 'run:' "$workflow" | grep -Fq '${{ inputs.version }}'; then
echo "untrusted workflow input must not be interpolated into run" >&2
exit 1
fi

grep -Fq 'refusing downgrade' "$promoter"
grep -Fq 'current_digest="$(sha256sum "$current"' "$promoter"
grep -Fq '== "$current_digest"' "$promoter"
grep -Fq 'updater-manifest.json' "$promoter"
grep -Fq 'desktop-v" + $version + "/"' "$promoter"
grep -Fq 'gh release upload "$ROLLING_TAG" "$promotion"' "$promoter"
grep -Fq 'served latest.json does not match the promoted candidate' "$promoter"
grep -Fq 'promotion upload failed' "$promoter"

echo "OSS desktop promotion contract passed"
Loading
Loading