From 20d013459a2c72f02f918385a23a83c567a6ef56 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:39:41 +0100 Subject: [PATCH 1/5] Add published self-update qualification harness --- docs/release-matrix.md | 36 +++- docs/roadmap-decisions.md | 10 +- docs/roadmap-implementation-requirements.md | 10 +- scripts/qualify-self-update-published.ps1 | 221 ++++++++++++++++++++ 4 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 scripts/qualify-self-update-published.ps1 diff --git a/docs/release-matrix.md b/docs/release-matrix.md index 0610d4f..2711254 100644 --- a/docs/release-matrix.md +++ b/docs/release-matrix.md @@ -193,7 +193,41 @@ So CI validates compilation and pure/unit logic on a GitHub-hosted Windows runner. The matrix is what validates the `docs/shell-contract.md` semantics on a real Windows 11 + Docker Desktop host before a release. -### Live self-update attestation verifier check +### Live published-release self-update qualification + +The repository includes a Windows/amd64 harness that builds the current source +with an injected earlier version, installs isolated hardlink and byte-identical +managed shims, and updates that private installation to an exact canonical +published release: + +```powershell +$env:GH_TOKEN = gh auth token --hostname github.com +pwsh -NoProfile -File .\scripts\qualify-self-update-published.ps1 ` + -FromVersion v1.0.0 ` + -TargetVersion v1.1.0 ` + -GitHubCLI (Get-Command gh.exe).Source +``` + +Run it from the repository root. `go` may be a native executable or a +ContainerBin shim; the harness sets the intended repository working directory +and explicit Windows/amd64 build target in the same child process. The selected +GitHub CLI must be the official Authenticode-valid executable, and an explicit +`GH_TOKEN` or `GITHUB_TOKEN` is required. The harness never prints the token. + +This exercises release selection, canonical bounded downloads, checksum and +GitHub build-provenance verification, the private helper handoff, repeated +verification after the parent exits, transactional replacement, bootstrap +version smoke testing, hardlink reconciliation for both originally hardlinked +and byte-identical shims, and cleanup of staging/helper/rollback artifacts. It +uses a unique system-temp installation and removes only that validated path. + +Qualification evidence on 2026-10-04: current `main` at `e625ab6` was built as +`v1.0.0` and updated to the canonical published `v1.1.0` release on native +Windows/amd64. The exact published asset (`cb.exe`, 3,383,808 bytes), checksum +manifest and GitHub Actions provenance passed; both managed shims were +reconciled as hardlinks and no private update artifact remained. + +### Lower-level live attestation verifier check `internal/selfupdate` includes an opt-in native-Windows integration test for the external verifier boundary. It must be run with an Authenticode-valid GitHub CLI diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index bc38948..fed6e07 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -21,7 +21,7 @@ items from being repeatedly rediscovered as if they were immediately actionable. | Image trust | **Policy-driven Sigstore/cosign at lock time** | Ready after signed-registry policy. Digest locking remains default where policy permits. Required trust never silently falls back to digest-only. | | Per-project overlays | **Explicit digest-bound, add-only trust model** | Implementation-ready on the merged policy foundation. Initial overlays exclude host mounts, env prefixes and shared cross-project volumes. | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two concrete integrations cannot be expressed safely by the declarative model. | -| RM-31 self-update | **Explicit transactional, attestation-verifying update** | Selection/check, staging, verification, ARM64 artifact selection, the private wait helper and explicit apply wiring are implemented. Real published-release E2E remains. | +| RM-31 self-update | **Explicit transactional, attestation-verifying update** | Selection/check, staging, verification, ARM64 artifact selection, the private wait helper and explicit apply wiring are implemented. A repeatable Windows/amd64 published-release E2E harness now qualifies the complete current-source-to-v1.1.0 transaction; future release candidates must rerun it against their exact published target. | | RM-30 Authenticode | **Design accepted; externally blocked** | Implement only after a real code-signing certificate and protected signing mechanism exist. Stable and prerelease release artifacts are both signed. | | RM-29 Windows ARM64 | **Lowest priority** | Native GitHub Windows ARM64 CI shipped in PR #78, reproducible release packaging in PR #86 and ARM64 self-update selection in PR #91. Real Windows-on-Arm + Docker Desktop qualification remains. Do not delay other roadmap work. | | Standalone Linux/macOS | **Demand-gated** | No support claim yet. WSL should create reusable narrow Linux host abstractions, but standalone hosts require their own contract and real Docker qualification. | @@ -330,14 +330,18 @@ is not completion. inputs; - explicit private-registry credential bridging remains. -2. **Remaining RM-31 self-update qualification** +2. **RM-31 self-update qualification** - selection/check, bounded staging and `gh attestation verify` are merged in PRs #76, #81 and #82; - ARM64 archive selection, verification and exact extraction are merged in PR #91; - rollback-safe Windows transaction, managed-shim reconciliation, the temporary wait helper and user-facing apply wiring are implemented; - - real published-release/self-test E2E remains. + - the repeatable Windows/amd64 qualification harness covers a complete + current-source-to-canonical-published-release transaction, including + provenance, helper handoff, replacement, shim reconciliation and cleanup; + - it passed against canonical v1.1.0 assets on 2026-10-04; each future + release candidate must rerun it against that exact published target. 3. **Remaining WSL2** - narrow reusable Linux host interfaces, fail-closed boundary and native diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index adf956b..0abd5ec 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -73,7 +73,7 @@ The minimum delivery gate for a code change is: | RM-26 Python global CLI exposure | **Completed in PR #74** | Stateful pipx + `cb expose pipx` shipped; plain pip `/venv/bin` remains intentionally unexposed | | RM-29 Windows ARM64 | **Native CI, release packaging and update selection shipped / hardware work remains** | PR #78 added native hosted ARM64 CI, PR #86 added reproducible release packaging and PR #91 added ARM64 self-update selection; real Windows-on-Arm + Docker Desktop E2E remains | | RM-30 Authenticode | **Design complete / externally blocked** | Provision real code-signing certificate and protected signing mechanism | -| RM-31 self-update | **Core pipeline shipped / explicit apply implemented** | PRs #76, #81, #82 and #91 shipped the read-only plan, fail-closed staging, provenance verification and ARM64 artifact selection; this tree adds the rollback-safe replacement transaction, protected wait helper and explicit user-facing apply wiring. Real published-release E2E remains | +| RM-31 self-update | **Core pipeline and repeatable published-release E2E implemented** | PRs #76, #81, #82 and #91 shipped the read-only plan, fail-closed staging, provenance verification and ARM64 artifact selection; this tree adds the rollback-safe replacement transaction, protected wait helper and explicit user-facing apply wiring. The Windows/amd64 harness qualifies the complete transaction against a canonical published release and must be rerun for each release candidate | | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all/explicit selection are sufficient; reopen only for concrete unmet use case | | Linux/macOS hosts | **Demand-gated** | WSL may factor reusable Linux host code; standalone support needs its own demand and qualification | | Enterprise policy | **Foundation and signed registry shipped / image trust remains** | PRs #75 and #84 shipped the machine-owned constraint layer and authenticated registry; image trust remains | @@ -404,7 +404,8 @@ also selects, stages and verifies the Windows/arm64 archive from native `GOARCH`; unsupported architectures still fail closed. This tree also implements the rollback-safe Windows replacement transaction, complete proven-shim reconciliation, protected wait helper and explicit user-facing -apply wiring. Real published-release E2E remains incomplete. +apply wiring. A repeatable native-Windows/amd64 qualification harness now +exercises the complete transaction against a canonical published release. ### Command and selection requirements @@ -467,8 +468,9 @@ ABSOLUTE_GH_EXE` is now wired through private same-volume staging and a protected copy of the proven installed binary. The helper waits for the parent, re-verifies the staged artifact and bound identities, then enters the serialized rollback-safe transaction. Unit/native-Windows CI covers the process boundary; -the remaining acceptance gate is real published-release E2E across the manual -release matrix. +the complete Windows/amd64 path passed against canonical v1.1.0 assets on +2026-10-04. Release candidates must rerun the harness against their exact +published target and retain the result with the manual release-matrix evidence. ## Linux and macOS hosts diff --git a/scripts/qualify-self-update-published.ps1 b/scripts/qualify-self-update-published.ps1 new file mode 100644 index 0000000..4e5a914 --- /dev/null +++ b/scripts/qualify-self-update-published.ps1 @@ -0,0 +1,221 @@ +#Requires -Version 7.0 + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$')] + [string]$FromVersion, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$')] + [string]$TargetVersion, + + [Parameter(Mandatory = $true)] + [string]$GitHubCLI, + + [string]$GoExecutable +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Invoke-ExactProcess { + param( + [Parameter(Mandatory = $true)] + [string]$Executable, + + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [Parameter(Mandatory = $true)] + [string]$WorkingDirectory, + + [hashtable]$Environment = @{} + ) + + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $Executable + $start.WorkingDirectory = $WorkingDirectory + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in $Arguments) { + [void]$start.ArgumentList.Add($argument) + } + foreach ($name in $Environment.Keys) { + $start.Environment[$name] = [string]$Environment[$name] + } + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + try { + if (-not $process.Start()) { + throw "Failed to start $Executable" + } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Stdout = $stdout + Stderr = $stderr + } + } + finally { + $process.Dispose() + } +} + +function Assert-Succeeded { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Result, + + [Parameter(Mandatory = $true)] + [string]$Operation + ) + + if ($Result.ExitCode -ne 0) { + throw "$Operation failed with exit code $($Result.ExitCode). stdout=$($Result.Stdout.Trim()) stderr=$($Result.Stderr.Trim())" + } +} + +if (-not $IsWindows) { + throw 'Published self-update qualification requires native Windows.' +} +if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne [Runtime.InteropServices.Architecture]::X64) { + throw 'This qualification harness currently covers Windows/amd64 only.' +} +if ($FromVersion -eq $TargetVersion) { + throw 'FromVersion and TargetVersion must differ.' +} +if ([string]::IsNullOrWhiteSpace($env:GH_TOKEN) -and [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + throw 'Set an explicit GH_TOKEN or GITHUB_TOKEN for GitHub attestation verification.' +} + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +if (-not (Test-Path -LiteralPath (Join-Path $repositoryRoot 'go.mod') -PathType Leaf)) { + throw "Repository root is not valid: $repositoryRoot" +} + +if ([string]::IsNullOrWhiteSpace($GoExecutable)) { + $GoExecutable = (Get-Command go.exe -ErrorAction Stop).Source +} +foreach ($executable in @($GoExecutable, $GitHubCLI)) { + if (-not [IO.Path]::IsPathFullyQualified($executable)) { + throw "Required executable path must be absolute: $executable" + } + if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "Required executable does not exist: $executable" + } +} +$GoExecutable = [IO.Path]::GetFullPath($GoExecutable) +$GitHubCLI = [IO.Path]::GetFullPath($GitHubCLI) + +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) +$qualificationRoot = [IO.Path]::GetFullPath((Join-Path $tempRoot ('container-bin-self-update-e2e-' + [Guid]::NewGuid().ToString('N')))) +if (-not [string]::Equals([IO.Path]::GetDirectoryName($qualificationRoot), $tempRoot, [StringComparison]::OrdinalIgnoreCase) -or + -not [IO.Path]::GetFileName($qualificationRoot).StartsWith('container-bin-self-update-e2e-', [StringComparison]::Ordinal)) { + throw "Refusing to use qualification directory outside the system temp directory: $qualificationRoot" +} + +[IO.Directory]::CreateDirectory($qualificationRoot) | Out-Null +$installed = Join-Path $qualificationRoot 'cb.exe' +$hardlinkShim = Join-Path $qualificationRoot 'go.exe' +$copyShim = Join-Path $qualificationRoot 'gofmt.exe' + +try { + $build = Invoke-ExactProcess ` + -Executable $GoExecutable ` + -Arguments @('build', '-trimpath', '-buildvcs=false', '-ldflags', "-s -w -X main.version=$FromVersion", '-o', $installed, '.') ` + -WorkingDirectory $repositoryRoot ` + -Environment @{ GOOS = 'windows'; GOARCH = 'amd64' } + Assert-Succeeded -Result $build -Operation 'Build qualification executable' + + New-Item -ItemType HardLink -Path $hardlinkShim -Target $installed | Out-Null + Copy-Item -LiteralPath $installed -Destination $copyShim + + $before = Invoke-ExactProcess -Executable $installed -Arguments @('version') -WorkingDirectory $qualificationRoot + Assert-Succeeded -Result $before -Operation 'Read pre-update version' + if ($before.Stdout.Trim() -ne "container-bin $FromVersion") { + throw "Unexpected pre-update version output: $($before.Stdout.Trim())" + } + + $apply = Invoke-ExactProcess ` + -Executable $installed ` + -Arguments @('self-update', '--apply', '--version', $TargetVersion, '--gh-executable', $GitHubCLI) ` + -WorkingDirectory $qualificationRoot + Assert-Succeeded -Result $apply -Operation 'Apply published self-update' + + $deadline = [DateTime]::UtcNow.AddMinutes(2) + $after = $null + do { + Start-Sleep -Milliseconds 250 + try { + $candidate = Invoke-ExactProcess -Executable $installed -Arguments @('version') -WorkingDirectory $qualificationRoot + if ($candidate.ExitCode -eq 0) { + $after = $candidate + } + } + catch { + $after = $null + } + } while (($null -eq $after -or $after.Stdout.Trim() -ne "container-bin $TargetVersion") -and [DateTime]::UtcNow -lt $deadline) + if ($null -eq $after -or $after.Stdout.Trim() -ne "container-bin $TargetVersion") { + $lastOutput = if ($null -eq $after) { '' } else { $after.Stdout.Trim() } + throw "Updated executable did not report $TargetVersion; last output: $lastOutput" + } + + $deadline = [DateTime]::UtcNow.AddMinutes(1) + do { + $privateArtifacts = @(Get-ChildItem -LiteralPath $qualificationRoot -Force | Where-Object { $_.Name -like '.container-bin-update-*' }) + if ($privateArtifacts.Count -eq 0) { + break + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if ($privateArtifacts.Count -ne 0) { + throw "Private self-update artifacts remain: $($privateArtifacts.Name -join ', ')" + } + + $installedHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installed).Hash + foreach ($shim in @($hardlinkShim, $copyShim)) { + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $shim).Hash -ne $installedHash) { + throw "Managed shim bytes were not reconciled: $shim" + } + } + + $fsutil = Join-Path $env:SystemRoot 'System32\fsutil.exe' + $links = Invoke-ExactProcess -Executable $fsutil -Arguments @('hardlink', 'list', $installed) -WorkingDirectory $qualificationRoot + Assert-Succeeded -Result $links -Operation 'Inspect managed hardlinks' + $hardlinkPaths = @($links.Stdout -split "`r?`n") + foreach ($leaf in @('go.exe', 'gofmt.exe')) { + if (-not ($hardlinkPaths | Where-Object { $_.Trim().EndsWith('\' + $leaf, [StringComparison]::OrdinalIgnoreCase) })) { + throw "Updated managed shim is not a hardlink to cb.exe: $leaf" + } + } + + [pscustomobject][ordered]@{ + schema_version = 1 + result = 'PASS' + source_version = $FromVersion + target_version = $TargetVersion + platform = 'windows/amd64' + managed_shims = 2 + private_artifacts = 0 + apply_output = $apply.Stdout.Trim() + } | ConvertTo-Json -Depth 3 +} +finally { + if (Test-Path -LiteralPath $qualificationRoot) { + $resolved = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $qualificationRoot).ProviderPath) + if (-not [string]::Equals([IO.Path]::GetDirectoryName($resolved), $tempRoot, [StringComparison]::OrdinalIgnoreCase) -or + -not [IO.Path]::GetFileName($resolved).StartsWith('container-bin-self-update-e2e-', [StringComparison]::Ordinal)) { + throw "Refusing to remove unsafe qualification directory: $resolved" + } + Remove-Item -LiteralPath $resolved -Recurse -Force + } +} From d9e1a3c1919de09aa3ce9bb6ce4901f073d91f26 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:54:16 +0100 Subject: [PATCH 2/5] Harden published update qualification --- .../ISSUE_TEMPLATE/release-qualification.yml | 8 +++ docs/release-matrix.md | 16 +++-- scripts/qualify-self-update-published.ps1 | 68 ++++++++++++++++--- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release-qualification.yml b/.github/ISSUE_TEMPLATE/release-qualification.yml index dde3875..971eee7 100644 --- a/.github/ISSUE_TEMPLATE/release-qualification.yml +++ b/.github/ISSUE_TEMPLATE/release-qualification.yml @@ -21,6 +21,14 @@ body: placeholder: "v1.2.3" validations: required: true + - type: textarea + id: self-update-published-e2e + attributes: + label: Published-release self-update E2E + description: Record the previous release, exact published candidate tag, source commit, signed GitHub CLI version/publisher, command, and complete sanitized JSON result from scripts/qualify-self-update-published.ps1. + render: text + validations: + required: true - type: textarea id: cell-powershell51-current attributes: diff --git a/docs/release-matrix.md b/docs/release-matrix.md index 2711254..4856651 100644 --- a/docs/release-matrix.md +++ b/docs/release-matrix.md @@ -200,11 +200,17 @@ with an injected earlier version, installs isolated hardlink and byte-identical managed shims, and updates that private installation to an exact canonical published release: +For a release candidate, set the source version to the previous supported +release and the target to the exact already-published candidate tag, then retain +the emitted JSON in the release-qualification issue: + ```powershell +$previousVersion = 'v1.1.0' +$candidateVersion = 'v2.0.0-rc.1' $env:GH_TOKEN = gh auth token --hostname github.com pwsh -NoProfile -File .\scripts\qualify-self-update-published.ps1 ` - -FromVersion v1.0.0 ` - -TargetVersion v1.1.0 ` + -FromVersion $previousVersion ` + -TargetVersion $candidateVersion ` -GitHubCLI (Get-Command gh.exe).Source ``` @@ -218,8 +224,10 @@ This exercises release selection, canonical bounded downloads, checksum and GitHub build-provenance verification, the private helper handoff, repeated verification after the parent exits, transactional replacement, bootstrap version smoke testing, hardlink reconciliation for both originally hardlinked -and byte-identical shims, and cleanup of staging/helper/rollback artifacts. It -uses a unique system-temp installation and removes only that validated path. +and byte-identical shims, bounded child-process execution, and an exact +post-transaction survivor allowlist that catches staging/helper/rollback +directories, replacement temporary files and mutation-lock residue. It uses a +unique system-temp installation and removes only that validated path. Qualification evidence on 2026-10-04: current `main` at `e625ab6` was built as `v1.0.0` and updated to the canonical published `v1.1.0` release on native diff --git a/scripts/qualify-self-update-published.ps1 b/scripts/qualify-self-update-published.ps1 index 4e5a914..f74f343 100644 --- a/scripts/qualify-self-update-published.ps1 +++ b/scripts/qualify-self-update-published.ps1 @@ -30,6 +30,12 @@ function Invoke-ExactProcess { [Parameter(Mandatory = $true)] [string]$WorkingDirectory, + [Parameter(Mandatory = $true)] + [ValidateRange(1, 3600)] + [int]$TimeoutSeconds, + + [string]$OwnedProcessRoot, + [hashtable]$Environment = @{} ) @@ -55,7 +61,47 @@ function Invoke-ExactProcess { } $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() - $process.WaitForExit() + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + $timedOut = -not $process.WaitForExit($TimeoutSeconds * 1000) + if (-not $timedOut) { + $remaining = [Math]::Max(1, [int](($deadline - [DateTime]::UtcNow).TotalMilliseconds)) + $outputTasks = [Threading.Tasks.Task]::WhenAll([Threading.Tasks.Task[]]@($stdoutTask, $stderrTask)) + $timedOut = -not $outputTasks.Wait($remaining) + } + if ($timedOut) { + if (-not $process.HasExited) { + try { + $process.Kill($true) + } + catch { + # The process may have exited between HasExited and Kill. + } + } + if (-not [string]::IsNullOrWhiteSpace($OwnedProcessRoot)) { + $ownedRoot = [IO.Path]::GetFullPath($OwnedProcessRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + $ownedPrefix = $ownedRoot + [IO.Path]::DirectorySeparatorChar + foreach ($candidate in Get-Process) { + try { + $candidatePath = $candidate.Path + } + catch { + continue + } + if (-not [string]::IsNullOrWhiteSpace($candidatePath) -and + ([string]::Equals($candidatePath, $ownedRoot, [StringComparison]::OrdinalIgnoreCase) -or + $candidatePath.StartsWith($ownedPrefix, [StringComparison]::OrdinalIgnoreCase))) { + Stop-Process -Id $candidate.Id -Force -ErrorAction SilentlyContinue + } + } + } + try { + [void][Threading.Tasks.Task]::WhenAll([Threading.Tasks.Task[]]@($stdoutTask, $stderrTask)).Wait(10000) + } + catch { + # Preserve the timeout as the primary qualification failure. + } + throw "Process timed out after $TimeoutSeconds seconds: $Executable" + } $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() return [pscustomobject]@{ @@ -132,13 +178,14 @@ try { -Executable $GoExecutable ` -Arguments @('build', '-trimpath', '-buildvcs=false', '-ldflags', "-s -w -X main.version=$FromVersion", '-o', $installed, '.') ` -WorkingDirectory $repositoryRoot ` + -TimeoutSeconds 300 ` -Environment @{ GOOS = 'windows'; GOARCH = 'amd64' } Assert-Succeeded -Result $build -Operation 'Build qualification executable' New-Item -ItemType HardLink -Path $hardlinkShim -Target $installed | Out-Null Copy-Item -LiteralPath $installed -Destination $copyShim - $before = Invoke-ExactProcess -Executable $installed -Arguments @('version') -WorkingDirectory $qualificationRoot + $before = Invoke-ExactProcess -Executable $installed -Arguments @('version') -WorkingDirectory $qualificationRoot -TimeoutSeconds 30 -OwnedProcessRoot $qualificationRoot Assert-Succeeded -Result $before -Operation 'Read pre-update version' if ($before.Stdout.Trim() -ne "container-bin $FromVersion") { throw "Unexpected pre-update version output: $($before.Stdout.Trim())" @@ -147,7 +194,9 @@ try { $apply = Invoke-ExactProcess ` -Executable $installed ` -Arguments @('self-update', '--apply', '--version', $TargetVersion, '--gh-executable', $GitHubCLI) ` - -WorkingDirectory $qualificationRoot + -WorkingDirectory $qualificationRoot ` + -TimeoutSeconds 900 ` + -OwnedProcessRoot $qualificationRoot Assert-Succeeded -Result $apply -Operation 'Apply published self-update' $deadline = [DateTime]::UtcNow.AddMinutes(2) @@ -155,7 +204,7 @@ try { do { Start-Sleep -Milliseconds 250 try { - $candidate = Invoke-ExactProcess -Executable $installed -Arguments @('version') -WorkingDirectory $qualificationRoot + $candidate = Invoke-ExactProcess -Executable $installed -Arguments @('version') -WorkingDirectory $qualificationRoot -TimeoutSeconds 30 -OwnedProcessRoot $qualificationRoot if ($candidate.ExitCode -eq 0) { $after = $candidate } @@ -170,15 +219,16 @@ try { } $deadline = [DateTime]::UtcNow.AddMinutes(1) + $expectedSurvivors = @('cb.exe', 'go.exe', 'gofmt.exe') do { - $privateArtifacts = @(Get-ChildItem -LiteralPath $qualificationRoot -Force | Where-Object { $_.Name -like '.container-bin-update-*' }) - if ($privateArtifacts.Count -eq 0) { + $unexpectedArtifacts = @(Get-ChildItem -LiteralPath $qualificationRoot -Force | Where-Object { $_.Name -notin $expectedSurvivors }) + if ($unexpectedArtifacts.Count -eq 0) { break } Start-Sleep -Milliseconds 250 } while ([DateTime]::UtcNow -lt $deadline) - if ($privateArtifacts.Count -ne 0) { - throw "Private self-update artifacts remain: $($privateArtifacts.Name -join ', ')" + if ($unexpectedArtifacts.Count -ne 0) { + throw "Unexpected self-update artifacts remain: $($unexpectedArtifacts.Name -join ', ')" } $installedHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installed).Hash @@ -189,7 +239,7 @@ try { } $fsutil = Join-Path $env:SystemRoot 'System32\fsutil.exe' - $links = Invoke-ExactProcess -Executable $fsutil -Arguments @('hardlink', 'list', $installed) -WorkingDirectory $qualificationRoot + $links = Invoke-ExactProcess -Executable $fsutil -Arguments @('hardlink', 'list', $installed) -WorkingDirectory $qualificationRoot -TimeoutSeconds 30 Assert-Succeeded -Result $links -Operation 'Inspect managed hardlinks' $hardlinkPaths = @($links.Stdout -split "`r?`n") foreach ($leaf in @('go.exe', 'gofmt.exe')) { From a8a349a3bbe392017774b5d7a3cd396ec51e4f46 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:03:48 +0100 Subject: [PATCH 3/5] Preserve self-update qualification diagnostics --- scripts/qualify-self-update-published.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/qualify-self-update-published.ps1 b/scripts/qualify-self-update-published.ps1 index f74f343..18de854 100644 --- a/scripts/qualify-self-update-published.ps1 +++ b/scripts/qualify-self-update-published.ps1 @@ -201,21 +201,28 @@ try { $deadline = [DateTime]::UtcNow.AddMinutes(2) $after = $null + $lastAttemptError = $null do { Start-Sleep -Milliseconds 250 try { $candidate = Invoke-ExactProcess -Executable $installed -Arguments @('version') -WorkingDirectory $qualificationRoot -TimeoutSeconds 30 -OwnedProcessRoot $qualificationRoot if ($candidate.ExitCode -eq 0) { $after = $candidate + $lastAttemptError = $null + } + else { + $lastAttemptError = "exit code $($candidate.ExitCode); stderr: $($candidate.Stderr.Trim())" } } catch { $after = $null + $lastAttemptError = $_.Exception.Message } } while (($null -eq $after -or $after.Stdout.Trim() -ne "container-bin $TargetVersion") -and [DateTime]::UtcNow -lt $deadline) if ($null -eq $after -or $after.Stdout.Trim() -ne "container-bin $TargetVersion") { $lastOutput = if ($null -eq $after) { '' } else { $after.Stdout.Trim() } - throw "Updated executable did not report $TargetVersion; last output: $lastOutput" + $lastError = if ([string]::IsNullOrWhiteSpace($lastAttemptError)) { '' } else { $lastAttemptError } + throw "Updated executable did not report $TargetVersion; last output: $lastOutput; last error: $lastError" } $deadline = [DateTime]::UtcNow.AddMinutes(1) @@ -266,6 +273,11 @@ finally { -not [IO.Path]::GetFileName($resolved).StartsWith('container-bin-self-update-e2e-', [StringComparison]::Ordinal)) { throw "Refusing to remove unsafe qualification directory: $resolved" } - Remove-Item -LiteralPath $resolved -Recurse -Force + try { + Remove-Item -LiteralPath $resolved -Recurse -Force -ErrorAction Stop + } + catch { + Write-Warning "Unable to remove qualification directory '$resolved': $($_.Exception.Message)" + } } } From 5ed46f34b09046ef588e75dc967006370741dd2c Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:09:46 +0100 Subject: [PATCH 4/5] Keep self-update poll diagnostics consistent --- scripts/qualify-self-update-published.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/qualify-self-update-published.ps1 b/scripts/qualify-self-update-published.ps1 index 18de854..45320a3 100644 --- a/scripts/qualify-self-update-published.ps1 +++ b/scripts/qualify-self-update-published.ps1 @@ -211,6 +211,7 @@ try { $lastAttemptError = $null } else { + $after = $null $lastAttemptError = "exit code $($candidate.ExitCode); stderr: $($candidate.Stderr.Trim())" } } From 241c8c543d58fd004fee60872619957924a21111 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:14:39 +0100 Subject: [PATCH 5/5] Fix self-update qualification prose --- docs/release-matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-matrix.md b/docs/release-matrix.md index 4856651..ae40501 100644 --- a/docs/release-matrix.md +++ b/docs/release-matrix.md @@ -198,7 +198,7 @@ real Windows 11 + Docker Desktop host before a release. The repository includes a Windows/amd64 harness that builds the current source with an injected earlier version, installs isolated hardlink and byte-identical managed shims, and updates that private installation to an exact canonical -published release: +published release. For a release candidate, set the source version to the previous supported release and the target to the exact already-published candidate tag, then retain