diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 6fefb0ab8..e748636ce 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -47,6 +47,14 @@ on: required: false type: boolean default: false + mobile_release_decision: + description: 'Required mobile impact decision: no-mobile-impact, existing-mobile-build-compatible, mobile-candidate-uploaded, or mobile-candidate-required' + required: true + type: string + mobile_release_evidence: + description: 'Evidence for existing-mobile-build-compatible or mobile-candidate-uploaded decisions' + required: false + type: string concurrency: group: release-${{ github.event.inputs.version || github.ref || github.run_id }} @@ -113,6 +121,7 @@ jobs: VERSION docs/release-control/control_plane.json scripts/release_control/control_plane.py + scripts/release_control/mobile_release_gate.py scripts/release_control/repo_file_io.py - name: Resolve required release branch @@ -138,6 +147,19 @@ jobs: fi echo "[OK] VERSION file matches requested version ($REQUESTED_VERSION)" + - name: Validate mobile release decision + if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }} + env: + MOBILE_RELEASE_DECISION: ${{ github.event.inputs.mobile_release_decision }} + MOBILE_RELEASE_EVIDENCE: ${{ github.event.inputs.mobile_release_evidence }} + run: | + set -euo pipefail + python3 scripts/release_control/mobile_release_gate.py \ + --version "${{ steps.extract.outputs.version }}" \ + --decision "${MOBILE_RELEASE_DECISION}" \ + --evidence "${MOBILE_RELEASE_EVIDENCE}" \ + --github-annotations + - name: Validate promotion policy if: ${{ steps.extract.outputs.historical_asset_backfill_only != 'true' }} id: promotion diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 02eb43b98..9ee6dbffb 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -44,6 +44,14 @@ on: description: 'Optional note/reason for the dry run' required: false type: string + mobile_release_decision: + description: 'Mobile impact decision: no-mobile-impact, existing-mobile-build-compatible, mobile-candidate-uploaded, or mobile-candidate-required' + required: false + type: string + mobile_release_evidence: + description: 'Evidence for existing-mobile-build-compatible or mobile-candidate-uploaded decisions' + required: false + type: string permissions: contents: read @@ -159,6 +167,26 @@ jobs: echo "[OK] Rehearsal metadata validated for ${TAG}" + - name: Validate mobile release decision + id: mobile_release + env: + EVENT_NAME: ${{ github.event_name }} + MOBILE_RELEASE_DECISION: ${{ inputs.mobile_release_decision }} + MOBILE_RELEASE_EVIDENCE: ${{ inputs.mobile_release_evidence }} + run: | + set -euo pipefail + DECISION="${MOBILE_RELEASE_DECISION:-}" + EVIDENCE="${MOBILE_RELEASE_EVIDENCE:-}" + if [ -z "${DECISION}" ] && [ "${EVENT_NAME}" = "schedule" ]; then + DECISION="no-mobile-impact" + EVIDENCE="Scheduled release dry-run watchdog; no mobile release packet is being dispatched." + fi + python3 scripts/release_control/mobile_release_gate.py \ + --version "${{ steps.rehearsal.outputs.version }}" \ + --decision "${DECISION}" \ + --evidence "${EVIDENCE}" \ + --github-annotations + - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.gitignore b/.gitignore index b05198541..e373d8815 100644 --- a/.gitignore +++ b/.gitignore @@ -239,6 +239,8 @@ scripts/release_control/* !scripts/release_control/governance_stage_guard.py !scripts/release_control/governance_stage_guard_test.py !scripts/release_control/mobile_relay_auth_approvals_proof.py +!scripts/release_control/mobile_release_gate.py +!scripts/release_control/mobile_release_gate_test.py !scripts/release_control/proof_entrypoints_test.py !scripts/release_control/readiness_assertion_guard.py !scripts/release_control/readiness_assertion_guard_test.py diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index ee94b4ca1..a839f3603 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -93,36 +93,38 @@ TLS floor in the dynamic config. 57. `scripts/release_control/record_rc_to_ga_rehearsal.py` 58. `scripts/release_control/release_promotion_policy_support.py` 59. `scripts/release_control/resolve_release_promotion.py` -60. `scripts/release_control/validate_artifact_release_line.py` -61. `scripts/release_ldflags.sh` -62. `scripts/run_cloud_public_signup_smoke.sh` -63. `scripts/run_demo_public_browser_smoke.sh` -64. `scripts/demo_public_browser_smoke.cjs` -65. `scripts/run_hosted_staging_smoke.sh` -66. `scripts/trigger-release-dry-run.sh` -67. `scripts/trigger-release.sh` -68. `scripts/toggle-mock.sh` -69. `deploy/provider-msp/` -70. `deploy/helm/pulse/` -70. `tests/integration/playwright.config.ts` -71. `tests/integration/QUICK_START.md` -72. `tests/integration/README.md` -73. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs` -74. `tests/integration/scripts/hosted-mobile-token-runtime.mjs` -75. `tests/integration/scripts/hosted-tenant-approval-store.mjs` -76. `tests/integration/scripts/hosted-tenant-runtime.mjs` -77. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs` -78. `tests/integration/scripts/managed-dev-runtime.mjs` -79. `tests/integration/scripts/relay-mobile-token-helper.go` -80. `tests/integration/tests/helpers.ts` -81. `tests/integration/tests/runtime-defaults.ts` -82. `docker-compose.yml` -83. `scripts/install-docker.sh` -84. `scripts/validate-published-release.sh` -85. `scripts/validate-release.sh` -86. `scripts/release_asset_common.sh` -87. `scripts/backfill-release-assets.sh` -88. `.github/workflows/backfill-release-assets.yml` +60. `scripts/release_control/mobile_release_gate.py` +61. `scripts/release_control/mobile_release_gate_test.py` +62. `scripts/release_control/validate_artifact_release_line.py` +63. `scripts/release_ldflags.sh` +64. `scripts/run_cloud_public_signup_smoke.sh` +65. `scripts/run_demo_public_browser_smoke.sh` +66. `scripts/demo_public_browser_smoke.cjs` +67. `scripts/run_hosted_staging_smoke.sh` +68. `scripts/trigger-release-dry-run.sh` +69. `scripts/trigger-release.sh` +70. `scripts/toggle-mock.sh` +71. `deploy/provider-msp/` +72. `deploy/helm/pulse/` +73. `tests/integration/playwright.config.ts` +74. `tests/integration/QUICK_START.md` +75. `tests/integration/README.md` +76. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs` +77. `tests/integration/scripts/hosted-mobile-token-runtime.mjs` +78. `tests/integration/scripts/hosted-tenant-approval-store.mjs` +79. `tests/integration/scripts/hosted-tenant-runtime.mjs` +80. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs` +81. `tests/integration/scripts/managed-dev-runtime.mjs` +82. `tests/integration/scripts/relay-mobile-token-helper.go` +83. `tests/integration/tests/helpers.ts` +84. `tests/integration/tests/runtime-defaults.ts` +85. `docker-compose.yml` +86. `scripts/install-docker.sh` +87. `scripts/validate-published-release.sh` +88. `scripts/validate-release.sh` +89. `scripts/release_asset_common.sh` +90. `scripts/backfill-release-assets.sh` +91. `.github/workflows/backfill-release-assets.yml` ## Shared Boundaries @@ -303,7 +305,7 @@ TLS floor in the dynamic config. ## Extension Points 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` -2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` +2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` Release-facing agent-paradigm blurbs under `docs/releases/` must describe `pulse-mcp` as a generic MCP adapter for MCP-speaking clients, not a client-specific release artifact, and full-surface token guidance must come @@ -470,7 +472,7 @@ TLS floor in the dynamic config. `PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS`) so live dev verification of host-side LXC Docker inventory does not silently restart into default-off monitoring. -6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` +6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/mobile_release_gate_test.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` That release-promotion boundary also owns prerelease note packet lineage: shipped RC notes must remain historically accurate, the top-level `docs/RELEASE_NOTES.md` index must continue to point at the current shipped @@ -808,12 +810,20 @@ TLS floor in the dynamic config. Whenever that policy changes, update the owning workflow/install proof files in `scripts/installtests/build_release_assets_test.go` and `scripts/release_control/release_promotion_policy_*` in the same slice. -11. Keep forward release signing pinned to an explicit trust root. Governed +11. Keep mobile impact explicit on governed server releases. Every release + publish and manual release dry run must record one of the canonical mobile + decisions (`no-mobile-impact`, `existing-mobile-build-compatible`, + `mobile-candidate-uploaded`, or `mobile-candidate-required`), and + `mobile-candidate-required` is a blocking state until the mobile candidate + is built/submitted and the release is rerun with `mobile-candidate-uploaded` + evidence. Compatibility or uploaded-candidate decisions must carry evidence + text rather than relying on memory. +12. Keep forward release signing pinned to an explicit trust root. Governed release scripts, Docker release builds, and historical backfill paths must accept the active private signing key only alongside a non-secret expected public key or equivalent pinned identity, and they must fail closed before publication if the signer drifts from that expected trust root. -12. When the governed update signer changes, the canonical operator-facing +13. When the governed update signer changes, the canonical operator-facing release docs under `docs/releases/` and the governed upgrade guide `docs/UPGRADE_v6.md` must state the continuity impact explicitly. Those docs must not imply automatic updater continuity from a historical signer unless @@ -1058,6 +1068,18 @@ unpublished tag must locate the existing draft release, retarget its git tag and release `target_commitish` to the current governed release-line head, and continue publication without requiring an operator to delete the tag manually; published tags remain immutable and must still fail closed. +That same release-dispatch boundary now also owns mobile impact gating for +server releases. `.github/workflows/create-release.yml`, +`.github/workflows/release-dry-run.yml`, `scripts/trigger-release.sh`, and +`scripts/trigger-release-dry-run.sh` must require an explicit mobile release +decision before a governed release packet can proceed. A server-only release may +record `no-mobile-impact`; a mobile/relay/onboarding/API-compatible release may +record `existing-mobile-build-compatible` with proof; a release that already +has a matching TestFlight/Play candidate may record `mobile-candidate-uploaded` +with build evidence; and `mobile-candidate-required` must fail closed until the +mobile candidate exists. This gate does not auto-submit App Store/TestFlight or +Play builds, but it prevents release packets from silently ignoring the mobile +track. That same upload boundary must tolerate transient GitHub release-asset API failures. `.github/workflows/create-release.yml` must retry every `gh release upload` operation with bounded backoff before failing the release diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 94e207f60..20b8404f5 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -3470,6 +3470,7 @@ "docs/UPGRADE_v6.md", "scripts/check-workflow-dispatch-inputs.py", "scripts/release_control/internal/record_rc_to_ga_rehearsal.py", + "scripts/release_control/mobile_release_gate.py", "scripts/release_control/record_rc_to_ga_rehearsal.py", "scripts/release_control/release_promotion_policy_support.py", "scripts/release_control/render_release_body.py", @@ -3484,6 +3485,7 @@ "exact_files": [ "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", diff --git a/scripts/release_control/mobile_release_gate.py b/scripts/release_control/mobile_release_gate.py new file mode 100644 index 000000000..610f4df23 --- /dev/null +++ b/scripts/release_control/mobile_release_gate.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Validate the explicit mobile-release decision for governed release dispatch.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +VALID_DECISIONS = ( + "no-mobile-impact", + "existing-mobile-build-compatible", + "mobile-candidate-uploaded", + "mobile-candidate-required", +) + +EVIDENCE_REQUIRED_DECISIONS = { + "existing-mobile-build-compatible", + "mobile-candidate-uploaded", +} + +BLOCKING_DECISION = "mobile-candidate-required" + + +def normalize_decision(value: str) -> str: + return value.strip().lower().replace("_", "-") + + +def validate_mobile_release_decision( + decision: str, + evidence: str, +) -> list[str]: + normalized = normalize_decision(decision) + trimmed_evidence = evidence.strip() + errors: list[str] = [] + + if not normalized: + return [ + "mobile_release_decision is required. Choose one of: " + + ", ".join(VALID_DECISIONS) + ] + + if normalized not in VALID_DECISIONS: + return [ + f"unknown mobile_release_decision {decision!r}. Choose one of: " + + ", ".join(VALID_DECISIONS) + ] + + if normalized == BLOCKING_DECISION: + errors.append( + "mobile-candidate-required is a blocking release state. " + "Build and submit the required mobile candidate first, then rerun " + "with mobile-candidate-uploaded and evidence." + ) + + if normalized in EVIDENCE_REQUIRED_DECISIONS and not trimmed_evidence: + errors.append( + f"mobile_release_evidence is required for {normalized}. " + "Record the compatible build proof or the uploaded candidate build." + ) + + return errors + + +def _run_git(repo: Path, args: list[str]) -> str: + try: + completed = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except (OSError, subprocess.CalledProcessError): + return "" + return completed.stdout.strip() + + +def _read_json(path: Path) -> dict[str, object]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def summarize_mobile_repo(repo: Path) -> str: + lines: list[str] = [f"Pulse Mobile release context: {repo}"] + + if not (repo / ".git").exists(): + lines.append(" pulse-mobile repo not found; make an explicit mobile release decision manually.") + return "\n".join(lines) + + app = _read_json(repo / "app.json") + expo = app.get("expo") if isinstance(app.get("expo"), dict) else {} + ios = expo.get("ios") if isinstance(expo.get("ios"), dict) else {} + android = expo.get("android") if isinstance(expo.get("android"), dict) else {} + readiness = _read_json(repo / "store" / "release-readiness.json") + current = readiness.get("currentCandidate") if isinstance(readiness.get("currentCandidate"), dict) else {} + + lines.append( + " app.json: " + f"version={expo.get('version', '-')}, " + f"iosBuildNumber={ios.get('buildNumber', '-')}, " + f"androidVersionCode={android.get('versionCode', '-')}" + ) + lines.append( + " release-readiness currentCandidate: " + f"marketingVersion={current.get('marketingVersion', '-')}, " + f"iosBuildNumber={current.get('iosBuildNumber', '-')}, " + f"androidVersionCode={current.get('androidVersionCode', '-')}, " + f"recordedOn={current.get('recordedOn', '-')}" + ) + + recorded_on = str(current.get("recordedOn") or "").strip() + if recorded_on: + recent = _run_git( + repo, + ["log", f"--since={recorded_on}T00:00:00", "--oneline", "--max-count=8"], + ) + if recent: + lines.append(" commits since recorded candidate:") + lines.extend(f" {line}" for line in recent.splitlines()) + else: + lines.append(" commits since recorded candidate: none") + else: + recent = _run_git(repo, ["log", "--oneline", "--max-count=5"]) + if recent: + lines.append(" recent commits:") + lines.extend(f" {line}" for line in recent.splitlines()) + + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate the mobile impact decision required by release dispatch." + ) + parser.add_argument("--decision", default="") + parser.add_argument("--evidence", default="") + parser.add_argument("--version", default="") + parser.add_argument("--mobile-repo", default="") + parser.add_argument("--summary-only", action="store_true") + parser.add_argument("--github-annotations", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if args.mobile_repo: + print(summarize_mobile_repo(Path(args.mobile_repo))) + if args.summary_only: + return 0 + + errors = validate_mobile_release_decision(args.decision, args.evidence) + if errors: + for error in errors: + if args.github_annotations: + print(f"::error::{error}", file=sys.stderr) + else: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + normalized = normalize_decision(args.decision) + version_context = f" for {args.version}" if args.version else "" + print(f"[OK] Mobile release decision{version_context}: {normalized}") + if args.evidence.strip(): + print(f"[OK] Mobile release evidence: {args.evidence.strip()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/mobile_release_gate_test.py b/scripts/release_control/mobile_release_gate_test.py new file mode 100644 index 000000000..f46ce6148 --- /dev/null +++ b/scripts/release_control/mobile_release_gate_test.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Tests for the governed mobile release decision gate.""" + +from __future__ import annotations + +import unittest + +from mobile_release_gate import normalize_decision, validate_mobile_release_decision + + +class MobileReleaseGateTest(unittest.TestCase): + def test_no_mobile_impact_allows_empty_evidence(self) -> None: + self.assertEqual(validate_mobile_release_decision("no-mobile-impact", ""), []) + + def test_existing_build_compatibility_requires_evidence(self) -> None: + errors = validate_mobile_release_decision("existing-mobile-build-compatible", "") + self.assertEqual(len(errors), 1) + self.assertIn("mobile_release_evidence is required", errors[0]) + + def test_uploaded_candidate_requires_evidence(self) -> None: + errors = validate_mobile_release_decision("mobile-candidate-uploaded", "") + self.assertEqual(len(errors), 1) + self.assertIn("mobile_release_evidence is required", errors[0]) + + def test_uploaded_candidate_accepts_evidence(self) -> None: + self.assertEqual( + validate_mobile_release_decision("mobile-candidate-uploaded", "iOS build 6 uploaded to TestFlight"), + [], + ) + + def test_mobile_candidate_required_blocks_release(self) -> None: + errors = validate_mobile_release_decision("mobile-candidate-required", "") + self.assertEqual(len(errors), 1) + self.assertIn("blocking release state", errors[0]) + + def test_unknown_decision_is_rejected(self) -> None: + errors = validate_mobile_release_decision("ship-it", "proof") + self.assertEqual(len(errors), 1) + self.assertIn("unknown mobile_release_decision", errors[0]) + + def test_underscore_aliases_normalize_to_canonical_decision(self) -> None: + self.assertEqual( + normalize_decision("existing_mobile_build_compatible"), + "existing-mobile-build-compatible", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 7e1a640db..42fc9c0fc 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3562,6 +3562,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", @@ -3598,6 +3599,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", @@ -3641,6 +3643,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", @@ -3677,6 +3680,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", @@ -3713,6 +3717,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", @@ -3749,6 +3754,7 @@ class SubsystemLookupTest(unittest.TestCase): [ "scripts/installtests/build_release_assets_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", + "scripts/release_control/mobile_release_gate_test.py", "scripts/release_control/release_promotion_policy_support_test.py", "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", diff --git a/scripts/trigger-release-dry-run.sh b/scripts/trigger-release-dry-run.sh index e86488706..5e81d5436 100755 --- a/scripts/trigger-release-dry-run.sh +++ b/scripts/trigger-release-dry-run.sh @@ -82,9 +82,32 @@ python3 scripts/check-workflow-dispatch-inputs.py \ --require v5_eos_date \ --require hotfix_exception \ --require hotfix_reason \ - --require note + --require note \ + --require mobile_release_decision \ + --require mobile_release_evidence echo "✓ Remote release-branch dry-run workflow contract matches governed inputs" +MOBILE_RELEASE_DECISION="" +MOBILE_RELEASE_EVIDENCE="" +MOBILE_REPO="../pulse-mobile" + +echo "" +python3 scripts/release_control/mobile_release_gate.py --mobile-repo "$MOBILE_REPO" --summary-only || true +echo "" +echo "Mobile release decision:" +echo " no-mobile-impact Server release has no mobile/relay/onboarding compatibility impact." +echo " existing-mobile-build-compatible Existing TestFlight/Play candidate is proven compatible." +echo " mobile-candidate-uploaded A new mobile candidate has already been uploaded." +echo " mobile-candidate-required A mobile candidate is required; stop this release dispatch." +echo "" +read -r -p "Mobile release decision: " MOBILE_RELEASE_DECISION +read -r -p "Mobile release evidence or note: " MOBILE_RELEASE_EVIDENCE +python3 scripts/release_control/mobile_release_gate.py \ + --version "$VERSION" \ + --decision "$MOBILE_RELEASE_DECISION" \ + --evidence "$MOBILE_RELEASE_EVIDENCE" +echo "✓ Mobile release decision recorded" + ROLLBACK_VERSION="" PROMOTED_FROM_TAG="" GA_DATE="" @@ -177,7 +200,9 @@ gh workflow run release-dry-run.yml \ -f v5_eos_date="$V5_EOS_DATE" \ -f hotfix_exception="$HOTFIX_EXCEPTION" \ -f hotfix_reason="$HOTFIX_REASON" \ - -f note="Governed release rehearsal for ${VERSION}" + -f note="Governed release rehearsal for ${VERSION}" \ + -f mobile_release_decision="$MOBILE_RELEASE_DECISION" \ + -f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE" echo "" echo "✓ Release Dry Run workflow triggered" diff --git a/scripts/trigger-release.sh b/scripts/trigger-release.sh index 3096781f3..729cf963e 100755 --- a/scripts/trigger-release.sh +++ b/scripts/trigger-release.sh @@ -94,9 +94,32 @@ python3 scripts/check-workflow-dispatch-inputs.py \ --require v5_eos_date \ --require hotfix_exception \ --require hotfix_reason \ - --require draft_only + --require draft_only \ + --require mobile_release_decision \ + --require mobile_release_evidence echo "✓ Remote release-branch publish workflow contract matches governed inputs" +MOBILE_RELEASE_DECISION="" +MOBILE_RELEASE_EVIDENCE="" +MOBILE_REPO="../pulse-mobile" + +echo "" +python3 scripts/release_control/mobile_release_gate.py --mobile-repo "$MOBILE_REPO" --summary-only || true +echo "" +echo "Mobile release decision:" +echo " no-mobile-impact Server release has no mobile/relay/onboarding compatibility impact." +echo " existing-mobile-build-compatible Existing TestFlight/Play candidate is proven compatible." +echo " mobile-candidate-uploaded A new mobile candidate has already been uploaded." +echo " mobile-candidate-required A mobile candidate is required; stop this release dispatch." +echo "" +read -r -p "Mobile release decision: " MOBILE_RELEASE_DECISION +read -r -p "Mobile release evidence or note: " MOBILE_RELEASE_EVIDENCE +python3 scripts/release_control/mobile_release_gate.py \ + --version "$VERSION" \ + --decision "$MOBILE_RELEASE_DECISION" \ + --evidence "$MOBILE_RELEASE_EVIDENCE" +echo "✓ Mobile release decision recorded" + echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Ready to release v${VERSION}" @@ -265,7 +288,9 @@ if [ -n "$NOTES_FILE" ]; then -f ga_date="${GA_DATE}" \ -f v5_eos_date="${V5_EOS_DATE}" \ -f hotfix_exception="${HOTFIX_EXCEPTION}" \ - -f hotfix_reason="${HOTFIX_REASON}" + -f hotfix_reason="${HOTFIX_REASON}" \ + -f mobile_release_decision="${MOBILE_RELEASE_DECISION}" \ + -f mobile_release_evidence="${MOBILE_RELEASE_EVIDENCE}" else # This should be unreachable due to check above, but kept for safety echo "❌ Error: Release notes are required"