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
17 changes: 14 additions & 3 deletions .github/workflows/auto-tag-on-release-pr-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ jobs:
echo "enabled=true"
echo "tag=${TAG_PREFIX}${VERSION}"
if [[ "$TAG_PREFIX" == desktop-v ]]; then
echo "target_sha=${{ github.event.pull_request.merge_commit_sha }}"
echo "target_sha=${{ github.event.pull_request.head.sha }}"
echo "desktop=true"
else
echo "target_sha=$GITHUB_SHA"
Expand All @@ -112,6 +112,7 @@ jobs:
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
MERGED_AT: ${{ github.event.pull_request.merged_at }}
run: |
VERSION="${VERSION#desktop-v}"
export VERSION
Expand Down Expand Up @@ -146,7 +147,17 @@ jobs:
exit 1
fi
fi
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$TAG" \
-f sha="$TARGET_SHA" \
--silent
--silent; then
# Ref creation is atomic. A concurrent retry may have won the race;
# accept that only when it created the exact immutable ref.
EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)"
if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
echo "Tag $TAG was concurrently created at $TARGET_SHA"
exit 0
fi
echo "::error::Tag creation failed and $TAG resolves to $EXISTING_SHA (expected $TARGET_SHA)"
exit 1
fi
2 changes: 2 additions & 0 deletions .github/workflows/desktop-release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:

permissions:
contents: read
pull-requests: read

jobs:
validate:
Expand All @@ -20,6 +21,7 @@ jobs:
- name: Validate immutable desktop candidate
if: startsWith(github.event.pull_request.head.ref, 'version-bump/')
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ github.event.pull_request.head.ref }}
run: |
VERSION="${VERSION#version-bump/}"
Expand Down
46 changes: 24 additions & 22 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,30 @@ or mobile GitHub Release.
### Desktop

1. Run `just release-desktop <version>` from a clean, up-to-date `main` checkout.
The script fetches the current `origin/main`, regenerates
`version-bump/<version>` as one
deterministic candidate commit, records the frozen base and proposed
`desktop-v<version>` tag in `.release/desktop-candidate.json`, updates every
desktop manifest and lockfile, writes a full-SHA changelog, and opens or
updates the PR.
2. Review the recorded base and candidate SHA, the complete changelog, and CI.
The required **Desktop Release Candidate** check validates the exact head.
A trusted repository member, owner, or collaborator must approve that exact
candidate head. Any regeneration or push changes the head, invalidates the
prior approval, and requires both the checks and approval to run again.
3. **Squash merge** the PR. The protected branch must still be exactly the
recorded base; otherwise regenerate the candidate from current `main`.
4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity,
required checks, and trusted approval on the exact candidate head, then tags
the squash commit as `desktop-v<version>`. An admin or ruleset bypass does not
authorize desktop tagging.
5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel
macOS, Windows, and Linux artifacts; publishes the versioned release only
after the complete set succeeds; then updates the rolling updater manifest
last for stable versions. A failed platform leaves no partially published
versioned release.
The script creates one deterministic candidate commit and records both its
frozen base and the verified prior release ledger in candidate metadata.
2. Review the exact candidate SHA, complete changelog, and CI. Regenerating or
pushing the branch creates a new candidate and requires checks to run again.
3. **Squash merge** the PR after all protected-branch checks pass. The merge is
the human authorization event; an authorized owner/admin bypass is treated
the same way. Unrelated changes reaching `main` do not invalidate the
reviewed candidate.
4. `auto-tag-on-release-pr-merge` verifies the closed event against GitHub's PR
identity, validates candidate content, and proves every required check came
from its trusted producer and was successful when the PR merged. It creates
`desktop-v<version>` at the exact reviewed PR head—not the squash commit.
Retries accept that tag only at the same SHA and never move it. GitHub does
not expose when an individual check rerun was created, so an ordinary rerun
after merge deliberately makes tag verification fail closed; inspect that
run and create a new candidate version rather than retrying the blocked tag.
5. The tag triggers `release.yml`. It builds and stages all platform artifacts,
publishes the versioned release only after the complete set succeeds, then
updates the rolling updater manifest last for stable versions.

Because squash merging leaves immutable candidate tags on side history, the next
release uses validated prior candidate metadata as its ledger boundary. It
includes unrelated commits after the prior frozen base and excludes exactly the
prior release's recorded squash commit; tag ancestry is deliberately irrelevant.

### Relay

Expand Down
151 changes: 112 additions & 39 deletions scripts/desktop_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@

import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
ROOT = Path(os.environ.get("DESKTOP_RELEASE_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.-]+)?$")
STABLE_TAG = re.compile(r"desktop-v([0-9]+)\.([0-9]+)\.([0-9]+)$")
DESKTOP_PATHS = (
"desktop/",
"crates/buzz-core/",
Expand Down Expand Up @@ -54,30 +56,94 @@ def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[d
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):
def gh_json(endpoint: str) -> object:
try:
return json.loads(subprocess.check_output(
["gh", "api", endpoint], cwd=ROOT, text=True
))
except (subprocess.CalledProcessError, json.JSONDecodeError) as error:
raise SystemExit(f"cannot verify prior desktop release via GitHub: {error}") from error


def stable_tags() -> list[tuple[tuple[int, int, int], str, str]]:
tags: list[tuple[tuple[int, int, int], str, str]] = []
aliases: dict[tuple[int, int, int], list[tuple[str, str]]] = {}
for tag in git("tag", "--list", "desktop-v*").splitlines():
match = STABLE_TAG.fullmatch(tag)
if not match:
continue
version = tuple(map(int, match.groups()))
sha = git("rev-list", "-n", "1", tag)
distance = int(git("rev-list", "--count", f"{sha}..{base_sha}"))
tags.append((distance, tag, sha))
aliases.setdefault(version, []).append((tag, sha))
for version, refs in aliases.items():
if len(refs) != 1:
detail = ", ".join(f"{tag}@{sha}" for tag, sha in refs)
raise SystemExit(f"ambiguous desktop release version {version}: {detail}")
tag, sha = refs[0]
tags.append((version, 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 previous_release(
version: str, repo: str, *, allow_target_sha: str | None = None
) -> dict[str, str] | None:
target = tuple(map(int, version.split("-", 1)[0].split(".")))
target_tag = f"desktop-v{version}"
target_refs = git("tag", "--list", target_tag).splitlines()
target_ref = (
(target_tag, git("rev-list", "-n", "1", target_tag))
if target_refs
else None
)
target_collision = target_ref is not None and (
allow_target_sha is None or target_ref[1] != allow_target_sha
)
tags = stable_tags()
newer = [item for item in tags if item[0] > target]
equal = [item for item in tags if item[0] == target]
allowed_stable_retry = (
"-" not in version
and len(equal) == 1
and allow_target_sha is not None
and equal[0][1] == target_tag
and equal[0][2] == allow_target_sha
)
if target_collision or newer or (equal and not allowed_stable_retry):
blocked = [item[1] for item in newer + equal]
if target_collision and target_tag not in blocked:
blocked.append(target_tag)
detail = ", ".join(blocked)
raise SystemExit(f"desktop release version must increase beyond existing tags: {detail}")
eligible = [item for item in tags if item[0] < target]
if not eligible:
return None
prior_version, tag, candidate_sha = max(eligible)
try:
metadata = json.loads(git("show", f"{tag}:.release/desktop-candidate.json"))
except (subprocess.CalledProcessError, json.JSONDecodeError) as error:
raise SystemExit(f"prior release {tag} has invalid candidate metadata") from error
expected = {
"version": ".".join(map(str, prior_version)),
"tag": tag,
}
if any(metadata.get(key) != value for key, value in expected.items()):
raise SystemExit(f"prior release {tag} metadata does not match its tag")
base_sha = metadata.get("base_sha")
if not isinstance(base_sha, str) or not re.fullmatch(r"[0-9a-f]{40}", base_sha):
raise SystemExit(f"prior release {tag} has invalid base_sha")
pulls = gh_json(f"repos/{repo}/commits/{candidate_sha}/pulls")
matches = [pr for pr in pulls if pr.get("merged_at") and (
pr.get("head", {}).get("sha") == candidate_sha
or pr.get("merge_commit_sha") == candidate_sha
)]
if len(matches) != 1 or not matches[0].get("merge_commit_sha"):
raise SystemExit(f"prior release {tag} does not identify exactly one merged release PR")
return {
"tag": tag,
"candidate_sha": candidate_sha,
"base_sha": base_sha,
"merge_sha": matches[0]["merge_commit_sha"],
}


def bullet(commit: dict[str, str], repo: str) -> str:
Expand All @@ -91,24 +157,27 @@ def bullet(commit: dict[str, str], repo: str) -> str:
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)}
def expected(base_sha: str, previous_base: str, previous_merge: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
# Immutable candidate tags may live on side history after squash merge. The
# prior candidate metadata is the ledger boundary; exclude only its known
# squash commit so unrelated commits around that merge remain accounted for.
range_spec = f"{previous_base}..{base_sha}" if previous_base else base_sha
all_commits = [c for c in commit_list(range_spec) if c["sha"] != previous_merge]
relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)} - {previous_merge}
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)
def render(version: str, base_sha: str, previous: dict[str, str] | None, repo: str) -> tuple[str, list[str]]:
relevant, other = expected(
base_sha, previous["base_sha"] if previous else "", previous["merge_sha"] if previous else ""
)
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]
compare_start = previous["tag"] if previous else 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]

Expand All @@ -117,8 +186,8 @@ 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")
previous = previous_release(args.version, repo)
block, commits = render(args.version, base_sha, previous, repo)
old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n"
if not old.startswith("# Changelog"):
Expand All @@ -127,10 +196,12 @@ def generate(args: argparse.Namespace) -> None:
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,
"schema": 2,
"version": args.version,
"base_sha": base_sha,
"previous_tag": previous or None,
"previous_tag": previous["tag"] if previous else None,
"previous_base_sha": previous["base_sha"] if previous else None,
"previous_merge_sha": previous["merge_sha"] if previous else None,
"tag": f"desktop-v{args.version}",
"commit_count": len(commits),
}, indent=2) + "\n")
Expand All @@ -157,14 +228,16 @@ def validate(args: argparse.Namespace) -> None:
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"
previous = previous_release(version, repo, allow_target_sha=candidate)
recorded_previous = {
"tag": data.get("previous_tag"),
"base_sha": data.get("previous_base_sha"),
"merge_sha": data.get("previous_merge_sha"),
} if data.get("previous_tag") else None
expected_previous = {key: previous[key] for key in ("tag", "base_sha", "merge_sha")} if previous else None
if recorded_previous != expected_previous:
raise SystemExit("recorded previous release ledger does not match immutable prior release")
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)
Expand Down
4 changes: 2 additions & 2 deletions scripts/prepare-desktop-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ cat >"$body" <<EOF
- **Previous desktop release:** \`$previous_tag\`
- **Proposed immutable tag:** \`desktop-v$version\`

This PR must be **squash merged** only after the Desktop Release Candidate check passes. The branch must remain based directly on current \`main\`; stale base, payload drift, incomplete notes, or an unauthorized merge produce no tag.
This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on \`main\` cannot alter it.

The checked-in changelog accounts for every non-merge commit in the release range. Publication remains bound to the immutable candidate tag.
The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that 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"
Expand Down
19 changes: 15 additions & 4 deletions scripts/required-check-succeeded.jq
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
# GitHub treats success, skipped, and neutral as successful conclusions for
# required checks. Evaluate the newest run so a stale pass cannot mask a rerun.
# REST check runs do not expose per-attempt creation time. The endpoint is
# intentionally queried with filter=latest; select the highest immutable run ID
# for the trusted producer and require that returned attempt to have completed
# successfully by merge. Any ordinary post-merge rerun therefore fails closed
# and needs operator inspection. DCO alone has a bounded five-minute exception.
[
.[].check_runs[]
| select(.name == $name)
| select(.name == $name and .app.id == $integration_id)
]
| sort_by(.started_at // .created_at // "")
| sort_by(.id)
| last
| select((.completed_at // null) != null)
| select(
(.completed_at | fromdateiso8601)
<= (
($merged_at | fromdateiso8601)
+ (if $name == "DCO Check" then 300 else 0 end)
)
)
| .status == "completed"
and (
.conclusion == "success"
Expand Down
1 change: 0 additions & 1 deletion scripts/review-decision-approved.jq

This file was deleted.

Loading
Loading