Skip to content

Add published self-update qualification harness - #126

Merged
AviBackToBlack merged 5 commits into
mainfrom
codex/self-update-published-e2e
Oct 4, 2026
Merged

AviBackToBlack merged 5 commits into
mainfrom
codex/self-update-published-e2e

Conversation

@AviBackToBlack

@AviBackToBlack AviBackToBlack commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • add a fail-closed Windows/amd64 harness for the complete published-release self-update transaction
  • exercise exact release selection, bounded downloads, checksum/provenance verification, helper handoff, replacement, smoke testing, shim hardlink reconciliation, and private-artifact cleanup
  • record the 2026-10-04 current-main-to-v1.1.0 qualification evidence and make rerunning it an explicit release-candidate gate

Validation

  • scripts/qualify-self-update-published.ps1 -FromVersion v1.0.0 -TargetVersion v1.1.0 ... — PASS against canonical published v1.1.0 assets; two managed shims reconciled; zero private artifacts
  • go test -race ./...
  • go vet ./...
  • release-style builds for windows/amd64, windows/arm64, linux/amd64, and linux/arm64 with v2.0.0-citest
  • canonical Windows/amd64 cb.exe version smoke test
  • PowerShell parser validation
  • git diff --check

The commit is intentionally unsigned because repository commit signing is currently disabled. No runtime behavior changes are included.


Devin Review

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Process execution can hang indefinitely, and the documented release workflow does not enforce or record the new per-candidate gate.

Review effort: Balanced
Findings: 1 Medium severity · 1 Low severity

Open (2)
What changed in this PR

Adds a Windows/amd64 harness for qualifying published self-update transactions and records the release gate.

Changes:

  • Adds isolated end-to-end self-update qualification.
  • Verifies replacement, shims, provenance, and cleanup.
  • Updates roadmap and release documentation.
File Description
scripts/​qualify-self-update-published.ps1 Adds the qualification harness.
docs/​roadmap-implementation-requirements.md Updates self-update completion requirements.
docs/​roadmap-decisions.md Records the qualification decision.
docs/​release-matrix.md Documents execution and evidence.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/qualify-self-update-published.ps1 Outdated
Comment thread docs/roadmap-implementation-requirements.md

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Add published self-update qualification harness

Initial review of 20d0134 (COMMENT only; checks were still pending at review time, but this change is docs + a manual PowerShell harness, so Go CI coverage is unaffected).

What I verified

  • The harness's exercised contract matches the implementation: cb.exe self-update --apply --version V --gh-executable P parses via internal/selfupdate/selfupdate.go::ParseArgs; cb.exe version is a bootstrap command requiring no registry/host-frontend (main.go), so the temp install works.
  • Shim reconciliation coverage is real: discoverManagedShims (internal/selfupdate/apply.go) scans the install dir for regular .exe files whose name is a valid non-reserved tool name and whose bytes match the old digest. go/gofmt qualify; staged/helper/rollback cb.exe copies live in subdirectories and are correctly ignored.
  • The staged/helper/rollback private directory prefixes all match the harness's .container-bin-update-* sweep; the helper's deferred staged cleanup and the detached PowerShell helper-dir cleanup explain why the 1-minute drain loop is needed.
  • Helper stdout/stderr are inherited from the parent (startSelfUpdateHelper), so the apply invocation's redirected-pipe read effectively waits for the helper to finish — the subsequent version poll is a belt-and-suspenders confirmation rather than the primary signal. launchHelperCleanup correctly detaches (nil std handles), so it does not hold the pipe.
  • Qualification evidence is accurate: published v1.1.0 exists with cb.exe = 3,383,808 bytes, amd64 archive and SHA256SUMS, matching docs/release-matrix.md.
  • Build flags (-trimpath -buildvcs=false -ldflags "-s -w -X main.version=...") match release.yml exactly; fail-closed temp-path validation, re-validation before Remove-Item, and never printing the token are all consistent with the repo's stated security posture.
  • Docs edits are internally consistent: remaining "E2E" statements now correctly refer to Windows-on-Arm + Docker Desktop qualification, and the roadmap retains the rerun-per-release-candidate gate.

Findings

No blocking or important issues. Two optional observations:

  • 🟢 [nit] scripts/qualify-self-update-published.ps1:174 — the private-artifact sweep only matches .container-bin-update-*. Other private update files can outlive the transaction without matching: .{name}-update-*.tmp replacement temps created by replaceManagedFile (e.g. .go.exe-update-*.tmp), and container-bin.mutation.lock, which mutationlock's unlock deliberately leaves behind if its re-read fails or the token mismatches. If a transaction completes but leaves one of those, the harness would still emit private_artifacts = 0 and the "no private update artifact remained" evidence claim would be overstated. Consider asserting an exact allowlist of expected survivors (cb.exe, go.exe, gofmt.exe) — it is a stronger statement for a release-candidate gate.
  • 🟢 [nit] Invoke-ExactProcess has no timeout: WaitForExit() and the ReadToEndAsync results block indefinitely. For the apply call this means the script already waits for the full helper transaction (inherited pipe), so a wedged helper — or a hung go shim build — stalls qualification with no diagnostic. A bounded wait + kill on expiry would make hangs reportable as failures instead.

Summary

A careful, fail-closed harness that genuinely exercises the complete published-release transaction end to end — exact selection, canonical bounded staging, checksum + provenance, private helper handoff, transactional replacement, hardlink and byte-identical shim reconciliation, and private-artifact cleanup — with the surrounding roadmap/release-matrix docs updated accurately and the 2026-10-04 evidence verifiable against the real v1.1.0 release. Good to merge once CI settles; the two notes above are optional hardening for future reruns.

@AviBackToBlack

Copy link
Copy Markdown
Owner Author

Addressed both Cheryl optional hardening notes, plus the two overlapping Copilot findings, in d9e1a3c:

  • every child operation now has an explicit bound; timeout handling covers both process exit and inherited-pipe completion and kills only the live tree / executables inside the unique qualification directory;
  • post-transaction cleanup now asserts an exact cb.exe/go.exe/gofmt.exe survivor allowlist, catching helper/staging/rollback directories, replacement temps, mutation-lock residue, and anything else unexpected;
  • release-candidate instructions are parameterized, and the release-qualification issue template now requires retaining candidate-specific E2E evidence.

The hardened harness passed again against canonical published v1.1.0 assets with both shims hardlinked and no unexpected survivor. PowerShell parsing and git diff --check also pass. The commit is intentionally unsigned because signing is disabled.

Please re-review the current head when convenient.

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: published self-update qualification harness (d9e1a3c)

Validation of previous findings

Verified each prior finding against the current head and the actual self-update implementation:

  • 🟢 [nit] Private-artifact sweep too narrow — FIXED. The post-transaction check now asserts an exact survivor allowlist (cb.exe, go.exe, gofmt.exe) via Get-ChildItem -Force ... -notin $expectedSurvivors, with a 1-minute drain for the detached helper-dir cleanup. This genuinely covers everything the old .container-bin-update-* glob missed: replaceManagedFile's .{name}-update-*.tmp temps (apply.go:367), container-bin.mutation.lock residue that mutationlock's unlock deliberately leaves on re-read mismatch/failure (mutationlock.go:65-75), and the staging/helper/rollback directories. It also catches anything else unexpected — a strictly stronger gate than the fix I suggested.
  • 🟢 [nit] Unbounded Invoke-ExactProcess wait — FIXED. TimeoutSeconds is now mandatory (ValidateRange(1,3600)), every call site is bounded (300/30/900/30/30s), and the timeout covers both process exit and inherited-pipe drain within one deadline — important because startSelfUpdateHelper passes os.Stdout/os.Stderr through to the helper, so ReadToEndAsync only completes when the helper exits. On expiry the script kills the direct tree via Kill($true) and sweeps executables under OwnedProcessRoot, which correctly catches a wedged cb-update-helper.exe living in .container-bin-update-helper-* under the qualification root. It correctly does not sweep the detached cleanup powershell.exe (System32 path, DevNull std handles — doesn't hold the pipe).
  • Copilot: release workflow lacked per-candidate gate — FIXED. docs/release-matrix.md now gives parameterized previous-release → published-candidate instructions and .github/ISSUE_TEMPLATE/release-qualification.yml adds a required "Published-release self-update E2E" field demanding the retained JSON.

Fresh review of current head

  • Timeout model is correct: WaitForExit(ms) plus WhenAll(...).Wait(remaining) share one deadline, so an exited-but-pipe-holding transaction (parent exits, helper inherits the redirected pipes) is still bounded; the post-kill 10s task drain is best-effort and preserves the timeout as the primary error.
  • OwnedProcessRoot prefix matching is case-insensitive with proper trailing-separator handling and per-process Path access guarded — scoped to the unique GUID temp dir, so it cannot kill unrelated processes.
  • The documented candidate flow is implementable: exact --version selection accepts prerelease tags like v2.0.0-rc.1 (only drafts are rejected, selfupdate.go:317), the tag parses under the same ValidatePattern used for FromVersion/TargetVersion, and ExpectedRef/ReleaseURL canonicalization works for prerelease tags.
  • The allowlist assertion can't false-positive on this fixture: ApplyVerified's mutation lock is removed on the normal path, Staged.Cleanup/helper-dir cleanup are fail-closed about what they delete, and gofmt.exe's copy becomes a real hardlink via preferHardlink — matching the fsutil hardlink list assertion for both shims.
  • Docs are internally consistent: the evidence paragraph, the "bounded child-process execution / exact survivor allowlist" description, the roadmap's rerun-per-candidate gate, and the issue-template field all match the implementation. CI is green.

Findings

No blocking or important issues. Two optional diagnostics notes:

  • 🟢 [nit] scripts/qualify-self-update-published.ps1:262-270 — if the try block throws and the finally Remove-Item -Recurse -Force then hits a terminating error (e.g., a file still locked by a just-killed helper in delete-pending state, or a racing detached cleanup), the removal error replaces the original failure — losing the more useful diagnostic (e.g., which survivor artifact was found). Best-effort cleanup (-ErrorAction Continue + warning) or wrapping the removal in try/catch that appends rather than replaces would keep the primary failure visible.
  • 🟢 [nit] scripts/qualify-self-update-published.ps1:212-214 — the version-poll catch discards each attempt's failure reason, so a run where every poll times out reports last output: <unavailable> without indicating the failure mode (timeout vs. non-zero exit vs. wrong version). Recording $_.Exception.Message on failure would make a persistently wedged post-update executable distinguishable in the qualification evidence.

Summary

The hardening round landed cleanly and is verified against the real transaction code rather than just the claims — the bounded-wait design correctly handles the inherited-pipe helper case, and the exact allowlist is a meaningfully stronger post-transaction invariant than the prefix sweep it replaces. Docs, the issue-template gate, and the recorded evidence are all consistent with the implementation. Good to merge; the two notes above are optional.

@AviBackToBlack

Copy link
Copy Markdown
Owner Author

Addressed both optional re-review diagnostics notes in a8a349a: version polling now preserves the final timeout/non-zero failure message, and best-effort qualification-root cleanup warns instead of replacing the primary failure. Revalidated PowerShell parsing, the live published v1.0.0 to v1.1.0 transaction (PASS; 2 shims; 0 private artifacts), go test -race ./..., go vet ./..., and git diff --check. The commit is intentionally unsigned because signing is disabled.

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: published self-update qualification harness (a8a349a)

Validation of previous findings

Both diagnostics notes from the d9e1a3c round are verified fixed in the current head:

  • 🟢 Best-effort root cleanup — FIXED. The finally block now wraps Remove-Item -Recurse -Force -ErrorAction Stop in try/catch that downgrades removal failures to Write-Warning (scripts/qualify-self-update-published.ps1:276-281). A locked or racing file can no longer replace the primary qualification failure. The fail-closed re-validation of the resolved path before deletion is preserved.
  • 🟢 Version-poll failure diagnostics — FIXED. The poll loop now records $lastAttemptError — $_ .Exception.Message on timeout/exception and exit code N; stderr: ... on non-zero exit — and the failure message reports last error alongside last output (scripts/qualify-self-update-published.ps1:204-225). A persistently wedged post-update executable is now distinguishable in the retained evidence (timeout vs. non-zero exit vs. wrong version).

Fresh review of the current PR

Re-checked the complete diff (20d0134..a8a349a → script, three docs, issue template) against the implementation rather than the delta alone:

  • Contract still matches internal/selfupdate: --apply requires cb.exe basename (selfupdate.go:215), exact --version selection, --gh-executable absolute path; version is a bootstrap command; managed-shim discovery and the helper handoff semantics are unchanged.
  • Evidence claims verified against the live release: v1.1.0 publishes cb.exe (3,383,808 bytes), the amd64 archive and SHA256SUMS, matching docs/release-matrix.md; e625ab6 is the recorded base commit.
  • The documented "go may be a native executable or a ContainerBin shim" claim holds: the default go/golang tool profile whitelists GOOS/GOARCH in env_names (internal/registry/registry.go:267), so the injected build-target env propagates through a shim into the container. Worst case under an unshared temp path is a fail-closed build error, not a false pass.
  • Issue-template gate is valid YAML and consistent with the new required per-candidate step; docs edits in release-matrix.md, roadmap-decisions.md and roadmap-implementation-requirements.md are internally consistent.
  • git diff --check clean. No pwsh locally to re-parse, but the delta is small and structurally simple.

Findings

No blocking or important issues. One residual nit in the new diagnostics code:

  • 🟢 [nit] scripts/qualify-self-update-published.ps1:213-215 — the non-zero-exit else branch records $lastAttemptError but does not reset $after, while the catch branch resets it to $null. If a poll returns exit 0 with the wrong version and a later attempt exits non-zero, the final message pairs last output from the earlier attempt with last error from the latest one — two diagnostics labeled "last" that came from different attempts, which could be confusing when correlating a failed run. Setting $after = $null in the else branch (or labeling the fields last successful output / last attempt error) would keep the pair consistent. Gate correctness is unaffected — the loop condition still requires a matching version to pass.

Summary

Both prior findings are properly fixed and the rest of the harness, docs and issue-template gate remain verified against the actual transaction code and published assets. Good to merge; the note above is optional.

@AviBackToBlack

Copy link
Copy Markdown
Owner Author

Addressed the residual diagnostics nit in 5ed46f3: a non-zero version probe now clears the previous successful-but-wrong result, so last output and last error always describe the same latest attempt. Revalidated PowerShell parsing, the live published v1.0.0 to v1.1.0 transaction (PASS; 2 shims; 0 private artifacts), go test -race ./..., go vet ./..., and git diff --check. The commit is intentionally unsigned because signing is disabled.

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: published self-update qualification harness (5ed46f3)

Validation of previous findings

The residual nit from the a8a349a round is verified fixed against the current head:

  • 🟢 Poll diagnostics pairing — FIXED. The non-zero-exit else branch now sets $after = $null alongside $lastAttemptError (scripts/qualify-self-update-published.ps1:214). In all three paths — exit 0, non-zero exit, and thrown exception/timeout — $after and $lastAttemptError now describe the same latest attempt, so the final last output / last error pair can no longer mix diagnostics across attempts. Gate semantics unchanged: the loop still requires an exit-0 probe reporting the exact target version.

Fresh review of the current PR

Re-checked the complete diff against the implementation rather than the one-line delta:

  • Contract still matches internal/selfupdate: --apply requires a cb.exe basename (selfupdate.go:215) and an absolute --gh-executable; exact --version selection requires the exact published tag and rejects drafts (selfupdate.go:317), so the documented v2.0.0-rc.1 candidate flow is implementable; the harness's ValidatePattern accepts the same canonical-with-prerelease form.
  • version is a bootstrap command returning before registry/policy load (main.go:416, proven by main_test.go), output format container-bin X matches both assertions, and the transaction itself already runs a bounded smoke version check (apply.go:410-422) — the outer 2-minute poll is a correct belt-and-suspenders layer.
  • Shim reconciliation coverage holds: discoverManagedShims (apply.go:273) accepts os.SameFile hardlinks and size+digest-identical regular files with valid non-reserved names; go/gofmt qualify (registry.go:923-953), staging/helper/rollback copies live in subdirectories and are ignored.
  • The exact survivor allowlist still covers every private-artifact class in the implementation: .container-bin-update-* staging, .container-bin-update-helper-*, .container-bin-update-rollback-*, .{name}-update-*.tmp replacement temps (apply.go:367), and container-bin.mutation.lock residue (mutationlock.go:26).
  • Token handling is sound: the verifier passes GH_TOKEN/GITHUB_TOKEN to gh through an env whitelist only (verify.go:653-661), and nothing in the script or output path prints it.
  • Timeout/kill model, OwnedProcessRoot prefix matching, post-kill drain, warn-on-failure root cleanup, and fail-closed path revalidation are unchanged from the previously verified state.
  • git diff --check clean; CI is fully green on the current head (format/vet/test, Windows and ARM64 builds, release-bundle build + independent reproduction, CodeQL, govulncheck, zizmor, dependency review).

Findings

No blocking or important issues. One trivial docs nit:

  • 🟢 [nit] docs/release-matrix.md — the harness description sentence ends with a colon ("...updates that private installation to an exact canonical published release:") but is followed by a prose paragraph rather than the content the colon introduces; the second colon correctly leads into the command block. Ending the first sentence with a period would read correctly.

Summary

The last-round diagnostics fix is correct and complete; re-verification of the full harness against the transaction code, the published-asset contract, and the docs found nothing else of substance. CI is green. Good to merge.

@AviBackToBlack

Copy link
Copy Markdown
Owner Author

Addressed the final trivial docs nit in 241c8c5: the harness-description sentence now ends with a period rather than a colon before the following prose paragraph. Revalidated go test -race ./..., go vet ./..., and git diff --check. The commit is intentionally unsigned because signing is disabled.

@AviBackToBlack
AviBackToBlack merged commit 5acb9e9 into main Oct 4, 2026
15 checks passed
@AviBackToBlack
AviBackToBlack deleted the codex/self-update-published-e2e branch October 4, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants