On this page
rlsbl monorepo workspaces: workspace.toml, the root member and loader refusals, the graph, batch releases, mirrors and their tag verdicts, the CI router.
#Monorepo guide
rlsbl supports monorepo workflows via the rlsbl monorepo command family. A monorepo workspace manages multiple independently-versioned projects sharing one git repository, coordinated through a single .rlsbl-monorepo/workspace.toml file at the repository root. A single workspace can contain any mix of the supported release targets.
#Getting started
# Initialize a monorepo workspace (creates .rlsbl-monorepo/ with workspace.toml).
# The root member's kind is a required choice: --root-dev-node, or
# --root-releasable <name> --tag-format <format>.
rlsbl monorepo init --root-dev-node
# Add projects to the workspace (the path is a positional argument, and
# --releasable is required -- a name, or the literal `false` to opt out).
# A name that [[releasables]] does not declare yet is CREATED as a singleton
# releasable, with its tag_format written out explicitly (see below).
rlsbl monorepo add packages/mylib --name mylib --library true --releasable mylib
rlsbl monorepo add packages/cli --name cli --depends-on mylib --releasable cli
rlsbl monorepo add packages/tests --name tests --dev-only true --releasable false
# Scaffold CI for all projects
rlsbl scaffold
# Sync per-project CI workflows to shared .github/workflows/
rlsbl monorepo sync
# Show workspace status (versions, unreleased commits)
rlsbl monorepo status
# List all projects
rlsbl monorepo list#workspace.toml format
The workspace file lives at .rlsbl-monorepo/workspace.toml and serves as the single source of truth for all project registrations, dependency declarations, and architectural layer rules. It uses TOML array-of-tables syntax for project declarations, with one [[projects]] block per sub-project:
[[releasables]]
name = "mylib"
[[releasables]]
name = "cli"
[[projects]]
path = "."
name = "root"
dev_only = true
releasable = false
[[projects]]
path = "packages/mylib"
name = "mylib"
library = true
releasable = "mylib"
depends_on = []
[[projects]]
path = "packages/cli"
name = "cli"
releasable = "cli"
depends_on = ["mylib"]
registry_name = "@org/cli"
[[projects]]
path = "packages/tests"
name = "tests"
dev_only = true
releasable = false
[layers]
order = ["foundation", "app"]
[layers.assignments]
foundation = ["mylib"]
app = ["cli"]
[layers.overrides]
unrestricted = ["tests"]#Project fields
Each [[projects]] block declares the project's identity, its inter-project relationships, and its behavioral flags. path is always required, and releasable is required of every project that is not opted out of versioning -- every other field either has a sensible default (like deriving name from the path basename) or is an opt-in feature that activates additional checks and behaviors. Note what is NOT declarable: what CI reacts to. The router's paths filters are derived from the workspace (see Router paths filters), and a watch key is refused at load time.
| Field | Required | Type | Description |
|---|---|---|---|
path | yes | string | Relative path from repo root to the project directory |
releasable | yes | string or false | The [[releasables]] entry this project is versioned under, or false to opt out of versioning entirely |
name | no | string | Project name (defaults to basename of path; the member at path = "." must be named root) |
depends_on | no | list of strings | Explicit intra-workspace dependencies (project names) |
library | no | bool | Mark as a shared library (enables library-lint check) |
dev_only | no | bool | Mark as a dev-only project (no changelog, no CHANGELOG.md). A dev-only project outside every releasable is a dev node |
import_name | no | string | The name this member is imported under, when it differs from the project name (the import scanner attributes imports with it) |
registry_name | no | string | Override package name on the registry (e.g., scoped npm name) |
description | no | string | Short project description for documentation |
test_only | no | bool | Mark as test infrastructure; carried into the workspace snapshot |
lint_allow | no | list of strings | Imports the library-lint check allows for this member |
The table is the member surface in full: it is bound in the suite to rlsbl.workspace.MEMBER_KEYS, the one constant the loader refuses against, so a key added there and not documented here fails a test. A release target is not among them — targets are detected from the member's own manifests, never declared here — and neither is any retired key, each of which is refused by name with its own remedy (see What the loader refuses).
#The root member
Every workspace declares the repository root itself as a member, at path = ".". It exists so that no tracked file falls outside the ownership model: territory is derived from declared member paths and never enumerated, so every file belongs to the member with the most specific declared path, and the root member owns everything no other member claims. That residual territory is exactly what the router renders for it — **, narrowed by a negated exclude of every other member's territory (see Router paths filters).
Its name is root, and the spelling is not a preference. Job keys, router filters and check regexes are all derived from it, so it cannot vary from repository to repository: the root member may be named nothing else, no other member may take the name, and omitting name on the root member applies it automatically.
Its kind is a choice, and rlsbl monorepo init makes it a required one rather than picking for you:
| Kind | Declaration | What it means |
|---|---|---|
| Dev node | dev_only = true, releasable = false — rlsbl monorepo init --root-dev-node | The root files need no changelog coverage, and stand outside every releasable. |
| Releasable member | releasable = "<name>" — rlsbl monorepo init --root-releasable <name> --tag-format <format> | The root files get changelog coverage under that releasable, which must then declare tag_format explicitly. |
#What the loader refuses
workspace.toml is validated as a whole every time it is loaded, and each refusal carries its own remedy. Structural facts about the member list are reported before per-member key errors, so a remedy for a stray key never presumes a member list that is itself unsound.
The rows are in the order the loader reports them.
| Refusal | Remedy |
|---|---|
| A key at the top level that is neither section | Delete it, or fix the spelling of the section header it was meant to be. A workspace has no top-level scalar settings; the sections with a reader are projects, releasables and layers. It is reported first because a misspelled section header is why the sections below look wrong. |
No [[releasables]] section at all — the retired implicit mode | Add the section and give every releasable member a releasable key (a name, or false). The error also names the last rlsbl version that reads such a workspace, for a repository that genuinely cannot convert right now. |
| More than one root member, or two members whose paths normalize to the same territory | Keep one and give the other a path of its own. Two members cannot own one territory. |
| No root member | Add one, choosing its kind. The error prints both declarations in full. |
A watch key on any member | Delete it. Territory is derived from declared paths, never enumerated. A member that genuinely needs to own files outside its own directory declares that directory as a member of its own. |
A subtree_remote key on any member | Move the line into that member's [[releasables]] entry. A mirror's destination belongs to the unit that owns a version, a changelog and a tag scheme. |
A dev_node key on any member | Replace it with both halves it stood for: dev_only = true (what the member IS) and releasable = false (where it sits). If the member is not actually dev-only, give it releasable = "<name>" instead. |
| Any other key a member table does not know | Delete it, or fix its spelling. The known keys are the project fields above. The retired keys are refused before this one so each keeps its own remedy. |
A key a [[releasables]] table does not know | Delete it, or fix its spelling. That surface's known keys are derived from the releasable model itself. |
A root member named anything but root | Set name = "root", or omit name entirely. |
A non-root member named root | Rename it, or give it path = "." in place of the root member declared today. Which member owns the repository root is your decision, and rlsbl will not guess it. |
A releasable that owns the root member and declares no tag_format | Declare it: v{version} for bare version tags, {name}@v{version} to keep the workspace scheme. The repository's existing tags decide which, and only you can read them. |
#Releasables section
[[releasables]] names the units of versioning. Each entry has a name and two optional keys, tag_format and subtree_remote:
[[releasables]]
name = "core"
[[releasables]]
name = "app"
tag_format = "v{version}"
[[releasables]]
name = "uikit"
subtree_remote = "git@github.com:owner/uikit.git"subtree_remote binds the releasable to a standalone mirror. It is a releasable key, not a member key: a mirror carries one subtree's whole history, its tags and its GitHub Releases, and the releasable is the unit that owns a version, a changelog and a tag scheme. A member still carrying the key is a hard error at load time naming the exact edit, and a releasable with more than one member may not declare one at all -- there would be no single subtree to mirror.
tag_format is explicit or absent, never implicitly filled in. An entry that omits it tags with the workspace scheme, {name}@v{version}; an entry that declares it tags with what it declared. Absence is carried through loading and saving, so a rewrite of workspace.toml neither invents the key nor deletes a line an operator wrote — including one that spells out the default.
An entry is written by hand, or by one of the two commands that create a releasable from a member: rlsbl monorepo add --releasable <name> naming a group the workspace does not declare yet, and rlsbl monorepo absorb for an arriving member. Both create a singleton releasable holding that one member, and both write its tag_format out explicitly rather than letting it be inherited by accident. The format is derived from the member's primary target's monorepo scheme — {name}@v{version} for every target but Go, and the Go module proxy's <path>/v{version} for Go — and a member whose targets span BOTH schemes has no single answer, so it is refused with --tag-format named as the remedy. --tag-format states the format directly; it is illegal when --releasable names a releasable that already exists (which owns its own format) and with --releasable false (which creates none). rlsbl monorepo sync scaffolds the created releasable's state directory.
A releasable is also the unit a repository boundary moves: rlsbl monorepo extract moves one out into a repository of its own and rlsbl monorepo absorb moves an external repository in as one. Splitting a member out of a releasable it shares with others is a workspace edit first -- see Repository conversions.
The distinction is not cosmetic. **A releasable that owns the root member (path = ".") must declare tag_format, and the loader refuses one that does not.** A repository root's releases are commonly tagged v1.2.3 because the repository used to be a standalone one, and inheriting {name}@v{version} there would silently orphan every existing tag. Only the operator knows which scheme the repository's history already uses, so rlsbl monorepo init --root-releasable <name> requires --tag-format alongside it. That is the only command that creates the root member's releasable: every workspace the loader accepts already declares a root member, so rlsbl monorepo add . is refused as a path the workspace already claims.
#Layers section
The optional [layers] section enforces architectural dependency direction by grouping projects into ordered layers and blocking imports that violate the hierarchy. Higher layers may depend on lower layers, but not vice versa. See layers.md for full configuration reference.
#Project types
#Regular projects
Standard projects get the full release experience, including changelog enforcement, CI pipeline generation, and all workspace validation checks. This is the default project type when neither library nor dev_only is set:
- JSONL changelog with commit coverage enforcement
- Generated CHANGELOG.md
- CI workflows (test + publish)
- Pre-push hook enforcement
- All workspace checks apply
#Library projects (library = true)
Libraries are packages consumed by other workspace projects as runtime or dev dependencies. They get everything regular projects have, plus additional quality checks that ensure shared code stays clean and is actually used within the workspace:
library-lintquality check (runs language-specific lint rules)dead-workspace-packagesdetection (warns if the library has no dependents)- Built-in lint runs during
rlsbl release run(non-libraries skip built-in lint)
Lint config resolves at two levels: a member's own .rlsbl/lint/<language>.toml wins when present, otherwise a releasable member falls back to the shared .rlsbl-monorepo/releasables/<name>/lint/<language>.toml. rlsbl monorepo cleanup removes a member's .rlsbl/lint/ only when it is byte-identical to that shared config (a genuine override is preserved).
#Dev nodes (dev_only = true, releasable = false)
Dev nodes are projects at the edge of the dependency graph that nothing user-facing depends on — test infrastructure, conformance suites, dev tooling, and internal utilities consumed only during development. A project is a dev node when it is dev_only and outside every releasable; a dev_only project that still declares releasable = "<name>" is an ordinary member of that releasable. Dev nodes cannot be released:
- No changelog system: no
.rlsbl/changes/, nounreleased.jsonl, noCHANGELOG.md. This is enforced, not merely expected: a member outside every releasable that carries its own.rlsbl/changes/is a hard error wherever that directory would be read — the changelog-directory enumeration behind hash validation and scrub remapping,rlsbl monorepo status, and the pre-push coverage check. Nothing finalizes entries there and no release record explains their range, so either the directory is residue and should be deleted, or its content belongs to a releasable - No releases:
rlsbl release runandrlsbl release editerror with "non-releasable projects cannot be released" rlsbl changelog adderrors with "dev node projects don't use changelogs"- Scaffold skips changelog infrastructure
- Pre-push check ignores dev node commits
- Batch release (
rlsbl monorepo release run) excludes dev nodes - Give the project a
releasable = "<name>"in workspace.toml (droppingdev_onlyif it is genuinely not dev-only) to make it releasable - The
dev-only-boundarycheck prevents non-dev-node projects from declaring runtime dependencies on dev nodes
A dev node is excluded from releases but not from their consequences. When its uv.lock records a releasable sibling through an editable path source (source = { editable = "../python" }), that lock pins the sibling's version — and the version bump is what stales it. So the release runs uv lock in every non-releasable workspace project whose lock resolves a path source into a directory the bump touches, and the refreshed lock joins the version-bump commit. The candidate is then self-consistent from the first push: a dev node's lock-pin meta-test passes on it, instead of going red at the CI gate and forcing a dev-node-only fix-forward whose window no releasable's paths filter matches.
#Dependency graph
The workspace builds a directed dependency graph from two complementary sources, combining automatic manifest scanning with explicit declarations to capture all inter-project relationships. This graph drives topological release ordering, impact analysis, dead-package detection, and the dev-only boundary guardrail that prevents user-facing projects from depending on dev nodes:
- Manifest scanning — pluggable scanners (
PypiScanner,NpmScanner,DartScanner) parse each project's manifest file looking for intra-workspace dependencies - **Explicit
depends_on** — the workspace.toml field adds edges the scanners cannot detect
Dependencies have a scope attribute with 4 possible values: runtime, dev, peer, or explicit. The scope determines which edges the dev-only-boundary check considers (only 2 of the 4 scopes -- runtime and explicit -- trigger the boundary violation).
#Viewing the graph
The graph has two renderings selected by --format -- DOT for Graphviz and an indented text tree for terminal inspection -- plus the machine form under the framework-owned --json, which puts the structured graph in the envelope's payload (see Machine output). Every form supports the same filtering options, including scoping to a single root package and its transitive dependencies, reverse dependency queries showing what depends on a given package, and depth limiting to control how many levels of the graph are traversed:
# Text tree, indented (default)
rlsbl monorepo graph
# DOT format for Graphviz
rlsbl monorepo graph --format dot --output graph.dot
# Structured graph in the envelope's payload
rlsbl monorepo graph --json
# Filter to a single package's transitive dependencies
rlsbl monorepo graph --root mylib
# Filter to reverse dependencies (what depends on mylib)
rlsbl monorepo graph --reverse mylib
# Limit depth
rlsbl monorepo graph --root mylib --depth 2#Topological order
# Show release order (leaves first, dependents after their dependencies)
rlsbl monorepo release orderUses Kahn's algorithm. Projects with no dependencies appear first. Detects and reports circular dependencies as a hard error.
#Impact analysis
rlsbl monorepo impact computes the blast radius of a change by performing BFS on the reverse dependency graph, showing every direct and transitive dependent that could be affected. This helps determine which packages need testing and which are release candidates after a change.
#Three input modes
# By package name
rlsbl monorepo impact mylib
# By file path (maps to containing package)
rlsbl monorepo impact packages/mylib/src/core.py
# By git diff range (all changed files since a ref)
rlsbl monorepo impact --since v0.5.0#Output (impact)
The command reports a structured breakdown of the blast radius across 5 output sections, organized by dependency distance from the changed package. Each section helps answer a different question about what to test, review, and release:
| Section | Meaning |
|---|---|
| Input packages | The directly changed packages |
| Direct dependents | Packages with an immediate edge to the changed package |
| Transitive dependents | All packages reachable via BFS on reverse deps |
| Test scope | Packages that should be tested (input + all dependents) |
| Release candidates | Packages that may need a new release |
Supports --depth N to limit BFS traversal depth (default: unlimited, traverses the full transitive closure). The same breakdown is available as a structured document under the framework-owned --json, in the envelope's payload (see Machine output).
#Batch release
rlsbl monorepo release run releases multiple releasables in a single coordinated flow, respecting topological order so that leaf releasables (those whose members have no intra-workspace dependencies) are released first, followed by their dependents. This ensures downstream packages always reference the latest versions of their workspace dependencies.
The unit of a batch is the releasable, not the package: a releasable's position in the order is the highest topological position of any of its member packages, and each releasable is released through one representative member.
#Workflow
- Run
rlsbl monorepo release initto scaffold.rlsbl-monorepo/releases/unreleased.toml - Edit the file: set bump type, description, and context per releasable
- Run
rlsbl monorepo release run --watch --approve-consequential
#release init scaffolding
rlsbl monorepo release init auto-detects release targets for each releasable's members and generates a TOML file with pre-populated per-releasable sections. Releasables with no unreleased commits are commented out, and dev nodes are excluded entirely since they cannot be released:
[releasables.mylib]
bump = "patch"
description = ""
include = ["pypi"]
[releasables.cli]
bump = "minor"
description = ""
include = ["npm"]
# [releasables.tests]
# No unreleased commits since tests@v0.3.0[releasables.<name>] is the only section form a batch release file takes. A file carrying a [packages] section is refused with the rewrite it needs -- there is no per-package batch mode, because there is no workspace mode without [[releasables]] (the loader refuses one).
- Dev nodes are excluded entirely (they have no changelog)
- Releasables with zero unreleased commits since their last tag are rendered as commented-out sections
- Each section's
includelist is pre-populated from the targets detected across the releasable's members
#Execution
Each releasable is released sequentially through the standard single-package release flow (validation, tests, version bump, commit, tag, push, GitHub Release), run from one representative member. The batch orchestrator determines execution order from the workspace dependency graph:
- Validate all listed releasables exist in workspace.toml
- Build topological order from the full workspace graph
- Map each releasable to its highest-positioned member, preserving topological order
- Release each releasable in order
The resolved base version, target version and tag of every item are written to a companion plan file (unreleased.plan.json) beside the batch file before anything is released, and the plan is never regenerated mid-flight -- so a re-run skips exactly the items it can prove already shipped.
#Partial failure
If a releasable's release fails mid-batch, there is no automatic resume of the batch itself. The command prints what succeeded, then re-raises the error. To recover, fix the issue and re-run: items the plan proves are already released are skipped.
#Snapshot
rlsbl monorepo snapshot generates a committed JSON artifact at .rlsbl-monorepo/snapshot.json that captures the entire workspace state, including package metadata, dependency edges, and the computed topological order. This artifact is useful for CI verification and external tooling that needs to inspect workspace structure without parsing TOML.
# Generate and commit snapshot
rlsbl monorepo snapshot
# Verify snapshot is up-to-date (exits 1 if stale)
rlsbl monorepo snapshot-checkThe snapshot contains:
- All package names, paths, versions, and targets
- Dependency edges with type, constraint, and scope
- Graph metadata (topological order, leaf nodes, root nodes)
- Timestamp of generation
The snapshot is auto-committed with an Autogenerated: true trailer (exempt from changelog coverage). Use rlsbl monorepo snapshot-check in CI to ensure the snapshot stays current: it is read-only and exits 1 when the artifact is stale or missing.
#Mirror
rlsbl monorepo mirror <project> reconciles a workspace project's subtree mirror — a standalone git repository containing only that project's subtree history, plus its own rlsbl scaffold and CI workflows so the mirrored code builds and tests on its own. Consumers can clone just the one project, or resolve it by URL, without the full monorepo.
The mirror does not publish. Its scaffold renders no publish workflow, and any publish workflow that reaches the mirror by another route is swept on the next convergence: a mirror's tags and GitHub Releases are written by the monorepo's release flow (see A mirror never releases itself).
#The mirror is tool-owned
The mirror is a derived artifact: it is regenerated from the monorepo and nothing is ever authored on it by hand. Because the mirror is fully derived, force-push (with lease) is the routine write, not an exceptional one — every convergence rewrites main to match the monorepo's current state.
Treat mirror repositories as read-only downstreams. To change a project, change it in the monorepo and re-run mirror. Never commit to a mirror directly: a hand-authored commit is a contract violation that the reconciler refuses to erase silently (see the tripwire below).
#Requirements
- The releasable the project belongs to must declare
subtree_remotein workspace.toml, and that releasable must have exactly one member - SSH host must be consistent between
subtree_remoteand origin - Recommended: enable branch protection on the mirror's
mainfor humans while allowing the automation identity to force-push, so the tool-owned contract is enforced at the remote too
#Plan and apply
The mirror command follows an observe-then-converge reconciliation pattern. In dry-run mode it inspects the current state of the remote mirror and the local monorepo, produces a human-readable plan describing what would change, and exits without writing anything. In apply mode it executes the convergence steps, force-pushing with lease to update the mirror to match the current subtree state:
rlsbl monorepo mirror <project> --dry-run— observe and print a plan; makes zero writes (beyond the loose objects a branchless subtree split leaves in the monorepo's own object store).rlsbl monorepo mirror <project>— observe, then converge (apply).
The desired state of the mirror's main is exactly one scaffold commit atop the current split-ancestry commit, where that commit is the deterministic branchless git subtree split of the project's current history, and the scaffold commit touches only scaffold-owned paths.
Observation reports one of the following for the mirror's branch, and, beside it, one item per released version for the mirror's tags (see Release tags on the mirror):
| State | Meaning | What apply does |
|---|---|---|
converged | Scaffold commit atop the current split. | Nothing — clean no-op. |
scaffold-stale | A scaffold layer atop the current split, but the tip carries a publish workflow (from an older scaffold layer, or through the split from the member's own directory). Named on the plan. | Re-push the split (with lease) and rebuild the layer without it — a mirror never releases itself. |
behind | A scaffold layer atop an older split; a new split is available (shows old → new). | Force-push the new split (with lease), then re-scaffold. |
scaffold-missing | The tip is a bare split commit with no scaffold layer (the pre-scaffold-layer shape). May also be behind. | Add the scaffold commit (and push a new split first if behind). |
contract-violated | A foreign, hand-authored commit exists on the mirror. | Hard error, touches nothing. Lists the offending commit(s) and paths, and tells you to either port the change into the monorepo or reset the mirror branch, then re-run. |
ancestry-undetermined | Git could not determine whether the mirror's commits descend from the current split (typically objects that were pruned, or never fetched), and no split boundary could be confirmed. | Hard error, touches nothing. Names the unanswerable commit(s) and points at fetching/deepening the history, never at resetting the mirror. |
remote-missing-or-empty | Virgin remote. | Push the split, then scaffold CI. |
Apply is idempotent: re-running on a converged mirror is a clean no-op, and an interrupted apply (killed between the split push and the scaffold commit) heals on the next run — it re-observes as scaffold-missing and adds the scaffold layer.
#The tripwire
Convergence never blindly overwrites the mirror. The remote tip must be either a bare split-ancestry commit (the current split SHA or an older one — this covers legacy mirrors that never received a scaffold layer) or exactly one commit atop a split-ancestry commit whose changed paths are all scaffold-owned (.rlsbl/, .github/, and a small set of root files like CHANGELOG.md). Anything else is a foreign commit: apply refuses and reports it. This makes contract violations loud instead of silently force-erased.
Note:
rlsbl monorepo syncdoes not update mirror repositories.syncregenerates the monorepo's own.github/workflows. Mirrors are updated only by re-runningrlsbl monorepo mirror <project>(for example after a release).
#Release tags on the mirror
The mirror carries every released version under its own standalone tag name (v1.2.3, not the workspace's {name}@v1.2.3, which is exactly what a consumer resolving the mirror by URL cannot read). The commit each tag stands at is derived, never guessed: it is the subtree split of the commit that version's release archive release commits -- the commit CI verified.
That makes the tags a second dimension of the same reconciliation. A mirror can be perfectly converged on main and still carry none of its releasable's tags (a mirror bound after the fact, a tag push that failed at release time, a mirror that was reset), so observation reports one item per released version beside the branch's own verdict:
| State | Meaning | What apply does |
|---|---|---|
present | The mirror already carries the tag. | Nothing. |
materialize | The mirror has no such tag. The subtree split of the version's recorded release commit is the commit it belongs at. | Push the tag at that commit, then create the mirror's GitHub Release with that version's notes. |
underivable | No mirror commit for this version can be derived: its release archive records no commit at all, or records one the subtree split cannot answer for — typically a release commit predating the member's own directory, from a release absorbed out of another repository. | Nothing, and nothing is guessed. The version is named with the reason it could not be derived; the branch and every other version reconcile as usual. |
never-released | The version's archive records never_released = true: the version number exists in the release record, but no release was ever published under it. Not a failure to derive anything — there is no commit to restore and nothing ever shipped under that number. | Nothing. The version is named with that reason, distinctly from underivable, so nobody goes looking for a lost commit. |
A tag standing at a different commit is never moved. That is a hard error naming both commits: a released tag names what shipped, and choosing which commit a version shipped from is never the reconciler's decision.
Two invariants follow:
- A mirror never releases itself. Its scaffold deliberately renders no publish workflow, and every convergence sweeps any publish workflow that reached the mirror another way — a leftover in an older scaffold layer, or one that rode in through the subtree split because the member's own directory carries it. (The member keeps its copy in the monorepo; only the mirror's is swept.) A mirror's Releases are written by the monorepo's release flow, or by this command materializing what the flow missed.
- A mirrored package's identity manifests name the mirror. The scaffold commit rewrites them -- Go's
go.modmoduledirective is the case that exists, since it IS the fetch URL -- and those files are scaffold-owned on the mirror as a result. A mirror remote whose URL names no module host is a hard error rather than ago.modnobody cango get.
#The release flow's own mirror steps
Releasing a releasable that declares a subtree_remote does both halves without a separate command. After the primary release is published, rlsbl release run converges the mirror's branch through the same reconciler this chapter describes, then publishes that version's tag and GitHub Release on the mirror. The tag's commit is the subtree split of the release's recorded release commit — the CI-verified candidate — not the mirror's branch tip, so the mirror's tag names the same code the monorepo's does even though the finalization commits have moved main on since.
Both steps are non-fatal: the primary release has already shipped and nothing is rolled back. A failure is still recorded on the release state, so the run exits non-zero, stays resumable, and names its healer — rlsbl monorepo mirror <project> for the mirror, rlsbl release reconcile for this repository's own release refs.
#Extracting a mirrored releasable promotes the mirror
The mirror already holds this subtree's standalone history: every commit that touched the member has a synthetic counterpart there, produced by the deterministic subtree split, and consumers already resolve those commit ids. So extracting a mirrored releasable does not filter a second history out of the monorepo -- it promotes the mirror. Same command, different engine:
- the destination is a clone of the mirror, whose remote becomes its origin;
- the monorepo-to-mirror correspondence is derived by splitting each commit the conversion has to translate, and every changelog hash and release commit is remapped through it;
- deleting the monorepo's copy is justified by tree-hash equality:
HEAD:<member>in the monorepo must equal the root tree of the mirror's pre-scaffold split commit. A mirror that is behind stops the promotion and says to runrlsbl monorepo mirror <project>first; - the correspondence is persisted into the extracted repository's transition record as a
promotion-split-mapevent, so the promoted repository can explain its own hashes without the monorepo.
After a promotion the mirror is no longer a derived artifact: nothing regenerates it, and a force-push to it is destructive. It also carries no publish workflow (a mirror's scaffold renders none), so a repository that publishes needs rlsbl scaffold run in it.
#Sync
rlsbl monorepo sync folds every project's CI jobs into a single generated router at the repository root's shared .github/workflows/ directory, performing template variable resolution along the way. This is required because GitHub Actions only reads workflows from the repository root, not from individual project subdirectories.
The sync process:
- For each project in the workspace, reads its scaffolded CI workflow
- Injects
working-directoryinto job steps so they run in the correct subdirectory - Inlines every project's jobs into one generated
ci-router.yml, keyed by a per-file prefix and gated on adetectjob's paths filter, and inlines publish jobs intopublish.ymlthe same way - Removes any stale per-project workflow copy left at the root by an older sync (via saferm)
- Commits the generated routers
Jobs are inlined rather than invoked as reusable workflows: GitHub rejects a workflow file that references 20 or more of them, so uses:-based routing cannot scale past a certain workspace size. A guardrail refuses to emit a generated router containing any reusable call at all. Each inlined job gets an explicit name: "{prefix} / {job}", so check-run names are identical to the ones the reusable-workflow era produced and the publish gate's regexes and any branch protection rules keep matching.
This ensures every project has its CI pipeline properly wired even when using different targets or custom workflow steps.
#Router paths filters
The generated router filters each project's inlined jobs on a dorny/paths-filter entry derived from the workspace. Nothing is declared per project; the entry is composed of:
- the project's own territory -- its declared
path-- aspath/**; - the territory of every workspace project it depends on, transitively and in every dependency scope (
runtime,dev,peer,explicit): a change to a dev-scoped dependency breaks the dependent's tests, which is what its CI job runs; - the workspace-root manifests and lockfiles that are actually present (
pyproject.toml,uv.lock,package.json,go.mod, and their kin), so a root dependency bump triggers every member; - the generated router itself, so a change to it re-runs everything;
- for the root member, whose territory is the residual,
**narrowed by a negated exclude of every other member's territory -- minus the territories it depends on, which stay included.
The step declares predicate-quantifier: some-with-excludes. Under the action's default (some) a negated pattern matches everything outside itself, so the root member's excludes would match exactly the paths they exclude.
A push whose diff matches none of a project's patterns leaves that project's CI job skipped on the pushed commit. rlsbl check --name router-filters-fresh re-derives the block and fails when the committed router no longer matches the workspace; regenerate with rlsbl monorepo sync.
In explicit releasable mode, one more pattern is appended to every member of a releasable: the releasable's own CHANGELOG.md under .rlsbl-monorepo/releasables/<name>/. It is a single path shared by all members, so any commit that touches it matches all of their filters at once. This is a deliberate run-everything hook. A release commit may touch nothing under a member's own directory -- guaranteed on a first release, where the version write is a no-op -- and the publish gate refuses to treat that member's skipped check as passing, with no re-runnable recovery. Since the release commit always regenerates and commits the releasable CHANGELOG.md, release-commit recording every member's filter on it makes the release commit verifiable for all members.
Be aware of the cost: releasing a releasable runs the CI jobs of every one of its members, including members whose own code did not change. That is the accepted trade, not a bug -- see Publish gating in the release workflow docs for the full rationale, including why the gate is never relaxed to accept skipped, and what a push that touches only non-member paths (a dev node's directory, for instance) looks like.
#Running every job on one commit (run_all)
The router declares a workflow_dispatch input, run_all. Dispatching with run_all=true short-circuits the paths filter: every inlined job's condition is (needs.detect.outputs.<project> == 'true' || inputs.run_all), so all of them run on the dispatched commit.
gh workflow run ci-router.yml --ref main -f run_all=trueThis is the sanctioned exit from a candidate whose push window is honestly narrow. A first release candidate rides the run-everything hook and runs every member's CI; if some of those jobs fail, the fix-forward commits that heal them touch only the members they fix. The next candidate's window therefore covers only those members, every other member's job concludes skipped, and the release gate refuses -- correctly, because a skipped check proves nothing about the commit. Widening the window would mean committing churn under paths that did not change, which lies in both the history and the changelog. Dispatching run_all re-runs the same commit with the filter short-circuited instead.
Nothing is waived by the dispatch. The jobs execute for real, and a job that fails there still blocks the release. Both gates group matching check runs by name across every check suite on the commit, and a skipped conclusion loses to any completed, non-skipped conclusion of the same name -- so the dispatched run's verdict supersedes the push run's skipped, and a red verdict supersedes just as readily as a green one. Ordering does not enter into it: rlsbl dispatches as soon as the candidate is pushed, while the push run's project jobs are still queued behind detect, so the skipped check run is routinely stamped after the dispatched run has already concluded. A name that is only ever skipped still blocks the release. The router's concurrency group includes the input, so a run_all dispatch never cancels an in-flight push run for the same commit (a cancelled run is a red verdict at the workflow-run level, before any per-check collapse happens).
One complication both gates handle explicitly: GitHub does not expand a matrix for a job its if skipped. The skipped job collapses to a single check run under the unsuffixed name (cli-ci / test), while the run that executes it emits one per leg (cli-ci / test (3.12)). They never share a name, so a plain per-name collapse would leave the skip standing. A skipped check is therefore dropped when a completed, non-skipped check run for the same job -- its matrix expansion, matched by name -- exists; the legs are then judged on their own conclusions, so a red leg still blocks. Nothing else can cover a skip: not a sibling job, not a merely prefix-sharing name (test-extra is a different job), and not a leg that was itself skipped.
Typical sequence when a release stops at a skipped member, after the run has already concluded:
gh workflow run ci-router.yml --ref main -f run_all=true
gh run watch <run-id>
rlsbl release resume#rlsbl dispatches it itself when a resume's window is empty
The dispatch above needs the commit on the remote, because it resolves a ref. That used to deadlock: a resume whose fix-forward touched none of the releasing project's paths was refused by the pre-push window guard, and the refusal withheld the very push the prescribed remedy required.
So on exactly that shape -- a push is owed and an earlier attempt already published a candidate (BRANCH_PUSHED is recorded) -- the release no longer refuses. It pushes the candidate, dispatches ci-router.yml with run_all=true itself, correlates the created run to the pushed commit by head SHA, and then runs the CI gate on it. The dispatch is recorded as owed on the release state before the push, so a crash in between is repaired by rlsbl release resume rather than walking into a skipped-check refusal.
Nothing is relaxed by this. Every member's real jobs run on the candidate, a failure still blocks the release, and the correlation is fail-closed: if the dispatched run belongs to some other commit (something else reached the branch in between), that is a hard error rather than a gate on a run nobody established.
The refusal stays for every other empty window -- most of all a fresh release whose own version-bump commit matches none of its filters, which is a configuration defect and not an honestly narrow fix-forward. A workspace with no generated ci-router.yml on disk has nothing to dispatch, so it keeps the refusal too.
#Workspace checks
Fourteen checks run under rlsbl check --tag workspace, covering CI configuration consistency, project registration hygiene, dependency boundary enforcement, buildability, and code liveness. All error-severity checks block releases when they fail:
| Check | Severity | Description |
|---|---|---|
workspace-ci-router | error | Verifies the generated ci-router.yml exists at the repo root (it holds every project's inlined jobs; per-project coverage is workspace-ci-synced) |
workspace-ci-synced | error | Verifies each in-scope project's CI jobs are inlined into the shared ci-router.yml. A member with no CI workflow file of its own is skipped with a note (sync inlines nothing for it); the root member's generated routers are never read as its own workflows |
workspace-targets | error | Every project must have at least one detectable release target |
workspace-unregistered | error | Detects project directories with manifests that are not in workspace.toml |
workspace-stale-entries | error | Detects workspace.toml entries pointing to non-existent directories |
dev-only-boundary | error | Non-dev-only projects cannot have runtime dependencies on dev-only projects |
unversioned-boundary | error | Releasable projects cannot have runtime dependencies on unversioned (releasable = false) projects |
dead-workspace-packages | warn | Library projects with zero dependents (may indicate unused code) |
subtree-remote-reachable | error | All configured subtree_remote URLs must be accessible (network check) |
mirror-required | error | Members consumed by repository URL (SPM) must belong to a releasable that declares a subtree_remote |
workspace-unbuildable | error | Workspace members build under uv sync --all-packages (pypi workspaces only); also tagged preflight, so a manifest that stopped resolving blocks the release rather than only narrowing the router's derived filters |
scaffold-gitignore-stale | warn | Workspace project .gitignore files contain all rlsbl-managed entries |
root-rlsbl-conflict | error | Root .rlsbl/ must not coexist with .rlsbl-monorepo/ |
go-companion-tags | warn | Non-private Go members of releasables have companion tags for the current version |
test-suite-workspace | error | Runs tests for affected workspace projects (also tagged prepush) |
Run all workspace checks:
rlsbl check --tag workspaceSee checks.md for the full check reference across all tags.
#Dev node boundary
The dev-only-boundary check is a structural guardrail that prevents misuse of the dev_only flag by ensuring dev-only projects remain true leaf nodes in the dependency graph, consumed by nothing user-facing. The rule:
If a non-dev-only project has a runtime dependency on a
dev_onlyproject,rlsbl check --tag workspaceerrors.
This ensures dev nodes are truly leaf nodes consumed by nothing user-facing. The check distinguishes:
- Runtime dependencies (scope:
runtimeorexplicit) — carry changes to users. These trigger the boundary violation. - Dev dependencies (scope:
dev) — only affect test/build environments. These are allowed.
If the boundary check fails, either:
- Remove
dev_only = truefrom the dependency (it is not actually a dev-only project) - Move the runtime dependency to a dev dependency in the consumer's manifest
#Examples
#Setting up a monorepo from scratch
cd ~/Projects/my-monorepo
git init
# Initialize the workspace
rlsbl monorepo init --root-dev-node
# Initialized monorepo workspace in .rlsbl-monorepo/
# Root member 'root' is a dev node.
# Add a Python library. "core" is not declared in [[releasables]] yet, so this
# creates it as a singleton releasable with tag_format = "{name}@v{version}".
mkdir -p packages/core
# ... create packages/core/pyproject.toml ...
rlsbl monorepo add packages/core --name core --library true --releasable core
# Add an npm CLI that depends on the library ("cli" is created the same way)
mkdir -p packages/cli
# ... create packages/cli/package.json ...
rlsbl monorepo add packages/cli --name cli --depends-on core --releasable cli
# Add a test suite (dev node -- no changelog, no releases)
mkdir -p packages/tests
rlsbl monorepo add packages/tests --name tests --dev-only true --releasable false
# Scaffold CI for each project
cd packages/core && rlsbl scaffold && cd ../..
cd packages/cli && rlsbl scaffold && cd ../..
# Sync all CI workflows to the repo root
rlsbl monorepo sync
# Synced packages/core CI -> .github/workflows/ci-router.yml
# Synced packages/cli CI -> .github/workflows/ci-router.yml#Releasing multiple packages
# Check workspace status
rlsbl monorepo status
# core 0.1.0 2 commits ahead of core@v0.1.0
# cli 0.2.0 3 commits ahead of cli@v0.2.0
# tests (dev node -- not releasable)
# Scaffold the release file
rlsbl monorepo release init
# Created .rlsbl-monorepo/releases/unreleased.toml
# Edit the release file:
# [releasables.core]
# bump = "minor"
# description = "Add async support to core API"
#
# [releasables.cli]
# bump = "patch"
# description = "Update CLI to use new async core API"
# Release in dependency order (core first, then cli)
rlsbl monorepo release run --no-allow-dirty --watch --approve-consequential
# Release order: core, cli
# Releasing core 0.1.0 -> 0.2.0 ...
# Validating ... OK
# Tests ... OK
# Committing core@v0.2.0 ... OK
# Releasing cli 0.2.0 -> 0.2.1 ...
# Validating ... OK
# Tests ... OK
# Committing cli@v0.2.1 ... OK
# Watching CI ...#Analyzing the impact of a change
# What breaks if we change the core library?
rlsbl monorepo impact core
# Input packages: core
# Direct dependents: cli
# Test scope: core, cli
# Release candidates: core, cli
# What changed since the last release?
rlsbl monorepo impact --since core@v0.1.0
# Changed packages: core
# Direct dependents: cli
# Test scope: core, cli#Viewing the dependency graph
# Text tree format
rlsbl monorepo graph --format text
# core
# <- cli
# tests (dev node)
# DOT format for visualization
rlsbl monorepo graph --format dot --output workspace.dot
dot -Tpng workspace.dot -o workspace.png#Workspace module
The workspace module handles discovery, loading, saving, and resolution of monorepo workspaces. It walks the directory tree upward to locate the nearest workspace.toml, parses the TOML structure into validated WorkspaceProject entries, and writes changes back atomically using tomlkit to preserve formatting and comments.
#rlsbl.workspace
Workspace data layer for monorepo support handling discovery, loading, saving, and resolution of workspaces from workspace.toml config.
#read_releasable_version
def read_releasable_version(workspace_root, releasable_name)Read the version string from a releasable's version file.
Args:
workspace_root: path to the monorepo root.releasable_name: name of the releasable.
Returns:
- The version string (stripped of whitespace).
Raises:
WorkspaceError: if the version file does not exist or is empty.
#write_releasable_version
def write_releasable_version(workspace_root, releasable_name, version)Write a version string to a releasable's version file atomically.
Creates the releasable directory if it does not exist. Writes to a temporary file in the same directory and then atomically replaces the target file via os.replace().
Args:
workspace_root: path to the monorepo root.releasable_name: name of the releasable.version: the version string to write.
#find_workspace_root
def find_workspace_root(start_path='.')Walk up from start_path looking for a .rlsbl-monorepo/workspace.toml.
Returns the directory containing .rlsbl-monorepo/, or None if not found.
#load_workspace
def load_workspace(root)Read and validate workspace.toml, returning a list of WorkspaceProject.
Each project has at least 'path' (str) and 'name' (str, defaults to basename of path). The returned WorkspaceProject instances support dict-like access for backward compatibility.
Raises FileNotFoundError if workspace.toml doesn't exist. Raises WorkspaceError on invalid structure.
#releasable_keys
def releasable_keys() -> frozensetEvery key a [[releasables]] table may carry.
Derived from :class:~rlsbl.workspace_types.Releasable's own fields rather than restated, so a field added to the model stops being unknown on the same edit that adds it.
#_unknown_keys
def _unknown_keys(table, known)The keys of table outside known, in the order they were written.
#_refuse_unknown_keys
def _refuse_unknown_keys(table, known, *, surface, file_label)Refuse a table carrying a key rlsbl does not know.
The message names the surface (which table, of what kind), the offending key, and the file it was read from -- everything an operator needs to find and delete the line. A tolerated unknown key is a line that was written and never read: the file would say something the tools never do.
#_refuse_releasable_unknown_keys
def _refuse_releasable_unknown_keys(index, raw)Refuse a [[releasables]] table carrying a key the model lacks.
#is_root_path
def is_root_path(path) -> boolDoes path spell the repository root ("", ".", "./")?
#_declared_paths
def _declared_paths(data, projects)The path spellings as written, aligned with projects by index.
#_validate_member_paths
def _validate_member_paths(data, projects)Refuse a member list that does not give each path exactly one member.
Two members claiming one territory make ownership depend on declaration order, so both spellings of the collision are refused at load: identical paths, and paths that differ only in spelling (a and ./a are the same directory, and normalize to the same member path).
#validate_workspace_model
def validate_workspace_model(data, projects)Enforce the workspace ownership model on a parsed workspace.toml.
Each condition below is a hard error carrying its own remedy, and they are reported in the order an operator can act on them:
- a key at the top level that is neither section -- first, because a
misspelled section header is why the sections below look wrong;
- an implicit-mode workspace (no
[[releasables]]) -- because
every other remedy below is written for an explicit-mode workspace;
- more than one root member;
- two members whose paths normalize to the same territory;
- no root member;
- a
watchkey on any member; - a
subtree_remotekey on any member; - a
dev_nodekey on any member; - any other unknown key on a member table;
- an unknown key on a
[[releasables]]table; - a root member named anything but
root; - a non-root member named
root; - a releasable owning the root member with no explicit
tag_format.
Structural facts about the member list (3-5) precede per-member key errors (6-9): a remedy for a stray key presumes the member list itself is sound. The retired keys (6-8) precede the generic unknown-key refusal (9) so each keeps its own remedy.
data is the raw parsed document (needed for the releasables section and for the paths as the operator spelled them), projects the already-built :class:WorkspaceProject list, whose paths are normalized.
#load_releasables
def load_releasables(root, projects=None)Load releasable definitions from workspace.toml.
Reads and validates the [[releasables]] section, then validates that every releasable project has a valid releasable field referencing a defined releasable name (or false).
Args:
root: path to the monorepo root (containing .rlsbl-monorepo/).projects: optional pre-loaded project list. If None, loads via
load_workspace(root).
Returns:
- A list of Releasable instances.
Raises:
- WorkspaceError if
[[releasables]]is missing, or on invalid - releasable definitions or missing/invalid project releasable fields.
#_load_explicit_releasables
def _load_explicit_releasables(raw_releasables, projects)Parse [[releasables]] section and validate project membership.
Every releasable project must have a releasable field that is either a string referencing a defined releasable name, or false.
#members_of
def members_of(releasable_name, projects)Return the list of projects that belong to a given releasable.
Projects with releasable = "<name>" matching the given name are returned as members.
Args:
releasable_name: the releasable name to look up.projects: list of WorkspaceProject or dict instances.
Returns:
- List of projects that are members of the releasable.
#resolve_releasable_for_project
def resolve_releasable_for_project(proj, releasables)Return the Releasable that a project belongs to, or None.
Looks up the project's releasable field and matches it against the list of releasables.
Args:
proj: WorkspaceProject or dict with at leastnameand optionally
releasable.
releasables: list of Releasable instances.
Returns:
- The matching Releasable, or None if the project is not releasable
- (
releasable = false) or no match is found.
#mirror_remote_for
def mirror_remote_for(proj, releasables) -> strThe mirror destination a project's releasable declares, or "".
The ONE resolution of "is this member mirrored, and where": the binding lives on the releasable, so every reader that used to read a member's own subtree_remote asks this instead. A member outside every releasable, or one whose releasable declares no mirror, answers the empty string.
#mirrored_releasable_for
def mirrored_releasable_for(proj, releasables)The mirrored :class:Releasable proj belongs to, or None.
#_get_releasable_value
def _get_releasable_value(proj)Extract the releasable value from a project (WorkspaceProject or dict).
Returns str, False, or None. Does not validate -- just reads the raw value.
#_build_project_table
def _build_project_table(d)Build a fresh tomlkit table for a project dict.
Key order: path, name, then all remaining keys sorted. This matches the layout used when scaffolding a brand-new workspace.toml.
#_build_releasable_table
def _build_releasable_table(d)Build a fresh tomlkit table for a releasable desired-dict.
d carries name and (optionally) tag_format and subtree_remote.
#_update_table_fields
def _update_table_fields(table, desired)Update a tomlkit table in place to match desired (a plain dict).
- Existing keys are reassigned only when their value actually changed
(so untouched keys keep their original formatting and inline comments).
- New keys are appended (preserving the order of already-present keys).
- Keys absent from
desiredare removed.
Intra-table comments attached to surviving keys are preserved by tomlkit.
#_sync_aot_in_place
def _sync_aot_in_place(aot, desired_list, id_key, build_fn)Reconcile an existing tomlkit array-of-tables with a desired list.
Items are matched by identity (id_key: path for projects, name for releasables). Matched tables are updated field-by-field in place (preserving comments and key order). Tables whose identity is not in desired_list are removed. Desired items with no matching table are appended (in desired order) as fresh tables with a leading blank line so they read like the surrounding array-of-tables.
#_separate_releasables_from_projects
def _separate_releasables_from_projects(doc)Keep a blank line between the two sections when one follows the other.
An appended array-of-tables entry carries a blank line BEFORE itself (trivia.indent), which separates it from its own siblings but not from whatever section comes next: a newcomer appended as the last [[releasables]] entry otherwise butts straight against the [[projects]] header. tomlkit holds the blank line AFTER a table as a trailing whitespace element inside that table, which is how a file that already reads well is recognized and left byte-for-byte alone -- a no-op save must not perturb a single byte.
#_member_to_write
def _member_to_write(proj)The member dict save_workspace serializes, minus runtime bookkeeping.
Two things a load->save cycle must never do: persist a key a caller hung off the member at runtime (monorepo sync attaches its inlined-CI bookkeeping to the member dicts it walks), and write a key the loader would then refuse. The first is stripped -- those keys are the tools' own and were never part of the file -- and the second is a hard error here rather than an unreadable workspace.toml discovered on the next load.
#save_workspace
def save_workspace(root, projects, releasables=None)Write workspace.toml atomically, editing the existing document in place.
When the file already exists it is parsed with tomlkit and the [[projects]] (and, when requested, [[releasables]]) arrays-of-tables are reconciled surgically: matched items (by path for projects and name for releasables) are updated field-by-field, absent items are removed, and new items are appended. Untouched tables, intra-table comments, key order, and every other top-level section are preserved byte-for-byte. When the file does not yet exist, a fresh document is created.
When releasables is passed (a list of Releasable instances), the [[releasables]] section is reconciled in place. When releasables is None, any existing [[releasables]] section is preserved untouched. Pass an empty list to write an explicitly empty section (releasables = []): a workspace with no releasables yet is still an explicit-mode workspace, and removing the section entirely would make it unreadable.
Creates .rlsbl-monorepo/ directory if it doesn't exist.
#resolve_project
def resolve_project(root, cwd='.')Determine which project cwd is inside, returning a WorkspaceProject or None.
Uses the one path rule (:func:rlsbl.ownership.member_for_directory): the most specific declared member path wins, and the root member answers for every directory no other member claims -- including the repository root itself, which is exactly what a member declared at path = "." always matched.
None only when cwd is outside the workspace tree entirely. Inside a workspace, some member always answers, because a workspace always declares a root member.
#_derive_standalone_name
def _derive_standalone_name(project_root, detected_targets=None, targets_map=None)Derive a project name for the standalone releasable.
Tries target read_name (first detected target), then falls back to the directory basename.
Args:
project_root: path to the project root (str or Path).detected_targets: pre-detected list of TargetEntry instances.targets_map: dict mapping target names to target objects.
Returns:
- A non-empty name string.
#load_standalone_releasable
def load_standalone_releasable(project_root)Load an explicit releasable definition from .rlsbl/releasable.toml.
If the file exists, reads name and tag_format from it. If absent, returns None (caller should use create_standalone_releasable).
Args:
project_root: path to the project root (str or Path).
Returns:
- A Releasable instance, or None if the file does not exist.
Raises:
- WorkspaceError on invalid file contents.
#save_standalone_releasable
def save_standalone_releasable(project_root, releasable)Write .rlsbl/releasable.toml for releasable.
tag_format is written only when the releasable declares one, so a load -> save cycle neither invents the key on a file that stated none nor drops it from a file that did.
#create_standalone_releasable
def create_standalone_releasable(project_root)Return a Releasable representing a single-project repo.
If .rlsbl/releasable.toml exists, uses its explicit configuration. Otherwise, derives the name from the project's target metadata (e.g., pyproject.toml [project].name) or the directory basename, and uses the standalone tag format (v{version}).
This function does NOT create any files on disk -- the releasable is purely an internal abstraction.
Args:
project_root: path to the project root (str or Path).
Returns:
- A Releasable instance.