Pulse/.github/workflows/release-dry-run.yml
2026-07-26 21:07:47 +01:00

502 lines
21 KiB
YAML

name: Release Dry Run
run-name: Release Dry Run v${{ inputs.version || 'scheduled' }}
on:
# Weekly drift watchdog: fires every Tuesday 07:00 UTC against the governed
# release branch so fixture, manifest, and load-calibration drift surfaces a
# week at a time instead of piling up until an RC publish. Scheduled runs
# carry no workflow_dispatch inputs (GitHub does not apply input defaults to
# schedule events), so the rehearsal step derives the rollback target as the
# latest stable tag preceding VERSION. Manual dispatches must still supply
# rollback_version explicitly.
schedule:
- cron: '0 7 * * 2'
workflow_dispatch:
inputs:
version:
description: 'Optional version under rehearsal (e.g. 6.0.0-rc.2 or 6.0.0)'
required: false
type: string
promoted_from_tag:
description: 'Stable rehearsal only: prerelease tag being promoted (for example v6.0.0-rc.2)'
required: false
type: string
rollback_version:
description: 'Required rollback stable version to rehearse (for example 6.0.4 or v6.0.4)'
required: true
type: string
ga_date:
description: 'Stable v6.0.0 rehearsal only: planned GA publish date (YYYY-MM-DD)'
required: false
type: string
v5_eos_date:
description: 'Stable v6.0.0 rehearsal only: Pulse v5 end-of-support date (YYYY-MM-DD)'
required: false
type: string
hotfix_exception:
description: 'Stable rehearsal only: bypass 72-hour prerelease soak for urgent customer harm'
required: false
type: boolean
default: false
hotfix_reason:
description: 'Stable rehearsal only: reason for hotfix soak exception'
required: false
type: string
unsigned_windows_exception:
description: 'v6.1.0, v6.1.1, or v6.1.2 only: rehearse Windows agents without Authenticode under a recorded owner exception'
required: false
type: boolean
default: false
unsigned_windows_reason:
description: 'v6.1.0, v6.1.1, or v6.1.2 only: owner reason for the unsigned Windows exception'
required: false
type: string
note:
description: 'Optional note/reason for the dry run'
required: false
type: string
mobile_release_decision:
description: 'Mobile impact decision: no-mobile-impact, existing-mobile-build-compatible, mobile-candidate-uploaded, or mobile-candidate-required'
required: false
type: string
mobile_release_evidence:
description: 'Evidence for existing-mobile-build-compatible or mobile-candidate-uploaded decisions'
required: false
type: string
permissions:
contents: read
jobs:
build_release_candidate:
name: Build Immutable Release Candidate
if: ${{ inputs.version != '' }}
permissions:
actions: read
contents: read
uses: ./.github/workflows/build-release-candidate.yml
secrets: inherit
with:
version: ${{ inputs.version }}
require_macos_signing: true
require_windows_signing: ${{ !contains(inputs.version, '-') && !((inputs.version == '6.1.0' || inputs.version == '6.1.1' || inputs.version == '6.1.2') && inputs.unsigned_windows_exception) }}
windows_signing_backend: signpath
dry-run:
name: Preflight Release Checks (No Publish)
runs-on: ubuntu-24.04
timeout-minutes: 90
permissions:
contents: read
packages: read
outputs:
version: ${{ steps.rehearsal.outputs.version }}
tag: ${{ steps.rehearsal.outputs.tag }}
is_prerelease: ${{ steps.rehearsal.outputs.is_prerelease }}
promoted_from_tag: ${{ steps.rehearsal.outputs.promoted_from_tag }}
rollback_tag: ${{ steps.rehearsal.outputs.rollback_tag }}
rollback_command: ${{ steps.rehearsal.outputs.rollback_command }}
ga_date: ${{ steps.rehearsal.outputs.ga_date }}
v5_eos_date: ${{ steps.rehearsal.outputs.v5_eos_date }}
soak_hours: ${{ steps.rehearsal.outputs.soak_hours }}
hotfix_exception: ${{ steps.rehearsal.outputs.hotfix_exception }}
hotfix_reason: ${{ steps.rehearsal.outputs.hotfix_reason }}
require_windows_signing: ${{ steps.rehearsal.outputs.require_windows_signing }}
unsigned_windows_exception: ${{ steps.rehearsal.outputs.unsigned_windows_exception }}
unsigned_windows_reason: ${{ steps.rehearsal.outputs.unsigned_windows_reason }}
steps:
- name: Validate release ref
run: |
if [[ "${GITHUB_REF}" != refs/heads/* ]]; then
echo "::error::Release dry run must be executed from a branch ref. Current ref: ${GITHUB_REF}"
exit 1
fi
echo "[OK] Dry run executing on branch ${GITHUB_REF_NAME}"
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Resolve required release branch
id: branch_policy
env:
VERSION_INPUT: ${{ inputs.version }}
run: |
VERSION="${VERSION_INPUT:-}"
if [ -z "$VERSION" ]; then
VERSION="$(tr -d '\r\n' < VERSION)"
fi
REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")"
echo "required_branch=${REQUIRED_BRANCH}" >> "$GITHUB_OUTPUT"
echo "[OK] Governed release branch for ${VERSION} is ${REQUIRED_BRANCH}"
- name: Resolve rehearsal metadata
id: rehearsal
env:
EVENT_NAME: ${{ github.event_name }}
VERSION_INPUT: ${{ inputs.version }}
PROMOTED_FROM_TAG_INPUT: ${{ inputs.promoted_from_tag }}
ROLLBACK_VERSION_INPUT: ${{ inputs.rollback_version }}
GA_DATE_INPUT: ${{ inputs.ga_date }}
V5_EOS_DATE_INPUT: ${{ inputs.v5_eos_date }}
HOTFIX_EXCEPTION_INPUT: ${{ inputs.hotfix_exception }}
HOTFIX_REASON_INPUT: ${{ inputs.hotfix_reason }}
UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ inputs.unsigned_windows_exception }}
UNSIGNED_WINDOWS_REASON_INPUT: ${{ inputs.unsigned_windows_reason }}
run: |
set -euo pipefail
VERSION="${VERSION_INPUT:-}"
if [ -z "$VERSION" ]; then
VERSION="$(tr -d '\r\n' < VERSION)"
fi
TAG="v${VERSION}"
IS_PRERELEASE="false"
if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then
IS_PRERELEASE="true"
fi
REQUIRED_BRANCH="${{ steps.branch_policy.outputs.required_branch }}"
if [ "${GITHUB_REF_NAME}" != "$REQUIRED_BRANCH" ]; then
echo "::error::Rehearsal version ${VERSION} requires branch ${REQUIRED_BRANCH}, but workflow ran on ${GITHUB_REF_NAME}."
exit 1
fi
FILE_VERSION="$(tr -d '\r\n' < VERSION)"
if [ "$FILE_VERSION" != "$VERSION" ]; then
echo "::error::VERSION file (${FILE_VERSION}) does not match rehearsal version (${VERSION})."
exit 1
fi
git fetch --prune origin main "${REQUIRED_BRANCH}" --tags
HELPER_ARGS=(
--version "${VERSION}"
--promoted-from-tag "${PROMOTED_FROM_TAG_INPUT:-}"
--rollback-version "${ROLLBACK_VERSION_INPUT:-}"
--ga-date "${GA_DATE_INPUT:-}"
--v5-eos-date "${V5_EOS_DATE_INPUT:-}"
--hotfix-reason "${HOTFIX_REASON_INPUT:-}"
)
if [ "${HOTFIX_EXCEPTION_INPUT:-false}" = "true" ]; then
HELPER_ARGS+=(--hotfix-exception)
fi
if [ "${UNSIGNED_WINDOWS_EXCEPTION_INPUT:-false}" = "true" ]; then
HELPER_ARGS+=(
--unsigned-windows-exception
--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT:-}"
)
elif [ -n "${UNSIGNED_WINDOWS_REASON_INPUT:-}" ]; then
HELPER_ARGS+=(--unsigned-windows-reason "${UNSIGNED_WINDOWS_REASON_INPUT}")
fi
if [ "${EVENT_NAME}" = "schedule" ] && [ -z "${ROLLBACK_VERSION_INPUT:-}" ]; then
# Scheduled watchdog runs carry no dispatch inputs; derive the
# rollback target instead of failing. Manual dispatches never get
# this flag, so their explicit-rollback requirement stands.
HELPER_ARGS+=(--derive-rollback-latest-stable)
echo "[OK] Scheduled rehearsal: deriving rollback target from the latest preceding stable tag"
fi
python3 scripts/release_control/resolve_release_promotion.py \
"${HELPER_ARGS[@]}" > "$RUNNER_TEMP/rehearsal-metadata.out"
{
echo "version=${VERSION}"
echo "tag=${TAG}"
echo "is_prerelease=${IS_PRERELEASE}"
cat "$RUNNER_TEMP/rehearsal-metadata.out"
} >> "$GITHUB_OUTPUT"
echo "[OK] Rehearsal metadata validated for ${TAG}"
- name: Validate mobile release decision
id: mobile_release
env:
EVENT_NAME: ${{ github.event_name }}
MOBILE_RELEASE_DECISION: ${{ inputs.mobile_release_decision }}
MOBILE_RELEASE_EVIDENCE: ${{ inputs.mobile_release_evidence }}
run: |
set -euo pipefail
DECISION="${MOBILE_RELEASE_DECISION:-}"
EVIDENCE="${MOBILE_RELEASE_EVIDENCE:-}"
if [ -z "${DECISION}" ] && [ "${EVENT_NAME}" = "schedule" ]; then
DECISION="no-mobile-impact"
EVIDENCE="Scheduled release dry-run watchdog; no mobile release packet is being dispatched."
fi
python3 scripts/release_control/mobile_release_gate.py \
--version "${{ steps.rehearsal.outputs.version }}" \
--decision "${DECISION}" \
--evidence "${EVIDENCE}" \
--github-annotations
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'frontend-modern/package-lock.json'
- name: Install frontend dependencies
run: npm --prefix frontend-modern ci
- name: Build frontend bundle for Go embed
run: |
npm --prefix frontend-modern run build
rm -rf internal/api/frontend-modern
mkdir -p internal/api/frontend-modern
cp -r frontend-modern/dist internal/api/frontend-modern/
- name: Lint frontend
run: npm --prefix frontend-modern run lint
- name: Audit header composition
run: npm --prefix frontend-modern run lint:headers
- name: Check frontend copy-paste duplication
run: npm --prefix frontend-modern run lint:cpd
- name: Install docker-compose
run: |
sudo apt-get update
sudo apt-get install -y docker-compose
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Run backend tests
# Serial package execution keeps non-race SLO tests meaningful on
# 2-core hosted runners instead of measuring cross-package contention.
run: go test -p 1 ./...
- name: Prepare integration test dependencies
working-directory: tests/integration
run: |
npm ci
npx playwright install --with-deps chromium
- name: Build Pulse binaries for integration tests
run: make build
- name: Log in to GHCR for build cache
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ -z "${GH_TOKEN:-}" ]; then
echo "::error::GITHUB_TOKEN not available for GHCR login"
exit 1
fi
echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Build Docker images for integration tests
run: |
docker build -t pulse:test --target runtime .
docker build -t pulse-mock-github:test tests/integration/mock-github-server
env:
PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}
- name: Run integration diagnostics
working-directory: tests/integration
env:
MOCK_CHECKSUM_ERROR: "false"
MOCK_NETWORK_ERROR: "false"
MOCK_RATE_LIMIT: "false"
MOCK_STALE_RELEASE: "false"
run: |
docker compose -f docker-compose.test.yml up -d --wait
echo "Verifying Pulse API is reachable..."
timeout 60 sh -c 'until curl -fsS http://localhost:7655/api/health > /dev/null; do sleep 2; done'
echo "Running Playwright diagnostics..."
npx playwright test tests/00-diagnostic.spec.ts --reporter=list
echo "Running update API route smoke check..."
STATUS=$(curl -s -o /tmp/update-status.json -w "%{http_code}" http://localhost:7655/api/updates/status || true)
echo "Update status endpoint returned HTTP ${STATUS}"
case "${STATUS}" in
200|401|403)
;;
*)
echo "Unexpected response from /api/updates/status"
cat /tmp/update-status.json || true
exit 1
;;
esac
docker compose -f docker-compose.test.yml down -v
- name: Cleanup integration environment
if: always()
working-directory: tests/integration
run: docker compose -f docker-compose.test.yml down -v || true
- name: Write rehearsal summary
if: always()
env:
NOTE: ${{ inputs.note }}
REHEARSAL_CONCLUSION: ${{ steps.rehearsal.conclusion }}
JOB_CONCLUSION: ${{ job.status }}
run: |
mkdir -p release-dry-run
SUMMARY_FILE="release-dry-run/rc-to-ga-rehearsal-summary.md"
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
if [ "${REHEARSAL_CONCLUSION}" != "success" ]; then
{
echo "# Prerelease-to-GA Rehearsal Summary"
echo ""
echo "- Workflow run: ${RUN_URL}"
echo "- Branch: ${GITHUB_REF_NAME}"
echo "- Result: ${JOB_CONCLUSION}"
if [ -n "${NOTE}" ]; then
echo "- Operator note: ${NOTE}"
fi
echo ""
echo "## Result"
echo ""
echo "This run did not produce a valid promotion metadata envelope."
echo "Do not use this artifact to clear \`rc-to-ga-promotion-readiness\`."
echo "Fix the failed rehearsal metadata or branch-state preconditions and rerun the workflow."
} > "$SUMMARY_FILE"
else
{
echo "# Prerelease-to-GA Rehearsal Summary"
echo ""
echo "- Workflow run: ${RUN_URL}"
echo "- Branch: ${GITHUB_REF_NAME}"
echo "- Version: ${{ steps.rehearsal.outputs.version }}"
echo "- Candidate stable tag: ${{ steps.rehearsal.outputs.tag }}"
echo "- Promotion channel: ${{ steps.rehearsal.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}"
if [ -n "${{ steps.rehearsal.outputs.promoted_from_tag }}" ]; then
echo "- Promoted prerelease tag: ${{ steps.rehearsal.outputs.promoted_from_tag }}"
fi
if [ -n "${{ steps.rehearsal.outputs.rollback_tag }}" ]; then
echo "- Rollback target: ${{ steps.rehearsal.outputs.rollback_tag }}"
fi
if [ -n "${{ steps.rehearsal.outputs.rollback_command }}" ]; then
echo "- Rollback command: \`${{ steps.rehearsal.outputs.rollback_command }}\`"
fi
if [ -n "${{ steps.rehearsal.outputs.soak_hours }}" ]; then
echo "- Prerelease soak hours at rehearsal time: ${{ steps.rehearsal.outputs.soak_hours }}"
fi
if [ -n "${{ steps.rehearsal.outputs.ga_date }}" ]; then
echo "- Planned GA date: ${{ steps.rehearsal.outputs.ga_date }}"
fi
if [ -n "${{ steps.rehearsal.outputs.v5_eos_date }}" ]; then
echo "- Planned v5 end-of-support date: ${{ steps.rehearsal.outputs.v5_eos_date }}"
fi
echo "- Hotfix exception: ${{ steps.rehearsal.outputs.hotfix_exception }}"
if [ -n "${{ steps.rehearsal.outputs.hotfix_reason }}" ]; then
echo "- Hotfix reason: ${{ steps.rehearsal.outputs.hotfix_reason }}"
fi
echo "- Windows Authenticode required: ${{ steps.rehearsal.outputs.require_windows_signing }}"
echo "- Unsigned Windows exception: ${{ steps.rehearsal.outputs.unsigned_windows_exception }}"
if [ -n "${{ steps.rehearsal.outputs.unsigned_windows_reason }}" ]; then
echo "- Unsigned Windows reason: ${{ steps.rehearsal.outputs.unsigned_windows_reason }}"
fi
if [ -n "${NOTE}" ]; then
echo "- Operator note: ${NOTE}"
fi
echo ""
echo "## Result"
echo ""
echo "This run exercised the non-publish release path and validated the current promotion contract on the selected branch."
echo "Record this run URL in the release ticket when clearing \`rc-to-ga-promotion-readiness\`."
echo ""
echo "## Governed Record"
echo ""
echo "Materialize the dated rehearsal record from this exact run with:"
echo "\`python3 scripts/release_control/record_rc_to_ga_rehearsal.py --run-id ${{ github.run_id }}\`"
echo ""
echo "If you do not pass \`--output\`, the recorder writes to \`docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-rehearsal-<record-date>.md\`."
} > "$SUMMARY_FILE"
fi
cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
- name: Upload rehearsal summary artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rc-to-ga-rehearsal-summary
path: release-dry-run/rc-to-ga-rehearsal-summary.md
demo_path_preflight:
name: Verify Current Stable Demo Path (No Mutation)
needs: dry-run
permissions:
contents: read
uses: ./.github/workflows/update-demo-server.yml
secrets: inherit
with:
tag: latest
target: stable
verify_only: true
release_dry_run_verdict:
name: Definitive Dry-Run Verdict
needs:
- build_release_candidate
- dry-run
- demo_path_preflight
if: ${{ always() }}
runs-on: ubuntu-24.04
steps:
- name: Enforce exact-SHA rehearsal outcomes
env:
EVENT_NAME: ${{ github.event_name }}
VERSION_INPUT: ${{ inputs.version }}
CANDIDATE_RESULT: ${{ needs.build_release_candidate.result }}
PREFLIGHT_RESULT: ${{ needs.dry-run.result }}
DEMO_RESULT: ${{ needs.demo_path_preflight.result }}
WINDOWS_SIGNING_BACKEND: ${{ needs.build_release_candidate.outputs.windows_signing_backend }}
REQUIRE_WINDOWS_SIGNING: ${{ needs.dry-run.outputs.require_windows_signing }}
UNSIGNED_WINDOWS_EXCEPTION: ${{ needs.dry-run.outputs.unsigned_windows_exception }}
UNSIGNED_WINDOWS_REASON: ${{ needs.dry-run.outputs.unsigned_windows_reason }}
run: |
set -euo pipefail
failed=0
require_result() {
local label="$1"
local actual="$2"
local expected="$3"
if [ "$actual" != "$expected" ]; then
echo "::error::${label} ended as ${actual}; expected ${expected}."
failed=1
fi
}
require_result "release preflight" "$PREFLIGHT_RESULT" success
require_result "stable demo no-mutation verification" "$DEMO_RESULT" success
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -n "${VERSION_INPUT:-}" ]; then
require_result "exact-SHA release candidate" "$CANDIDATE_RESULT" success
elif [ "$CANDIDATE_RESULT" != "skipped" ]; then
echo "::error::Scheduled watchdog candidate result was ${CANDIDATE_RESULT}; expected skipped."
failed=1
fi
{
echo "## Definitive dry-run verdict"
echo
echo "- Source SHA: \`${GITHUB_SHA}\`"
echo "- Version: \`${VERSION_INPUT:-scheduled}\`"
echo "- Release preflight: \`${PREFLIGHT_RESULT}\`"
echo "- Stable demo verification: \`${DEMO_RESULT}\`"
echo "- Release candidate: \`${CANDIDATE_RESULT}\`"
echo "- Windows Authenticode required: \`${REQUIRE_WINDOWS_SIGNING}\`"
echo "- Unsigned Windows exception: \`${UNSIGNED_WINDOWS_EXCEPTION}\`"
if [ -n "${UNSIGNED_WINDOWS_REASON:-}" ]; then
echo "- Unsigned Windows reason: ${UNSIGNED_WINDOWS_REASON}"
fi
if [ -n "${WINDOWS_SIGNING_BACKEND:-}" ]; then
echo "- Windows signing backend: \`${WINDOWS_SIGNING_BACKEND}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
exit "$failed"