On this page
Guide to rlsbl monorepo workspaces — workspace.toml format, dependency graphs, batch releases, impact analysis, snapshots, mirrors, and checks.
#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 18 supported release targets.
#Getting started
# Initialize a monorepo workspace (creates .rlsbl-monorepo/ with workspace.toml)
rlsbl monorepo init
# Add projects to the workspace
rlsbl monorepo add --name mylib --path packages/mylib --target pypi --library
rlsbl monorepo add --name cli --path packages/cli --target npm
rlsbl monorepo add --name tests --path packages/tests --dev-node
# 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:
[[projects]]
path = "packages/mylib"
name = "mylib"
target = "pypi"
library = true
watch = ["packages/mylib/**", "shared/types/**"]
depends_on = []
[[projects]]
path = "packages/cli"
name = "cli"
target = "npm"
depends_on = ["mylib"]
registry_name = "@org/cli"
[[projects]]
path = "packages/tests"
name = "tests"
dev_node = true
[layers]
order = ["foundation", "app"]
[layers.assignments]
foundation = ["mylib"]
app = ["cli"]
[layers.overrides]
unrestricted = ["tests"]#Project fields
Each [[projects]] block supports 10 fields (1 required, 9 optional) that define the project's identity, release target, change detection scope, inter-project relationships, and behavioral flags. The only required field is path -- all other fields either have sensible defaults (like deriving name from the path basename) or are opt-in features that activate additional checks and behaviors:
| Field | Required | Type | Description |
|---|---|---|---|
path | yes | string | Relative path from repo root to the project directory |
name | no | string | Project name (defaults to basename of path) |
target | no | string | Release target (auto-detected if omitted) |
watch | no | list of strings | Glob patterns for change detection beyond the project path |
subtree_remote | no | string | Git remote URL for subtree mirror publishing |
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_node | no | bool | Mark as a dev-only project (no changelog, no CHANGELOG/) |
registry_name | no | string | Override package name on the registry (e.g., scoped npm name) |
description | no | string | Short project description for documentation |
#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_node flags are 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)
#Dev nodes (dev_node = true)
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. Dev nodes cannot be released:
- No changelog system: no
.rlsbl/changes/, nounreleased.jsonl, noCHANGELOG.md - No releases:
rlsbl release runandrlsbl release editerror with "dev_node 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 - Remove
dev_node = truefrom workspace.toml to make a project releasable - The
dev-node-boundarycheck prevents non-dev-node projects from declaring runtime dependencies on dev nodes
#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-node 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-node-boundary check considers (only 2 of the 4 scopes -- runtime and explicit -- trigger the boundary violation).
#Viewing the graph
# JSON format (default)
rlsbl monorepo graph
# DOT format for Graphviz
rlsbl monorepo graph --format dot --output graph.dot
# Text tree (indented)
rlsbl monorepo graph --format text
# 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, 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).
#Batch release
rlsbl monorepo release run releases multiple packages in a single coordinated flow, respecting topological order so that leaf packages (those with no intra-workspace dependencies) are released first, followed by their dependents. This ensures downstream packages always reference the latest versions of their workspace dependencies.
#Workflow
- Run
rlsbl monorepo release initto scaffold.rlsbl-monorepo/releases/unreleased.toml - Edit the file: set bump type, description, and context per package
- Run
rlsbl monorepo release run --watch --yes
#release init scaffolding
rlsbl monorepo release init auto-detects release targets for each workspace project and generates a TOML file with pre-populated per-package sections. Packages with no unreleased commits are commented out, and dev nodes are excluded entirely since they cannot be released:
[packages.mylib]
bump = "patch"
description = ""
include = ["pypi"]
[packages.cli]
bump = "minor"
description = ""
include = ["npm"]
# [packages.tests]
# No unreleased commits since [email protected]- Dev nodes are excluded entirely (they have no changelog)
- Packages with zero unreleased commits since their last tag are rendered as commented-out sections
- Each section's
includelist is pre-populated from detected targets
#Execution
Each package is released sequentially using the standard single-package release flow (validation, tests, version bump, commit, tag, push, GitHub Release). The batch orchestrator determines execution order from the workspace dependency graph:
- Validate all listed packages exist in workspace
- Build topological order from the full workspace graph
- Filter to only packages in the batch, preserving topological order
- Release each package in order
#Partial failure
If a package's release fails mid-batch, there is no automatic resume. The command prints what succeeded, then re-raises the error. To recover, fix the issue, remove already-released packages from the batch file, and re-run.
#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 --check in CI to ensure the snapshot stays current.
#Mirror
rlsbl monorepo mirror <project> initializes a subtree mirror repository for a workspace project, enabling consumers to clone just one project without the full monorepo. The mirror is a standalone git repository containing only the project's subtree history, with its own rlsbl scaffold and CI workflows for independent publishing.
#Requirements
- The project must have
subtree_remoteconfigured in workspace.toml - The remote must be reachable (validated via
git ls-remote) - SSH host must be consistent between subtree_remote and origin
#Steps performed
- Validates remote reachability and SSH host consistency
- Runs
git subtree split --prefix=<path>to extract the project's history - Pushes the split branch to the configured
subtree_remote - Clones the mirror to a temp directory
- Scaffolds rlsbl CI in the mirror
- Pushes the scaffolded result
After initial setup, rlsbl monorepo sync keeps mirror repositories updated after releases.
#Sync
rlsbl monorepo sync copies per-project CI workflows to the shared .github/workflows/ directory at the repository root, performing template variable resolution and trigger rewriting 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
- Rewrites the
on:trigger toworkflow_call:(making it callable from a router) - Injects
working-directoryinto job steps so they run in the correct subdirectory - Generates a router workflow that dispatches to per-project workflows based on changed paths
- Commits the synced workflows
This ensures every project has its CI pipeline properly wired even when using different targets or custom workflow steps.
#Workspace checks
Eight checks run under rlsbl check --tag workspace (7 error-severity, 1 warning-severity), covering CI configuration consistency, project registration hygiene, dependency boundary enforcement, and code liveness. All error-severity checks block releases when they fail:
| Check | Severity | Description |
|---|---|---|
workspace-ci-router | error | Validates the CI router workflow dispatches to all registered projects |
workspace-ci-synced | error | Verifies per-project workflows in .github/workflows/ match their scaffolded source |
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-node-boundary | error | Non-dev-node projects cannot have runtime dependencies on dev_node 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) |
Run all workspace checks:
rlsbl check --tag workspaceSee checks.md for the full check reference across all tags.
#Dev node boundary
The dev-node-boundary check is a structural guardrail that prevents misuse of the dev_node 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-node project has a runtime dependency on a dev_node project,
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 the
dev_nodeflag from the dependency (it is not actually a dev-only project) - Move the runtime dependency to a dev dependency in the consumer's manifest
#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.
#get_releasable_dir
def get_releasable_dir(workspace_root, releasable_name)Return the directory path for a releasable's state files.
Path:
Args:
workspace_root: path to the monorepo root.releasable_name: name of the releasable.
Returns:
- Absolute path string to the releasable directory.
#get_releasable_changes_dir
def get_releasable_changes_dir(workspace_root, releasable_name)Return the path to a releasable's changelog changes directory.
Path:
Args:
workspace_root: path to the monorepo root.releasable_name: name of the releasable.
Returns:
- Absolute path string to the changes directory.
#get_releasable_version_path
def get_releasable_version_path(workspace_root, releasable_name)Return the path to a releasable's version file.
Path:
Args:
workspace_root: path to the monorepo root.releasable_name: name of the releasable.
Returns:
- Absolute path string to the version file.
#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.
#is_explicit_mode
def is_explicit_mode(workspace_root)Check whether the workspace has a [[releasables]] section.
Returns True when [[releasables]] is present in workspace.toml, False otherwise.
Args:
workspace_root: path to the monorepo root.
Returns:
- bool
#Releasable
A named unit of versioning: a group of packages sharing version, changelog, and release.
Releasables are defined via [[releasables]] in workspace.toml.
#WorkspaceProject
Typed wrapper over a workspace.toml project dict.
Provides typed property access for known fields while preserving the underlying dict for round-trip serialization. Unknown fields are kept intact. Dict-like [], get(), and in access is supported for backward compatibility with code that treats projects as dicts.
#name
def name(self) -> str#path
def path(self) -> str#watch
def watch(self) -> list[str]#library
def library(self) -> bool#dev_only
def dev_only(self) -> bool#dev_node
def dev_node(self) -> boolDerived shorthand: True when dev_only and not a member of any releasable.
A project is considered non-releasable when releasable is explicitly False, OR when releasable is None and the legacy dev_node flag is set.
#is_releasable
def is_releasable(self) -> boolWhether this project can produce releases.
A project is releasable when it belongs to some releasable unit (releasable = "name"). Returns False when releasable = false is set explicitly, or when the project is a dev_node.
#depends_on
def depends_on(self) -> list[str]#releasable
def releasable(self) -> 'str | bool | None'The releasable this project belongs to.
Returns:
str: name of the releasable group this project belongs to.False: project is explicitly unversioned (no releases).None: field not set.
#import_name
def import_name(self) -> str#registry_name
def registry_name(self) -> str#get
def get(self, key, default=None)Dict-like access for backward compatibility.
#to_dict
def to_dict(self) -> dictReturn the underlying dict for serialization.
#project_is_dev_only
def project_is_dev_only(proj) -> boolCheck if a project is dev_only (works with WorkspaceProject or dict).
#project_is_releasable
def project_is_releasable(proj) -> boolCheck if a project is releasable (works with WorkspaceProject or dict).
#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.
#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 = " 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.
#_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.
#save_workspace
def save_workspace(root, projects, releasables=None)Write workspace.toml atomically using tomlkit for clean TOML output.
Preserves top-level sections, comments, and formatting from the existing file by reading it with tomlkit first and modifying the [[projects]] array in-place. Falls back to creating a new document when the file does not yet exist.
When releasables is passed (a list of Releasable instances), the [[releasables]] section is replaced. When releasables is None, any existing [[releasables]] section is preserved as-is. Pass an empty list to explicitly remove the section.
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.
If multiple projects match (nested paths), returns the most specific one.
#_derive_standalone_name
def _derive_standalone_name(project_root)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).
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.
#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.