Skip to content

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.valid reports 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 as valid=True and bernstein delegation verify exited 0, which reads as "this chain narrows" to anyone consuming the exit code. Grading is now a separate three-state verdict on ChainResult: an axis that widens against its parent or its nearest scoped ancestor, or a scope_ref that resolves to something other than the inline scope, fails; a chain with nothing to compare — no scope recorded, an unresolvable scope_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. verify exits 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-release fails 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; EPUBLISHCONFLICT and the older E403 "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 unset NPM_TOKEN. Verified against a stubbed npm covering all six cases.

  • Release follow-up workflows are dispatched explicitly (#3323, #3335, #3345). publish-homebrew.yml and sbom.yml started on release: published and nothing else. A release created with GITHUB_TOKEN raises 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 its release trigger for releases created in the UI. The guard is written against the property rather than against the two files: any job running gh release create must 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.yml gated attachment on github.event_name == 'release', so a dispatched run generated both SBOMs and attached neither, and its release lookup read every non-zero gh exit 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 stubbed gh for success, 404, 401, 403 permission, 403 rate limit, 502 and a connection failure.

  • reconcile-release refuses a verdict it could not verify (#3345). Each channel probe degrades to unknown when the registry cannot be read, and an unknown channel is excluded from the drift comparison — which left drift=false meaning 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 as unknown_channels / channels_verified; auto-close and the no-drift notice both require drift=false and channels_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 npm latest dist-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.spec existed and nothing built or published it, so the RPM channel had shipped nothing since 1.4.11. A publish-copr job 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.py rewrites Version: from the tag and records the release in %changelog with a locale-independent date stamp; the rendering half is pure, so the version binding is covered on machines with no rpmbuild. 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_CONFIG reaches the shell through env rather than expression interpolation and is written with mode 600; a missing secret fails the job rather than skipping the channel. A copr_only dispatch input republishes the single channel for a tag that is already released.

  • Two defects found by running that new job end to end. copr-cli 2.5 imports rich at module scope while declaring only copr, humanize, jinja2 and setuptools, 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 own timeout-minutes cancelled 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-ack verdict (#3313, #3331). The required context is written by a workflow_run hop, 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 by pull_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 new republish job covers the case it leaves open, holding actions: write and deliberately not checks: write, so a run with no verdict cannot write one. The re-dispatch is bounded to one per head SHA, marked by run_attempt > 1 on 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 into scripts/ack_publisher_currency.py as 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 … || true and tested the output for emptiness; gh prints 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 raised KeyError and turned every push on main red. 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 --json could 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_version probes each CI tool under a 10s budget and caught only FileNotFoundError, so a TimeoutExpired — or any other OSError from 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 run serialises 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 match raised AttributeError on any catalog hit (#3310, #3326). The rendering path called Text.extend, which rich.text.Text does 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_audit imported get_audit_log from a compatibility alias, which resolves at run time but is opaque to static analysis, and the surrounding contextlib.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.run re-entered _do_heartbeat immediately when it returned None, 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 of node_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/.bat shim (#3311, #3333). Such a shim always spawns through cmd.exe /c, which enforces its own ~8191-character line buffer — independent of, and much smaller than, the ~32767 characters CreateProcess allows. 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 the claude adapter, whose CLI reads the prompt from stdin when -p is 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 in docs/operations/TROUBLESHOOTING.md.

  • bernstein stop --force reaps 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 to lsof, which does not exist there. _start_watchdog now 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 via Win32_Process, parsed with the stdlib csv reader 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.md stopped at 2.5.0 against a 3.12.0 tree and carried two Unreleased headings, one orphaned mid-history; docs/CHANGELOG.md stopped at 3.5.0 with a different set of unreleased entries; and bernstein changelog fetched 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 to docs/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, so v3.12.0 wins over v3.9.0 and 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, a v* tag fires publish.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.md was 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.md named a CLI entry point that is registered as serve (#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 DelegationScope narrowing algebra, the scope_ref cross-check, and the third state for a chain that records no scope, with the CLI exit codes and the documentation in docs/security/delegation-narrowing.md and docs/operations/delegation-verify.md.