diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe18d34d..cc64a39a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,9 +54,33 @@ jobs: - name: Analyze shell: pwsh run: fvm flutter analyze --no-fatal-infos + + - name: Validate PowerShell Scripts + shell: pwsh + run: | + $parseErrors = @( + Get-ChildItem -Path scripts, installer -Filter "*.ps1" -File | ForEach-Object { + $tokens = $null + $parseFileErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $_.FullName, + [ref]$tokens, + [ref]$parseFileErrors + ) + $parseFileErrors + } + ) + + if ($parseErrors.Count -gt 0) { + throw "PowerShell parse errors: $($parseErrors.Message -join ', ')" + } + - name: Check Bundled Wall Heights shell: pwsh run: fvm dart run tool/check_bundled_wall_heights.dart + - name: Test Release Signing Gates + shell: powershell + run: ./scripts/test_release_signing.ps1 - name: Run Tests shell: pwsh run: fvm flutter test diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 86dae011..f236505c 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -44,6 +44,7 @@ on: permissions: contents: write + id-token: write jobs: build: @@ -54,6 +55,11 @@ jobs: with: fetch-depth: 0 + - name: Require Main Branch + if: ${{ github.ref != 'refs/heads/main' }} + shell: pwsh + run: throw "Signed desktop releases must be dispatched from main." + - uses: dart-lang/setup-dart@v1 - name: Add Pub Cache To PATH @@ -69,18 +75,89 @@ jobs: shell: pwsh run: fvm install - - name: Run Desktop Release Script + - name: Build Windows Release Binaries + shell: pwsh + env: + POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} + run: | + powershell -ExecutionPolicy Bypass -File scripts/release_desktop.ps1 -Phase build -VersionBump "${{ inputs.version_bump }}" -Channel "${{ inputs.channel }}" + + - name: Sign In To Azure With OIDC + uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign Windows Release Binaries + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_ARTIFACT_SIGNING_PROFILE }} + files-folder: ${{ github.workspace }}\build\windows\x64\runner\Release + files-folder-filter: exe,dll + files-folder-recurse: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + + - name: Build Signed Updater Archive And Installer + shell: pwsh + run: | + powershell -ExecutionPolicy Bypass -File scripts/release_desktop.ps1 -Phase package -Channel "${{ inputs.channel }}" + + - name: Sign Windows Installer + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_ARTIFACT_SIGNING_PROFILE }} + files-folder: ${{ github.workspace }}\build\installer + files-folder-filter: exe + files-folder-recurse: false + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + + - name: Verify Authenticode Signatures + shell: pwsh + run: | + . ./scripts/common_release.ps1 + Assert-AuthenticodeSignatures -Path "build/windows/x64/runner/Release" + Assert-AuthenticodeSignatures -Path "build/installer" + + - name: Stage And Publish Desktop Release shell: pwsh env: CHANGE_MESSAGE: ${{ inputs.change_message }} RELEASE_TITLE: ${{ inputs.release_title }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} run: | $args = @( "-ExecutionPolicy", "Bypass", "-File", "scripts/release_desktop.ps1", - "-VersionBump", "${{ inputs.version_bump }}", + "-Phase", "stage", "-Channel", "${{ inputs.channel }}", "-ChangeMessage", $env:CHANGE_MESSAGE ) @@ -128,7 +205,7 @@ jobs: Write-Host "https://sunkenintime.github.io/icarus/updates/windows/$channel/app-archive.json" - name: Commit Version And Metadata Changes - if: ${{ github.ref_type == 'branch' }} + if: ${{ inputs.publish_pages && github.ref_type == 'branch' }} shell: pwsh run: | $changes = git status --porcelain -- pubspec.yaml lib/const/settings.dart release/metadata diff --git a/docs/release_process.md b/docs/release_process.md index f4df0851..5ca0b180 100644 --- a/docs/release_process.md +++ b/docs/release_process.md @@ -33,7 +33,7 @@ Use this when you want to publish the direct installer channel. 1. Go to `Actions` in GitHub. 2. Open `Release Desktop`. -3. Click `Run workflow`. +3. Click `Run workflow` and select `main`. 4. Choose: - `version_bump`: `none` if the version is already correct, otherwise `patch`, `minor`, or `major` - `channel`: `stable` @@ -51,10 +51,11 @@ Use this when you want to publish the direct installer channel. ## Desktop Prerelease Checklist -Use this when you want to validate updater behavior before shipping to `main`. +Use this to validate updater behavior before publishing to the stable channel. +Signed releases run from `main`, matching the Azure federated credential. -1. Checkout branch `update/prerelease`. -2. Push the updater changes you want to validate. +1. Merge the reviewed changes into `main`. +2. Select `main` as the workflow branch. 3. Go to `Actions` in GitHub. 4. Open `Release Desktop`. 5. Click `Run workflow`. @@ -73,7 +74,7 @@ Use this when you want to validate updater behavior before shipping to `main`. - app exits for restart - relaunched app is the new version - second cold launch still shows the new version -10. After validation, merge/fix as needed and publish stable from `main`. +10. After validation, publish stable from `main`. ## Store Release Checklist @@ -96,7 +97,7 @@ Use this when you want to publish the Microsoft Store channel. - Desktop-only update: - Run `Release Desktop` only. - Desktop prerelease validation: - - Use branch `update/prerelease`. + - Use branch `main`. - Run `Release Desktop` with `channel=prerelease`. - After validation, rerun desktop release on `main` with `channel=stable`. - Store-only update: @@ -107,9 +108,34 @@ Use this when you want to publish the Microsoft Store channel. ## Notes - Local prerelease publish: - - `scripts/publish_prerelease_local.ps1` pushes the staged site content to `gh-pages`. + - `scripts/publish_prerelease_local.ps1` cannot publish an unsigned build. Use `Release Desktop` on `main` with `channel=prerelease` for signing and publication. + - The shared scripts verify EXE and DLL signatures before packaging, staging, and pushing Pages content. Manual phased releases require signing between build and package, then signing the installer before stage. - GitHub Pages should be configured to serve `gh-pages` from `/ (root)`. - No extra Pages deploy workflow is needed for prerelease testing. - Direct desktop installs now use a per-user install path and per-user registry registration. - Store installs should continue to use the Microsoft Store update path only. - The metadata file should not be a generic `template.json` in the live metadata folder, because the manifest generator treats every JSON file there as a real release entry. + +## Azure signing setup and first verification + +GitHub repository secrets: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and +`AZURE_SUBSCRIPTION_ID`. Repository variables: +`AZURE_ARTIFACT_SIGNING_ENDPOINT`, `AZURE_ARTIFACT_SIGNING_ACCOUNT`, and +`AZURE_ARTIFACT_SIGNING_PROFILE`. + +The Azure application needs a federated credential with issuer +`https://token.actions.githubusercontent.com`, audience +`api://AzureADTokenExchange`, and subject +`repo:SunkenInTime/icarus:ref:refs/heads/main`. Assign its service principal the +Artifact Signing Certificate Profile Signer role on the signing profile. +The public trust identity validation and certificate profile must be active. + +After merging the signing workflow, first run it on `main` with +`version_bump=none`, `channel=prerelease`, and `publish_pages=false`. +This signs and verifies artifacts without publishing Pages or committing +version/metadata changes. Download the installer artifact, check its expected +publisher in Windows, and test installation and launch. Then publish a +prerelease and test updating an older prerelease installation before stable. + +A green PR check validates code and the signature rejection gates. It does not +prove Azure login, signing permissions, or an end-to-end signed release works. diff --git a/scripts/build_desktop_release.ps1 b/scripts/build_desktop_release.ps1 index 765ebbca..4503f5e8 100644 --- a/scripts/build_desktop_release.ps1 +++ b/scripts/build_desktop_release.ps1 @@ -1,4 +1,6 @@ param( + [ValidateSet("all", "build", "package", "stage")] + [string]$Phase = "all", [ValidateSet("stable", "prerelease")] [string]$Channel = "stable", [switch]$Mandatory, @@ -19,52 +21,63 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot -$env:FLUTTER_ROOT = Get-FlutterRoot -RepoRoot $repoRoot +$runBuild = @("all", "build") -contains $Phase +$runPackage = @("all", "package") -contains $Phase +$runStage = @("all", "stage") -contains $Phase -Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @("dart", "run", "tool/check_bundled_wall_heights.dart") - -if (-not $SkipPubGet) { - Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @("flutter", "pub", "get") +if ($runBuild -or $runPackage) { + $env:FLUTTER_ROOT = Get-FlutterRoot -RepoRoot $repoRoot } -# The bundled sightline models must be the reviewed ones before packaging. -Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @( - "flutter", "test", "--no-pub", "test/bundled_map_models_test.dart" -) +if ($runBuild) { + Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @("dart", "run", "tool/check_bundled_wall_heights.dart") + + if (-not $SkipPubGet) { + Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @("flutter", "pub", "get") + } -$dartDefinesPath = $null -try { - $releaseArguments = @( - "dart", - "run", - "desktop_updater:release", - "windows", - "--release", - "--dart-define=ICARUS_UPDATE_CHANNEL=$Channel" + # Keep the reviewed bundled map checks from the shared build path. + Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @( + "flutter", "test", "--no-pub", "test/bundled_map_models_test.dart" ) - if (-not [string]::IsNullOrWhiteSpace($PostHogProjectToken)) { - $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) - Write-JsonFileUtf8 -Path $dartDefinesPath -Value @{ - POSTHOG_PROJECT_TOKEN = $PostHogProjectToken - POSTHOG_HOST = $PostHogHost + + $dartDefinesPath = $null + try { + $releaseArguments = @( + "dart", + "run", + "desktop_updater:release", + "windows", + "--release", + "--dart-define=ICARUS_UPDATE_CHANNEL=$Channel" + ) + if (-not [string]::IsNullOrWhiteSpace($PostHogProjectToken)) { + $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) + Write-JsonFileUtf8 -Path $dartDefinesPath -Value @{ + POSTHOG_PROJECT_TOKEN = $PostHogProjectToken + POSTHOG_HOST = $PostHogHost + } + $releaseArguments += "--dart-define-from-file=$dartDefinesPath" } - $releaseArguments += "--dart-define-from-file=$dartDefinesPath" + Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments $releaseArguments } - Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments $releaseArguments -} -finally { - if ($null -ne $dartDefinesPath -and (Test-Path -LiteralPath $dartDefinesPath)) { - Remove-Item -LiteralPath $dartDefinesPath -Force + finally { + if ($null -ne $dartDefinesPath -and (Test-Path -LiteralPath $dartDefinesPath)) { + Remove-Item -LiteralPath $dartDefinesPath -Force + } + } + + # Stage the video-export encoder before signing and packaging the build. + Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments @( + "-ExecutionPolicy", "Bypass", "-File", "scripts/fetch_ffmpeg.ps1" + ) + + if ($Phase -eq "build") { + Write-Host "Desktop binaries are ready for signing." -ForegroundColor Green + return } } -# Stage the video-export encoder into the build output before archiving. -Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments @( - "-ExecutionPolicy", "Bypass", "-File", "scripts/fetch_ffmpeg.ps1" -) -# desktop_updater:release snapshots the Windows build into its app-prefixed -# dist folder before returning. Refresh that snapshot after staging FFmpeg; -# desktop_updater:archive hashes the snapshot, not the live build output. $versionInfo = Get-VersionInfo -RepoRoot $repoRoot $releaseOutputPath = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath "build\windows\x64\runner\Release" $desktopUpdaterSourcePath = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath ( @@ -73,53 +86,82 @@ $desktopUpdaterSourcePath = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath ( $distArchivePath = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath ( "dist\{0}\{1}" -f $versionInfo.BuildNumber, $versionInfo.WindowsArchiveFolderName ) -if (-not (Test-Path -LiteralPath $releaseOutputPath)) { - throw "Windows release output not found at $releaseOutputPath" -} -if (Test-Path -LiteralPath $desktopUpdaterSourcePath) { - Remove-Item -LiteralPath $desktopUpdaterSourcePath -Recurse -Force + +if ($runPackage) { + if (-not (Test-Path -LiteralPath $releaseOutputPath)) { + throw "Windows release output not found at $releaseOutputPath" + } + + Assert-AuthenticodeSignatures -Path $releaseOutputPath + + # desktop_updater:archive hashes this snapshot, so refresh it only after + # every shipped executable and DLL in the release output has been signed. + if (Test-Path -LiteralPath $desktopUpdaterSourcePath) { + Remove-Item -LiteralPath $desktopUpdaterSourcePath -Recurse -Force + } + Copy-Item -LiteralPath $releaseOutputPath -Destination $desktopUpdaterSourcePath -Recurse -Force + if (Test-Path -LiteralPath $distArchivePath) { + Remove-Item -LiteralPath $distArchivePath -Recurse -Force + } + + Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @("dart", "run", "desktop_updater:archive", "windows") + if (-not (Test-Path -LiteralPath $distArchivePath)) { + throw "Desktop Updater archive folder not found at $distArchivePath" + } + $archivedFfmpegDirectory = Join-Path $distArchivePath "ffmpeg" + $archivedFfmpegPath = Join-Path $archivedFfmpegDirectory "ffmpeg.exe" + $archiveHashesPath = Join-Path $distArchivePath "hashes.json" + if (-not (Test-Path -LiteralPath $archivedFfmpegPath)) { + throw "FFmpeg was not included in the Desktop Updater archive at $archivedFfmpegPath" + } + if (-not (Get-ChildItem -LiteralPath $archivedFfmpegDirectory -File -Filter "*.dll")) { + throw "FFmpeg shared runtime DLLs were not included in the Desktop Updater archive at $archivedFfmpegDirectory" + } + if (-not (Test-Path -LiteralPath $archiveHashesPath)) { + throw "Desktop Updater hashes file not found at $archiveHashesPath" + } + $archiveHashes = Get-Content -LiteralPath $archiveHashesPath -Raw | ConvertFrom-Json + $archiveHashPaths = @($archiveHashes | ForEach-Object { [string]$_.path }) + foreach ($ffmpegRuntimeFile in Get-ChildItem -LiteralPath $archivedFfmpegDirectory -File) { + $runtimeRelativePath = "ffmpeg\$($ffmpegRuntimeFile.Name)" + if ($archiveHashPaths -notcontains $runtimeRelativePath) { + throw "FFmpeg runtime file '$runtimeRelativePath' was not included in the Desktop Updater hashes at $archiveHashesPath" + } + } + + $oversizedPagesFiles = @(Get-ChildItem -LiteralPath $distArchivePath -Recurse -File | Where-Object { + $_.Length -gt 100MB + }) + if ($oversizedPagesFiles.Count -gt 0) { + $oversizedFileList = $oversizedPagesFiles | ForEach-Object { + "$($_.FullName) ($($_.Length) bytes)" + } + throw "Desktop Updater files exceed GitHub Pages' 100 MiB blob limit:`n$($oversizedFileList -join "`n")" + } + + Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments @("-ExecutionPolicy", "Bypass", "-File", "installer/build_installer.ps1", "-Configuration", "Release") + + if ($Phase -eq "package") { + Write-Host "Updater archive and installer are ready for signing." -ForegroundColor Green + return + } } -Copy-Item -LiteralPath $releaseOutputPath -Destination $desktopUpdaterSourcePath -Recurse -Force -if (Test-Path -LiteralPath $distArchivePath) { - Remove-Item -LiteralPath $distArchivePath -Recurse -Force + +if (-not $runStage) { + return } -Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @("dart", "run", "desktop_updater:archive", "windows") if (-not (Test-Path -LiteralPath $distArchivePath)) { throw "Desktop Updater archive folder not found at $distArchivePath" } -$archivedFfmpegDirectory = Join-Path $distArchivePath "ffmpeg" -$archivedFfmpegPath = Join-Path $archivedFfmpegDirectory "ffmpeg.exe" -$archiveHashesPath = Join-Path $distArchivePath "hashes.json" -if (-not (Test-Path -LiteralPath $archivedFfmpegPath)) { - throw "FFmpeg was not included in the Desktop Updater archive at $archivedFfmpegPath" -} -if (-not (Get-ChildItem -LiteralPath $archivedFfmpegDirectory -File -Filter "*.dll")) { - throw "FFmpeg shared runtime DLLs were not included in the Desktop Updater archive at $archivedFfmpegDirectory" -} -if (-not (Test-Path -LiteralPath $archiveHashesPath)) { - throw "Desktop Updater hashes file not found at $archiveHashesPath" -} -$archiveHashes = Get-Content -LiteralPath $archiveHashesPath -Raw | ConvertFrom-Json -$archiveHashPaths = @($archiveHashes | ForEach-Object { [string]$_.path }) -foreach ($ffmpegRuntimeFile in Get-ChildItem -LiteralPath $archivedFfmpegDirectory -File) { - $runtimeRelativePath = "ffmpeg\$($ffmpegRuntimeFile.Name)" - if ($archiveHashPaths -notcontains $runtimeRelativePath) { - throw "FFmpeg runtime file '$runtimeRelativePath' was not included in the Desktop Updater hashes at $archiveHashesPath" - } -} - -$oversizedPagesFiles = @(Get-ChildItem -LiteralPath $distArchivePath -Recurse -File | Where-Object { - $_.Length -gt 100MB -}) -if ($oversizedPagesFiles.Count -gt 0) { - $oversizedFileList = $oversizedPagesFiles | ForEach-Object { - "$($_.FullName) ($($_.Length) bytes)" - } - throw "Desktop Updater files exceed GitHub Pages' 100 MiB blob limit:`n$($oversizedFileList -join "`n")" -} -Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments @("-ExecutionPolicy", "Bypass", "-File", "installer/build_installer.ps1", "-Configuration", "Release") +# Validate the actual archive and installer before writing any publishable output. +Assert-AuthenticodeSignatures -Path $distArchivePath +$installerOutputDir = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath "build\installer" +$installerFileName = "icarus-setup-{0}.exe" -f $versionInfo.VersionName +$installerSourcePath = Join-Path $installerOutputDir $installerFileName +Assert-AuthenticodeSignatures -Path $installerSourcePath +Assert-AuthenticodeSignatures -Path $installerOutputDir $metadataRoot = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath $MetadataDir New-Item -ItemType Directory -Force -Path $metadataRoot | Out-Null @@ -179,32 +221,23 @@ Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments "-Channel", $Channel ) -$installerOutputDir = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath "build\installer" -if (Test-Path $installerOutputDir) { - $desktopArtifactDir = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath ("release\out\desktop\{0}" -f $versionInfo.FullVersion) - New-Item -ItemType Directory -Force -Path $desktopArtifactDir | Out-Null - Copy-Item -Path (Join-Path $installerOutputDir "*") -Destination $desktopArtifactDir -Recurse -Force - - $installerFileName = "icarus-setup-{0}.exe" -f $versionInfo.VersionName - $installerSourcePath = Join-Path $installerOutputDir $installerFileName - if (-not (Test-Path $installerSourcePath)) { - throw "Expected installer not found at $installerSourcePath" - } +$desktopArtifactDir = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath ("release\out\desktop\{0}" -f $versionInfo.FullVersion) +New-Item -ItemType Directory -Force -Path $desktopArtifactDir | Out-Null +Copy-Item -Path (Join-Path $installerOutputDir "*") -Destination $desktopArtifactDir -Recurse -Force - $downloadsRoot = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath ("{0}\downloads\windows\{1}" -f $PagesStageRoot, $Channel) - if (Test-Path $downloadsRoot) { - Remove-Item -Path $downloadsRoot -Recurse -Force - } - New-Item -ItemType Directory -Force -Path $downloadsRoot | Out-Null +$downloadsRoot = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath ("{0}\downloads\windows\{1}" -f $PagesStageRoot, $Channel) +if (Test-Path $downloadsRoot) { + Remove-Item -Path $downloadsRoot -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $downloadsRoot | Out-Null - $versionedInstallerPath = Join-Path $downloadsRoot $installerFileName - $latestInstallerPath = Join-Path $downloadsRoot "icarus-setup-latest.exe" - Copy-Item -Path $installerSourcePath -Destination $versionedInstallerPath -Force - Copy-Item -Path $installerSourcePath -Destination $latestInstallerPath -Force +$versionedInstallerPath = Join-Path $downloadsRoot $installerFileName +$latestInstallerPath = Join-Path $downloadsRoot "icarus-setup-latest.exe" +Copy-Item -Path $installerSourcePath -Destination $versionedInstallerPath -Force +Copy-Item -Path $installerSourcePath -Destination $latestInstallerPath -Force - Write-Host ("Published installer downloads to {0}" -f $downloadsRoot) -ForegroundColor Green - Write-Host ("Latest installer URL path: /downloads/windows/{0}/icarus-setup-latest.exe" -f $Channel) -ForegroundColor Green -} +Write-Host ("Published installer downloads to {0}" -f $downloadsRoot) -ForegroundColor Green +Write-Host ("Latest installer URL path: /downloads/windows/{0}/icarus-setup-latest.exe" -f $Channel) -ForegroundColor Green Write-Host "Desktop release staging complete for $($versionInfo.FullVersion)." -ForegroundColor Green Write-Host "Pages output: $channelRoot" diff --git a/scripts/common_release.ps1 b/scripts/common_release.ps1 index 7c309c1f..1f90a2a5 100644 --- a/scripts/common_release.ps1 +++ b/scripts/common_release.ps1 @@ -1,5 +1,36 @@ Set-StrictMode -Version Latest +function Assert-AuthenticodeSignatures { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw "Signing input not found at $Path" + } + $item = Get-Item -LiteralPath $Path + $files = if ($item.PSIsContainer) { + @(Get-ChildItem -LiteralPath $Path -Recurse -File | Where-Object { + $_.Extension -in @('.exe', '.dll') + }) + } + else { + @($item) + } + $files = @($files) + if ($files.Count -eq 0) { + throw "No Windows executables or libraries found at $Path" + } + foreach ($file in $files) { + $signature = Get-AuthenticodeSignature -LiteralPath $file.FullName + if ($signature.Status -ne 'Valid') { + throw "Invalid Authenticode signature ($($signature.Status)): $($file.FullName). Run Release Desktop from main to build and sign release artifacts." + } + } + Write-Host "Verified $($files.Count) signed Windows files at $Path" +} + function Get-RepoRoot { param( [Parameter(Mandatory = $true)] diff --git a/scripts/publish_pages_branch.ps1 b/scripts/publish_pages_branch.ps1 index 2f2ee184..d3a08433 100644 --- a/scripts/publish_pages_branch.ps1 +++ b/scripts/publish_pages_branch.ps1 @@ -17,6 +17,16 @@ if (-not (Test-Path $resolvedSourceDir)) { throw "Pages source directory not found at $resolvedSourceDir" } +# Check the staged bytes even when this publisher is invoked directly. +# Both channels are checked before either half of a desktop release is pushed. +$windowsPayloadRoots = @('updates/windows', 'downloads/windows') +foreach ($payloadRoot in $windowsPayloadRoots) { + $payloadPath = Join-Path $resolvedSourceDir $payloadRoot + if (Test-Path -LiteralPath $payloadPath) { + Assert-AuthenticodeSignatures -Path $payloadPath + } +} + $remoteUrl = (& git -C $repoRoot remote get-url $Remote).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($remoteUrl)) { throw "Could not resolve git remote URL for '$Remote'." diff --git a/scripts/release_desktop.ps1 b/scripts/release_desktop.ps1 index 7a7e4e97..a39a33e6 100644 --- a/scripts/release_desktop.ps1 +++ b/scripts/release_desktop.ps1 @@ -1,4 +1,6 @@ param( + [ValidateSet("all", "build", "package", "stage")] + [string]$Phase = "all", [ValidateSet("none", "patch", "minor", "major")] [string]$VersionBump = "none", [ValidateSet("stable", "prerelease")] @@ -28,7 +30,15 @@ if ([string]::IsNullOrWhiteSpace($AppArchiveBaseUrl)) { $AppArchiveBaseUrl = "https://sunkenintime.github.io/icarus/updates/windows/$Channel" } -if ($VersionBump -ne "none") { +if ($PublishPages -and (@("all", "stage") -notcontains $Phase)) { + throw "Pages can only be published during the 'all' or 'stage' release phase." +} + +if ($VersionBump -ne "none" -and (@("all", "build") -notcontains $Phase)) { + throw "Version bumps can only be applied during the 'all' or 'build' release phase." +} + +if ($VersionBump -ne "none" -and (@("all", "build") -contains $Phase)) { Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments @( "-ExecutionPolicy", "Bypass", @@ -44,6 +54,8 @@ $buildArgs = @( "Bypass", "-File", "scripts/build_desktop_release.ps1", + "-Phase", + $Phase, "-Channel", $Channel, "-PagesStageRoot", diff --git a/scripts/test_release_signing.ps1 b/scripts/test_release_signing.ps1 new file mode 100644 index 00000000..7de44c8a --- /dev/null +++ b/scripts/test_release_signing.ps1 @@ -0,0 +1,67 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. (Join-Path $PSScriptRoot 'common_release.ps1') + +function Assert-Rejected { + param([scriptblock]$Action, [string]$Expected) + try { + & $Action + } + catch { + if ($_.Exception.Message -notlike "*$Expected*") { throw } + return + } + throw "Expected rejection containing '$Expected'." +} + +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('icarus-signing-test-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $testRoot | Out-Null +try { + $signedSource = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe' + Assert-AuthenticodeSignatures -Path $signedSource + Assert-Rejected { Assert-AuthenticodeSignatures -Path (Join-Path $testRoot 'missing') } 'not found' + Assert-Rejected { Assert-AuthenticodeSignatures -Path $testRoot } 'No Windows executables' + + $scripts = Join-Path $testRoot 'scripts' + New-Item -ItemType Directory -Path $scripts | Out-Null + foreach ($name in @('common_release.ps1', 'build_desktop_release.ps1', 'publish_pages_branch.ps1')) { + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination $scripts + } + Set-Content -LiteralPath (Join-Path $testRoot 'pubspec.yaml') -Value 'version: 1.2.3+4' + $archive = Join-Path $testRoot 'dist/4/1.2.3+4-windows' + $installer = Join-Path $testRoot 'build/installer' + New-Item -ItemType Directory -Path $archive, $installer -Force | Out-Null + Copy-Item -LiteralPath $signedSource -Destination (Join-Path $archive 'icarus.exe') + Assert-AuthenticodeSignatures -Path $archive + $nested = Join-Path $archive 'ffmpeg' + New-Item -ItemType Directory -Path $nested | Out-Null + $unsignedDll = Join-Path $nested 'runtime.dll' + Set-Content -LiteralPath $unsignedDll -Value 'unsigned runtime' + Assert-Rejected { Assert-AuthenticodeSignatures -Path $archive } 'Invalid Authenticode signature' + Assert-Rejected { & (Join-Path $scripts 'build_desktop_release.ps1') -Phase stage } 'Invalid Authenticode signature' + if (Test-Path (Join-Path $testRoot 'release')) { throw 'Stage wrote output before checking signatures.' } + + Copy-Item -LiteralPath $signedSource -Destination $unsignedDll -Force + $installerExe = Join-Path $installer 'icarus-setup-1.2.3.exe' + Set-Content -LiteralPath $installerExe -Value 'unsigned installer' + Assert-Rejected { & (Join-Path $scripts 'build_desktop_release.ps1') -Phase stage } 'Invalid Authenticode signature' + if (Test-Path (Join-Path $testRoot 'release')) { throw 'Stage wrote output before checking installer.' } + + $pages = Join-Path $testRoot 'pages' + $downloads = Join-Path $pages 'downloads/windows/prerelease' + New-Item -ItemType Directory -Path $downloads -Force | Out-Null + Copy-Item -LiteralPath $installerExe -Destination $downloads + Assert-Rejected { + & (Join-Path $scripts 'publish_pages_branch.ps1') -SourceDir 'pages' -Remote 'must-not-contact-remote' -SyncPaths 'downloads/windows/prerelease' + } 'Invalid Authenticode signature' + Write-Host 'Release signing tests passed: signed file accepted; missing, empty, nested unsigned DLL, unsigned installer, and direct unsigned publication rejected.' +} +finally { + $resolvedTestRoot = [IO.Path]::GetFullPath($testRoot) + $tempParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (-not $resolvedTestRoot.StartsWith($tempParent, [StringComparison]::OrdinalIgnoreCase) -or + (Split-Path -Leaf $resolvedTestRoot) -notlike 'icarus-signing-test-*') { + throw "Refusing to remove unexpected test directory $resolvedTestRoot" + } + Remove-Item -LiteralPath $resolvedTestRoot -Recurse -Force +}