v3.13.0¶
A reliability release, and mostly one defect repeated across the release machinery: a step that could not run reported as a step that passed. The npm wrapper had been failing inside a green job since 2.3.0; Homebrew, the SBOMs and the container image never ran at all for a release cut by automation; and the drift detector that was supposed to catch any of that closed its own tickets over registries it had failed to read. All four are fixed, the RPM channel that had shipped nothing since 1.4.11 is wired up, and the reconciler now refuses to return a verdict for a channel it could not probe. Around that, six command-line defects found by reading the code rather than by waiting for reports, the merge gate now guarantees a verdict on every pull-request head, and the changelog surfaces stop disagreeing with each other. Upgrade in place; read the behaviour changes first.
Behaviour changes (read before upgrading)¶
-
A delegation chain with no recorded scope is now unproven rather than valid (#3306).
ChainResult.validreports structural validity — signatures and linkage — and a chain that records no scope at any hop passes that test while proving nothing about narrowing. It reached callers asvalid=Trueandbernstein delegation verifyexited 0, which reads as "this chain narrows" to anyone consuming the exit code. Grading is now a separate three-state verdict onChainResult: an axis that widens against its parent or its nearest scoped ancestor, or ascope_refthat resolves to something other than the inlinescope, fails; a chain with nothing to compare — no scope recorded, an unresolvablescope_ref, an unavailable parent receipt, an axis the comparator cannot order — is unproven; everything else passes. Fail dominates over unproven and the grade is computed without short-circuit, so a later widening is still reported.verifyexits 1 when the chain is structurally invalid or any hop fails, 3 when the grade is unproven, 0 otherwise, so a script that treated 0 as proof of narrowing now sees 3 where it was previously told nothing was wrong. The field is additive with a default factory and no receipt schema changed. -
A failed npm wrapper publish now fails its job (#3322). Releases that previously reported success with the wrapper left behind will now report the failure. Nothing depends on
publish-npm, so this surfaces the problem without holding back PyPI or the GitHub Release. -
reconcile-releasefails when it could not read a channel (#3345). A run that previously ended green because every unreadable channel was excluded from the verdict now ends red, after filing whatever drift it did find.
Release and distribution chain¶
The trigger was #3324, filed by the automation itself when auto-release skipped a commit and reported failure. Reading the chain from that end turned up three more places with the same shape.
-
The npm wrapper publish reports its own outcome (#3322, #3335). The step was written as
if ! npm publish; then echo "::warning::…"; fi, and a warning inside a green job is not a signal anyone reads, so the wrapper sat on a registry version from May while every release since reported success. The outcome is now classified: success passes;EPUBLISHCONFLICTand the olderE403"cannot publish over the previously published versions" emit a notice and pass, because a re-run or two triggers racing the same tag must be idempotent; anything else — including the 404 the registry answers for a token without publish rights — fails the job with npm's exit code, as does an unsetNPM_TOKEN. Verified against a stubbednpmcovering all six cases. -
Release follow-up workflows are dispatched explicitly (#3323, #3335, #3345).
publish-homebrew.ymlandsbom.ymlstarted onrelease: publishedand nothing else. A release created withGITHUB_TOKENraises no such event — the limitation the Docker publish had already worked around — so for an automated release neither ran, and the release ended green having shipped to PyPI and to nothing else. Both release entrypoints now dispatch the full consumer set with the repo-scoped token, and each workflow keeps itsreleasetrigger for releases created in the UI. The guard is written against the property rather than against the two files: any job runninggh release createmust dispatch the whole set, with input names the target workflow actually declares, so a third entrypoint cannot reintroduce the gap. -
SBOM attachment distinguishes an absent release from a failed lookup (#3345).
sbom.ymlgated attachment ongithub.event_name == 'release', so a dispatched run generated both SBOMs and attached neither, and its release lookup read every non-zeroghexit as "no release for this tag" — a bad token, a rate limit or a 5xx therefore expired both documents as a 30-day workflow artifact. Attachment is now keyed on a release existing for the ref being built, both entry paths share one upload step, and the lookup classifies the API status: only 404 means absence, everything else fails the job. The shell is executed against a stubbedghfor success, 404, 401, 403 permission, 403 rate limit, 502 and a connection failure. -
reconcile-releaserefuses a verdict it could not verify (#3345). Each channel probe degrades tounknownwhen the registry cannot be read, and anunknownchannel is excluded from the drift comparison — which leftdrift=falsemeaning both "every channel is level" and "we could not check". The auto-close step acted on the first reading of both, so a registry outage closed the ticket tracking the publish that outage had dropped, over a comment asserting everything was level. Unreadable channels are now collected and published asunknown_channels/channels_verified; auto-close and the no-drift notice both requiredrift=falseandchannels_verified=true; and the run fails at the end, after the reporting steps, so drift found on the channels that were readable is still filed. The comparison set grew from three to six — PyPI, the GitHub Release assets, the npmlatestdist-tag, the version pinned by the Homebrew tap formula, both SBOM assets, and Copr — because a channel nobody compares cannot be seen to fall behind. The issue title now carries the set of channels that are behind rather than a version per channel. -
RPM builds publish to Copr from the tag chain (#3325, #3336).
packaging/rpm/bernstein.specexisted and nothing built or published it, so the RPM channel had shipped nothing since 1.4.11. Apublish-coprjob now runs beside the PyPI, npm and GHCR jobs on the same tag trigger, gated on the PyPI publish because the wrapper RPM resolves the package at run time.scripts/build_copr_srpm.pyrewritesVersion:from the tag and records the release in%changelogwith a locale-independent date stamp; the rendering half is pure, so the version binding is covered on machines with norpmbuild. There is no warn-and-continue path: the submit step waits for the build, so a rejected submission or a failed chroot build fails the job.COPR_CONFIGreaches the shell throughenvrather than expression interpolation and is written with mode600; a missing secret fails the job rather than skipping the channel. Acopr_onlydispatch input republishes the single channel for a tag that is already released. -
Two defects found by running that new job end to end.
copr-cli2.5 importsrichat module scope while declaring onlycopr,humanize,jinja2andsetuptools, so a clean install produced a CLI that could not start; the tool is pinned to a release whose dependency set is complete (#3339). Its build watch has no deadline of its own and ran until the job's owntimeout-minutescancelled it 45 minutes in, with the build still pending on shared builders; the watch now carries its own deadline, distinct from the job budget (#3346).
Merge gate¶
-
Every pull-request head now gets a
review-bot-ackverdict (#3313, #3331). The required context is written by aworkflow_runhop, which exists because a fork's gate run holds a read-only token. One head collects several gate runs — a push, body edits, a review from each bot — so the hop elects one writer per head, and it elected by run id. The two halves of that rule were not complementary: the currency check stood down every publisher that was not newest for the head, and the job condition then declined to run for the newest one in two cases — a cancelled run, and a run triggered bypull_request_review. A head left with no writer never got the context, and the only recorded remedy was closing and reopening the pull request. The writer is now elected by what each run can do rather than by its id: a publisher stands down when a newer run can still publish, writes when no newer one can, and when no gate run on the head can publish at all, the newest re-dispatches the gate. A cancelled run still writes nothing — that rule is unchanged — and a newrepublishjob covers the case it leaves open, holdingactions: writeand deliberately notchecks: write, so a run with no verdict cannot write one. The re-dispatch is bounded to one per head SHA, marked byrun_attempt > 1on any gate run for the head, which is GitHub's own state rather than a ledger, and skipped when the head already carries a terminal context. The rule moved out of workflow shell intoscripts/ack_publisher_currency.pyas a pure function, because logic that exists only as YAML shell is how this stayed invisible. It is covered by a property test over every gate-run history up to length three — exactly one publisher writes or re-dispatches, never zero and never two — and by a replay of the three incident heads. -
The coverage ratchet stops reading an API error body as a baseline (#3347). The guard fetched the open baseline with
gh api … || trueand tested the output for emptiness;ghprints the error body to stdout, so when the ratchet branch was auto-deleted after its own pull request merged, the 404 body passed the emptiness check and reached the JSON parser, which raisedKeyErrorand turned every push onmainred. The guard now branches on the exit code: a missing ref proceeds as a fresh ratchet, any other API failure fails the job with the captured error, and a baseline that parses but lacks the expected key is rejected with a message rather than a traceback.
Command line¶
Six defects, each with the test that reproduces it.
-
bernstein doctor --jsoncould exit having written nothing at all to stdout, leaving a machine consumer unable to tell "the doctor ran and everything is fine" from "the doctor never produced a payload" (#3330, #3332)._check_tool_versionprobes each CI tool under a 10s budget and caught onlyFileNotFoundError, so aTimeoutExpired— or any otherOSErrorfrom a failed launch — propagated out through the doctor command, and because the payload is serialised once at the end, an exception anywhere before that discarded every row rather than one.uv runserialises on a shared cache lock, so concurrent probes on a loaded host queue behind one another and the budget becomes reachable, which made the failure load-dependent and intermittent. Both conditions are now reported the way a diagnostic reports things: a failed check row with an actionable hint, inside the payload. The timeout is a named constant so the budget and the detail string cannot drift. -
bernstein agents matchraisedAttributeErroron any catalog hit (#3310, #3326). The rendering path calledText.extend, whichrich.text.Textdoes not expose. The no-hit path never reached the call, so it shipped with no coverage on the branch that crashes. -
Undo audit logging resolves through the canonical module (#3308, #3329).
_log_undo_auditimportedget_audit_logfrom a compatibility alias, which resolves at run time but is opaque to static analysis, and the surroundingcontextlib.suppress(Exception)covered the import as well as the write — so a future failure to resolve it would have been swallowed rather than surfaced. The import is direct and the suppression now covers only the audit write, where a misbehaving backend should not fail the undo. -
The worker heartbeat loop backs off on a re-registration failure (#3309, #3327).
WorkerLoop.runre-entered_do_heartbeatimmediately when it returnedNone, skipping the poll-interval wait the success path takes, so a worker whose connection to the central server was failing sent back-to-back requests for as long as the failure lasted. The failure path now takes the same wait as the success path, and finished agents are reaped every cycle regardless ofnode_id, so an agent that completes while re-registration is retrying is still reported instead of being stranded. -
A long prompt no longer fails to spawn through a Windows
.cmd/.batshim (#3311, #3333). Such a shim always spawns throughcmd.exe /c, which enforces its own ~8191-character line buffer — independent of, and much smaller than, the ~32767 charactersCreateProcessallows. A prompt long enough to push the line past that buffer was rejected before the adapter binary started, and Bernstein read the immediate exit as an agent that started and produced nothing, then proceeded to the merge path and discarded real work. For theclaudeadapter, whose CLI reads the prompt from stdin when-pis passed with no value, an overflowing prompt is now written to a session-scoped file and piped in. Every spawn already under the buffer is untouched, which is every POSIX spawn and most Windows ones; other adapters keep today's behaviour, since their CLI's stdin contract for the prompt is not yet verified. The failure mode and the current scope are documented indocs/operations/TROUBLESHOOTING.md. -
bernstein stop --forcereaps an orphaned watchdog (#3312, #3334). The fallback scan that finds infrastructure processes when the pidfile is missing, stale, or overwritten by a later run in the same directory never attributed a watchdog to its checkout on Windows, for two compounding reasons: the process probe read the executable path rather than the command line, so none of the argv markers the classifier looks for were present; and ownership was confirmed by shelling out tolsof, which does not exist there._start_watchdognow writes its working directory into its own launch argv, so the scan attributes the process by reading the command line with no cwd probe, and the Windows probe queries the real command line viaWin32_Process, parsed with the stdlibcsvreader so an embedded comma or quote does not break a naive split.
Task records¶
Three typed-interface mismatches in the tasks package, each a runtime defect on its own path rather than an annotation gap, and each sitting behind a fail-open guard — so none of them raised, and all three silently dropped the work they were supposed to do (#3315, #3316, #3317, #3328). _task_to_record built a TaskRecord without the parent_context key its TypedDict declares as required, and because the reader uses .get(), a subtask persisted and replayed came back with no parent context and its agent started without the exploration that produced its task. The dead-letter path called IncidentSynthesizer._load_existing_ids and the lifecycle summary read AgentSession.files_changed, neither of which exists, so dead-lettered tasks produced no incident eval case and the summary silently lost its file list.
Documentation and changelog surfaces¶
- The changelog surfaces stop contradicting each other and the release notes (#3343). Release history is written to
docs/release-notes/, one page per tag, by the commit that bumps the version, but two other surfaces were still presented as the release history:CHANGELOG.mdstopped at 2.5.0 against a 3.12.0 tree and carried twoUnreleasedheadings, one orphaned mid-history;docs/CHANGELOG.mdstopped at 3.5.0 with a different set of unreleased entries; andbernstein changelogfetched the root file, so it described 2.5.0 on a 3.12.0 install. Both files are now pointer documents, entries that had landed but appeared in no release-notes page moved todocs/release-notes/unreleased.md, and the command resolves the page for the installed version, falling back to the highest-versioned local page — picked by parsing(major, minor, patch)rather than by sorting names, sov3.12.0wins overv3.9.0and unversioned pages are not eligible. A bump whose release-notes page is missing now fails its test rather than serving the previous release as current. - The release flow is documented as it works: a version-bump pull request merges to
main, CI passes, av*tag firespublish.yml(#3344). Three documents still described releases as managed by a tool whose configuration files nothing read; those files are removed (#3340). - Command surfaces deleted in the previous cycle are swept out of the docs, and two claims the code could not back are corrected (#3342). The Cloudflare quickstart is rewritten to the path that works today rather than deleted, so it still runs end to end.
docs/whats-new.mdwas a standalone summary of the 1.9.x wave, three major versions behind, generated by nothing; it is now a pointer to the release notes.SECURITY.mdnamed a CLI entry point that is registered asserve(#3340).- The adapter last-green table is regenerated from canary receipts, each row anchored to the receipt that attested it (#3337).
Contributors¶
- Tymofii Pidlisnyi — designed and implemented delegation-chain grading (#3306): the closed verifier reason set, the fail-dominant composition over the existing
DelegationScopenarrowing algebra, thescope_refcross-check, and the third state for a chain that records no scope, with the CLI exit codes and the documentation indocs/security/delegation-narrowing.mdanddocs/operations/delegation-verify.md.