2563 lines
144 KiB
YAML
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).
|