From c4711cb32d14f6dc048c9af4ad2dd9c5166cbc9e Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 4 Aug 2026 18:30:49 -0600 Subject: [PATCH 1/4] fix(release): tag immutable desktop candidates Make the merged release PR authorize publication of its exact reviewed head, freeze trusted check evidence at merge time, and carry a metadata-backed ledger across squash-merged candidate tags. Co-authored-by: Carl Signed-off-by: Wes --- .../auto-tag-on-release-pr-merge.yml | 17 ++- RELEASING.md | 46 ++++--- scripts/desktop_release.py | 128 ++++++++++++------ scripts/prepare-desktop-release.sh | 4 +- scripts/required-check-succeeded.jq | 19 ++- scripts/review-decision-approved.jq | 1 - scripts/test-desktop-release-authorization.sh | 69 ---------- scripts/test-desktop-release-candidate.sh | 115 +++++++++++----- scripts/test-release-ref-contract.sh | 101 +++++++------- .../verify-desktop-release-authorization.sh | 15 -- scripts/verify-desktop-release-merge.sh | 95 ++++++++----- 11 files changed, 341 insertions(+), 269 deletions(-) delete mode 100644 scripts/review-decision-approved.jq delete mode 100755 scripts/test-desktop-release-authorization.sh delete mode 100755 scripts/verify-desktop-release-authorization.sh diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 3a090b3ebd..3db5c6baaa 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -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" @@ -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 @@ -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 diff --git a/RELEASING.md b/RELEASING.md index 23dacea2ce..53d5805561 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -48,28 +48,30 @@ or mobile GitHub Release. ### Desktop 1. Run `just release-desktop ` from a clean, up-to-date `main` checkout. - The script fetches the current `origin/main`, regenerates - `version-bump/` as one - deterministic candidate commit, records the frozen base and proposed - `desktop-v` 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`. 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` 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 diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py index d6518b26b1..0991f49950 100755 --- a/scripts/desktop_release.py +++ b/scripts/desktop_release.py @@ -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/", @@ -54,30 +56,71 @@ 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) -> dict[str, str] | None: + target = tuple(map(int, version.split("-", 1)[0].split("."))) + tags = stable_tags() + newer = [item for item in tags if item[0] >= target] + if newer: + detail = ", ".join(item[1] for item in newer) + 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: @@ -91,24 +134,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] @@ -117,8 +163,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"): @@ -127,10 +173,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") @@ -157,14 +205,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 ''} does not match " - f"nearest release tag {actual_previous or ''}" - ) repo = args.repo or "block/buzz" + previous = previous_release(version, repo) + 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) diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh index fb586005fb..3626b31a9a 100755 --- a/scripts/prepare-desktop-release.sh +++ b/scripts/prepare-desktop-release.sh @@ -71,9 +71,9 @@ cat >"$body" <"$tmp/bin/gh" <<'GH' -#!/usr/bin/env bash -set -euo pipefail -printf '%q ' "$@" >>"$GH_CALLS" -printf '\n' >>"$GH_CALLS" - -[[ "${1:-}" == api ]] || { echo "expected gh api" >&2; exit 91; } -if [[ "${2:-}" == graphql ]]; then - expected_query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' - [[ "$#" -eq 12 && "$3" == -f && "$4" == "query=$expected_query" && - "$5" == -F && "$6" == owner=block && - "$7" == -F && "$8" == repo=buzz && - "$9" == -F && "${10}" == number=123 && - "${11}" == --jq && "${12}" == '.data.repository.pullRequest' ]] || { - echo "GraphQL call does not match the deployed query contract" >&2; exit 92; - } - if [[ -n "${REVIEW_DECISION:-}" ]]; then printf '%s\n' "$REVIEW_DECISION"; else printf '%s\n' '{"reviewDecision":"APPROVED"}'; fi -elif [[ "$#" -eq 4 && "$2" == --paginate && "$3" == --slurp && "$4" == "repos/block/buzz/pulls/123/reviews?per_page=100&page=1" ]]; then - [[ "${GH_FAIL_REVIEWS:-false}" != true ]] || { echo "simulated reviews API failure" >&2; exit 94; } - if [[ -n "${REVIEWS:-}" ]]; then printf '%s\n' "$REVIEWS"; else printf '%s\n' '[[],[{"state":"APPROVED","commit_id":"head","author_association":"MEMBER"}]]'; fi -else - echo "unexpected or malformed gh call: $*" >&2 - exit 95 -fi -GH -chmod +x "$tmp/bin/gh" - -run_authorization() { - (cd "$repo_root" && PATH="$tmp/bin:$PATH" GH_CALLS="$tmp/calls" GH_TOKEN=test \ - GITHUB_REPOSITORY=block/buzz PR_NUMBER=123 PR_HEAD_SHA=head \ - REVIEW_DECISION="${REVIEW_DECISION-}" REVIEWS="${REVIEWS-}" GH_FAIL_REVIEWS="${GH_FAIL_REVIEWS-false}" \ - scripts/verify-desktop-release-authorization.sh) -} - -: >"$tmp/calls" -run_authorization -! grep -Fq 'rule-suites' "$tmp/calls" - -for invalid in \ - '[[{"state":"APPROVED","commit_id":"stale","author_association":"MEMBER"}]]' \ - '[[{"state":"APPROVED","commit_id":"head","author_association":"NONE"}]]' \ - '[[{"state":"CHANGES_REQUESTED","commit_id":"head","author_association":"MEMBER"}]]'; do - : >"$tmp/calls" - if REVIEWS="$invalid" run_authorization >/dev/null 2>&1; then - echo "invalid approval was accepted: $invalid" >&2 - exit 1 - fi -done - -: >"$tmp/calls" -if REVIEW_DECISION='{"reviewDecision":"CHANGES_REQUESTED"}' run_authorization >/dev/null 2>&1; then - echo "changes-requested review decision was accepted" >&2 - exit 1 -fi - -: >"$tmp/calls" -if GH_FAIL_REVIEWS=true run_authorization >/dev/null 2>&1; then - echo "reviews API failure was ignored" >&2 - exit 1 -fi - -echo "desktop release authorization passed" diff --git a/scripts/test-desktop-release-candidate.sh b/scripts/test-desktop-release-candidate.sh index f36a1d690a..de5fd6be44 100755 --- a/scripts/test-desktop-release-candidate.sh +++ b/scripts/test-desktop-release-candidate.sh @@ -5,7 +5,6 @@ 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 @@ -14,54 +13,106 @@ 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" +printf '# Changelog\n' > "$tmp/CHANGELOG.md" +echo root > "$tmp/ROOT.md" +git -C "$tmp" add . +git -C "$tmp" commit -qm 'feat: root content' +prior_base=$(git -C "$tmp" rev-parse HEAD) + +# The prior immutable candidate lives on side history after its squash merge. +git -C "$tmp" checkout -qb prior-candidate +echo prior > "$tmp/desktop/feature" +cat > "$tmp/.release/desktop-candidate.json" <> "$tmp/desktop/feature" -git -C "$tmp" commit -qam 'fix: desktop fix' -echo policy > "$tmp/POLICY.md" +git -C "$tmp" commit -qm 'chore(release): release Buzz Desktop version 1.0.0' +prior_candidate=$(git -C "$tmp" rev-parse HEAD) +git -C "$tmp" -c tag.gpgSign=false tag desktop-v1.0.0 + +git -C "$tmp" checkout -q - +echo before-squash > "$tmp/POLICY.md" git -C "$tmp" add POLICY.md -git -C "$tmp" commit -qm 'docs: repository policy' +git -C "$tmp" commit -qm 'chore(release): unrelated hostile subject' +unrelated_before=$(git -C "$tmp" rev-parse HEAD) +echo squash > "$tmp/PRIOR_RELEASE.md" +git -C "$tmp" add PRIOR_RELEASE.md +git -C "$tmp" commit -qm 'edited prior release subject' +prior_merge=$(git -C "$tmp" rev-parse HEAD) +echo after-squash >> "$tmp/desktop/feature" +git -C "$tmp" add desktop/feature +git -C "$tmp" commit -qm 'fix: desktop fix after prior release' +unrelated_after=$(git -C "$tmp" rev-parse HEAD) base=$(git -C "$tmp" rev-parse HEAD) + +mock_bin=$(mktemp -d) +cat > "$mock_bin/gh" <msg <<'EOF' -chore(release): release Buzz Desktop version 1.0.1 - -Co-authored-by: Test Automation -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 + git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -m 'chore(release): release Buzz Desktop version 1.0.1' -m 'Co-authored-by: Test Automation ' + PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + grep -Fq "$unrelated_before" CHANGELOG.md + grep -Fq "$unrelated_after" CHANGELOG.md + ! grep -Fq "$prior_merge" CHANGELOG.md + jq -e --arg base "$prior_base" --arg merge "$prior_merge" \ + '.schema == 2 and .previous_tag == "desktop-v1.0.0" and .previous_base_sha == $base and .previous_merge_sha == $merge' \ + .release/desktop-candidate.json >/dev/null - # 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 + jq '.previous_merge_sha = "0000000000000000000000000000000000000000"' metadata.json > .release/desktop-candidate.json + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a forged previous release ledger" >&2; exit 1 fi mv metadata.json .release/desktop-candidate.json ) -# An initial release must account for the root commit, not silently omit it. +# Equal and decreasing versions are rejected before any GitHub lookup. +for invalid_version in 1.0.0 0.9.9; do + if (cd "$tmp" && PATH="/usr/bin:/bin" scripts/desktop_release.py generate "$invalid_version" --base "$base" --repo block/buzz) >/dev/null 2>&1; then + echo "generator accepted non-increasing version $invalid_version" >&2; exit 1 + fi +done + +# A production-style schema-1 tag points at its squash commit on main. It must +# still resolve as the prior ledger during migration to head-tagged releases. +migration=$(mktemp -d) +git clone -q "$tmp" "$migration" +git -C "$migration" config user.name test +git -C "$migration" config user.email test@example.com +git -C "$migration" checkout -q "$prior_base" +GIT_EDITOR=true git -C "$migration" cherry-pick "$prior_candidate" >/dev/null +production_tag=$(git -C "$migration" rev-parse HEAD) +git -C "$migration" -c tag.gpgSign=false tag -f desktop-v1.0.0 "$production_tag" >/dev/null +echo migration >> "$migration/desktop/feature" +git -C "$migration" add desktop/feature +git -C "$migration" commit -qm 'fix: migration change' +migration_base=$(git -C "$migration" rev-parse HEAD) +cat > "$mock_bin/gh" </dev/null +rm -rf "$migration" + +# An initial release still accounts for the root commit without calling GitHub. initial=$(mktemp -d) cp "$repo_root/scripts/desktop_release.py" "$initial/desktop_release.py" git -C "$initial" init -q diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 8bfd6798ee..30747559e2 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -64,69 +64,76 @@ 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.merge_commit_sha }}' "$auto_tag" +grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag" grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag" -grep -Fq 'current \`main\`' "$repo_root/scripts/prepare-desktop-release.sh" +grep -Fq 'reviewed candidate' "$repo_root/scripts/prepare-desktop-release.sh" if grep -Fq 'current `main`' "$repo_root/scripts/prepare-desktop-release.sh"; then echo "desktop release PR body contains executable command substitution" >&2 exit 1 fi -"$repo_root/scripts/test-desktop-release-authorization.sh" -if rg -q 'rule-suites|desktop-release-bypass-authorized|MERGED_BY' \ - "$repo_root/scripts/verify-desktop-release-merge.sh" \ - "$repo_root/scripts/verify-desktop-release-authorization.sh" \ - "$auto_tag"; then - echo "desktop auto-tag still depends on unavailable rule-suite authorization" >&2 - exit 1 -fi - -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 - } + local expected="$1" conclusion="$2" app="${3:-15368}" completed="${4:-2026-01-01T00:00:00Z}" + local payload actual + # Production-shaped REST check run: notably, there is no created_at field. + payload=$(jq -n --arg conclusion "$conclusion" --argjson app "$app" --arg completed "$completed" \ + '{check_runs: [{id: 100, check_suite: {id: 10}, name: "Web", app: {id: $app}, status: "completed", conclusion: $conclusion, started_at: "2026-01-01T00:00:00Z", completed_at: $completed}]}') + if jq -e --arg name Web --argjson integration_id 15368 \ + --arg merged_at 2026-01-02T00:00:00Z \ + -f "$required_check_filter" <<<"[$payload]" >/dev/null; then actual=pass; else actual=fail; fi + [[ "$actual" == "$expected" ]] || { echo "required-check fixture expected $expected, got $actual" >&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_fixture fail success 999 +check_fixture fail success 15368 2026-01-03T00:00:00Z + +# filter=latest may still return multiple same-name runs from distinct workflows. +# Highest immutable run ID is authoritative and must not reveal stale green. +jq -e --arg name Web --argjson integration_id 15368 --arg merged_at 2026-01-02T00:00:00Z \ + -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"} + {"id":100,"check_suite":{"id":10},"name":"Web","app":{"id":15368},"status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z","completed_at":"2026-01-01T01:00:00Z"}, + {"id":101,"check_suite":{"id":11},"name":"Web","app":{"id":15368},"status":"in_progress","conclusion":null,"started_at":"2026-01-01T23:59:00Z","completed_at":null} ]}] JSON - echo "required-check filter accepted a stale pass over a newer failure" >&2 - exit 1 + echo "required-check filter hid the highest-ID pending attempt" >&2; exit 1; } +# A post-merge rerun is indistinguishable from other latest attempts and fails closed. +jq -e --arg name Web --argjson integration_id 15368 --arg merged_at 2026-01-02T00:00:00Z \ + -f "$required_check_filter" >/dev/null <<'JSON' && { +[{"check_runs":[ + {"id":100,"check_suite":{"id":10},"name":"Web","app":{"id":15368},"status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z","completed_at":"2026-01-01T01:00:00Z"}, + {"id":101,"check_suite":{"id":10},"name":"Web","app":{"id":15368},"status":"completed","conclusion":"failure","started_at":"2026-01-02T00:01:00Z","completed_at":"2026-01-02T00:10:00Z"} +]}] +JSON + echo "required-check filter accepted stale success after post-merge rerun" >&2; exit 1; +} +# DCO alone may complete just after merge, inside its explicit five-minute bound. +dco_fixture() { + local expected="$1" completed="$2" actual + if jq -e --arg name "DCO Check" --argjson integration_id 1455659 --arg merged_at 2026-01-02T00:00:00Z \ + -f "$required_check_filter" >/dev/null <&2; exit 1; } +} +dco_fixture pass 2026-01-02T00:04:59Z +dco_fixture fail 2026-01-02T00:05:01Z + +# The verifier must request production endpoint semantics and pin helpers before checkout. +verify_merge="$repo_root/scripts/verify-desktop-release-merge.sh" +grep -Fq 'check-runs?filter=latest&per_page=100' "$verify_merge" +grep -Fq 'git fetch origin main --no-tags' "$verify_merge" +grep -Fq 'git merge-base --is-ancestor "$candidate_parents" origin/main' "$verify_merge" +grep -Fq 'git show "$candidate_parents:scripts/desktop_release.py"' "$verify_merge" +grep -Fq 'git show "$candidate_parents:scripts/required-check-succeeded.jq"' "$verify_merge" +grep -Fq 'DESKTOP_RELEASE_ROOT="$PWD" python3 "$verifier_dir/desktop_release.py"' "$verify_merge" +grep -Fq -- '-f "$verifier_dir/required-check-succeeded.jq"' "$verify_merge" + 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; diff --git a/scripts/verify-desktop-release-authorization.sh b/scripts/verify-desktop-release-authorization.sh deleted file mode 100755 index b18cf6cade..0000000000 --- a/scripts/verify-desktop-release-authorization.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${PR_HEAD_SHA:?}" -: "${PR_NUMBER:?}" -: "${GITHUB_REPOSITORY:?}" -: "${GH_TOKEN:?}" - -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')" -reviews="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100&page=1")" -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")" -if ! jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || [[ "$valid_approvals" -eq 0 ]]; then - echo "release lacks an exact-head approval" >&2 - exit 1 -fi diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh index a9fbb48d09..57b4e54af1 100755 --- a/scripts/verify-desktop-release-merge.sh +++ b/scripts/verify-desktop-release-merge.sh @@ -3,25 +3,30 @@ set -euo pipefail : "${PR_HEAD_SHA:?}" : "${MERGE_SHA:?}" +: "${MERGED_AT:?}" : "${VERSION:?}" : "${PR_NUMBER:?}" : "${GH_TOKEN:?}" +# Keep this list aligned with the main ruleset. Producer IDs prevent a check +# with a copied display name from authorizing a release. Every current required +# gate is a check run; add explicit legacy-status verification before introducing +# any required context that reports only through the commit-status API. 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" - "Desktop Release Candidate" + "Desktop E2E Integration:15368" + "Desktop:15368" + "Rust Lint:15368" + "Security:15368" + "Unit Tests:15368" + "Windows Rust (x86_64-pc-windows-msvc):15368" + "Mobile:15368" + "Web:15368" + "Backend Integration (relay e2e):15368" + "Desktop E2E Relay:15368" + "Relay E2E:15368" + "Desktop Build (macOS):15368" + "DCO Check:1455659" + "Desktop Release Candidate:15368" ) expected_branch="version-bump/$VERSION" @@ -29,33 +34,53 @@ expected_branch="version-bump/$VERSION" [[ "${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 1 ]] || { echo "desktop release was not squash merged" >&2; exit 1; } -base_sha="$(git show "$PR_HEAD_SHA:.release/desktop-candidate.json" | jq -r .base_sha)" -[[ "${parents[0]}" == "$base_sha" ]] || { echo "squash parent is not the frozen candidate base" >&2; exit 1; } -[[ "$(git show -s --format=%T "$MERGE_SHA")" == "$(git show -s --format=%T "$PR_HEAD_SHA")" ]] || { - echo "squash tree differs from the validated candidate" >&2 +# The API identity must match the closed event. Branch names are mutable and are +# never used to resolve the artifact. +pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")" +jq -e \ + --arg head "$PR_HEAD_SHA" --arg head_ref "$PR_HEAD_REF" --arg head_repo "$PR_HEAD_REPO" \ + --arg base "$PR_BASE_REF" --arg merge "$MERGE_SHA" --arg merged_at "$MERGED_AT" \ + '.merged == true and .head.sha == $head and .head.ref == $head_ref and + .head.repo.full_name == $head_repo and .base.ref == $base and + .merge_commit_sha == $merge and .merged_at == $merged_at' <<<"$pr" >/dev/null || { + echo "pull request API identity does not match the closed merge event" >&2 exit 1 } -git merge-base --is-ancestor "$MERGE_SHA" origin/main || { echo "squash commit 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" +# Pin trusted verifier code from the candidate's frozen base, not from the +# candidate or its squash. A release PR cannot alter the code that validates it. +git fetch origin main --no-tags +git fetch origin "$PR_HEAD_SHA" --no-tags +candidate_parents="$(git show -s --format=%P "$PR_HEAD_SHA")" +[[ "$candidate_parents" =~ ^[0-9a-f]{40}$ ]] || { + echo "desktop candidate must have exactly one parent before validation" >&2 + exit 1 +} +git merge-base --is-ancestor "$candidate_parents" origin/main || { + echo "desktop candidate base is not protected main history" >&2 + exit 1 +} +verifier_dir="$(mktemp -d)" +trap 'rm -rf "$verifier_dir"' EXIT +git show "$candidate_parents:scripts/desktop_release.py" > "$verifier_dir/desktop_release.py" +git show "$candidate_parents:scripts/required-check-succeeded.jq" > "$verifier_dir/required-check-succeeded.jq" -scripts/verify-desktop-release-authorization.sh +git checkout --detach "$PR_HEAD_SHA" +DESKTOP_RELEASE_ROOT="$PWD" python3 "$verifier_dir/desktop_release.py" \ + validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" -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 +# `filter=latest` is deliberate: GitHub exposes no per-rerun creation time. A +# post-merge rerun replaces the visible attempt and fails closed below. +checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?filter=latest&per_page=100")" +for entry in "${required_checks[@]}"; do + required="${entry%:*}" + integration_id="${entry##*:}" + jq -e --arg name "$required" --argjson integration_id "$integration_id" \ + --arg merged_at "$MERGED_AT" \ + -f "$verifier_dir/required-check-succeeded.jq" <<<"$checks" >/dev/null || { + echo "trusted required check was not successful at merge: $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 desktop candidate $PR_HEAD_SHA at squash $MERGE_SHA" +echo "verified immutable desktop candidate $PR_HEAD_SHA authorized by merged PR $PR_NUMBER" From ddb82065498a9fdf472daa3e33bc780b3863b097 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 09:07:12 -0600 Subject: [PATCH 2/4] fix(release): preserve candidate validation retries Authenticate the required candidate check's prior-release lookup and allow post-merge verification to retry only when an existing target-version tag already resolves to the exact immutable candidate. Co-authored-by: Carl Signed-off-by: Wes --- .github/workflows/desktop-release-candidate.yml | 1 + scripts/desktop_release.py | 15 ++++++++++----- scripts/test-desktop-release-candidate.sh | 12 ++++++++++++ scripts/test-release-ref-contract.sh | 5 +++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml index eddebea685..f5cf19beba 100644 --- a/.github/workflows/desktop-release-candidate.yml +++ b/.github/workflows/desktop-release-candidate.yml @@ -20,6 +20,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/}" diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py index 0991f49950..aa6d51b75f 100755 --- a/scripts/desktop_release.py +++ b/scripts/desktop_release.py @@ -84,12 +84,17 @@ def stable_tags() -> list[tuple[tuple[int, int, int], str, str]]: return tags -def previous_release(version: str, repo: str) -> dict[str, str] | None: +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("."))) tags = stable_tags() - newer = [item for item in tags if item[0] >= target] - if newer: - detail = ", ".join(item[1] for item in newer) + newer = [item for item in tags if item[0] > target] + equal = [item for item in tags if item[0] == target] + if newer or ( + equal and (allow_target_sha is None or equal[0][2] != allow_target_sha) + ): + detail = ", ".join(item[1] for item in newer + equal) 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: @@ -206,7 +211,7 @@ def validate(args: argparse.Namespace) -> None: detail.append(f"missing required files: {', '.join(sorted(missing))}") raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")") repo = args.repo or "block/buzz" - previous = previous_release(version, repo) + previous = previous_release(version, repo, allow_target_sha=candidate) recorded_previous = { "tag": data.get("previous_tag"), "base_sha": data.get("previous_base_sha"), diff --git a/scripts/test-desktop-release-candidate.sh b/scripts/test-desktop-release-candidate.sh index de5fd6be44..ca84212b25 100755 --- a/scripts/test-desktop-release-candidate.sh +++ b/scripts/test-desktop-release-candidate.sh @@ -79,6 +79,18 @@ PY echo "validator accepted a forged previous release ledger" >&2; exit 1 fi mv metadata.json .release/desktop-candidate.json + + # Post-merge verification may be retried after this candidate's immutable tag + # already exists. Accept only the exact candidate SHA; an equal-version tag + # anywhere else remains a collision. + candidate=$(git rev-parse HEAD) + git -c tag.gpgSign=false tag desktop-v1.0.1 "$candidate" + PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + git -c tag.gpgSign=false tag -f desktop-v1.0.1 "$base" >/dev/null + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted an equal-version tag at the wrong SHA" >&2; exit 1 + fi + git tag -d desktop-v1.0.1 >/dev/null ) # Equal and decreasing versions are rejected before any GitHub lookup. diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 30747559e2..0a8d504e0a 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -66,6 +66,11 @@ 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" +candidate_workflow="$repo_root/.github/workflows/desktop-release-candidate.yml" +grep -Fq 'GH_TOKEN: ${{ github.token }}' "$candidate_workflow" || { + echo "desktop candidate validation has no GitHub token for prior-release lookup" >&2 + exit 1 +} grep -Fq 'reviewed candidate' "$repo_root/scripts/prepare-desktop-release.sh" if grep -Fq 'current `main`' "$repo_root/scripts/prepare-desktop-release.sh"; then echo "desktop release PR body contains executable command substitution" >&2 From 771494d52cdc44713fa1072889ad475e3e1af5a3 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 09:11:17 -0600 Subject: [PATCH 3/4] fix(release): tighten candidate retry authorization Grant the candidate workflow the pull-request read scope required by its prior-release lookup, and accept an existing version tag only when both its full name and commit match the retry target. Co-authored-by: Carl Signed-off-by: Wes --- .../workflows/desktop-release-candidate.yml | 1 + scripts/desktop_release.py | 6 +++++- scripts/test-desktop-release-candidate.sh | 20 +++++++++++++++++++ scripts/test-release-ref-contract.sh | 4 ++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml index f5cf19beba..61ccc800af 100644 --- a/.github/workflows/desktop-release-candidate.yml +++ b/.github/workflows/desktop-release-candidate.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: read jobs: validate: diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py index aa6d51b75f..d88e5a3ac8 100755 --- a/scripts/desktop_release.py +++ b/scripts/desktop_release.py @@ -92,7 +92,11 @@ def previous_release( newer = [item for item in tags if item[0] > target] equal = [item for item in tags if item[0] == target] if newer or ( - equal and (allow_target_sha is None or equal[0][2] != allow_target_sha) + equal and ( + allow_target_sha is None + or equal[0][1] != f"desktop-v{version}" + or equal[0][2] != allow_target_sha + ) ): detail = ", ".join(item[1] for item in newer + equal) raise SystemExit(f"desktop release version must increase beyond existing tags: {detail}") diff --git a/scripts/test-desktop-release-candidate.sh b/scripts/test-desktop-release-candidate.sh index ca84212b25..aa9e773d12 100755 --- a/scripts/test-desktop-release-candidate.sh +++ b/scripts/test-desktop-release-candidate.sh @@ -91,6 +91,26 @@ PY echo "validator accepted an equal-version tag at the wrong SHA" >&2; exit 1 fi git tag -d desktop-v1.0.1 >/dev/null + + # Numeric equality is not enough for prereleases: desktop-v1.0.1 is not the + # target tag desktop-v1.0.1-beta, even when both resolve to the candidate. + python3 - "$candidate" <<'PY' +import importlib.util +import pathlib +import sys + +candidate = sys.argv[1] +spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py")) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +module.stable_tags = lambda: [((1, 0, 1), "desktop-v1.0.1", candidate)] +try: + module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate) +except SystemExit: + pass +else: + raise SystemExit("validator accepted a mismatched stable tag for a prerelease target") +PY ) # Equal and decreasing versions are rejected before any GitHub lookup. diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 0a8d504e0a..a42f437efd 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -67,6 +67,10 @@ 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" candidate_workflow="$repo_root/.github/workflows/desktop-release-candidate.yml" +grep -Eq '^ pull-requests: read$' "$candidate_workflow" || { + echo "desktop candidate token cannot read pull requests for prior-release lookup" >&2 + exit 1 +} grep -Fq 'GH_TOKEN: ${{ github.token }}' "$candidate_workflow" || { echo "desktop candidate validation has no GitHub token for prior-release lookup" >&2 exit 1 From 400102d4a38f2e48535f0fabb51ae10bec312b53 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 09:14:19 -0600 Subject: [PATCH 4/4] fix(release): enforce prerelease tag collisions Check the literal target tag independently from stable prior-release ledgers so prerelease retries require the exact candidate SHA and wrong-SHA collisions fail during candidate validation. Co-authored-by: Carl Signed-off-by: Wes --- scripts/desktop_release.py | 30 +++++++++---- scripts/test-desktop-release-candidate.sh | 51 +++++++++++++++++------ 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py index d88e5a3ac8..ce9ceaab93 100755 --- a/scripts/desktop_release.py +++ b/scripts/desktop_release.py @@ -88,17 +88,31 @@ 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] - if newer or ( - equal and ( - allow_target_sha is None - or equal[0][1] != f"desktop-v{version}" - or equal[0][2] != allow_target_sha - ) - ): - detail = ", ".join(item[1] for item in newer + equal) + 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: diff --git a/scripts/test-desktop-release-candidate.sh b/scripts/test-desktop-release-candidate.sh index aa9e773d12..c63a157c00 100755 --- a/scripts/test-desktop-release-candidate.sh +++ b/scripts/test-desktop-release-candidate.sh @@ -92,25 +92,52 @@ PY fi git tag -d desktop-v1.0.1 >/dev/null - # Numeric equality is not enough for prereleases: desktop-v1.0.1 is not the - # target tag desktop-v1.0.1-beta, even when both resolve to the candidate. - python3 - "$candidate" <<'PY' + # Prerelease tags are not prior-release ledgers, but the exact target tag is + # still a collision boundary: same-SHA retry passes; wrong-SHA reuse fails. + git -c tag.gpgSign=false tag desktop-v1.0.1-beta "$candidate" + PATH="$mock_bin:$PATH" python3 - <<'PY' import importlib.util import pathlib -import sys -candidate = sys.argv[1] spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py")) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) -module.stable_tags = lambda: [((1, 0, 1), "desktop-v1.0.1", candidate)] -try: - module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate) -except SystemExit: - pass -else: - raise SystemExit("validator accepted a mismatched stable tag for a prerelease target") +candidate = module.git("rev-parse", "HEAD") +module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate) PY + git -c tag.gpgSign=false tag -f desktop-v1.0.1-beta "$base" >/dev/null + if PATH="$mock_bin:$PATH" python3 - <<'PY' +import importlib.util +import pathlib + +spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py")) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +candidate = module.git("rev-parse", "HEAD") +module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate) +PY + then + echo "validator accepted a prerelease target tag at the wrong SHA" >&2; exit 1 + fi + git tag -d desktop-v1.0.1-beta >/dev/null + + # A stable tag with the same numeric tuple is a different tag and cannot + # authorize a prerelease retry, even when it points at the candidate. + git -c tag.gpgSign=false tag desktop-v1.0.1 "$candidate" + if PATH="$mock_bin:$PATH" python3 - <<'PY' +import importlib.util +import pathlib + +spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py")) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +candidate = module.git("rev-parse", "HEAD") +module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate) +PY + then + echo "validator accepted a mismatched stable tag for a prerelease target" >&2; exit 1 + fi + git tag -d desktop-v1.0.1 >/dev/null ) # Equal and decreasing versions are rejected before any GitHub lookup.