From a460d4b7bf7a056458288dfe87a38747cc51fe57 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Tue, 22 Sep 2026 18:06:13 -0700 Subject: [PATCH] Add the Windows release-publishing path Windows packaging and Authenticode signing were already implemented and tested, but nothing ever ran them: no workflow referenced package.ps1, and every published release carries only graphcode-macos-arm64.dmg. The gap was release plumbing, not cryptography, and it can be built without a certificate. Tools/windows/release.ps1 is the single decision point between package.ps1 and a release asset. It resolves a release tag to a package version, imports signing material only when it is supplied in full, pins the imported certificate to the declared thumbprint, re-verifies the built package against the expected publisher, and refuses to continue when the package's own metadata.json contradicts what the run actually did. Signed builds publish as graphcode-windows-x86_64.zip, mirroring the versionless macOS DMG; unsigned builds are named -unsigned and cannot be attached to a release without an explicit opt-in. Partial signing secrets fail the run instead of silently downgrading to unsigned. windows-release.yml is workflow_dispatch-only, matching the maintainer-driven macOS process (make release-dmg plus a manual gh release create); it never fires on a tag push or a published release, defaults publish to false, and always uploads the build as an inspectable workflow artifact. This is a prerequisite only. No certificate is provisioned, so the workflow currently produces a clearly labeled unsigned development artifact, and no in-app updater download/install path is added. The Install progress and Relaunch prompt ledger rows stay Blocked. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/windows-release.yml | 88 +++++++ Tools/windows/PACKAGING.md | 76 ++++++ Tools/windows/Tests/Release.Tests.ps1 | 350 +++++++++++++++++++++++++ Tools/windows/release.ps1 | 247 +++++++++++++++++ Tools/windows/stage-swift-products.ps1 | 79 ++++++ Tools/windows/validate.ps1 | 1 + Tools/windows/validation-matrix.md | 1 + graphcode-windows/README.md | 6 + investigation/ui-parity-matrix.md | 4 +- 9 files changed, 850 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/windows-release.yml create mode 100644 Tools/windows/Tests/Release.Tests.ps1 create mode 100644 Tools/windows/release.ps1 create mode 100644 Tools/windows/stage-swift-products.ps1 diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 00000000..6fe72b71 --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -0,0 +1,88 @@ +name: Windows release publishing + +# The macOS DMG is built and attached by a maintainer rather than by CI, so this +# workflow is deliberately manual too: it never runs on a tag push or on a +# published GitHub release, and it does not attach anything unless explicitly +# asked to. + +on: + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to build (for example v0.1.74)" + required: true + type: string + publish: + description: "Attach the built asset to that tag's GitHub release" + required: false + type: boolean + default: false + allow_unsigned_publish: + description: "Allow attaching a clearly labeled UNSIGNED development artifact" + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + package: + name: "Build and (optionally) attach the Windows artifact" + runs-on: windows-2022 + permissions: + contents: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6 # v0.4.1 + with: + swift-version: swift-6.3.3-release + swift-build: 6.3.3-RELEASE + cache: true + + - name: Bootstrap exact Windows dependencies + shell: pwsh + run: ./Tools/windows/bootstrap.ps1 -ToolRoot .ci-tools -ProviderRoot .ci-providers + + - name: Stage the Swift daemon and CLI for packaging + shell: pwsh + run: ./Tools/windows/stage-swift-products.ps1 + + - name: Mask the declared signing thumbprint + shell: pwsh + env: + GRAPHCODE_SIGNING_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_THUMBPRINT }} + run: | + if ($env:GRAPHCODE_SIGNING_THUMBPRINT) { + Write-Host "::add-mask::$env:GRAPHCODE_SIGNING_THUMBPRINT" + } + + # With none of the signing secrets configured this step still succeeds and + # produces graphcode-windows-x86_64-unsigned.zip, which release.ps1 will + # not attach to a release unless allow_unsigned_publish was requested. + - name: Package the Windows release artifact + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_PUBLISH: ${{ inputs.publish }} + RELEASE_ALLOW_UNSIGNED_PUBLISH: ${{ inputs.allow_unsigned_publish }} + GRAPHCODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE }} + GRAPHCODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERTIFICATE_PASSWORD }} + GRAPHCODE_SIGNING_THUMBPRINT: ${{ secrets.WINDOWS_SIGNING_THUMBPRINT }} + GRAPHCODE_SIGNING_TIMESTAMP_URL: ${{ secrets.WINDOWS_SIGNING_TIMESTAMP_URL }} + run: | + $arguments = @( + "-Tag", $env:RELEASE_TAG, + "-OutputDirectory", ".build/windows/release-publish") + if ($env:RELEASE_PUBLISH -eq "true") { $arguments += "-Publish" } + if ($env:RELEASE_ALLOW_UNSIGNED_PUBLISH -eq "true") { $arguments += "-AllowUnsignedPublish" } + ./Tools/windows/release.ps1 @arguments + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: graphcode-windows-x86_64 + path: .build/windows/release-publish/publish/ + if-no-files-found: warn diff --git a/Tools/windows/PACKAGING.md b/Tools/windows/PACKAGING.md index 41ea96bf..d3e0b808 100644 --- a/Tools/windows/PACKAGING.md +++ b/Tools/windows/PACKAGING.md @@ -202,6 +202,82 @@ production publisher trust. `Packaging.Scheduler.Tests.ps1` exercises an owned idle task through native stop/delete and verifies the actual missing-task HRESULT without suppressing account or permission errors. +## Publishing a Windows release + +`Tools/windows/release.ps1` is the only supported path from `package.ps1` to a +GitHub release asset, and `.github/workflows/windows-release.yml` is the only +thing that runs it in CI. + +Like the macOS DMG — which a maintainer builds locally with `make release-dmg` +and attaches with `gh release create` — Windows publication is deliberately +manual. The workflow has a single `workflow_dispatch` trigger and never fires on +a tag push or on a published release. Its inputs are the existing release `tag`, +`publish` (default `false`), and `allow_unsigned_publish` (default `false`). The +built artifact is always uploaded as a workflow artifact, so a build can be +inspected without touching any release. + +```powershell +pwsh -NoProfile -File Tools\windows\release.ps1 -Tag v1.2.3 +pwsh -NoProfile -File Tools\windows\release.ps1 -Tag v1.2.3 -Publish +``` + +The tag supplies the package version (`v1.2.3` and `1.2.3-beta1` are accepted; +`dev` versions and anything that is not a release version are refused before +anything is built). `release.ps1` then builds through `package.ps1`, re-runs +`package.ps1 -Command Verify` — pinned to the expected publisher when signing — +reads the built package's own `metadata.json`, and **refuses to continue if the +package's declared signing state contradicts what the run actually did**. + +Locally, `Tools\windows\stage-swift-products.ps1` produces the Swift half of the +release inputs (`graphcoded.exe`, `graphcode.exe`, and the Swift runtime DLLs in +`.build\windows\release`); `package.ps1` builds the versioned shell itself from +the pinned providers. + +### Asset names + +| Build | Asset | Publishable | +|---|---|---| +| signed | `graphcode-windows-x86_64.zip` + `.sha256` | yes | +| unsigned | `graphcode-windows-x86_64-unsigned.zip` + `.sha256` | only with `allow_unsigned_publish` | + +The signed name is versionless, matching `graphcode-macos-arm64.dmg`, so +`releases/latest/download/graphcode-windows-x86_64.zip` resolves. An unsigned +development build can never occupy that name. + +### Secrets that enable signing + +Set these as repository secrets (**Settings → Secrets and variables → Actions → +New repository secret**). No certificate material is stored in this repository. + +| Secret | Format | Required | +|---|---|---| +| `WINDOWS_SIGNING_CERTIFICATE` | Base64 text of a PFX holding the code-signing certificate **and** its private key (`[Convert]::ToBase64String([IO.File]::ReadAllBytes('signing.pfx'))`) | yes | +| `WINDOWS_SIGNING_CERTIFICATE_PASSWORD` | That PFX's password | yes | +| `WINDOWS_SIGNING_THUMBPRINT` | The certificate's SHA-1 thumbprint, exactly 40 hexadecimal characters (`package.ps1` validates `^[0-9a-fA-F]{40}$`) | yes | +| `WINDOWS_SIGNING_TIMESTAMP_URL` | RFC 3161 timestamp service `https://` URL | recommended | + +The PFX is required because release jobs run on ephemeral GitHub-hosted runners, +which have no certificate store to pre-provision. The workflow imports it into +`Cert:\CurrentUser\My`, **requires the imported certificate's own thumbprint to +equal `WINDOWS_SIGNING_THUMBPRINT`**, passes only that thumbprint to +`package.ps1 -SignCertificate`, and removes both the imported certificate and the +decoded PFX before the job ends. A mismatched or malformed thumbprint aborts +before anything is built. + +With **none** of the signing secrets configured the workflow still succeeds and +produces the unsigned development artifact described above. Supplying only +*some* of them is a hard failure rather than a silent downgrade to unsigned, so a +misconfigured secret can never be mistaken for a signed release. + +`Release.Tests.ps1` covers tag resolution, unsigned labeling and its publication +gate, incomplete signing material, thumbprint pinning and certificate cleanup, +the signing-state honesty gate, build/upload failure propagation, and the +workflow's own triggers, action pinning, and input defaults. It runs first under +`validate.ps1 -Task packaging` and needs no certificate, network access, or real +build; its signed path uses an ephemeral self-signed certificate that it removes +again. Publishing plumbing is not production-signing evidence: no certificate +exists yet, and no Windows asset has been published. + ## Retained provider sources Both exact public provider pins have the annotated source-retention tag diff --git a/Tools/windows/Tests/Release.Tests.ps1 b/Tools/windows/Tests/Release.Tests.ps1 new file mode 100644 index 00000000..175fff06 --- /dev/null +++ b/Tools/windows/Tests/Release.Tests.ps1 @@ -0,0 +1,350 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path +$releaseScript = Join-Path $repoRoot "Tools\windows\release.ps1" +if (-not (Test-Path -LiteralPath $releaseScript -PathType Leaf)) { + throw "Windows release orchestrator is missing: $releaseScript" +} +$tokens = $null +$errors = $null +foreach ($script in @($releaseScript, (Join-Path $repoRoot "Tools\windows\stage-swift-products.ps1"))) { + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) { + throw "Windows release tooling is missing: $script" + } + [void] [Management.Automation.Language.Parser]::ParseFile($script, [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw "$script has parse errors: $errors" } +} + +$fixture = Join-Path ([IO.Path]::GetTempPath()) "graphcode-release-$([guid]::NewGuid())" +$stubPackage = Join-Path $fixture "stub-package.ps1" +$stubGh = Join-Path $fixture "stub-gh.ps1" +$log = Join-Path $fixture "invocations.log" +$installedThumbprint = $null + +function Reset-Log { Set-Content -LiteralPath $log -Value "" -Encoding utf8 } +function Get-Invocations { + @(Get-Content -LiteralPath $log | Where-Object { $_ } | ForEach-Object { $_ | ConvertFrom-Json }) +} +function Get-PackageCommands([string] $command) { + @(Get-Invocations | Where-Object { $_.tool -eq "package" -and $_.Command -eq $command }) +} +function Invoke-Release([hashtable] $parameters, [switch] $ExpectFailure, [string] $Message) { + Reset-Log + $arguments = @("-NoProfile", "-File", $releaseScript, + "-PackageScript", $stubPackage, "-GitHubCli", $stubGh) + foreach ($key in $parameters.Keys) { + $value = $parameters[$key] + if ($value -is [bool] -or $value -is [switch]) { + if ($value) { $arguments += "-$key" } + } else { + $arguments += @("-$key", [string] $value) + } + } + $output = & pwsh @arguments 2>&1 | Out-String + $code = $LASTEXITCODE + if ($ExpectFailure) { + if ($code -eq 0) { throw "RED: release was accepted but should have failed: $output" } + if ($Message -and $output -notmatch [regex]::Escape($Message)) { + throw "release failed for the wrong reason (expected '$Message'): $output" + } + } elseif ($code -ne 0) { + throw "release failed unexpectedly: $output" + } + return $output +} +function New-EphemeralCertificate { + $key = [Security.Cryptography.RSA]::Create(2048) + try { + $request = [Security.Cryptography.X509Certificates.CertificateRequest]::new( + "CN=GraphCode release publishing fixture", $key, + [Security.Cryptography.HashAlgorithmName]::SHA256, + [Security.Cryptography.RSASignaturePadding]::Pkcs1) + $usages = [Security.Cryptography.OidCollection]::new() + [void] $usages.Add([Security.Cryptography.Oid]::new("1.3.6.1.5.5.7.3.3")) + $request.CertificateExtensions.Add( + [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($usages, $false)) + $request.CertificateExtensions.Add( + [Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, $true)) + return $request.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddMinutes(-5), [DateTimeOffset]::UtcNow.AddHours(1)) + } finally { $key.Dispose() } +} + +try { + New-Item -ItemType Directory -Path $fixture -Force | Out-Null + Reset-Log + + # The stub stands in for package.ps1: it records the exact argument vector and + # produces a package whose metadata.json reports the signing state it was told + # to report, so mislabeling can be simulated without a certificate. + @' +[CmdletBinding()] +param( + [string] $Command = "Build", + [string] $InputDirectory, + [string] $OutputDirectory, + [string] $Package, + [string] $InstallRoot, + [string] $Version, + [string] $SignCertificate, + [string] $SignTimestampUrl, + [string] $SignToolPath, + [string] $TrustedSignerThumbprint, + [string] $WinghosttyRoot, + [string] $ZmxRoot, + [string] $Zig0152, + [string] $Zig0160 +) +$ErrorActionPreference = "Stop" +$record = [ordered]@{ tool = "package" } +foreach ($entry in $PSBoundParameters.GetEnumerator()) { $record[$entry.Key] = [string] $entry.Value } +$record | ConvertTo-Json -Compress | Add-Content -LiteralPath $env:GRAPHCODE_STUB_LOG +if ($env:GRAPHCODE_STUB_BUILD_FAILS -eq "1" -and $Command -eq "Build") { + Write-Error "stub package build failure" + exit 3 +} +if ($Command -eq "Verify") { + $root = $Package + if ([IO.Path]::GetExtension($root) -eq ".zip") { $root = $root.Substring(0, $root.Length - 4) } + $metadata = Get-Content -LiteralPath (Join-Path $root "metadata.json") -Raw | ConvertFrom-Json + if ($TrustedSignerThumbprint -and $metadata.signing -ne "signed") { + Write-Error "stub verify rejected an unsigned package under a trusted publisher pin" + exit 4 + } + Write-Output "Package verification: PASS" + exit 0 +} +$label = if ($env:GRAPHCODE_STUB_SIGNING_LABEL) { + $env:GRAPHCODE_STUB_SIGNING_LABEL +} elseif ($SignCertificate) { "signed" } else { "UNSIGNED (development artifact; not code signed)" } +$root = Join-Path $OutputDirectory "GraphCode-$Version-windows-x86_64" +if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } +New-Item -ItemType Directory -Path (Join-Path $root "bin") -Force | Out-Null +Set-Content -LiteralPath (Join-Path $root "bin\graphcode-windows.exe") "stub payload $Version" +[ordered]@{ schemaVersion = 1; version = $Version; platform = "windows-x86_64"; signing = $label } | + ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $root "metadata.json") -Encoding utf8 +$archive = Join-Path $OutputDirectory "GraphCode-$Version-windows-x86_64.zip" +if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force } +[IO.Compression.ZipFile]::CreateFromDirectory($root, $archive) +$hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() +Set-Content -LiteralPath "$archive.sha256" -Value "$hash $(Split-Path $archive -Leaf)" -Encoding ascii +Write-Output $archive +'@ | Set-Content -LiteralPath $stubPackage -Encoding utf8 + + @' +$ErrorActionPreference = "Stop" +([ordered]@{ tool = "gh"; arguments = @($args) } | ConvertTo-Json -Compress) | + Add-Content -LiteralPath $env:GRAPHCODE_STUB_LOG +if ($env:GRAPHCODE_STUB_GH_FAILS -eq "1") { exit 7 } +exit 0 +'@ | Set-Content -LiteralPath $stubGh -Encoding utf8 + + $env:GRAPHCODE_STUB_LOG = $log + $out = Join-Path $fixture "out" + + # 1. A release tag becomes the package version, and junk tags are refused + # before anything is built. + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out } | Out-Null + $build = Get-PackageCommands "Build" + if ($build.Count -ne 1 -or $build[0].Version -ne "0.1.74") { + throw "stable tag did not resolve to its package version: $($build | ConvertTo-Json -Compress)" + } + Invoke-Release @{ Tag = "0.1.74-beta1"; OutputDirectory = $out } | Out-Null + if ((Get-PackageCommands "Build")[0].Version -ne "0.1.74-beta1") { + throw "prerelease tag did not resolve to its package version" + } + foreach ($tag in @("release", "v1.2", "v1.2.3.4", "v1.2.3 && whoami", "vdev", "v0.0.0-dev")) { + Invoke-Release @{ Tag = $tag; OutputDirectory = $out } -ExpectFailure -Message "release tag" | Out-Null + if (@(Get-Invocations).Count -ne 0) { throw "invalid tag '$tag' still invoked packaging" } + } + Write-Output "Release tag resolution and rejection: PASS" + + # 2. Without signing material the artifact is built, labeled unsigned, and + # never published. + $output = Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out } + $unsignedAsset = Join-Path $out "publish\graphcode-windows-x86_64-unsigned.zip" + if (-not (Test-Path -LiteralPath $unsignedAsset)) { + throw "unsigned build did not stage a clearly labeled asset: $output" + } + if (Test-Path -LiteralPath (Join-Path $out "publish\graphcode-windows-x86_64.zip")) { + throw "unsigned build staged the signed asset name" + } + $sidecar = Get-Content -LiteralPath "$unsignedAsset.sha256" -Raw + $expected = (Get-FileHash -LiteralPath $unsignedAsset -Algorithm SHA256).Hash.ToLowerInvariant() + if ($sidecar.Trim() -ne "$expected graphcode-windows-x86_64-unsigned.zip") { + throw "checksum sidecar does not describe the published asset name: $sidecar" + } + $summary = Get-Content -LiteralPath (Join-Path $out "publish\release-summary.json") -Raw | ConvertFrom-Json + if ($summary.signed -ne $false -or $summary.signing -notmatch "^UNSIGNED" -or + $summary.published -ne $false -or $summary.tag -ne "v0.1.74" -or $summary.version -ne "0.1.74") { + throw "unsigned summary is not honest: $($summary | ConvertTo-Json -Compress)" + } + if (@(Get-Invocations | Where-Object tool -eq "gh").Count -ne 0) { + throw "a build-only run contacted the release API" + } + if ((Get-PackageCommands "Verify").Count -ne 1 -or (Get-PackageCommands "Verify")[0].PSObject.Properties.Name -contains "TrustedSignerThumbprint") { + throw "unsigned verification must not claim a trusted publisher pin" + } + Write-Output "Unsigned development artifact labeling: PASS" + + # 3. Publishing an unsigned artifact requires an explicit opt-in, and even + # then keeps the unsigned asset name. + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out; Publish = $true } ` + -ExpectFailure -Message "refusing to publish an unsigned artifact" | Out-Null + if (@(Get-Invocations | Where-Object tool -eq "gh").Count -ne 0) { + throw "a refused unsigned publish still contacted the release API" + } + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out; Publish = $true; AllowUnsignedPublish = $true } | Out-Null + $uploads = @(Get-Invocations | Where-Object tool -eq "gh") + if ($uploads.Count -ne 1) { throw "explicit unsigned publish did not upload exactly once" } + $arguments = @($uploads[0].arguments) + if ($arguments[0] -ne "release" -or $arguments[1] -ne "upload" -or $arguments[2] -ne "v0.1.74" -or + $arguments -notcontains "--clobber" -or + ($arguments | Where-Object { $_ -like "*\graphcode-windows-x86_64-unsigned.zip" }).Count -ne 1 -or + ($arguments | Where-Object { $_ -like "*\graphcode-windows-x86_64-unsigned.zip.sha256" }).Count -ne 1) { + throw "unexpected upload argument vector: $($arguments -join ' ')" + } + if (($arguments | Where-Object { $_ -like "*graphcode-windows-x86_64.zip*" -and $_ -notlike "*unsigned*" }).Count -ne 0) { + throw "an unsigned artifact was uploaded under the signed asset name" + } + Write-Output "Unsigned publication gate: PASS" + + # 4. Partial signing material is a hard failure, never a silent downgrade to + # an unsigned build. + $certificate = New-EphemeralCertificate + try { + $password = [guid]::NewGuid().ToString("N") + $pfxBytes = $certificate.Export( + [Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $password) + $pfx = [Convert]::ToBase64String($pfxBytes) + $thumbprint = $certificate.Thumbprint + $partials = @( + @{ SigningThumbprint = $thumbprint }, + @{ SigningCertificateBase64 = $pfx }, + @{ SigningCertificateBase64 = $pfx; SigningCertificatePassword = $password }, + @{ SigningCertificateBase64 = $pfx; SigningThumbprint = $thumbprint }, + @{ SigningCertificatePassword = $password; SigningThumbprint = $thumbprint } + ) + foreach ($partial in $partials) { + $parameters = @{ Tag = "v0.1.74"; OutputDirectory = $out } + $partial + Invoke-Release $parameters -ExpectFailure -Message "incomplete signing material" | Out-Null + if (@(Get-Invocations).Count -ne 0) { throw "incomplete signing material still invoked packaging" } + } + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out + SigningCertificateBase64 = $pfx; SigningCertificatePassword = $password + SigningThumbprint = ("A" * 40) } -ExpectFailure -Message "does not match" | Out-Null + if (@(Get-Invocations).Count -ne 0) { throw "a thumbprint mismatch still invoked packaging" } + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out + SigningCertificateBase64 = $pfx; SigningCertificatePassword = $password + SigningThumbprint = "not-a-thumbprint" } -ExpectFailure -Message "thumbprint" | Out-Null + Write-Output "Signing material completeness and thumbprint pinning: PASS" + + # 5. Complete signing material signs, verifies under the publisher pin, and + # publishes under the macOS-consistent versionless asset name. + $installedThumbprint = $thumbprint + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out + SigningCertificateBase64 = $pfx; SigningCertificatePassword = $password + SigningThumbprint = $thumbprint + SignTimestampUrl = "https://timestamp.example/rfc3161" + Publish = $true } | Out-Null + $installedThumbprint = $null + $build = Get-PackageCommands "Build" + if ($build.Count -ne 1 -or $build[0].SignCertificate -ne $thumbprint -or + $build[0].SignTimestampUrl -ne "https://timestamp.example/rfc3161") { + throw "signed build did not receive the pinned certificate and timestamp service" + } + $verify = Get-PackageCommands "Verify" + if ($verify.Count -ne 1 -or $verify[0].TrustedSignerThumbprint -ne $thumbprint) { + throw "signed package was not re-verified against the expected publisher" + } + $signedAsset = Join-Path $out "publish\graphcode-windows-x86_64.zip" + if (-not (Test-Path -LiteralPath $signedAsset)) { throw "signed asset name was not produced" } + if (Test-Path -LiteralPath (Join-Path $out "publish\graphcode-windows-x86_64-unsigned.zip")) { + throw "a signed run left an unsigned asset name behind" + } + $summary = Get-Content -LiteralPath (Join-Path $out "publish\release-summary.json") -Raw | ConvertFrom-Json + if ($summary.signed -ne $true -or $summary.signing -ne "signed" -or $summary.published -ne $true) { + throw "signed summary is not honest: $($summary | ConvertTo-Json -Compress)" + } + if (@(Get-ChildItem "Cert:\CurrentUser\My" | + Where-Object Thumbprint -eq $thumbprint).Count -ne 0) { + throw "the imported signing certificate was left in the user store" + } + Write-Output "Signed publication path and certificate cleanup: PASS" + + # 6. A package whose own metadata contradicts the requested signing state is + # never published under either name. + $env:GRAPHCODE_STUB_SIGNING_LABEL = "signed" + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out } ` + -ExpectFailure -Message "signing state" | Out-Null + $env:GRAPHCODE_STUB_SIGNING_LABEL = "UNSIGNED (development artifact; not code signed)" + $installedThumbprint = $thumbprint + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out + SigningCertificateBase64 = $pfx; SigningCertificatePassword = $password + SigningThumbprint = $thumbprint; Publish = $true } ` + -ExpectFailure -Message "signing state" | Out-Null + $installedThumbprint = $null + if (@(Get-Invocations | Where-Object tool -eq "gh").Count -ne 0) { + throw "a mislabeled package was still uploaded" + } + $env:GRAPHCODE_STUB_SIGNING_LABEL = $null + Write-Output "Package signing-state honesty gate: PASS" + } finally { + if ($installedThumbprint) { + Get-ChildItem "Cert:\CurrentUser\My" | + Where-Object Thumbprint -eq $installedThumbprint | + Remove-Item -Force -ErrorAction SilentlyContinue + } + $certificate.Dispose() + } + + # 7. A failed package build never reaches publication. + $env:GRAPHCODE_STUB_BUILD_FAILS = "1" + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out; Publish = $true; AllowUnsignedPublish = $true } ` + -ExpectFailure -Message "packaging" | Out-Null + if (@(Get-Invocations | Where-Object tool -eq "gh").Count -ne 0) { + throw "a failed build still uploaded a release asset" + } + $env:GRAPHCODE_STUB_BUILD_FAILS = $null + $env:GRAPHCODE_STUB_GH_FAILS = "1" + Invoke-Release @{ Tag = "v0.1.74"; OutputDirectory = $out; Publish = $true; AllowUnsignedPublish = $true } ` + -ExpectFailure -Message "upload" | Out-Null + $env:GRAPHCODE_STUB_GH_FAILS = $null + Write-Output "Build and upload failure propagation: PASS" + + # 8. The workflow that drives this script keeps the same guarantees. + $workflow = Get-Content -LiteralPath (Join-Path $repoRoot ".github\workflows\windows-release.yml") -Raw + foreach ($required in @( + "workflow_dispatch:", "Tools/windows/release.ps1", "actions/checkout@", "add-mask", + "GRAPHCODE_SIGNING_CERTIFICATE", "GRAPHCODE_SIGNING_CERTIFICATE_PASSWORD", + "GRAPHCODE_SIGNING_THUMBPRINT", "GRAPHCODE_SIGNING_TIMESTAMP_URL")) { + if ($workflow -notmatch [regex]::Escape($required)) { + throw "the release workflow no longer references $required" + } + } + if ($workflow -match "(?m)^on:\s*$\s*(.*\n)*?\s*(push|release|schedule):") { + throw "the release workflow gained an automatic publishing trigger" + } + foreach ($uses in [regex]::Matches($workflow, "(?m)uses:\s*(\S+)")) { + if ($uses.Groups[1].Value -notmatch "@[0-9a-f]{40}$") { + throw "the release workflow uses an unpinned action: $($uses.Groups[1].Value)" + } + } + foreach ($default in [regex]::Matches($workflow, "(?ms)(publish|allow_unsigned_publish):.*?default:\s*(\S+)")) { + if ($default.Groups[2].Value -notmatch "^(false|'false'|`"false`")$") { + throw "a publishing input no longer defaults to false: $($default.Groups[0].Value)" + } + } + Write-Output "Release workflow contract: PASS" +} finally { + foreach ($name in @("GRAPHCODE_STUB_LOG", "GRAPHCODE_STUB_SIGNING_LABEL", + "GRAPHCODE_STUB_BUILD_FAILS", "GRAPHCODE_STUB_GH_FAILS")) { + Remove-Item -LiteralPath "env:$name" -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $fixture) { + Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/Tools/windows/release.ps1 b/Tools/windows/release.ps1 new file mode 100644 index 00000000..9a6fc8e1 --- /dev/null +++ b/Tools/windows/release.ps1 @@ -0,0 +1,247 @@ +<# +.SYNOPSIS + Builds, labels, and optionally publishes the Windows release artifact. + +.DESCRIPTION + This is the single decision point between Tools/windows/package.ps1 and a + GitHub release asset. It resolves a release tag to a package version, imports + signing material when it is supplied in full, and refuses to let a build whose + actual signing state contradicts the requested one reach a release. + + Signing is optional. With no signing material the run still succeeds and + produces an artifact named and labeled as an unsigned development build, which + cannot be published without an explicit opt-in. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $Tag, + [string] $OutputDirectory, + [string] $WinghosttyRoot = $env:GRAPHCODE_WINGHOSTTY_ROOT, + [string] $ZmxRoot = $env:GRAPHCODE_ZMX_ROOT, + [string] $Zig0152 = $env:GRAPHCODE_ZIG0152, + [string] $Zig0160 = $env:GRAPHCODE_ZIG0160, + [string] $SigningCertificateBase64 = $env:GRAPHCODE_SIGNING_CERTIFICATE, + [string] $SigningCertificatePassword = $env:GRAPHCODE_SIGNING_CERTIFICATE_PASSWORD, + [string] $SigningThumbprint = $env:GRAPHCODE_SIGNING_THUMBPRINT, + [string] $SignTimestampUrl = $env:GRAPHCODE_SIGNING_TIMESTAMP_URL, + [string] $PackageScript, + [string] $GitHubCli = "gh", + [switch] $Publish, + [switch] $AllowUnsignedPublish +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +if (-not $PackageScript) { $PackageScript = Join-Path $repoRoot "Tools\windows\package.ps1" } +if (-not (Test-Path -LiteralPath $PackageScript -PathType Leaf)) { + throw "packaging script was not found: $PackageScript" +} +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $repoRoot ".build\windows\release-publish" } + +$UnsignedLabelPrefix = "UNSIGNED" +$importedCertificatePath = $null +$certificateFile = $null + +function Resolve-ReleaseVersion([string] $tag) { + $candidate = $tag.Trim() + if ($candidate -notmatch "^v?([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.]*)?)$") { + throw "release tag does not name a publishable version: '$tag'" + } + $version = $Matches[1] + if ($version -in @("0.0.0-dev") -or $version -match "(?i)(^|[-.])dev([-.]|$)") { + throw "release tag does not name a publishable version: '$tag'" + } + return $version +} + +function Resolve-SigningRequest { + $supplied = @( + @{ name = "certificate"; value = $SigningCertificateBase64 }, + @{ name = "certificate password"; value = $SigningCertificatePassword }, + @{ name = "certificate thumbprint"; value = $SigningThumbprint } + ) + $present = @($supplied | Where-Object { $_.value }) + if ($present.Count -eq 0) { + if ($SignTimestampUrl) { + Write-Warning "A timestamp service was configured without signing material; building unsigned." + } + return $false + } + if ($present.Count -ne $supplied.Count) { + $missing = @($supplied | Where-Object { -not $_.value } | ForEach-Object { $_.name }) + throw "incomplete signing material: $($missing -join ', ') was not supplied. " + + "Supply all of the signing secrets or none of them; a partial configuration is never " + + "downgraded to an unsigned build." + } + if ($SigningThumbprint -notmatch "^[0-9a-fA-F]{40}$") { + throw "signing certificate thumbprint must be 40 hexadecimal characters" + } + if ($SignTimestampUrl -and $SignTimestampUrl -notmatch "^https://") { + throw "signing timestamp service must be an https URL" + } + return $true +} + +function Import-SigningCertificate([string] $staging) { + $script:certificateFile = Join-Path $staging "signing-$([guid]::NewGuid()).pfx" + try { + $bytes = [Convert]::FromBase64String($SigningCertificateBase64) + } catch { + throw "signing certificate is not valid base64-encoded PFX content" + } + [IO.File]::WriteAllBytes($script:certificateFile, $bytes) + $password = ConvertTo-SecureString $SigningCertificatePassword -AsPlainText -Force + $imported = Import-PfxCertificate -FilePath $script:certificateFile ` + -CertStoreLocation "Cert:\CurrentUser\My" -Password $password + $script:importedCertificatePath = "Cert:\CurrentUser\My\$($imported.Thumbprint)" + if ($imported.Thumbprint -ne $SigningThumbprint.ToUpperInvariant()) { + throw "the imported signing certificate thumbprint does not match the declared thumbprint" + } + if (-not $imported.HasPrivateKey) { + throw "the imported signing certificate has no private key" + } + return $imported.Thumbprint +} + +function Invoke-Packaging([string[]] $arguments, [string] $activity) { + & pwsh -NoProfile -File $PackageScript @arguments + if ($LASTEXITCODE -ne 0) { + throw "Windows packaging ($activity) failed with exit code $LASTEXITCODE" + } +} + +$version = Resolve-ReleaseVersion $Tag +$signingRequested = Resolve-SigningRequest +if ($Publish -and -not $signingRequested -and -not $AllowUnsignedPublish) { + throw "refusing to publish an unsigned artifact; pass -AllowUnsignedPublish to attach a " + + "clearly labeled development build to release $Tag" +} + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$OutputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path +$packages = Join-Path $OutputDirectory "packages" +$publishDirectory = Join-Path $OutputDirectory "publish" +$staging = Join-Path $OutputDirectory "staging" +foreach ($directory in @($publishDirectory, $staging)) { + if (Test-Path -LiteralPath $directory) { Remove-Item -LiteralPath $directory -Recurse -Force } +} +New-Item -ItemType Directory -Force -Path $packages, $publishDirectory, $staging | Out-Null + +try { + $thumbprint = $null + if ($signingRequested) { $thumbprint = Import-SigningCertificate $staging } + + $buildArguments = @("-Command", "Build", "-OutputDirectory", $packages, "-Version", $version) + foreach ($pair in @( + @{ name = "WinghosttyRoot"; value = $WinghosttyRoot }, + @{ name = "ZmxRoot"; value = $ZmxRoot }, + @{ name = "Zig0152"; value = $Zig0152 }, + @{ name = "Zig0160"; value = $Zig0160 })) { + if ($pair.value) { $buildArguments += @("-$($pair.name)", $pair.value) } + } + if ($signingRequested) { + $buildArguments += @("-SignCertificate", $thumbprint) + if ($SignTimestampUrl) { $buildArguments += @("-SignTimestampUrl", $SignTimestampUrl) } + } + Invoke-Packaging $buildArguments "build" + + $packageName = "GraphCode-$version-windows-x86_64" + $archive = Join-Path $packages "$packageName.zip" + $packageRoot = Join-Path $packages $packageName + foreach ($produced in @($archive, $packageRoot)) { + if (-not (Test-Path -LiteralPath $produced)) { + throw "Windows packaging did not produce $produced" + } + } + + # The package states its own signing state. Publishing is allowed only when + # that statement agrees with what this run actually did. + $metadata = Get-Content -LiteralPath (Join-Path $packageRoot "metadata.json") -Raw | ConvertFrom-Json + $reported = [string] $metadata.signing + $expectedSigned = $reported -eq "signed" + $expectedUnsigned = $reported.StartsWith($UnsignedLabelPrefix) + if ($signingRequested -ne $expectedSigned -or ($signingRequested -eq $expectedUnsigned)) { + throw "package signing state '$reported' contradicts this run " + + "(signing requested: $signingRequested); refusing to publish" + } + if ([string] $metadata.version -ne $version) { + throw "package reports version '$($metadata.version)', expected '$version'" + } + + $verifyArguments = @("-Command", "Verify", "-Package", $archive) + if ($signingRequested) { $verifyArguments += @("-TrustedSignerThumbprint", $thumbprint) } + Invoke-Packaging $verifyArguments "verification" + + # The published name mirrors the versionless macOS DMG so that + # releases/latest/download keeps working, and an unsigned development build + # can never occupy that name. + $assetName = if ($signingRequested) { + "graphcode-windows-x86_64.zip" + } else { + "graphcode-windows-x86_64-unsigned.zip" + } + $asset = Join-Path $publishDirectory $assetName + Copy-Item -LiteralPath $archive -Destination $asset -Force + $assetHash = (Get-FileHash -LiteralPath $asset -Algorithm SHA256).Hash.ToLowerInvariant() + $checksum = "$asset.sha256" + Set-Content -LiteralPath $checksum -Value "$assetHash $assetName" -Encoding ascii + Copy-Item -LiteralPath (Join-Path $packageRoot "metadata.json") ` + -Destination (Join-Path $publishDirectory "metadata.json") -Force + + $published = $false + if ($Publish) { + & $GitHubCli release upload $Tag $asset $checksum --clobber + if ($LASTEXITCODE -ne 0) { + throw "release asset upload failed with exit code $LASTEXITCODE" + } + $published = $true + } + + $summary = [ordered]@{ + schemaVersion = 1 + tag = $Tag + version = $version + signed = $signingRequested + signing = $reported + asset = $assetName + sha256 = $assetHash + published = $published + } + $summary | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $publishDirectory "release-summary.json") -Encoding utf8 + + $state = if ($signingRequested) { + "signed with certificate $thumbprint" + } else { + "$reported - not publishable as a release artifact without -AllowUnsignedPublish" + } + Write-Host "GraphCode $version for Windows: $state" + Write-Host "Asset: $asset" + Write-Host "SHA-256: $assetHash" + Write-Host "Published to $Tag`: $published" + if ($env:GITHUB_STEP_SUMMARY) { + @( + "### GraphCode Windows $version", + "", + "| Field | Value |", + "| --- | --- |", + "| Tag | ``$Tag`` |", + "| Signing | ``$reported`` |", + "| Asset | ``$assetName`` |", + "| SHA-256 | ``$assetHash`` |", + "| Published | ``$published`` |" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + } + Write-Output $asset +} finally { + if ($importedCertificatePath -and (Test-Path -LiteralPath $importedCertificatePath)) { + Remove-Item -LiteralPath $importedCertificatePath -Force -ErrorAction SilentlyContinue + } + if ($certificateFile -and (Test-Path -LiteralPath $certificateFile)) { + Remove-Item -LiteralPath $certificateFile -Force -ErrorAction SilentlyContinue + } + if (Test-Path -LiteralPath $staging) { + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/Tools/windows/stage-swift-products.ps1 b/Tools/windows/stage-swift-products.ps1 new file mode 100644 index 00000000..2effa714 --- /dev/null +++ b/Tools/windows/stage-swift-products.ps1 @@ -0,0 +1,79 @@ +<# +.SYNOPSIS + Builds graphcoded/graphcode and stages them with the Swift runtime DLLs where + Tools/windows/package.ps1 looks for release inputs. + +.DESCRIPTION + package.ps1 collects release inputs from graphcode-windows\zig-out\bin and + .build\windows\release. The Zig side is built by package.ps1 itself; this + script produces the Swift side, using the same toolchain and runtime + resolution as Tools/windows/validate.ps1. +#> +[CmdletBinding()] +param( + [string] $SwiftExecutable = $env:GRAPHCODE_SWIFT633, + [string] $Destination +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +if (-not $Destination) { $Destination = Join-Path $repoRoot ".build\windows\release" } + +function Resolve-Swift { + if ($SwiftExecutable) { return (Resolve-Path -LiteralPath $SwiftExecutable).Path } + $candidates = @() + $command = Get-Command swift.exe -ErrorAction SilentlyContinue + if ($command) { $candidates += $command.Source } + $candidates += Get-ChildItem (Join-Path $env:LOCALAPPDATA "Programs\Swift\Toolchains") ` + -Recurse -Filter swift.exe -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + $candidates += Get-ChildItem "C:\Library\Developer\Toolchains" ` + -Recurse -Filter swift.exe -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + foreach ($candidate in $candidates | Select-Object -Unique) { + if ($candidate -match "\\Toolchains\\([0-9]+)\.[^\\]*\\usr\\bin\\swift\.exe$" -and + [int] $Matches[1] -ge 6) { + return $candidate + } + } + throw "Swift 6 toolchain was not found; run Tools\windows\bootstrap.ps1 first" +} + +function Resolve-SwiftRuntimeDirectory([string] $swift) { + $toolBin = Split-Path $swift + if ($swift -match "^(.*)\\Toolchains\\([^\\]+)\\usr\\bin\\swift\.exe$") { + $version = $Matches[2].Split("+")[0] + $candidates = @((Join-Path $Matches[1] "Runtimes\$version\usr\bin"), $toolBin) + foreach ($runtime in $candidates | Select-Object -Unique) { + if ((Test-Path -LiteralPath $runtime) -and + (Get-ChildItem -LiteralPath $runtime -Filter *.dll -ErrorAction SilentlyContinue)) { + return $runtime + } + } + } + throw "Swift runtime DLL directory was not found for $swift" +} + +$swift = Resolve-Swift +$swiftBin = Split-Path $swift +$build = Join-Path $swiftBin "swift-build.exe" +foreach ($product in @("graphcoded", "graphcode")) { + & $build --package-path $repoRoot --configuration release --product $product + if ($LASTEXITCODE -ne 0) { throw "Swift release build failed for $product" } +} +$binPath = & $build --package-path $repoRoot --configuration release --show-bin-path +if ($LASTEXITCODE -ne 0) { throw "Swift release bin path lookup failed" } +$binPath = $binPath | Select-Object -Last 1 + +New-Item -ItemType Directory -Force -Path $Destination | Out-Null +foreach ($name in @("graphcoded.exe", "graphcode.exe")) { + $produced = Join-Path $binPath $name + if (-not (Test-Path -LiteralPath $produced -PathType Leaf)) { + throw "Swift release build did not produce $produced" + } + Copy-Item -LiteralPath $produced -Destination (Join-Path $Destination $name) -Force +} +Get-ChildItem -LiteralPath (Resolve-SwiftRuntimeDirectory $swift) -Filter *.dll | + Copy-Item -Destination $Destination -Force + +Write-Host "Staged Swift release products in $Destination" diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index eecc014d..fd3ad2b2 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -667,6 +667,7 @@ function Invoke-Task([string] $name) { } } "packaging" { + & (Join-Path $repoRoot "Tools\windows\Tests\Release.Tests.ps1") & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Signing.Tests.ps1") & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.ScriptSigning.Tests.ps1") & (Join-Path $repoRoot "Tools\windows\Tests\Packaging.Scheduler.Tests.ps1") diff --git a/Tools/windows/validation-matrix.md b/Tools/windows/validation-matrix.md index 577525ea..e36aaaea 100644 --- a/Tools/windows/validation-matrix.md +++ b/Tools/windows/validation-matrix.md @@ -21,6 +21,7 @@ The Windows port must have runnable commands before implementation fleets begin. | Native missing/idle task stop and deletion | `pwsh Tools\windows\Tests\Packaging.Scheduler.Tests.ps1` | | Failed-upgrade preservation and recoverable rollback | `pwsh Tools\windows\Tests\Packaging.Rollback.Tests.ps1` | | Standalone setup under PowerShell 5.1 and 7 | `pwsh Tools\windows\Tests\Packaging.Standalone.Tests.ps1` | +| Release publishing: signing gates, asset labeling, workflow contract | `pwsh Tools\windows\Tests\Release.Tests.ps1` | | Product/investigation provider pin consistency | `pwsh Tools\windows\Tests\ProviderPins.Tests.ps1` | | Native release feed, URL handoff, and allocation safety | `pwsh Tools\windows\Tests\WindowsShell.Tests.ps1 -ZigExecutable $env:GRAPHCODE_ZIG0152` (includes updater tests) | | Real-product packaging and source/standalone install/upgrade/rollback/uninstall | `pwsh Tools\windows\validate.ps1 -Task packaging` | diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 338dcad5..cffafe43 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -67,6 +67,12 @@ installation and uninstall no longer require a source checkout or build tools. Opt-in signed packages bind the complete payload with a publisher-pinned Authenticode catalog; see `Tools\windows\PACKAGING.md`, including setup-script verification before execution. +`Tools\windows\release.ps1` and `.github\workflows\windows-release.yml` add the +maintainer-triggered path that builds such a package and can attach it to an +existing release; its "Publishing a Windows release" section lists the signing +secrets. That plumbing does not itself sign anything: with no signing secrets +configured it produces a clearly labeled unsigned development artifact that it +refuses to publish without an explicit opt-in. There is not yet a published production-signed installer or an automatic install/relaunch path in the native updater. diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 614e5c97..0addbecb 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -138,8 +138,8 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| | Available update alert | Install, Release Notes, Later | The native client reads the real `scgopi/GraphCode` API response shape, retains the offered project version, and only opens that repository's HTTPS release pages. The native offer now exposes separately labeled Release Notes and Later actions plus a visible disabled Install action explaining that no published, signed Windows artifact exists yet. Native fixtures cover API metadata, channel filtering, URL validation, and allocation failure. Direct in-app installation remains unavailable with the installer-dependent rows still Blocked | Partial | -| Install progress | In-window progress indicator | Blocked on a published, signed Windows artifact and updater integration. ZIP packaging now bundles a standalone PowerShell setup with shared verification/rollback logic and local real-product lifecycle evidence under Windows PowerShell 5.1. Signed builds include the setup in both Authenticode and the SHA-256 catalog; OS trust decisions remain simulated in signing contracts, not production-certificate evidence. The latest recorded stable/beta asset check (2026-09-17) found only macOS DMGs. No in-app Windows download/install path is enabled | Blocked | -| Relaunch prompt | Relaunch Now/Later and session continuity explanation | Blocked with installation because there is no published Windows artifact to stage or relaunch into. The native tray lifecycle and zmx-backed sessions already preserve daemon/terminal continuity, but the updater cannot truthfully offer Relaunch Now until a signed Windows package exists | Blocked | +| Install progress | In-window progress indicator | Blocked on a published, signed Windows artifact and updater integration. ZIP packaging now bundles a standalone PowerShell setup with shared verification/rollback logic and local real-product lifecycle evidence under Windows PowerShell 5.1. Signed builds include the setup in both Authenticode and the SHA-256 catalog; OS trust decisions remain simulated in signing contracts, not production-certificate evidence. A maintainer-triggered release-publishing path now exists (`Tools/windows/release.ps1` + `.github/workflows/windows-release.yml`, covered by `Release.Tests.ps1`), so a signed artifact *can* be built and attached once a certificate is provisioned through the documented repository secrets. That is a prerequisite only: no certificate exists, so the workflow currently produces a clearly labeled unsigned development artifact it refuses to publish without an explicit opt-in, and it does not add any in-app download/install path. The latest recorded stable/beta asset check (2026-09-17) found only macOS DMGs. No in-app Windows download/install path is enabled | Blocked | +| Relaunch prompt | Relaunch Now/Later and session continuity explanation | Blocked with installation because there is no published Windows artifact to stage or relaunch into. The native tray lifecycle and zmx-backed sessions already preserve daemon/terminal continuity, but the updater cannot truthfully offer Relaunch Now until a signed Windows package exists. The new release-publishing workflow removes the plumbing half of that dependency without satisfying it: it neither signs (no certificate is provisioned) nor touches the updater's download/stage/relaunch path | Blocked | | Install failure | Download in Browser/Cancel with reason | The Windows flow exposes Release Notes/Later and a disabled Install action with an explicit signed-artifact reason, then hands off only through the verified browser release page when notes are requested. It does not attempt an in-app install because no published, signed Windows artifact exists; this remains Partial until that dependency is available | Partial | | Loop rename | Title field, Return submits, explanatory text | The dedicated single-title modal explains where the title appears, prepopulates the current value, trims and validates submission, and re-resolves the stable loop ID after the modal. The populated UIA gate edits the native field and verifies Return submits and closes the dialog | Validated | | Loop delete | Named loop and full consequence message | Names the loop, explains graph-connection removal, and defaults to cancellation | Validated |