Files
waggle-os/.github/workflows/release.yml
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

2563 lines
144 KiB
YAML

# 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(?<version>\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(?<version>\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(?<version>\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).