On this page
Inline publish logic for monorepo projects: GitHub Actions workflow parsing, job extraction, trigger rewriting, and YAML emission.
#rlsbl.commands.monorepo.publish_inline
#rlsbl.commands.monorepo.publish_inline
Inline publish logic for monorepo projects: workflow parsing and YAML emission.
#parse_publish_workflow
def parse_publish_workflow(path: str) -> dictParse a GitHub Actions publish workflow file.
Reads the YAML file at path, validates it has a jobs: key, and returns a dict with the top-level keys that matter for inline publish generation.
Returns a dict with keys: jobs -- the jobs mapping from the workflow permissions -- workflow-level permissions mapping, or None env -- workflow-level env mapping, or None name -- workflow name string, or None
#parse_publish_workflow_content
def parse_publish_workflow_content(content: str, source: str='<string>') -> dictParse a publish workflow from a YAML string.
Same contract as :func:parse_publish_workflow but reads from an in-memory string. Used when the workflow is rendered from templates (root publisher) rather than read from disk.
#_literal_str_representer
def _literal_str_representer(representer, data)Represent multi-line strings with | literal block style.
#emit_workflow
def emit_workflow(workflow_dict: dict) -> strEmit a workflow dict as a YAML string.
Uses literal block style (|) for multi-line strings and preserves key order. The custom representer is registered on a private Dumper subclass so the global yaml state is never modified.
#prefix_jobs
def prefix_jobs(project_name: str, jobs: dict) -> dictPrefix every job key with {project_name}- and rewrite needs: references.
needs: gate references are preserved as-is: the router emits ONE shared gate job, so member references to it must never be force-prefixed (a {project}-gate need would dangle).
Returns a new dict; the original jobs is not mutated.
#inject_job_metadata
def inject_job_metadata(jobs: dict, tag_prefix: str, working_dir: str) -> dictAdd if: condition and defaults.run.working-directory to every job.
Returns a new dict; the original jobs is not mutated.
#_compose_project_subpath
def _compose_project_subpath(project_path: str, sub: str) -> strAnchor a per-target sub path under project_path.
Mirrors :func:inject_job_metadata's working-directory composition, but string-based so packages-dir's trailing dist/ slash is preserved (os.path.normpath would strip it, and consumers/tests rely on it).
The merged-publish generator pre-injects each subdir target's own subpath into these inputs (e.g. py/dist/ for a pypi target living in py/). This composes that subpath under the project's path rather than overwriting it with a root-anchored value. Behaviour is unchanged for targets at the project root (sub already correct relative to the repo root) and for inputs already anchored under project_path (idempotent -- never double-prefixed).
#rewrite_action_paths
def rewrite_action_paths(jobs: dict, project_path: str) -> dictRewrite action inputs that contain file paths so they are relative to project_path.
Handles:
pypa/gh-action-pypi-publish: composeswith.packages-dir(default
dist/) under project_path, preserving any per-target subpath the merged-publish generator pre-injected.
actions/setup-{go,python,node}: composes version-file paths under
project_path, likewise preserving pre-injected per-target subpaths.
Returns a new dict; the original jobs is not mutated.
#resolve_permissions
def resolve_permissions(jobs: dict, workflow_permissions: dict | None) -> dictPush workflow-level permissions down to jobs that lack their own.
- Jobs with an explicit
permissions:key keep it unchanged. - Jobs without
permissions:inherit workflow_permissions (if non-None). - If workflow_permissions is None and the job has no permissions, nothing is added.
Returns a new dict; the original jobs is not mutated.
#transform_project_jobs
def transform_project_jobs(project_name: str, project_path: str, tag_prefix: str, workflow_path: str) -> dictParse a sub-project's publish workflow and transform its jobs for the monorepo router.
Applies all transforms in the correct order:
- strip the member's own gate job (the router emits ONE shared gate)
- resolve_permissions (before prefixing — references original job structure)
- rewrite_action_paths
- inject_job_metadata
- prefix_jobs (last — changes keys;
needs: gateis preserved) - ensure every inlined job depends on the shared gate
Returns a dict of transformed jobs ready for merging into the root workflow.
#transform_parsed_jobs
def transform_parsed_jobs(project_name: str, project_path: str, tag_prefix: str, jobs: dict, permissions: dict | None) -> dictTransform already-parsed publish jobs for the monorepo router.
Shared by :func:transform_project_jobs (member workflows read from disk) and the root-publisher path (jobs rendered from templates). See :func:transform_project_jobs for the full ordered transform contract.
#_require_root_publish_gate_regex
def _require_root_publish_gate_regex(project: dict, root: str) -> strReturn the mandatory publish_gate_check_regex for a root publisher.
Unlike sub-project members -- whose CI runs through the generated ci-router.yml with known <prefix> / <job> check-run names -- the root package's CI lives in a hand-authored workflow whose check-run names rlsbl cannot infer. The gate must match check runs that actually execute at the release SHA, so the regex is a REQUIRED config key with no default (this is a security gate; a wrong or absent regex would let publishes run ungated).
Raises ConfigError (hard error) when the key is missing.
#_render_root_publisher_jobs
def _render_root_publisher_jobs(project: dict, root: str, tag_prefix: str) -> dictRender the root publisher's publish jobs from config/templates.
The root's publish.yml IS the router output, so it must never be read as a source (source==destination, and the transform pipeline is not idempotent on its own output). Instead the jobs are rendered from the project's pipeline config using the SAME standalone publish-template rendering the scaffold uses (_generate_merged_publish), then fed through the shared transform pipeline like every other member. This makes subsequent syncs regenerate from config, never from the router output -- idempotent by construction.
#_root_job_prefix
def _root_job_prefix(project: dict, root: str) -> strReturn a valid GitHub job-key prefix for the root publisher.
A root project's derived name is the basename of its path (".") -- not a valid job ID (GitHub job IDs must match [A-Za-z_][A-Za-z0-9_-]*). Use the releasable name when the root belongs to one (explicit mode), else the repository directory name.
#generate_inline_publish_router
def generate_inline_publish_router(projects_with_publish: list, root: str, releasables=None) -> strGenerate a monorepo publish router with all sub-project jobs inlined.
Instead of calling per-project reusable workflows via workflow_call, this inlines every sub-project's publish jobs directly into a single publish.yml. Each job gets an if: startsWith(github.ref_name, ...) condition so only the relevant project's jobs run on a given release -- ref-based so a workflow_dispatch retry at the tag ref hits the same jobs as the original release event.
A single shared gate job blocks all inlined publish jobs until the releasing project's CI check runs (resolved from the tag ref) conclude successfully. Member gate jobs are stripped during inlining.
When releasables are provided, tag prefixes are derived from the releasable's tag_format instead of the target's monorepo_tag_glob.
Returns the complete YAML string, ready to write to disk.
#compute_publish_hashes
def compute_publish_hashes(projects: list, root: str) -> dictCompute SHA256 hashes of each project's publish workflow.
Returns a dict mapping project name to the hex digest of its publish.yml content, or None if the project has no publish workflow.
A reserved __rlsbl_version__ key carries the current rlsbl version. Member publish.yml hashes don't change when only the router generator changes across an rlsbl upgrade, so without this key a version bump that alters router output would silently skip regeneration. Seeding the version into the cache structure invalidates the cache on any rlsbl version change.
#load_publish_cache
def load_publish_cache(monorepo_dir: str) -> dict | NoneLoad the publish hash cache from monorepo_dir.
Returns the parsed dict, or None if the cache file does not exist or contains invalid JSON.
#save_publish_cache
def save_publish_cache(monorepo_dir: str, hashes: dict) -> strWrite the publish hash cache to monorepo_dir.
Returns the absolute path to the written cache file.
#should_regenerate_router
def should_regenerate_router(cached: dict | None, current: dict, router_path: str) -> boolDecide whether the publish router needs regeneration.
Returns False (skip) only when cached matches current exactly AND router_path exists on disk. Any mismatch -- missing cache, changed hash, added/removed project, missing router file -- returns True.