From 366c9fb483e8af6f3d983e55a608c2c8b7f480be Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:31:01 -0400 Subject: [PATCH] feat(release): sign Windows artifacts with Azure --- .github/workflows/ci.yml | 21 +++ .github/workflows/release-desktop.yml | 98 +++++++++++- scripts/build_desktop_release.ps1 | 220 +++++++++++++++----------- scripts/release_desktop.ps1 | 14 +- 4 files changed, 256 insertions(+), 97 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4038fa88..c295b7a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,27 @@ 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: Run Tests shell: pwsh run: fvm flutter test diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 86dae011..8d82f283 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,104 @@ 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: | + $releaseFiles = Get-ChildItem -Path "build/windows/x64/runner/Release" -Recurse -File -Include "*.exe", "*.dll" + $installerFiles = Get-ChildItem -Path "build/installer" -File -Filter "*.exe" + $signedFiles = @($releaseFiles) + @($installerFiles) + + if ($signedFiles.Count -eq 0) { + throw "No Windows executables or libraries were found to verify." + } + + $invalidFiles = @( + $signedFiles | Where-Object { + (Get-AuthenticodeSignature -FilePath $_.FullName).Status -ne "Valid" + } + ) + if ($invalidFiles.Count -gt 0) { + throw "Invalid Authenticode signatures: $($invalidFiles.FullName -join ', ')" + } + + Write-Host "Verified $($signedFiles.Count) signed Windows files." + + - 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 ) diff --git a/scripts/build_desktop_release.ps1 b/scripts/build_desktop_release.ps1 index a9285b20..f0dfa063 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,45 +21,56 @@ 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 -if (-not $SkipPubGet) { - Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @("flutter", "pub", "get") +if ($runBuild -or $runPackage) { + $env:FLUTTER_ROOT = Get-FlutterRoot -RepoRoot $repoRoot } -$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 +if ($runBuild) { + 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" + ) + 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 ( @@ -66,53 +79,72 @@ $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 -} -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" +if ($runPackage) { + if (-not (Test-Path -LiteralPath $releaseOutputPath)) { + throw "Windows release output not found at $releaseOutputPath" } -} -$oversizedPagesFiles = @(Get-ChildItem -LiteralPath $distArchivePath -Recurse -File | Where-Object { - $_.Length -gt 100MB -}) -if ($oversizedPagesFiles.Count -gt 0) { - $oversizedFileList = $oversizedPagesFiles | ForEach-Object { - "$($_.FullName) ($($_.Length) bytes)" + # 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 } - 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 (-not $runStage) { + return +} + +if (-not (Test-Path -LiteralPath $distArchivePath)) { + throw "Desktop Updater archive folder not found at $distArchivePath" +} $metadataRoot = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath $MetadataDir New-Item -ItemType Directory -Force -Path $metadataRoot | Out-Null @@ -173,31 +205,33 @@ Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments ) $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" - } +if (-not (Test-Path -LiteralPath $installerOutputDir)) { + throw "Signed installer output not found at $installerOutputDir" +} - $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 +$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 - $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 +$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" +} - 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 +$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 + +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/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",