Add mobile impact gate to release dispatch

This commit is contained in:
rcourtman 2026-07-07 18:21:20 +01:00
parent 1fad0948d4
commit 155023d86a
10 changed files with 395 additions and 38 deletions

View file

@ -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())

View file

@ -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()

View file

@ -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",

View file

@ -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"

View file

@ -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"