From 987deec9a7b3e86fe49d4d48aa15513d505ef773 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:50:01 +0000 Subject: [PATCH 1/4] fix(ai): Resolve issue #2073 - Add canonical macOS and Linux first-release profil Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- .github/workflows/desktop-release-guard.yml | 223 +++--------------- apps/desktop/README.md | 51 ++-- apps/desktop/scripts/release-artifacts.mjs | 122 ++++++---- .../scripts/release-artifacts.test.mjs | 137 ++++++++++- apps/desktop/scripts/release-profiles.mjs | 55 +++++ apps/desktop/scripts/release-publish.mjs | 80 ++++++- apps/desktop/scripts/release-publish.test.mjs | 83 ++++++- apps/desktop/src/release-workflow.test.ts | 104 ++++---- apps/desktop/src/signed-update-policy.test.ts | 53 ++++- apps/desktop/src/signed-updates.ts | 32 ++- 10 files changed, 587 insertions(+), 353 deletions(-) create mode 100644 apps/desktop/scripts/release-profiles.mjs diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index 3b5f93e63..57bfee5d0 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -46,6 +46,7 @@ jobs: if: github.event_name == 'pull_request' needs: validation-version runs-on: ${{ matrix.runner }} + continue-on-error: ${{ matrix.release_target == false }} timeout-minutes: 60 strategy: fail-fast: false @@ -54,21 +55,39 @@ jobs: - platform: linux arch: x64 runner: ubuntu-24.04 + release_target: true + release_profile: macos-linux-v1 + artifact_group: canonical - platform: linux arch: arm64 runner: ubuntu-24.04-arm + release_target: true + release_profile: macos-linux-v1 + artifact_group: canonical - platform: darwin arch: x64 runner: macos-15-intel + release_target: true + release_profile: macos-linux-v1 + artifact_group: canonical - platform: darwin arch: arm64 runner: macos-15 + release_target: true + release_profile: macos-linux-v1 + artifact_group: canonical - platform: win32 arch: x64 runner: windows-2025 + release_target: false + release_profile: macos-linux-windows-v1 + artifact_group: optional-windows - platform: win32 arch: arm64 runner: windows-11-arm + release_target: false + release_profile: macos-linux-windows-v1 + artifact_group: optional-windows env: PROPR_DESKTOP_VERSION: ${{ needs.validation-version.outputs.version }} steps: @@ -227,6 +246,7 @@ jobs: run: | node apps/desktop/scripts/release-artifacts.mjs probe-dmg-private-snapshot-isolation \ --version "$PROPR_DESKTOP_VERSION" \ + --profile "${{ matrix.release_profile }}" \ --make-directory apps/desktop/out/make \ --arch "${{ matrix.arch }}" @@ -235,6 +255,7 @@ jobs: run: | node apps/desktop/scripts/release-artifacts.mjs stage \ --version "$PROPR_DESKTOP_VERSION" \ + --profile "${{ matrix.release_profile }}" \ --platform "${{ matrix.platform }}" \ --arch "${{ matrix.arch }}" \ --make-directory apps/desktop/out/make \ @@ -243,7 +264,7 @@ jobs: - name: Upload unsigned validation target uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: propr-desktop-validation-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.run_id }} + name: propr-desktop-validation-${{ matrix.artifact_group }}-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.run_id }} path: desktop-release-${{ matrix.platform }}-${{ matrix.arch }} if-no-files-found: error retention-days: 14 @@ -273,13 +294,12 @@ jobs: - name: Install cross-format inspection tools run: | sudo apt-get update - sudo apt-get install --yes cpio msitools p7zip-full rpm - test -x /usr/bin/msiextract + sudo apt-get install --yes cpio p7zip-full rpm - name: Download all unsigned native artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: - pattern: propr-desktop-validation-*-${{ github.run_id }} + pattern: propr-desktop-validation-canonical-*-${{ github.run_id }} path: desktop-release-fragments - name: Verify architecture, matrix completeness, and checksums @@ -288,6 +308,7 @@ jobs: run: | node apps/desktop/scripts/release-artifacts.mjs finalize \ --version "$RELEASE_VERSION" \ + --profile macos-linux-v1 \ --input desktop-release-fragments \ --output desktop-release-final (cd desktop-release-final && sha256sum --check SHA256SUMS) @@ -366,22 +387,15 @@ jobs: - platform: darwin arch: arm64 runner: macos-15 - - platform: win32 - arch: x64 - runner: windows-2025 - - platform: win32 - arch: arm64 - runner: windows-11-arm env: PROPR_DESKTOP_VERSION: ${{ needs.preflight.outputs.version }} PROPR_DESKTOP_PRODUCTION_RELEASE: '1' PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS: '1' + PROPR_DESKTOP_RELEASE_PROFILE: macos-linux-v1 UPDATE_PUBLIC_KEY: ${{ vars.PROPR_DESKTOP_UPDATE_PUBLIC_KEY }} UPDATE_MANIFEST_URL: ${{ vars.PROPR_DESKTOP_UPDATE_MANIFEST_URL }} UPDATE_MAC_SIGNING_IDENTITY: ${{ vars.PROPR_DESKTOP_MAC_SIGNING_IDENTITY }} UPDATE_MAC_TEAM_ID: ${{ vars.PROPR_DESKTOP_MAC_TEAM_ID }} - UPDATE_WINDOWS_SIGNING_IDENTITY: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY }} - UPDATE_WINDOWS_SIGNER_PINS: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNER_PINS }} steps: - name: Revalidate immutable tag before checkout shell: bash @@ -431,37 +445,6 @@ jobs: - name: Install locked dependencies run: npm ci - - name: Provision pinned WiX 3.14.1 binaries for Windows ARM64 - if: matrix.platform == 'win32' && matrix.arch == 'arm64' - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $downloadUrl = 'https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip' - $expectedSha256 = '6ac824e1642d6f7277d0ed7ea09411a508f6116ba6fae0aa5f2c7daa2ff43d31' - $archive = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64.zip' - $wixDirectory = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64' - if ((Test-Path -LiteralPath $archive) -or (Test-Path -LiteralPath $wixDirectory)) { - throw 'Pinned ARM64 WiX runner-temp paths are not fresh' - } - Invoke-WebRequest -Uri $downloadUrl -OutFile $archive -MaximumRedirection 5 -TimeoutSec 120 - $archiveItem = Get-Item -LiteralPath $archive -Force - if ($archiveItem.PSIsContainer -or ($archiveItem.Attributes -band [IO.FileAttributes]::ReparsePoint) ` - -or $archiveItem.Length -le 0 -or $archiveItem.Length -gt 64MB) { - throw 'Pinned ARM64 WiX archive is not a regular file' - } - if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -cne $expectedSha256) { - throw 'Pinned ARM64 WiX archive digest mismatch' - } - New-Item -ItemType Directory -Path $wixDirectory | Out-Null - Expand-Archive -LiteralPath $archive -DestinationPath $wixDirectory - Remove-Item -LiteralPath $archive -Force - "PROPR_DESKTOP_WIX_DIRECTORY=$wixDirectory" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Probe canonical WiX 3.14.1 compiler - if: matrix.platform == 'win32' - shell: pwsh - run: node apps/desktop/scripts/build-windows-machine-installer.mjs probe '${{ matrix.arch }}' $env:PROPR_DESKTOP_WIX_DIRECTORY - - name: Install native Linux package tools if: matrix.platform == 'linux' run: | @@ -500,61 +483,6 @@ jobs: echo "PROPR_DESKTOP_APPLE_API_ISSUER_ID=$APPLE_API_ISSUER_ID" >> "$GITHUB_ENV" echo "DESKTOP_PLATFORM_CODE_SIGNED=1" >> "$GITHUB_ENV" - - name: Configure required Windows signing - if: matrix.platform == 'win32' - shell: pwsh - env: - CERTIFICATE_PFX_BASE64: ${{ secrets.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64 }} - CERTIFICATE_PASSWORD: ${{ secrets.PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD }} - run: | - $values = @{ - CERTIFICATE_PFX_BASE64 = $env:CERTIFICATE_PFX_BASE64 - CERTIFICATE_PASSWORD = $env:CERTIFICATE_PASSWORD - UPDATE_WINDOWS_SIGNING_IDENTITY = $env:UPDATE_WINDOWS_SIGNING_IDENTITY - UPDATE_WINDOWS_SIGNER_PINS = $env:UPDATE_WINDOWS_SIGNER_PINS - } - foreach ($entry in $values.GetEnumerator()) { if (!$entry.Value) { throw "Required production Windows field $($entry.Key) is missing" } } - $pins = $env:UPDATE_WINDOWS_SIGNER_PINS -split ',' - if ($pins.Count -gt 16 -or (($pins | Sort-Object -CaseSensitive -Unique) -join ',') -cne $env:UPDATE_WINDOWS_SIGNER_PINS) { - throw 'Windows signer pin allowlist is not sorted and unique' - } - foreach ($pin in $pins) { - if ($pin -cnotmatch '^(certificate|spki)-sha256:[a-f0-9]{64}$') { throw 'Windows signer pin is not canonical' } - } - $certificate = Join-Path $env:RUNNER_TEMP 'propr-desktop-signing.pfx' - [IO.File]::WriteAllBytes($certificate, [Convert]::FromBase64String($env:CERTIFICATE_PFX_BASE64)) - $signingCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( - $certificate, - $env:CERTIFICATE_PASSWORD, - [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet - ) - $codeSigningEku = @($signingCertificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } | - ForEach-Object { $_.EnhancedKeyUsages } | ForEach-Object { $_.Value }) -ccontains '1.3.6.1.5.5.7.3.3' - if ($signingCertificate.Subject -cne $env:UPDATE_WINDOWS_SIGNING_IDENTITY - -or [DateTime]::Now -lt $signingCertificate.NotBefore - -or [DateTime]::Now -gt $signingCertificate.NotAfter - -or !$codeSigningEku) { - throw 'Windows signing certificate publisher, validity, or code-signing EKU is invalid' - } - $chain = [Security.Cryptography.X509Certificates.X509Chain]::new() - $chain.ChainPolicy.RevocationMode = [Security.Cryptography.X509Certificates.X509RevocationMode]::Online - $chain.ChainPolicy.RevocationFlag = [Security.Cryptography.X509Certificates.X509RevocationFlag]::EntireChain - $chain.ChainPolicy.VerificationFlags = [Security.Cryptography.X509Certificates.X509VerificationFlags]::NoFlag - $chain.ChainPolicy.UrlRetrievalTimeout = [TimeSpan]::FromSeconds(15) - if (!$chain.Build($signingCertificate)) { throw 'Windows signing certificate chain or revocation policy is invalid' } - $certificateBase64 = [Convert]::ToBase64String($signingCertificate.RawData) - $fingerprints = (node -e 'const {createHash,X509Certificate}=require("node:crypto");const certificate=new X509Certificate(Buffer.from(process.argv[1],"base64"));process.stdout.write(JSON.stringify({certificateSha256:certificate.fingerprint256.replaceAll(":","").toLowerCase(),spkiSha256:createHash("sha256").update(certificate.publicKey.export({format:"der",type:"spki"})).digest("hex")}))' $certificateBase64) | ConvertFrom-Json - $actualPins = @("certificate-sha256:$($fingerprints.certificateSha256)", "spki-sha256:$($fingerprints.spkiSha256)") - if (@($actualPins | Where-Object { $pins -ccontains $_ }).Count -eq 0) { - throw 'Windows signing certificate does not match the configured cryptographic pin policy' - } - "PROPR_DESKTOP_WINDOWS_CERTIFICATE_FILE=$certificate" | Out-File -FilePath $env:GITHUB_ENV -Append - "PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD=$env:CERTIFICATE_PASSWORD" | Out-File -FilePath $env:GITHUB_ENV -Append - "PROPR_DESKTOP_WINDOWS_SIGNER_PINS=$env:UPDATE_WINDOWS_SIGNER_PINS" | Out-File -FilePath $env:GITHUB_ENV -Append - "PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256=$($fingerprints.certificateSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append - "PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256=$($fingerprints.spkiSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append - 'DESKTOP_PLATFORM_CODE_SIGNED=1' | Out-File -FilePath $env:GITHUB_ENV -Append - - name: Require macOS signed-update runtime configuration if: matrix.platform == 'darwin' shell: bash @@ -581,11 +509,6 @@ jobs: test ! -e apps/desktop/out npm run desktop:package - - name: Assert signed Windows MVP package excludes update authority - if: matrix.platform == 'win32' - shell: bash - run: node apps/desktop/scripts/assert-windows-mvp-package.mjs "$(pwd)/apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}" - - name: Typecheck and test production desktop runtime shell: bash run: | @@ -613,22 +536,6 @@ jobs: xcrun stapler validate "$dmg" node apps/desktop/scripts/verify-darwin-image.mjs "$dmg" - - name: Make signed Windows production installer - if: matrix.platform == 'win32' - shell: pwsh - run: npm run make -w @propr/desktop -- --arch=${{ matrix.arch }} - - - name: Install and exercise signed ordinary-user Windows application - if: matrix.platform == 'win32' - shell: pwsh - run: | - $installers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') - if ($installers.Count -ne 1) { throw 'Signed machine-wide Windows installer is missing or ambiguous' } - & apps/desktop/scripts/test-installed-windows-app.ps1 ` - -Installer $installers[0].FullName ` - -Architecture '${{ matrix.arch }}' - "PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1" | Out-File -FilePath $env:GITHUB_ENV -Append - - name: Launch packaged Linux application if: matrix.platform == 'linux' shell: bash @@ -637,11 +544,6 @@ jobs: sudo chmod 4755 "apps/desktop/out/propr-desktop-linux-${{ matrix.arch }}/chrome-sandbox" xvfb-run --auto-servernum npm run desktop:smoke - - name: Launch signed packaged Windows application and exercise MVP desktop flows - if: matrix.platform == 'win32' - shell: bash - run: npm run desktop:smoke - - name: Inspect signed and notarized macOS application if: matrix.platform == 'darwin' shell: bash @@ -665,56 +567,6 @@ jobs: echo 'PROPR_EOF' } >> "$GITHUB_ENV" - - name: Inspect signed Windows application and installer payload - if: matrix.platform == 'win32' - shell: pwsh - run: | - npm run desktop:smoke:inspect - $machineInstallers = @(Get-ChildItem apps/desktop/out/make -Recurse -File -Filter '*Machine-Setup.msi') - $appExecutable = "apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}/propr-desktop.exe" - node apps/desktop/scripts/assert-windows-mvp-package.mjs (Resolve-Path "apps/desktop/out/propr-desktop-win32-${{ matrix.arch }}").Path - if ($machineInstallers.Count -ne 1) { throw 'Canonical Windows MSI is missing or ambiguous' } - $machineInstaller = $machineInstallers[0] - node apps/desktop/scripts/release-architecture.mjs inspect ` - --path $machineInstaller.FullName ` - --kind msi ` - --platform win32 ` - --arch '${{ matrix.arch }}' - function Get-ValidatedSignerEvidence([string]$Path) { - $signature = Get-AuthenticodeSignature -LiteralPath $Path - if ($signature.Status -ne 'Valid' -or !$signature.SignerCertificate -or !$signature.TimeStamperCertificate) { - throw "Windows Authenticode chain or timestamp status is invalid for $Path" - } - $certificateBase64 = [Convert]::ToBase64String($signature.SignerCertificate.RawData) - $fingerprints = (node -e 'const {createHash,X509Certificate}=require("node:crypto");const certificate=new X509Certificate(Buffer.from(process.argv[1],"base64"));process.stdout.write(JSON.stringify({certificateSha256:certificate.fingerprint256.replaceAll(":","").toLowerCase(),spkiSha256:createHash("sha256").update(certificate.publicKey.export({format:"der",type:"spki"})).digest("hex")}))' $certificateBase64) | ConvertFrom-Json - [PSCustomObject]@{ - Subject = $signature.SignerCertificate.Subject - CertificateSha256 = $fingerprints.certificateSha256 - SpkiSha256 = $fingerprints.spkiSha256 - } - } - $evidence = @( - Get-ValidatedSignerEvidence $machineInstaller.FullName - Get-ValidatedSignerEvidence $appExecutable - ) - foreach ($signer in $evidence) { - if ($signer.Subject -cne $env:UPDATE_WINDOWS_SIGNING_IDENTITY) { throw 'Windows Authenticode signer does not match the configured exact subject' } - } - $distinctSigners = @($evidence | ForEach-Object { $_ | ConvertTo-Json -Compress } | Sort-Object -Unique) - if ($distinctSigners.Count -ne 1) { throw 'Windows artifacts have mixed Authenticode signers' } - $actualPins = @( - "certificate-sha256:$($evidence[0].CertificateSha256)" - "spki-sha256:$($evidence[0].SpkiSha256)" - ) - $allowedPins = @($env:UPDATE_WINDOWS_SIGNER_PINS -split ',') - if (@($actualPins | Where-Object { $allowedPins -ccontains $_ }).Count -eq 0) { - throw 'Windows Authenticode signer does not match the configured build pin' - } - "PROPR_DESKTOP_ACTUAL_SIGNER_TYPE=authenticode-subject" | Out-File -FilePath $env:GITHUB_ENV -Append - "PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY=$($evidence[0].Subject)" | Out-File -FilePath $env:GITHUB_ENV -Append - "PROPR_DESKTOP_ACTUAL_WINDOWS_CERTIFICATE_SHA256=$($evidence[0].CertificateSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append - "PROPR_DESKTOP_ACTUAL_WINDOWS_SPKI_SHA256=$($evidence[0].SpkiSha256)" | Out-File -FilePath $env:GITHUB_ENV -Append - - name: Inspect native Linux production packages if: matrix.platform == 'linux' shell: bash @@ -729,6 +581,7 @@ jobs: run: | node apps/desktop/scripts/release-artifacts.mjs probe-dmg-private-snapshot-isolation \ --version "$PROPR_DESKTOP_VERSION" \ + --profile "$PROPR_DESKTOP_RELEASE_PROFILE" \ --make-directory apps/desktop/out/make \ --arch "${{ matrix.arch }}" @@ -737,6 +590,7 @@ jobs: run: | node apps/desktop/scripts/release-artifacts.mjs stage \ --version "$PROPR_DESKTOP_VERSION" \ + --profile "$PROPR_DESKTOP_RELEASE_PROFILE" \ --platform "${{ matrix.platform }}" \ --arch "${{ matrix.arch }}" \ --make-directory apps/desktop/out/make \ @@ -750,18 +604,6 @@ jobs: if-no-files-found: error retention-days: 14 - - name: Clean pinned Windows ARM64 WiX binaries - if: always() && matrix.platform == 'win32' && matrix.arch == 'arm64' - shell: pwsh - run: | - $archive = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64.zip' - $wixDirectory = Join-Path $env:RUNNER_TEMP 'propr-wix3141-arm64' - Remove-Item -LiteralPath $archive -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $wixDirectory -Recurse -Force -ErrorAction SilentlyContinue - if ((Test-Path -LiteralPath $archive) -or (Test-Path -LiteralPath $wixDirectory)) { - throw 'Pinned ARM64 WiX cleanup failed' - } - release-finalize: name: Revalidate production architectures and finalize checksums if: needs.preflight.result == 'success' @@ -778,8 +620,7 @@ jobs: - name: Install cross-format inspection tools run: | sudo apt-get update - sudo apt-get install --yes cpio msitools p7zip-full rpm - test -x /usr/bin/msiextract + sudo apt-get install --yes cpio p7zip-full rpm - name: Download all trusted native artifacts uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 @@ -793,6 +634,7 @@ jobs: run: | node apps/desktop/scripts/release-artifacts.mjs finalize \ --version "$RELEASE_VERSION" \ + --profile macos-linux-v1 \ --input desktop-release-fragments \ --output desktop-release-validated (cd desktop-release-validated && sha256sum --check SHA256SUMS) @@ -846,14 +688,14 @@ jobs: PROPR_DESKTOP_UPDATE_PUBLIC_KEY: ${{ vars.PROPR_DESKTOP_UPDATE_PUBLIC_KEY }} PROPR_DESKTOP_UPDATE_MANIFEST_URL: ${{ vars.PROPR_DESKTOP_UPDATE_MANIFEST_URL }} PROPR_DESKTOP_MAC_TEAM_ID: ${{ vars.PROPR_DESKTOP_MAC_TEAM_ID }} - PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY }} - PROPR_DESKTOP_WINDOWS_SIGNER_PINS: ${{ vars.PROPR_DESKTOP_WINDOWS_SIGNER_PINS }} PROPR_DESKTOP_DARWIN_X64_FEED_URL: ${{ vars.PROPR_DESKTOP_DARWIN_X64_FEED_URL }} PROPR_DESKTOP_DARWIN_ARM64_FEED_URL: ${{ vars.PROPR_DESKTOP_DARWIN_ARM64_FEED_URL }} + PROPR_DESKTOP_RELEASE_PROFILE: macos-linux-v1 RELEASE_VERSION: ${{ needs.preflight.outputs.version }} run: | node apps/desktop/scripts/release-artifacts.mjs sign \ --version "$RELEASE_VERSION" \ + --profile "$PROPR_DESKTOP_RELEASE_PROFILE" \ --input desktop-release-validated \ --output desktop-release-signed test -s desktop-release-signed/desktop-release.json.sig @@ -895,6 +737,7 @@ jobs: RELEASE_TAG: ${{ needs.preflight.outputs.tag }} TAG_OBJECT_SHA: ${{ needs.preflight.outputs.tag_object_sha }} RELEASE_DIRECTORY: desktop-release-final + PROPR_DESKTOP_RELEASE_PROFILE: macos-linux-v1 run: | set -euo pipefail test -s desktop-release-final/desktop-release.json.sig diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c33d0488f..97f4240f0 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -71,21 +71,23 @@ not download, install, start, or execute ProPR runtime components. Desktop releases have their own `desktop-v..` tags. They do not use or require the monorepo's `v` tag. `PROPR_DESKTOP_VERSION` propagates the tag version into the packaged application, renderer, native -metadata, Linux packages, protected machine MSI, artifact names, and release manifest without changing the monorepo +metadata, Linux packages, the deferred protected machine MSI, artifact names, and release manifest without changing the monorepo package versions. -The native GitHub Actions matrix produces these assets for both x64 and arm64: +The first production release uses the explicit, fail-closed `macos-linux-v1` profile. It produces exactly 10 native +artifacts for these four targets: `linux-x64`, `linux-arm64`, `darwin-x64`, and `darwin-arm64`. | Platform | Native runner | Direct-distribution artifacts | | --- | --- | --- | | Linux | `ubuntu-24.04`, `ubuntu-24.04-arm` | DEB, RPM, ZIP | | macOS | `macos-15-intel`, `macos-15` | DMG, ZIP | -| Windows | `windows-2025`, `windows-11-arm` | signed per-machine Program Files MSI | -Every matrix job stages DEB/RPM/ZIP/DMG names as `ProPR-Desktop---.` and retains -`ProPR-Desktop--windows--Machine-Setup.msi` for Windows. The final job rejects -missing targets or changed fragment checksums, emits `SHA256SUMS` and `desktop-release.json`, and attaches the complete -set to the matching GitHub release. Production publication is triggered only by a new, non-forced +Each Linux architecture contributes DEB, RPM, and ZIP (six artifacts); each macOS architecture contributes DMG and ZIP +(four artifacts). Every matrix job stages names as `ProPR-Desktop---.`. Finalization, +metadata signing, checksum aggregation, and publication all require the explicit profile and reject a missing, +duplicate, or unexpected fragment or artifact—including every Windows artifact. They emit `SHA256SUMS` and +`desktop-release.json` and attach the complete set to the matching GitHub release. Production publication is triggered +only by a new, non-forced `desktop-v..` tag push; there is no manual dispatch path. A secretless preflight must succeed before any job can request the protected release environment or receive release secrets. Normal local packages are unsigned and have updates disabled: @@ -124,7 +126,8 @@ unreviewed tag cannot enter the environment or obtain the App credential. The pr signing, notarization, update-signing, release-publication, or production deployment secret. Signing material is read only from the distinct approval-protected `desktop-release` GitHub environment and written -to runner-temporary files/keychains. Every value below is mandatory for a production `desktop-v*` tag; unsigned and +to runner-temporary files/keychains. Every macOS/update value below is mandatory for a production `desktop-v*` tag; +Windows credentials are neither read nor required by `macos-linux-v1`. Unsigned and partially signed production releases fail before publication. Pull-request package validation and the preflight environment receive none of these secrets and explicitly check that release-secret environment variables are absent. @@ -135,18 +138,12 @@ GitHub Actions secrets: - `PROPR_DESKTOP_APPLE_API_KEY_P8_BASE64`: base64 of the App Store Connect API `.p8` key. - `PROPR_DESKTOP_APPLE_API_KEY_ID`: App Store Connect API key ID. - `PROPR_DESKTOP_APPLE_API_ISSUER_ID`: App Store Connect issuer UUID. -- `PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64`: base64 of the Authenticode `.pfx`. -- `PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD`: password for that `.pfx`. - `PROPR_DESKTOP_UPDATE_PRIVATE_KEY`: base64 Ed25519 PKCS#8 DER key used only to sign update-channel metadata. GitHub Actions variables (public configuration, not secrets): - `PROPR_DESKTOP_MAC_SIGNING_IDENTITY`: exact Developer ID Application identity. - `PROPR_DESKTOP_MAC_TEAM_ID`: exact Team ID embedded in signed macOS update builds and verified from produced apps. -- `PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY`: exact Authenticode certificate subject expected by installed builds. -- `PROPR_DESKTOP_WINDOWS_SIGNER_PINS`: sorted, unique comma-separated allowlist of one or more - `certificate-sha256:<64 lowercase hex>` or `spki-sha256:<64 lowercase hex>` fingerprints. Production Windows - packaging fails closed when this public operator pin is absent, malformed, or does not match the signing key. - `PROPR_DESKTOP_UPDATE_PUBLIC_KEY`: base64 Ed25519 SPKI DER public key matching the update private key. - `PROPR_DESKTOP_UPDATE_MANIFEST_URL`: stable HTTPS URL from which clients fetch `desktop-release.json`; the detached signature must be published beside it as `desktop-release.json.sig`. @@ -179,12 +176,20 @@ bound macOS packages to their configured HTTPS URLs. The manifest URL must not c always the documented pathname plus `.sig`. Linux never checks for native updates. macOS remains a signed, check-only channel: it verifies the Ed25519 manifest, -exact target/version/feed bytes, package URL/size/SHA-256, and actual Team ID/designated requirement. Windows self-update -is explicitly `unsupported` for this release. The Windows build embeds no update URL or key even when update environment -variables are present; its public check and apply boundaries return `unsupported` before any network, cache, artifact, -signer, install-authority, or apply-capability call, and signed release metadata advertises no Windows feed. - -Windows still publishes exactly one timestamped Authenticode-signed machine-wide MSI for each x64 and ARM64 target, -with the packaged application's signer and architecture inspected before staging. Per-user Squirrel Setup/NUPKG -artifacts remain unsupported and are never staged, checksummed, advertised, or published. Unsigned developer packages -remain update-disabled. Windows self-update installation work resumes only under issue #2000. +exact target/version/feed bytes, package URL/size/SHA-256, and actual Team ID/designated requirement. + +### Deferred Windows publication + +Windows publication is deferred to a separately gated follow-up release. The source, security assertions, unit tests, +native x64/ARM64 package jobs, machine-wide MSI checks, and ordinary-user runtime validation remain intact. Those two PR +matrix entries use the deliberate `macos-linux-windows-v1` compatibility profile, are non-blocking for +`macos-linux-v1`, and upload under an optional-Windows artifact namespace that canonical finalization never downloads. +The six-target profile retains its 12-artifact contract for future activation, including Windows certificate subject, +cryptographic pin, timestamp, installed-application, architecture, and signer-equality gates. It is not a production +workflow mode yet. Per-user Squirrel Setup/NUPKG artifacts remain unsupported. + +Before a future Windows-inclusive mode can be enabled, operators must separately configure the Authenticode PFX and +password, exact signing identity, and sorted certificate/SPKI SHA-256 pin allowlist. None of those values belongs in or +can satisfy the first-release profile. For the current release, the external credentials that must be ready are the +Developer ID Application P12/password, App Store Connect notarization key/key ID/issuer ID, Ed25519 update signing key +pair, stable HTTPS manifest URL, and both architecture-specific macOS feed URLs listed above. diff --git a/apps/desktop/scripts/release-artifacts.mjs b/apps/desktop/scripts/release-artifacts.mjs index f7bf7781e..4c75206cf 100644 --- a/apps/desktop/scripts/release-artifacts.mjs +++ b/apps/desktop/scripts/release-artifacts.mjs @@ -9,18 +9,15 @@ import { inspectArtifactArchitecture, NATIVE_DMG_VALIDATOR, } from './release-architecture.mjs'; +import { + expectedProfileArtifacts, + releaseFileName, + resolveReleaseProfile, +} from './release-profiles.mjs'; const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const WINDOWS_SIGNER_PIN_PATTERN = /^(?:certificate|spki)-sha256:[a-f0-9]{64}$/; -const TARGETS = new Map([ - ['linux-x64', ['deb', 'rpm', 'zip']], - ['linux-arm64', ['deb', 'rpm', 'zip']], - ['darwin-x64', ['dmg', 'zip']], - ['darwin-arm64', ['dmg', 'zip']], - ['win32-x64', ['msi']], - ['win32-arm64', ['msi']], -]); const DMG_HELPERS = [ 'propr-desktop Helper.app', 'propr-desktop Helper (GPU).app', @@ -443,21 +440,8 @@ const artifactKind = (path, platform) => { return ['deb', 'rpm', 'zip', 'dmg'].includes(extension) ? extension : undefined; }; -const releaseFileName = (version, platform, arch, kind) => { - const platformName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; - return kind === 'msi' - ? `ProPR-Desktop-${version}-${platformName}-${arch}-Machine-Setup.msi` - : `ProPR-Desktop-${version}-${platformName}-${arch}.${kind}`; -}; - -const validateCanonicalArtifactMatrix = (artifacts, version, label) => { - const expected = new Map(); - for (const [target, targetKinds] of TARGETS) { - const [platform, arch] = target.split('-'); - for (const kind of targetKinds) { - expected.set(releaseFileName(version, platform, arch, kind), { platform, arch, kind }); - } - } +const validateCanonicalArtifactMatrix = (artifacts, version, label, profile) => { + const expected = expectedProfileArtifacts(profile, version); if (!Array.isArray(artifacts) || artifacts.length !== expected.size) { throw new Error(`${label} must contain the exact ${expected.size}-artifact matrix`); } @@ -517,14 +501,16 @@ export const stageArtifacts = async ({ platform, arch, version, + profile: profileName, env = process.env, inspectArchitecture = inspectArtifactArchitecture, privateDmgFixtureAuthority, }) => { if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid desktop release version: ${version}`); + const profile = resolveReleaseProfile(profileName); const target = `${platform}-${arch}`; - const expectedKinds = TARGETS.get(target); - if (!expectedKinds) throw new Error(`Unsupported desktop release target: ${target}`); + const expectedKinds = profile.targets.get(target); + if (!expectedKinds) throw new Error(`Target ${target} is not allowed by release profile ${profile.name}`); if (platform === 'darwin' && process.platform === 'win32' && (inspectArchitecture === inspectArtifactArchitecture || !isScopedWindowsDmgFixtureAuthority(privateDmgFixtureAuthority))) { @@ -627,6 +613,7 @@ export const stageArtifacts = async ({ } const fragment = { schemaVersion: 2, + releaseProfile: profile.name, version, tag: `desktop-v${version}`, target, @@ -638,7 +625,7 @@ export const stageArtifacts = async ({ return fragment; }; -export const probePrivateDmgSnapshotIsolation = async ({ makeDirectory, arch, version, env = process.env }) => { +export const probePrivateDmgSnapshotIsolation = async ({ makeDirectory, arch, version, profile, env = process.env }) => { if (process.platform !== 'darwin') { throw new Error('Private-snapshot DMG isolation probe is available only on native macOS'); } @@ -660,6 +647,7 @@ export const probePrivateDmgSnapshotIsolation = async ({ makeDirectory, arch, ve platform: 'darwin', arch, version, + profile, env, inspectArchitecture: arguments_ => inspectArtifactArchitecture({ ...arguments_, @@ -709,31 +697,39 @@ export const finalizeArtifacts = async ({ inputDirectory, outputDirectory, version, + profile: profileName, inspectArchitecture = inspectArtifactArchitecture, }) => { if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid desktop release version: ${version}`); + const profile = resolveReleaseProfile(profileName); const fragments = await readFragments(inputDirectory); - if (fragments.length !== TARGETS.size) { - throw new Error(`Expected ${TARGETS.size} release fragments, found ${fragments.length}`); + if (fragments.length !== profile.targets.size) { + throw new Error(`Expected ${profile.targets.size} release fragments, found ${fragments.length} for profile ${profile.name}`); } await rm(outputDirectory, { recursive: true, force: true }); await mkdir(outputDirectory, { recursive: true }); const seenTargets = new Set(); const seenNames = new Set(); + const consumedInputFiles = new Set(); const artifacts = []; const nativeSigners = {}; for (const { path, value } of fragments) { - if (value.schemaVersion !== 2 || value.version !== version || value.tag !== `desktop-v${version}`) { + consumedInputFiles.add(resolve(path)); + if (value.schemaVersion !== 2 || value.releaseProfile !== profile.name + || value.version !== version || value.tag !== `desktop-v${version}`) { throw new Error(`Release fragment metadata does not match desktop-v${version}: ${path}`); } - const expectedKinds = TARGETS.get(value.target); + const expectedKinds = profile.targets.get(value.target); if (!expectedKinds || seenTargets.has(value.target)) throw new Error(`Duplicate or invalid target ${value.target}`); seenTargets.add(value.target); if (!Array.isArray(value.artifacts) || value.artifacts.length !== expectedKinds.length) { throw new Error(`Release fragment ${value.target} has an unexpected artifact count`); } const [targetPlatform, targetArch] = value.target.split('-'); + if (targetPlatform === 'linux' && value.nativeSigner !== undefined) { + throw new Error(`Release fragment ${value.target} has foreign native signer evidence`); + } if (targetPlatform === 'win32' && value.installedApplicationValidated !== true) { throw new Error(`Release fragment ${value.target} skipped the installed ordinary-user application gate`); } @@ -776,6 +772,7 @@ export const finalizeArtifacts = async ({ throw new Error(`Release fragment ${value.target} attaches native DMG evidence to a non-DMG artifact`); } const source = join(dirname(path), artifact.fileName); + consumedInputFiles.add(resolve(source)); let inspection; if (artifact.kind === 'dmg') { const held = await openHeldDmg(source); @@ -822,14 +819,21 @@ export const finalizeArtifacts = async ({ artifacts.push(artifact); } } - for (const target of TARGETS.keys()) { + for (const target of profile.targets.keys()) { if (!seenTargets.has(target)) throw new Error(`Missing release target ${target}`); } - const windowsSigners = ['win32-x64', 'win32-arm64'].map(target => nativeSigners[target]).filter(Boolean); - if (windowsSigners.length === 2 && JSON.stringify(windowsSigners[0]) !== JSON.stringify(windowsSigners[1])) { + const unexpectedInputFiles = (await recursiveFiles(inputDirectory)) + .filter(path => !consumedInputFiles.has(resolve(path))); + if (unexpectedInputFiles.length) { + throw new Error(`Release profile ${profile.name} contains unexpected input artifacts`); + } + const windowsSigners = ['win32-x64', 'win32-arm64'] + .filter(target => profile.targets.has(target)).map(target => nativeSigners[target]).filter(Boolean); + if (profile.windowsIncluded && windowsSigners.length === 2 + && JSON.stringify(windowsSigners[0]) !== JSON.stringify(windowsSigners[1])) { throw new Error('Windows release targets contain mixed native signer evidence'); } - validateCanonicalArtifactMatrix(artifacts, version, 'Final desktop release'); + validateCanonicalArtifactMatrix(artifacts, version, 'Final desktop release', profile); artifacts.sort((left, right) => left.fileName.localeCompare(right.fileName)); const publishedAt = process.env.SOURCE_DATE_EPOCH @@ -837,6 +841,7 @@ export const finalizeArtifacts = async ({ : new Date().toISOString(); const manifest = { schemaVersion: 2, + releaseProfile: profile.name, channel: 'stable', version, tag: `desktop-v${version}`, @@ -907,11 +912,13 @@ const createSignedFeeds = async (manifest, outputDirectory, env) => { return { feeds, feedFiles }; }; -export const signReleaseMetadata = async ({ inputDirectory, outputDirectory, version, env = process.env }) => { +export const signReleaseMetadata = async ({ inputDirectory, outputDirectory, version, profile: profileName, env = process.env }) => { if (!VERSION_PATTERN.test(version)) throw new Error(`Invalid desktop release version: ${version}`); + const profile = resolveReleaseProfile(profileName); const unsignedManifest = JSON.parse(await readFile(join(inputDirectory, 'desktop-release.json'), 'utf8')); if ( unsignedManifest.schemaVersion !== 2 + || unsignedManifest.releaseProfile !== profile.name || unsignedManifest.version !== version || unsignedManifest.tag !== `desktop-v${version}` || Object.keys(unsignedManifest.feeds ?? {}).length !== 0 @@ -919,7 +926,10 @@ export const signReleaseMetadata = async ({ inputDirectory, outputDirectory, ver ) { throw new Error('Unsigned release metadata is invalid'); } - validateCanonicalArtifactMatrix(unsignedManifest.artifacts, version, 'Unsigned release metadata'); + if (!profile.windowsIncluded && Object.hasOwn(unsignedManifest, 'windowsSignerPins')) { + throw new Error(`Unsigned release metadata contains Windows signer configuration forbidden by profile ${profile.name}`); + } + validateCanonicalArtifactMatrix(unsignedManifest.artifacts, version, 'Unsigned release metadata', profile); for (const artifact of unsignedManifest.artifacts) { const path = join(inputDirectory, artifact.fileName); if (basename(artifact.fileName) !== artifact.fileName @@ -934,15 +944,19 @@ export const signReleaseMetadata = async ({ inputDirectory, outputDirectory, ver 'PROPR_DESKTOP_UPDATE_PUBLIC_KEY', 'PROPR_DESKTOP_UPDATE_MANIFEST_URL', 'PROPR_DESKTOP_MAC_TEAM_ID', - 'PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY', - 'PROPR_DESKTOP_WINDOWS_SIGNER_PINS', + ...(profile.windowsIncluded ? [ + 'PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY', + 'PROPR_DESKTOP_WINDOWS_SIGNER_PINS', + ] : []), ...configuredFeedDefinitions.map(([, name]) => name), ]; const present = configurationNames.filter(name => env[name]?.trim()); if (present.length !== configurationNames.length) { throw new Error(`Trusted update signing configuration is incomplete; missing ${configurationNames.filter(name => !env[name]?.trim()).join(', ')}`); } - const windowsSignerPins = parseWindowsSignerPins(env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS); + const windowsSignerPins = profile.windowsIncluded + ? parseWindowsSignerPins(env.PROPR_DESKTOP_WINDOWS_SIGNER_PINS) + : undefined; for (const target of ['darwin-x64', 'darwin-arm64']) { const signer = readNativeSigner('darwin', { @@ -957,7 +971,7 @@ export const signReleaseMetadata = async ({ inputDirectory, outputDirectory, ver } } const windowsSigners = []; - for (const target of ['win32-x64', 'win32-arm64']) { + for (const target of [...profile.targets.keys()].filter(candidate => candidate.startsWith('win32-'))) { const signer = readNativeSigner('win32', { PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: unsignedManifest.nativeSigners?.[target]?.type, PROPR_DESKTOP_ACTUAL_SIGNER_IDENTITY: unsignedManifest.nativeSigners?.[target]?.identity, @@ -971,7 +985,8 @@ export const signReleaseMetadata = async ({ inputDirectory, outputDirectory, ver } windowsSigners.push(signer); } - if (JSON.stringify(windowsSigners[0]) !== JSON.stringify(windowsSigners[1])) { + if (profile.windowsIncluded && (windowsSigners.length !== 2 + || JSON.stringify(windowsSigners[0]) !== JSON.stringify(windowsSigners[1]))) { throw new Error('Windows release targets contain mixed native signer evidence'); } @@ -994,7 +1009,12 @@ export const signReleaseMetadata = async ({ inputDirectory, outputDirectory, ver await rm(outputDirectory, { recursive: true, force: true }); await cp(inputDirectory, outputDirectory, { recursive: true }); const { feeds, feedFiles } = await createSignedFeeds(unsignedManifest, outputDirectory, env); - const signedManifest = { ...unsignedManifest, manifestUrl, windowsSignerPins, feeds }; + const signedManifest = { + ...unsignedManifest, + manifestUrl, + ...(profile.windowsIncluded ? { windowsSignerPins } : {}), + feeds, + }; const manifestPayload = Buffer.from(`${JSON.stringify(signedManifest, null, 2)}\n`); const signaturePayload = Buffer.from(`${sign(null, manifestPayload, privateKey).toString('base64')}\n`); await writeFile(join(outputDirectory, 'desktop-release.json'), manifestPayload); @@ -1024,40 +1044,48 @@ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.m const makeDirectory = argument('--make-directory'); const arch = argument('--arch'); const version = argument('--version'); - if (!makeDirectory || !arch || !version) { - throw new Error('Private-snapshot DMG isolation probe requires --make-directory, --arch, and --version'); + const profile = argument('--profile'); + if (!makeDirectory || !arch || !version || !profile) { + throw new Error('Private-snapshot DMG isolation probe requires --make-directory, --arch, --version, and --profile'); } const result = await probePrivateDmgSnapshotIsolation({ makeDirectory: resolve(makeDirectory), arch, version, + profile, }); console.log(JSON.stringify({ privateSnapshotDmgIsolation: true, architecture: arch, ...result })); } else if (command === 'stage') { const version = argument('--version'); - if (!version) throw new Error('--version is required'); + const profile = argument('--profile'); + if (!version || !profile) throw new Error('--version and --profile are required'); await stageArtifacts({ makeDirectory: resolve(argument('--make-directory') || 'out/make'), outputDirectory: resolve(argument('--output') || 'release-staging'), platform: argument('--platform') || process.platform, arch: argument('--arch') || process.arch, version, + profile, }); } else if (command === 'finalize') { const version = argument('--version'); - if (!version) throw new Error('--version is required'); + const profile = argument('--profile'); + if (!version || !profile) throw new Error('--version and --profile are required'); await finalizeArtifacts({ inputDirectory: resolve(argument('--input') || 'release-artifacts'), outputDirectory: resolve(argument('--output') || 'release-final'), version, + profile, }); } else if (command === 'sign') { const version = argument('--version'); - if (!version) throw new Error('--version is required'); + const profile = argument('--profile'); + if (!version || !profile) throw new Error('--version and --profile are required'); await signReleaseMetadata({ inputDirectory: resolve(argument('--input') || 'release-final'), outputDirectory: resolve(argument('--output') || 'release-signed'), version, + profile, }); } else { throw new Error('Expected release-artifacts.mjs private-snapshot probe, stage, finalize, or sign command'); diff --git a/apps/desktop/scripts/release-artifacts.test.mjs b/apps/desktop/scripts/release-artifacts.test.mjs index 42d913283..f3d8ce2b1 100644 --- a/apps/desktop/scripts/release-artifacts.test.mjs +++ b/apps/desktop/scripts/release-artifacts.test.mjs @@ -8,10 +8,15 @@ import { dirname, join } from 'node:path'; import { describe, test } from 'node:test'; import { promisify } from 'node:util'; import { - finalizeArtifacts, - signReleaseMetadata, + finalizeArtifacts as finalizeProfileArtifacts, + signReleaseMetadata as signProfileMetadata, stageArtifacts, } from './release-artifacts.mjs'; +import { + MACOS_LINUX_RELEASE_PROFILE, + WINDOWS_INCLUSIVE_RELEASE_PROFILE, + resolveReleaseProfile, +} from './release-profiles.mjs'; import { createHeldDmgArtifact, inspectArtifactArchitecture, @@ -42,6 +47,7 @@ const expectedDistributableNames = [ 'ProPR-Desktop-1.2.3-windows-x64-Machine-Setup.msi', 'ProPR-Desktop-1.2.3-windows-arm64-Machine-Setup.msi', ]; +const expectedFirstReleaseNames = expectedDistributableNames.filter(name => !name.includes('-windows-')); const sourceName = kind => kind === 'msi' ? 'Desktop-Machine-Setup.msi' : `desktop.${kind}`; const certificateSha256 = '1'.repeat(64); @@ -118,10 +124,21 @@ const windowsDmgFixtureAuthority = Object.freeze({ }); const stageFixtureArtifacts = arguments_ => stageArtifacts({ + profile: WINDOWS_INCLUSIVE_RELEASE_PROFILE, ...arguments_, privateDmgFixtureAuthority: windowsDmgFixtureAuthority, }); +const finalizeArtifacts = arguments_ => finalizeProfileArtifacts({ + profile: WINDOWS_INCLUSIVE_RELEASE_PROFILE, + ...arguments_, +}); + +const signReleaseMetadata = arguments_ => signProfileMetadata({ + profile: WINDOWS_INCLUSIVE_RELEASE_PROFILE, + ...arguments_, +}); + const signerEnvironment = platform => platform === 'darwin' ? { PROPR_DESKTOP_ACTUAL_SIGNER_TYPE: 'apple-team-id', @@ -137,9 +154,13 @@ const signerEnvironment = platform => platform === 'darwin' } : {}; -const createFragments = async (root, { signed = false } = {}) => { +const createFragments = async (root, { + signed = false, + profile: profileName = WINDOWS_INCLUSIVE_RELEASE_PROFILE, +} = {}) => { const fragments = join(root, 'fragments'); - for (const [target, targetKinds] of Object.entries(kinds)) { + const profile = resolveReleaseProfile(profileName); + for (const [target, targetKinds] of profile.targets) { const [platform, arch] = target.split('-'); const makeDirectory = join(root, 'make', target); await mkdir(makeDirectory, { recursive: true }); @@ -152,6 +173,7 @@ const createFragments = async (root, { signed = false } = {}) => { platform, arch, version: '1.2.3', + profile: profile.name, env: { ...(signed ? signerEnvironment(platform) : {}), ...(platform === 'win32' ? { PROPR_DESKTOP_WINDOWS_INSTALLED_APP: '1' } : {}), @@ -318,6 +340,112 @@ const storedZip = entries => { }; describe('desktop release artifacts', () => { + test('dry-runs the canonical macOS/Linux profile through finalize and signed update metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-first-release-test-')); + const fragments = await createFragments(root, { + signed: true, + profile: MACOS_LINUX_RELEASE_PROFILE, + }); + const unsignedDirectory = join(root, 'final'); + const manifest = await finalizeProfileArtifacts({ + inputDirectory: fragments, + outputDirectory: unsignedDirectory, + version: '1.2.3', + profile: MACOS_LINUX_RELEASE_PROFILE, + inspectArchitecture: architectureInspector, + }); + assert.equal(manifest.releaseProfile, MACOS_LINUX_RELEASE_PROFILE); + assert.equal(manifest.artifacts.length, 10); + assert.deepEqual(manifest.artifacts.map(artifact => artifact.fileName).sort(), [...expectedFirstReleaseNames].sort()); + assert.deepEqual(Object.keys(manifest.nativeSigners).sort(), ['darwin-arm64', 'darwin-x64']); + assert.ok(!JSON.stringify(manifest).includes('win32')); + assert.ok(!JSON.stringify(manifest).includes('windows')); + + const keys = generateKeyPairSync('ed25519'); + const signedDirectory = join(root, 'signed'); + const environment = signingEnvironment(keys); + delete environment.PROPR_DESKTOP_WINDOWS_SIGNING_IDENTITY; + delete environment.PROPR_DESKTOP_WINDOWS_SIGNER_PINS; + const signed = await signProfileMetadata({ + inputDirectory: unsignedDirectory, + outputDirectory: signedDirectory, + version: '1.2.3', + profile: MACOS_LINUX_RELEASE_PROFILE, + env: environment, + }); + assert.equal(signed.artifacts.length, 10); + assert.deepEqual(Object.keys(signed.feeds).sort(), ['darwin-arm64', 'darwin-x64']); + assert.equal(Object.hasOwn(signed, 'windowsSignerPins'), false); + const checksumNames = (await readFile(join(signedDirectory, 'SHA256SUMS'), 'utf8')) + .trim().split('\n').map(line => line.slice(line.indexOf(' ') + 2)); + assert.equal(checksumNames.filter(name => expectedFirstReleaseNames.includes(name)).length, 10); + assert.equal(checksumNames.some(name => name.includes('windows')), false); + }); + + test('fails closed for missing profiles and rejects Windows fragments in the macOS/Linux profile', async () => { + const root = await mkdtemp(join(tmpdir(), 'propr-first-release-reject-')); + const profile = resolveReleaseProfile(MACOS_LINUX_RELEASE_PROFILE); + assert.deepEqual([...profile.targets.keys()], [ + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + ]); + assert.equal(profile.artifactCount, 10); + await assert.rejects( + stageFixtureArtifacts({ + makeDirectory: join(root, 'unused'), + outputDirectory: join(root, 'unused-output'), + platform: 'win32', + arch: 'x64', + version: '1.2.3', + profile: MACOS_LINUX_RELEASE_PROFILE, + inspectArchitecture: architectureInspector, + }), + /Target win32-x64 is not allowed by release profile macos-linux-v1/, + ); + const fragments = await createFragments(root, { profile: MACOS_LINUX_RELEASE_PROFILE }); + await assert.rejects( + finalizeProfileArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'missing-profile'), + version: '1.2.3', + inspectArchitecture: architectureInspector, + }), + /missing explicit desktop release profile/, + ); + await writeFile( + join(fragments, 'linux-x64', 'ProPR-Desktop-1.2.3-windows-x64-Machine-Setup.msi'), + 'accidental-windows-artifact', + ); + await assert.rejects( + finalizeProfileArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'foreign-file'), + version: '1.2.3', + profile: MACOS_LINUX_RELEASE_PROFILE, + inspectArchitecture: architectureInspector, + }), + /contains unexpected input artifacts/, + ); + await rm(join(fragments, 'linux-x64', 'ProPR-Desktop-1.2.3-windows-x64-Machine-Setup.msi')); + const windowsRoot = await mkdtemp(join(tmpdir(), 'propr-first-release-windows-')); + const windowsFragments = await createFragments(windowsRoot); + await mkdir(join(fragments, 'unexpected-windows')); + const windowsFragment = await readFile(join(windowsFragments, 'win32-x64', 'release-fragment.json')); + await writeFile(join(fragments, 'unexpected-windows', 'release-fragment.json'), windowsFragment); + await assert.rejects( + finalizeProfileArtifacts({ + inputDirectory: fragments, + outputDirectory: join(root, 'unexpected'), + version: '1.2.3', + profile: MACOS_LINUX_RELEASE_PROFILE, + inspectArchitecture: architectureInspector, + }), + /Expected 4 release fragments, found 5/, + ); + }); + test('stages named artifacts and finalizes unsigned validation metadata', async () => { const root = await mkdtemp(join(tmpdir(), 'propr-release-test-')); const fragments = await createFragments(root); @@ -330,6 +458,7 @@ describe('desktop release artifacts', () => { const output = join(root, 'final'); const manifest = await finalizeArtifacts({ inputDirectory: fragments, outputDirectory: output, version: '1.2.3', inspectArchitecture: architectureInspector }); assert.equal(manifest.schemaVersion, 2); + assert.equal(manifest.releaseProfile, WINDOWS_INCLUSIVE_RELEASE_PROFILE); assert.equal(manifest.artifacts.length, 12); assert.equal(manifest.tag, 'desktop-v1.2.3'); assert.equal(Object.keys(manifest.feeds).length, 0); diff --git a/apps/desktop/scripts/release-profiles.mjs b/apps/desktop/scripts/release-profiles.mjs new file mode 100644 index 000000000..c6a70d2b9 --- /dev/null +++ b/apps/desktop/scripts/release-profiles.mjs @@ -0,0 +1,55 @@ +export const MACOS_LINUX_RELEASE_PROFILE = 'macos-linux-v1'; +export const WINDOWS_INCLUSIVE_RELEASE_PROFILE = 'macos-linux-windows-v1'; + +const profileEntries = [ + [MACOS_LINUX_RELEASE_PROFILE, [ + ['linux-x64', ['deb', 'rpm', 'zip']], + ['linux-arm64', ['deb', 'rpm', 'zip']], + ['darwin-x64', ['dmg', 'zip']], + ['darwin-arm64', ['dmg', 'zip']], + ]], + [WINDOWS_INCLUSIVE_RELEASE_PROFILE, [ + ['linux-x64', ['deb', 'rpm', 'zip']], + ['linux-arm64', ['deb', 'rpm', 'zip']], + ['darwin-x64', ['dmg', 'zip']], + ['darwin-arm64', ['dmg', 'zip']], + ['win32-x64', ['msi']], + ['win32-arm64', ['msi']], + ]], +]; + +const PROFILES = new Map(profileEntries.map(([name, targets]) => [ + name, + Object.freeze({ + name, + targets: new Map(targets.map(([target, kinds]) => [target, Object.freeze([...kinds])])), + artifactCount: targets.reduce((count, [, kinds]) => count + kinds.length, 0), + windowsIncluded: targets.some(([target]) => target.startsWith('win32-')), + }), +])); + +export const resolveReleaseProfile = value => { + if (typeof value !== 'string' || !PROFILES.has(value)) { + throw new Error(`Unsupported or missing explicit desktop release profile: ${value ?? ''}`); + } + const profile = PROFILES.get(value); + return Object.freeze({ ...profile, targets: new Map(profile.targets) }); +}; + +export const releaseFileName = (version, platform, arch, kind) => { + const platformName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; + return kind === 'msi' + ? `ProPR-Desktop-${version}-${platformName}-${arch}-Machine-Setup.msi` + : `ProPR-Desktop-${version}-${platformName}-${arch}.${kind}`; +}; + +export const expectedProfileArtifacts = (profile, version) => { + const expected = new Map(); + for (const [target, kinds] of profile.targets) { + const [platform, arch] = target.split('-'); + for (const kind of kinds) { + expected.set(releaseFileName(version, platform, arch, kind), { platform, arch, kind }); + } + } + return expected; +}; diff --git a/apps/desktop/scripts/release-publish.mjs b/apps/desktop/scripts/release-publish.mjs index 6a8b183a4..8b0219bd0 100644 --- a/apps/desktop/scripts/release-publish.mjs +++ b/apps/desktop/scripts/release-publish.mjs @@ -3,6 +3,7 @@ import { createReadStream } from 'node:fs'; import { readdir, readFile, stat } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { expectedProfileArtifacts, resolveReleaseProfile } from './release-profiles.mjs'; const VERSIONED_TAG_PATTERN = /^desktop-v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; const SHA_PATTERN = /^[a-f0-9]{40}$/; @@ -16,13 +17,76 @@ const sha256File = async path => { return hash.digest('hex'); }; -const readFinalAssetSet = async directory => { +const validateFinalManifest = (manifest, profile, version, tag) => { + const expectedArtifacts = expectedProfileArtifacts(profile, version); + if (manifest?.schemaVersion !== 2 || manifest.releaseProfile !== profile.name + || manifest.version !== version || manifest.tag !== tag || !Array.isArray(manifest.artifacts) + || manifest.artifacts.length !== profile.artifactCount) { + throw new Error(`Signed release manifest does not match release profile ${profile.name}`); + } + const names = new Set(); + for (const artifact of manifest.artifacts) { + const expected = expectedArtifacts.get(artifact?.fileName); + if (!expected || artifact.platform !== expected.platform || artifact.arch !== expected.arch + || artifact.kind !== expected.kind || names.has(artifact.fileName) + || !Number.isSafeInteger(artifact.size) || artifact.size <= 0 + || typeof artifact.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(artifact.sha256)) { + throw new Error(`Signed release manifest contains an unexpected or duplicate artifact for profile ${profile.name}`); + } + names.add(artifact.fileName); + expectedArtifacts.set(artifact.fileName, { ...expected, size: artifact.size, sha256: artifact.sha256 }); + } + if (names.size !== expectedArtifacts.size || [...expectedArtifacts.keys()].some(name => !names.has(name))) { + throw new Error(`Signed release manifest must contain exactly ${profile.artifactCount} native artifacts`); + } + const expectedSignerTargets = new Set([...profile.targets.keys()].filter(target => target.startsWith('darwin-') || target.startsWith('win32-'))); + const signerTargets = Object.keys(manifest.nativeSigners ?? {}); + if (signerTargets.length !== expectedSignerTargets.size + || signerTargets.some(target => !expectedSignerTargets.has(target))) { + throw new Error(`Signed release manifest signer metadata does not match release profile ${profile.name}`); + } + const feedTargets = Object.keys(manifest.feeds ?? {}); + if (feedTargets.length !== 2 || !feedTargets.includes('darwin-x64') || !feedTargets.includes('darwin-arm64')) { + throw new Error('Signed release manifest must contain the exact two macOS update feeds'); + } + const feedFiles = new Map(); + for (const target of feedTargets) { + const feed = manifest.feeds[target]; + const arch = target.split('-')[1]; + if (feed?.target !== target || !Number.isSafeInteger(feed.feed?.size) || feed.feed.size <= 0 + || typeof feed.feed.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(feed.feed.sha256)) { + throw new Error(`Signed release manifest has invalid update metadata for ${target}`); + } + feedFiles.set(`ProPR-Desktop-${version}-macos-${arch}-RELEASES.json`, { + size: feed.feed.size, + sha256: feed.feed.sha256, + }); + } + if (!profile.windowsIncluded && Object.hasOwn(manifest, 'windowsSignerPins')) { + throw new Error(`Signed release manifest contains Windows metadata forbidden by profile ${profile.name}`); + } + if (profile.windowsIncluded && !Array.isArray(manifest.windowsSignerPins)) { + throw new Error(`Signed release manifest lacks Windows signer metadata required by profile ${profile.name}`); + } + return { expectedArtifacts, feedFiles }; +}; + +const readFinalAssetSet = async (directory, profileName, version, tag) => { + const profile = resolveReleaseProfile(profileName); const entries = await readdir(directory, { withFileTypes: true }); if (entries.some(entry => !entry.isFile())) throw new Error('Final release directory may contain only regular files'); const names = entries.map(entry => entry.name).sort(); if (new Set(names).size !== names.length || names.some(name => basename(name) !== name)) { throw new Error('Final release directory contains duplicate or invalid asset names'); } + const manifest = JSON.parse(await readFile(join(directory, 'desktop-release.json'), 'utf8')); + const { expectedArtifacts, feedFiles } = validateFinalManifest(manifest, profile, version, tag); + const expectedChecksummedNames = new Set([ + ...expectedArtifacts.keys(), + `ProPR-Desktop-${version}-macos-x64-RELEASES.json`, + `ProPR-Desktop-${version}-macos-arm64-RELEASES.json`, + ...REQUIRED_METADATA, + ]); const checksumLines = (await readFile(join(directory, CHECKSUM_FILE), 'utf8')).split(/\r?\n/).filter(Boolean); const checksums = new Map(); for (const line of checksumLines) { @@ -32,8 +96,9 @@ const readFinalAssetSet = async directory => { } checksums.set(match[2], match[1]); } - if (checksums.size === 0 || REQUIRED_METADATA.some(name => !checksums.has(name))) { - throw new Error('Finalized SHA256SUMS does not cover the signed release metadata'); + if (checksums.size !== expectedChecksummedNames.size + || [...expectedChecksummedNames].some(name => !checksums.has(name))) { + throw new Error(`Finalized SHA256SUMS does not match release profile ${profile.name}`); } const expectedNames = [...checksums.keys(), CHECKSUM_FILE].sort(); if (JSON.stringify(names) !== JSON.stringify(expectedNames)) { @@ -48,6 +113,10 @@ const readFinalAssetSet = async directory => { if (name !== CHECKSUM_FILE && digest !== checksums.get(name)) { throw new Error(`Final release asset ${name} does not match finalized checksums`); } + const manifestMetadata = expectedArtifacts.get(name) ?? feedFiles.get(name); + if (manifestMetadata && (details.size !== manifestMetadata.size || digest !== manifestMetadata.sha256)) { + throw new Error(`Final release asset ${name} does not match signed manifest metadata`); + } assets.set(name, { name, path, size: details.size, sha256: digest }); } return assets; @@ -168,6 +237,7 @@ export const publishDesktopRelease = async ({ releaseSha, tagObjectSha, directory, + profile: profileName, token, apiUrl = 'https://api.github.com', fetchImpl = fetch, @@ -176,8 +246,9 @@ export const publishDesktopRelease = async ({ || !SHA_PATTERN.test(tagObjectSha) || !token) { throw new Error('Desktop release publication inputs are invalid'); } + const version = tag.startsWith('desktop-v') ? tag.slice('desktop-v'.length) : ''; const finalDirectory = resolve(directory); - const expected = await readFinalAssetSet(finalDirectory); + const expected = await readFinalAssetSet(finalDirectory, profileName, version, tag); const apiOrigin = new URL(apiUrl).origin; const baseOptions = { fetchImpl, apiUrl, repository, token }; const request = (path, options = {}) => githubRequest({ ...baseOptions, path, ...options }); @@ -253,6 +324,7 @@ if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.m releaseSha: process.env.RELEASE_SHA, tagObjectSha: process.env.TAG_OBJECT_SHA, directory: process.env.RELEASE_DIRECTORY || 'desktop-release-final', + profile: process.env.PROPR_DESKTOP_RELEASE_PROFILE, token: process.env.GITHUB_TOKEN, apiUrl: process.env.GITHUB_API_URL, }); diff --git a/apps/desktop/scripts/release-publish.test.mjs b/apps/desktop/scripts/release-publish.test.mjs index f08fa2bd0..04f021aad 100644 --- a/apps/desktop/scripts/release-publish.test.mjs +++ b/apps/desktop/scripts/release-publish.test.mjs @@ -1,25 +1,70 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; import { describe, test } from 'node:test'; import { publishDesktopRelease } from './release-publish.mjs'; +import { + expectedProfileArtifacts, + MACOS_LINUX_RELEASE_PROFILE, + resolveReleaseProfile, +} from './release-profiles.mjs'; const releaseSha = '1'.repeat(40); const tagObjectSha = '2'.repeat(40); const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); -const createFinalAssets = async (extraCount = 1) => { +const createFinalAssets = async () => { const directory = await mkdtemp(join(tmpdir(), 'propr-publish-')); + const profile = resolveReleaseProfile(MACOS_LINUX_RELEASE_PROFILE); + const artifactFiles = new Map([...expectedProfileArtifacts(profile, '1.2.3')] + .map(([fileName]) => [fileName, Buffer.from(`${fileName}\n`)])); + const artifacts = [...expectedProfileArtifacts(profile, '1.2.3')].map(([fileName, artifact]) => { + const bytes = artifactFiles.get(fileName); + return { ...artifact, fileName, size: bytes.length, sha256: sha256(bytes) }; + }); + const feedFiles = new Map([ + ['ProPR-Desktop-1.2.3-macos-x64-RELEASES.json', Buffer.from('{"target":"darwin-x64"}\n')], + ['ProPR-Desktop-1.2.3-macos-arm64-RELEASES.json', Buffer.from('{"target":"darwin-arm64"}\n')], + ]); + const manifest = { + schemaVersion: 2, + releaseProfile: MACOS_LINUX_RELEASE_PROFILE, + channel: 'stable', + version: '1.2.3', + tag: 'desktop-v1.2.3', + publishedAt: '2026-09-02T00:00:00.000Z', + manifestUrl: 'https://updates.example.test/stable/desktop-release.json', + feeds: { + 'darwin-x64': { + target: 'darwin-x64', + feed: { + size: feedFiles.get('ProPR-Desktop-1.2.3-macos-x64-RELEASES.json').length, + sha256: sha256(feedFiles.get('ProPR-Desktop-1.2.3-macos-x64-RELEASES.json')), + }, + }, + 'darwin-arm64': { + target: 'darwin-arm64', + feed: { + size: feedFiles.get('ProPR-Desktop-1.2.3-macos-arm64-RELEASES.json').length, + sha256: sha256(feedFiles.get('ProPR-Desktop-1.2.3-macos-arm64-RELEASES.json')), + }, + }, + }, + nativeSigners: { + 'darwin-x64': { type: 'apple-team-id' }, + 'darwin-arm64': { type: 'apple-team-id' }, + }, + artifacts, + }; const files = new Map([ - ['desktop-release.json', Buffer.from('{}\n')], + ...artifactFiles, + ...feedFiles, + ['desktop-release.json', Buffer.from(`${JSON.stringify(manifest)}\n`)], ['desktop-release.json.sig', Buffer.from('signed\n')], ]); - for (let index = 0; index < extraCount; index += 1) { - files.set(`ProPR-Desktop-asset-${String(index).padStart(3, '0')}.bin`, Buffer.from(`asset-${index}\n`)); - } const checksums = [...files] .sort(([left], [right]) => left.localeCompare(right)) .map(([name, bytes]) => `${sha256(bytes)} ${name}`) @@ -121,25 +166,25 @@ const publish = ({ directory, fetchImpl }) => publishDesktopRelease({ releaseSha, tagObjectSha, directory, + profile: MACOS_LINUX_RELEASE_PROFILE, token: 'token', apiUrl: 'https://api.github.com', fetchImpl, }); describe('atomic desktop release publication', () => { - test('creates a draft, paginates and verifies the exact final assets, then publishes', async () => { - const { directory } = await createFinalAssets(101); + test('creates a draft, verifies the exact profile assets, then publishes', async () => { + const { directory } = await createFinalAssets(); const github = createGitHub(); const result = await publish({ directory, fetchImpl: github.fetchImpl }); assert.equal(result.draft, false); assert.equal(github.patchCalls, 1); - assert.equal(github.assets.length, 104); - assert(github.calls.includes('GET /releases/7/assets?per_page=100&page=2')); + assert.equal(github.assets.length, 15); assert(github.calls.lastIndexOf('GET /git/ref/tags/desktop-v1.2.3') < github.calls.indexOf('PATCH /releases/7')); }); test('leaves a partial upload as a recoverable draft and resumes only matching assets', async () => { - const { directory, files } = await createFinalAssets(2); + const { directory, files } = await createFinalAssets(); const github = createGitHub({ failUploadAt: 2 }); await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /failed with HTTP 500/); assert.equal(github.release.draft, true); @@ -194,4 +239,20 @@ describe('atomic desktop release publication', () => { await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /checksum allowlist/); assert.equal(github.calls.length, 0); }); + + test('rejects accidental Windows artifacts in the macOS/Linux manifest before any API call', async () => { + const { directory } = await createFinalAssets(); + const manifestPath = join(directory, 'desktop-release.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + manifest.artifacts[0] = { + platform: 'win32', + arch: 'x64', + kind: 'msi', + fileName: 'ProPR-Desktop-1.2.3-windows-x64-Machine-Setup.msi', + }; + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + const github = createGitHub(); + await assert.rejects(publish({ directory, fetchImpl: github.fetchImpl }), /unexpected or duplicate artifact/); + assert.equal(github.calls.length, 0); + }); }); diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index 13f7ca1a1..f1ce8cbdb 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -133,7 +133,7 @@ describe('desktop trusted release workflow', () => { assert.ok(!preflight.includes('permission-actions:')); }); - test('keeps every certificate and the update private key inside preflight-dependent environment jobs', () => { + test('keeps required macOS certificates and the update private key inside preflight-dependent environment jobs', () => { const packageJob = job('release-package', 'release-finalize'); const signing = job('sign', 'publish'); for (const secret of [ @@ -142,8 +142,6 @@ describe('desktop trusted release workflow', () => { 'PROPR_DESKTOP_APPLE_API_KEY_P8_BASE64', 'PROPR_DESKTOP_APPLE_API_KEY_ID', 'PROPR_DESKTOP_APPLE_API_ISSUER_ID', - 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PFX_BASE64', - 'PROPR_DESKTOP_WINDOWS_CERTIFICATE_PASSWORD', ]) { assert.equal(workflow.match(new RegExp(`secrets\\.${secret}`, 'g'))?.length, 1); assert.ok(packageJob.includes(`secrets.${secret}`)); @@ -152,6 +150,8 @@ describe('desktop trusted release workflow', () => { assert.ok(signing.includes('secrets.PROPR_DESKTOP_UPDATE_PRIVATE_KEY')); assert.match(signing, /needs: \[preflight, release-finalize\]/); assert.match(signing, /environment:\s+name: desktop-release/); + assert.doesNotMatch(packageJob, /WINDOWS_CERTIFICATE|WINDOWS_SIGNING|WINDOWS_SIGNER_PINS/); + assert.doesNotMatch(signing, /WINDOWS_CERTIFICATE|WINDOWS_SIGNING|WINDOWS_SIGNER_PINS/); }); test('fails closed for every production signing, notarization, update, and signer condition', () => { @@ -164,9 +164,6 @@ describe('desktop trusted release workflow', () => { 'APPLE_API_ISSUER_ID', 'UPDATE_MAC_SIGNING_IDENTITY', 'UPDATE_MAC_TEAM_ID', - 'CERTIFICATE_PFX_BASE64', - 'UPDATE_WINDOWS_SIGNING_IDENTITY', - 'UPDATE_WINDOWS_SIGNER_PINS', 'UPDATE_PUBLIC_KEY', 'UPDATE_MANIFEST_URL', ]) assert.ok(production.includes(field), `missing fail-closed production field ${field}`); @@ -174,23 +171,15 @@ describe('desktop trusted release workflow', () => { production, /for name in CERTIFICATE_P12_BASE64 CERTIFICATE_PASSWORD APPLE_API_KEY_P8_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER_ID UPDATE_MAC_SIGNING_IDENTITY UPDATE_MAC_TEAM_ID; do\s+test -n "\$\{!name\}"/, ); - assert.match(production, /foreach \(\$entry in \$values\.GetEnumerator\(\)\) \{ if \(!\$entry\.Value\) \{ throw/); assert.ok(!production.includes('signing_present')); assert.ok(!production.includes('notarization_present')); assert.match(production, /Production updates require a code-signed build/); assert.match(production, /codesign --verify --deep --strict/); assert.match(production, /spctl --assess/); assert.match(production, /stapler validate/); - assert.match(production, /Authenticode signer does not match the configured build pin/); - assert.match(production, /TimeStamperCertificate/); - assert.match(production, /CertificateSha256/); - assert.match(production, /SpkiSha256/); - assert.match(production, /Windows artifacts have mixed Authenticode signers/); - assert.match(production, /certificate\|spki\)-sha256:\[a-f0-9\]\{64\}/); - assert.match(production, /release-architecture\.mjs inspect[\s\S]*--kind msi/); - assert.doesNotMatch(production, /--kind nupkg|full\.nupkg|\*Setup\.exe/); - assert.equal(production.match(/Expand-Archive -LiteralPath \$archive -DestinationPath \$wixDirectory/g)?.length, 1); + assert.doesNotMatch(production, /win32|Windows|WINDOWS_|\.msi/i); assert.match(production, /PROPR_DESKTOP_REQUIRE_SIGNED_ARTIFACTS: '1'/); + assert.match(production, /PROPR_DESKTOP_RELEASE_PROFILE: macos-linux-v1/); }); test('preserves the opaque Windows certificate password for package and MSI signing', () => { @@ -208,13 +197,14 @@ describe('desktop trusted release workflow', () => { }); test('rechecks package architecture in staging and finalization and publishes only signed new releases', () => { - assert.equal(workflow.match(platformArchitecturePattern)?.length, 12); + assert.equal(workflow.match(platformArchitecturePattern)?.length, 10); assert.equal(workflow.match(/release-artifacts\.mjs stage/g)?.length, 2); assert.equal(workflow.match(/release-artifacts\.mjs finalize/g)?.length, 2); assert.match(job('finalize', 'preflight'), /needs: \[validation-version, package\]/); assert.match(job('release-finalize', 'sign'), /needs: \[preflight, release-package\]/); - assert.equal(workflow.match(/sudo apt-get install --yes cpio msitools p7zip-full rpm/g)?.length, 2); - assert.equal(workflow.match(/test -x \/usr\/bin\/msiextract/g)?.length, 2); + assert.equal(workflow.match(/sudo apt-get install --yes cpio p7zip-full rpm/g)?.length, 2); + assert.doesNotMatch(`${job('finalize', 'preflight')}\n${job('release-finalize', 'sign')}`, /msitools|msiextract/); + assert.equal(workflow.match(/--profile (?:"\$\{\{ matrix\.release_profile \}\}"|"\$PROPR_DESKTOP_RELEASE_PROFILE"|macos-linux-v1)/g)?.length, 7); const publish = job('publish'); assert.match(publish, /test -s desktop-release-final\/desktop-release\.json\.sig/); assert.match(publish, /ref: \$\{\{ needs\.preflight\.outputs\.release_sha \}\}/); @@ -228,16 +218,16 @@ describe('desktop trusted release workflow', () => { test('retains the exact native matrix when the workflow checkout uses CRLF', () => { const crlfFixture = workflow.replaceAll('\n', '\r\n'); const normalizedFixture = normalizeWorkflowText(crlfFixture); - assert.equal(normalizedFixture.match(platformArchitecturePattern)?.length, 12); + assert.equal(normalizedFixture.match(platformArchitecturePattern)?.length, 10); assert.equal(normalizedFixture, workflow); }); test('runs the native DMG layout suite on both macOS architectures', () => { - for (const [jobName, section] of [ - ['unsigned validation', job('package', 'finalize')], - ['trusted production', job('release-package', 'release-finalize')], + for (const [jobName, section, targetCount] of [ + ['unsigned validation', job('package', 'finalize'), 6], + ['trusted production', job('release-package', 'release-finalize'), 4], ] as const) { - assert.equal(section.match(platformArchitecturePattern)?.length, 6, `${jobName} must retain all six native jobs`); + assert.equal(section.match(platformArchitecturePattern)?.length, targetCount, `${jobName} has the wrong native target count`); assert.match(section, /- platform: darwin\n\s+arch: x64\n\s+runner: macos-15-intel/, `${jobName} is missing native macOS x64`); assert.match(section, /- platform: darwin\n\s+arch: arm64\n\s+runner: macos-15/, `${jobName} is missing native macOS arm64`); assert.match( @@ -294,14 +284,12 @@ describe('desktop trusted release workflow', () => { }); - test('keeps both Windows architectures and the complete machine-scope installer contract mandatory', () => { - for (const [jobName, section] of [ - ['unsigned validation', job('package', 'finalize')], - ['trusted production', job('release-package', 'release-finalize')], - ] as const) { + test('keeps complete Windows validation assertions runnable but non-blocking and outside production', () => { + const section = job('package', 'finalize'); + { assert.match(section, /- platform: win32\n\s+arch: x64\n\s+runner: windows-2025/); assert.match(section, /- platform: win32\n\s+arch: arm64\n\s+runner: windows-11-arm/); - assert.match(section, /Assert (?:signed )?Windows MVP package excludes update authority/); + assert.match(section, /Assert Windows MVP package excludes update authority/); assert.match(section, /Probe canonical WiX 3\.14\.1 compiler/); assert.match( section, @@ -317,14 +305,18 @@ describe('desktop trusted release workflow', () => { assert.match(section, /PROPR_DESKTOP_WIX_DIRECTORY=\$wixDirectory/); assert.match(section, /Clean pinned Windows ARM64 WiX binaries\n\s+if: always\(\) && matrix\.platform == 'win32' && matrix\.arch == 'arm64'/); assert.doesNotMatch(section, /choco|Chocolatey|wixVendor|electron-winstaller/); - assert.match(section, /Install and exercise (?:signed )?ordinary-user Windows application/); - assert.match(section, /Launch (?:signed )?packaged Windows application and exercise MVP desktop flows/); + assert.match(section, /Install and exercise ordinary-user Windows application/); + assert.match(section, /Launch packaged Windows application and exercise MVP desktop flows/); assert.doesNotMatch(section, /READY|broker:build|windows-authority-build|windows-update-authority\.test|probe-packaged-windows-authority/, - `${jobName} retained a deferred Windows authority gate`); + 'unsigned validation retained a deferred Windows authority gate'); } - assert.equal(workflow.match(/\*Machine-Setup\.msi/g)?.length, 3); - assert.equal(workflow.match(/test-installed-windows-app\.ps1/g)?.length, 2); - assert.equal(workflow.match(/PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1/g)?.length, 2); + assert.match(section, /continue-on-error: \$\{\{ matrix\.release_target == false \}\}/); + assert.match(section, /platform: win32[\s\S]*release_target: false[\s\S]*release_profile: macos-linux-windows-v1[\s\S]*artifact_group: optional-windows/); + const production = job('release-package', 'release-finalize'); + assert.doesNotMatch(production, /platform: win32|Windows|WINDOWS_|\.msi/i); + assert.equal(workflow.match(/\*Machine-Setup\.msi/g)?.length, 1); + assert.equal(workflow.match(/test-installed-windows-app\.ps1/g)?.length, 1); + assert.equal(workflow.match(/PROPR_DESKTOP_WINDOWS_INSTALLED_APP=1/g)?.length, 1); assert.doesNotMatch(forgeConfig, /extraResource|windows-authority|postPackage/); assert.match(forgeConfig, /buildWindowsMachineInstaller/); assert.match(forgeConfig, /wixDirectory: process\.env\.PROPR_DESKTOP_WIX_DIRECTORY/); @@ -371,24 +363,23 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppTest, /-ExpectedPresent \$true/); assert.match(installedWindowsAppTest, /-ExpectedPresent \$false/); assert.match(installedWindowsAppTest, /machine uninstall left the common Start Menu folder behind/); - assert.equal(workflow.match(/https:\/\/github\.com\/wixtoolset\/wix3\/releases\/download\/wix3141rtm\/wix314-binaries\.zip/g)?.length, 2); - assert.equal(workflow.match(/6ac824e1642d6f7277d0ed7ea09411a508f6116ba6fae0aa5f2c7daa2ff43d31/g)?.length, 2); + assert.equal(workflow.match(/https:\/\/github\.com\/wixtoolset\/wix3\/releases\/download\/wix3141rtm\/wix314-binaries\.zip/g)?.length, 1); + assert.equal(workflow.match(/6ac824e1642d6f7277d0ed7ea09411a508f6116ba6fae0aa5f2c7daa2ff43d31/g)?.length, 1); }); - test('revalidates each real WiX MSI first on native Windows and then from the same staged bytes on Linux', () => { - for (const [nativeJob, aggregateJob] of [ - [job('package', 'finalize'), job('finalize', 'preflight')], - [job('release-package', 'release-finalize'), job('release-finalize', 'sign')], - ] as const) { - const make = nativeJob.search(/Make (?:signed )?Windows/); - const installed = nativeJob.indexOf('ordinary-user Windows application'); - const stage = nativeJob.indexOf('release-artifacts.mjs stage'); - const upload = nativeJob.indexOf('Upload'); - assert.ok(make >= 0 && installed > make && stage > installed && upload > stage); - assert.match(aggregateJob, /sudo apt-get install --yes cpio msitools p7zip-full rpm/); - assert.match(aggregateJob, /test -x \/usr\/bin\/msiextract/); - assert.ok(aggregateJob.indexOf('Download all') < aggregateJob.indexOf('release-artifacts.mjs finalize')); - } + test('keeps native Windows MSI validation isolated from canonical aggregation', () => { + const nativeJob = job('package', 'finalize'); + const aggregateJob = job('finalize', 'preflight'); + const make = nativeJob.search(/Make Windows/); + const installed = nativeJob.indexOf('ordinary-user Windows application'); + const stage = nativeJob.indexOf('release-artifacts.mjs stage'); + const upload = nativeJob.indexOf('Upload'); + assert.ok(make >= 0 && installed > make && stage > installed && upload > stage); + assert.ok(aggregateJob.indexOf('Download all') < aggregateJob.indexOf('release-artifacts.mjs finalize')); + assert.match(nativeJob, /propr-desktop-validation-\$\{\{ matrix\.artifact_group \}\}/); + assert.match(aggregateJob, /pattern: propr-desktop-validation-canonical-/); + assert.match(aggregateJob, /--profile macos-linux-v1/); + assert.doesNotMatch(aggregateJob, /msitools|msiextract|optional-windows/); assert.match(releaseArchitecture, /const MSIEXTRACT = '\/usr\/bin\/msiextract'/); assert.match(releaseArchitecture, /KERNEL_MSIEXEC = String\.raw`\\\\\?\\GLOBALROOT\\SystemRoot\\System32\\msiexec\.exe`/); assert.doesNotMatch(releaseArchitecture, /electron-winstaller|7z-(?:x64|arm64)\.exe/); @@ -535,11 +526,10 @@ describe('desktop trusted release workflow', () => { assert.match(installedWindowsAppTest, /Remove-LocalUser -Name \$testUser -ErrorAction Stop/); assert.match(installedWindowsAppTest, /Remove-Item -LiteralPath \$installRoot -Recurse -Force -ErrorAction Stop/); - for (const section of [job('package', 'finalize'), job('release-package', 'release-finalize')]) { - assert.match(section, /- platform: win32\n\s+arch: x64\n/); - assert.match(section, /- platform: win32\n\s+arch: arm64\n/); - assert.equal(section.match(/test-installed-windows-app\.ps1/g)?.length, 1); - } + const windowsValidation = job('package', 'finalize'); + assert.match(windowsValidation, /- platform: win32\n\s+arch: x64\n/); + assert.match(windowsValidation, /- platform: win32\n\s+arch: arm64\n/); + assert.equal(windowsValidation.match(/test-installed-windows-app\.ps1/g)?.length, 1); }); test('uses bounded network logon impersonation with secure native credential cleanup', () => { diff --git a/apps/desktop/src/signed-update-policy.test.ts b/apps/desktop/src/signed-update-policy.test.ts index 3417d7b5d..a7014a659 100644 --- a/apps/desktop/src/signed-update-policy.test.ts +++ b/apps/desktop/src/signed-update-policy.test.ts @@ -5,7 +5,6 @@ import { applySignedUpdate, checkForSignedUpdates, parseSignedUpdateManifest, - type SignedUpdateManifest, } from './signed-updates'; const keys = generateKeyPairSync('ed25519'); @@ -52,9 +51,9 @@ test('signed macOS feeds accept only the canonical ZIP extension and matching ar const artifactUrl = `https://updates.example.test/darwin/x64/${fileName}`; const manifest = { schemaVersion: 2, + releaseProfile: 'macos-linux-v1', channel: 'stable', manifestUrl: config.manifestUrl, - windowsSignerPins: [], version: '1.2.4', tag: 'desktop-v1.2.4', publishedAt: '2026-08-30T00:00:00.000Z', @@ -70,6 +69,23 @@ test('signed macOS feeds accept only the canonical ZIP extension and matching ar designatedRequirement: 'designated => identifier "dev.propr.desktop" and anchor apple generic', }, }, + 'darwin-arm64': { + target: 'darwin-arm64', + version: '1.2.4', + feed: { url: 'https://updates.example.test/darwin/arm64/RELEASES.json', size: 101, sha256: '3'.repeat(64) }, + artifact: { + url: 'https://updates.example.test/darwin/arm64/ProPR-Desktop-1.2.4-macos-arm64.zip', + fileName: 'ProPR-Desktop-1.2.4-macos-arm64.zip', + kind: 'zip', + size: 201, + sha256: '4'.repeat(64), + }, + signer: { + type: 'apple-team-id', + identity: 'TEAM123456', + designatedRequirement: 'designated => identifier "dev.propr.desktop" and anchor apple generic', + }, + }, }, }; assert.equal( @@ -101,6 +117,12 @@ test('signed macOS feeds accept only the canonical ZIP extension and matching ar () => parseSignedUpdateManifest(Buffer.from(JSON.stringify(wrongKind))), /artifact does not match its target or URL/, ); + const windowsMetadata = structuredClone(manifest) as typeof manifest & { windowsSignerPins: string[] }; + windowsMetadata.windowsSignerPins = []; + assert.throws( + () => parseSignedUpdateManifest(Buffer.from(JSON.stringify(windowsMetadata))), + /pin policy does not match its release profile/, + ); }); test('macOS signed-update check remains check-only and verifies its exact feed and artifact', { @@ -115,11 +137,11 @@ test('macOS signed-update check remains check-only and verifies its exact feed a size: value.length, sha256: createHash('sha256').update(value).digest('hex'), }); - const manifest: SignedUpdateManifest = { + const manifest = { schemaVersion: 2, + releaseProfile: 'macos-linux-v1', channel: 'stable', manifestUrl: config.manifestUrl, - windowsSignerPins: [], version: '1.2.4', tag: 'desktop-v1.2.4', publishedAt: '2026-08-30T00:00:00.000Z', @@ -135,8 +157,29 @@ test('macOS signed-update check remains check-only and verifies its exact feed a designatedRequirement: 'designated => identifier "dev.propr.desktop" and anchor apple generic', }, }, + 'darwin-arm64': { + target: 'darwin-arm64', + version: '1.2.4', + feed: { + url: 'https://updates.example.test/darwin/arm64/RELEASES.json', + size: 100, + sha256: '3'.repeat(64), + }, + artifact: { + url: 'https://updates.example.test/darwin/arm64/ProPR-Desktop-1.2.4-macos-arm64.zip', + fileName: 'ProPR-Desktop-1.2.4-macos-arm64.zip', + kind: 'zip', + size: 200, + sha256: '4'.repeat(64), + }, + signer: { + type: 'apple-team-id', + identity: 'TEAM123456', + designatedRequirement: 'designated => identifier "dev.propr.desktop" and anchor apple generic', + }, + }, }, - }; + } as const; const payload = Buffer.from(`${JSON.stringify(manifest)}\n`); const signature = Buffer.from(sign(null, payload, keys.privateKey).toString('base64')); let artifactRequests = 0; diff --git a/apps/desktop/src/signed-updates.ts b/apps/desktop/src/signed-updates.ts index fbe2fa469..d16dd3598 100644 --- a/apps/desktop/src/signed-updates.ts +++ b/apps/desktop/src/signed-updates.ts @@ -83,6 +83,7 @@ export interface SignedUpdateFeed { export interface SignedUpdateManifest { schemaVersion: 2; + releaseProfile: 'macos-linux-v1' | 'macos-linux-windows-v1'; channel: 'stable'; manifestUrl: string; windowsSignerPins: readonly string[]; @@ -284,6 +285,9 @@ export const parseSignedUpdateManifest = (payload: Buffer): SignedUpdateManifest if (!isRecord(value) || value.schemaVersion !== 2 || value.channel !== 'stable') { throw new Error('Signed update manifest has an unsupported schema or channel'); } + if (value.releaseProfile !== 'macos-linux-v1' && value.releaseProfile !== 'macos-linux-windows-v1') { + throw new Error('Signed update manifest release profile is missing or unsupported'); + } if (typeof value.version !== 'string' || !VERSION_PATTERN.test(value.version)) { throw new Error('Signed update manifest version is not canonical stable semver'); } @@ -298,16 +302,19 @@ export const parseSignedUpdateManifest = (payload: Buffer): SignedUpdateManifest if (typeof value.publishedAt !== 'string' || !Number.isFinite(Date.parse(value.publishedAt))) { throw new Error('Signed update manifest publishedAt is invalid'); } - if (!Array.isArray(value.windowsSignerPins) - || value.windowsSignerPins.some(pin => typeof pin !== 'string')) { - throw new Error('Signed update manifest Windows signer pin policy is invalid'); - } - const windowsSignerPins = value.windowsSignerPins.length === 0 - ? [] - : parseWindowsSignerPins( - (value.windowsSignerPins as string[]).join(','), - 'Signed update manifest Windows signer pin policy', - ); + const windowsIncluded = value.releaseProfile === 'macos-linux-windows-v1'; + if ((!windowsIncluded && Object.hasOwn(value, 'windowsSignerPins')) + || (windowsIncluded && (!Array.isArray(value.windowsSignerPins) + || value.windowsSignerPins.length === 0 + || value.windowsSignerPins.some(pin => typeof pin !== 'string')))) { + throw new Error('Signed update manifest Windows signer pin policy does not match its release profile'); + } + const windowsSignerPins = windowsIncluded + ? parseWindowsSignerPins( + (value.windowsSignerPins as string[]).join(','), + 'Signed update manifest Windows signer pin policy', + ) + : []; if (!isRecord(value.feeds)) throw new Error('Signed update manifest feeds are missing'); const feeds: Record = {}; @@ -315,8 +322,9 @@ export const parseSignedUpdateManifest = (payload: Buffer): SignedUpdateManifest if (!TARGET_PATTERN.test(target)) throw new Error(`Signed update manifest feed ${target} is invalid`); feeds[target] = parseFeed(candidate, target, value.version); } - if (Object.keys(feeds).some(target => target.startsWith('win32-')) && windowsSignerPins.length === 0) { - throw new Error('Signed update manifest Windows signer pin policy is required for Windows feeds'); + const feedTargets = Object.keys(feeds).sort(); + if (JSON.stringify(feedTargets) !== JSON.stringify(['darwin-arm64', 'darwin-x64'])) { + throw new Error('Signed update manifest must contain exactly the two canonical macOS feeds'); } return { ...value, manifestUrl, windowsSignerPins, feeds } as unknown as SignedUpdateManifest; }; From 6a7755c8a318893ece91a78203498d41e956cda3 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:55:08 +0000 Subject: [PATCH 2/4] feat(ai): Fixed the CI failure by refreshing vulnerable transitive dependencies in [package-lock.json](/tmp/git-processor/worktrees/integry/propr/pr-2076-followup-2026-09-02T18-50-43/package-lock.json): MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed the CI failure by refreshing vulnerable transitive dependencies in [package-lock.json](/tmp/git-processor/worktrees/integry/propr/pr-2076-followup-2026-09-02T18-50-43/package-lock.json): - `fast-uri` 3.1.5 → 3.1.7 - `qs` 6.15.3 → 6.16.0 - `browserslist` 4.28.1 → 4.28.8, including its lockfile dependencies Verified: - Clean `npm ci` - Both CI audit commands report zero vulnerabilities - Desktop typecheck passed - Desktop tests: 178 passed, 6 platform skips - Linux x64 desktop packaging passed - `git diff --check` passed Only `package-lock.json` changed. No commit was created. PR: #2076 Comment by: @propr-dev[bot] (ID: 5514676078) Model: gpt-5.6-sol --- package-lock.json | 56 +++++++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b8e9b63c..2da7df5d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4521,11 +4521,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.7", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/before-after-hook": { @@ -4710,7 +4715,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -4728,11 +4735,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -4856,7 +4863,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001760", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -6134,7 +6143,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -7410,9 +7421,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -10995,9 +11006,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -11997,9 +12013,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -14341,7 +14357,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.2", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { From d9d9e75a5b329d7edd582a020234b28657d40e67 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:06:29 +0000 Subject: [PATCH 3/4] feat(ai): Updated only [package-lock.json](/tmp/git-processor/worktrees/integry/propr/pr-2076-followup-2026-09-02T19-02-56/package-lock.json:7423): Updated only [package-lock.json](/tmp/git-processor/worktrees/integry/propr/pr-2076-followup-2026-09-02T19-02-56/package-lock.json:7423): - `fast-uri` pinned to `3.1.6` with the exact requested URL and integrity. - `browserslist 4.28.8` and `qs 6.16.0` unchanged. - Funding/license metadata preserved. Validation passed: - `npm ci` - Runtime audit: 0 vulnerabilities - Desktop packaging audit: 0 vulnerabilities - Installed `fast-uri@3.1.6` - `git diff --check` - Only `package-lock.json` modified No commit or merge performed. PR: #2076 Comment by: @integry (ID: 5514826817) Model: gpt-5.6-sol --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2da7df5d2..b31974a1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7421,9 +7421,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", From 97d13b61b20a53f5b13a4bfcc8093716a8b91b80 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:19:38 +0000 Subject: [PATCH 4/4] feat(ai): Implemented only the requested integration omissions: Implemented only the requested integration omissions: - Made `native-windows-durability` nonblocking while preserving all Windows assertions: [workflow](/tmp/git-processor/worktrees/integry/propr/pr-2076-followup-2026-09-04T21-16-33/.github/workflows/desktop-release-guard.yml:27) - Corrected Windows release-target wording: [README](/tmp/git-processor/worktrees/integry/propr/pr-2076-followup-2026-09-04T21-16-33/apps/desktop/README.md:53) - Added focused workflow coverage for the standalone job: [test](/tmp/git-processor/worktrees/integry/propr/pr-2076-followup-2026-09-04T21-16-33/apps/desktop/src/release-workflow.test.ts:311) Validation: - Focused workflow tests: 24/24 passed - Workflow YAML parsing: passed - `git diff --check`: passed - Four-target `macos-linux-v1` profile preserved - No runtime or dependency/lockfile changes - No visual preview needed - Changes left uncommitted as requested; PR remains open for validation PR: #2076 Comment by: @integry (ID: 5546542739) Model: gpt-5.6-sol --- .github/workflows/desktop-release-guard.yml | 1 + apps/desktop/README.md | 4 ++-- apps/desktop/src/release-workflow.test.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/desktop-release-guard.yml b/.github/workflows/desktop-release-guard.yml index 1e74645b5..44d4ce757 100644 --- a/.github/workflows/desktop-release-guard.yml +++ b/.github/workflows/desktop-release-guard.yml @@ -28,6 +28,7 @@ jobs: name: Native Windows durability (x64) if: github.event_name == 'pull_request' runs-on: windows-latest + continue-on-error: true timeout-minutes: 30 env: PROPR_NATIVE_WINDOWS_DURABILITY_REQUIRED: '1' diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7f6f05b69..cd68199b1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -53,8 +53,8 @@ and default, deletes the disposable keychain, and removes all temporary signing The first-release Windows MVP packages only the normal desktop application. Native self-update installation authority is deferred to issue #2000: no broker, bootstrap, launcher, service, or authority custom action is built, copied into -`resources`, or installed by the MSI. Both Windows architectures remain mandatory release targets, and package/MSI -inspection fails if any deferred authority resource appears. +`resources`, or installed by the MSI. Both Windows architectures remain optional validation targets while Windows +publication is deferred, and package/MSI inspection fails if any deferred authority resource appears. `desktop:audit` deliberately applies separate policies to the two dependency surfaces: low-or-higher advisories fail the production-runtime audit, while high and critical advisories fail the desktop development/build-tool audit. Release diff --git a/apps/desktop/src/release-workflow.test.ts b/apps/desktop/src/release-workflow.test.ts index d026fd972..960a5c36a 100644 --- a/apps/desktop/src/release-workflow.test.ts +++ b/apps/desktop/src/release-workflow.test.ts @@ -308,6 +308,19 @@ describe('desktop trusted release workflow', () => { }); + test('keeps standalone native Windows durability assertions runnable but non-blocking', () => { + const section = job('native-windows-durability', 'validation-version'); + assert.match(section, /if: github\.event_name == 'pull_request'/); + assert.match(section, /runs-on: windows-latest/); + assert.match(section, /continue-on-error: true/); + assert.match(section, /PROPR_NATIVE_WINDOWS_DURABILITY_REQUIRED: '1'/); + assert.match(section, /npm run test:native-durability -w @propr\/desktop/); + assert.match(section, /npm run desktop:typecheck/); + assert.match(section, /npm run desktop:test/); + assert.match(section, /npm run desktop:package/); + assert.match(section, /npm run desktop:smoke/); + }); + test('keeps complete Windows validation assertions runnable but non-blocking and outside production', () => { const section = job('package', 'finalize'); {