diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0a81d86aea..395e9528ef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/promote-oss-desktop-release.yml b/.github/workflows/promote-oss-desktop-release.yml new file mode 100644 index 00000000000..f73bbd032b1 --- /dev/null +++ b/.github/workflows/promote-oss-desktop-release.yml @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9da067b74ee..2b0eb25c688 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | @@ -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 diff --git a/RELEASING.md b/RELEASING.md index 53d58055619..8d1fad74807 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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` tag fails, rerun that failed workflow from GitHub Actions -(or use `gh run rerun --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 --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. @@ -200,8 +200,25 @@ for the rest of the private pipeline contract. Desktop publishes two GitHub releases: -1. **`desktop-v`**: the user-facing release with installers. -2. **`buzz-desktop-latest`**: the rolling auto-updater release. +1. **`desktop-v`**: 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` 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 diff --git a/scripts/promote-oss-desktop-release.sh b/scripts/promote-oss-desktop-release.sh new file mode 100755 index 00000000000..7566a9a4e57 --- /dev/null +++ b/scripts/promote-oss-desktop-release.sh @@ -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}" diff --git a/scripts/test-oss-desktop-promotion-behavior.sh b/scripts/test-oss-desktop-promotion-behavior.sh new file mode 100755 index 00000000000..44cfde11d11 --- /dev/null +++ b/scripts/test-oss-desktop-promotion-behavior.sh @@ -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" diff --git a/scripts/test-oss-desktop-promotion.sh b/scripts/test-oss-desktop-promotion.sh new file mode 100755 index 00000000000..391c5341baa --- /dev/null +++ b/scripts/test-oss-desktop-promotion.sh @@ -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" diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index a42f437efd1..962134ab57c 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -152,15 +152,11 @@ 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: \${{ !contains(needs.setup.outputs.version, '-') }}" "$release_workflow" -if grep -Fq "env.already_published != 'true' && !contains(needs.setup.outputs.version, '-')" "$release_workflow"; then - echo "rolling updater retry is incorrectly gated by versioned publication state" >&2; exit 1 -fi 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 -c 'gh release upload' "$release_workflow")" -eq 1 ]] || { + echo "desktop release must only upload versioned 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"