From 674364c74960f210c289a7703440c5f862e93a38 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Jul 2026 15:37:48 +0100 Subject: [PATCH] Record v6.1.0 unsigned Windows exception --- .github/workflows/build-release-candidate.yml | 4 +- .github/workflows/create-release.yml | 29 ++++++- .github/workflows/release-dry-run.yml | 41 +++++++++- .../HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md | 17 ++-- .../v6/internal/RELEASE_PROMOTION_POLICY.md | 18 +++-- ...stable-cutoff-owner-approval-2026-07-22.md | 15 ++++ docs/release-control/v6/internal/status.json | 2 +- .../subsystems/deployment-installability.md | 50 +++++++----- docs/releases/RELEASE_NOTES_v6.1.0.md | 10 ++- docs/releases/V6_CHANGELOG_v6.1.0.md | 10 ++- .../installtests/build_release_assets_test.go | 19 +++-- .../internal/record_rc_to_ga_rehearsal.py | 9 +++ .../record_rc_to_ga_rehearsal_test.py | 8 ++ .../release_promotion_policy_test.py | 15 +++- .../release_control/render_release_body.py | 9 +++ .../render_release_body_test.py | 5 ++ .../resolve_release_promotion.py | 31 ++++++++ .../resolve_release_promotion_test.py | 77 +++++++++++++++++++ scripts/trigger-release-dry-run.sh | 25 ++++++ scripts/trigger-release.sh | 27 +++++++ 20 files changed, 369 insertions(+), 52 deletions(-) diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 59279c8821..4cdd238b3e 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -365,7 +365,7 @@ jobs: retention-days: 30 build: - name: Build and Validate Signed Candidate + name: Build and Validate Release Candidate needs: [sign-macos-agent, sign-windows-agent] if: ${{ always() && (!inputs.require_macos_signing || needs.sign-macos-agent.result == 'success') && (!inputs.require_windows_signing || needs.sign-windows-agent.result == 'success') }} runs-on: ubuntu-24.04 @@ -450,7 +450,7 @@ jobs: name: windows-signing-evidence-${{ github.sha }}-${{ inputs.version }} path: release-candidate-manifest - - name: Build signed release candidate + - name: Build release candidate run: ./scripts/build-release.sh "${{ inputs.version }}" env: PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index c57a3a0e1e..bb14ab725d 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -37,6 +37,15 @@ on: description: 'Stable only: reason for hotfix soak exception' required: false type: string + unsigned_windows_exception: + description: 'v6.1.0 only: publish Windows agents without Authenticode under the recorded owner exception' + required: false + type: boolean + default: false + unsigned_windows_reason: + description: 'v6.1.0 only: owner reason for the unsigned Windows exception' + required: false + type: string historical_asset_backfill_only: description: 'Repair an already-published release packet in place without rebuilding binaries' required: false @@ -82,6 +91,9 @@ jobs: v5_eos_date: ${{ steps.promotion.outputs.v5_eos_date }} hotfix_exception: ${{ steps.promotion.outputs.hotfix_exception }} hotfix_reason: ${{ steps.promotion.outputs.hotfix_reason }} + require_windows_signing: ${{ steps.promotion.outputs.require_windows_signing }} + unsigned_windows_exception: ${{ steps.promotion.outputs.unsigned_windows_exception }} + unsigned_windows_reason: ${{ steps.promotion.outputs.unsigned_windows_reason }} promotion_mode: ${{ steps.promotion.outputs.promotion_mode }} is_stable_patch: ${{ steps.promotion.outputs.is_stable_patch }} historical_asset_backfill_only: ${{ steps.extract.outputs.historical_asset_backfill_only }} @@ -177,6 +189,8 @@ jobs: V5_EOS_DATE_INPUT: ${{ github.event.inputs.v5_eos_date }} HOTFIX_EXCEPTION_INPUT: ${{ github.event.inputs.hotfix_exception }} HOTFIX_REASON_INPUT: ${{ github.event.inputs.hotfix_reason }} + UNSIGNED_WINDOWS_EXCEPTION_INPUT: ${{ github.event.inputs.unsigned_windows_exception }} + UNSIGNED_WINDOWS_REASON_INPUT: ${{ github.event.inputs.unsigned_windows_reason }} run: | set -euo pipefail @@ -201,6 +215,14 @@ jobs: 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 python3 scripts/release_control/resolve_release_promotion.py "${HELPER_ARGS[@]}" > "$RUNNER_TEMP/promotion-metadata.out" rm -f "$NOTES_FILE" @@ -223,7 +245,7 @@ jobs: with: version: ${{ needs.prepare.outputs.version }} require_macos_signing: true - require_windows_signing: ${{ needs.prepare.outputs.is_prerelease != 'true' }} + require_windows_signing: ${{ needs.prepare.outputs.require_windows_signing == 'true' }} windows_signing_backend: signpath # Frontend checks run in parallel with backend tests @@ -724,7 +746,10 @@ jobs: --planned-ga-date "${{ needs.prepare.outputs.ga_date }}" \ --planned-v5-eos-date "${{ needs.prepare.outputs.v5_eos_date }}" \ --hotfix-exception "${{ needs.prepare.outputs.hotfix_exception }}" \ - --hotfix-reason "${{ needs.prepare.outputs.hotfix_reason }}" + --hotfix-reason "${{ needs.prepare.outputs.hotfix_reason }}" \ + --require-windows-signing "${{ needs.prepare.outputs.require_windows_signing }}" \ + --unsigned-windows-exception "${{ needs.prepare.outputs.unsigned_windows_exception }}" \ + --unsigned-windows-reason "${{ needs.prepare.outputs.unsigned_windows_reason }}" # The in-app "What's New" banner only fires when the release body has # a Highlights section (see frontend-modern/src/components/whatsNewModel.ts). diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index fce39b1225..6d7cd9f743 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -42,6 +42,15 @@ on: description: 'Stable rehearsal only: reason for hotfix soak exception' required: false type: string + unsigned_windows_exception: + description: 'v6.1.0 only: rehearse Windows agents without Authenticode under the recorded owner exception' + required: false + type: boolean + default: false + unsigned_windows_reason: + description: 'v6.1.0 only: owner reason for the unsigned Windows exception' + required: false + type: string note: description: 'Optional note/reason for the dry run' required: false @@ -70,7 +79,7 @@ jobs: with: version: ${{ inputs.version }} require_macos_signing: true - require_windows_signing: ${{ !contains(inputs.version, '-') }} + require_windows_signing: ${{ !contains(inputs.version, '-') && !(inputs.version == '6.1.0' && inputs.unsigned_windows_exception) }} windows_signing_backend: signpath dry-run: @@ -92,6 +101,9 @@ jobs: 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 @@ -131,6 +143,8 @@ jobs: 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 @@ -171,6 +185,14 @@ jobs: 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 @@ -373,6 +395,11 @@ jobs: 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 @@ -429,6 +456,9 @@ jobs: 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 @@ -445,7 +475,7 @@ jobs: 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 signed candidate" "$CANDIDATE_RESULT" success + 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 @@ -458,7 +488,12 @@ jobs: echo "- Version: \`${VERSION_INPUT:-scheduled}\`" echo "- Release preflight: \`${PREFLIGHT_RESULT}\`" echo "- Stable demo verification: \`${DEMO_RESULT}\`" - echo "- Signed candidate: \`${CANDIDATE_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 diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 1e864fcd11..61a49d0f87 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -1021,18 +1021,23 @@ Companion drill: 6. Confirm `v6.0.5` release timestamps and asset count remain unchanged and the public demo remains healthy on stable `6.0.5`. - Pass when: - The external rehearsal proves the canonical candidate builder, local tests - pin candidate-only publication and GitHub digest validation, and the static - release DAG contains no duplicate release build, backend-to-integration - serialization, full-download standard validator, or avoidable post-release - serialization. + The external rehearsal proves the canonical exact-SHA candidate builder and + its version-specific native-signing policy. If the `v6.1.0` Windows exception + is used, the rehearsal must preserve checksum, detached-signature, manifest, + and digest controls and record the owner reason and public disclosure. Local + tests pin candidate-only publication and GitHub digest validation, and the + static release DAG contains no duplicate release build, + backend-to-integration serialization, full-download standard validator, or + avoidable post-release serialization. - Latest exercised record: `docs/release-control/v6/internal/records/single-build-release-promotion-path-2026-07-09.md` - Block release if: Publication rebuilds release assets, the candidate manifest does not pin the exact SHA and complete asset set, standard validation downloads the full release packet, independent post-release jobs are serialized, or the - definitive verdict can pass without all applicable downstream results. + definitive verdict can pass without all applicable downstream results. Also + block if an unsigned stable Windows exception can apply to any version other + than `v6.1.0` or can omit its owner reason and release-note disclosure. ## Gate: `operational-trust-canonical-lifecycle` diff --git a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md index 4378d15da9..db8fa2acba 100644 --- a/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md +++ b/docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md @@ -174,6 +174,13 @@ Cloud, and self-hosted production users. Run` and the normal single-build publication workflow. Only a security, data-loss, upgrade-breaking, startup-blocking, or release-pipeline defect may reopen the cutoff; ordinary findings move to v6.1.1. + - After exact-SHA rehearsal `29927692302` exposed unavailable external + SignPath configuration, the release owner approved a `v6.1.0`-only + Windows Authenticode exception. The Windows artifacts must remain bound + by the exact-SHA candidate manifest, checksums, detached `.sig`/`.sshsig` + signatures, and published digests, and the public notes must disclose the + Unknown Publisher state. Later stable releases restore the Authenticode + requirement; this flag cannot be reused for another version. - Unproved self-service commercial transitions remain unavailable and unadvertised under the exposure-safety gate. This exception does not authorize enabling that feature or running a production billing proof. @@ -181,11 +188,12 @@ Cloud, and self-hosted production users. ## Single-Build Release Path 1. Every normal RC, stable, and patch release is initiated once through - `create-release.yml`. The workflow builds one signed candidate for the exact - pushed SHA while frontend, backend, Docker, Helm, and integration checks run - in parallel. No tag, draft, or public release mutation occurs until those - checks and the candidate build pass. -2. The signed candidate is uploaded as a one-day Actions artifact with a + `create-release.yml`. The workflow builds one exact-SHA candidate with the + native signing lanes required by that version's governed policy while + frontend, backend, Docker, Helm, and integration checks run in parallel. No + tag, draft, or public release mutation occurs until those checks and the + candidate build pass. +2. The release candidate is uploaded as a one-day Actions artifact with a machine-readable manifest that pins source SHA, version, filename, size, and SHA-256 for every release asset. Publication downloads and verifies that exact candidate; it must not rebuild release binaries or installers. diff --git a/docs/release-control/v6/internal/records/v6.1.0-stable-cutoff-owner-approval-2026-07-22.md b/docs/release-control/v6/internal/records/v6.1.0-stable-cutoff-owner-approval-2026-07-22.md index f5f697b4c8..644f345b40 100644 --- a/docs/release-control/v6/internal/records/v6.1.0-stable-cutoff-owner-approval-2026-07-22.md +++ b/docs/release-control/v6/internal/records/v6.1.0-stable-cutoff-owner-approval-2026-07-22.md @@ -20,6 +20,21 @@ metadata on top of the content cutoff base. The exact publish SHA must be bound by the no-public-release `Release Dry Run` and then consumed by the normal single-build release workflow without further product changes. +## Windows Signing Exception + +The first exact-SHA stable rehearsal, GitHub Actions run `29927692302`, stopped +before candidate assembly because the external SignPath configuration was +unavailable. It created no tag, draft, or public release. The release owner +then explicitly approved publishing `v6.1.0` without Windows Authenticode +signing rather than entering another release-candidate loop. + +This is a `v6.1.0`-only exception. Windows Unified Agent binaries remain bound +to the exact source SHA and candidate manifest and must retain checksum, +detached `.sig`/`.sshsig`, and post-publication digest verification. The public +release notes must state that the Windows binaries are not +Authenticode-signed and may show an Unknown Publisher warning. Every later +stable version restores the Windows Authenticode requirement automatically. + ## Cutoff Reopen Rule Only a security defect, data-loss risk, broken upgrade, startup failure, or diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 59416b55fc..d10c6f4254 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -8396,7 +8396,7 @@ }, { "id": "single-build-release-promotion-path", - "summary": "Confirm normal RC, stable, and patch releases build one exact-SHA signed candidate in parallel with checks, promote that candidate without rebuilding, validate GitHub asset digests without full packet downloads, and run independent post-publication lanes concurrently.", + "summary": "Confirm normal RC, stable, and patch releases build one exact-SHA candidate with policy-required native signing in parallel with checks, promote that candidate without rebuilding, validate GitHub asset digests without full packet downloads, and run independent post-publication lanes concurrently.", "owner": "project-owner", "blocking_level": "release-ready", "minimum_evidence_tier": "real-external-e2e", diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 6093a0eccd..5ee65067c0 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -357,10 +357,10 @@ TLS floor in the dynamic config. 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` 2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against the published release, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/validate-release-assets.yml` Normal releases are single-build promotions. The exact pushed SHA must - produce one signed candidate through - `.github/workflows/build-release-candidate.yml` while independent release - checks run in parallel. `create-release.yml` may publish only that candidate - after `scripts/release_candidate_manifest.py` verifies its version, source + produce one release candidate with the policy-required native signing lanes + through `.github/workflows/build-release-candidate.yml` while independent + release checks run in parallel. `create-release.yml` may publish only that + candidate after `scripts/release_candidate_manifest.py` verifies its version, source SHA, filenames, sizes, and SHA-256 values. Standard post-upload validation must compare that manifest with GitHub's server-side asset digests instead of downloading the complete release packet again. Historical repair and @@ -374,17 +374,19 @@ TLS floor in the dynamic config. it must not decompress a multi-gigabyte release archive again for every required entry, and the release-promotion contract test must reject a return to per-entry archive streaming. - A manually dispatched release rehearsal must activate the same signed + A manually dispatched release rehearsal must activate the same candidate build whenever its required `version` input is non-empty and must apply the same channel-specific native-signing policy as a publish run. macOS notarization remains mandatory for both prerelease and stable - candidates. Windows Authenticode remains mandatory for stable candidates; - prerelease candidates may retain checksum and detached-signature - verification without Authenticode while the release packet explicitly - discloses the unknown-publisher warning and stable promotion remains - blocked. A cheap signing-configuration job must report every missing secret - for the platforms required by that candidate before either platform runner - is allocated. Stable Windows signing must use SignPath's GitHub + candidates. Windows Authenticode remains mandatory for stable candidates + except for the explicitly version-bound `v6.1.0` owner exception; + prerelease candidates and that one stable exception may retain checksum and + detached-signature verification without Authenticode while the release + packet explicitly discloses the unknown-publisher warning. Prerelease promotion remains + blocked on the normal stable signing requirement, and every stable version + after `v6.1.0` restores it automatically. A cheap signing-configuration job + must report every missing secret for the platforms required by that + candidate before either platform runner is allocated. Stable Windows signing must use SignPath's GitHub trusted-build-system action by default, submit an immutable GitHub artifact by id, verify every returned executable, and retain evidence binding the request, source SHA, signer identity, and file digests. A repository-secret @@ -1028,9 +1030,12 @@ Authenticode backend. The reusable builder fails fast on missing configuration, submits the GitHub-hosted unsigned artifact through the pinned official action, verifies all returned executables, and stores request/source/signer/digest evidence beside the candidate manifest. Release Dry Run now has a terminal -verdict covering the signed candidate and no-mutation demo lane. The gate stays -blocked until the external SignPath project is configured and one stable dry -run passes for an exact `main` SHA. +verdict covering the exact-SHA candidate and no-mutation demo lane. Stable +rehearsal `29927692302` confirmed that the external SignPath project was not +configured and stopped without creating a public release. The release owner +subsequently approved a `v6.1.0`-only unsigned-Windows exception; the gate stays +blocked until a new exact-`main` rehearsal proves that exception and the +remaining candidate controls. Every caller of the reusable release-candidate builder must delegate `actions: read` alongside `contents: read`; the Windows signing job reads the exact uploaded artifact through the GitHub Actions API, and GitHub validates @@ -1049,7 +1054,7 @@ This stable minor release uses `promoted_from_tag=v6.1.0-rc.4`, recorded on 2026-07-22. The workflow input `hotfix_exception=true` carries that approved soak bypass through the existing promotion resolver; it does not reclassify the release as a patch hotfix. The exact stable `main` SHA must pass -the signed no-publication dry run before the same SHA is dispatched through the +the no-publication dry run before the same SHA is dispatched through the single-build publish workflow. The release publishes versioned GitHub, Docker, and Helm artifacts and advances the stable/latest install pointers and stable semver aliases. It promotes @@ -1080,9 +1085,12 @@ Agent Doctor into a routed, filterable diagnostic workflow with copyable reports and platform-correct host-local cleanup handoff, adds SAS and SCSI SMART coverage, restores actionable agent-update controls, fixes Patrol finding and proposal provenance, improves metrics and audit-store concurrency, and prepares -the stable Windows SignPath signing path. Stable publication requires the -SignPath-returned Windows binaries to pass Authenticode and signer verification; -the unsigned-Windows prerelease exception does not apply to `v6.1.0`. +the stable Windows SignPath signing path. For `v6.1.0`, the release owner +explicitly waived Authenticode after the first stable rehearsal exposed +unavailable external SignPath configuration. The unsigned Windows binaries +remain exact-SHA and manifest-bound with checksum, detached `.sig`/`.sshsig`, +and published-digest verification, and the release notes disclose the Unknown +Publisher state. This exception cannot apply to a later stable version. The stable server cut is classified `existing-mobile-build-compatible`. Pulse Mobile `1.0.0` iOS build `11` and Android versionCode `9` remain the existing candidate builds; the canonical core/mobile contract proves that `v6.1.0` @@ -2465,7 +2473,9 @@ externally owned bounded residual, but only when the RC packet explicitly discloses the unsigned Windows publisher state and the Windows binaries retain the exact-SHA candidate, checksum, detached-signature, and post-publication digest controls. Stable publication and the stable-path dry-run must continue -to require both native signing lanes. `scripts/build-release.sh` must replace +to require both native signing lanes except for the recorded, version-bound +`v6.1.0` Windows exception; every subsequent stable release restores both +requirements. `scripts/build-release.sh` must replace only the native targets required by those independent inputs and must fail closed when a required native-binary directory or target is absent. Historical published-release repair must flow through diff --git a/docs/releases/RELEASE_NOTES_v6.1.0.md b/docs/releases/RELEASE_NOTES_v6.1.0.md index a6c71449e8..4859e5c392 100644 --- a/docs/releases/RELEASE_NOTES_v6.1.0.md +++ b/docs/releases/RELEASE_NOTES_v6.1.0.md @@ -41,8 +41,9 @@ plus the final compatibility and reliability fixes in the stable cutoff. - `--report-ip` can set the Unified Agent's reported host address and becomes the leading host-identity signal. - Disk monitoring understands SAS transport and SCSI SMART attributes. -- Windows Unified Agent artifacts use the governed exact-SHA SignPath path; - stable publication requires Authenticode, signer, and digest verification. +- Windows Unified Agent artifacts remain bound to the exact release SHA and + retain checksum, detached-signature, manifest, and published-digest + verification. ## Improved @@ -93,6 +94,11 @@ Use the normal v6 install or update flow for `v6.1.0`. Existing Unified Agent configurations keep one authoritative Pulse server; observer reporting remains opt-in and no observer is created during upgrade. +Windows Unified Agent binaries in `v6.1.0` are not Authenticode-signed and may +show an Unknown Publisher warning. Verify the published checksums and detached +`.sig` or `.sshsig` signatures before installation. This is a one-release owner +exception; later stable releases restore the Windows Authenticode requirement. + The rollback target is `v6.0.5`. The exact rollback reinstall command is: ```bash diff --git a/docs/releases/V6_CHANGELOG_v6.1.0.md b/docs/releases/V6_CHANGELOG_v6.1.0.md index 2c86539fe4..967275c020 100644 --- a/docs/releases/V6_CHANGELOG_v6.1.0.md +++ b/docs/releases/V6_CHANGELOG_v6.1.0.md @@ -17,8 +17,8 @@ _This changelog describes stable `v6.1.0` compared with stable `v6.0.5`._ bounded native-tool execution. - Routed Agent Doctor workflow, `--report-ip` host identity, SAS transport, and SCSI SMART attribute support. -- Exact-SHA SignPath submission, Authenticode verification, signer validation, - and evidence recording for stable Windows Unified Agent artifacts. +- Exact-SHA, checksum, detached-signature, manifest, and published-digest + verification for Windows Unified Agent artifacts. ## Changed @@ -60,7 +60,11 @@ _This changelog describes stable `v6.1.0` compared with stable `v6.0.5`._ - Rollback target: `v6.0.5` - Rollback command: `./scripts/install.sh --version v6.0.5` - Promotion path: owner-approved exact-SHA stable cutoff from `main`, using the - one-version soak exception and the single-build signed-candidate workflow + one-version soak exception and the single-build release-candidate workflow +- Windows signing decision: one-release owner exception; the `v6.1.0` Windows + Unified Agent binaries are not Authenticode-signed and may show an Unknown + Publisher warning, while checksums and detached `.sig`/`.sshsig` signatures + remain required. Later stable releases restore the Authenticode requirement. - Mobile decision: `existing-mobile-build-compatible`; Pulse Mobile `1.0.0` iOS build `11` and Android versionCode `9` require no companion upload, and no public store rollout is part of this server release diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index bf16f37038..1f9b283226 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -345,7 +345,7 @@ func TestCurrentStableMinorReleasePacketTracksInstallMetadata(t *testing.T) { "Operational Trust", "Actions provides a dedicated inbox", "existing Pulse Mobile candidate", - "SignPath", + "not Authenticode-signed", "The rollback target is `v"+previous+"`", ) assertFileContainsAllNormalized(t, changelogPath, @@ -383,9 +383,9 @@ func TestCurrentStableMinorReleasePacketTracksInstallMetadata(t *testing.T) { assertFileContainsAllNormalized(t, repoFile("docs", "release-control", "v6", "internal", "subsystems", "deployment-installability.md"), "The active stable `v"+version+"` cut sets the repo-root `VERSION`, repo-root `docker-compose.yml` image default, `scripts/install-docker.sh` fallback, and Helm chart release metadata to the same `"+version+"` release version.", "`rollback_version=v"+previous+"`", - "The exact stable `main` SHA must pass the signed no-publication dry run before the same SHA is dispatched through the single-build publish workflow.", + "The exact stable `main` SHA must pass the no-publication dry run before the same SHA is dispatched through the single-build publish workflow.", "The stable server cut is classified `existing-mobile-build-compatible`.", - "Stable publication requires the SignPath-returned Windows binaries to pass Authenticode and signer verification", + "This exception cannot apply to a later stable version", "For the active stable `v"+version+"` cut, the repo-root compose default and `scripts/install-docker.sh` fallback must both pin `"+version+"`", ) } @@ -842,14 +842,23 @@ func TestReleaseCandidateRequiresPlatformNativeAgentSigning(t *testing.T) { } assertFileContainsAll(t, repoFile(".github", "workflows", "create-release.yml"), `require_macos_signing: true`, - `require_windows_signing: ${{ needs.prepare.outputs.is_prerelease != 'true' }}`, + `require_windows_signing: ${{ needs.prepare.outputs.require_windows_signing == 'true' }}`, + `unsigned_windows_exception:`, + `unsigned_windows_reason:`, `windows_signing_backend: signpath`, ) assertFileContainsAll(t, repoFile(".github", "workflows", "release-dry-run.yml"), `Definitive Dry-Run Verdict`, - `require_result "exact-SHA signed candidate" "$CANDIDATE_RESULT" success`, + `require_windows_signing: ${{ !contains(inputs.version, '-') && !(inputs.version == '6.1.0' && inputs.unsigned_windows_exception) }}`, + `require_result "exact-SHA release candidate" "$CANDIDATE_RESULT" success`, `require_result "stable demo no-mutation verification" "$DEMO_RESULT" success`, ) + assertFileContainsAll(t, repoFile("scripts", "release_control", "resolve_release_promotion.py"), + `version != "6.1.0"`, + `unsigned_windows_reason is required`, + `not Authenticode-signed`, + `require_windows_signing = not is_prerelease and not unsigned_windows_exception`, + ) assertFileContainsAll(t, repoFile("scripts", "build-release.sh"), `PULSE_AGENT_NATIVE_BINARIES_DIR`, `native_targets=()`, diff --git a/scripts/release_control/internal/record_rc_to_ga_rehearsal.py b/scripts/release_control/internal/record_rc_to_ga_rehearsal.py index abd540be5d..e7fa2a0b9f 100644 --- a/scripts/release_control/internal/record_rc_to_ga_rehearsal.py +++ b/scripts/release_control/internal/record_rc_to_ga_rehearsal.py @@ -222,6 +222,9 @@ def render_record( published_eos = v5_eos_date or summary_metadata.get("planned_v5_eos_date", "") hotfix_exception = summary_metadata.get("hotfix_exception", "") hotfix_reason = summary_metadata.get("hotfix_reason", "") + require_windows_signing = summary_metadata.get("windows_authenticode_required", "") + unsigned_windows_exception = summary_metadata.get("unsigned_windows_exception", "") + unsigned_windows_reason = summary_metadata.get("unsigned_windows_reason", "") operator_note = summary_metadata.get("operator_note", "") lines = [ @@ -248,6 +251,12 @@ def render_record( lines.append(f"- Hotfix exception: {hotfix_exception}") if hotfix_reason: lines.append(f"- Hotfix reason: {hotfix_reason}") + if require_windows_signing: + lines.append(f"- Windows Authenticode required: {require_windows_signing}") + if unsigned_windows_exception: + lines.append(f"- Unsigned Windows exception: {unsigned_windows_exception}") + if unsigned_windows_reason: + lines.append(f"- Unsigned Windows reason: {unsigned_windows_reason}") if operator_note: lines.append(f"- Workflow operator note: {operator_note}") for note in notes: diff --git a/scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py b/scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py index 7dd80f20cb..7c3ff7f7b7 100644 --- a/scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py +++ b/scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py @@ -29,6 +29,9 @@ SUMMARY = """# Prerelease-to-GA Rehearsal Summary - Planned GA date: 2026-03-15 - Planned v5 end-of-support date: 2026-06-11 - Hotfix exception: false +- Windows Authenticode required: false +- Unsigned Windows exception: true +- Unsigned Windows reason: v6.1.0 owner exception - Operator note: dry-run from test """ @@ -43,6 +46,8 @@ class RecordRcToGaRehearsalTest(unittest.TestCase): self.assertEqual(parsed["rollback_command"], "`./scripts/install.sh --version v5.1.23`") self.assertEqual(parsed["planned_ga_date"], "2026-03-15") self.assertEqual(parsed["planned_v5_eos_date"], "2026-06-11") + self.assertEqual(parsed["windows_authenticode_required"], "false") + self.assertEqual(parsed["unsigned_windows_exception"], "true") def test_render_record_contains_required_fields(self) -> None: rendered = mod.render_record( @@ -65,6 +70,9 @@ class RecordRcToGaRehearsalTest(unittest.TestCase): self.assertIn("Exact rollback or reinstall command: `./scripts/install.sh --version v5.1.23`", rendered) self.assertIn("Exact GA date to publish: 2026-03-15", rendered) self.assertIn("Exact v5 end-of-support date to publish: 2026-06-13", rendered) + self.assertIn("Windows Authenticode required: false", rendered) + self.assertIn("Unsigned Windows exception: true", rendered) + self.assertIn("Unsigned Windows reason: v6.1.0 owner exception", rendered) self.assertIn("Publish GA release notes with the EOS date.", rendered) self.assertIn("```md", rendered) diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 68d6ecabe3..119d141f7b 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -487,7 +487,12 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("build_release_candidate:", workflow) self.assertIn("if: ${{ inputs.version != '' }}", workflow) self.assertIn("require_macos_signing: true", workflow) - self.assertIn("require_windows_signing: ${{ !contains(inputs.version, '-') }}", workflow) + self.assertIn( + "require_windows_signing: ${{ !contains(inputs.version, '-') && !(inputs.version == '6.1.0' && inputs.unsigned_windows_exception) }}", + workflow, + ) + self.assertIn("unsigned_windows_exception:", workflow) + self.assertIn("unsigned_windows_reason:", workflow) self.assertIn("windows_signing_backend: signpath", workflow) self.assertRegex( workflow, @@ -500,7 +505,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): r" actions: read\n contents: read\n uses: \.\/\.github\/workflows\/build-release-candidate\.yml$", ) self.assertIn("Definitive Dry-Run Verdict", workflow) - self.assertIn('require_result "exact-SHA signed candidate" "$CANDIDATE_RESULT" success', workflow) + self.assertIn('require_result "exact-SHA release candidate" "$CANDIDATE_RESULT" success', workflow) self.assertIn('require_result "stable demo no-mutation verification" "$DEMO_RESULT" success', workflow) self.assertNotIn("if: ${{ github.event_name == 'workflow_dispatch' }}", workflow) self.assertIn("record_rc_to_ga_rehearsal.py --run-id ${{ github.run_id }}", workflow) @@ -661,7 +666,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("timeout-minutes: 60", candidate_workflow) self.assertIn("Verify Native Signing Configuration", candidate_workflow) self.assertEqual(candidate_workflow.count("needs: signing-configuration"), 2) - self.assertIn("require_windows_signing: ${{ needs.prepare.outputs.is_prerelease != 'true' }}", content) + self.assertIn("require_windows_signing: ${{ needs.prepare.outputs.require_windows_signing == 'true' }}", content) + self.assertIn("unsigned_windows_exception:", content) + self.assertIn("unsigned_windows_reason:", content) + self.assertIn('version != "6.1.0"', resolver) + self.assertIn("not Authenticode-signed", resolver) self.assertIn("windows_signing_backend: signpath", content) self.assertIn('if [[ "$REQUIRE_WINDOWS_SIGNING" == "true" ]]', candidate_workflow) self.assertIn("inputs.require_windows_signing", candidate_workflow) diff --git a/scripts/release_control/render_release_body.py b/scripts/release_control/render_release_body.py index a3b61ef566..114c14b2bd 100644 --- a/scripts/release_control/render_release_body.py +++ b/scripts/release_control/render_release_body.py @@ -219,6 +219,10 @@ def build_promotion_metadata_section(args: argparse.Namespace) -> str: lines.append(f"- Hotfix exception: {args.hotfix_exception}") if args.hotfix_reason: lines.append(f"- Hotfix reason: {args.hotfix_reason}") + lines.append(f"- Windows Authenticode required: {args.require_windows_signing}") + lines.append(f"- Unsigned Windows exception: {args.unsigned_windows_exception}") + if args.unsigned_windows_reason: + lines.append(f"- Unsigned Windows reason: {args.unsigned_windows_reason}") return "\n".join(lines) @@ -239,6 +243,9 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--planned-v5-eos-date", default="") parser.add_argument("--hotfix-exception") parser.add_argument("--hotfix-reason", default="") + parser.add_argument("--require-windows-signing") + parser.add_argument("--unsigned-windows-exception") + parser.add_argument("--unsigned-windows-reason", default="") args = parser.parse_args() if args.validate_notes_file or args.validate_body_file: @@ -264,6 +271,8 @@ def parse_args() -> argparse.Namespace: "--rollback-target": args.rollback_target, "--rollback-command": args.rollback_command, "--hotfix-exception": args.hotfix_exception, + "--require-windows-signing": args.require_windows_signing, + "--unsigned-windows-exception": args.unsigned_windows_exception, } missing = [name for name, value in required_render_args.items() if value is None] if missing: diff --git a/scripts/release_control/render_release_body_test.py b/scripts/release_control/render_release_body_test.py index 60bda0ef99..f27b2a41a9 100644 --- a/scripts/release_control/render_release_body_test.py +++ b/scripts/release_control/render_release_body_test.py @@ -91,6 +91,9 @@ Old metadata section. "planned_v5_eos_date": "", "hotfix_exception": "false", "hotfix_reason": "", + "require_windows_signing": "false", + "unsigned_windows_exception": "false", + "unsigned_windows_reason": "", }, )() @@ -113,6 +116,8 @@ Old metadata section. ) self.assertIn("https://pulserelay.pro/download.html", body) self.assertIn("- Rollback target: v5.1.28", body) + self.assertIn("- Windows Authenticode required: false", body) + self.assertIn("- Unsigned Windows exception: false", body) render_release_body.validate_release_body_shape(body, "6.0.0-rc.2") def test_flattened_release_notes_fail_closed(self) -> None: diff --git a/scripts/release_control/resolve_release_promotion.py b/scripts/release_control/resolve_release_promotion.py index 52b82efd4d..3a36cf2aa9 100644 --- a/scripts/release_control/resolve_release_promotion.py +++ b/scripts/release_control/resolve_release_promotion.py @@ -221,6 +221,8 @@ def resolve_metadata( hotfix_exception: bool, hotfix_reason_input: str, release_notes_input: str, + unsigned_windows_exception: bool = False, + unsigned_windows_reason_input: str = "", derive_rollback_when_missing: bool = False, list_stable_tags_fn: Callable[[], list[str]] = list_stable_tags, list_same_version_rc_tags_fn: Callable[[str], list[str]] = list_same_version_rc_tags, @@ -236,11 +238,33 @@ def resolve_metadata( ga_date = (ga_date_input or "").strip() v5_eos_date = (v5_eos_date_input or "").strip() hotfix_reason = normalize_whitespace(hotfix_reason_input) + unsigned_windows_reason = normalize_whitespace(unsigned_windows_reason_input) release_notes = release_notes_input or "" is_prerelease = is_prerelease_version(version) stable_patch = is_stable_patch_version(version) promotion_mode = "prerelease" if is_prerelease else "stable-rc-promotion" + if unsigned_windows_exception: + if version != "6.1.0": + raise ValueError( + "unsigned_windows_exception is approved only for stable v6.1.0. " + "Later stable releases must restore Windows Authenticode signing." + ) + if not unsigned_windows_reason: + raise ValueError( + "unsigned_windows_reason is required when unsigned_windows_exception is true." + ) + if release_notes and "not authenticode-signed" not in release_notes.lower(): + raise ValueError( + "Stable v6.1.0 release_notes must disclose that Windows binaries are not Authenticode-signed." + ) + elif unsigned_windows_reason: + raise ValueError( + "unsigned_windows_reason is allowed only when unsigned_windows_exception is true." + ) + + require_windows_signing = not is_prerelease and not unsigned_windows_exception + if not rollback_tag and derive_rollback_when_missing: rollback_tag = derive_latest_stable_rollback_tag(version, list_stable_tags_fn()) if not rollback_tag: @@ -374,6 +398,9 @@ def resolve_metadata( "v5_eos_date": v5_eos_date, "hotfix_exception": "true" if hotfix_exception else "false", "hotfix_reason": hotfix_reason, + "unsigned_windows_exception": "true" if unsigned_windows_exception else "false", + "unsigned_windows_reason": unsigned_windows_reason, + "require_windows_signing": "true" if require_windows_signing else "false", "soak_hours": soak_hours, } @@ -395,6 +422,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--v5-eos-date", default="") parser.add_argument("--hotfix-exception", action="store_true") parser.add_argument("--hotfix-reason", default="") + parser.add_argument("--unsigned-windows-exception", action="store_true") + parser.add_argument("--unsigned-windows-reason", default="") parser.add_argument("--release-notes-file", default="") return parser.parse_args() @@ -415,6 +444,8 @@ def main() -> int: hotfix_exception=args.hotfix_exception, hotfix_reason_input=args.hotfix_reason, release_notes_input=release_notes, + unsigned_windows_exception=args.unsigned_windows_exception, + unsigned_windows_reason_input=args.unsigned_windows_reason, ) for key, value in metadata.items(): diff --git a/scripts/release_control/resolve_release_promotion_test.py b/scripts/release_control/resolve_release_promotion_test.py index 9509564d05..25211b37ed 100644 --- a/scripts/release_control/resolve_release_promotion_test.py +++ b/scripts/release_control/resolve_release_promotion_test.py @@ -138,6 +138,83 @@ class ResolveReleasePromotionTest(unittest.TestCase): ) self.assertEqual(metadata["promoted_from_tag"], "v6.0.0-rc.2") self.assertEqual(metadata["soak_hours"], "73") + self.assertEqual(metadata["require_windows_signing"], "true") + self.assertEqual(metadata["unsigned_windows_exception"], "false") + + def test_v610_owner_exception_allows_disclosed_unsigned_windows_candidate(self) -> None: + metadata = resolver.resolve_metadata( + version="6.1.0", + promoted_from_tag_input="v6.1.0-rc.4", + rollback_version_input="v6.0.5", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=True, + hotfix_reason_input="Owner waived the remaining prerelease soak.", + release_notes_input=( + "Windows Unified Agent binaries are not Authenticode-signed for v6.1.0." + ), + unsigned_windows_exception=True, + unsigned_windows_reason_input=( + "Release owner accepted the Windows unknown-publisher warning for v6.1.0." + ), + tag_exists_fn=lambda tag: tag in {"v6.1.0-rc.4", "v6.0.5"}, + tag_commit_fn=lambda tag: "rc4-commit", + head_descends_from_fn=lambda commit: commit == "rc4-commit", + tag_created_unix_fn=lambda tag: 100, + now_unix_fn=lambda: 100 + (27 * 3600), + ) + + self.assertEqual(metadata["require_windows_signing"], "false") + self.assertEqual(metadata["unsigned_windows_exception"], "true") + self.assertEqual( + metadata["unsigned_windows_reason"], + "Release owner accepted the Windows unknown-publisher warning for v6.1.0.", + ) + + def test_unsigned_windows_exception_is_rejected_for_other_stable_versions(self) -> None: + with self.assertRaisesRegex(ValueError, "approved only for stable v6.1.0"): + resolver.resolve_metadata( + version="6.1.1", + promoted_from_tag_input="", + rollback_version_input="v6.1.0", + ga_date_input="", + v5_eos_date_input="", + hotfix_exception=True, + hotfix_reason_input="Emergency patch.", + release_notes_input="Windows binaries are not Authenticode-signed.", + unsigned_windows_exception=True, + unsigned_windows_reason_input="Not approved for this version.", + tag_exists_fn=lambda tag: True, + ) + + def test_unsigned_windows_exception_requires_reason_and_release_note_disclosure(self) -> None: + common = { + "version": "6.1.0", + "promoted_from_tag_input": "v6.1.0-rc.4", + "rollback_version_input": "v6.0.5", + "ga_date_input": "", + "v5_eos_date_input": "", + "hotfix_exception": True, + "hotfix_reason_input": "Owner waived the remaining prerelease soak.", + "unsigned_windows_exception": True, + "tag_exists_fn": lambda tag: True, + "tag_commit_fn": lambda tag: "rc4-commit", + "head_descends_from_fn": lambda commit: True, + "tag_created_unix_fn": lambda tag: 100, + "now_unix_fn": lambda: 100 + (27 * 3600), + } + with self.assertRaisesRegex(ValueError, "unsigned_windows_reason is required"): + resolver.resolve_metadata( + **common, + release_notes_input="Windows binaries are not Authenticode-signed.", + unsigned_windows_reason_input="", + ) + with self.assertRaisesRegex(ValueError, "must disclose"): + resolver.resolve_metadata( + **common, + release_notes_input="Windows agent details omitted.", + unsigned_windows_reason_input="Owner accepted the warning.", + ) def test_stable_requires_release_notes_notice_when_supplied(self) -> None: with self.assertRaisesRegex( diff --git a/scripts/trigger-release-dry-run.sh b/scripts/trigger-release-dry-run.sh index 5e81d54365..c9b3c9ee47 100755 --- a/scripts/trigger-release-dry-run.sh +++ b/scripts/trigger-release-dry-run.sh @@ -82,6 +82,8 @@ python3 scripts/check-workflow-dispatch-inputs.py \ --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 @@ -114,6 +116,8 @@ 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 @@ -166,6 +170,19 @@ if [ "$IS_PRERELEASE" != "true" ]; then exit 1 fi fi + + if [ "$VERSION" = "6.1.0" ]; then + echo "" + read -r -p "Use the recorded v6.1.0 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 echo "" @@ -186,6 +203,12 @@ fi if [ "$HOTFIX_EXCEPTION" = "true" ]; then RESOLVER_ARGS+=(--hotfix-exception --hotfix-reason "$HOTFIX_REASON") 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-dry-run-metadata.out cat /tmp/pulse-release-dry-run-metadata.out @@ -200,6 +223,8 @@ gh workflow run release-dry-run.yml \ -f v5_eos_date="$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="Governed release rehearsal for ${VERSION}" \ -f mobile_release_decision="$MOBILE_RELEASE_DECISION" \ -f mobile_release_evidence="$MOBILE_RELEASE_EVIDENCE" diff --git a/scripts/trigger-release.sh b/scripts/trigger-release.sh index 29c1d31bb7..2d27ba2f1c 100755 --- a/scripts/trigger-release.sh +++ b/scripts/trigger-release.sh @@ -95,6 +95,8 @@ python3 scripts/check-workflow-dispatch-inputs.py \ --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 @@ -195,6 +197,8 @@ 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 @@ -256,6 +260,19 @@ if [ "$IS_PRERELEASE" != "true" ]; then exit 1 fi fi + + if [ "$VERSION" = "6.1.0" ]; then + echo "" + read -r -p "Use the recorded v6.1.0 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 @@ -279,6 +296,12 @@ 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 @@ -294,6 +317,8 @@ if [ -n "$NOTES_FILE" ]; then --arg v5_eos_date "$V5_EOS_DATE" \ --argjson hotfix_exception "$HOTFIX_EXCEPTION" \ --arg hotfix_reason "$HOTFIX_REASON" \ + --argjson unsigned_windows_exception "$UNSIGNED_WINDOWS_EXCEPTION" \ + --arg unsigned_windows_reason "$UNSIGNED_WINDOWS_REASON" \ --argjson draft_only false \ --arg mobile_release_decision "$MOBILE_RELEASE_DECISION" \ --arg mobile_release_evidence "$MOBILE_RELEASE_EVIDENCE" \ @@ -306,6 +331,8 @@ if [ -n "$NOTES_FILE" ]; then 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