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

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

View file

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

2
.gitignore vendored
View file

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

View file

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

View file

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

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"