Skip to content
Monorepo guide
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

$_ bash
# 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:

TM toml
[[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.

Project fields
FieldRequiredTypeDescription
pathyesstringRelative path from repo root to the project directory
releasableyesstring or falseThe [[releasables]] entry this project is versioned under, or false to opt out of versioning entirely
namenostringProject name (defaults to basename of path; the member at path = "." must be named root)
depends_onnolist of stringsExplicit intra-workspace dependencies (project names)
librarynoboolMark as a shared library (enables library-lint check)
dev_onlynoboolMark as a dev-only project (no changelog, no CHANGELOG.md). A dev-only project outside every releasable is a dev node
import_namenostringThe name this member is imported under, when it differs from the project name (the import scanner attributes imports with it)
registry_namenostringOverride package name on the registry (e.g., scoped npm name)
descriptionnostringShort project description for documentation
test_onlynoboolMark as test infrastructure; carried into the workspace snapshot
lint_allownolist of stringsImports 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:

The root member
KindDeclarationWhat it means
Dev nodedev_only = true, releasable = falserlsbl monorepo init --root-dev-nodeThe root files need no changelog coverage, and stand outside every releasable.
Releasable memberreleasable = "<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.

What the loader refuses
RefusalRemedy
A key at the top level that is neither sectionDelete 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 modeAdd 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 territoryKeep one and give the other a path of its own. Two members cannot own one territory.
No root memberAdd one, choosing its kind. The error prints both declarations in full.
A watch key on any memberDelete 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 memberMove 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 memberReplace 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 knowDelete 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 knowDelete it, or fix its spelling. That surface's known keys are derived from the releasable model itself.
A root member named anything but rootSet name = "root", or omit name entirely.
A non-root member named rootRename 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_formatDeclare 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:

TM toml
[[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-lint quality check (runs language-specific lint rules)
  • dead-workspace-packages detection (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/, no unreleased.jsonl, no CHANGELOG.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 run and rlsbl release edit error with "non-releasable projects cannot be released"
  • rlsbl changelog add errors 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 (dropping dev_only if it is genuinely not dev-only) to make it releasable
  • The dev-only-boundary check 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:

  1. Manifest scanning — pluggable scanners (PypiScanner, NpmScanner, DartScanner) parse each project's manifest file looking for intra-workspace dependencies
  2. **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:

$_ bash
# 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

$_ bash
# Show release order (leaves first, dependents after their dependencies)
rlsbl monorepo release order

Uses 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

$_ bash
# 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:

Output (impact)
SectionMeaning
Input packagesThe directly changed packages
Direct dependentsPackages with an immediate edge to the changed package
Transitive dependentsAll packages reachable via BFS on reverse deps
Test scopePackages that should be tested (input + all dependents)
Release candidatesPackages 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

  1. Run rlsbl monorepo release init to scaffold .rlsbl-monorepo/releases/unreleased.toml
  2. Edit the file: set bump type, description, and context per releasable
  3. 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:

TM toml
[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 include list 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:

  1. Validate all listed releasables exist in workspace.toml
  2. Build topological order from the full workspace graph
  3. Map each releasable to its highest-positioned member, preserving topological order
  4. 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.

$_ bash
# Generate and commit snapshot
rlsbl monorepo snapshot

# Verify snapshot is up-to-date (exits 1 if stale)
rlsbl monorepo snapshot-check

The 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_remote in workspace.toml, and that releasable must have exactly one member
  • SSH host must be consistent between subtree_remote and origin
  • Recommended: enable branch protection on the mirror's main for 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):

Plan and apply
StateMeaningWhat apply does
convergedScaffold commit atop the current split.Nothing — clean no-op.
scaffold-staleA 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.
behindA 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-missingThe 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-violatedA 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-undeterminedGit 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-emptyVirgin 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 sync does not update mirror repositories. sync regenerates the monorepo's own .github/workflows. Mirrors are updated only by re-running rlsbl 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:

Release tags on the mirror
StateMeaningWhat apply does
presentThe mirror already carries the tag.Nothing.
materializeThe 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.
underivableNo 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-releasedThe 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.mod module directive 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 a go.mod nobody can go 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 run rlsbl monorepo mirror <project> first;
  • the correspondence is persisted into the extracted repository's transition record as a promotion-split-map event, 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:

  1. For each project in the workspace, reads its scaffolded CI workflow
  2. Injects working-directory into job steps so they run in the correct subdirectory
  3. Inlines every project's jobs into one generated ci-router.yml, keyed by a per-file prefix and gated on a detect job's paths filter, and inlines publish jobs into publish.yml the same way
  4. Removes any stale per-project workflow copy left at the root by an older sync (via saferm)
  5. 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 -- as path/**;
  • 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.

$_ bash
gh workflow run ci-router.yml --ref main -f run_all=true

This 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:

$_ bash
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:

Workspace checks
CheckSeverityDescription
workspace-ci-routererrorVerifies 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-syncederrorVerifies 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-targetserrorEvery project must have at least one detectable release target
workspace-unregisterederrorDetects project directories with manifests that are not in workspace.toml
workspace-stale-entrieserrorDetects workspace.toml entries pointing to non-existent directories
dev-only-boundaryerrorNon-dev-only projects cannot have runtime dependencies on dev-only projects
unversioned-boundaryerrorReleasable projects cannot have runtime dependencies on unversioned (releasable = false) projects
dead-workspace-packageswarnLibrary projects with zero dependents (may indicate unused code)
subtree-remote-reachableerrorAll configured subtree_remote URLs must be accessible (network check)
mirror-requirederrorMembers consumed by repository URL (SPM) must belong to a releasable that declares a subtree_remote
workspace-unbuildableerrorWorkspace 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-stalewarnWorkspace project .gitignore files contain all rlsbl-managed entries
root-rlsbl-conflicterrorRoot .rlsbl/ must not coexist with .rlsbl-monorepo/
go-companion-tagswarnNon-private Go members of releasables have companion tags for the current version
test-suite-workspaceerrorRuns tests for affected workspace projects (also tagged prepush)

Run all workspace checks:

$_ bash
rlsbl check --tag workspace

See 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_only project, rlsbl check --tag workspace errors.

This ensures dev nodes are truly leaf nodes consumed by nothing user-facing. The check distinguishes:

  • Runtime dependencies (scope: runtime or explicit) — 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:

  1. Remove dev_only = true from the dependency (it is not actually a dev-only project)
  2. Move the runtime dependency to a dev dependency in the consumer's manifest

#Examples

#Setting up a monorepo from scratch

$_ bash
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

$_ bash
# 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

$_ bash
# 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

$_ bash
# 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

python
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

python
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

python
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

python
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

python
def releasable_keys() -> frozenset

Every 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

python
def _unknown_keys(table, known)

The keys of table outside known, in the order they were written.

#_refuse_unknown_keys

python
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

python
def _refuse_releasable_unknown_keys(index, raw)

Refuse a [[releasables]] table carrying a key the model lacks.

#is_root_path

python
def is_root_path(path) -> bool

Does path spell the repository root ("", ".", "./")?

#_declared_paths

python
def _declared_paths(data, projects)

The path spellings as written, aligned with projects by index.

#_validate_member_paths

python
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

python
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:

  1. a key at the top level that is neither section -- first, because a

misspelled section header is why the sections below look wrong;

  1. an implicit-mode workspace (no [[releasables]]) -- because

every other remedy below is written for an explicit-mode workspace;

  1. more than one root member;
  2. two members whose paths normalize to the same territory;
  3. no root member;
  4. a watch key on any member;
  5. a subtree_remote key on any member;
  6. a dev_node key on any member;
  7. any other unknown key on a member table;
  8. an unknown key on a [[releasables]] table;
  9. a root member named anything but root;
  10. a non-root member named root;
  11. 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

python
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

python
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

python
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

python
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 least name and 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

python
def mirror_remote_for(proj, releasables) -> str

The 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

python
def mirrored_releasable_for(proj, releasables)

The mirrored :class:Releasable proj belongs to, or None.

#_get_releasable_value

python
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

python
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

python
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

python
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 desired are removed.

Intra-table comments attached to surviving keys are preserved by tomlkit.

#_sync_aot_in_place

python
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

python
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

python
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

python
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

python
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

python
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

python
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

python
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

python
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.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search