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
22
.github/workflows/create-release.yml
vendored
22
.github/workflows/create-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
28
.github/workflows/release-dry-run.yml
vendored
28
.github/workflows/release-dry-run.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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