diff --git a/.github/scripts/check-desktop-prod-promotion-policy.py b/.github/scripts/check-desktop-prod-promotion-policy.py index 68db36d11bb..7afe321c5ef 100755 --- a/.github/scripts/check-desktop-prod-promotion-policy.py +++ b/.github/scripts/check-desktop-prod-promotion-policy.py @@ -54,6 +54,7 @@ def main() -> int: fail(f"prod promotion must allow only workflow_dispatch, got: {', '.join(triggers) or ''}") require("release_tag:", text, "manual promotion must require an explicit release tag") require("confirm:", text, "manual promotion must require an explicit confirmation input") + require("python_backend_sha:", text, "manual promotion must require an explicit compatible python backend SHA") require("promote-stable", text, "manual promotion confirmation phrase must remain explicit") if "\n force:" in text or "inputs.force" in text: fail("prod promotion must stay roll-forward only; do not expose a force rollback input") @@ -70,6 +71,26 @@ def main() -> int: fail(f"desktop backend prod promotion must not use automatic trigger {trigger.strip()}") require("check-desktop-release-promotion.py", text, "workflow must run pre-release sanity checks") + require( + "check-desktop-python-backend-coupling.py", + text, + "workflow must validate attested python-backend coupling via the extracted checker", + ) + require("--desktop-target-sha", text, "workflow must pass the desktop target SHA to the coupling checker") + require("--python-backend-sha", text, "workflow must pass the attested python-backend SHA to the coupling checker") + require( + "python-backend-bless-${PYTHON_BACKEND_SHA}", + text, + "workflow must look up the explicit python-backend attestation", + ) + require("--tag-sha", text, "workflow must verify the python-backend attestation tag points to the explicit SHA") + require( + "backend/scripts/bless-python-backend.sh", + text, + "workflow must tell operators how to create python-backend attestation", + ) + require("COUPLING_CHECK_ARGS=(", text, "workflow must keep coupling-check args populated under set -u") + require("live_prod_check: NOT_RUN", text, "workflow must honestly report that live prod SHA matching is not run") require("--break-glass", text, "workflow must expose an audited emergency bypass") require("--break-glass-confirm", text, "workflow must require typed break-glass confirmation") require("--break-glass-reason", text, "workflow must require a break-glass audit rationale") @@ -81,6 +102,12 @@ def main() -> int: text, "workflow must keep promotion-check arguments populated when break glass is disabled under set -u", ) + if "I-ACCEPT-UNBLESSED-PROD-RISK" in text: + fail("desktop prod promotion must not hardcode the python-backend unblessed override phrase") + if "--override-unblessed" in text: + fail("desktop prod promotion must not call check-python-backend-blessing.py with override flags") + if "git fetch --depth=1" in text: + fail("desktop prod promotion must not shallow-fetch the attested python-backend SHA") if "BREAK_GLASS_ARGS=()" in text: fail("normal stable promotion must not expand an empty optional array under set -u") require( diff --git a/.github/scripts/check-desktop-python-backend-coupling.py b/.github/scripts/check-desktop-python-backend-coupling.py new file mode 100755 index 00000000000..541fdbd1ad4 --- /dev/null +++ b/.github/scripts/check-desktop-python-backend-coupling.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Validate desktop stable promotion against an attested python-backend SHA. + +Happy path: require a full 40-char attested SHA, validate the matching +python-backend-bless- release via check-python-backend-blessing.py (no +override flags), then require that SHA is a git ancestor of the desktop target. + +Break-glass: when the desktop stable-promotion risk phrase and a single-line +reason are present, skip the entire coupling gate. Do not bridge to the +python-backend unblessed override phrase. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +BREAK_GLASS_CONFIRM = "I-ACCEPT-STABLE-PROMOTION-RISK" +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +SCRIPT_DIR = Path(__file__).resolve().parent +BLESSING_CHECKER = SCRIPT_DIR / "check-python-backend-blessing.py" + + +def fail(message: str) -> None: + raise SystemExit(f"FAIL: {message}") + + +def write_github_output(path: str | None, values: dict[str, str]) -> None: + if not path: + return + with Path(path).open("a", encoding="utf-8") as output: + for key, value in values.items(): + print(f"{key}={value}", file=output) + + +def parse_break_glass(args: argparse.Namespace) -> bool: + break_glass_reason = args.break_glass_reason.strip() + break_glass = ( + args.break_glass + and args.break_glass_confirm == BREAK_GLASS_CONFIRM + and bool(break_glass_reason) + and "\n" not in break_glass_reason + and "\r" not in break_glass_reason + ) + if args.break_glass and not break_glass: + fail( + f"--break-glass requires --break-glass-confirm {BREAK_GLASS_CONFIRM} " + "and a non-empty --break-glass-reason" + ) + return break_glass + + +def is_ancestor(python_sha: str, desktop_sha: str, test_is_ancestor: str | None) -> bool: + if test_is_ancestor is not None: + return test_is_ancestor == "true" + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", python_sha, desktop_sha], + check=False, + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def validate_attestation(release_json: str, python_sha: str, tag_sha: str | None) -> None: + if not release_json: + fail("--release-json is required when validating python-backend coupling") + cmd = [ + sys.executable, + str(BLESSING_CHECKER), + "--release-json", + release_json, + "--target-sha", + python_sha, + ] + if tag_sha: + cmd.extend(["--tag-sha", tag_sha]) + result = subprocess.run(cmd, check=False, capture_output=True, text=True) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "python-backend blessing check failed").strip() + fail(detail) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--desktop-target-sha", required=True) + parser.add_argument("--python-backend-sha", default="") + parser.add_argument("--release-json", default="") + parser.add_argument("--tag-sha", default="") + parser.add_argument("--github-output") + parser.add_argument( + "--break-glass", + action="store_true", + help="DANGER: skip the entire python-backend coupling gate", + ) + parser.add_argument( + "--break-glass-confirm", + default="", + help=f"Must be {BREAK_GLASS_CONFIRM} when --break-glass is set", + ) + parser.add_argument("--break-glass-reason", default="", help="Required audit rationale for --break-glass") + parser.add_argument( + "--test-is-ancestor", + choices=("true", "false"), + help="Test-only ancestor override; if unset, call real git merge-base", + ) + args = parser.parse_args() + + if parse_break_glass(args): + message = ( + "python-backend coupling skipped by audited desktop break-glass " + f"({BREAK_GLASS_CONFIRM}): {args.break_glass_reason.strip()}" + ) + print(message) + write_github_output( + args.github_output, + {"python_backend_coupling": "skipped_break_glass"}, + ) + return 0 + + python_sha = args.python_backend_sha.strip() + if not python_sha: + fail( + "python_backend_sha is required for desktop stable promotion. " + "Provide the full 40-char SHA from python-backend-bless-, " + "or enable audited break glass." + ) + if not SHA_RE.match(python_sha): + fail("--python-backend-sha must be a full 40-char lowercase git SHA") + + desktop_sha = args.desktop_target_sha.strip() + if not SHA_RE.match(desktop_sha): + fail("--desktop-target-sha must be a full 40-char lowercase git SHA") + + validate_attestation(args.release_json.strip(), python_sha, args.tag_sha.strip() or None) + + if not is_ancestor(python_sha, desktop_sha, args.test_is_ancestor): + fail(f"python_backend_sha ({python_sha}) is not an ancestor of desktop target ({desktop_sha})") + + write_github_output( + args.github_output, + { + "python_backend_attested_sha": python_sha, + "python_backend_coupling": "ok", + }, + ) + print(f"python-backend coupling OK: attested {python_sha} is ancestor of {desktop_sha}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/check-desktop-release-terminology.py b/.github/scripts/check-desktop-release-terminology.py index d9e6925c36c..0318e2c05ac 100755 --- a/.github/scripts/check-desktop-release-terminology.py +++ b/.github/scripts/check-desktop-release-terminology.py @@ -20,6 +20,19 @@ "web/admin/app/(protected)/dashboard/releases/page.tsx", ) +# Protocol IDs for the python-backend attestation surface may appear on operator +# surfaces without a "legacy" disclaimer. Desktop beta qualification still must +# not use bare bless/blessed/blessing wording. +PROTOCOL_ALLOWLIST = ( + "python-backend-bless-", + "bless-python-backend.sh", + "check-python-backend-blessing.py", +) + + +def _is_protocol_mention(line: str) -> bool: + return any(token in line for token in PROTOCOL_ALLOWLIST) + def main() -> int: failures: list[str] = [] @@ -27,7 +40,7 @@ def main() -> int: for relative in OPERATOR_SURFACES: path = ROOT / relative for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): - if legacy_term.search(line) and "legacy" not in line.lower(): + if legacy_term.search(line) and "legacy" not in line.lower() and not _is_protocol_mention(line): failures.append(f"{relative}:{line_number}: desktop beta qualification uses legacy terminology") canonical_script = ROOT / "desktop/macos/scripts/qualify-desktop-beta.sh" diff --git a/.github/scripts/test_check_desktop_python_backend_coupling.py b/.github/scripts/test_check_desktop_python_backend_coupling.py new file mode 100755 index 00000000000..a3946159e4c --- /dev/null +++ b/.github/scripts/test_check_desktop_python_backend_coupling.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Hermetic tests for desktop ↔ python-backend coupling gate.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / ".github/scripts/check-desktop-python-backend-coupling.py" +DESKTOP_SHA = "a" * 40 +PYTHON_SHA = "b" * 40 +OTHER_SHA = "c" * 40 +BREAK_GLASS_CONFIRM = "I-ACCEPT-STABLE-PROMOTION-RISK" + + +def blessing_release(sha: str) -> dict: + evidence = f"python-backend-bless-evidence-{sha}.json" + lines = [ + "KEY_VALUE_START", + "blessed.python-backend: true", + f"blessed.python-backend.sha: {sha}", + "blessed.python-backend.at: 2026-07-10T00:00:00Z", + "blessed.python-backend.tier: unit+workflow-contracts+openapi", + f"blessed.python-backend.evidence: {evidence}", + "KEY_VALUE_END", + ] + return { + "tagName": f"python-backend-bless-{sha}", + "isDraft": False, + "isPrerelease": False, + "body": "\n".join(lines), + "assets": [{"name": evidence}], + } + + +def run_check(*extra: str, release: dict | None = None) -> subprocess.CompletedProcess[str]: + cmd = [ + sys.executable, + str(SCRIPT), + "--desktop-target-sha", + DESKTOP_SHA, + *extra, + ] + with tempfile.TemporaryDirectory() as tmp: + if release is not None: + release_path = Path(tmp) / "python-backend-attestation.json" + release_path.write_text(json.dumps(release), encoding="utf-8") + cmd.extend(["--release-json", str(release_path)]) + return subprocess.run(cmd, text=True, capture_output=True) + + +def test_attested_ancestor_passes() -> None: + result = run_check( + "--python-backend-sha", + PYTHON_SHA, + "--tag-sha", + PYTHON_SHA, + "--test-is-ancestor", + "true", + release=blessing_release(PYTHON_SHA), + ) + assert result.returncode == 0, result.stderr + assert "coupling OK" in result.stdout + + +def test_missing_sha_fails() -> None: + result = run_check() + assert result.returncode != 0 + assert "python_backend_sha is required" in result.stderr + + +def test_missing_sha_with_break_glass_passes() -> None: + result = run_check( + "--break-glass", + "--break-glass-confirm", + BREAK_GLASS_CONFIRM, + "--break-glass-reason", + "urgent stable hotfix without attested backend", + ) + assert result.returncode == 0, result.stderr + assert "skipped by audited desktop break-glass" in result.stdout + + +def test_bad_attestation_fails() -> None: + result = run_check( + "--python-backend-sha", + PYTHON_SHA, + "--test-is-ancestor", + "true", + release={"tagName": "wrong-tag", "body": "", "assets": []}, + ) + assert result.returncode != 0 + assert "blessing tag mismatch" in result.stderr or "FAIL:" in result.stderr + + +def test_non_ancestor_fails() -> None: + result = run_check( + "--python-backend-sha", + PYTHON_SHA, + "--tag-sha", + PYTHON_SHA, + "--test-is-ancestor", + "false", + release=blessing_release(PYTHON_SHA), + ) + assert result.returncode != 0 + assert "not an ancestor" in result.stderr + + +def test_non_ancestor_with_break_glass_passes() -> None: + result = run_check( + "--python-backend-sha", + OTHER_SHA, + "--test-is-ancestor", + "false", + "--break-glass", + "--break-glass-confirm", + BREAK_GLASS_CONFIRM, + "--break-glass-reason", + "promote despite non-ancestor attested sha", + release=blessing_release(OTHER_SHA), + ) + assert result.returncode == 0, result.stderr + assert "skipped by audited desktop break-glass" in result.stdout + + +def test_break_glass_requires_stable_phrase() -> None: + rejected = run_check( + "--break-glass", + "--break-glass-confirm", + "I-ACCEPT-UNBLESSED-PROD-RISK", + "--break-glass-reason", + "wrong phrase must not skip coupling", + ) + assert rejected.returncode != 0 + assert BREAK_GLASS_CONFIRM in rejected.stderr + + +def test_invalid_sha_format_fails() -> None: + result = run_check( + "--python-backend-sha", + "abc123", + "--test-is-ancestor", + "true", + release=blessing_release(PYTHON_SHA), + ) + assert result.returncode != 0 + assert "40-char" in result.stderr + + +def test_break_glass_path_does_not_need_unblessed_phrase() -> None: + result = run_check( + "--python-backend-sha", + PYTHON_SHA, + "--break-glass", + "--break-glass-confirm", + BREAK_GLASS_CONFIRM, + "--break-glass-reason", + "skip coupling without unblessed override bridge", + ) + assert result.returncode == 0, result.stderr + combined = f"{result.stdout}\n{result.stderr}" + assert "I-ACCEPT-UNBLESSED-PROD-RISK" not in combined + assert "--override-unblessed" not in combined + + +if __name__ == "__main__": + test_attested_ancestor_passes() + test_missing_sha_fails() + test_missing_sha_with_break_glass_passes() + test_bad_attestation_fails() + test_non_ancestor_fails() + test_non_ancestor_with_break_glass_passes() + test_break_glass_requires_stable_phrase() + test_invalid_sha_format_fails() + test_break_glass_path_does_not_need_unblessed_phrase() + print("check-desktop-python-backend-coupling tests OK") diff --git a/.github/workflows/desktop_promote_prod.yml b/.github/workflows/desktop_promote_prod.yml index 1d78b51e576..21e482d521e 100644 --- a/.github/workflows/desktop_promote_prod.yml +++ b/.github/workflows/desktop_promote_prod.yml @@ -21,6 +21,10 @@ on: confirm: description: 'Type promote-stable to deploy prod and mark the release stable' required: true + python_backend_sha: + description: 'Full 40-char SHA from python-backend-bless- (attestation tag; not Cloud Run short tag; not desktop tag SHA)' + required: false + default: '' break_glass: description: 'DANGER: bypass qualification and stable-candidate gates' required: false @@ -70,6 +74,7 @@ jobs: REPO: ${{ github.repository }} RELEASE_TAG: ${{ inputs.release_tag }} CONFIRM: ${{ inputs.confirm }} + PYTHON_BACKEND_SHA: ${{ inputs.python_backend_sha }} BREAK_GLASS: ${{ inputs.break_glass }} BREAK_GLASS_CONFIRM: ${{ inputs.break_glass_confirm }} BREAK_GLASS_REASON: ${{ inputs.break_glass_reason }} @@ -114,6 +119,49 @@ jobs: python3 .github/scripts/check-desktop-release-promotion.py \ "${PROMOTION_CHECK_ARGS[@]}" + COUPLING_CHECK_ARGS=( + --desktop-target-sha "$TARGET_SHA" + --python-backend-sha "$PYTHON_BACKEND_SHA" + --github-output "$GITHUB_OUTPUT" + ) + if [ "$BREAK_GLASS" = "true" ]; then + COUPLING_CHECK_ARGS+=( + --break-glass + --break-glass-confirm "$BREAK_GLASS_CONFIRM" + --break-glass-reason "$BREAK_GLASS_REASON" + ) + echo "::warning::Audited break glass skips the entire python-backend coupling gate for $RELEASE_TAG" + echo "Audited break glass skips the entire python-backend coupling gate for $RELEASE_TAG" >> "$GITHUB_STEP_SUMMARY" + elif [ -n "$PYTHON_BACKEND_SHA" ]; then + if ! git cat-file -e "${PYTHON_BACKEND_SHA}^{commit}" 2>/dev/null; then + git fetch origin "$PYTHON_BACKEND_SHA" + fi + PYTHON_BACKEND_ATTEST_TAG="python-backend-bless-${PYTHON_BACKEND_SHA}" + git fetch --force --tags origin "+refs/tags/${PYTHON_BACKEND_ATTEST_TAG}:refs/tags/${PYTHON_BACKEND_ATTEST_TAG}" || true + if ! gh release view "$PYTHON_BACKEND_ATTEST_TAG" --repo "$REPO" \ + --json tagName,body,isDraft,isPrerelease,assets \ + > /tmp/python-backend-attestation.json; then + echo "Missing python-backend attestation release: ${PYTHON_BACKEND_ATTEST_TAG}" >&2 + echo "Create it with backend/scripts/bless-python-backend.sh." >&2 + exit 1 + fi + COUPLING_CHECK_ARGS+=( + --release-json /tmp/python-backend-attestation.json + --tag-sha "$(git rev-list -n1 "$PYTHON_BACKEND_ATTEST_TAG" 2>/dev/null || true)" + ) + fi + + python3 .github/scripts/check-desktop-python-backend-coupling.py \ + "${COUPLING_CHECK_ARGS[@]}" + + ATTESTED_SHA=$(grep '^python_backend_attested_sha=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2- || true) + COUPLING_RESULT=$(grep '^python_backend_coupling=' "$GITHUB_OUTPUT" | tail -n1 | cut -d= -f2- || true) + { + echo "python_backend_attested_sha: ${ATTESTED_SHA:-missing}" + echo "python_backend_coupling: ${COUPLING_RESULT:-unknown}" + echo "live_prod_check: NOT_RUN" + } >> "$GITHUB_STEP_SUMMARY" + CURRENT_SHA=$(git rev-list -n1 "$TRACK_TAG" 2>/dev/null || true) if ! git grep -q "OMI_DESKTOP_RELEASE_TAG" "$TARGET_SHA" -- desktop/macos/Backend-Rust/src \ diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index 9614c4e0b4f..0819d5bf96d 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -116,6 +116,9 @@ jobs: - name: Test desktop release promotion gate run: python3 .github/scripts/test_check_desktop_release_promotion.py + - name: Test desktop python-backend coupling gate + run: python3 .github/scripts/test_check_desktop_python_backend_coupling.py + - name: Test automatic desktop beta candidate gate run: python3 .github/scripts/test_check_desktop_auto_beta_candidate.py diff --git a/AGENTS.md b/AGENTS.md index 13a2740c697..c99914fb852 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,7 +345,7 @@ Full RELEASE flow + `gh workflow run gcp_backend.yml -f environment=prod -f bran ## CI/CD & Logs -- Desktop release pipeline: `.github/workflows/desktop_auto_release.yml` batches `main` into at most one daily `v*-macos` build candidate. Codemagic builds/signs/notarizes it non-live, verifies published digests against signed-smoke evidence, then runs static checks, hermetic T2, and the fault suite before automatically dispatching beta promotion for the newest tag. `DESKTOP_AUTO_BETA_ENABLED=false` pauses automatic beta. Before stable, `.github/workflows/desktop_nominate_stable_candidate.yml` records soak, telemetry, release-note, and qualification evidence without changing either channel pointer. Stable/prod remains manual-only through `.github/workflows/desktop_promote_prod.yml` with `release_tag` and `confirm=promote-stable`; it accepts a nominated stable candidate by default, is roll-forward only, and advances stable only after backend verification. Desktop Rust backend deploys require environment-scoped `DESKTOP_BACKEND_BASE_API_URL` so OAuth callbacks set runtime `BASE_API_URL`. +- Desktop release pipeline: `.github/workflows/desktop_auto_release.yml` batches `main` into at most one daily `v*-macos` build candidate. Codemagic builds/signs/notarizes it non-live, verifies published digests against signed-smoke evidence, then runs static checks, hermetic T2, and the fault suite before automatically dispatching beta promotion for the newest tag. `DESKTOP_AUTO_BETA_ENABLED=false` pauses automatic beta. Use `desktop/macos/scripts/qualify-desktop-beta.sh ` only for manual recovery. Before stable, `.github/workflows/desktop_nominate_stable_candidate.yml` records soak, telemetry, release-note, and qualification evidence without changing either channel pointer. Stable/prod requires `desktop/macos/docs/agent-prod-promotion-runbook.md`, then manual-only `.github/workflows/desktop_promote_prod.yml` with `release_tag`, `confirm=promote-stable`, and an attested `python_backend_sha` (ancestor of the desktop tag; attestation ≠ live prod); it accepts a nominated stable candidate by default, is roll-forward only, and advances the stable pointer only after backend verification. Desktop Rust backend deploys require environment-scoped `DESKTOP_BACKEND_BASE_API_URL` so OAuth callbacks set runtime `BASE_API_URL`. - Backend deploy: `gh workflow run gcp_backend.yml -f environment=prod -f branch=main`. - Firmware release (Omi CV1): manual `.github/workflows/firmware_release.yml`. Bump `CONFIG_BT_DIS_FW_REV_STR` in `omi/firmware/omi/omi.conf` first, then `gh workflow run firmware_release.yml -f publish=publish -f changelog="..." -f minimum_app_version_code=...` (omit `publish` for a build-only QA run). It builds via Docker (NCS 2.9.0 sysbuild + MCUboot), names the OTA asset `Omi_CV1_OTA_v.zip` (the "ota" substring is required), and publishes a `Omi_CV1_v` GitHub Release with the `KEY_VALUE` body that `backend/routers/firmware.py` serves. Build logic lives in `omi/firmware/scripts/ci/`. diff --git a/desktop/macos/AGENTS.md b/desktop/macos/AGENTS.md index f6f0b20469e..b9a2dd68d05 100644 --- a/desktop/macos/AGENTS.md +++ b/desktop/macos/AGENTS.md @@ -55,7 +55,7 @@ Stable/prod is manual: - Automatic qualification never nominates or promotes stable. Stable workflows remain `workflow_dispatch` only. - Nominate the current qualified beta with `desktop_nominate_stable_candidate.yml`. Nomination records the tag/SHA, operator, rationale, soak review, telemetry review, release-note review, and qualification evidence. It never changes beta/stable pointers or deploys production. - Before preparing stable/prod promotion, follow `docs/agent-prod-promotion-runbook.md` for target discovery, curated stable release-log creation, shared-backend coupling, approval shape, and deterministic post-promotion checks. External readiness is handled separately. -- Run GitHub Actions workflow `desktop_promote_prod.yml` with the nominated `release_tag=v*-macos` stable candidate and `confirm=promote-stable`. +- Run GitHub Actions workflow `desktop_promote_prod.yml` with the nominated `release_tag=v*-macos` stable candidate, `confirm=promote-stable`, and `python_backend_sha=`. - The workflow runs `.github/scripts/check-desktop-release-promotion.py`, deploys the Rust backend from that exact tag, verifies `/health` reports the release tag/SHA, promotes the Firestore bridge release, marks the GitHub release `channel: stable`, then moves `desktop-backend-prod-deployed`. - Do not manually edit a release to stable before the backend is promoted; the promotion workflow owns that mutation. - The promotion workflow is roll-forward only. Stable rollback needs a newer fixed release or a separate manual infrastructure rollback plan, because both desktop feeds choose the newest stable app release. diff --git a/desktop/macos/changelog/unreleased/20260710-desktop-python-backend-sha-gate.json b/desktop/macos/changelog/unreleased/20260710-desktop-python-backend-sha-gate.json new file mode 100644 index 00000000000..f61018f6d1d --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260710-desktop-python-backend-sha-gate.json @@ -0,0 +1,3 @@ +{ + "change": "Desktop stable promotion now requires a compatible attested Python backend SHA" +} diff --git a/desktop/macos/docs/agent-prod-promotion-runbook.md b/desktop/macos/docs/agent-prod-promotion-runbook.md index 57e0bb7d737..3a8b68d86c7 100644 --- a/desktop/macos/docs/agent-prod-promotion-runbook.md +++ b/desktop/macos/docs/agent-prod-promotion-runbook.md @@ -61,6 +61,7 @@ Confirm: - assets include `Omi.zip` and `omi.dmg`; - release body has `isLive: true`, `channel: beta`, and an `edSignature`; - release metadata includes canonical `qualifiedBeta: true`, `qualifiedBetaTier: 2`, `qualifiedBetaSha` matching the tag commit, `qualifiedBetaAt`, and a published `qualifiedBetaEvidence` asset; legacy `blessed*` metadata remains valid for releases qualified before the migration; +- a compatible Python backend commit is attested by a `python-backend-bless-` release, and that attested SHA is a git ancestor of the desktop release tag (attestation ≠ live prod deploy; see §4); - live appcast beta/dev item points to the same build. For high-risk desktop auth/runtime releases, run the live signed-artifact canary @@ -140,13 +141,24 @@ Curate the final stable release log into these buckets: Include this curated log in the approval plan. Do not rely on the target release's per-build notes alone when the target spans multiple beta/dev builds since stable. -## 4. Decide shared backend coupling +## 4. Python-backend attestation vs deploy sequencing Desktop promotion always deploys the **Rust `desktop-backend`** from the exact `v*-macos` tag via `desktop_promote_prod.yml`. -The **shared Python backend** is separate (`gcp_backend.yml`). It is not always required, but many desktop capabilities are coupled to shared backend routes, schemas, OAuth clients, feature flags, or response contracts. +### 4a. Coupling attestation (required at promote) -Before approval, classify shared backend as: +Stable promotion must name a `python_backend_sha` that is: + +1. the full 40-character SHA from an existing `python-backend-bless-` GitHub Release (create/refresh with `backend/scripts/bless-python-backend.sh`), and +2. a git ancestor of the desktop release tag SHA. + +The promote workflow validates that attestation + ancestry gate. **It does not prove live Cloud Run / GKE prod is already running that SHA** (prod image tags are short SHAs; the workflow records `live_prod_check: NOT_RUN`). Live proof stays here in the runbook. + +### 4b. Deploy sequencing (`backend_required` / phase) + +The **shared Python backend** deploy is separate (`gcp_backend.yml`). Attestation does not replace deciding whether a live backend deploy is needed for the desktop capability. + +Before approval, classify shared backend deploy sequencing as: ```text backend_required: yes | no | optional @@ -224,12 +236,16 @@ End with an explicit confirmation question naming the exact tag and phase(s). Desktop stable promotion: +Use the full 40-character SHA from the compatible `python-backend-bless-` GitHub Release (attestation tag; not a Cloud Run short tag; not the desktop tag SHA). Create or refresh that attestation with `backend/scripts/bless-python-backend.sh` when needed. This gate checks attestation + ancestry only; it does not prove live prod is on that SHA. + ```bash RELEASE_TAG='vX.Y.Z+BUILD-macos' +PYTHON_BACKEND_SHA='<40-char attested python-backend SHA>' gh workflow run desktop_promote_prod.yml \ --repo BasedHardware/omi \ -f release_tag="$RELEASE_TAG" \ + -f python_backend_sha="$PYTHON_BACKEND_SHA" \ -f confirm='promote-stable' ```