On this page
Monorepo sync command and helpers: trigger rewriting, working-directory injection, inlined CI router generation, import name detection, and tag prefixes.
#rlsbl.commands.monorepo.sync
#rlsbl.commands.monorepo.sync
Monorepo sync command and all sync helpers: working-directory injection, inline CI router generation.
#_inject_packages_dir
def _inject_packages_dir(doc, project_path)Add packages-dir to PyPI publish steps.
When working-directory is set, uv build creates artifacts in {project_path}/dist/ but the publish action looks for dist/ at the repo root. We inject packages-dir so it finds the right directory.
#count_reusable_workflow_calls
def count_reusable_workflow_calls(jobs)Count job-level uses: entries (reusable workflow calls) in a jobs mapping.
Step-level uses: (actions) are not counted -- only jobs whose mapping carries a top-level uses: key, which is what GitHub treats as a reusable workflow call.
#validate_router_reusable_calls
def validate_router_reusable_calls(jobs, router_name)Hard-error when a generated router carries >= 20 reusable workflow calls.
GitHub rejects workflow files with too many reusable-workflow calls outright -- the router never runs and CI silently stops. Generated routers must inline jobs instead of calling reusable workflows.
Raises:
ConfigError: when jobs containsGITHUB_MAX_REUSABLE_CALLSor
more job-level uses: entries.
#_strip_expression_wrapper
def _strip_expression_wrapper(expr)Strip an optional ${{ ... }} wrapper from a GitHub expression.
#_releasable_finalize_artifact
def _releasable_finalize_artifact(project, releasables)Repo-relative path of the releasable artifact a release commit writes.
In explicit releasable mode a release finalizes the releasable's CHANGELOG.md under .rlsbl-monorepo/releasables/<name>/. That file is part of the project's own change surface: a release commit IS a change to the released project, so it must satisfy the router's paths filter.
This entry is a deliberate run-everything hook, not an accident.
Mechanism: the finalize artifact is one path shared by every member of the releasable, and it is appended to each member's filter. A commit that touches it therefore matches all of their filters at once and triggers all of their CI jobs. The release commit always touches it -- the version-bump commit regenerates and commits the releasable CHANGELOG.md (it gains the new version's heading) before the candidate push.
Why it exists: a release push can touch nothing under a member's own path. That is inevitable on a FIRST release, where the version write is a no-op, and it is possible on any release whose per-member writes all land outside the member's directory. The member's CI job would then conclude skipped on the very commit its tag points at, and the publish gate deliberately refuses to treat skipped as passing (:mod:rlsbl.publish_gate) -- a skipped check proves nothing about the commit. The result is a deadlock with no re-runnable recovery: re-running CI on that commit skips the job again, for the same reason it skipped the first time. Anchoring every member's filter on the one file a release always writes makes the gated commit verifiable for all of them.
The cost, accepted knowingly: any release of a releasable runs the full CI job set of every one of its members, including members whose own code did not change, because the finalize artifact matches all of their filters. That is the intended trade -- CI minutes in exchange for never tagging a commit the gate cannot read a verdict for. Relaxing the gate to accept skipped would be the cheaper fix and is rejected: it would let a release publish on a commit nothing actually verified.
Deliberately the finalize artifact only, NOT the whole releasable directory: rlsbl changelog add writes the JSONL between releases and must not spend CI minutes on every entry.
Returns None outside explicit releasable mode or for a non-releasable project.
#router_filter_patterns
def router_filter_patterns(project, releasables=None)The dorny/paths-filter patterns the router emits for one project.
A push whose diff matches none of these patterns leaves that project's CI job skipped on the pushed commit -- and the publish gate refuses to treat a skipped check as passing. The release engine therefore has to guarantee that the commit it tags rode in on a push whose diff matches every participating project's patterns, which is why this list is a published function rather than a local in :func:_generate_router.
In explicit releasable mode the list ends with the releasable's finalize artifact (see :func:_releasable_finalize_artifact), a path shared by every member of the releasable. Any commit touching it matches all of their filters at once, so a release of the releasable runs every member's CI jobs. That breadth is the point, not a leak in the filter.
#_generate_router
def _generate_router(projects, releasables=None)Generate ci-router.yml content with every project's CI jobs inlined.
Each project dict must carry _ci_docs: a list of (job_prefix, doc) pairs where doc is a parsed CI workflow (working-directory already injected) and job_prefix is the per-file key ({name}-ci or {name}-ci-{target}).
GitHub rejects workflow files with 20+ reusable-workflow calls, so the router inlines every project's CI jobs directly instead of uses: calls. Each inlined job:
- gets its key prefixed with the CI file's job prefix (unique per file),
- gets an explicit
name: "{prefix} / {job}"so check-run names stay
identical to the reusable-workflow era (publish gate regexes and branch protection rules keep matching),
- is gated on
needs: detect+if: needs.detect.outputs.{project},
short-circuited by the run_all dispatch input (see :data:RUN_ALL_INPUT),
- keeps intra-workflow
needs:(rewritten to the prefixed keys).
#_get_monorepo_tag_prefix
def _get_monorepo_tag_prefix(project, root, releasables=None)Return the tag prefix for a monorepo project's publish router condition.
When releasables are provided and the project belongs to a releasable (releasable = "X"), the prefix is derived from the releasable's tag_format (e.g. "{name}@v{version}" -> "X@v").
Otherwise falls back to the target's monorepo_tag_glob (glob minus trailing *). For Go projects this yields go/v, for others name@v.
#_root_is_publisher
def _root_is_publisher(project, root)Return True when the root project (path='.') actually publishes.
A root publisher has publish_mode != "none" and at least one detectable publish target. Detection is config-based (not based on the on-disk publish.yml, which is the generated router itself). A ConfigError during publish_mode or target detection means the root is not a resolvable publisher -- treat it as a non-publisher rather than crashing sync.
#_member_suppresses_publish
def _member_suppresses_publish(project, root)Return True when project's effective config declares publish_mode: "none".
The publish router inlines each member's publish jobs verbatim, so a member that declares it must not publish has to be excluded BEFORE inlining -- otherwise a stale per-member publish.yml (e.g. a legacy PyPI job left behind when the repo went private) becomes a live registry publish in the generated root router.
publish_mode is read with releasable-level inheritance, mirroring :func:_root_is_publisher. A member with no resolvable publish_mode is NOT suppressed: absence of a declaration is not a declaration, and the publish-mode config check hard-errors on the missing key separately.
#_build_project_template_vars
def _build_project_template_vars(project_dir, root)Build a template vars dict for a project, with both namespaced and un-namespaced keys.
Detects the project's targets, calls each target's template_vars(), and returns a merged dict where each target's vars appear under both their bare names and {target_name}.{key} namespaced names. This allows process_template to resolve patterns like {{pypi.minRequiredPython}} in workflow comments.
#scaffold_releasable_dirs
def scaffold_releasable_dirs(workspace_root)Create the directory structure for each releasable in explicit mode.
In explicit mode (when [[releasables]] exists in workspace.toml), each releasable gets:
.rlsbl-monorepo/releasables/{name}/version(user-owned, never overwritten).rlsbl-monorepo/releasables/{name}/changes/unreleased.jsonl(user-owned)
Hook scripts are no longer scaffolded here -- hooks are config-driven (see hooks key in config.json). Version and unreleased.jsonl are user-owned: created once, never overwritten.
Args:
workspace_root: path to the monorepo root.
Returns:
- A list of file paths that were created or updated (for commit tracking).
#_is_generated_router
def _is_generated_router(filepath)True when filepath carries the generated-router header.
Distinguishes a router this tool wrote (safe to remove when it becomes obsolete) from a hand-authored root workflow (never touched).
#_saferm_workflow
def _saferm_workflow(filepath, description)Delete a stale generated workflow file via saferm (audit trail).
Raises RuntimeError if saferm is not on PATH and propagates subprocess.CalledProcessError if saferm exits non-zero.
#_sync_import_names
def _sync_import_names(root, projects)Auto-populate import_name for Python projects whose import name differs from their project name.
For each Python project (has pyproject.toml with [project]), detects the package root via detect_python_package_root and compares the derived import name against the underscored project name. When they differ and import_name is not already set in workspace.toml, writes import_name to the project's entry.
Returns the workspace.toml path if it was modified, or None.