On this page
Scaffold command that generates and updates CI workflows, hooks, and config from templates using three-way merge with plan/apply architecture.
#rlsbl.commands.init_cmd
#rlsbl.commands.init_cmd
Init command that scaffolds release infrastructure from templates, creating CI workflows, hooks, changelog, and config files.
#_find_git_dir
def _find_git_dir()Find the .git directory using git rev-parse, which works from any subdirectory.
Returns the absolute path to the .git directory, or None if not inside a git repo. Unlike os.path.isdir(".git"), this works when CWD is a monorepo sub-project where .git/ lives at the repo root.
#_is_workspace_root
def _is_workspace_root(project_root)Check if project_root is a monorepo workspace root.
Returns True when .rlsbl-monorepo/workspace.toml exists at the project root. Workspace roots are not importable Python packages, so CI templates (which run import checks) should be skipped -- the ci-router already handles per-package CI.
#_is_non_releasable_project
def _is_non_releasable_project(project_root)Check if the current project is non-releasable in its monorepo workspace.
Returns False if not in a monorepo or if the project is releasable.
#_is_releasable_member_project
def _is_releasable_member_project(project_root)Check if the project belongs to a named releasable in explicit mode.
When a project has releasable = "name" (explicit mode), its changelog infrastructure lives at the releasable level, not per-package. Per-package CHANGELOG.md and unreleased.jsonl should be skipped.
Returns False if not in a monorepo, not in explicit mode, or project has no named releasable assignment.
#_get_releasable_config_dir
def _get_releasable_config_dir(project_root)Return the releasable config directory for a releasable member project.
Returns the path to .rlsbl-monorepo/releasables/{name}/ if the project belongs to a releasable in explicit mode, or None otherwise.
#_skip_redundant_releasable_configs
def _skip_redundant_releasable_configs(project_root, warnings)Remove per-package config.json that duplicates releasable-level config.
When a releasable member's per-package config is identical to the releasable-level config, the per-package file is redundant -- the config inheritance system will produce the same result without it.
For files that existed before scaffold and are identical to the releasable config, a warning is emitted suggesting cleanup.
Modifies warnings list in place (appends cleanup warnings).
Returns a list of paths that were removed (for commit tracking).
#_check_npm_lockfile_missing
def _check_npm_lockfile_missing(start_dir='.')Check if any npm lockfile exists from start_dir up to the git root.
Returns True if no lockfile is found (i.e., lockfile is missing). Prints a warning to stderr when missing.
#_is_npm_wrapper
def _is_npm_wrapper(npm_dir_path)Check if the npm package at npm_dir_path is a wrapper.
A package is a wrapper if it has no test script OR has zero dependencies (no dependencies and no devDependencies, or both are empty objects).
#file_hash
def file_hash(path)SHA-256 hash of a file's contents.
#load_hashes
def load_hashes()Load stored file hashes from .rlsbl/hashes.json.
#save_hashes
def save_hashes(hashes)Write file hashes to .rlsbl/hashes.json.
#load_managed_files
def load_managed_files()Load the managed-files registry from .rlsbl/managed-files.json.
The managed-files registry tracks template-derived files from apply_plans for orphan detection. Separate from hashes.json which tracks content hashes for change detection.
Returns the files dict ({path: hash}), or {} if the file is missing.
#save_managed_files
def save_managed_files(files)Write the managed-files registry to .rlsbl/managed-files.json.
#_ensure_target_in_config
def _ensure_target_in_config(registry_name, ctx)Add registry_name to the targets array in .rlsbl/config.json if not already present.
#process_template
def process_template(template_content, vars_dict, template_path=None, *, required_vars=None)Process a template string with substitution and escape handling.
Pass 1 resolves {{action "owner/name"}} placeholders against the central action-version table (rlsbl/data/action_versions.toml). An unknown action raises :class:UnknownActionError immediately -- no implicit defaults.
Pass 1.5 resolves conditional blocks {{#if varName}}...{{/if}}. If vars_dict[varName] is truthy (present and non-empty string), the body is kept; otherwise the entire block is removed. Blank lines left by removed blocks are collapsed. Non-nested only. Actions inside conditional blocks are resolved (Pass 1 runs first); variables inside surviving blocks are resolved (Pass 2 runs after).
Pass 2 resolves the existing {{varName}} (and dotted {{a.b}}) placeholders against vars_dict.
Escaped placeholders: \{{word}} in a template emits {{word}} literally in the output (the backslash is consumed, the braces are preserved). This lets templates contain third-party {{...}} syntax (e.g. Docker metadata-action's {{version}}) without colliding with rlsbl's template engine.
If required_vars is provided (a set of variable names), any variable in that set that remains unreplaced after substitution raises :class:ValueError. This turns silent placeholder leaks into hard errors for critical template variables.
Returns (content, unreplaced) where unreplaced is the list of variable names in pass 2 that had no entry in vars_dict. Pass 1 misses raise instead of being collected.
#check_unreplaced_vars
def check_unreplaced_vars(source_path, unreplaced)Raise ConfigError if unreplaced is non-empty.
Shared by scaffold (apply_plans) and monorepo sync so the check logic lives in one place and tests can exercise it directly.
#_save_base
def _save_base(target, content)Save rendered template content as the merge base for future three-way merges.
#_load_base
def _load_base(target)Load the stored merge base for a target file. Returns None if not stored.
#_three_way_merge
def _three_way_merge(ours_text, base_text, theirs_text)Three-way merge using git merge-file.
Writes three temp files in the project dir (not /tmp), runs git merge-file -p ours base theirs, and returns (merged_text, has_conflicts). Exit code: 0 = clean merge, positive = number of conflicts, negative = error.
#plan_mappings
def plan_mappings(template_dir, mappings, vars_dict, force, *, required_vars=None)Compute what process_mappings would do, without writing anything.
When required_vars is provided (a set of variable names), it is forwarded to :func:process_template for every mapping. Any required variable that remains unresolved raises :class:ValueError, turning silent placeholder leaks into hard scaffold-time errors.
Returns a list of plan dicts. Each plan represents one mapping and contains: - "target": the target file path - "status": one of "new", "updated", "unchanged", "skipped", "user-owned", or a string starting with "CONFLICTS"; or status values like "overwritten", "created", "merged", "updated (additive merge)", "year updated (...)" -- the same vocabulary the original function produced for the (created, skipped) lists. - "bucket": "created" or "skipped" -- which result list this entry belongs in - "action": one of "write", "save_base_only", "license_year_update", "gitignore_merge", "merge_write", "none". Tells apply_plans what to do. - "content": the bytes to write (when action requires it). None otherwise. - "base_content": template content to save as the new merge base. None when no base should be saved this run. - "warning": optional extra warning string emitted alongside this plan - "unreplaced": list of unreplaced template var names (for warnings) - "year_update": for license_year_update, a dict with "current_year", "old_year" so apply can recompute the new content - "additive_lines": for gitignore_merge, the lines to append - "existing_content": for gitignore_merge, the original content - "template_not_found": True for warning-only entries
#apply_plans
def apply_plans(plans)Apply a list of plans from plan_mappings, performing all side effects.
Returns (created, skipped, warnings, new_hashes) matching the original process_mappings return shape.
#process_mappings
def process_mappings(template_dir, mappings, vars_dict, force, existing_hashes=None)Process a list of template mappings: read each template, apply vars, write target files.
Uses a universal three-way merge (via git merge-file) for existing files: base (last scaffolded version) + ours (user's current file) + theirs (new template). USER_OWNED files are never overwritten or merged.
Returns (created, skipped, warnings, new_hashes). created/skipped are lists of (target, status) tuples for unified display.
Implemented as plan_mappings() (pure analysis) + apply_plans() (side effects).
#_print_file_status_table
def _print_file_status_table(created, skipped)Print the unified file list table with dot-padded status column.
#_print_dry_run_report
def _print_dry_run_report(plans_groups, registry=None, registries=None, existing_hashes=None)Print the file status table from plans without applying them.
plans_groups is a list of plan lists (registry plans, shared plans, etc.).
#_install_or_update_pre_push_hook
def _install_or_update_pre_push_hook()Install the rlsbl pre-push hook, upgrading older versions in place.
See rlsbl/hook_hashes.py for the historical hash set.
Behavior: - .git missing -> no-op - hook missing -> write current template, chmod 755 - hook matches current hash -> no-op (already up to date) - hook matches old known hash -> overwrite, print upgrade notice - hook hash unknown -> skip, print warning + unified diff
#_finalize_scaffold
def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnings, *, registry=None, flags=None, registries=None, npm_lockfile_missing=False, target_paths=None, project_root, config, removed_paths=None)Shared post-processing for scaffold: chmod, hooks, version marker, hashes, tagging, summary.
all_hash_dicts is a list of dicts to merge into existing_hashes. flags is the CLI flags dict (used for tagging check). registries is a list of registry names (used for tagging). npm_lockfile_missing: if True, prepend a lockfile step to npm next steps.
#_resolve_private
def _resolve_private(flags, ctx)Determine if this is a private repository.
Checks --private flag first, then saved config, then auto-detects via GitHub API. The caller is responsible for persisting the value to config.json.
Returns True/False, or False if detection fails.
#_append_deploy_workflow_if_configured
def _append_deploy_workflow_if_configured(mappings, config)Add deploy workflow template to mappings if deploy config exists.
#_append_release_dispatch_if_configured
def _append_release_dispatch_if_configured(mappings, config)Add release-dispatch workflow template to mappings if remote_release is enabled.
#_append_release_finalize_if_configured
def _append_release_finalize_if_configured(mappings, config)Add release-finalize workflow template to mappings if release.mode is "pr".
#_print_private_summary
def _print_private_summary()Print helpful output for private repository scaffold.
#_ensure_pipeline_config
def _ensure_pipeline_config(registries, ctx)Generate default pipeline config for detected targets if not already present.
For each detected target whose name matches a PIPELINE_TYPES key, creates a pipeline entry with name=target_name, type=target_name, local=false. If multiple targets share the same pipeline type, errors with a message telling the user to name pipelines manually.
Writes the generated pipeline entries to config.json under the "pipelines" key. Skips if "pipelines" already exists in config.
#_trigger_monorepo_sync
def _trigger_monorepo_sync(no_commit=False)If the current directory is inside a monorepo workspace, run sync.
Uses a subprocess so that sys.exit() calls inside sync don't kill scaffold. Failures are silently ignored -- sync is best-effort after scaffold.
When no_commit is True, propagates --no-commit to the sync call so a single user invocation with --no-commit produces zero commits.
#run_cmd
def run_cmd(registry, args, flags, ctx)Init command handler.
Scaffolds release infrastructure (CI, publish workflows, changelog, etc.) from templates.
#_extract_top_level_block
def _extract_top_level_block(lines, key)Extract a top-level YAML block (e.g., 'permissions:', 'env:') from template lines.
Returns (block_lines, remaining_lines) where block_lines are the key + its indented children, and remaining_lines are everything else.
#_parse_permissions
def _parse_permissions(block_lines)Parse permission key-value pairs from a permissions block.
Returns a dict like {"contents": "write", "id-token": "write"}.
#_parse_env
def _parse_env(block_lines)Parse env key-value pairs from an env block.
Returns a list of (key, full_line) tuples to preserve formatting. Keys are used for deduplication; full lines are used for output.
#_merge_permissions
def _merge_permissions(perm_dicts)Merge multiple permission dicts, choosing the most permissive value for each key.
Permission escalation order: read < write.
#_parse_on_triggers
def _parse_on_triggers(block_lines)Parse an on: block into a dict of trigger names to sub-block lines.
Each trigger key maps to a list of its indented continuation lines (if any). Triggers without sub-keys (e.g. workflow_dispatch:) map to an empty list.
Returns a dict like::
{"release": [" types: [published]\n"], "workflow_dispatch": []}
#_merge_on_triggers
def _merge_on_triggers(trigger_dicts)Merge multiple parsed on: trigger dicts into a single dict.
Unions all trigger keys. For triggers with sub-blocks, the first non-empty sub-block wins (all templates currently have identical sub-blocks). Ensures workflow_dispatch is always present.
#_extract_jobs_section
def _extract_jobs_section(lines)Extract the content under the 'jobs:' key from template lines.
Returns lines starting from the first job definition (the indented content after 'jobs:'), not including the 'jobs:' line itself.
#_generate_merged_publish
def _generate_merged_publish(targets, template_vars, target_paths=None)Generate a merged publish.yml from individual target publish templates.
Reads each target's publish.yml.tpl, renders template variables, parses as structured YAML, and merges on-triggers, permissions, env, and jobs into a single workflow dict.
When target_paths is provided (a dict mapping target name to its directory path), subdirectory targets get:
defaults.run.working-directoryinjected into their jobspackages-dirrewritten for PyPI publish actions- version-file inputs prefixed for setup actions
#_rewrite_action_paths_for_jobs
def _rewrite_action_paths_for_jobs(jobs, project_path)Rewrite action inputs with file paths so they are relative to project_path.
Modifies jobs in place. Handles:
pypa/gh-action-pypi-publish: setswith.packages-diractions/setup-{go,python,node}: prefixes version-file paths
#_overlay_target_vars
def _overlay_target_vars(merged_vars, target_name)Promote namespaced {target_name}.{key} entries to bare {key}.
Scans merged_vars for keys starting with {target_name}. and adds bare versions so templates like {{registryUrl}} resolve even when the target is not the primary. Does not overwrite existing bare keys.
#_merge_template_vars
def _merge_template_vars(registries_list, primary, target_paths, ctx)Build a merged template vars dict with namespaced keys from all targets.
The primary target's vars are included un-namespaced (as the base). Non-primary targets contribute only their namespaced keys (keys containing a dot), so they do not overwrite the primary's bare keys.
TemplateVars auto-generates {target_name}.{key} entries, so no manual namespacing loop is needed.
target_paths is a dict mapping target name to its directory path.
#_plan_merged_publish
def _plan_merged_publish(publish_target, merged_content, force)Compute a plan for the merged publish workflow (analysis only).
#run_cmd_multi
def run_cmd_multi(registries_list, args, flags, ctx)Scaffold for multiple registries with per-target CI and merged publish.
Generates per-target CI workflows (ci-{target}.yml) and a merged publish.yml that contains jobs for all detected registries.