mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Add mobile impact gate to release dispatch
This commit is contained in:
parent
1fad0948d4
commit
155023d86a
10 changed files with 395 additions and 38 deletions
176
scripts/release_control/mobile_release_gate.py
Normal file
176
scripts/release_control/mobile_release_gate.py
Normal 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())
|
||||
49
scripts/release_control/mobile_release_gate_test.py
Normal file
49
scripts/release_control/mobile_release_gate_test.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue