# Waggle — Release Build Workflow # # Builds desktop apps for Windows (NSIS) and macOS (DMG) on tag push. # Publishes only the certified Windows installer; macOS outputs remain # workflow verification artifacts until the deferred macOS release gate lands. # # Trigger: push tag v* (e.g., v1.0.0). Use tauri-build-pr.yml for manual # unsigned verification builds; production signing is tag-only. name: Release Build on: push: tags: - 'v*' permissions: contents: read env: WINDOWS_SIGNING_TRANSPORT_MAX_ITEMS: 60000 concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: build-windows-prebuilt: runs-on: windows-latest permissions: contents: read outputs: build_receipt_sha256: ${{ steps.handoff.outputs.receipt_sha256 }} handoff_size_bytes: ${{ steps.handoff.outputs.handoff_size_bytes }} artifact_id: ${{ steps.upload-prebuilt.outputs.artifact-id }} artifact_digest: ${{ steps.upload-prebuilt.outputs.artifact-digest }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 persist-credentials: false - name: Validate immutable Windows build boundary shell: pwsh run: | if ($env:GITHUB_REPOSITORY -cne 'marolinik/waggle-os' -or $env:GITHUB_REF_TYPE -cne 'tag' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$') { throw 'Windows release build requires the exact repository, tag, and revision boundary.' } $checkedOutRevision = (git rev-parse --verify HEAD).Trim().ToLowerInvariant() if ($LASTEXITCODE -ne 0 -or $checkedOutRevision -cne $env:GITHUB_SHA) { throw 'Checked-out Windows release revision differs from GITHUB_SHA.' } if (@(git status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'Windows release build checkout must be clean.' } $version = [string](Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' | ConvertFrom-Json).version $expectedTag = "v$version" if ($env:GITHUB_REF -ne "refs/tags/$expectedTag" -or $env:GITHUB_REF_NAME -ne $expectedTag) { throw "Release tag $env:GITHUB_REF_NAME does not exactly match app version $version" } $remoteMainRevision = (git rev-parse --verify refs/remotes/origin/main).Trim().ToLowerInvariant() if ($LASTEXITCODE -ne 0 -or $remoteMainRevision -cnotmatch '^[0-9a-f]{40}$') { throw 'Fresh checkout did not provide canonical origin/main for release ancestry validation' } git merge-base --is-ancestor $env:GITHUB_SHA refs/remotes/origin/main if ($LASTEXITCODE -ne 0) { throw 'Release tag commit is not an ancestor of canonical origin/main' } - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22.23.2 cache: npm - name: Setup Rust uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 with: toolchain: 1.94.0 - name: Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: app/src-tauri - name: Install dependencies run: npm ci - name: Install locked Tauri CLI run: npm ci --prefix app --ignore-scripts - name: Build packages (shared -> core -> agent -> server) run: npm run build:packages - name: Bundle Node.js runtime run: node scripts/bundle-node.mjs - name: Build sidecar run: node scripts/build-sidecar.mjs - name: Bundle native dependencies run: node scripts/bundle-native-deps.mjs - name: Stage sidecar dependencies run: node scripts/stage-sidecar-deps.mjs - name: Verify packaged hook lifecycles run: node node_modules/vitest/vitest.mjs run --root . --config vitest.config.ts packages/agent/tests/hook-packages-runtime.test.ts -t "runs staged Tauri hook lifecycles" env: WAGGLE_VERIFY_STAGED_HOOK_RUNTIME: '1' - name: Build frontend run: cd apps/web && npx vite build - name: Build full unsigned Tauri NSIS package shell: pwsh run: | $override = Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.build-override.conf.json' | ConvertFrom-Json if ($null -ne $override.bundle.windows -and $null -ne $override.bundle.windows.PSObject.Properties['signCommand']) { throw 'The hosted prebuilt job must not carry a Windows signing command.' } Push-Location app try { & node node_modules/@tauri-apps/cli/tauri.js build ` --target x86_64-pc-windows-msvc ` --bundles nsis ` --config src-tauri/tauri.build-override.conf.json if ($LASTEXITCODE -ne 0) { throw 'Full unsigned Tauri NSIS package failed.' } } finally { Pop-Location } - name: Issue immutable Windows signing handoff id: handoff shell: pwsh run: | $handoffRoot = Join-Path $env:RUNNER_TEMP 'waggle-windows-signing-handoff' if (Test-Path -LiteralPath $handoffRoot) { throw "Windows signing handoff root already exists: $handoffRoot" } & ./app/scripts/new-windows-signing-handoff.ps1 ` -SourceTargetRoot 'app/src-tauri/target' ` -DestinationRoot $handoffRoot if ($LASTEXITCODE -ne 0) { throw 'Windows signing handoff failed.' } $receiptPath = Join-Path $handoffRoot 'build-receipt.json' $prebuiltRoot = Join-Path $handoffRoot 'prebuilt' if (-not (Test-Path -LiteralPath $receiptPath -PathType Leaf) -or -not (Test-Path -LiteralPath $prebuiltRoot -PathType Container)) { throw 'Windows signing handoff did not publish its canonical receipt and prebuilt root.' } $receiptSha256 = (Get-FileHash -LiteralPath $receiptPath -Algorithm SHA256).Hash if ($receiptSha256 -notmatch '^[0-9A-F]{64}$') { throw 'Windows signing handoff receipt digest is invalid.' } $handoffSizeBytes = [long]( Get-ChildItem -LiteralPath $handoffRoot -Recurse -Force -File | Measure-Object -Property Length -Sum ).Sum if ($handoffSizeBytes -le 0) { throw 'Windows signing handoff has an invalid aggregate size.' } "receipt_sha256=$receiptSha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "handoff_size_bytes=$handoffSizeBytes" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Upload immutable Windows signing handoff id: upload-prebuilt uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: waggle-windows-prebuilt-${{ github.sha }} path: ${{ runner.temp }}\waggle-windows-signing-handoff if-no-files-found: error include-hidden-files: true retention-days: 7 prepare-windows-signing: needs: build-windows-prebuilt runs-on: windows-latest permissions: contents: read outputs: release_mode: ${{ steps.release-mode.outputs.mode }} candidate_version: ${{ steps.release-mode.outputs.candidate_version }} bootstrap_identity: ${{ steps.release-mode.outputs.bootstrap_identity }} upgrade_base_tag: ${{ steps.release-mode.outputs.upgrade_base_tag }} upgrade_base_asset_name: ${{ steps.release-mode.outputs.upgrade_base_asset_name }} upgrade_base_sha256: ${{ steps.release-mode.outputs.upgrade_base_sha256 }} upgrade_base_commit: ${{ steps.release-mode.outputs.upgrade_base_commit }} signer_subject: ${{ steps.signer-identity.outputs.subject }} preparation_receipt_sha256: ${{ steps.stage-prepared.outputs.receipt_sha256 }} preparation_size_bytes: ${{ steps.stage-prepared.outputs.size_bytes }} artifact_id: ${{ steps.upload-prepared.outputs.artifact-id }} artifact_digest: ${{ steps.upload-prepared.outputs.artifact-digest }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 persist-credentials: false - name: Validate exact hosted signing preparation boundary shell: pwsh run: | $version = [string](Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' | ConvertFrom-Json).version if ($env:GITHUB_ACTIONS -cne 'true' -or $env:GITHUB_EVENT_NAME -cne 'push' -or $env:GITHUB_REPOSITORY -cne 'marolinik/waggle-os' -or $env:GITHUB_REF_TYPE -cne 'tag' -or $env:GITHUB_REF -cne "refs/tags/v$version" -or $env:GITHUB_REF_NAME -cne "v$version" -or $env:GITHUB_WORKFLOW_REF -cne "marolinik/waggle-os/.github/workflows/release.yml@$env:GITHUB_REF" -or $env:GITHUB_WORKFLOW_SHA -cne $env:GITHUB_SHA -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$') { throw 'Windows signing preparation requires the exact hosted push, repository, tag, workflow, and revision boundary.' } $checkedOutRevision = (git rev-parse --verify HEAD).Trim().ToLowerInvariant() if ($LASTEXITCODE -ne 0 -or $checkedOutRevision -cne $env:GITHUB_SHA -or @(git status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'Windows signing preparation checkout is not the clean GITHUB_SHA revision.' } - name: Resolve Windows release mode id: release-mode shell: pwsh env: WINDOWS_BOOTSTRAP_RELEASE_IDENTITY: ${{ vars.WINDOWS_BOOTSTRAP_RELEASE_IDENTITY }} WINDOWS_UPGRADE_BASE_TAG: ${{ vars.WINDOWS_UPGRADE_BASE_TAG }} WINDOWS_UPGRADE_BASE_ASSET_NAME: ${{ vars.WINDOWS_UPGRADE_BASE_ASSET_NAME }} WINDOWS_UPGRADE_BASE_SHA256: ${{ vars.WINDOWS_UPGRADE_BASE_SHA256 }} WINDOWS_UPGRADE_BASE_COMMIT: ${{ vars.WINDOWS_UPGRADE_BASE_COMMIT }} run: | function Resolve-WindowsReleaseMode { param( [string]$CandidateVersion, [string]$CandidateTag, [string]$CandidateSha, [string]$BootstrapIdentity, [string]$BaseTag, [string]$BaseAssetName, [string]$BaseSha256, [string]$BaseCommit ) if ($CandidateVersion -notmatch '^\d+\.\d+\.\d+$' -or $CandidateTag -cne "v$CandidateVersion" -or $CandidateSha -cnotmatch '^[0-9a-f]{40}$') { throw 'Candidate release identity is not a strict tag, version, and commit tuple.' } $candidateSemVer = [version]$CandidateVersion $bootstrapSemVer = [version]'0.2.0' $releaseInputs = @( $BootstrapIdentity, $BaseTag, $BaseAssetName, $BaseSha256, $BaseCommit ) if (@($releaseInputs | Where-Object { $_ -ne $_.Trim() }).Count -gt 0) { throw 'Windows release-mode inputs must be exact values without surrounding whitespace.' } $baselineInputs = @($BaseTag, $BaseAssetName, $BaseSha256, $BaseCommit) $configuredBaselineInputs = @( $baselineInputs | Where-Object { -not [string]::IsNullOrEmpty($_) } ).Count if ($CandidateVersion -ceq '0.2.0') { if ($CandidateTag -cne 'v0.2.0' -or $configuredBaselineInputs -ne 0) { throw 'The v0.2.0 bootstrap requires all protected upgrade-baseline inputs to be exactly empty.' } $expectedBootstrapIdentity = "v0.2.0@$CandidateSha" if (-not [string]::Equals( $BootstrapIdentity, $expectedBootstrapIdentity, [System.StringComparison]::Ordinal )) { throw 'The v0.2.0 bootstrap authorization does not exactly match the release tag and commit.' } return 'bootstrap' } if ($candidateSemVer -le $bootstrapSemVer) { throw 'Non-bootstrap Windows releases must be newer than v0.2.0.' } if (-not [string]::IsNullOrEmpty($BootstrapIdentity)) { throw 'Bootstrap authorization is valid only for the exact v0.2.0 release.' } if ($configuredBaselineInputs -ne 4) { throw 'Non-bootstrap Windows releases require all four protected upgrade-baseline inputs.' } if ($CandidateVersion -ceq '0.2.1' -and $BaseTag -cne 'v0.2.0') { throw 'The first Windows upgrade must use the signed v0.2.0 bootstrap baseline.' } return 'upgrade' } $candidateVersion = [string](Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' | ConvertFrom-Json).version $bootstrapIdentity = [string]$env:WINDOWS_BOOTSTRAP_RELEASE_IDENTITY $baseTag = [string]$env:WINDOWS_UPGRADE_BASE_TAG $baseAssetName = [string]$env:WINDOWS_UPGRADE_BASE_ASSET_NAME $baseSha256 = [string]$env:WINDOWS_UPGRADE_BASE_SHA256 $baseCommit = [string]$env:WINDOWS_UPGRADE_BASE_COMMIT $releaseMode = Resolve-WindowsReleaseMode ` -CandidateVersion $candidateVersion ` -CandidateTag $env:GITHUB_REF_NAME ` -CandidateSha $env:GITHUB_SHA ` -BootstrapIdentity $bootstrapIdentity ` -BaseTag $baseTag ` -BaseAssetName $baseAssetName ` -BaseSha256 $baseSha256 ` -BaseCommit $baseCommit "mode=$releaseMode" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "candidate_version=$candidateVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "bootstrap_identity=$bootstrapIdentity" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "upgrade_base_tag=$baseTag" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "upgrade_base_asset_name=$baseAssetName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "upgrade_base_sha256=$baseSha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "upgrade_base_commit=$baseCommit" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Validate protected signer subject id: signer-identity shell: pwsh env: WINDOWS_CODESIGN_APPROVED_SUBJECT: ${{ vars.WINDOWS_CODESIGN_APPROVED_SUBJECT }} run: | $expected = 'CN=EGZAKTA DOO BEOGRAD, O=EGZAKTA DOO BEOGRAD, L=Amsterdam, C=NL' $subject = [string]$env:WINDOWS_CODESIGN_APPROVED_SUBJECT if ($subject -cne $expected -or $subject -cne $subject.Trim() -or $subject -match '[\x00-\x1F\x7F]') { throw 'WINDOWS_CODESIGN_APPROVED_SUBJECT does not exactly match the approved public identity.' } "subject=$subject" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Validate capacity before duplicate handoff extraction shell: pwsh env: WAGGLE_HANDOFF_SIZE_BYTES: ${{ needs.build-windows-prebuilt.outputs.handoff_size_bytes }} run: | $handoffSizeBytes = [long]0 if (-not [long]::TryParse( [string]$env:WAGGLE_HANDOFF_SIZE_BYTES, [Globalization.NumberStyles]::None, [Globalization.CultureInfo]::InvariantCulture, [ref]$handoffSizeBytes ) -or $handoffSizeBytes -le 0 -or $handoffSizeBytes -gt 40GB) { throw 'Hosted build handoff size output is missing or invalid.' } $dependencyAndCertificationOverheadBytes = [long]9GB if ($handoffSizeBytes -gt ([long]::MaxValue - $dependencyAndCertificationOverheadBytes) / 2) { throw 'Hosted build handoff size overflows the capacity calculation.' } $requiredBytes = [long]( $handoffSizeBytes * 2 + $dependencyAndCertificationOverheadBytes ) $runnerRoot = [IO.Path]::GetPathRoot([IO.Path]::GetFullPath($env:RUNNER_TEMP)) $availableBytes = [IO.DriveInfo]::new($runnerRoot).AvailableFreeSpace if ($availableBytes -lt $requiredBytes) { throw "Insufficient disk for two isolated handoff extractions: requires=$requiredBytes available=$availableBytes" } - name: Setup Node.js for protected packaging uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '22.23.2' cache: npm cache-dependency-path: app/package-lock.json - name: Install locked Tauri packaging CLI run: npm ci --prefix app --ignore-scripts - name: Download immutable prebuilt handoff for unsigned verification uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: artifact-ids: ${{ needs.build-windows-prebuilt.outputs.artifact_id }} path: ${{ runner.temp }}\waggle-prebuilt-unsigned merge-multiple: true - name: Download immutable prebuilt handoff for signed packaging uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: artifact-ids: ${{ needs.build-windows-prebuilt.outputs.artifact_id }} path: ${{ runner.temp }}\waggle-prebuilt-signing merge-multiple: true - name: Verify duplicate handoff roots and bound receipt id: verify-handoff shell: pwsh env: EXPECTED_BUILD_RECEIPT_SHA256: ${{ needs.build-windows-prebuilt.outputs.build_receipt_sha256 }} run: | $expectedSha256 = [string]$env:EXPECTED_BUILD_RECEIPT_SHA256 if ($expectedSha256 -notmatch '^[0-9A-F]{64}$') { throw 'Build receipt SHA-256 output is missing or invalid.' } $unsignedDownload = [IO.Path]::GetFullPath((Resolve-Path (Join-Path $env:RUNNER_TEMP 'waggle-prebuilt-unsigned')).Path) $signingDownload = [IO.Path]::GetFullPath((Resolve-Path (Join-Path $env:RUNNER_TEMP 'waggle-prebuilt-signing')).Path) if ([string]::Equals($unsignedDownload, $signingDownload, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsigned and signing handoff downloads must be distinct.' } $unsignedReceipt = Join-Path $unsignedDownload 'build-receipt.json' $signingReceipt = Join-Path $signingDownload 'build-receipt.json' $unsignedRoot = Join-Path $unsignedDownload 'prebuilt' $signingRoot = Join-Path $signingDownload 'prebuilt' $unsignedNsisRoot = Join-Path $unsignedDownload 'nsis-toolchain' $signingNsisRoot = Join-Path $signingDownload 'nsis-toolchain' foreach ($path in @($unsignedReceipt, $signingReceipt)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or -not [string]::Equals( (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash, $expectedSha256, [StringComparison]::OrdinalIgnoreCase )) { throw 'Downloaded hosted build receipt differs from its immutable handoff digest.' } } $signingReceiptForPackage = Join-Path $signingDownload 'build-receipt.package.json' [IO.File]::Copy($signingReceipt, $signingReceiptForPackage, $false) if ((Get-FileHash -LiteralPath $signingReceiptForPackage -Algorithm SHA256).Hash -cne $expectedSha256) { throw 'Signing package receipt copy changed before use.' } foreach ($path in @($unsignedRoot, $signingRoot)) { if (-not (Test-Path -LiteralPath $path -PathType Container)) { throw 'Downloaded prebuilt input root is missing.' } } foreach ($path in @($unsignedNsisRoot, $signingNsisRoot)) { if (-not (Test-Path -LiteralPath $path -PathType Container)) { throw 'Downloaded NSIS toolchain root is missing.' } } $receipt = Get-Content -Raw -LiteralPath $unsignedReceipt | ConvertFrom-Json -Depth 32 $expectedNsisAggregate = '1FC822D1A183552A80ADEA01B0BF456F462B90518256EF1FE9EDFA22D76CD85A' if (@($receipt.nsisInventory.entries).Count -ne 442 -or [string]$receipt.nsisInventory.sha256 -cne $expectedNsisAggregate) { throw 'Hosted build receipt does not bind the pinned 442-file NSIS closure.' } . ./app/scripts/sign-windows-artifact.ps1 $unsignedNsisInventory = New-WagglePrebuiltInventory $unsignedNsisRoot $signingNsisInventory = New-WagglePrebuiltInventory $signingNsisRoot foreach ($inventory in @($unsignedNsisInventory, $signingNsisInventory)) { if (@($inventory.entries).Count -ne 442 -or [string]$inventory.sha256 -cne $expectedNsisAggregate -or ($inventory | ConvertTo-Json -Depth 8 -Compress) -cne ($receipt.nsisInventory | ConvertTo-Json -Depth 8 -Compress)) { throw 'Downloaded NSIS toolchain does not exactly match its canonical receipt inventory.' } } $installedNsisRoot = Join-Path $env:LOCALAPPDATA 'tauri\NSIS' if (Test-Path -LiteralPath $installedNsisRoot) { throw 'Fresh protected signer runner unexpectedly already contains a Tauri NSIS closure.' } $installedNsisParent = Split-Path $installedNsisRoot -Parent New-Item -ItemType Directory -Path $installedNsisParent -Force | Out-Null $temporaryNsisRoot = Join-Path $installedNsisParent "NSIS.$([Guid]::NewGuid().ToString('N')).tmp" Copy-Item -LiteralPath $unsignedNsisRoot -Destination $temporaryNsisRoot -Recurse -ErrorAction Stop [IO.Directory]::Move($temporaryNsisRoot, $installedNsisRoot) $installedNsisInventory = New-WagglePrebuiltInventory $installedNsisRoot if (($installedNsisInventory | ConvertTo-Json -Depth 8 -Compress) -cne ($receipt.nsisInventory | ConvertTo-Json -Depth 8 -Compress)) { throw 'Installed signer NSIS closure changed during receipt-bound staging.' } $resourceEntries = @($receipt.resourcesInventory.entries) Assert-WaggleCanonicalInventoryEntries $resourceEntries 'Hosted resources inventory' $sourceResourcesRoot = Get-TrustedPath ` (Join-Path $unsignedRoot 'resources') 'Hosted source resources' 'Container' $signingResourcesRoot = Get-TrustedPath ` (Join-Path $signingRoot 'resources') 'Hosted signing resources' 'Container' $canonicalResourcesRoot = Get-TrustedPath ` ([IO.Path]::GetFullPath('app/src-tauri/resources')) ` 'Canonical repository resources' 'Container' $canonicalResourcesPrefix = $canonicalResourcesRoot.TrimEnd('\') + '\' $materializedEntries = [Collections.Generic.List[object]]::new() foreach ($entry in $resourceEntries) { $relative = [string]$entry.path $sourcePath = Get-TrustedPath ` (Join-Path $sourceResourcesRoot $relative) 'Hosted resource file' -AllowHardLink $signingPath = Get-TrustedPath ` (Join-Path $signingResourcesRoot $relative) 'Hosted signing resource file' -AllowHardLink foreach ($path in @($sourcePath, $signingPath)) { $item = Get-Item -LiteralPath $path -Force if ([long]$item.Length -ne [long]$entry.size -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne [string]$entry.sha256) { throw 'Hosted resource file differs from its canonical build receipt.' } } $destinationPath = [IO.Path]::GetFullPath( (Join-Path $canonicalResourcesRoot $relative) ) if (-not $destinationPath.StartsWith( $canonicalResourcesPrefix, [StringComparison]::OrdinalIgnoreCase )) { throw 'Hosted resource destination escaped the canonical repository resources root.' } [IO.Directory]::CreateDirectory((Split-Path $destinationPath -Parent)) | Out-Null if (Test-Path -LiteralPath $destinationPath) { $existing = Get-TrustedPath ` $destinationPath 'Existing canonical resource' -AllowHardLink if ((Get-FileHash -LiteralPath $existing -Algorithm SHA256).Hash -cne [string]$entry.sha256) { throw 'Tracked canonical resource differs from the hosted build receipt.' } } else { [IO.File]::Copy($sourcePath, $destinationPath, $false) } $materialized = Get-Item -LiteralPath $destinationPath -Force $materializedEntries.Add([pscustomobject][ordered]@{ path = $relative size = [long]$materialized.Length sha256 = (Get-FileHash -LiteralPath $destinationPath -Algorithm SHA256).Hash }) } if (@(Get-ChildItem -LiteralPath $canonicalResourcesRoot -Recurse -Force -File).Count -ne $resourceEntries.Count -or (Get-WaggleInventorySha256 @($materializedEntries)) -cne [string]$receipt.resourcesInventory.sha256) { throw 'Materialized canonical repository resources differ from the hosted build receipt.' } $canonicalService = Get-TrustedPath ` (Join-Path $canonicalResourcesRoot 'service.js') ` 'Canonical source-bound service.js' "unsigned_root=$unsignedRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "signing_root=$signingRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "receipt_path=$signingReceiptForPackage" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "source_service_path=$canonicalService" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Provision pinned Artifact Signing client package id: artifact-signing-package shell: pwsh run: | $packagePath = Join-Path $env:RUNNER_TEMP 'Microsoft.ArtifactSigning.Client.1.0.128.nupkg' if (Test-Path -LiteralPath $packagePath) { throw 'Artifact Signing package destination already exists.' } Invoke-WebRequest ` -Uri 'https://www.nuget.org/api/v2/package/Microsoft.ArtifactSigning.Client/1.0.128' ` -OutFile $packagePath ` -UseBasicParsing $actualSha256 = (Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash if ($actualSha256 -cne '74BD7D27E6CE1051409C38D9B46BC8DF0400ECD643D51FFBF2AC00869061E40B') { throw 'Official Artifact Signing package does not match the repository-pinned SHA-256.' } "package_path=$packagePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Validate protected Azure OIDC bindings shell: pwsh env: AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} run: | foreach ($entry in @( @('AZURE_CLIENT_ID', [string]$env:AZURE_CLIENT_ID), @('AZURE_TENANT_ID', [string]$env:AZURE_TENANT_ID), @('AZURE_SUBSCRIPTION_ID', [string]$env:AZURE_SUBSCRIPTION_ID) )) { if ([string]$entry[1] -cnotmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' -or [string]$entry[1] -cne ([string]$entry[1]).Trim()) { throw "$([string]$entry[0]) must be one exact GUID in the protected signing environment." } } - name: Provision pinned portable signing toolchain id: portable-toolchain shell: pwsh run: | $stageRoot = Join-Path $env:RUNNER_TEMP 'waggle-portable-signing-stage' $root = Join-Path $stageRoot 'toolchain' if (Test-Path -LiteralPath $root) { throw 'Portable signing toolchain root already exists.' } . ./app/scripts/sign-windows-artifact.ps1 $root = New-PrivateDirectory $root $downloads = Join-Path $stageRoot 'downloads' New-Item -ItemType Directory -Path $downloads | Out-Null $packages = @( [pscustomobject]@{ Name = 'node.zip' Uri = 'https://nodejs.org/dist/v22.22.2/node-v22.22.2-win-x64.zip' Sha256 = '7C93E9D92BF68C07182B471AA187E35EE6CD08EF0F24AB060DFFF605FCC1C57C' }, [pscustomobject]@{ Name = 'mingit.zip' Uri = 'https://github.com/git-for-windows/git/releases/download/v2.51.0.windows.1/MinGit-2.51.0-64-bit.zip' Sha256 = 'C2C955A21FA99889D83F485F24FA5D9A38FFFC2D509D4022385510E11C26B250' }, [pscustomobject]@{ Name = 'sevenzip.exe' Uri = 'https://www.7-zip.org/a/7z2501-x64.exe' Sha256 = '78AFA2A1C773CAF3CF7EDF62F857D2A8A5DA55FB0FFF5DA416074C0D28B2B55F' } ) foreach ($package in $packages) { $path = Join-Path $downloads $package.Name Invoke-WebRequest -Uri $package.Uri -OutFile $path -UseBasicParsing if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne $package.Sha256) { throw "Portable signing package has the wrong SHA-256: $($package.Name)" } } $nodeRoot = Join-Path $root 'node' $gitRoot = Join-Path $root 'git' $sevenZipRoot = Join-Path $root 'sevenzip' Expand-Archive -LiteralPath (Join-Path $downloads 'node.zip') -DestinationPath $nodeRoot Expand-Archive -LiteralPath (Join-Path $downloads 'mingit.zip') -DestinationPath $gitRoot New-Item -ItemType Directory -Path $sevenZipRoot | Out-Null & 'C:\Windows\System32\tar.exe' ` -xf (Join-Path $downloads 'sevenzip.exe') ` -C $sevenZipRoot if ($LASTEXITCODE -ne 0) { throw 'Pinned 7-Zip package extraction failed.' } $nodePath = Join-Path $nodeRoot 'node-v22.22.2-win-x64\node.exe' $gitPath = Join-Path $gitRoot 'cmd\git.exe' $gitRuntimePath = Join-Path $gitRoot 'mingw64\bin\git.exe' $sevenZipPath = Join-Path $sevenZipRoot '7z.exe' $sevenZipDllPath = Join-Path $sevenZipRoot '7z.dll' $innerFiles = @( @($nodePath, 'AE1A50511BE58E987483FDBC12125407443926D2D394669ADE2352776E920DD3'), @($gitPath, '34A408843194BE320D8A87A3C12CD5C7D2E08D03B24567A41DB32E21D12569D2'), @($gitRuntimePath, '755D4896D35663D0FF08924F84507F35236B83D240635B512C519BF43CC71A87'), @($sevenZipPath, '4CD7D776C686427226A151789D2D61F0B2ED2C392148CC4E69C0238362FAFECF'), @($sevenZipDllPath, '5BD20FB38499D95C39594F41D4781B6181B3304B7F1F4D06B0182F514E7EAA74') ) foreach ($binding in $innerFiles) { $path = [IO.Path]::GetFullPath([string]$binding[0]) if (-not $path.StartsWith( [IO.Path]::GetFullPath($root).TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase ) -or -not (Test-Path -LiteralPath $path -PathType Leaf) -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne [string]$binding[1]) { throw 'Portable signing toolchain inner binary does not match its pinned identity.' } } $nodeSignature = Get-AuthenticodeSignature -LiteralPath $nodePath $gitSignature = Get-AuthenticodeSignature -LiteralPath $gitPath $gitRuntimeSignature = Get-AuthenticodeSignature -LiteralPath $gitRuntimePath if ($nodeSignature.Status -ne 'Valid' -or [string]$nodeSignature.SignerCertificate.Subject -cne 'CN=OpenJS Foundation, O=OpenJS Foundation, L=San Francisco, S=California, C=US' -or $gitSignature.Status -ne 'Valid' -or [string]$gitSignature.SignerCertificate.Subject -cne 'CN=Johannes Schindelin, O=Johannes Schindelin, S=Nordrhein-Westfalen, C=DE' -or $gitRuntimeSignature.Status -ne 'Valid' -or [string]$gitRuntimeSignature.SignerCertificate.Subject -cne 'CN=Johannes Schindelin, O=Johannes Schindelin, S=Nordrhein-Westfalen, C=DE') { throw 'Portable Node.js or Git lacks its approved Authenticode publisher evidence.' } $allowedFiles = @( Get-ChildItem -LiteralPath $root -Recurse -Force | Where-Object { -not $_.PSIsContainer } ) foreach ($file in $allowedFiles) { if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or @(Get-Item -LiteralPath $file.FullName -Stream *).Count -ne 1) { throw "Portable signing toolchain contains a linked file or alternate data stream: $($file.FullName)" } } $toolchainInventory = New-WagglePrebuiltInventory -Root $root if (@($toolchainInventory.entries).Count -ne 2495 -or [string]$toolchainInventory.sha256 -cne 'D64F897D4E1A7F07FE9BA62D6AF062EF9F0E41C595CDAF2F3C4F73991BBEA0F5') { throw 'Portable signing toolchain differs from the pinned official extracted closure.' } $receiptPath = Join-Path $stageRoot 'portable-toolchain-receipt.json' if (Test-Path -LiteralPath $receiptPath) { throw 'Portable signing toolchain receipt destination already exists.' } $toolchainReceipt = [ordered]@{ schemaVersion = 1 portableToolchainRoot = [IO.Path]::GetFullPath($root) archives = [ordered]@{ node = [ordered]@{ path = [IO.Path]::GetFullPath((Join-Path $downloads 'node.zip')) sha256 = '7C93E9D92BF68C07182B471AA187E35EE6CD08EF0F24AB060DFFF605FCC1C57C' } git = [ordered]@{ path = [IO.Path]::GetFullPath((Join-Path $downloads 'mingit.zip')) sha256 = 'C2C955A21FA99889D83F485F24FA5D9A38FFFC2D509D4022385510E11C26B250' } sevenZip = [ordered]@{ path = [IO.Path]::GetFullPath((Join-Path $downloads 'sevenzip.exe')) sha256 = '78AFA2A1C773CAF3CF7EDF62F857D2A8A5DA55FB0FFF5DA416074C0D28B2B55F' } } inventory = $toolchainInventory } Write-WaggleJsonNoBom $receiptPath $toolchainReceipt $receiptSha256 = (Get-FileHash -LiteralPath $receiptPath -Algorithm SHA256).Hash if ($receiptSha256 -notmatch '^[0-9A-F]{64}$') { throw 'Portable signing toolchain receipt digest is invalid.' } "root=$root" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "node_path=$nodePath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "git_path=$gitPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "sevenzip_path=$sevenZipPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "receipt_path=$receiptPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "receipt_sha256=$receiptSha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Stage fully inventoried Windows signing preparation id: stage-prepared shell: pwsh env: WAGGLE_RELEASE_MODE: ${{ steps.release-mode.outputs.mode }} WAGGLE_CANDIDATE_VERSION: ${{ steps.release-mode.outputs.candidate_version }} WINDOWS_BOOTSTRAP_RELEASE_IDENTITY: ${{ steps.release-mode.outputs.bootstrap_identity }} WINDOWS_UPGRADE_BASE_TAG: ${{ steps.release-mode.outputs.upgrade_base_tag }} WINDOWS_UPGRADE_BASE_ASSET_NAME: ${{ steps.release-mode.outputs.upgrade_base_asset_name }} WINDOWS_UPGRADE_BASE_SHA256: ${{ steps.release-mode.outputs.upgrade_base_sha256 }} WINDOWS_UPGRADE_BASE_COMMIT: ${{ steps.release-mode.outputs.upgrade_base_commit }} WINDOWS_CODESIGN_APPROVED_SUBJECT: ${{ steps.signer-identity.outputs.subject }} AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} EXPECTED_BUILD_RECEIPT_SHA256: ${{ needs.build-windows-prebuilt.outputs.build_receipt_sha256 }} EXPECTED_BUILD_ARTIFACT_ID: ${{ needs.build-windows-prebuilt.outputs.artifact_id }} EXPECTED_BUILD_ARTIFACT_DIGEST: ${{ needs.build-windows-prebuilt.outputs.artifact_digest }} EXPECTED_BUILD_HANDOFF_SIZE_BYTES: ${{ needs.build-windows-prebuilt.outputs.handoff_size_bytes }} run: | function Write-WaggleJsonNoBom { param([string]$Path, [object]$Value) [IO.File]::WriteAllText( $Path, ($Value | ConvertTo-Json -Depth 32), [Text.UTF8Encoding]::new($false) ) } function Copy-WagglePreparedItem { param([string]$Source, [string]$Destination, [string]$Kind = 'Leaf') if (-not (Test-Path -LiteralPath $Source -PathType $Kind) -or (Test-Path -LiteralPath $Destination)) { throw "Signing preparation source is missing or destination already exists: $Destination" } [IO.Directory]::CreateDirectory((Split-Path $Destination -Parent)) | Out-Null Copy-Item -LiteralPath $Source -Destination $Destination -Recurse -ErrorAction Stop } function Assert-WagglePreparedTree { param([string]$Root, [string]$Label) $trustedRoot = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $Root).Path) $items = @(Get-ChildItem -LiteralPath $trustedRoot -Recurse -Force) $maxItems = [int]$env:WINDOWS_SIGNING_TRANSPORT_MAX_ITEMS if ($items.Count -gt $maxItems) { throw "$Label exceeds the bounded $maxItems-item transport envelope." } foreach ($item in $items) { $linkProperty = $item.PSObject.Properties['LinkType'] $linkType = if ($null -eq $linkProperty) { '' } else { [string]$linkProperty.Value } if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or -not [string]::IsNullOrEmpty($linkType)) { throw "$Label contains a linked or reparse filesystem object." } if (-not $item.PSIsContainer -and @(Get-Item -LiteralPath $item.FullName -Stream *).Count -ne 1) { throw "$Label contains a file with an alternate data stream." } } } foreach ($digest in @( [string]$env:EXPECTED_BUILD_RECEIPT_SHA256, [string]$env:EXPECTED_BUILD_ARTIFACT_DIGEST )) { if ($digest -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Signing preparation received an invalid upstream digest.' } } if ($env:EXPECTED_BUILD_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$' -or $env:EXPECTED_BUILD_HANDOFF_SIZE_BYTES -cnotmatch '^[1-9][0-9]*$') { throw 'Signing preparation received an invalid upstream artifact identity or size.' } . ./app/scripts/sign-windows-artifact.ps1 $preparedRoot = Join-Path $env:RUNNER_TEMP 'waggle-windows-signing-preparation' $payloadRoot = Join-Path $preparedRoot 'payload' if (Test-Path -LiteralPath $preparedRoot) { throw 'Windows signing preparation root already exists.' } [IO.Directory]::CreateDirectory($payloadRoot) | Out-Null foreach ($variant in @('unsigned', 'signing')) { $sourceRoot = Join-Path $env:RUNNER_TEMP "waggle-prebuilt-$variant" $destinationRoot = Join-Path $payloadRoot "raw\$variant" foreach ($relative in @('build-receipt.json', 'prebuilt', 'nsis-toolchain')) { $kind = if ($relative -eq 'build-receipt.json') { 'Leaf' } else { 'Container' } Copy-WagglePreparedItem ` (Join-Path $sourceRoot $relative) (Join-Path $destinationRoot $relative) $kind } } $artifactSigningDestination = Join-Path $payloadRoot ` 'artifact-signing\Microsoft.ArtifactSigning.Client.1.0.128.nupkg' Copy-WagglePreparedItem ` '${{ steps.artifact-signing-package.outputs.package_path }}' ` $artifactSigningDestination if ((Get-FileHash -LiteralPath $artifactSigningDestination -Algorithm SHA256).Hash -cne '74BD7D27E6CE1051409C38D9B46BC8DF0400ECD643D51FFBF2AC00869061E40B') { throw 'Staged Artifact Signing package differs from its pinned identity.' } $portableSourceRoot = '${{ steps.portable-toolchain.outputs.root }}' $portableSourceReceiptPath = '${{ steps.portable-toolchain.outputs.receipt_path }}' $portableSourceReceipt = Get-Content -Raw -LiteralPath $portableSourceReceiptPath | ConvertFrom-Json -Depth 32 if ([int]$portableSourceReceipt.schemaVersion -ne 1 -or [string]$portableSourceReceipt.portableToolchainRoot -cne [IO.Path]::GetFullPath($portableSourceRoot) -or @($portableSourceReceipt.inventory.entries).Count -ne 2495 -or [string]$portableSourceReceipt.inventory.sha256 -cne 'D64F897D4E1A7F07FE9BA62D6AF062EF9F0E41C595CDAF2F3C4F73991BBEA0F5') { throw 'Portable signing toolchain source receipt is not the pinned semantic closure.' } Copy-WagglePreparedItem ` $portableSourceRoot (Join-Path $payloadRoot 'portable\toolchain') 'Container' $portableDownloadsSource = Join-Path (Split-Path $portableSourceRoot -Parent) 'downloads' Copy-WagglePreparedItem ` $portableDownloadsSource (Join-Path $payloadRoot 'portable\downloads') 'Container' $relativePortableReceipt = [ordered]@{ schemaVersion = 1 portableToolchainRoot = 'portable\toolchain' archives = [ordered]@{ node = [ordered]@{ path = 'portable\downloads\node.zip' sha256 = '7C93E9D92BF68C07182B471AA187E35EE6CD08EF0F24AB060DFFF605FCC1C57C' } git = [ordered]@{ path = 'portable\downloads\mingit.zip' sha256 = 'C2C955A21FA99889D83F485F24FA5D9A38FFFC2D509D4022385510E11C26B250' } sevenZip = [ordered]@{ path = 'portable\downloads\sevenzip.exe' sha256 = '78AFA2A1C773CAF3CF7EDF62F857D2A8A5DA55FB0FFF5DA416074C0D28B2B55F' } } inventory = $portableSourceReceipt.inventory } Write-WaggleJsonNoBom ` (Join-Path $payloadRoot 'portable\portable-toolchain-receipt.json') ` $relativePortableReceipt $tauriDestination = Join-Path $payloadRoot 'tauri\node_modules\@tauri-apps' foreach ($packageName in @('cli', 'cli-win32-x64-msvc')) { $sourcePackage = Join-Path 'app\node_modules\@tauri-apps' $packageName $package = Get-Content -Raw -LiteralPath (Join-Path $sourcePackage 'package.json') | ConvertFrom-Json if ([string]$package.name -cne "@tauri-apps/$packageName" -or [string]$package.version -cne '2.10.1') { throw "Prepared Tauri package is not the locked Windows package: $packageName" } Copy-WagglePreparedItem ` $sourcePackage (Join-Path $tauriDestination $packageName) 'Container' } Assert-WagglePreparedTree $payloadRoot 'Windows signing preparation payload' $payloadInventory = New-WagglePrebuiltInventory -Root $payloadRoot $allowedPrefixes = @( 'raw\unsigned\', 'raw\signing\', 'portable\toolchain\', 'portable\downloads\', 'tauri\node_modules\@tauri-apps\cli\', 'tauri\node_modules\@tauri-apps\cli-win32-x64-msvc\' ) $allowedExact = @( 'artifact-signing\Microsoft.ArtifactSigning.Client.1.0.128.nupkg', 'portable\portable-toolchain-receipt.json' ) foreach ($entry in @($payloadInventory.entries)) { $relative = [string]$entry.path $allowed = $allowedExact -ccontains $relative foreach ($prefix in $allowedPrefixes) { if ($relative.StartsWith($prefix, [StringComparison]::Ordinal)) { $allowed = $true break } } if (-not $allowed) { throw "Windows signing preparation contains a non-allowlisted payload path: $relative" } } $preparationReceipt = [ordered]@{ schemaVersion = 1 repository = 'marolinik/waggle-os' sourceRevision = $env:GITHUB_SHA candidateTag = $env:GITHUB_REF_NAME candidateVersion = $env:WAGGLE_CANDIDATE_VERSION workflowRef = $env:GITHUB_WORKFLOW_REF workflowSha = $env:GITHUB_WORKFLOW_SHA release = [ordered]@{ mode = $env:WAGGLE_RELEASE_MODE bootstrapIdentity = $env:WINDOWS_BOOTSTRAP_RELEASE_IDENTITY upgradeBaseTag = $env:WINDOWS_UPGRADE_BASE_TAG upgradeBaseAssetName = $env:WINDOWS_UPGRADE_BASE_ASSET_NAME upgradeBaseSha256 = $env:WINDOWS_UPGRADE_BASE_SHA256 upgradeBaseCommit = $env:WINDOWS_UPGRADE_BASE_COMMIT } signerSubject = $env:WINDOWS_CODESIGN_APPROVED_SUBJECT build = [ordered]@{ receiptSha256 = $env:EXPECTED_BUILD_RECEIPT_SHA256.ToUpperInvariant() artifactId = $env:EXPECTED_BUILD_ARTIFACT_ID artifactDigest = $env:EXPECTED_BUILD_ARTIFACT_DIGEST.ToUpperInvariant() handoffSizeBytes = $env:EXPECTED_BUILD_HANDOFF_SIZE_BYTES } azure = [ordered]@{ clientId = $env:AZURE_CLIENT_ID tenantId = $env:AZURE_TENANT_ID subscriptionId = $env:AZURE_SUBSCRIPTION_ID audience = 'api://AzureADTokenExchange' } payloadInventory = $payloadInventory } $preparationReceiptPath = Join-Path $preparedRoot 'preparation-receipt.json' Write-WaggleJsonNoBom $preparationReceiptPath $preparationReceipt Assert-WagglePreparedTree $preparedRoot 'Windows signing preparation artifact' $receiptSha256 = (Get-FileHash -LiteralPath $preparationReceiptPath -Algorithm SHA256).Hash $sizeBytes = [long]( Get-ChildItem -LiteralPath $preparedRoot -Recurse -Force -File | Measure-Object -Property Length -Sum ).Sum if ($receiptSha256 -cnotmatch '^[0-9A-F]{64}$' -or $sizeBytes -le 0) { throw 'Windows signing preparation receipt or aggregate size is invalid.' } "receipt_sha256=$receiptSha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "size_bytes=$sizeBytes" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Upload immutable prepared Windows signing handoff id: upload-prepared uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: waggle-windows-signing-prepared-${{ github.sha }} path: ${{ runner.temp }}\waggle-windows-signing-preparation if-no-files-found: error include-hidden-files: true retention-days: 7 sign-windows: needs: [build-windows-prebuilt, prepare-windows-signing] runs-on: windows-latest permissions: contents: read id-token: write outputs: release_mode: ${{ needs.prepare-windows-signing.outputs.release_mode }} candidate_sha256: ${{ steps.stage-signed.outputs.candidate_sha256 }} candidate_version: ${{ steps.stage-signed.outputs.candidate_version }} bootstrap_identity: ${{ needs.prepare-windows-signing.outputs.bootstrap_identity }} upgrade_base_tag: ${{ needs.prepare-windows-signing.outputs.upgrade_base_tag }} upgrade_base_asset_name: ${{ needs.prepare-windows-signing.outputs.upgrade_base_asset_name }} upgrade_base_sha256: ${{ needs.prepare-windows-signing.outputs.upgrade_base_sha256 }} upgrade_base_commit: ${{ needs.prepare-windows-signing.outputs.upgrade_base_commit }} signer_subject: ${{ needs.prepare-windows-signing.outputs.signer_subject }} build_receipt_sha256: ${{ needs.build-windows-prebuilt.outputs.build_receipt_sha256 }} build_artifact_id: ${{ needs.build-windows-prebuilt.outputs.artifact_id }} build_artifact_digest: ${{ needs.build-windows-prebuilt.outputs.artifact_digest }} handoff_receipt_sha256: ${{ steps.stage-signed.outputs.receipt_sha256 }} artifact_id: ${{ steps.upload-signed.outputs.artifact-id }} artifact_digest: ${{ steps.upload-signed.outputs.artifact-digest }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 persist-credentials: false - name: Validate exact hosted OIDC release boundary shell: pwsh run: | $version = [string](Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' | ConvertFrom-Json).version if ($env:GITHUB_ACTIONS -cne 'true' -or $env:GITHUB_EVENT_NAME -cne 'push' -or $env:GITHUB_REPOSITORY -cne 'marolinik/waggle-os' -or $env:GITHUB_REF_TYPE -cne 'tag' -or $env:GITHUB_REF -cne "refs/tags/v$version" -or $env:GITHUB_REF_NAME -cne "v$version" -or $env:GITHUB_WORKFLOW_REF -cne "marolinik/waggle-os/.github/workflows/release.yml@$env:GITHUB_REF" -or $env:GITHUB_WORKFLOW_SHA -cne $env:GITHUB_SHA -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:GITHUB_SHA -cnotmatch '^[0-9a-f]{40}$') { throw 'Windows signing requires the exact hosted push, repository, tag, workflow, and revision boundary.' } $checkedOutRevision = (git rev-parse --verify HEAD).Trim().ToLowerInvariant() if ($LASTEXITCODE -ne 0 -or $checkedOutRevision -cne $env:GITHUB_SHA -or @(git status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'Windows signing checkout is not the clean GITHUB_SHA revision.' } - name: Validate initial signing revision on fresh origin main shell: pwsh run: | $checkedOutRevision = (git rev-parse --verify HEAD).Trim().ToLowerInvariant() $remoteMainRevision = (git rev-parse --verify refs/remotes/origin/main).Trim().ToLowerInvariant() if ($LASTEXITCODE -ne 0 -or $checkedOutRevision -cne $env:GITHUB_SHA -or $env:GITHUB_WORKFLOW_SHA -cne $env:GITHUB_SHA -or $remoteMainRevision -cnotmatch '^[0-9a-f]{40}$' -or @(git status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'Initial signing checkout, workflow, or origin/main revision evidence is invalid.' } git merge-base --is-ancestor $env:GITHUB_SHA refs/remotes/origin/main if ($LASTEXITCODE -ne 0) { throw 'The exact signing revision is not contained by initial fresh origin/main.' } - name: Download immutable prepared Windows signing handoff uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: artifact-ids: ${{ needs.prepare-windows-signing.outputs.artifact_id }} path: ${{ runner.temp }}\waggle-windows-signing-prepared merge-multiple: true - name: Verify and restore immutable prepared Windows signing handoff id: verify-prepared shell: pwsh env: EXPECTED_PREPARATION_RECEIPT_SHA256: ${{ needs.prepare-windows-signing.outputs.preparation_receipt_sha256 }} EXPECTED_PREPARATION_ARTIFACT_ID: ${{ needs.prepare-windows-signing.outputs.artifact_id }} EXPECTED_PREPARATION_ARTIFACT_DIGEST: ${{ needs.prepare-windows-signing.outputs.artifact_digest }} EXPECTED_PREPARATION_SIZE_BYTES: ${{ needs.prepare-windows-signing.outputs.preparation_size_bytes }} EXPECTED_RELEASE_MODE: ${{ needs.prepare-windows-signing.outputs.release_mode }} EXPECTED_CANDIDATE_VERSION: ${{ needs.prepare-windows-signing.outputs.candidate_version }} EXPECTED_BOOTSTRAP_IDENTITY: ${{ needs.prepare-windows-signing.outputs.bootstrap_identity }} EXPECTED_UPGRADE_BASE_TAG: ${{ needs.prepare-windows-signing.outputs.upgrade_base_tag }} EXPECTED_UPGRADE_BASE_ASSET_NAME: ${{ needs.prepare-windows-signing.outputs.upgrade_base_asset_name }} EXPECTED_UPGRADE_BASE_SHA256: ${{ needs.prepare-windows-signing.outputs.upgrade_base_sha256 }} EXPECTED_UPGRADE_BASE_COMMIT: ${{ needs.prepare-windows-signing.outputs.upgrade_base_commit }} EXPECTED_SIGNER_SUBJECT: ${{ needs.prepare-windows-signing.outputs.signer_subject }} EXPECTED_BUILD_RECEIPT_SHA256: ${{ needs.build-windows-prebuilt.outputs.build_receipt_sha256 }} EXPECTED_BUILD_ARTIFACT_ID: ${{ needs.build-windows-prebuilt.outputs.artifact_id }} EXPECTED_BUILD_ARTIFACT_DIGEST: ${{ needs.build-windows-prebuilt.outputs.artifact_digest }} EXPECTED_BUILD_HANDOFF_SIZE_BYTES: ${{ needs.build-windows-prebuilt.outputs.handoff_size_bytes }} AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} run: | function Write-WaggleJsonNoBom { param([string]$Path, [object]$Value) [IO.File]::WriteAllText( $Path, ($Value | ConvertTo-Json -Depth 32), [Text.UTF8Encoding]::new($false) ) } function Assert-WaggleTransportTree { param([string]$Root, [string]$Label) $items = @(Get-ChildItem -LiteralPath $Root -Recurse -Force) $maxItems = [int]$env:WINDOWS_SIGNING_TRANSPORT_MAX_ITEMS if ($items.Count -gt $maxItems) { throw "$Label exceeds the bounded $maxItems-item transport envelope." } foreach ($item in $items) { $linkProperty = $item.PSObject.Properties['LinkType'] $linkType = if ($null -eq $linkProperty) { '' } else { [string]$linkProperty.Value } if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or -not [string]::IsNullOrEmpty($linkType)) { throw "$Label contains a linked or reparse filesystem object." } if (-not $item.PSIsContainer -and @(Get-Item -LiteralPath $item.FullName -Stream *).Count -ne 1) { throw "$Label contains a file with an alternate data stream." } } } function Assert-WaggleExactValue { param([object]$Actual, [string]$Expected, [string]$Label) if ([string]$Actual -cne $Expected) { throw "Prepared Windows signing binding differs: $Label" } } $preparedRoot = [IO.Path]::GetFullPath( (Resolve-Path -LiteralPath (Join-Path $env:RUNNER_TEMP 'waggle-windows-signing-prepared')).Path ) $payloadRoot = Join-Path $preparedRoot 'payload' $receiptPath = Join-Path $preparedRoot 'preparation-receipt.json' foreach ($digest in @( [string]$env:EXPECTED_PREPARATION_RECEIPT_SHA256, [string]$env:EXPECTED_PREPARATION_ARTIFACT_DIGEST, [string]$env:EXPECTED_BUILD_RECEIPT_SHA256, [string]$env:EXPECTED_BUILD_ARTIFACT_DIGEST )) { if ($digest -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Prepared Windows signing digest binding is invalid.' } } if ($env:EXPECTED_PREPARATION_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$' -or $env:EXPECTED_BUILD_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$' -or $env:EXPECTED_PREPARATION_SIZE_BYTES -cnotmatch '^[1-9][0-9]*$') { throw 'Prepared Windows signing artifact identity or size binding is invalid.' } Assert-WaggleTransportTree $preparedRoot 'Prepared Windows signing artifact' $actualSizeBytes = [long]( Get-ChildItem -LiteralPath $preparedRoot -Recurse -Force -File | Measure-Object -Property Length -Sum ).Sum if (-not (Test-Path -LiteralPath $payloadRoot -PathType Container) -or -not (Test-Path -LiteralPath $receiptPath -PathType Leaf) -or $actualSizeBytes -ne [long]$env:EXPECTED_PREPARATION_SIZE_BYTES -or (Get-FileHash -LiteralPath $receiptPath -Algorithm SHA256).Hash -cne $env:EXPECTED_PREPARATION_RECEIPT_SHA256.ToUpperInvariant()) { throw 'Prepared Windows signing artifact differs from its immutable receipt or size.' } . ./app/scripts/sign-windows-artifact.ps1 $receipt = Get-Content -Raw -LiteralPath $receiptPath | ConvertFrom-Json -Depth 32 if ([int]$receipt.schemaVersion -ne 1) { throw 'Prepared Windows signing receipt schema is invalid.' } Assert-WaggleExactValue $receipt.repository 'marolinik/waggle-os' 'repository' Assert-WaggleExactValue $receipt.sourceRevision $env:GITHUB_SHA 'sourceRevision' Assert-WaggleExactValue $receipt.candidateTag $env:GITHUB_REF_NAME 'candidateTag' Assert-WaggleExactValue $receipt.candidateVersion $env:EXPECTED_CANDIDATE_VERSION 'candidateVersion' Assert-WaggleExactValue $receipt.workflowRef $env:GITHUB_WORKFLOW_REF 'workflowRef' Assert-WaggleExactValue $receipt.workflowSha $env:GITHUB_WORKFLOW_SHA 'workflowSha' Assert-WaggleExactValue $receipt.release.mode $env:EXPECTED_RELEASE_MODE 'release.mode' Assert-WaggleExactValue $receipt.release.bootstrapIdentity $env:EXPECTED_BOOTSTRAP_IDENTITY 'release.bootstrapIdentity' Assert-WaggleExactValue $receipt.release.upgradeBaseTag $env:EXPECTED_UPGRADE_BASE_TAG 'release.upgradeBaseTag' Assert-WaggleExactValue $receipt.release.upgradeBaseAssetName $env:EXPECTED_UPGRADE_BASE_ASSET_NAME 'release.upgradeBaseAssetName' Assert-WaggleExactValue $receipt.release.upgradeBaseSha256 $env:EXPECTED_UPGRADE_BASE_SHA256 'release.upgradeBaseSha256' Assert-WaggleExactValue $receipt.release.upgradeBaseCommit $env:EXPECTED_UPGRADE_BASE_COMMIT 'release.upgradeBaseCommit' Assert-WaggleExactValue $receipt.signerSubject $env:EXPECTED_SIGNER_SUBJECT 'signerSubject' Assert-WaggleExactValue $receipt.build.receiptSha256 $env:EXPECTED_BUILD_RECEIPT_SHA256.ToUpperInvariant() 'build.receiptSha256' Assert-WaggleExactValue $receipt.build.artifactId $env:EXPECTED_BUILD_ARTIFACT_ID 'build.artifactId' Assert-WaggleExactValue $receipt.build.artifactDigest $env:EXPECTED_BUILD_ARTIFACT_DIGEST.ToUpperInvariant() 'build.artifactDigest' Assert-WaggleExactValue $receipt.build.handoffSizeBytes $env:EXPECTED_BUILD_HANDOFF_SIZE_BYTES 'build.handoffSizeBytes' Assert-WaggleExactValue $receipt.azure.clientId $env:AZURE_CLIENT_ID 'azure.clientId' Assert-WaggleExactValue $receipt.azure.tenantId $env:AZURE_TENANT_ID 'azure.tenantId' Assert-WaggleExactValue $receipt.azure.subscriptionId $env:AZURE_SUBSCRIPTION_ID 'azure.subscriptionId' Assert-WaggleExactValue $receipt.azure.audience 'api://AzureADTokenExchange' 'azure.audience' $actualPayloadInventory = New-WagglePrebuiltInventory -Root $payloadRoot if (($actualPayloadInventory | ConvertTo-Json -Depth 32 -Compress) -cne ($receipt.payloadInventory | ConvertTo-Json -Depth 32 -Compress)) { throw 'Prepared Windows signing payload differs from its canonical inventory.' } $unsignedRoot = Get-TrustedPath ` (Join-Path $payloadRoot 'raw\unsigned\prebuilt') 'Prepared unsigned root' 'Container' $signingRoot = Get-TrustedPath ` (Join-Path $payloadRoot 'raw\signing\prebuilt') 'Prepared signing root' 'Container' $unsignedReceipt = Get-TrustedPath ` (Join-Path $payloadRoot 'raw\unsigned\build-receipt.json') 'Prepared unsigned build receipt' $signingReceipt = Get-TrustedPath ` (Join-Path $payloadRoot 'raw\signing\build-receipt.json') 'Prepared signing build receipt' foreach ($buildReceiptPath in @($unsignedReceipt, $signingReceipt)) { if ((Get-FileHash -LiteralPath $buildReceiptPath -Algorithm SHA256).Hash -cne $env:EXPECTED_BUILD_RECEIPT_SHA256.ToUpperInvariant()) { throw 'Prepared raw handoff receipt differs from the immutable build receipt.' } } $buildReceipt = Get-Content -Raw -LiteralPath $unsignedReceipt | ConvertFrom-Json -Depth 32 foreach ($rawRoot in @($unsignedRoot, $signingRoot)) { $rawInventory = New-WagglePrebuiltInventory -Root $rawRoot if (($rawInventory | ConvertTo-Json -Depth 32 -Compress) -cne ($buildReceipt.targetInventory | ConvertTo-Json -Depth 32 -Compress)) { throw 'Prepared raw prebuilt root differs from the hosted build receipt.' } } $expectedNsisAggregate = '1FC822D1A183552A80ADEA01B0BF456F462B90518256EF1FE9EDFA22D76CD85A' foreach ($nsisRoot in @( (Join-Path $payloadRoot 'raw\unsigned\nsis-toolchain'), (Join-Path $payloadRoot 'raw\signing\nsis-toolchain') )) { $nsisInventory = New-WagglePrebuiltInventory -Root $nsisRoot if (@($nsisInventory.entries).Count -ne 442 -or [string]$nsisInventory.sha256 -cne $expectedNsisAggregate -or ($nsisInventory | ConvertTo-Json -Depth 8 -Compress) -cne ($buildReceipt.nsisInventory | ConvertTo-Json -Depth 8 -Compress)) { throw 'Prepared NSIS toolchain differs from the hosted build receipt.' } } $installedNsisRoot = Join-Path $env:LOCALAPPDATA 'tauri\NSIS' if (Test-Path -LiteralPath $installedNsisRoot) { throw 'Fresh OIDC signer unexpectedly already contains a Tauri NSIS closure.' } [IO.Directory]::CreateDirectory((Split-Path $installedNsisRoot -Parent)) | Out-Null Copy-Item -LiteralPath (Join-Path $payloadRoot 'raw\unsigned\nsis-toolchain') ` -Destination $installedNsisRoot -Recurse -ErrorAction Stop $installedNsisInventory = New-WagglePrebuiltInventory -Root $installedNsisRoot if (($installedNsisInventory | ConvertTo-Json -Depth 8 -Compress) -cne ($buildReceipt.nsisInventory | ConvertTo-Json -Depth 8 -Compress)) { throw 'Restored signer NSIS closure differs from the hosted build receipt.' } $resourceEntries = @($buildReceipt.resourcesInventory.entries) Assert-WaggleCanonicalInventoryEntries $resourceEntries 'Prepared resources inventory' $sourceResourcesRoot = Get-TrustedPath ` (Join-Path $unsignedRoot 'resources') 'Prepared source resources' 'Container' $signingResourcesRoot = Get-TrustedPath ` (Join-Path $signingRoot 'resources') 'Prepared signing resources' 'Container' $canonicalResourcesRoot = Get-TrustedPath ` ([IO.Path]::GetFullPath('app/src-tauri/resources')) 'Canonical repository resources' 'Container' $canonicalPrefix = $canonicalResourcesRoot.TrimEnd('\') + '\' $materializedEntries = [Collections.Generic.List[object]]::new() foreach ($entry in $resourceEntries) { $relative = [string]$entry.path $sourcePath = Get-TrustedPath ` (Join-Path $sourceResourcesRoot $relative) 'Prepared resource file' -AllowHardLink $signingPath = Get-TrustedPath ` (Join-Path $signingResourcesRoot $relative) 'Prepared signing resource file' -AllowHardLink foreach ($path in @($sourcePath, $signingPath)) { $item = Get-Item -LiteralPath $path -Force if ([long]$item.Length -ne [long]$entry.size -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne [string]$entry.sha256) { throw 'Prepared resource file differs from its hosted receipt.' } } $destination = [IO.Path]::GetFullPath((Join-Path $canonicalResourcesRoot $relative)) if (-not $destination.StartsWith($canonicalPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Prepared resource destination escaped the canonical repository root.' } [IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) | Out-Null if (Test-Path -LiteralPath $destination) { if ((Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash -cne [string]$entry.sha256) { throw 'Canonical repository resource differs from the prepared receipt.' } } else { [IO.File]::Copy($sourcePath, $destination, $false) } $materialized = Get-Item -LiteralPath $destination -Force $materializedEntries.Add([pscustomobject][ordered]@{ path = $relative size = [long]$materialized.Length sha256 = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash }) } if ((Get-WaggleInventorySha256 @($materializedEntries)) -cne [string]$buildReceipt.resourcesInventory.sha256) { throw 'Restored canonical resources differ from the prepared build receipt.' } $tauriSource = Join-Path $payloadRoot 'tauri\node_modules\@tauri-apps' $tauriDestination = [IO.Path]::GetFullPath('app\node_modules\@tauri-apps') if (Test-Path -LiteralPath (Split-Path $tauriDestination -Parent)) { throw 'Fresh OIDC signer unexpectedly already contains app node_modules.' } [IO.Directory]::CreateDirectory($tauriDestination) | Out-Null foreach ($packageName in @('cli', 'cli-win32-x64-msvc')) { Copy-Item -LiteralPath (Join-Path $tauriSource $packageName) ` -Destination $tauriDestination -Recurse -ErrorAction Stop } $restoredPackages = @( Get-ChildItem -LiteralPath $tauriDestination -Force -Directory | ForEach-Object { $_.Name } | Sort-Object ) if ([string]::Join("`n", $restoredPackages) -cne [string]::Join("`n", @('cli', 'cli-win32-x64-msvc'))) { throw 'OIDC signer restored more than the two approved Tauri CLI packages.' } $portableRelativeReceiptPath = Join-Path $payloadRoot ` 'portable\portable-toolchain-receipt.json' $portableRelativeReceipt = Get-Content -Raw -LiteralPath $portableRelativeReceiptPath | ConvertFrom-Json -Depth 32 if ([int]$portableRelativeReceipt.schemaVersion -ne 1 -or [string]$portableRelativeReceipt.portableToolchainRoot -cne 'portable\toolchain' -or [string]$portableRelativeReceipt.archives.node.path -cne 'portable\downloads\node.zip' -or [string]$portableRelativeReceipt.archives.git.path -cne 'portable\downloads\mingit.zip' -or [string]$portableRelativeReceipt.archives.sevenZip.path -cne 'portable\downloads\sevenzip.exe' -or @($portableRelativeReceipt.inventory.entries).Count -ne 2495 -or [string]$portableRelativeReceipt.inventory.sha256 -cne 'D64F897D4E1A7F07FE9BA62D6AF062EF9F0E41C595CDAF2F3C4F73991BBEA0F5') { throw 'Prepared portable toolchain receipt is not path-relative and repository-pinned.' } $portablePayloadRoot = Get-TrustedPath ` (Join-Path $payloadRoot 'portable\toolchain') 'Prepared portable toolchain' 'Container' $portableDownloads = Get-TrustedPath ` (Join-Path $payloadRoot 'portable\downloads') 'Restored portable downloads' 'Container' $portableActualInventory = New-WagglePrebuiltInventory -Root $portablePayloadRoot if (($portableActualInventory | ConvertTo-Json -Depth 32 -Compress) -cne ($portableRelativeReceipt.inventory | ConvertTo-Json -Depth 32 -Compress)) { throw 'Restored portable toolchain differs from its path-relative receipt.' } $relocatedReceiptRoot = Join-Path $env:RUNNER_TEMP 'waggle-portable-signing-restored' if (Test-Path -LiteralPath $relocatedReceiptRoot) { throw 'Relocated portable receipt root already exists.' } $relocatedReceiptRoot = New-PrivateDirectory $relocatedReceiptRoot $portableRoot = New-PrivateDirectory (Join-Path $relocatedReceiptRoot 'toolchain') foreach ($entry in @($portableRelativeReceipt.inventory.entries)) { $relative = [string]$entry.path $source = Get-TrustedPath ` (Join-Path $portablePayloadRoot $relative) 'Prepared portable toolchain file' $destination = Join-Path $portableRoot $relative [IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) | Out-Null [IO.File]::Copy($source, $destination, $false) } $restoredPortableInventory = New-WagglePrebuiltInventory -Root $portableRoot if (($restoredPortableInventory | ConvertTo-Json -Depth 32 -Compress) -cne ($portableRelativeReceipt.inventory | ConvertTo-Json -Depth 32 -Compress)) { throw 'Private restored portable toolchain differs from its path-relative receipt.' } $relocatedReceiptPath = Join-Path $relocatedReceiptRoot 'portable-toolchain-receipt.json' $relocatedReceipt = [ordered]@{ schemaVersion = 1 portableToolchainRoot = [IO.Path]::GetFullPath($portableRoot) archives = [ordered]@{ node = [ordered]@{ path = [IO.Path]::GetFullPath((Join-Path $portableDownloads 'node.zip')) sha256 = '7C93E9D92BF68C07182B471AA187E35EE6CD08EF0F24AB060DFFF605FCC1C57C' } git = [ordered]@{ path = [IO.Path]::GetFullPath((Join-Path $portableDownloads 'mingit.zip')) sha256 = 'C2C955A21FA99889D83F485F24FA5D9A38FFFC2D509D4022385510E11C26B250' } sevenZip = [ordered]@{ path = [IO.Path]::GetFullPath((Join-Path $portableDownloads 'sevenzip.exe')) sha256 = '78AFA2A1C773CAF3CF7EDF62F857D2A8A5DA55FB0FFF5DA416074C0D28B2B55F' } } inventory = $portableRelativeReceipt.inventory } Write-WaggleJsonNoBom $relocatedReceiptPath $relocatedReceipt $relocatedReceiptSha256 = (Get-FileHash -LiteralPath $relocatedReceiptPath -Algorithm SHA256).Hash $artifactSigningPackage = Get-TrustedPath ` (Join-Path $payloadRoot 'artifact-signing\Microsoft.ArtifactSigning.Client.1.0.128.nupkg') ` 'Prepared Artifact Signing package' if ((Get-FileHash -LiteralPath $artifactSigningPackage -Algorithm SHA256).Hash -cne '74BD7D27E6CE1051409C38D9B46BC8DF0400ECD643D51FFBF2AC00869061E40B') { throw 'Prepared Artifact Signing package differs from its pinned SHA-256.' } "unsigned_root=$unsignedRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "signing_root=$signingRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "build_receipt_path=$signingReceipt" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "artifact_signing_package=$artifactSigningPackage" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "portable_root=$portableRoot" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "portable_node=$([IO.Path]::GetFullPath((Join-Path $portableRoot 'node\node-v22.22.2-win-x64\node.exe')))" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "portable_git=$([IO.Path]::GetFullPath((Join-Path $portableRoot 'git\cmd\git.exe')))" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "portable_sevenzip=$([IO.Path]::GetFullPath((Join-Path $portableRoot 'sevenzip\7z.exe')))" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "portable_receipt_path=$relocatedReceiptPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "portable_receipt_sha256=$relocatedReceiptSha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Refresh exact signing repository refs uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: marolinik/waggle-os ref: ${{ github.ref }} fetch-depth: 0 persist-credentials: false clean: false - name: Revalidate exact signing revision against fresh origin main shell: pwsh run: | $checkedOutRevision = (git rev-parse --verify HEAD).Trim().ToLowerInvariant() if ($LASTEXITCODE -ne 0) { throw 'Could not resolve the exact signing checkout after refreshing repository refs.' } $remoteMainRevision = (git rev-parse --verify refs/remotes/origin/main).Trim().ToLowerInvariant() if ($LASTEXITCODE -ne 0 -or $checkedOutRevision -cne $env:GITHUB_SHA -or $env:GITHUB_WORKFLOW_SHA -cne $env:GITHUB_SHA -or $remoteMainRevision -cnotmatch '^[0-9a-f]{40}$' -or @(git status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'Fresh signing checkout, workflow, or origin/main revision evidence is invalid.' } git merge-base --is-ancestor $env:GITHUB_SHA refs/remotes/origin/main if ($LASTEXITCODE -ne 0) { throw 'The exact signing revision is not contained by fresh origin/main.' } - name: Authenticate Azure Artifact Signing with OIDC uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 with: client-id: ${{ vars.AZURE_CLIENT_ID }} tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} audience: api://AzureADTokenExchange - name: Package and sign from immutable prepared inputs id: sign-windows shell: pwsh env: WINDOWS_CODESIGN_APPROVED_SUBJECT: ${{ needs.prepare-windows-signing.outputs.signer_subject }} EXPECTED_BUILD_RECEIPT_SHA256: ${{ needs.build-windows-prebuilt.outputs.build_receipt_sha256 }} run: | & 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' ` -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ` -File ./app/scripts/sign-windows-artifact.ps1 ` -Mode Package ` -UnsignedInputRoot '${{ steps.verify-prepared.outputs.unsigned_root }}' ` -SigningInputRoot '${{ steps.verify-prepared.outputs.signing_root }}' ` -BuildReceiptPath '${{ steps.verify-prepared.outputs.build_receipt_path }}' ` -BuildReceiptSha256 $env:EXPECTED_BUILD_RECEIPT_SHA256 ` -ArtifactSigningPackageSource '${{ steps.verify-prepared.outputs.artifact_signing_package }}' ` -PortableToolchainRoot '${{ steps.verify-prepared.outputs.portable_root }}' ` -PortableToolchainReceiptPath '${{ steps.verify-prepared.outputs.portable_receipt_path }}' ` -PortableToolchainReceiptSha256 '${{ steps.verify-prepared.outputs.portable_receipt_sha256 }}' ` -PortableNodePath '${{ steps.verify-prepared.outputs.portable_node }}' ` -PortableGitPath '${{ steps.verify-prepared.outputs.portable_git }}' ` -PortableSevenZipPath '${{ steps.verify-prepared.outputs.portable_sevenzip }}' if ($LASTEXITCODE -ne 0) { throw 'Protected Artifact Signing package failed.' } $receipts = @(Get-ChildItem -LiteralPath 'app/src-tauri/target/.signing-sessions' -Recurse -Filter 'provenance-receipt.json' -File) if ($receipts.Count -ne 1) { throw "Expected exactly one sealed signing receipt, found $($receipts.Count)." } $receipt = Get-Content -Raw -LiteralPath $receipts[0].FullName | ConvertFrom-Json if ([string]$receipt.status -cne 'sealed' -or [string]$receipt.sourceRevision -cne $env:GITHUB_SHA -or [string]$receipt.signerSubject -cne $env:WINDOWS_CODESIGN_APPROVED_SUBJECT -or [string]$receipt.installerSha256 -notmatch '^[0-9A-F]{64}$') { throw 'Sealed signing receipt does not bind the approved revision and signer.' } $installerPath = [IO.Path]::GetFullPath([string]$receipt.installerPath) $targetRoot = [IO.Path]::GetFullPath((Resolve-Path 'app/src-tauri/target').Path) $targetPrefix = $targetRoot.TrimEnd([char[]]@('\', '/')) + [IO.Path]::DirectorySeparatorChar if (-not $installerPath.StartsWith($targetPrefix, [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $installerPath -PathType Leaf) -or -not [string]::Equals( (Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash, [string]$receipt.installerSha256, [StringComparison]::OrdinalIgnoreCase )) { throw 'Sealed installer is missing, escaped the target root, or changed after signing.' } if ($env:GITHUB_OUTPUT) { "candidate_sha256=$([string]$receipt.installerSha256)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append } - name: Stage exact signed handoff id: stage-signed shell: pwsh env: WAGGLE_RELEASE_MODE: ${{ needs.prepare-windows-signing.outputs.release_mode }} WINDOWS_BOOTSTRAP_RELEASE_IDENTITY: ${{ needs.prepare-windows-signing.outputs.bootstrap_identity }} WINDOWS_UPGRADE_BASE_TAG: ${{ needs.prepare-windows-signing.outputs.upgrade_base_tag }} WINDOWS_UPGRADE_BASE_ASSET_NAME: ${{ needs.prepare-windows-signing.outputs.upgrade_base_asset_name }} WINDOWS_UPGRADE_BASE_SHA256: ${{ needs.prepare-windows-signing.outputs.upgrade_base_sha256 }} WINDOWS_UPGRADE_BASE_COMMIT: ${{ needs.prepare-windows-signing.outputs.upgrade_base_commit }} WINDOWS_CODESIGN_APPROVED_SUBJECT: ${{ needs.prepare-windows-signing.outputs.signer_subject }} EXPECTED_BUILD_RECEIPT_SHA256: ${{ needs.build-windows-prebuilt.outputs.build_receipt_sha256 }} EXPECTED_BUILD_ARTIFACT_ID: ${{ needs.build-windows-prebuilt.outputs.artifact_id }} EXPECTED_BUILD_ARTIFACT_DIGEST: ${{ needs.build-windows-prebuilt.outputs.artifact_digest }} EXPECTED_PREPARATION_RECEIPT_SHA256: ${{ needs.prepare-windows-signing.outputs.preparation_receipt_sha256 }} EXPECTED_PREPARATION_ARTIFACT_ID: ${{ needs.prepare-windows-signing.outputs.artifact_id }} EXPECTED_PREPARATION_ARTIFACT_DIGEST: ${{ needs.prepare-windows-signing.outputs.artifact_digest }} run: | $signedRoot = Join-Path $env:RUNNER_TEMP 'waggle-windows-signed' if (Test-Path -LiteralPath $signedRoot) { throw 'Signed handoff root already exists.' } $releaseRoot = New-Item -ItemType Directory -Path (Join-Path $signedRoot 'release') $sourceNsisRoot = New-Item -ItemType Directory -Path (Join-Path $signedRoot 'source\release-nsis') $sourceResourcesRoot = New-Item -ItemType Directory -Path (Join-Path $signedRoot 'source\resources') $provenanceRoot = New-Item -ItemType Directory -Path (Join-Path $signedRoot 'provenance') $receipts = @( Get-ChildItem -LiteralPath 'app/src-tauri/target/.signing-sessions' ` -Recurse -Filter 'provenance-receipt.json' -File ) if ($receipts.Count -ne 1) { throw 'Signed handoff requires exactly one sealed provenance receipt.' } $provenance = Get-Content -Raw -LiteralPath $receipts[0].FullName | ConvertFrom-Json -Depth 32 $installer = Get-Item -LiteralPath ([string]$provenance.installerPath) $candidateVersion = [string]( Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' | ConvertFrom-Json ).version if ([string]$provenance.status -cne 'sealed' -or [string]$provenance.sourceRevision -cne $env:GITHUB_SHA -or [string]$provenance.signerSubject -cne $env:WINDOWS_CODESIGN_APPROVED_SUBJECT -or $candidateVersion -notmatch '^\d+\.\d+\.\d+$' -or $env:GITHUB_REF_NAME -cne "v$candidateVersion" -or (Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256).Hash -cne [string]$provenance.installerSha256) { throw 'Signed handoff source no longer matches the protected signing receipt.' } $signedReleaseRoot = Split-Path -Parent (Split-Path -Parent $installer.DirectoryName) $generatedInstallerScripts = @( Get-ChildItem -LiteralPath (Join-Path $signedReleaseRoot 'nsis') ` -Recurse -Filter 'installer.nsi' -File ) $sourceService = Get-Item -LiteralPath 'app/src-tauri/resources/service.js' if ($generatedInstallerScripts.Count -ne 1) { throw 'Signed handoff does not contain exactly one generated installer.nsi.' } Copy-Item -LiteralPath $installer.FullName -Destination $releaseRoot.FullName -ErrorAction Stop Copy-Item -LiteralPath $generatedInstallerScripts[0].FullName ` -Destination (Join-Path $sourceNsisRoot.FullName 'installer.nsi') -ErrorAction Stop Copy-Item -LiteralPath $sourceService.FullName ` -Destination (Join-Path $sourceResourcesRoot.FullName 'service.js') -ErrorAction Stop Copy-Item -LiteralPath $receipts[0].FullName ` -Destination (Join-Path $provenanceRoot.FullName 'provenance-receipt.json') -ErrorAction Stop . ./app/scripts/sign-windows-artifact.ps1 $inventory = New-WagglePrebuiltInventory -Root $signedRoot $expectedPaths = @( 'provenance\provenance-receipt.json' "release\$($installer.Name)" 'source\release-nsis\installer.nsi' 'source\resources\service.js' ) if (@($inventory.entries).Count -ne $expectedPaths.Count -or [string]::Join("`n", @($inventory.entries | ForEach-Object { [string]$_.path })) -cne [string]::Join("`n", $expectedPaths)) { throw 'Signed handoff topology differs from the exact four-file contract.' } foreach ($value in @( $env:EXPECTED_BUILD_RECEIPT_SHA256, $env:EXPECTED_BUILD_ARTIFACT_DIGEST, [string]$provenance.installerSha256 )) { if ($value -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Signed handoff digest binding is invalid.' } } if ($env:EXPECTED_BUILD_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$') { throw 'Signed handoff build artifact ID is invalid.' } if ($env:EXPECTED_PREPARATION_RECEIPT_SHA256 -cnotmatch '^[0-9A-Fa-f]{64}$' -or $env:EXPECTED_PREPARATION_ARTIFACT_DIGEST -cnotmatch '^[0-9A-Fa-f]{64}$' -or $env:EXPECTED_PREPARATION_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$') { throw 'Signed handoff preparation artifact binding is invalid.' } $handoffReceipt = [ordered]@{ schemaVersion = 1 sourceRevision = $env:GITHUB_SHA candidateTag = $env:GITHUB_REF_NAME candidateVersion = $candidateVersion releaseMode = $env:WAGGLE_RELEASE_MODE bootstrapIdentity = $env:WINDOWS_BOOTSTRAP_RELEASE_IDENTITY upgradeBaseTag = $env:WINDOWS_UPGRADE_BASE_TAG upgradeBaseAssetName = $env:WINDOWS_UPGRADE_BASE_ASSET_NAME upgradeBaseSha256 = $env:WINDOWS_UPGRADE_BASE_SHA256 upgradeBaseCommit = $env:WINDOWS_UPGRADE_BASE_COMMIT signerSubject = $env:WINDOWS_CODESIGN_APPROVED_SUBJECT candidateSha256 = ([string]$provenance.installerSha256).ToUpperInvariant() buildReceiptSha256 = $env:EXPECTED_BUILD_RECEIPT_SHA256.ToUpperInvariant() buildArtifactId = $env:EXPECTED_BUILD_ARTIFACT_ID buildArtifactDigest = $env:EXPECTED_BUILD_ARTIFACT_DIGEST.ToUpperInvariant() preparationReceiptSha256 = $env:EXPECTED_PREPARATION_RECEIPT_SHA256.ToUpperInvariant() preparationArtifactId = $env:EXPECTED_PREPARATION_ARTIFACT_ID preparationArtifactDigest = $env:EXPECTED_PREPARATION_ARTIFACT_DIGEST.ToUpperInvariant() timestampPolicy = 'fresh-certification-monotonic-v1' inventory = $inventory } $receiptPath = Join-Path $signedRoot 'signing-handoff-receipt.json' Write-WaggleJsonNoBom $receiptPath $handoffReceipt $receiptSha256 = (Get-FileHash -LiteralPath $receiptPath -Algorithm SHA256).Hash "receipt_sha256=$receiptSha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "candidate_sha256=$([string]$handoffReceipt.candidateSha256)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "candidate_version=$candidateVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Upload immutable signed Windows handoff id: upload-signed uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: waggle-windows-signed-${{ github.sha }} path: ${{ runner.temp }}\waggle-windows-signed if-no-files-found: error include-hidden-files: true retention-days: 7 certify-windows: needs: sign-windows runs-on: windows-latest permissions: contents: read outputs: release_mode: ${{ steps.verify-signed.outputs.release_mode }} candidate_sha256: ${{ steps.certify-windows.outputs.candidate_sha256 }} candidate_version: ${{ steps.certify-windows.outputs.candidate_version }} bootstrap_identity: ${{ steps.verify-signed.outputs.bootstrap_identity }} upgrade_base_tag: ${{ steps.verify-signed.outputs.upgrade_base_tag }} upgrade_base_asset_name: ${{ steps.verify-signed.outputs.upgrade_base_asset_name }} upgrade_base_sha256: ${{ steps.verify-signed.outputs.upgrade_base_sha256 }} upgrade_base_commit: ${{ steps.verify-signed.outputs.upgrade_base_commit }} signer_subject: ${{ steps.verify-signed.outputs.signer_subject }} sealed_receipt_sha256: ${{ steps.stage-sealed.outputs.receipt_sha256 }} artifact_id: ${{ steps.upload-sealed.outputs.artifact-id }} artifact_digest: ${{ steps.upload-sealed.outputs.artifact-digest }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 persist-credentials: false - name: Validate credential-free certification boundary shell: pwsh run: | $version = [string](Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' | ConvertFrom-Json).version $checkedOutRevision = (git rev-parse --verify HEAD).Trim().ToLowerInvariant() if ($env:GITHUB_REPOSITORY -cne 'marolinik/waggle-os' -or $env:GITHUB_REF_TYPE -cne 'tag' -or $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or $env:GITHUB_REF_NAME -cne "v$version" -or $checkedOutRevision -cne $env:GITHUB_SHA -or @(git status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'Windows certification requires a clean fresh runner at the exact release revision.' } - name: Setup Node.js for source-bound certification uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '22.23.2' cache: npm - name: Install locked certification dependencies run: npm ci - name: Download immutable signed Windows handoff uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: artifact-ids: ${{ needs.sign-windows.outputs.artifact_id }} path: ${{ runner.temp }}\waggle-windows-signed merge-multiple: true - name: Validate exact signed handoff before candidate execution id: verify-signed shell: pwsh env: EXPECTED_HANDOFF_RECEIPT_SHA256: ${{ needs.sign-windows.outputs.handoff_receipt_sha256 }} EXPECTED_SIGNED_ARTIFACT_ID: ${{ needs.sign-windows.outputs.artifact_id }} EXPECTED_SIGNED_ARTIFACT_DIGEST: ${{ needs.sign-windows.outputs.artifact_digest }} EXPECTED_BUILD_RECEIPT_SHA256: ${{ needs.sign-windows.outputs.build_receipt_sha256 }} EXPECTED_BUILD_ARTIFACT_ID: ${{ needs.sign-windows.outputs.build_artifact_id }} EXPECTED_BUILD_ARTIFACT_DIGEST: ${{ needs.sign-windows.outputs.build_artifact_digest }} EXPECTED_RELEASE_MODE: ${{ needs.sign-windows.outputs.release_mode }} EXPECTED_CANDIDATE_SHA256: ${{ needs.sign-windows.outputs.candidate_sha256 }} EXPECTED_CANDIDATE_VERSION: ${{ needs.sign-windows.outputs.candidate_version }} EXPECTED_BOOTSTRAP_IDENTITY: ${{ needs.sign-windows.outputs.bootstrap_identity }} EXPECTED_UPGRADE_BASE_TAG: ${{ needs.sign-windows.outputs.upgrade_base_tag }} EXPECTED_UPGRADE_BASE_ASSET_NAME: ${{ needs.sign-windows.outputs.upgrade_base_asset_name }} EXPECTED_UPGRADE_BASE_SHA256: ${{ needs.sign-windows.outputs.upgrade_base_sha256 }} EXPECTED_UPGRADE_BASE_COMMIT: ${{ needs.sign-windows.outputs.upgrade_base_commit }} EXPECTED_SIGNER_SUBJECT: ${{ needs.sign-windows.outputs.signer_subject }} run: | $signedRoot = [IO.Path]::GetFullPath( (Resolve-Path (Join-Path $env:RUNNER_TEMP 'waggle-windows-signed')).Path ) $receiptPath = Join-Path $signedRoot 'signing-handoff-receipt.json' foreach ($digest in @( $env:EXPECTED_HANDOFF_RECEIPT_SHA256, $env:EXPECTED_SIGNED_ARTIFACT_DIGEST, $env:EXPECTED_BUILD_RECEIPT_SHA256, $env:EXPECTED_CANDIDATE_SHA256 )) { if ($digest -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Signed handoff expected digest is invalid.' } } if ($env:EXPECTED_SIGNED_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$' -or -not (Test-Path -LiteralPath $receiptPath -PathType Leaf) -or (Get-FileHash -LiteralPath $receiptPath -Algorithm SHA256).Hash -cne $env:EXPECTED_HANDOFF_RECEIPT_SHA256.ToUpperInvariant()) { throw 'Signed handoff immutable artifact or receipt binding is invalid.' } $receipt = Get-Content -Raw -LiteralPath $receiptPath | ConvertFrom-Json -Depth 32 $expectedBindings = [ordered]@{ sourceRevision = $env:GITHUB_SHA candidateTag = $env:GITHUB_REF_NAME candidateVersion = $env:EXPECTED_CANDIDATE_VERSION releaseMode = $env:EXPECTED_RELEASE_MODE bootstrapIdentity = $env:EXPECTED_BOOTSTRAP_IDENTITY upgradeBaseTag = $env:EXPECTED_UPGRADE_BASE_TAG upgradeBaseAssetName = $env:EXPECTED_UPGRADE_BASE_ASSET_NAME upgradeBaseSha256 = $env:EXPECTED_UPGRADE_BASE_SHA256 upgradeBaseCommit = $env:EXPECTED_UPGRADE_BASE_COMMIT signerSubject = $env:EXPECTED_SIGNER_SUBJECT candidateSha256 = $env:EXPECTED_CANDIDATE_SHA256 buildReceiptSha256 = $env:EXPECTED_BUILD_RECEIPT_SHA256 buildArtifactId = $env:EXPECTED_BUILD_ARTIFACT_ID buildArtifactDigest = $env:EXPECTED_BUILD_ARTIFACT_DIGEST.ToUpperInvariant() timestampPolicy = 'fresh-certification-monotonic-v1' } if ($receipt.schemaVersion -is [string] -or [int]$receipt.schemaVersion -ne 1 -or $env:EXPECTED_BUILD_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$' -or $env:EXPECTED_BUILD_ARTIFACT_DIGEST -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Signed handoff receipt schema or upstream artifact binding is invalid.' } foreach ($property in $expectedBindings.Keys) { if (-not [string]::Equals( [string]$receipt.$property, [string]$expectedBindings[$property], [StringComparison]::Ordinal )) { throw "Signed handoff protected binding differs: $property" } } . ./app/scripts/sign-windows-artifact.ps1 $entries = @($receipt.inventory.entries) Assert-WaggleCanonicalInventoryEntries $entries 'Signed handoff inventory' if ($entries.Count -ne 4 -or (Get-WaggleInventorySha256 $entries) -cne [string]$receipt.inventory.sha256) { throw 'Signed handoff inventory aggregate is invalid.' } $actualPaths = @( Get-ChildItem -LiteralPath $signedRoot -Recurse -Force -File | ForEach-Object { [IO.Path]::GetRelativePath($signedRoot, $_.FullName).Replace('/', '\') } | Sort-Object -CaseSensitive ) $expectedPaths = @($entries | ForEach-Object { [string]$_.path }) + @('signing-handoff-receipt.json') $expectedPaths = @($expectedPaths | Sort-Object -CaseSensitive) if ([string]::Join("`n", $actualPaths) -cne [string]::Join("`n", $expectedPaths)) { throw 'Signed handoff extracted topology is not exact.' } foreach ($entry in $entries) { $path = Get-TrustedPath (Join-Path $signedRoot ([string]$entry.path)) 'Signed handoff file' $item = Get-Item -LiteralPath $path if ([long]$item.Length -ne [long]$entry.size -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne ([string]$entry.sha256).ToUpperInvariant()) { throw 'Signed handoff file differs from its exact receipt.' } } $installerEntries = @($entries | Where-Object { [string]$_.path -match '^release\\[^\\]+-setup\.exe$' }) if ($installerEntries.Count -ne 1) { throw 'Signed handoff has an ambiguous installer identity.' } $installerSource = Get-TrustedPath ` (Join-Path $signedRoot ([string]$installerEntries[0].path)) 'Signed candidate installer' $signature = Get-AuthenticodeSignature -LiteralPath $installerSource if ($signature.Status -ne 'Valid' -or [string]$signature.SignerCertificate.Subject -cne $env:EXPECTED_SIGNER_SUBJECT -or $null -eq $signature.TimeStamperCertificate) { throw 'Signed candidate lacks the approved signer and timestamp before execution.' } $targetReleaseRoot = [IO.Path]::GetFullPath( 'app/src-tauri/target/x86_64-pc-windows-msvc/release' ) if (Test-Path -LiteralPath $targetReleaseRoot) { throw 'Certification target unexpectedly exists.' } $bundleRoot = New-Item -ItemType Directory -Path (Join-Path $targetReleaseRoot 'bundle\nsis') $nsisRoot = New-Item -ItemType Directory -Path (Join-Path $targetReleaseRoot 'nsis') $installerTarget = Join-Path $bundleRoot.FullName (Split-Path $installerSource -Leaf) [IO.File]::Copy($installerSource, $installerTarget, $false) [IO.File]::Copy( (Join-Path $signedRoot 'source\release-nsis\installer.nsi'), (Join-Path $nsisRoot.FullName 'installer.nsi'), $false ) $canonicalServicePath = [IO.Path]::GetFullPath('app/src-tauri/resources/service.js') if (Test-Path -LiteralPath $canonicalServicePath) { throw 'Fresh certification checkout unexpectedly contains generated service.js.' } [IO.File]::Copy( (Join-Path $signedRoot 'source\resources\service.js'), $canonicalServicePath, $false ) $hookTime = (Get-Item -LiteralPath 'app/src-tauri/nsis/installer.nsi').LastWriteTimeUtc (Get-Item -LiteralPath (Join-Path $nsisRoot.FullName 'installer.nsi')).LastWriteTimeUtc = $hookTime.AddSeconds(1) (Get-Item -LiteralPath $installerTarget).LastWriteTimeUtc = $hookTime.AddSeconds(2) "release_mode=$([string]$receipt.releaseMode)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "bootstrap_identity=$([string]$receipt.bootstrapIdentity)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "upgrade_base_tag=$([string]$receipt.upgradeBaseTag)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "upgrade_base_asset_name=$([string]$receipt.upgradeBaseAssetName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "upgrade_base_sha256=$([string]$receipt.upgradeBaseSha256)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "upgrade_base_commit=$([string]$receipt.upgradeBaseCommit)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "signer_subject=$([string]$receipt.signerSubject)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Reclaim Windows build intermediates for managed-model certification shell: pwsh run: | $installers = @( Get-ChildItem -LiteralPath 'app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis' ` -Filter '*-setup.exe' -File ) if ($installers.Count -ne 1) { throw 'Certification target installer identity is ambiguous.' } $installer = $installers[0] $targetRoot = [IO.Path]::GetFullPath((Resolve-Path 'app/src-tauri/target').Path) $targetPrefix = $targetRoot.TrimEnd([char[]]@('\', '/')) + [IO.Path]::DirectorySeparatorChar $targetProfileRoot = [IO.Path]::GetFullPath((Split-Path -Parent (Split-Path -Parent $installer.DirectoryName))) if (-not $targetProfileRoot.StartsWith($targetPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to prune outside the Tauri target: $targetProfileRoot" } foreach ($directoryName in @('deps', 'incremental', 'build', '.fingerprint')) { $directory = Join-Path $targetProfileRoot $directoryName if (Test-Path -LiteralPath $directory -PathType Container) { Remove-Item -LiteralPath $directory -Recurse -Force -ErrorAction Stop } } $driveRoot = [IO.Path]::GetPathRoot($targetRoot) if ($driveRoot -notmatch '^[A-Za-z]:\\$') { throw "Unexpected Tauri target drive: $driveRoot" } $drive = Get-PSDrive -Name $driveRoot.Substring(0, 1) -PSProvider FileSystem if ([int64]$drive.Free -lt 8GB) { throw "Managed-model certification requires at least 8 GiB free after pruning; available=$($drive.Free)" } - name: Download signed Windows upgrade baseline if: steps.verify-signed.outputs.release_mode == 'upgrade' shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} WINDOWS_UPGRADE_BASE_TAG: ${{ steps.verify-signed.outputs.upgrade_base_tag }} WINDOWS_UPGRADE_BASE_ASSET_NAME: ${{ steps.verify-signed.outputs.upgrade_base_asset_name }} WINDOWS_UPGRADE_BASE_SHA256: ${{ steps.verify-signed.outputs.upgrade_base_sha256 }} WINDOWS_UPGRADE_BASE_COMMIT: ${{ steps.verify-signed.outputs.upgrade_base_commit }} run: | $baseTag = [string]$env:WINDOWS_UPGRADE_BASE_TAG $assetName = [string]$env:WINDOWS_UPGRADE_BASE_ASSET_NAME $expectedSha256 = ([string]$env:WINDOWS_UPGRADE_BASE_SHA256).Trim().ToUpperInvariant() $expectedBaseCommit = ([string]$env:WINDOWS_UPGRADE_BASE_COMMIT).Trim().ToLowerInvariant() if ($baseTag -notmatch '^v(?\d+\.\d+\.\d+)$') { throw 'WINDOWS_UPGRADE_BASE_TAG must use exact vX.Y.Z format' } $baseVersion = $Matches['version'] if ($assetName -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*-setup\.exe$') { throw 'WINDOWS_UPGRADE_BASE_ASSET_NAME must be one exact NSIS setup filename' } if ($expectedSha256 -notmatch '^[0-9A-F]{64}$') { throw 'WINDOWS_UPGRADE_BASE_SHA256 must be exactly 64 hexadecimal characters' } if ($expectedBaseCommit -notmatch '^[0-9a-f]{40}$') { throw 'WINDOWS_UPGRADE_BASE_COMMIT must be exactly 40 hexadecimal characters' } $candidateVersion = [string](Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' | ConvertFrom-Json).version if ($candidateVersion -notmatch '^\d+\.\d+\.\d+$' -or $env:GITHUB_REF_NAME -ne "v$candidateVersion") { throw 'Candidate installer version no longer matches the release tag' } if ([version]$baseVersion -ge [version]$candidateVersion) { throw 'The protected Windows upgrade baseline must be older than the candidate' } $baseCommit = (git rev-parse --verify "refs/tags/$baseTag^{}").Trim().ToLowerInvariant() if ($LASTEXITCODE -ne 0 -or $baseCommit -notmatch '^[0-9a-f]{40}$') { throw "Fresh checkout could not resolve protected upgrade baseline tag $baseTag" } if (-not [string]::Equals($baseCommit, $expectedBaseCommit, [System.StringComparison]::Ordinal)) { throw 'Protected Windows upgrade baseline tag does not resolve to WINDOWS_UPGRADE_BASE_COMMIT' } git merge-base --is-ancestor $baseCommit $env:GITHUB_SHA if ($LASTEXITCODE -ne 0) { throw 'The protected Windows upgrade baseline is not an ancestor of the candidate release' } $releaseJson = gh release view $baseTag --json isDraft,isPrerelease,tagName,assets if ($LASTEXITCODE -ne 0) { throw "Could not inspect protected upgrade baseline $baseTag" } $releaseData = $releaseJson | ConvertFrom-Json if ($releaseData.tagName -ne $baseTag -or $releaseData.isDraft -or $releaseData.isPrerelease) { throw 'Windows upgrade baseline must be an exact published, non-prerelease release' } $matchingAssets = @($releaseData.assets | Where-Object { $_.name -eq $assetName }) if ($matchingAssets.Count -ne 1) { throw "Expected exactly one protected upgrade baseline asset named $assetName" } $downloadRoot = Join-Path $env:RUNNER_TEMP 'waggle-windows-upgrade-baseline' if (Test-Path -LiteralPath $downloadRoot) { throw "Upgrade baseline directory already exists: $downloadRoot" } New-Item -ItemType Directory -Path $downloadRoot | Out-Null gh release download $baseTag --pattern $assetName --dir $downloadRoot if ($LASTEXITCODE -ne 0) { throw 'Could not download the protected Windows upgrade baseline' } $downloadedFiles = @(Get-ChildItem -LiteralPath $downloadRoot -File) if ($downloadedFiles.Count -ne 1 -or $downloadedFiles[0].Name -ne $assetName) { throw 'Downloaded Windows upgrade baseline identity is ambiguous' } $baseInstaller = $downloadedFiles[0] $actualSha256 = (Get-FileHash -LiteralPath $baseInstaller.FullName -Algorithm SHA256).Hash if (-not [string]::Equals($actualSha256, $expectedSha256, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'Downloaded Windows upgrade baseline does not match the protected SHA-256' } - name: Certify Windows Solo installer lifecycle id: certify-windows shell: pwsh env: WAGGLE_RELEASE_MODE: ${{ steps.verify-signed.outputs.release_mode }} WINDOWS_CODESIGN_APPROVED_SUBJECT: ${{ steps.verify-signed.outputs.signer_subject }} WINDOWS_UPGRADE_BASE_TAG: ${{ steps.verify-signed.outputs.upgrade_base_tag }} WINDOWS_UPGRADE_BASE_ASSET_NAME: ${{ steps.verify-signed.outputs.upgrade_base_asset_name }} WINDOWS_UPGRADE_BASE_SHA256: ${{ steps.verify-signed.outputs.upgrade_base_sha256 }} WAGGLE_UPGRADE_BASE_COMMIT: ${{ steps.verify-signed.outputs.upgrade_base_commit }} run: | if ($env:WAGGLE_RELEASE_MODE -notin @('bootstrap', 'upgrade')) { throw 'Windows release mode is missing or invalid during certification' } $installers = @( Get-ChildItem -LiteralPath 'app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis' ` -Filter '*-setup.exe' -File ) if ($installers.Count -ne 1) { throw 'Certification target installer identity is ambiguous.' } $installer = $installers[0] if ($installer.Name -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*-setup\.exe$' -or $installer.DirectoryName -notmatch '[\\/]bundle[\\/]nsis$') { throw 'Sealed signing receipt did not identify one canonical NSIS setup executable.' } $candidateVersion = [string](Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' | ConvertFrom-Json).version if ($candidateVersion -notmatch '^\d+\.\d+\.\d+$' -or $env:GITHUB_REF_NAME -ne "v$candidateVersion") { throw 'Candidate installer version no longer matches the release tag' } $candidateSha256 = (Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256).Hash if ($candidateSha256 -cne '${{ needs.sign-windows.outputs.candidate_sha256 }}') { throw 'Materialized candidate differs from the protected signed-handoff digest.' } $cleanReceiptPath = Join-Path $installer.DirectoryName 'windows-installer-certificate.json' $upgradeReceiptPath = Join-Path $installer.DirectoryName 'windows-installer-upgrade-certificate.json' if ((Test-Path -LiteralPath $cleanReceiptPath) -or (Test-Path -LiteralPath $upgradeReceiptPath)) { throw 'Refusing to overwrite a pre-existing Windows certification receipt' } & ./scripts/certify-windows-installer.ps1 ` -InstallerPath $installer.FullName ` -ReceiptPath $cleanReceiptPath ` -RequireAuthenticodeSignature ` -ExpectedSignerSubject $env:WINDOWS_CODESIGN_APPROVED_SUBJECT ` -ExpectedSourceRevision $env:GITHUB_SHA ` -VerifyManagedModel if ($env:WAGGLE_RELEASE_MODE -eq 'upgrade') { if ($env:WINDOWS_UPGRADE_BASE_TAG -notmatch '^v(?\d+\.\d+\.\d+)$') { throw 'Protected upgrade base tag is invalid during certification.' } $baseVersion = $Matches['version'] $baseInstallerPath = Join-Path ` (Join-Path $env:RUNNER_TEMP 'waggle-windows-upgrade-baseline') ` $env:WINDOWS_UPGRADE_BASE_ASSET_NAME & ./scripts/certify-windows-installer.ps1 ` -InstallerPath $installer.FullName ` -ReceiptPath $upgradeReceiptPath ` -RequireAuthenticodeSignature ` -ExpectedSignerSubject $env:WINDOWS_CODESIGN_APPROVED_SUBJECT ` -ExpectedSourceRevision $env:GITHUB_SHA ` -RequireVersionToVersionUpgrade ` -PreviousInstallerPath $baseInstallerPath ` -ExpectedPreviousInstallerSha256 $env:WINDOWS_UPGRADE_BASE_SHA256 ` -ExpectedPreviousVersion $baseVersion ` -ExpectedPreviousSourceRevision $env:WAGGLE_UPGRADE_BASE_COMMIT ` -ExpectedCandidateInstallerSha256 $candidateSha256 ` -ExpectedCandidateVersion $candidateVersion ` -VerifyManagedModel } elseif (Test-Path -LiteralPath $upgradeReceiptPath) { throw 'Bootstrap certification unexpectedly produced an upgrade receipt' } $postCertificationSha256 = (Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256).Hash if (-not [string]::Equals($postCertificationSha256, $candidateSha256, [System.StringComparison]::OrdinalIgnoreCase)) { throw 'Candidate installer changed during Windows lifecycle certification' } "candidate_sha256=$candidateSha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "candidate_version=$candidateVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Stage sealed Windows release outputs id: stage-sealed shell: pwsh env: WAGGLE_RELEASE_MODE: ${{ steps.verify-signed.outputs.release_mode }} WINDOWS_BOOTSTRAP_RELEASE_IDENTITY: ${{ steps.verify-signed.outputs.bootstrap_identity }} WINDOWS_UPGRADE_BASE_TAG: ${{ steps.verify-signed.outputs.upgrade_base_tag }} WINDOWS_UPGRADE_BASE_ASSET_NAME: ${{ steps.verify-signed.outputs.upgrade_base_asset_name }} WINDOWS_UPGRADE_BASE_SHA256: ${{ steps.verify-signed.outputs.upgrade_base_sha256 }} WINDOWS_UPGRADE_BASE_COMMIT: ${{ steps.verify-signed.outputs.upgrade_base_commit }} WINDOWS_CODESIGN_APPROVED_SUBJECT: ${{ steps.verify-signed.outputs.signer_subject }} EXPECTED_CANDIDATE_SHA256: ${{ steps.certify-windows.outputs.candidate_sha256 }} EXPECTED_CANDIDATE_VERSION: ${{ steps.certify-windows.outputs.candidate_version }} SIGNED_HANDOFF_RECEIPT_SHA256: ${{ needs.sign-windows.outputs.handoff_receipt_sha256 }} SIGNED_HANDOFF_ARTIFACT_ID: ${{ needs.sign-windows.outputs.artifact_id }} SIGNED_HANDOFF_ARTIFACT_DIGEST: ${{ needs.sign-windows.outputs.artifact_digest }} run: | $sealedRoot = Join-Path $env:RUNNER_TEMP 'waggle-windows-sealed' if (Test-Path -LiteralPath $sealedRoot) { throw 'Sealed Windows output root already exists.' } $releaseRoot = New-Item -ItemType Directory -Path (Join-Path $sealedRoot 'release') $sourceNsisRoot = New-Item -ItemType Directory -Path (Join-Path $sealedRoot 'source\release-nsis') $sourceResourcesRoot = New-Item -ItemType Directory -Path (Join-Path $sealedRoot 'source\resources') $provenanceRoot = New-Item -ItemType Directory -Path (Join-Path $sealedRoot 'provenance') $installers = @( Get-ChildItem -LiteralPath 'app/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis' ` -Filter '*-setup.exe' -File ) if ($installers.Count -ne 1) { throw 'Sealed release installer identity is ambiguous.' } $installer = $installers[0] $cleanReceipt = Get-Item -LiteralPath (Join-Path $installer.DirectoryName 'windows-installer-certificate.json') $upgradeReceiptPath = Join-Path $installer.DirectoryName 'windows-installer-upgrade-certificate.json' $provenanceReceipt = Get-Item -LiteralPath ( Join-Path $env:RUNNER_TEMP 'waggle-windows-signed\provenance\provenance-receipt.json' ) foreach ($file in @($installer, $cleanReceipt)) { Copy-Item -LiteralPath $file.FullName -Destination $releaseRoot.FullName -ErrorAction Stop } Copy-Item -LiteralPath $provenanceReceipt.FullName ` -Destination $provenanceRoot.FullName -ErrorAction Stop $cleanReceiptData = Get-Content -Raw -LiteralPath $cleanReceipt.FullName | ConvertFrom-Json -Depth 32 $signedReleaseRoot = Split-Path -Parent (Split-Path -Parent $installer.DirectoryName) $generatedInstallerScripts = @( Get-ChildItem -LiteralPath (Join-Path $signedReleaseRoot 'nsis') ` -Recurse -Filter 'installer.nsi' -File ) $sourceService = Get-Item -LiteralPath 'app/src-tauri/resources/service.js' if ($generatedInstallerScripts.Count -ne 1 -or (Get-FileHash -LiteralPath $generatedInstallerScripts[0].FullName -Algorithm SHA256).Hash -cne [string]$cleanReceiptData.evidence.generatedInstallerScriptSha256 -or (Get-FileHash -LiteralPath $sourceService.FullName -Algorithm SHA256).Hash -cne [string]$cleanReceiptData.evidence.sidecarBundleSha256) { throw 'Certified source inputs no longer match the clean-install receipt.' } Copy-Item -LiteralPath $generatedInstallerScripts[0].FullName ` -Destination (Join-Path $sourceNsisRoot.FullName 'installer.nsi') -ErrorAction Stop Copy-Item -LiteralPath $sourceService.FullName ` -Destination (Join-Path $sourceResourcesRoot.FullName 'service.js') -ErrorAction Stop if ($env:WAGGLE_RELEASE_MODE -ceq 'upgrade') { $upgradeReceipt = Get-Item -LiteralPath $upgradeReceiptPath Copy-Item -LiteralPath $upgradeReceipt.FullName -Destination $releaseRoot.FullName -ErrorAction Stop $baselineRoot = New-Item -ItemType Directory -Path (Join-Path $sealedRoot 'baseline') $baseInstaller = Get-Item -LiteralPath ( Join-Path ` (Join-Path $env:RUNNER_TEMP 'waggle-windows-upgrade-baseline') ` $env:WINDOWS_UPGRADE_BASE_ASSET_NAME ) Copy-Item -LiteralPath $baseInstaller.FullName -Destination $baselineRoot.FullName -ErrorAction Stop } elseif (Test-Path -LiteralPath $upgradeReceiptPath) { throw 'Bootstrap sealed outputs contain an unexpected upgrade receipt.' } $stagedInstaller = Get-Item -LiteralPath (Join-Path $releaseRoot.FullName $installer.Name) if (-not [string]::Equals( (Get-FileHash -LiteralPath $stagedInstaller.FullName -Algorithm SHA256).Hash, $env:EXPECTED_CANDIDATE_SHA256, [StringComparison]::OrdinalIgnoreCase )) { throw 'Staged sealed installer differs from its certified digest.' } if (@(Get-ChildItem -LiteralPath $provenanceRoot.FullName -File).Count -ne 1 -or @(Get-ChildItem -LiteralPath (Join-Path $sealedRoot 'source') -Recurse -File).Count -ne 2) { throw 'Sealed provenance and source inputs do not have the exact approved topology.' } . ./app/scripts/sign-windows-artifact.ps1 $sealedInventory = New-WagglePrebuiltInventory -Root $sealedRoot $expectedInventoryCount = if ($env:WAGGLE_RELEASE_MODE -ceq 'upgrade') { 7 } else { 5 } if (@($sealedInventory.entries).Count -ne $expectedInventoryCount) { throw 'Sealed release topology differs from the exact mode-specific contract.' } foreach ($digest in @( $env:EXPECTED_CANDIDATE_SHA256, $env:SIGNED_HANDOFF_RECEIPT_SHA256, $env:SIGNED_HANDOFF_ARTIFACT_DIGEST )) { if ($digest -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Sealed release digest binding is invalid.' } } if ($env:SIGNED_HANDOFF_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$') { throw 'Sealed release signed-handoff artifact ID is invalid.' } $sealedReceipt = [ordered]@{ schemaVersion = 1 sourceRevision = $env:GITHUB_SHA candidateTag = $env:GITHUB_REF_NAME candidateVersion = $env:EXPECTED_CANDIDATE_VERSION releaseMode = $env:WAGGLE_RELEASE_MODE bootstrapIdentity = $env:WINDOWS_BOOTSTRAP_RELEASE_IDENTITY upgradeBaseTag = $env:WINDOWS_UPGRADE_BASE_TAG upgradeBaseAssetName = $env:WINDOWS_UPGRADE_BASE_ASSET_NAME upgradeBaseSha256 = $env:WINDOWS_UPGRADE_BASE_SHA256 upgradeBaseCommit = $env:WINDOWS_UPGRADE_BASE_COMMIT signerSubject = $env:WINDOWS_CODESIGN_APPROVED_SUBJECT candidateSha256 = $env:EXPECTED_CANDIDATE_SHA256.ToUpperInvariant() signedHandoffReceiptSha256 = $env:SIGNED_HANDOFF_RECEIPT_SHA256.ToUpperInvariant() signedHandoffArtifactId = $env:SIGNED_HANDOFF_ARTIFACT_ID signedHandoffArtifactDigest = $env:SIGNED_HANDOFF_ARTIFACT_DIGEST.ToUpperInvariant() inventory = $sealedInventory } $sealedReceiptPath = Join-Path $sealedRoot 'sealed-release-receipt.json' Write-WaggleJsonNoBom $sealedReceiptPath $sealedReceipt $sealedReceiptSha256 = (Get-FileHash -LiteralPath $sealedReceiptPath -Algorithm SHA256).Hash "receipt_sha256=$sealedReceiptSha256" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Upload sealed Windows release outputs id: upload-sealed uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: waggle-windows-sealed-${{ github.sha }} path: ${{ runner.temp }}\waggle-windows-sealed if-no-files-found: error include-hidden-files: true retention-days: 30 attest-windows: needs: certify-windows if: github.event.repository.private == false environment: production runs-on: ubuntu-latest permissions: contents: read attestations: write id-token: write steps: - name: Download certified Windows release by immutable artifact ID uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: artifact-ids: ${{ needs.certify-windows.outputs.artifact_id }} path: ${{ runner.temp }}/waggle-windows-sealed merge-multiple: true - name: Validate certified artifact receipt and topology without execution shell: pwsh env: EXPECTED_SEALED_RECEIPT_SHA256: ${{ needs.certify-windows.outputs.sealed_receipt_sha256 }} EXPECTED_SEALED_ARTIFACT_ID: ${{ needs.certify-windows.outputs.artifact_id }} EXPECTED_SEALED_ARTIFACT_DIGEST: ${{ needs.certify-windows.outputs.artifact_digest }} EXPECTED_RELEASE_MODE: ${{ needs.certify-windows.outputs.release_mode }} EXPECTED_CANDIDATE_SHA256: ${{ needs.certify-windows.outputs.candidate_sha256 }} EXPECTED_CANDIDATE_VERSION: ${{ needs.certify-windows.outputs.candidate_version }} EXPECTED_BOOTSTRAP_IDENTITY: ${{ needs.certify-windows.outputs.bootstrap_identity }} EXPECTED_UPGRADE_BASE_TAG: ${{ needs.certify-windows.outputs.upgrade_base_tag }} EXPECTED_UPGRADE_BASE_ASSET_NAME: ${{ needs.certify-windows.outputs.upgrade_base_asset_name }} EXPECTED_UPGRADE_BASE_SHA256: ${{ needs.certify-windows.outputs.upgrade_base_sha256 }} EXPECTED_UPGRADE_BASE_COMMIT: ${{ needs.certify-windows.outputs.upgrade_base_commit }} EXPECTED_SIGNER_SUBJECT: ${{ needs.certify-windows.outputs.signer_subject }} run: | $sealedRoot = [IO.Path]::GetFullPath( (Resolve-Path (Join-Path $env:RUNNER_TEMP 'waggle-windows-sealed')).Path ) $receiptPath = Join-Path $sealedRoot 'sealed-release-receipt.json' foreach ($digest in @( $env:EXPECTED_SEALED_RECEIPT_SHA256, $env:EXPECTED_SEALED_ARTIFACT_DIGEST, $env:EXPECTED_CANDIDATE_SHA256 )) { if ($digest -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Attestation input digest is invalid.' } } if ($env:EXPECTED_SEALED_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$' -or -not (Test-Path -LiteralPath $receiptPath -PathType Leaf) -or (Get-FileHash -LiteralPath $receiptPath -Algorithm SHA256).Hash -cne $env:EXPECTED_SEALED_RECEIPT_SHA256.ToUpperInvariant()) { throw 'Attestation input immutable artifact or receipt binding is invalid.' } $receipt = Get-Content -Raw -LiteralPath $receiptPath | ConvertFrom-Json -Depth 32 $expectedBindings = [ordered]@{ sourceRevision = $env:GITHUB_SHA candidateTag = $env:GITHUB_REF_NAME candidateVersion = $env:EXPECTED_CANDIDATE_VERSION releaseMode = $env:EXPECTED_RELEASE_MODE bootstrapIdentity = $env:EXPECTED_BOOTSTRAP_IDENTITY upgradeBaseTag = $env:EXPECTED_UPGRADE_BASE_TAG upgradeBaseAssetName = $env:EXPECTED_UPGRADE_BASE_ASSET_NAME upgradeBaseSha256 = $env:EXPECTED_UPGRADE_BASE_SHA256 upgradeBaseCommit = $env:EXPECTED_UPGRADE_BASE_COMMIT signerSubject = $env:EXPECTED_SIGNER_SUBJECT candidateSha256 = $env:EXPECTED_CANDIDATE_SHA256 } if ([int]$receipt.schemaVersion -ne 1) { throw 'Sealed release receipt schema is invalid.' } foreach ($property in $expectedBindings.Keys) { if (-not [string]::Equals( [string]$receipt.$property, [string]$expectedBindings[$property], [StringComparison]::OrdinalIgnoreCase )) { throw "Sealed release protected binding differs before attestation: $property" } } $entries = @($receipt.inventory.entries) $expectedCount = if ($env:EXPECTED_RELEASE_MODE -ceq 'upgrade') { 7 } else { 5 } if ($entries.Count -ne $expectedCount) { throw 'Sealed release receipt inventory count is invalid.' } $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $orderedPaths = [Collections.Generic.List[string]]::new() foreach ($entry in $entries) { $relative = [string]$entry.path if ($relative -match '(^|[\\/])\.\.?(?:[\\/]|$)' -or $relative.Contains('/') -or $relative.Contains(':') -or -not $seen.Add($relative) -or [string]$entry.sha256 -cnotmatch '^[0-9A-Fa-f]{64}$' -or [long]$entry.size -lt 0) { throw 'Sealed release receipt contains an unsafe inventory entry.' } $orderedPaths.Add($relative) $platformRelative = $relative.Replace('\', [IO.Path]::DirectorySeparatorChar) $path = [IO.Path]::GetFullPath((Join-Path $sealedRoot $platformRelative)) $prefix = $sealedRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar if (-not $path.StartsWith($prefix, [StringComparison]::Ordinal) -or -not (Test-Path -LiteralPath $path -PathType Leaf) -or (Get-Item -LiteralPath $path).Length -ne [long]$entry.size -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne ([string]$entry.sha256).ToUpperInvariant()) { throw 'Sealed release artifact differs from its exact receipt.' } } $sortedPaths = @($orderedPaths | Sort-Object -CaseSensitive) if ([string]::Join("`n", $orderedPaths) -cne [string]::Join("`n", $sortedPaths)) { throw 'Sealed release receipt paths are not canonically ordered.' } $canonical = @($entries | ForEach-Object { [ordered]@{ path = [string]$_.path size = [long]$_.size sha256 = ([string]$_.sha256).ToUpperInvariant() } }) | ConvertTo-Json -Depth 8 -Compress $inventorySha256 = [Convert]::ToHexString( [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($canonical)) ) if ($inventorySha256 -cne [string]$receipt.inventory.sha256) { throw 'Sealed release receipt inventory aggregate is invalid.' } $actualPaths = @( Get-ChildItem -LiteralPath $sealedRoot -Recurse -Force -File | ForEach-Object { [IO.Path]::GetRelativePath($sealedRoot, $_.FullName).Replace('/', '\') } | Sort-Object -CaseSensitive ) $expectedPaths = @($orderedPaths) + @('sealed-release-receipt.json') $expectedPaths = @($expectedPaths | Sort-Object -CaseSensitive) if ([string]::Join("`n", $actualPaths) -cne [string]::Join("`n", $expectedPaths)) { throw 'Sealed release extracted topology is not exact before attestation.' } - name: Attest bootstrap Windows artifacts if: needs.certify-windows.outputs.release_mode == 'bootstrap' uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 with: subject-path: | ${{ runner.temp }}/waggle-windows-sealed/release/*-setup.exe ${{ runner.temp }}/waggle-windows-sealed/release/windows-installer-certificate.json ${{ runner.temp }}/waggle-windows-sealed/source/release-nsis/installer.nsi ${{ runner.temp }}/waggle-windows-sealed/source/resources/service.js ${{ runner.temp }}/waggle-windows-sealed/provenance/provenance-receipt.json ${{ runner.temp }}/waggle-windows-sealed/sealed-release-receipt.json - name: Attest upgrade Windows artifacts if: needs.certify-windows.outputs.release_mode == 'upgrade' uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 with: subject-path: | ${{ runner.temp }}/waggle-windows-sealed/release/*-setup.exe ${{ runner.temp }}/waggle-windows-sealed/release/windows-installer-certificate.json ${{ runner.temp }}/waggle-windows-sealed/release/windows-installer-upgrade-certificate.json ${{ runner.temp }}/waggle-windows-sealed/source/release-nsis/installer.nsi ${{ runner.temp }}/waggle-windows-sealed/source/resources/service.js ${{ runner.temp }}/waggle-windows-sealed/provenance/provenance-receipt.json ${{ runner.temp }}/waggle-windows-sealed/sealed-release-receipt.json publish-windows: needs: [certify-windows, attest-windows] if: >- vars.WINDOWS_PUBLIC_RELEASE_AUTHORIZED == 'true' && github.event.repository.private == false && startsWith(github.ref, 'refs/tags/v') runs-on: windows-latest permissions: contents: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 persist-credentials: false - name: Validate immutable publication boundary shell: pwsh run: | $version = [string](Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' | ConvertFrom-Json).version $checkedOutRevision = (git rev-parse --verify HEAD).Trim().ToLowerInvariant() if ($env:GITHUB_REPOSITORY -cne 'marolinik/waggle-os' -or $env:GITHUB_REF_TYPE -cne 'tag' -or $env:GITHUB_REF_NAME -cne "v$version" -or $checkedOutRevision -cne $env:GITHUB_SHA -or @(git status --porcelain=v1 --untracked-files=all).Count -ne 0) { throw 'Windows publication requires the clean exact repository, tag, revision, and version boundary.' } - name: Download sealed Windows release outputs uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: artifact-ids: ${{ needs.certify-windows.outputs.artifact_id }} path: ${{ runner.temp }}\waggle-windows-sealed merge-multiple: true - name: Stage exact publication inputs id: publication-inputs shell: pwsh env: WAGGLE_RELEASE_MODE: ${{ needs.certify-windows.outputs.release_mode }} EXPECTED_CANDIDATE_SHA256: ${{ needs.certify-windows.outputs.candidate_sha256 }} EXPECTED_CANDIDATE_VERSION: ${{ needs.certify-windows.outputs.candidate_version }} EXPECTED_BOOTSTRAP_IDENTITY: ${{ needs.certify-windows.outputs.bootstrap_identity }} EXPECTED_BASE_TAG: ${{ needs.certify-windows.outputs.upgrade_base_tag }} EXPECTED_BASE_ASSET_NAME: ${{ needs.certify-windows.outputs.upgrade_base_asset_name }} EXPECTED_BASE_SHA256: ${{ needs.certify-windows.outputs.upgrade_base_sha256 }} EXPECTED_BASE_COMMIT: ${{ needs.certify-windows.outputs.upgrade_base_commit }} EXPECTED_SIGNER_SUBJECT: ${{ needs.certify-windows.outputs.signer_subject }} EXPECTED_SEALED_RECEIPT_SHA256: ${{ needs.certify-windows.outputs.sealed_receipt_sha256 }} EXPECTED_SEALED_ARTIFACT_ID: ${{ needs.certify-windows.outputs.artifact_id }} EXPECTED_SEALED_ARTIFACT_DIGEST: ${{ needs.certify-windows.outputs.artifact_digest }} run: | $sealedRoot = Join-Path $env:RUNNER_TEMP 'waggle-windows-sealed' $sealedReceiptPath = Join-Path $sealedRoot 'sealed-release-receipt.json' foreach ($digest in @( $env:EXPECTED_SEALED_RECEIPT_SHA256, $env:EXPECTED_SEALED_ARTIFACT_DIGEST, $env:EXPECTED_CANDIDATE_SHA256 )) { if ($digest -cnotmatch '^[0-9A-Fa-f]{64}$') { throw 'Publication artifact digest is invalid.' } } if ($env:EXPECTED_SEALED_ARTIFACT_ID -cnotmatch '^[1-9][0-9]*$' -or -not (Test-Path -LiteralPath $sealedReceiptPath -PathType Leaf) -or (Get-FileHash -LiteralPath $sealedReceiptPath -Algorithm SHA256).Hash -cne $env:EXPECTED_SEALED_RECEIPT_SHA256.ToUpperInvariant()) { throw 'Publication immutable artifact or sealed receipt binding is invalid.' } $sealedReceipt = Get-Content -Raw -LiteralPath $sealedReceiptPath | ConvertFrom-Json -Depth 32 $expectedBindings = [ordered]@{ sourceRevision = $env:GITHUB_SHA candidateTag = $env:GITHUB_REF_NAME candidateVersion = $env:EXPECTED_CANDIDATE_VERSION releaseMode = $env:WAGGLE_RELEASE_MODE bootstrapIdentity = $env:EXPECTED_BOOTSTRAP_IDENTITY upgradeBaseTag = $env:EXPECTED_BASE_TAG upgradeBaseAssetName = $env:EXPECTED_BASE_ASSET_NAME upgradeBaseSha256 = $env:EXPECTED_BASE_SHA256 upgradeBaseCommit = $env:EXPECTED_BASE_COMMIT signerSubject = $env:EXPECTED_SIGNER_SUBJECT candidateSha256 = $env:EXPECTED_CANDIDATE_SHA256 } if ($sealedReceipt.schemaVersion -is [string] -or [int]$sealedReceipt.schemaVersion -ne 1) { throw 'Publication sealed receipt schema is invalid.' } foreach ($property in $expectedBindings.Keys) { if (-not [string]::Equals( [string]$sealedReceipt.$property, [string]$expectedBindings[$property], [StringComparison]::Ordinal )) { throw "Publication sealed receipt binding differs: $property" } } $entries = @($sealedReceipt.inventory.entries) $expectedEntryCount = if ($env:WAGGLE_RELEASE_MODE -ceq 'upgrade') { 7 } else { 5 } if ($entries.Count -ne $expectedEntryCount) { throw 'Publication sealed receipt inventory count is invalid.' } $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $orderedPaths = [Collections.Generic.List[string]]::new() foreach ($entry in $entries) { $relative = [string]$entry.path if ($relative -match '(^|[\\/])\.\.?(?:[\\/]|$)' -or $relative.Contains('/') -or $relative.Contains(':') -or -not $seen.Add($relative) -or [string]$entry.sha256 -cnotmatch '^[0-9A-Fa-f]{64}$' -or [long]$entry.size -lt 0) { throw 'Publication sealed receipt contains an unsafe inventory entry.' } $orderedPaths.Add($relative) $path = [IO.Path]::GetFullPath((Join-Path $sealedRoot $relative)) $prefix = [IO.Path]::GetFullPath($sealedRoot).TrimEnd('\') + '\' if (-not $path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) -or -not (Test-Path -LiteralPath $path -PathType Leaf) -or (Get-Item -LiteralPath $path).Length -ne [long]$entry.size -or (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne ([string]$entry.sha256).ToUpperInvariant()) { throw 'Publication artifact file differs from its sealed receipt.' } } $sortedPaths = @($orderedPaths | Sort-Object -CaseSensitive) if ([string]::Join("`n", $orderedPaths) -cne [string]::Join("`n", $sortedPaths)) { throw 'Publication sealed receipt paths are not canonically ordered.' } $canonical = @($entries | ForEach-Object { [ordered]@{ path = [string]$_.path size = [long]$_.size sha256 = ([string]$_.sha256).ToUpperInvariant() } }) | ConvertTo-Json -Depth 8 -Compress $inventorySha256 = [Convert]::ToHexString( [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($canonical)) ) if ($inventorySha256 -cne [string]$sealedReceipt.inventory.sha256) { throw 'Publication sealed receipt inventory aggregate is invalid.' } $actualPaths = @( Get-ChildItem -LiteralPath $sealedRoot -Recurse -Force -File | ForEach-Object { [IO.Path]::GetRelativePath($sealedRoot, $_.FullName).Replace('/', '\') } | Sort-Object -CaseSensitive ) $expectedPaths = @($orderedPaths) + @('sealed-release-receipt.json') $expectedPaths = @($expectedPaths | Sort-Object -CaseSensitive) if ([string]::Join("`n", $actualPaths) -cne [string]::Join("`n", $expectedPaths)) { throw 'Sealed publication artifact topology is not exact.' } $sourceReleaseRoot = Join-Path $sealedRoot 'release' $sealedSourceRoot = Join-Path $sealedRoot 'source' $sealedProvenanceRoot = Join-Path $sealedRoot 'provenance' $installers = @(Get-ChildItem -LiteralPath $sourceReleaseRoot -Filter '*-setup.exe' -File) if ($installers.Count -ne 1 -or -not [string]::Equals( (Get-FileHash -LiteralPath $installers[0].FullName -Algorithm SHA256).Hash, [string]$env:EXPECTED_CANDIDATE_SHA256, [StringComparison]::OrdinalIgnoreCase )) { throw 'Sealed publication artifact does not contain exactly the certified candidate installer.' } $cleanReceipt = Get-Item -LiteralPath ( Join-Path $sourceReleaseRoot 'windows-installer-certificate.json' ) $upgradeReceiptPath = Join-Path ` $sourceReleaseRoot 'windows-installer-upgrade-certificate.json' $expectedReleaseFileCount = if ($env:WAGGLE_RELEASE_MODE -ceq 'upgrade') { 3 } else { 2 } if (@(Get-ChildItem -LiteralPath $sourceReleaseRoot -File).Count -ne $expectedReleaseFileCount -or ($env:WAGGLE_RELEASE_MODE -ceq 'upgrade') -ne (Test-Path -LiteralPath $upgradeReceiptPath -PathType Leaf)) { throw 'Sealed release directory does not contain exactly the installer and required certificate receipt(s).' } $sealedGeneratedInstaller = Get-Item -LiteralPath ( Join-Path $sealedSourceRoot 'release-nsis\installer.nsi' ) $sealedService = Get-Item -LiteralPath ( Join-Path $sealedSourceRoot 'resources\service.js' ) $sealedProvenance = @(Get-ChildItem -LiteralPath $sealedProvenanceRoot -File) if (@(Get-ChildItem -LiteralPath $sealedSourceRoot -Recurse -File).Count -ne 2 -or $sealedProvenance.Count -ne 1 -or $sealedProvenance[0].Name -cne 'provenance-receipt.json') { throw 'Sealed publication source and provenance topology is not exact.' } $cleanReceiptData = Get-Content -Raw -LiteralPath $cleanReceipt.FullName | ConvertFrom-Json -Depth 32 if ((Get-FileHash -LiteralPath $sealedGeneratedInstaller.FullName -Algorithm SHA256).Hash -cne [string]$cleanReceiptData.evidence.generatedInstallerScriptSha256 -or (Get-FileHash -LiteralPath $sealedService.FullName -Algorithm SHA256).Hash -cne [string]$cleanReceiptData.evidence.sidecarBundleSha256) { throw 'Sealed publication source inputs differ from their certified hashes.' } $targetReleaseRoot = 'app/src-tauri/target/x86_64-pc-windows-msvc/release' if (Test-Path -LiteralPath $targetReleaseRoot) { throw 'Publication target already exists.' } $publicationRoot = New-Item -ItemType Directory -Path (Join-Path $targetReleaseRoot 'bundle\nsis') $generatedNsisRoot = New-Item -ItemType Directory -Path (Join-Path $targetReleaseRoot 'nsis') Copy-Item -LiteralPath $installers[0].FullName ` -Destination $publicationRoot.FullName -ErrorAction Stop Copy-Item -LiteralPath $cleanReceipt.FullName ` -Destination $publicationRoot.FullName -ErrorAction Stop if ($env:WAGGLE_RELEASE_MODE -ceq 'upgrade') { Copy-Item -LiteralPath $upgradeReceiptPath ` -Destination $publicationRoot.FullName -ErrorAction Stop } Copy-Item -LiteralPath $sealedGeneratedInstaller.FullName ` -Destination (Join-Path $generatedNsisRoot.FullName 'installer.nsi') -ErrorAction Stop $canonicalServicePath = [IO.Path]::GetFullPath('app/src-tauri/resources/service.js') if (Test-Path -LiteralPath $canonicalServicePath) { throw 'Fresh publication checkout unexpectedly already contains generated service.js.' } [IO.File]::Copy($sealedService.FullName, $canonicalServicePath, $false) if (@(Get-ChildItem -LiteralPath $publicationRoot.FullName -File).Count -ne $expectedReleaseFileCount) { throw 'Publication bundle contains an unexpected asset; provenance must remain separate.' } $baseInstallerPath = '' if ($env:WAGGLE_RELEASE_MODE -ceq 'upgrade') { $baselineRoot = Join-Path $sealedRoot 'baseline' $baseInstallers = @(Get-ChildItem -LiteralPath $baselineRoot -File) if ($baseInstallers.Count -ne 1 -or $baseInstallers[0].Name -cne $env:EXPECTED_BASE_ASSET_NAME -or -not [string]::Equals( (Get-FileHash -LiteralPath $baseInstallers[0].FullName -Algorithm SHA256).Hash, [string]$env:EXPECTED_BASE_SHA256, [StringComparison]::OrdinalIgnoreCase )) { throw 'Sealed publication artifact does not contain the exact protected upgrade baseline.' } $baseInstallerPath = $baseInstallers[0].FullName } elseif (Test-Path -LiteralPath (Join-Path $sealedRoot 'baseline')) { throw 'Bootstrap sealed publication artifact contains an unexpected upgrade baseline.' } "WAGGLE_UPGRADE_BASE_INSTALLER_PATH=$baseInstallerPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Publish certified Windows release if: success() && startsWith(github.ref, 'refs/tags/v') shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} WINDOWS_BOOTSTRAP_RELEASE_IDENTITY: ${{ needs.certify-windows.outputs.bootstrap_identity }} WINDOWS_UPGRADE_BASE_TAG: ${{ needs.certify-windows.outputs.upgrade_base_tag }} WINDOWS_UPGRADE_BASE_ASSET_NAME: ${{ needs.certify-windows.outputs.upgrade_base_asset_name }} WINDOWS_UPGRADE_BASE_SHA256: ${{ needs.certify-windows.outputs.upgrade_base_sha256 }} WINDOWS_UPGRADE_BASE_COMMIT: ${{ needs.certify-windows.outputs.upgrade_base_commit }} WINDOWS_CODESIGN_APPROVED_SUBJECT: ${{ needs.certify-windows.outputs.signer_subject }} WAGGLE_RELEASE_MODE: ${{ needs.certify-windows.outputs.release_mode }} WAGGLE_UPGRADE_BASE_COMMIT: ${{ needs.certify-windows.outputs.upgrade_base_commit }} WAGGLE_CERTIFIED_CANDIDATE_SHA256: ${{ needs.certify-windows.outputs.candidate_sha256 }} WAGGLE_CERTIFIED_CANDIDATE_VERSION: ${{ needs.certify-windows.outputs.candidate_version }} run: | if ($env:WAGGLE_RELEASE_MODE -notin @('bootstrap', 'upgrade')) { throw 'Protected signer did not authorize a publication mode.' } $baseVersion = '' if ($env:WAGGLE_RELEASE_MODE -ceq 'upgrade') { if ($env:WINDOWS_UPGRADE_BASE_TAG -notmatch '^v(?\d+\.\d+\.\d+)$') { throw 'Protected upgrade base tag is invalid during publication.' } $baseVersion = $Matches['version'] } $env:WAGGLE_UPGRADE_BASE_VERSION = $baseVersion ./scripts/publish-windows-release.ps1 -Mode $env:WAGGLE_RELEASE_MODE build-macos: strategy: matrix: include: - target: aarch64-apple-darwin arch: arm64 runner: macos-15 - target: x86_64-apple-darwin arch: x64 runner: macos-15-intel runs-on: ${{ matrix.runner }} env: TARGET_ARCH: ${{ matrix.arch }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22.23.2 cache: npm - name: Verify runner architecture run: node -e "if (process.arch !== process.env.TARGET_ARCH) { console.error('Expected ' + process.env.TARGET_ARCH + ' runner, got ' + process.arch); process.exit(1); }" - name: Setup Rust uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 with: toolchain: 1.94.0 targets: ${{ matrix.target }} - name: Rust cache uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: app/src-tauri - name: Install dependencies run: npm ci - name: Install locked Tauri CLI run: npm ci --prefix app --ignore-scripts - name: Build packages (shared -> core -> agent -> server) run: npm run build:packages - name: Bundle Node.js runtime run: node scripts/bundle-node.mjs - name: Build sidecar run: node scripts/build-sidecar.mjs - name: Bundle native dependencies run: node scripts/bundle-native-deps.mjs - name: Stage sidecar dependencies run: node scripts/stage-sidecar-deps.mjs - name: Build frontend run: cd apps/web && npx vite build - name: Build Tauri (macOS) run: cd app && node node_modules/@tauri-apps/cli/tauri.js build --target ${{ matrix.target }} env: TAURI_PRIVATE_KEY: '' TAURI_KEY_PASSWORD: '' - name: Upload macOS DMG verification artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: waggle-macos-dmg-verification-${{ matrix.target }}-${{ github.sha }} path: app/src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg if-no-files-found: error retention-days: 7 - name: Upload macOS app verification artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: waggle-macos-app-verification-${{ matrix.target }}-${{ github.sha }} path: app/src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app if-no-files-found: error retention-days: 7 # NOTE: the Tauri auto-updater is disabled for v1 (plugins.updater removed from # tauri.conf.json — see BUILD P0-2 / P1-8). The former `update-manifest` job # published a latest.json with EMPTY signatures, which every client rejected at # signature verification. Re-enabling the updater requires: # 1. Provision a TAURI_SIGNING_PRIVATE_KEY (+ password) repo secret. # 2. Restore `plugins.updater` (endpoints + pubkey) in tauri.conf.json and # set bundle.createUpdaterArtifacts so tauri-action emits signed .sig files. # 3. Restore a latest.json generator that reads the real signatures from the # build artifacts (tauri-action can publish the manifest directly).