Repository navigation
v0.118.0
The conversion, ownership and ledger release: repository conversions become verified move-outs and move-ins on releasables with lineage records; workspaces gain single-owner attribution with a mandatory root member and derived CI filters; release archives become the anchored ledger every range, status and undo read; the publication reconciler repairs tags and Releases from recorded facts; mirrors carry release tags and Releases and can be promoted to standalone repos.
Context
This release ships one campaign. The old conversion commands copied part of a
project's state and left both repositories incoherent; workspace ownership was
implicit and a root member owned nothing; what a release WAS lived only in git
tags; and mirrors were code copies that could not release. The campaign
replaces all four foundations: conversions verify tree identity and transplant
everything (state, anchors, tags, lineage); every file has exactly one owning
member and CI triggering derives from ownership plus dependencies; the
archived release files carry the CI-verified commit and tree hashes and are
the authority every consumer reads; and the reconciler and mirror publication
repair or extend the published surface only from recorded facts, refusing
anything no record explains. Implicit-mode workspaces and the migrate/extract
commands they used are gone -- deferred workspaces pin 0.117.2 per their own
conversion plans, and scripts/migrate_workspace_model.py migrates the rest.
Breaking
- Every workspace must now declare a root member.
workspace.tomldeclares the repository root as a member (path = ".", namedroot), which owns every tracked file no other member claims. The loader refuses a workspace without one, one whose root member is named anything else, one that gives another member the reserved name, one carrying awatchkey, an implicit-mode workspace with no[[releasables]], and a releasable owning the root member without an explicittag_format-- each error naming its own remedy. - Commands run at a monorepo workspace root now resolve to the root member. The workspace root is that member's own directory, so
rlsbl status,changelog addand the rest report on it instead of refusing with "not inside any project".release resumeand the changelog checks still treat standing there as "somewhere in this workspace" and look across every releasable. rlsbl release runat a monorepo workspace root now requires--releasable <name>. The root directory names the whole workspace rather than one releasable, so the invocation has to say which one; without the flag the command errors and lists the declared releasables. Running inside a member directory is unchanged, and passing the flag there (or in a standalone repository) is refused rather than ignored.- The workspace
watchkey is gone from every surface.rlsbl monorepo add --watchno longer exists,rlsbl monorepo statusno longer renders a Watch column, and every error message and doc page that told you to adjust watch patterns now points at the derivedfilters:block instead. The loader still refuses a workspace that carries the key, naming the member and the line to delete. - Workspace member paths must be unique. The loader refuses a workspace whose members claim the same path (including two spellings of one path, and two root members) instead of letting declaration order decide which member owns a file.
rlsbl monorepo extractis one command over the releasable, and it completes the move.monorepo extract-releasableis gone andmonorepo extractnow takes a releasable name instead of a package name -- a releasable is the portable unit, since it owns the version, changelog, release archives and tag scheme a package alone does not. The conversion now verifies each member's tree object survives the history filter, transplants the releasable's whole release state (remapping the release anchors and changelog hashes onto the rewritten commits), keeps one boundary alias tag at the current version, writes a lineage record explaining the conversion, and removes the departed members and their state from the source in one commit. It previews the entire plan under--dry-runand refuses -- before writing anything -- a mirrored releasable, one owning the root member, an inbound dependency (naming therlsbl rewritecommand that severs it), and a missing saferm unless--delete-with-rmis passed. Being destructive to the repository you are standing in, it now asks for confirmation.- The release archives are now the authority on what shipped. The unreleased range and every coverage figure are bounded by the highest archived release this checkout contains, not by
git describe, so a deleted or moved tag no longer erases a release from the range and a monorepo member with a mismatched tag glob no longer reports all of history as unreleased. A truncated history is a hard error naming the deepen remedy instead of silently reporting "never released". The--jsonpayloads ofrlsbl statusandrlsbl unreleasedcarrylatest_release,latest_release_in_checkoutandrange_anchor_*in place of the formertagandcommits_ahead_tagkeys. rlsbl monorepo statusnow reports each row's latest release from that project's release archives -- theTagcolumn is nowReleasedand shows the version, annotated when the checkout does not contain it -- and both its coverage figures andrlsbl monorepo release init's unreleased-commit counts measure from the archived release rather than from the newest matching git tag.- Release-ref verification now covers every released version. The
local-tag,remote-tagandgithub-releasechecks are replaced by oneunpublished-refscheck. The old three looked only at the current version's primary tag; the new one asks the release ledger which versions shipped and verifies every ref each of them owns -- primary tag, Go companion tags, and alias tags a rename or conversion recorded -- exists locally, exists on origin, and points at the commit the release anchored. It reports the three cases distinctly and namesrlsbl release reconcileas the remedy. rlsbl monorepo migrate-releasableis removed. It migrated a monorepo from per-package release state into the releasable model, and that state cannot exist any more: a workspace with no[[releasables]]section is refused at load. A workspace that still needs the conversion runs it under rlsbl 0.117.2, the last version that reads such a workspace.- The per-package batch release form (
[packages.<name>]) is removed. A batch release file declares[releasables.<name>]sections only; a file carrying a[packages]section is refused with the rewrite it needs. This follows the workspace loader's refusal of workspaces that declare no[[releasables]]-- there is no second release mode left for the per-package form to describe. - The subtree mirror's destination moved from the member to the releasable.
subtree_remoteis now a key on a[[releasables]]entry inworkspace.toml, not on a[[projects]]member; a member still carrying it is a hard error naming the exact edit, and a releasable with more than one member may not declare one at all -- a mirror carries one subtree.rlsbl monorepo addno longer takes--subtree-remote: declare the binding beside the releasable it belongs to.
Features
rlsbl rewrite go-module-path. Renames a Go module path across a repository: the module-path tokens in everygo.modand every Go import site under the old path, located by the tree-sitter import scanner and rewritten line-anchored.--dry-runprints a per-file plan with occurrence counts, and an apply refuses if a count moved since the preview.rlsbl rewrite uv-path-sources. Converts path- and workspace-sourced Python dependencies into registry constraints floored at the versionuv.lockresolves, covering[project].dependencies, optional-dependency extras and PEP 735 dependency groups, deleting the matching[tool.uv.sources]entry and declaring each converted name ininternal_dep_floors. A locked version PyPI does not have -- or a registry probe that fails to answer -- is a hard error naming the release-first remedy, so an unsatisfiable floor is never written.- A committed support matrix, and a check that keeps it fresh.
rlsbl/data/support-matrix.jsonrecords what every release target supports on every axis, plus each check's target scope and the pipeline table, all derived from the registries rather than written down beside them. The newtarget-matrix-freshcheck (error,project+preflight) fails when the committed file no longer matches a regeneration, so a stale matrix blocks the release instead of shipping. Regenerate withuv run python scripts/generate_support_matrix.py. - Monorepo CI router path filters are now derived from the workspace. A member's filter is its own territory plus the territories of everything it depends on (transitively, every dependency scope), the workspace-root manifests and lockfiles, and the tool's own machinery; the root member gets everything minus the other members' territories. Nothing is declared per project any more, and the generated router now sets
predicate-quantifier: some-with-excludesso those exclusions actually apply. - New check
router-filters-fresh. The generatedci-router.yml'sfilters:block must match a fresh derivation from the workspace. A stale block silently under-triggers a member's CI, and a member whose job concludesskippedon the commit a release tags cannot pass the publish check. Runs underrlsbl check --tag workspaceand in the release preflight. - New script.
scripts/backfill_release_anchors.pybackfills release archives in one reviewed pass: it anchors every archived version to the commit it shipped from, stamps the strictspecformat_versiongate onto archives written before the gate existed, materializes archives for released versions that never had one, and records a permanentunanchorable = truemarker on a version whose commit cannot be recovered from any source. - New script.
scripts/set_archived_descriptions.pyrewrites thedescriptionfield of archived release files from a reviewed JSON map, unlocking and relocking each 0444 archive, with a mandatory plan and a written-count assertion. - Docs. The release-workflow page documents how to backfill an existing repository's release anchors, and the
unanchorablemarker that permanently records a version whose commit could not be recovered. rlsbl monorepo absorbis rebuilt: an absorbed repository arrives as a releasable, and a crashed absorb is finished by re-running it. It previews the whole conversion under--dry-runand refuses before writing anything -- a colliding tag name, a version the destination already released, a mixed tag scheme (naming the new--tag-format), a dirty tree. The source's tags are no longer fetched at all, so a tag this repository already owns can never be moved or deleted; the imported tags are created at the rewritten commits, with one boundary alias at the current version. The arriving release state -- changelog, release archives with their anchors, config and version -- moves into the releasable's state directory with every hash and anchor remapped and each recorded tree verified against the rewritten history, and the run ends with the member scaffolded, the CI router re-synced, the snapshot regenerated and a lineage record explaining all of it.- Repository conversions are documented. A new guide covers
rlsbl monorepo extractandrlsbl monorepo absorbend to end: the releasable as the portable unit, every refusal, the tag translation and boundary-alias policy, tree and release-anchor verification, the manual procedure for splitting one member out of a shared releasable, re-run healing, and what lineage records hold. - New
dep-lockscheck: a lockfile that no longer resolves its manifest is an error. A dependency added, removed or re-constrained after the lock was written leavesuv syncandnpm ciinstalling the old resolution. The check comparesuv.lock's entry for the project againstpyproject.toml,package-lock.json's root entry againstpackage.json, andgo.sum's coverage ofgo.mod; each finding names the relock command. Structural and offline -- no package manager is invoked. - New
go-module-identitycheck: ago.modwhose module path no longer names the repository is an error. A renamed repository, a new owner, or an absorb that moved a module under a subdirectory leaves a module path the toolchain can no longer fetch. The expected path comes from the origin remote plus the module's directory (/vNmajor suffixes accepted), and every finding names therlsbl rewrite go-module-pathinvocation that fixes it. A repository with no origin skips rather than guessing an identity. - New
strictspec-generated-floorcheck: a strictspec floor below a generated validator'sGENERATED_BYis an error. Generated validators pair with the exact strictspec release that produced them at import time, so a floor a consumer can resolve below it ships an artifact whose validators cannot import -- invisible locally, because the repo's own lock resolves the newer release. The comparison is at full patch precision against the filesstrictspec.tomldeclares, whichdep-floors(major.minor, against the lock) cannot see. - Three new networked release checks.
ci-publish-secretsverifies that every Actions secret the configured CI publish pipelines authenticate with exists on the GitHub repo -- each pipeline declares its own:NPM_TOKENfor an npm pipeline, the Central Portal credentials and GPG signing key (SONATYPE_USERNAME,SONATYPE_PASSWORD,GPG_SIGNING_KEY,GPG_SIGNING_KEY_PASSWORD) for a maven-central one,HEX_API_KEYfor a hex one, and nothing for a pypi pipeline, which publishes through OIDC -- so a missing credential is found before the release tags rather than when the publish job dies withENEEDAUTH.old-repo-archivedreads the lineage record's absorb conversions and reports a source repository still active on GitHub, naming thegh repo archivecommand.go-deprecation-publishedreads recordedgo-module-pathtransitions and reports an old module path the proxy still serves with no// Deprecated:notice. All three carry thereleasetag so offline runs stay offline, and all three are fail-closed: a probe that cannot answer is an error, never a pass. rlsbl release reconcilenow reconciles everything a release publishes, not just the tags a rewrite moved. It observes origin once and judges every subject -- each released version's full ref set and its GitHub Release -- against four merged records rather than one: safegit's rewrite journal, the release ledger's anchors, the lineage records, and the committed scrub archives. Because the last three are committed, a fresh clone that never saw the journal can still explain a scrub it carries the archive for. The verdicts arematerialize(origin is missing a ref the ledger records, or a released version has no GitHub Release),already-correct,re-point-with-lease,refuse-foreignandrefuse-identity-mismatch. One ref origin holds that no record explains aborts the whole reconcile and nothing anywhere is repaired, and Go refuses to recreate a tag for a version released under a module path the repository has since changed. Consent is file-driven and the choice is required:--planwrites.rlsbl/releases/reconcile-plan.tomlwith a digest of the world it judged, and--applyre-observes and refuses when that world has moved.rlsbl monorepo mirrornow reconciles the mirror's release tags too, not just its branch. A mirror could be perfectly converged onmainand carry none of the tags its releasable has released -- a mirror bound after the fact, a tag push that failed at release time, a mirror that was reset. The preview now names every released version the mirror is missing, and an apply materializes it from the release ledger: the tag at the subtree split of that version's anchor commit, and the mirror's own GitHub Release with the version's notes. The scaffold commit also moves identity manifests onto the mirror's identity (a mirrored Go module'sgo.modnamed the monorepo, so it could not be fetched from the mirror), and a mirror's scaffold no longer renders a publish workflow -- a mirror's Releases come from the release flow, never from CI of its own.rlsbl monorepo extractnow promotes a mirrored releasable instead of refusing it. A releasable bound to a subtree mirror used to be a hard refusal: the mirror derives from the monorepo, so extracting would leave it deriving from a subtree that no longer exists. The same command now runs a different engine when a mirror binding exists -- it adopts the mirror's history rather than filtering a second one out of the monorepo, so the extracted repository keeps the commit ids consumers already resolve. Every changelog hash and release anchor is translated through the monorepo-to-mirror subtree-split correspondence, each released version's tag is materialized at the split of its anchor, and the correspondence itself is written into the new repository's lineage record so it can explain its own hashes without the monorepo. Deleting the monorepo's copy is justified by tree-hash equality against the mirror's pre-scaffold commit: a mirror that is behind stops the promotion and names the command that converges it.- The docs now state which part of rlsbl writes which git ref namespace. One table, in
CLAUDE.md, the README and the release-workflow page: origin's branch heads are written by releases and nothing else; origin's tags and their GitHub Releases by the release's tag step andrlsbl release reconcile, both composing the Release through one module; a subtree mirror's branch by the mirror reconciler's converge; a mirror's tags and Releases by the mirror publication module; and rewritten history byrlsbl release scrub, the one sanctioned rewrite write. - New script.
scripts/migrate_workspace_model.pyapplies the mechanical half of the workspace-model conversion to one repository: it declares the root member in the kind the operator names (--root-dev-node, or--root-releasable <name> --tag-format <fmt>), deletes every retiredwatchkey, moves a member-levelsubtree_remoteonto the releasable that owns the mirror, then loads the result and prints any remaining loader error verbatim as the list only an operator can resolve. It edits the file raw, so it reads a workspace the loader itself refuses; it is idempotent, previewable with--dry-run, and runs the release-anchor backfill afterwards.
Fixes
rlsbl monorepo absorb --dry-runno longer aborts before it can preview anything: its source-repo precondition check read the worktree with a command the preview refused to run.- strictspec floor corrected. Installing rlsbl could resolve a
strictspecolder than its generated validators require, making every rlsbl command fail at import with a runtime-version error. The declared floor now matches what the validators call for. monorepo mirror --dry-runrenders a plan again. The preview used to stop at step 1 with "branched on unsettled value" because observation'sgit subtree splitand inspection clone were recorded instead of run; both are now allowlisted observes, and the plan no longer advertises scratch directories no apply would create.- Ancestry checks no longer trust a shallow repository's "no". With both commits present but the connecting history truncated by a shallow fetch, git answers "not an ancestor" to a question it cannot follow to the end; rlsbl now treats that as undetermined, so the mirror tripwire, the release's CI-verified candidate check and
release resumerefuse instead of acting on a wrong answer. - Mirror honesty on unanswerable history. When git cannot determine whether mirror commits descend from the current subtree split (pruned or unfetched objects),
monorepo mirrornow reports a distinctlineage-undeterminedverdict with a fetch/deepen remedy instead of accusing the mirror of foreign commits. workspace-unregisteredno longer reports a member's declared target path subdirectory (e.g.{"name": "npm", "path": "npm"}) as an unregistered project- Similarly-named Go modules no longer confuse strictcli detection. A project importing a module whose path merely begins with another module's path (
github.com/o/foo-extrasnext togithub.com/o/foo) was matched as importing the latter, which could name the wrong main package as a repo's CLI entry point. - The pgdesign target no longer silently detects an undeclared
schema/subdirectory: detection looks only at the directory it is handed, and a schema in a subdirectory is declared with an explicit target path ({"name": "pgdesign", "path": "schema"}). Version and schema resolution raise a hard error naming that remedy instead of guessing. - Commits that only touch rlsbl's own generated files no longer need changelog coverage: the exempt set now also covers
.rlsbl/bases/,.rlsbl/lint/and the generated.github/workflows/ci-router.yml, alongside the changelog and release state it already covered. rlsbl release yankno longer passes over a target in silence. A target with no registry-removal action is now reported explicitly by name instead of relying on a fallthrough in the command's name-based dispatch.- A project whose target has no built-in test runner no longer records a passing test step. The release's test step,
rlsbl check --tag qualityand the pre-push workspace test check now report an explicit skip naming the target instead of silently succeeding, andtest-suite-workspacelists affected projects it could not test rather than dropping them from its count. rlsbl rewrite go-module-pathno longer skips whole directories. The sweep visited everything exceptvendor/and.git/as documented, but inherited the linters' build-output exclusions, so Go packages in ordinary directories namedbuild,dist,static,public,assetsornode_moduleswere silently left un-renamed while the command reported success.- Go imports written as raw strings are no longer invisible. An import written with backquotes instead of double quotes compiles identically, but the Go import scanner skipped it -- so
rlsbl rewrite go-module-pathleft it un-renamed, and the dead-package and strictcli detectors did not count it as a reference. - A workspace root member now owns its files. A member declared at
path = "."computed a prefix no git path could ever match, so it owned nothing: its changelog coverage was silently vacuous and onlywatchpatterns gave it any territory. Every tracked file now belongs to exactly one member -- the most specific declared path wins, and the root member owns whatever no other member claims. - An aborted
rlsbl rewritenow says what it already wrote. The count-mismatch abort read as if nothing had been written; it now enumerates the files or dependencies applied before the failure (which stay on disk -- there is no rollback) and states that a re-run re-plans from the current tree. A planned file that disappears between the preview and the apply is reported as an error naming the file instead of a traceback. - The pipelines and targets documentation no longer describes things that do not exist. The pipelines page claimed publish and asset steps were decided by
publishandbuild_assetstarget capabilities that no target ever declared and no code ever read; it now describes what actually decides a publish step. The targets page claimed thename-consistencycheck skips targets that cannot read a name, when it asks every detected target and reports the ones that answered nothing. rlsbl release yankprobes and yanks subdirectory targets correctly. A target declared with apathwas probed against the project root's manifest instead of its own, so its publication status could come from the wrong package entirely.name-consistencyreports non-answering targets when names disagree. A detected target that reports no package name is now named on the mismatch branch as well as the pass branch, so the mismatch detail no longer reads as the complete set of detected targets.rlsbl status --registrydistinguishes "no version API" from a failed query. Targets whose ecosystem has no latest-version endpoint (zig, docker, plain and the rest) no longer print(query failed), which read as a network problem; they name the missing API and skip the request. The JSON payload gains aNO_REGISTRY_APIdrift value.- The documentation tables no longer import rlsbl to render. The release-target table, the check-vs-target matrix, the pipeline table and the target count now read the committed support matrix, so building the docs needs the repository rather than an installed rlsbl -- the import path that once failed a release when the documentation environment lost its rlsbl overlay.
- The targets and checks pages stop restating what the tools already generate. The detection-files, ecosystem-classification and capability tables on the release-targets page were hand-typed duplicates of columns the generated table already renders; the support axes are now a rendered table covering every axis rather than five of them. Several pages also carried counts that had drifted from the registries -- 18 ecosystems against 17 registered, 12 changelog checks against 11 -- and now describe without counting.
rlsbl monorepo release runtags every package again in packages mode. It looked for each member's release state beside the package instead of under the member's releasable, so it treated each one as already finished: no tags, no finalized changelogs, and a success report.rlsbl monorepo check-namesno longer queries registries about dev nodes. A dev node publishes nothing, so it has no registry identity to check; every workspace has at least one once its root member is a dev node.rlsbl dev install --allno longer fails on dev nodes. A dev node ships nothing, so there is nothing to install from it; the run used to abort with "no targets detected". Naming one through--includestill selects it.- Changelog coverage no longer exempts nested lookalikes of root-only tool files. A path named
.rlsbl-monorepo/...or.github/workflows/ci-router.ymlbelow the repository root is a hand-written file with an owner, not rlsbl's own bookkeeping, so commits touching only such files need changelog coverage again. - A broken
workspace.tomlnow surfaces its loader error instead of looking like a repository with no releasables. Releasable-config resolution swallowed every exception and answered "no releasable", so a workspace the loader had refused produced a generated publish router with unrendered template variables and no explanation. - Releases no longer abort when a finalize file is unchanged. A commit now names only the files whose staging actually changes the tree, so a byte-identical
CHANGELOG.mdregeneration no longer makes safegit refuse the release's finalize commit. - Undo audit file is no longer silently discarded. A corrupted
undo-audit.jsonwas previously overwritten with a fresh single-record array, losing the record of every past undo;rlsbl release undonow reports it as an error and leaves the file untouched for repair. - Fixed: an unreadable workspace manifest no longer narrows the generated CI router's paths filters. A manifest no dependency scanner could parse contributed no edges, so
rlsbl monorepo syncwrote a filter missing a dependency territory and exited 0 -- androuter-filters-freshpassed, because it re-derived from the same broken manifest. Changes to a dependency could then stop triggering its dependents' CI. Sync, the freshness check and the release-time window simulation now refuse, naming each project and file, andworkspace-unbuildableruns in the release preflight. - Fixed:
router-filters-freshnow compares the generated CI router's whole filters block against a fresh derivation, so a filter entry deleted by hand is reported (missing, extra, stale and differing entries are each named by member). Previously the check only walked the committed entries, and a member whose entry had been removed -- whose CI therefore never ran again -- passed silently. - The empty-candidate-window refusal no longer offers excluded paths. The remediation list now separates negated filter patterns under a 'Not counting' heading instead of telling the operator to commit under paths the filter excludes.
- Undo refuses without its audit record.
rlsbl release undono longer deletes the GitHub Release, the tags and the release commits when itsundo-audit.jsoncannot be written -- an unwritable or malformed audit file now refuses the whole operation before anything is destroyed, naming the file and the repair. - Release-file errors name the file. A schema or validation failure on a release file now says which file failed (
<path>: <diagnostic>) instead of reporting only the field, for both single-project and batch release files. rlsbl release runnow refuses an editableunreleased.tomlcarrying anunanchorablemarker, alongside the existing refusal of hand-authoredcandidate_sha/tree_hashes.rlsbl watchnow names the commit it is watching by the release that commit shipped, read from the project's release archives, so a deleted or moved tag no longer changes the run label or the release page the success notification links to.--dry-runno longer reports a destroyed tag for a healthy release. Previewing a release of an already-released project aborted with a destroyed-tag diagnosis and three recovery options, none of which applied — the preview could not answer whether the current version's tag existed and that unanswerable read was treated as "no tag". Whether a version already shipped is now read from the release archives, and the guard still fires for a genuinely destroyed tag.rlsbl monorepo extractnames the edit that actually severs an inbound dependency. The remedy is read from the depending member's own manifests --pyproject.toml,package.json,go.mod-- instead of the dependency scanner's classification, so a Python edge declared through[tool.uv.sources]no longer gets a Go remedy pointing at ago.modthat does not exist, an npm edge gets the exactpackage.jsonedit, and an edge declared in two places gets both edits.rlsbl release undonow determines which release it is undoing from the project's release archives instead ofgit describe, so a release whose tag was already partly deleted is still undoable and a hand-made tag can no longer nominate a release that never happened. Its search for the previous release's boundary uses that release's recorded commit, so a deleted predecessor tag no longer silently widens it.rlsbl monorepo extractrefuses a member it cannot carry, before it touches anything. A submodule under a departing member used to be discovered at the source-side commit -- after the member's files were already deleted -- because the commit tool refuses a gitlink path; it is now a refusal at preview naming the submodule. A member path with nothing tracked at HEAD is refused by name too, instead of surfacing a rawgit rev-parsefailure.rlsbl monorepo extractnow checks that historical releases survive the rewrite. Each archived release anchor records the git tree of every path that version shipped; those hashes are content-addressed, so the extract compares its recomputed value against the recorded one instead of silently overwriting it. A disagreement is a hard error naming the archive, the path and both hashes, raised while the source is still untouched.- An extract no longer leaves a released version's changelog contradicting itself, and a rewritten changelog keeps its own permissions. When the history filter prunes every commit a released version's entries referenced, that version's
.mdis regenerated from what survived plus its archived release file -- so the section keeps its description and says "No user-facing changes" instead of listing entries the JSONL no longer holds -- and the run names the affected versions in its next steps. Separately,rlsbl changelog remap(and the remap inside the extract) pinned mode 0600 on every file it rewrote, so an ordinary 644 changelog silently became readable only by its owner. - The target support matrix now covers every per-target fact, not just the ones named like one. Completeness is stated by exclusion: every public attribute of the release-target protocol must be either a matrix axis or an explicitly excluded operation with a reason, so a fact can no longer escape the matrix by how it was spelled. Three axes join the documented tables: whether a target's publisher is bound to a repository, where such a publisher is registered, and what a user is told to run to create a project of that target.
- A non-ASCII or awkwardly named path no longer corrupts a commit rlsbl makes. The working-tree probe reads git's porcelain in NUL-separated mode, so a path holding non-ASCII characters, spaces, tabs, quotes or backslashes arrives exactly as it is on disk instead of in git's escaped spelling -- which named nothing when handed back to a commit, failing mid-operation after earlier steps had already run.
rlsbl rewrite uv-path-sourcesnow works for uv workspace members: when the target directory has nouv.lockof its own, the floor is read from the lock of the uv workspace root that claims it (a lock beside the manifest still wins, and neither location having one is still a hard error, now naming both probed paths).rlsbl dev syncandrlsbl dev statusrefuse an overlay that cannot mean what it says. An overlay naming a package this workspace itself builds, or pointing at a path inside this repository, is now a hard error explaining which one it hit: the first would leave which copy of a member the tests import to install order, and the second makes the environment depend on a tree that resolves on no other machine.rlsbl monorepo extractno longer makes a config file owner-only. Both configs it writes -- the extracted repository's merged.rlsbl/config.jsonand the dependency-floor declaration it adds to every releasable that stays -- pinned mode 0600, so an ordinary 644 config silently became readable only by its owner on the way through. A rewrite now keeps the mode the file already had, and a fresh one is written through the umask.- A repository absorbed into a workspace can be extracted back out. The absorb keeps the source's own tag beside the imported one at the current version, and the extract then translates that same version back to that same name -- which it refused as a tag collision, so a round trip was impossible. A tag standing at exactly the commit a translation would produce is now recognized as the boundary alias it is: kept, not re-created, and never a refusal. A tag of that name at a DIFFERENT commit is still a hard error.
- A release no longer garbles non-ASCII paths. Every working-tree read in the release flow goes through one
git status --porcelain -zhelper, so a file whose name holds a non-ASCII character, a space, a tab, a quote or a backslash is named exactly as it is on disk -- in the not-clean refusal, in the selfdoc and hook snapshots, and in the files the release commits. Previously git's C-escaped spelling ("pkg\\303\\251/x.txt") reached those places and named nothing any filesystem has. changelog generatecommits every file it generated. The sweep that decides which regenerated files to commit now reads the working tree in the NUL-separated form and asks for untracked files individually, so a per-version.mdunder a not-yet-tracked.rlsbl/changes/is committed instead of silently skipped, and a path outside plain ASCII is named as it is on disk rather than in git's escaped spelling.monorepo cleanupno longer leaves a member's deletions uncommitted. When a member package's directory holds a non-ASCII character, a space or a quote, the deletions are now committed under the member's real path instead of git's escaped spelling -- which named nothing, so the files were removed on disk while the tree stayed dirty and the command still reported a successful commit.monorepo syncauto-commits into the workspace it was given. Sync resolved its git repository from the process working directory instead of the workspace root it was handed, so a caller pointing it at another workspace could not use auto-commit -- git refused the paths as outside the repository. The commit now runs in the workspace being synced.monorepo absorbandmonorepo extractnow name a command that exists when a releasable has no release state. Both refusals pointed atmonorepo migrate-releasable, which is gone. They now say to runrlsbl monorepo sync-- which creates the state directory, its version file and its changelog for every declared releasable -- and to set the real version there first if the releasable has already shipped.- The monorepo guide describes only the releasable batch form. The batch-release section's examples and prose named per-package
[packages.<name>]sections, which no longer parse; they now show[releasables.<name>]and explain that a releasable is the unit a batch releases. - A repository that released but was never backfilled now says so. When the release ledger holds no archive at all but the repository carries version tags under its own tag scheme,
rlsblhard-errors with the backfill instruction instead of reporting the entire history as unreleased. A project before its first release -- no version tags -- is unaffected. - Re-running an interrupted
rlsbl monorepo absorbcompletes it instead of corrupting it. A changelog entry with noidis no longer migrated twice, a re-run aimed at a different--releasableis refused instead of silently skipping the version-overlap check, and a heal writes the version the first run recorded rather than re-reading it from the source (which a fork could have moved). - An unreadable release archive now fails
rlsbl check --tag releaseinstead of aborting the run. Theunpublished-refscheck reported the unreadable archive and then tried to finalize a pass, which the check framework refuses -- taking every other check down with it. It now fails cleanly, naming the archive. - Undoing a release no longer destroys changelog entries written after it. Un-finalizing a version now merges its entries back into
unreleased.jsonlahead of whatever accumulated since the release, instead of renaming the released file over them. rlsbl check --tag workspaceno longer demands a Go companion tag that the release never creates. When a releasable's own tag format is already the Go module proxy scheme (path/v{version}), the primary tag IS the proxy tag, so no companion exists -- thego-companion-tagscheck now derives its expectations from the same authority the release flow tags from.rlsbl monorepo extractprints its Trusted Publishing reminder on the real run. The next-step naming PyPI's repository registration appeared under--dry-runbut was dropped by the apply, exactly when the operator needed it. The tag summary also no longer claims the destination "keeps this releasable's tag format" for a releasable that owns no tag yet, where the format does change.rlsbl watchlabels a commit with its release from anywhere in the project. The label was read from a path relative to the working directory, so watching from a subdirectory -- or from inside a monorepo package whose release archives live under its releasable -- showed the short commit hash instead of the version it shipped.rlsbl statusnow says when the latest release's commit is not recoverable. A version recorded as unanchorable was printed as a bare version number, indistinguishable from one whose commit the checkout contains.release undoreworked around the real shape of a release. Its commits are found through the release archives rather than by walking down from the tag, so a tag sitting above the version-bump commit -- the normal shape of a resumed release -- no longer leaves undo reporting success with the version files still bumped, and a version-bump commit that cannot be found is a hard error naming the range searched. A conflicted revert now aborts and stops, committing and pushing nothing further, instead of being concluded by the next step's commit and pushed. Work above the released commit that would collide with the revert is refused by name before anything is deleted. And--dry-runlists every commit the real run makes, including the audit record and the two restore commits.- Concurrent
changelog addruns no longer mislead about what was recorded. When two adds race, the loser's commit finds nothing left to stage and fails -- which was reported as a bare commit failure while the entry was in fact safely committed by the winner. An add now asks git what happened: a recorded entry is stated as such, and an entry that no commit carries is a hard error naming the file and the commit to run, instead of a warning under an "Added entry" success line. A changelog file whose last line lost its newline no longer swallows the entry appended after it. - A workspace that does not load is now an error, not a wrong answer.
rlsbl unreleasedandrlsbl watchsilently fell back to the member package's own (empty) changelog directory when the workspace could not be read, and reported an unreleased range and coverage figure for a project they had failed to identify. The failure now surfaces. - The release-workflow guide no longer tells you to push after
rlsbl release undo. The command pushes the reverted branch itself -- it must, or the remote keeps serving the release the undo just removed locally -- and the instruction sent readers to a manual push that a release-branch repository refuses. dep-floorsnow polices a uv workspace member's floors. A member has nouv.lockof its own -- one lock at the workspace root resolves every member -- so the check reported "no uv.lock" and passed with zero comparisons for every member of every flat uv workspace. It now resolves the lock's location the same wayrlsbl rewrite uv-path-sourcesdoes, and an unreadable lock is an error instead of an absence.- A Go module path with a capital letter is now probed correctly.
rlsbl release yankandrlsbl status --registryasked the Go module proxy for the latest version at an unescaped URL, which the proxy does not serve, so a published module with an uppercase letter in its path reported as never published. workspace-unbuildablenow recognizes a uv workspace whosemembersuse a**glob. The glob was expanded non-recursively, sopackages/**behaved likepackages/*and a member one directory deeper did not resolve up to the root -- the check then synced the workspace project by project and reported "no root uv workspace". Every uv-workspace lookup in rlsbl now goes through one locator that reads the globs the way uv does, and resolves symlinked checkouts to the same workspace as direct ones.- A passing
dep-floorscheck now shows what it compared. The pypi and npm paths said nothing on success, so a passing run on a polyglot project printed only the Go note and read as if thepyproject.toml-floor-against-uv.lockcomparison had never run. Each compared dependency is now named with its declared constraint and the version the lock resolves, and a manifest declaring none of the enforced dependencies says so. rlsbl release undoandrlsbl release yanknow ask the Go module proxy too. A Go version's publication status was decided from one signal -- whether its tag exists on the git remote -- so a tag that was deleted after someone had already fetched the module read as "never published", whileproxy.golang.orgwent on serving it permanently. The proxy is now a second evidence source: it serving a version blocks the operation, and its NOT serving one is recorded as inconclusive rather than as absence, because the proxy indexes lazily and its lag must never be what clears a deletion.- A history rewrite no longer breaks the release ledger.
rlsbl release scrubremapped the JSONL changelog hashes through the rewrite and moved the tags, but never the release archives, which record which commit each version shipped from. Afterwards the tag pointed at the rewritten commit while the archive still named the old one, so every command that reads the ledger --rlsbl status,rlsbl unreleased, the changelog coverage check, the release itself -- hard-errored with a message accusing the tag of having moved, which was the one thing the scrub had repaired. The archives now go through the same commit map, their tree hashes are recomputed at the rewritten commits and any change to a released tree is reported, and the move is recorded in the lineage record so a fresh clone can explain it without safegit's journal. A rewrite performed outside rlsbl is healed the same way from the journal. - A scrub no longer destroys a pre-release version's GitHub Release, and every Release it recreates is a complete one.
rlsbl release scrubdeleted each affected Release before reading the version off its tag, and a-rc.1tag failed that read -- so the Release was deleted and never recreated. The version is now read first (pre-release tags included), a tag that is not a version tag at all is skipped with its Release untouched, and the rebuilt Release carries the same body the release flow writes: the version's changelog section, itsrlsbl-ci-shamarker taken from the release ledger's anchor, and the pre-release flag its version earns. rlsbl release reconcileno longer proposes to create Releases it simply did not list. The GitHub Release listing is capped, and a truncated answer looked exactly like a complete one -- so in a repository with more Releases than the cap, the unlisted ones were judged absent. A listing that comes back at the cap is now a hard error naming it.rlsbl release reconcile --applynow performs exactly what the plan named. It re-derived the work from a fresh observation, and the plan'sworld_digestcovers the remote only -- so a purely local change between plan and apply (a tag fetched or created since) silently enlarged the acted-on set and force-pushed a subject nobody previewed. An apply now refuses, naming the subject, when the fresh observation grows one the plan does not cover or when a planned subject's verdict, lease or target moved; planned items that became correct on their own are reported as no-ops.--planalso writes its plan file when it found nothing, so applying it is a clean no-op instead of an instruction to run the plan you just ran.- A release now really updates its subtree mirror. The mirror step pushed a temporary split branch to the mirror's
mainWITHOUT a force, which only ever succeeded on a mirror that had no scaffold commit on it -- so every release after the first reported a failed step and the mirror's branch never moved. The step now converges the branch through the mirror reconciler (which observes the remote, refuses a mirror carrying hand-authored work, force-pushes with a lease and re-applies the scaffold layer) and publishes the version's tag and GitHub Release through the same modulerlsbl monorepo mirroruses, at the subtree split of the commit CI verified rather than at whatever the branch tip happens to be. Both steps stay non-fatal: a failure records its marker, exits nonzero, and names the command that heals it. rlsbl release reconcilecan now repair the situation it was written for in a project that has release archives. After an out-of-band history rewrite the archives still named commits the repository no longer had, so every released ref read as disagreeing with the ledger and the tripwire aborted the whole reconcile -- refusing exactly the repair it exists to perform. The ledger's stale anchors are now moved through the same records that explain the rewrite (safegit's journal, a lineage anchor-remap event, or a committed scrub archive) and committed, before a single verdict is computed. A dangling anchor no record explains is still a hard error, and--dry-runwrites nothing while still previewing the verdicts a real run would produce.- A scrub's summary now reports the GitHub Releases it actually recreated. It counted the rewritten tags that looked like released versions instead, so a tag carrying no Release still added to the total.
release scrubno longer strands a tag without its Release. The GitHub Release for each rewritten tag is now updated in place -- notes,rlsbl-ci-shamarker and pre-release flag -- instead of being deleted and recreated, so a transient failure leaves the existing Release intact. A moved tag that carries no Release gets one created.- A mirror convergence no longer needs a machine-wide git identity. The reconciler clones the mirror to commit its scaffold layer, and that clone inherits none of the monorepo's local
user.name/user.email-- so on a machine with no global identity the commit failed after the split had already been force-pushed, leaving the mirror stripped of its scaffold layer. The clone now carries the monorepo's identity. - A publish workflow can no longer survive on a subtree mirror. A mirror never releases itself, but two shapes slipped past: a
publish.ymlleft in an older scaffold layer, and one that rode in through the subtree split because the member's own directory carries it. Every convergence now sweeps publish workflows out of the mirror clone, and a tip still carrying one is reportedscaffold-staleinstead ofconverged. The member keeps its own copy in the monorepo. - One underivable released version no longer stops the whole mirror command. A release anchored at a commit the subtree split cannot answer for -- typically an anchor predating the member's directory -- raised out of observation, so the branch never converged and no other version was judged. That version is now reported as
unanchoredwith the reason, and everything else reconciles as usual. - Promoting a mirrored releasable now asks the mirror contract first. Extracting a mirrored releasable adopts the mirror's history, and the only check was tree equality -- so a pair of hand-authored commits on the mirror that cancel each other out passed and became the extracted repository's permanent history. The reconciler's own classification runs first: foreign commits or an undetermined lineage refuse the promotion, naming the commits.
- The "who writes which ref namespace" tables now tell the truth. They claimed exactly one writer per namespace and that nothing else was written at all, while
release undo,release edit,deprecate,yank,changelog amend|editandrename-releasableall write those namespaces by design. Each table now names the routine writer and enumerates the sanctioned repair and retraction surfaces beside it. release scrub's help no longer claims it recreates GitHub Releases. Scrub andrelease reconcilerewrite each tag's Release document in place and create only the absent ones -- they have not deleted a Release in some time, so the help described a failure window that no longer exists.- The mirror chapter documents the
unanchoredtag state and the release flow's own mirror steps. The tag table stopped at present-versus-missing, and nothing said that releasing a mirrored releasable converges the mirror and publishes the version's tag and Release on it non-fatally. - The conversions chapter now explains that a mirrored releasable is promoted, not filtered. It described extraction as always running
git-filter-repo, and its refusal table listed the missing-tool precondition for conversions that never invoke the tool. - Subdirectory targets get CI that runs in their own directory. A project declaring a single target with a subdirectory
pathwas scaffolded CI that ran at the repository root. For a pgdesign schema in a subdirectory this meant the schema validated locally and failed on the first CI push, sincepgdesign checkresolves its project from the working directory. Root-path targets are unaffected and no longer pick up a needlessworking-directoryline. - The CLI help no longer states a wrong number of release targets. The top-level help claimed 17 while the
monorepogroup claimed 18, anddev installhand-listed which targets each install mode supports. All of these counts and lists are now derived from the target registry, so they cannot disagree with each other or go stale when a target is added. - The docs no longer claim 18 release targets, and no longer mention
rlsbl config init. Three pages hand-typed a target count one higher than the support matrix carries, and the configuration reference credited.rlsbl/config.jsonto a command that was removed. - The
statusandunreleasedpage descriptions no longer say the commands answer from the last git tag. Both answer from the release the ledger anchors the checkout to, and their documentation still described the old behavior. - The scrub's step list no longer says it recreates GitHub Releases. Step 8 rewrites each affected tag's Release document in place and creates only an absent one, which is what the guide now describes.
- Two batch-release error messages no longer call the plan file a "sidecar".
rlsbl monorepo release runnow says "Missing plan file" and "delete the plan file and re-run" when it refuses a batch whose companionunreleased.plan.jsonis missing or stale. - README corrections. Its per-tag check table is now rendered from the check registry instead of hand-typed counts that had drifted far below what
rlsbl checkruns; it no longer documentsrlsbl scaffold --forceor--no-tag, neither of which exists (the live opt-outs are--no-auto-commitand--no-auto-tag); and its release-flow summary describes the real ordering -- the candidate commit is pushed untagged, CI is awaited in-process, and the tag goes on the CI-verified commit. rlsbl monorepo extractnow rewrites the clone in a singlegit-filter-reporun, so a single-member extract's release anchors, changelog hashes and tags remap correctly on filter-repo releases that do not compose commit maps across chained runs (2.38, the Ubuntu 24.04 package). Previously the hoist ran as a second invocation and every original commit silently missed: anchors were left as recorded and changelog entries were dropped as pruned.