#!/usr/bin/env bash set -euo pipefail MODE="publish" VERSION="" MOBILE_RELEASE_DECISION="" MOBILE_RELEASE_EVIDENCE="" HOTFIX_REASON="" usage() { cat <<'EOF' Usage: scripts/trigger-stable-patch.sh [--dry-run] [options] [version] Dispatches exactly one governed workflow. The default release workflow builds and validates an immutable candidate before publication. Use --dry-run only when a no-public-release rehearsal is required. Options: --dry-run Dispatch Release Dry Run only. --mobile-release-decision VALUE Override the inferred mobile decision. --mobile-release-evidence VALUE Evidence for a mobile compatibility decision. --emergency-hotfix-reason VALUE Bypass an RC-required risk with an explicit reason. -h, --help Show this help. EOF } while [ "$#" -gt 0 ]; do case "$1" in --dry-run) MODE="dry-run" shift ;; --mobile-release-decision) MOBILE_RELEASE_DECISION="${2:?--mobile-release-decision requires a value}" shift 2 ;; --mobile-release-evidence) MOBILE_RELEASE_EVIDENCE="${2:?--mobile-release-evidence requires a value}" shift 2 ;; --emergency-hotfix-reason) HOTFIX_REASON="${2:?--emergency-hotfix-reason requires a value}" shift 2 ;; -h|--help) usage exit 0 ;; --*) echo "Unknown option: $1" >&2 usage >&2 exit 2 ;; *) if [ -n "$VERSION" ]; then echo "Only one version may be supplied." >&2 exit 2 fi VERSION="$1" shift ;; esac done VERSION="${VERSION:-$(tr -d '\n' < VERSION)}" if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[1-9][0-9]*$ ]]; then echo "Stable patch version required, got: ${VERSION}" >&2 exit 1 fi FILE_VERSION="$(tr -d '\n' < VERSION)" if [ "$FILE_VERSION" != "$VERSION" ]; then echo "VERSION contains ${FILE_VERSION}; requested ${VERSION}." >&2 exit 1 fi if [ -n "$(git status --porcelain=v1)" ]; then echo "The release worktree must be clean." >&2 git status --short exit 1 fi CURRENT_BRANCH="$(git branch --show-current)" REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "$VERSION")" if [ "$CURRENT_BRANCH" != "$REQUIRED_BRANCH" ]; then echo "Version ${VERSION} must be released from ${REQUIRED_BRANCH}, not ${CURRENT_BRANCH}." >&2 exit 1 fi git fetch --quiet --prune origin "$REQUIRED_BRANCH" --tags LOCAL_SHA="$(git rev-parse HEAD)" REMOTE_SHA="$(git rev-parse "origin/${REQUIRED_BRANCH}")" if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then echo "The exact release commit must already be pushed to origin/${REQUIRED_BRANCH}." >&2 exit 1 fi NOTES_FILE="docs/releases/RELEASE_NOTES_v${VERSION}.md" if [ ! -s "$NOTES_FILE" ]; then echo "Canonical release notes are required at ${NOTES_FILE}." >&2 exit 1 fi RESOLVER_ARGS=( --version "$VERSION" --derive-rollback-latest-stable --release-notes-file "$NOTES_FILE" ) HOTFIX_EXCEPTION="false" if [ -n "$HOTFIX_REASON" ]; then HOTFIX_EXCEPTION="true" RESOLVER_ARGS+=(--hotfix-exception --hotfix-reason "$HOTFIX_REASON") fi PROMOTION_METADATA="$(python3 scripts/release_control/resolve_release_promotion.py "${RESOLVER_ARGS[@]}")" ROLLBACK_TAG="$(awk -F= '$1 == "rollback_tag" {print $2}' <<<"$PROMOTION_METADATA")" if [ -z "$ROLLBACK_TAG" ]; then echo "Release preflight did not resolve a rollback tag." >&2 exit 1 fi MOBILE_IMPACT_PATHS="$({ git diff --name-only "${ROLLBACK_TAG}..HEAD" | rg '^(internal/(relay|mobile)/|pkg/relay/|internal/api/(cloud_handoff|magic_link|mobile)|tests/integration/.*(mobile|relay)|scripts/.*mobile)' } || true)" if [ -z "$MOBILE_RELEASE_DECISION" ]; then if [ -n "$MOBILE_IMPACT_PATHS" ]; then echo "Mobile-facing paths changed since ${ROLLBACK_TAG}:" >&2 printf '%s\n' "$MOBILE_IMPACT_PATHS" >&2 echo "Supply --mobile-release-decision and --mobile-release-evidence after completing the governed mobile check." >&2 exit 1 fi MOBILE_RELEASE_DECISION="no-mobile-impact" MOBILE_RELEASE_EVIDENCE="No mobile-facing paths changed between ${ROLLBACK_TAG} and ${LOCAL_SHA}." fi python3 scripts/release_control/mobile_release_gate.py \ --version "$VERSION" \ --decision "$MOBILE_RELEASE_DECISION" \ --evidence "$MOBILE_RELEASE_EVIDENCE" if [ "$MODE" = "dry-run" ]; then WORKFLOW="release-dry-run.yml" python3 scripts/check-workflow-dispatch-inputs.py \ --workflow-path .github/workflows/release-dry-run.yml \ --branch "$CURRENT_BRANCH" \ --require version \ --require promoted_from_tag \ --require rollback_version \ --require ga_date \ --require v5_eos_date \ --require hotfix_exception \ --require hotfix_reason \ --require note \ --require mobile_release_decision \ --require mobile_release_evidence gh workflow run "$WORKFLOW" \ --ref "$CURRENT_BRANCH" \ -f version="$VERSION" \ -f promoted_from_tag="" \ -f rollback_version="$ROLLBACK_TAG" \ -f ga_date="" \ -f v5_eos_date="" \ -f hotfix_exception="$HOTFIX_EXCEPTION" \ -f hotfix_reason="$HOTFIX_REASON" \ -f note="Stable patch preflight for ${VERSION} at ${LOCAL_SHA}" \ -f mobile_release_decision="$MOBILE_RELEASE_DECISION" \ -f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE" else python3 scripts/check-workflow-dispatch-inputs.py \ --workflow-path .github/workflows/create-release.yml \ --branch "$CURRENT_BRANCH" \ --require version \ --require release_notes \ --require promoted_from_tag \ --require rollback_version \ --require ga_date \ --require v5_eos_date \ --require hotfix_exception \ --require hotfix_reason \ --require draft_only \ --require mobile_release_decision \ --require mobile_release_evidence gh workflow run create-release.yml \ --ref "$CURRENT_BRANCH" \ -f version="$VERSION" \ -f release_notes="$(<"$NOTES_FILE")" \ -f promoted_from_tag="" \ -f rollback_version="$ROLLBACK_TAG" \ -f ga_date="" \ -f v5_eos_date="" \ -f hotfix_exception="$HOTFIX_EXCEPTION" \ -f hotfix_reason="$HOTFIX_REASON" \ -f draft_only=false \ -f mobile_release_decision="$MOBILE_RELEASE_DECISION" \ -f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE" fi echo "Dispatched ${WORKFLOW:-create-release.yml} for v${VERSION} at ${LOCAL_SHA}."