mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
8f516a014b
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 commita461fc9c0a) (cherry picked from commitf503b13442)
259 lines
8.7 KiB
Bash
Executable File
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}."
|