Files
pulse/scripts/trigger-release.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

421 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Helper script to trigger a release with pre-flight validation
# Usage: ./scripts/trigger-release.sh 4.30.0 [release-notes-file]
VERSION="${1:-}"
NOTES_FILE_ARG="${2:-}"
if [ -z "$VERSION" ]; then
echo "Error: Version number required"
echo "Usage: $0 <version>"
echo "Example: $0 4.30.0"
exit 1
fi
echo "Pre-flight checks for release v${VERSION}..."
echo ""
IS_PRERELEASE="false"
RELEASE_STAGE="stable"
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.([1-9][0-9]*)$ ]]; then
IS_PRERELEASE="true"
RELEASE_STAGE="${BASH_REMATCH[1]}"
elif [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ Unsupported release version: ${VERSION}"
echo "Use X.Y.Z, X.Y.Z-alpha.N, X.Y.Z-beta.N, or X.Y.Z-rc.N."
exit 1
fi
case "$RELEASE_STAGE" in
alpha)
echo "Maturity: alpha (incomplete internal evaluation build)"
;;
beta)
echo "Maturity: beta (user-testing build that is not yet release-ready)"
;;
rc)
echo "Maturity: release candidate (believed capable of becoming stable without product changes)"
echo "RC publication runs the stable-depth integration gate."
read -r -p "Confirm this build is genuinely a release candidate [y/N]: " RC_CONFIRMATION
if [[ ! "$RC_CONFIRMATION" =~ ^[Yy]$ ]]; then
echo "Release cancelled. Use a beta version while further product changes are expected."
exit 1
fi
;;
stable)
echo "Maturity: stable (general production release)"
;;
esac
# Check 1: VERSION file matches
FILE_VERSION=$(cat VERSION | tr -d '\n')
if [ "$FILE_VERSION" != "$VERSION" ]; then
echo "❌ VERSION file mismatch"
echo ""
echo " VERSION file contains: ${FILE_VERSION}"
echo " Requested version: ${VERSION}"
echo ""
echo "Fix: Update VERSION file and commit:"
echo " echo '${VERSION}' > VERSION"
echo " git add VERSION"
echo " git commit -m 'Prepare v${VERSION} release'"
echo " git push"
echo ""
exit 1
fi
echo "✓ VERSION file matches (${VERSION})"
# Check 2: Working directory is clean
if ! git diff-index --quiet HEAD --; then
echo "❌ Working directory has uncommitted changes"
echo ""
echo "Fix: Commit or stash changes before releasing"
echo ""
git status --short
exit 1
fi
echo "✓ Working directory is clean"
# Check 3: On required branch
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 "❌ Wrong release branch"
echo ""
echo " Current branch: ${CURRENT_BRANCH}"
echo " Required branch: ${REQUIRED_BRANCH}"
echo ""
exit 1
fi
echo "✓ On required branch (${REQUIRED_BRANCH})"
# Check 4: Up to date with remote
git fetch origin --quiet
LOCAL=$(git rev-parse @)
REMOTE=$(git rev-parse '@{u}')
if [ "$LOCAL" != "$REMOTE" ]; then
echo "❌ Local branch is not fully pushed to origin"
echo ""
echo " Local ref: ${LOCAL}"
echo " Remote ref: ${REMOTE}"
echo ""
echo "Release automation executes the selected remote ref, not local-only governance state."
echo "Push ${CURRENT_BRANCH} to origin before triggering the publish workflow."
git status -sb
exit 1
else
echo "✓ Up to date with remote"
fi
./scripts/run-release-preflight.sh \
--sha "$LOCAL" \
--profile release \
--if-configured
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
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}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Next: Provide release notes when prompted"
echo ""
# Check 5: Release notes file
NOTES_FILE="${NOTES_FILE_ARG:-/tmp/release_notes_${VERSION}.md}"
VISUAL_PLAN_FILE=$(mktemp)
rm -f "$VISUAL_PLAN_FILE"
VISUAL_PLAN_GENERATED_THIS_RUN="false"
if [ -f "$NOTES_FILE" ]; then
echo "Found release notes file: ${NOTES_FILE}"
echo ""
cat "$NOTES_FILE"
echo ""
read -p "Use these release notes? [Y/n] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Nn]$ ]]; then
echo "Release notes file ignored"
NOTES_FILE=""
fi
else
echo "No release notes file found at ${NOTES_FILE}"
echo ""
read -p "Generate release notes automatically? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
echo "Generating release notes..."
if RELEASE_NOTE_VISUAL_PLAN_FILE="$VISUAL_PLAN_FILE" \
./scripts/generate-release-notes.sh "$VERSION" > "$NOTES_FILE"; then
VISUAL_PLAN_GENERATED_THIS_RUN="true"
echo "Release notes generated at ${NOTES_FILE}"
echo ""
# Show first few lines
head -n 20 "$NOTES_FILE"
echo "... (truncated)"
echo ""
read -p "Use these release notes? [Y/n] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Nn]$ ]]; then
echo "Release notes rejected."
rm "$NOTES_FILE"
rm -f "$VISUAL_PLAN_FILE"
NOTES_FILE=""
fi
else
echo "Failed to generate release notes."
NOTES_FILE=""
fi
else
NOTES_FILE=""
fi
fi
if [ -z "$NOTES_FILE" ]; then
echo "❌ Error: Release notes are required"
echo ""
echo "Create ${NOTES_FILE} manually, then run this script again."
echo ""
exit 1
fi
python3 scripts/release_control/render_release_body.py \
--version "$VERSION" \
--validate-notes-file "$NOTES_FILE"
echo "✓ Release-note Markdown structure validated"
if [ "$VISUAL_PLAN_GENERATED_THIS_RUN" != "true" ]; then
# 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"
VISUAL_PLAN_GENERATED_THIS_RUN="true"
fi
python3 scripts/release_control/release_note_visuals.py \
validate --plan "$VISUAL_PLAN_FILE" --output "$VISUAL_PLAN_FILE"
VISUAL_CAPTURE_COUNT=$(python3 scripts/release_control/release_note_visuals.py \
count --plan "$VISUAL_PLAN_FILE")
if [ "$VISUAL_CAPTURE_COUNT" -gt 0 ]; then
echo ""
echo "Model-selected release-note visual plan:"
cat "$VISUAL_PLAN_FILE"
echo ""
read -p "Use this visual plan? [Y/n] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Nn]$ ]]; then
printf '%s\n' '{"schema_version":1,"decision":"The release owner declined the model-selected visual plan.","captures":[]}' > "$VISUAL_PLAN_FILE"
VISUAL_CAPTURE_COUNT=0
fi
fi
echo "✓ Release-note visual plan validated (${VISUAL_CAPTURE_COUNT} capture(s))"
ROLLBACK_VERSION=""
ROLLBACK_COMMAND=""
PROMOTED_FROM_TAG=""
GA_DATE=""
V5_EOS_DATE=""
HOTFIX_EXCEPTION="false"
HOTFIX_REASON=""
UNSIGNED_WINDOWS_EXCEPTION="false"
UNSIGNED_WINDOWS_REASON=""
echo ""
read -r -p "Rollback stable version (for example 5.1.14 or v5.1.14): " ROLLBACK_VERSION
if [ -z "$ROLLBACK_VERSION" ]; then
echo "❌ Error: rollback version is required"
exit 1
fi
if [[ "$ROLLBACK_VERSION" == v* ]]; then
ROLLBACK_COMMAND="sudo /bin/update --version ${ROLLBACK_VERSION}"
else
ROLLBACK_COMMAND="sudo /bin/update --version v${ROLLBACK_VERSION}"
fi
echo ""
echo "Derived rollback command: ${ROLLBACK_COMMAND}"
if [ "$IS_PRERELEASE" != "true" ]; then
echo ""
read -r -p "Hotfix exception to bypass 72-hour prerelease soak? [y/N] " HOTFIX_REPLY
if [[ "$HOTFIX_REPLY" =~ ^[Yy]$ ]]; then
HOTFIX_EXCEPTION="true"
read -r -p "Hotfix reason: " HOTFIX_REASON
if [ -z "$HOTFIX_REASON" ]; then
echo "❌ Error: hotfix reason is required when bypassing prerelease soak"
exit 1
fi
fi
DEFAULT_PROMOTED_FROM_TAG=$(git tag -l "v${VERSION}-rc.*" --sort=-version:refname | head -1 || true)
echo ""
if [ -n "$DEFAULT_PROMOTED_FROM_TAG" ]; then
if [ "$HOTFIX_EXCEPTION" = "true" ]; then
read -r -p "Promoted prerelease tag, or blank for hotfix with no RC lineage [${DEFAULT_PROMOTED_FROM_TAG}]: " PROMOTED_FROM_TAG
else
read -r -p "Promoted prerelease tag [${DEFAULT_PROMOTED_FROM_TAG}]: " PROMOTED_FROM_TAG
PROMOTED_FROM_TAG="${PROMOTED_FROM_TAG:-$DEFAULT_PROMOTED_FROM_TAG}"
fi
else
read -r -p "Promoted prerelease tag (for example v${VERSION}-rc.2; blank only for approved hotfix): " PROMOTED_FROM_TAG
fi
if [ -z "$PROMOTED_FROM_TAG" ] && [ "$HOTFIX_EXCEPTION" != "true" ]; then
echo "❌ Error: promoted prerelease tag is required for stable releases unless this is an approved hotfix"
exit 1
fi
if [ "$VERSION" = "6.0.0" ]; then
echo ""
read -r -p "v6 GA date to publish with GA (YYYY-MM-DD): " GA_DATE
if [[ ! "$GA_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "❌ Error: v6 GA date must be in YYYY-MM-DD form"
exit 1
fi
echo ""
read -r -p "v5 end-of-support date to publish with GA (YYYY-MM-DD): " V5_EOS_DATE
if [[ ! "$V5_EOS_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "❌ Error: v5 end-of-support date must be in YYYY-MM-DD form"
exit 1
fi
fi
if [[ "$VERSION" =~ ^6\.(1\.[012]|2\.0)$ ]]; then
echo ""
read -r -p "Use the recorded v${VERSION} unsigned Windows exception? [y/N] " UNSIGNED_WINDOWS_REPLY
if [[ "$UNSIGNED_WINDOWS_REPLY" =~ ^[Yy]$ ]]; then
UNSIGNED_WINDOWS_EXCEPTION="true"
read -r -p "Unsigned Windows exception reason: " UNSIGNED_WINDOWS_REASON
if [ -z "$UNSIGNED_WINDOWS_REASON" ]; then
echo "❌ Error: an owner reason is required for the unsigned Windows exception"
exit 1
fi
fi
fi
fi
# Trigger the workflow
echo ""
echo "Resolving governed promotion metadata..."
RESOLVER_ARGS=(
--version "$VERSION"
--rollback-version "$ROLLBACK_VERSION"
--hotfix-reason "$HOTFIX_REASON"
--release-notes-file "$NOTES_FILE"
--enforce-prerelease-observation-window
)
if [ -n "$PROMOTED_FROM_TAG" ]; then
RESOLVER_ARGS+=(--promoted-from-tag "$PROMOTED_FROM_TAG")
fi
if [ -n "$GA_DATE" ]; then
RESOLVER_ARGS+=(--ga-date "$GA_DATE")
fi
if [ -n "$V5_EOS_DATE" ]; then
RESOLVER_ARGS+=(--v5-eos-date "$V5_EOS_DATE")
fi
if [ "$HOTFIX_EXCEPTION" = "true" ]; then
RESOLVER_ARGS+=(--hotfix-exception)
fi
if [ "$UNSIGNED_WINDOWS_EXCEPTION" = "true" ]; then
RESOLVER_ARGS+=(
--unsigned-windows-exception
--unsigned-windows-reason "$UNSIGNED_WINDOWS_REASON"
)
fi
python3 scripts/release_control/resolve_release_promotion.py "${RESOLVER_ARGS[@]}" >/tmp/pulse-release-metadata.out
cat /tmp/pulse-release-metadata.out
echo ""
echo "Triggering release workflow..."
if [ -n "$NOTES_FILE" ]; then
jq -n \
--arg version "$VERSION" \
--arg expected_source_sha "$LOCAL" \
--rawfile release_notes "$NOTES_FILE" \
--rawfile release_screenshot_plan "$VISUAL_PLAN_FILE" \
--arg rollback_version "$ROLLBACK_VERSION" \
--arg promoted_from_tag "$PROMOTED_FROM_TAG" \
--arg ga_date "$GA_DATE" \
--arg v5_eos_date "$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,
rollback_version: $rollback_version,
promoted_from_tag: $promoted_from_tag,
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
else
# This should be unreachable due to check above, but kept for safety
echo "❌ Error: Release notes are required"
exit 1
fi
echo ""
echo "✓ Release workflow triggered"
echo ""
echo "Monitor progress:"
echo " gh run list --workflow=create-release.yml --limit 1"
echo " gh run watch <run-id>"
echo ""