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-.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"