Files
pulse/scripts/trigger-stable-patch.sh
T
pulse-triage[bot] f503b13442 Bind release dispatches to the admitted commit
A workflow dispatch by branch can resolve after that branch moves, allowing an unreviewed tip to enter the release pipeline. Require every publishing dispatch to name its expected source SHA and make the workflow reject a different source or workflow commit before checkout.

Change-source: pulse-maintainer
(cherry picked from commit a461fc9c0a)
2026-09-02 02:28:14 +01:00

259 lines
8.7 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
MODE="publish"
VERSION=""
MOBILE_RELEASE_DECISION=""
MOBILE_RELEASE_EVIDENCE=""
HOTFIX_REASON=""
UNSIGNED_WINDOWS_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.
--unsigned-windows-exception-reason VALUE
Use an approved version-bound unsigned Windows exception.
-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
;;
--unsigned-windows-exception-reason)
UNSIGNED_WINDOWS_REASON="${2:?--unsigned-windows-exception-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
VISUAL_PLAN_FILE=$(mktemp)
rm -f "$VISUAL_PLAN_FILE"
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
UNSIGNED_WINDOWS_EXCEPTION="false"
if [ -n "$UNSIGNED_WINDOWS_REASON" ]; then
UNSIGNED_WINDOWS_EXCEPTION="true"
RESOLVER_ARGS+=(
--unsigned-windows-exception
--unsigned-windows-reason "$UNSIGNED_WINDOWS_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 unsigned_windows_exception \
--require unsigned_windows_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 unsigned_windows_exception="$UNSIGNED_WINDOWS_EXCEPTION" \
-f unsigned_windows_reason="$UNSIGNED_WINDOWS_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/release_control/render_release_body.py \
--version "$VERSION" \
--validate-notes-file "$NOTES_FILE"
# A committed sidecar is review material, not proof that visual investigation
# ran for this dispatch. Always make the release model judge the exact notes
# and comparison range used by the publication request.
./scripts/generate-release-notes.sh --visual-plan "$VERSION" "$NOTES_FILE" > "$VISUAL_PLAN_FILE"
python3 scripts/release_control/release_note_visuals.py \
validate --plan "$VISUAL_PLAN_FILE" --output "$VISUAL_PLAN_FILE"
python3 scripts/check-workflow-dispatch-inputs.py \
--workflow-path .github/workflows/create-release.yml \
--branch "$CURRENT_BRANCH" \
--require version \
--require expected_source_sha \
--require release_notes \
--require release_screenshot_plan \
--require promoted_from_tag \
--require rollback_version \
--require ga_date \
--require v5_eos_date \
--require hotfix_exception \
--require hotfix_reason \
--require unsigned_windows_exception \
--require unsigned_windows_reason \
--require draft_only \
--require mobile_release_decision \
--require mobile_release_evidence
jq -n \
--arg version "$VERSION" \
--arg expected_source_sha "$LOCAL_SHA" \
--rawfile release_notes "$NOTES_FILE" \
--rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \
--arg promoted_from_tag "" \
--arg rollback_version "$ROLLBACK_TAG" \
--arg ga_date "" \
--arg v5_eos_date "" \
--arg hotfix_exception "$HOTFIX_EXCEPTION" \
--arg hotfix_reason "$HOTFIX_REASON" \
--arg unsigned_windows_exception "$UNSIGNED_WINDOWS_EXCEPTION" \
--arg unsigned_windows_reason "$UNSIGNED_WINDOWS_REASON" \
--arg draft_only "false" \
--arg mobile_release_decision "$MOBILE_RELEASE_DECISION" \
--arg mobile_release_evidence "$MOBILE_RELEASE_EVIDENCE" \
'{
version: $version,
expected_source_sha: $expected_source_sha,
release_notes: $release_notes,
release_screenshot_plan: $release_screenshot_plan,
promoted_from_tag: $promoted_from_tag,
rollback_version: $rollback_version,
ga_date: $ga_date,
v5_eos_date: $v5_eos_date,
hotfix_exception: $hotfix_exception,
hotfix_reason: $hotfix_reason,
unsigned_windows_exception: $unsigned_windows_exception,
unsigned_windows_reason: $unsigned_windows_reason,
draft_only: $draft_only,
mobile_release_decision: $mobile_release_decision,
mobile_release_evidence: $mobile_release_evidence
}' |
gh workflow run create-release.yml --ref "$CURRENT_BRANCH" --json
fi
echo "Dispatched ${WORKFLOW:-create-release.yml} for v${VERSION} at ${LOCAL_SHA}."