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:
Wes
2026-07-30 11:27:56 -07:00
committed by GitHub
co-authored by Carl
parent cca8839034
commit 1dfd89ea67
13 changed files with 763 additions and 198 deletions
+214
View File
@@ -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()
+83
View File
@@ -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
+14
View File
@@ -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"
)
+1
View File
@@ -0,0 +1 @@
.reviewDecision == "APPROVED"
+84
View File
@@ -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"
+71 -4
View File
@@ -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
+62
View File
@@ -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"