Pulse/scripts/trigger-stable-patch.sh
2026-07-09 20:16:13 +01:00

223 lines
7.2 KiB
Bash
Executable file

#!/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]
Runs the noninteractive stable patch preflight and dispatches exactly one
governed workflow. Run once with --dry-run, wait for that workflow to pass,
then run again without --dry-run to publish.
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
PREFLIGHT_CUTOFF="$(python3 - <<'PY'
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
print(cutoff.strftime("%Y-%m-%dT%H:%M:%SZ"))
PY
)"
PREFLIGHT_RUN="$(gh run list \
--workflow=release-dry-run.yml \
--branch "$CURRENT_BRANCH" \
--event workflow_dispatch \
--limit 30 \
--json displayTitle,headSha,conclusion,url,createdAt \
| jq -c --arg sha "$LOCAL_SHA" --arg title "Release Dry Run v${VERSION}" --arg cutoff "$PREFLIGHT_CUTOFF" \
'[.[] | select(.headSha == $sha and .displayTitle == $title and .conclusion == "success" and .createdAt >= $cutoff)] | sort_by(.createdAt) | last // empty')"
if [ -z "$PREFLIGHT_RUN" ]; then
echo "No successful exact-SHA Release Dry Run from the last 24 hours exists for v${VERSION}." >&2
echo "Run $0 --dry-run ${VERSION}, wait for success, then rerun this command." >&2
exit 1
fi
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"
echo "Accepted preflight: $(jq -r '.url' <<<"$PREFLIGHT_RUN")"
fi
echo "Dispatched ${WORKFLOW:-create-release.yml} for v${VERSION} at ${LOCAL_SHA}."