rlsbl v0.113.0 /rlsbl.commands.init_cmd
On this page

Init command that scaffolds release infrastructure from templates, creating CI workflows, hooks, changelog, and config files.

#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

python
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

python
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

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

#_skip_publish_scaffold

python
def _skip_publish_scaffold(private, is_ws_root, project_root)

Return True when this project must get no publish workflow.

Three cases, one rule -- a publish workflow is scaffolded only for something that can actually be released:

  • publish_mode: "none" -- publishing is suppressed by declaration;
  • a workspace root -- not a package;
  • a NON-RELEASABLE workspace project (dev_node = true, or

releasable = false) -- rlsbl release run hard-errors on these, so a publish workflow for one is unreachable and can only misfire.

The dev-node case is derived from the project's position in the workspace graph rather than declared twice: dev_node lives in workspace.toml and publish_mode in the project's own .rlsbl/config.json, and "none" carries a different meaning (a private repo suppressing public registry publishes). Requiring the operator to also set publish_mode: "none" on every dev node would make correct, ordinary configs a hard error. An already-scaffolded publish.yml is removed by the normal orphan sweep, exactly as it is for publish_mode: "none".

#_is_releasable_member_project

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

#resolve_tag_prefix

python
def resolve_tag_prefix(project_root)

Return the git tag prefix release tags of project_root carry.

"v" for a standalone repo (v1.2.3); "<name>@v" or "<path>/v" in a monorepo, derived from the SAME resolution the tag globs use (:func:rlsbl.tag_glob.resolve_monorepo_tag_glob, minus the trailing *).

Launcher shims reconstruct GitHub release-asset URLs from the version, so a shim that hardcodes v downloads from a tag that does not exist in any prefixed-tag repo.

#_get_releasable_config_dir

python
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

python
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

python
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

python
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

python
def file_hash(path)

SHA-256 hash of a file's contents.

#load_managed_files

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

Returns the files dict ({path: hash}), or {} if the file is missing.

#save_managed_files

python
def save_managed_files(files)

Write the managed-files registry to .rlsbl/managed-files.json.

#_ensure_target_in_config

python
def _ensure_target_in_config(registry_name, ctx)

Add registry_name to the targets array in the on-disk per-project .rlsbl/config.json if not already present.

Reads the CURRENT targets straight from disk -- the exact same file write_project_config writes -- rather than from ctx.config. ctx.config may be empty (a bare ProjectContext) or releasable-merged; using it would clobber structured on-disk entries like {"name": "go", "path": "go/"} down to plain strings, losing the subdirectory path. Structured dict entries are preserved untouched; the new registry is appended as a plain string only when it is not already present under either representation. The file is written only when something was actually added. ctx.config is refreshed to a fresh read of the merged project config afterwards so callers observe disk state.

#_go_artifact_kind

python
def _go_artifact_kind(config)

Return the artifact kind of the config's go pipeline, or None.

#_next_steps_for

python
def _next_steps_for(registry, config)

Return the next-steps lines for registry, or None.

Specialized by pipeline config where the generic text would be wrong.

#process_template

python
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

python
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

python
def _save_base(target, content)

Save rendered template content as the merge base for future three-way merges.

#_load_base

python
def _load_base(target)

Load the stored merge base for a target file. Returns None if not stored.

#_git_run

python
def _git_run(args)

Run a git command with a timeout, returning (returncode, stdout).

Never raises on non-zero exit -- callers inspect the return code. A missing git binary or a timeout is reported as a failure (returncode 1, empty output) so callers treat it the same as "no result".

#_reconstruct_base_from_history

python
def _reconstruct_base_from_history(target)

Reconstruct a merge base for target from its most recent scaffold commit.

Scaffold auto-commits with the message rlsbl scaffold (plus an Autogenerated trailer), so a managed file's content at its last scaffold IS its last-known base. This finds the most recent such commit that touched target and returns that revision's copy of the file.

Returns (base_content, short_sha) when a scaffold commit exists, or None when no rlsbl scaffold commit touched the file (or git is unavailable). The :./path form makes the pathspec resolve relative to the current working directory, which matters for monorepo sub-projects.

#_require_healable_bases_dir

python
def _require_healable_bases_dir()

Guard against the base-tracking-migration incident up front.

A project that was scaffolded before merge-base tracking existed has a managed-files.json registry but no .rlsbl/bases/ directory. Such a project cannot three-way merge template updates until per-file bases are healed from git history. Healing happens per file (see :func:_reconstruct_base_from_history), but the whole-directory-missing case is deliberately turned into a one-time hard error so the operator opts into healing consciously rather than having it happen invisibly.

Fresh projects (no managed-files.json) are exempt: their first scaffold legitimately has no bases yet and creates the directory as it runs.

#_three_way_merge

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

The three files are observe_scratch_files: git merge-file -p is an allowlisted observe, so it really runs under --dry-run, and operands that were only recorded would leave it reading absent paths -- turning every previewed merge into a fabricated conflict. They are created and deleted inside the block in every mode, so a preview leaves nothing behind either.

#plan_mappings

python
def plan_mappings(template_dir, mappings, vars_dict, *, required_vars=None, transform=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.

transform, when given, is callable(target_path, rendered_text) -> text applied to the rendered template ("theirs") BEFORE any merge decision. This is the only place a post-render rewrite may happen: applying one afterwards (patching content and storing the rewritten text as the merge base while "theirs" stayed raw) made base and theirs differ by the rewrite on every run, manufacturing conflicts out of unrelated local edits.

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

python
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

python
def process_mappings(template_dir, mappings, vars_dict)

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. When an existing file has no stored base, its base is healed from the file's most recent scaffold commit before merging (see :func:plan_mappings).

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

python
def _print_file_status_table(created, skipped)

Print the unified file list table with dot-padded status column.

#_print_dry_run_report

python
def _print_dry_run_report(plans_groups, registry=None, registries=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_hook

python
def _install_or_update_hook(hook_name, current_content, current_hash, known_hashes)

Install or update a git hook, upgrading older rlsbl versions in place.

Generic hook installer used for any git hook type (pre-push, post-rewrite, etc.). Each hook type provides its own content, current hash, and set of known historical hashes.

Args:

  • hook_name: git hook name (e.g. "pre-push", "post-rewrite").
  • current_content: the current template content to install.
  • current_hash: SHA-256 hash of current_content (via compute_hook_hash).
  • known_hashes: frozenset of all historical content hashes for this hook.

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

#_install_or_update_pre_push_hook

python
def _install_or_update_pre_push_hook()

Install the rlsbl pre-push hook, upgrading older versions in place.

Delegates to the generic _install_or_update_hook with pre-push-specific content and hashes from hook_hashes.py.

#_install_or_update_post_rewrite_hook

python
def _install_or_update_post_rewrite_hook()

Install the rlsbl post-rewrite hook, upgrading older versions in place.

Delegates to the generic _install_or_update_hook with post-rewrite-specific content and hashes from hook_hashes.py.

#_finalize_scaffold

python
def _finalize_scaffold(all_hash_dicts, created, skipped, warnings, *, registry=None, flags=None, registries=None, npm_lockfile_missing=False, target_paths=None, project_root, config)

Shared post-processing for scaffold: chmod, hooks, version marker, tagging, summary.

all_hash_dicts is a list of dicts to merge for managed-files tracking. 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_publish_mode

python
def _resolve_publish_mode(flags, ctx)

Determine the project's publish_mode ("ci" or "none").

Resolution order:

  1. The explicit --publish-mode flag, if passed (validated against the

enum).

  1. The value saved in .rlsbl/config.json, if present (validated).
  2. Auto-detection via the GitHub API: a repo that is definitively PRIVATE

is a hard error -- the choice affects publish routing and must be made explicitly with --publish-mode. A PUBLIC repo (or one whose visibility cannot be detected) defaults to "ci".

The caller persists the returned value to config.json.

Raises:

  • ConfigError when a private repo has no explicit choice, or when an
  • explicit/saved value is not a valid mode.

#_append_deploy_workflow_if_configured

python
def _append_deploy_workflow_if_configured(mappings, config)

Add deploy workflow template to mappings if deploy config exists.

#_append_release_dispatch_if_configured

python
def _append_release_dispatch_if_configured(mappings, config)

Add release-dispatch workflow template to mappings if remote_release is enabled.

#_print_private_summary

python
def _print_private_summary()

Print helpful output for a publish_mode "none" scaffold.

#_target_dir

python
def _target_dir(target_name, ctx)

Return target_name's own directory, falling back to the project root.

A target declared as {"name": "go", "path": "go/"} lives in a subdirectory; anything that introspects the target's ecosystem files must look there, not at the project root.

#_ensure_pipeline_config

python
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, and the mandatory target link pointing at the same-named target. If multiple targets share the same pipeline type, errors with a message telling the user to name pipelines manually.

Callers must register the targets in config (_ensure_target_in_config) BEFORE calling this, so the generated target link always resolves.

Go pipelines additionally set artifact to "library" or "binary" (auto-detected from the project's package layout). No implicit default -- the key is always explicit. Detection runs in the GO TARGET's own directory: a go target declared at a subdirectory path has no go.mod at the project root, so detecting there fails and falls back to "binary" -- writing a wrong declaration that everything downstream then (correctly) obeys.

Writes the generated pipeline entries to config.json under the "pipelines" key. Skips if "pipelines" already exists in config.

#_trigger_monorepo_sync

python
def _trigger_monorepo_sync(auto_commit=True)

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 auto_commit is False, propagates --no-auto-commit to the sync call so a single user invocation with --no-auto-commit produces zero commits.

#run_cmd

python
def run_cmd(registry, args, flags, ctx)

Init command handler.

Scaffolds release infrastructure (CI, publish workflows, changelog, etc.) from templates.

#_extract_top_level_block

python
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

python
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

python
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

python
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

python
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

python
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

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

#_producer_target_subdir

python
def _producer_target_subdir(producer_target_name, ctx)

Resolve the producer target's directory from ctx config targets.

#_resolve_launcher_shim_vars

python
def _resolve_launcher_shim_vars(pipeline, pipelines, ctx)

Resolve shim template vars from the wrapped producer pipeline.

Follows pipeline.config["wraps"] -> producer pipeline -> its linked target -> the target's binCommand/repoName template vars. These feed goreleaser asset naming (<PROJECT>_<VERSION>_<os>_<arch>) and the executable name baked into the shim. A resolution failure is a hard scaffold-time error, not a silent empty substitution.

#_ensure_launcher_manifest

python
def _ensure_launcher_manifest(pipeline, subdir, shim_vars, *, dry_run=False)

Fill-once manifest handling for a launcher target.

The manifest is the name authority: scaffold NEVER invents or writes the package name. When the manifest is absent, hard-error and direct the user to create it with their chosen (rlsbl check-name'd) name. When present, fill ONLY the missing non-name fields required for the shim, exactly once; existing values (and the name) are byte-preserved, so a second scaffold is a no-op. Returns the distribution name from the manifest.

#_fill_npm_launcher_manifest

python
def _fill_npm_launcher_manifest(manifest_path, shim_vars, download, *, dry_run=False)

Fill the npm launcher manifest for the given download mode.

Both modes map the command to bin/launcher.cjs. Only the postinstall mode adds a scripts.postinstall entry and ships the scripts/vendor directories. first-run performs zero network I/O at install time, so it emits NO postinstall script and ships only bin (the binary is cached outside the package on first invocation).

#_plan_launcher_shims

python
def _plan_launcher_shims(pipelines, ctx, *, dry_run=False)

Plan shim source files (and fill manifests) for all launcher pipelines.

Returns a list of plan dicts for :func:apply_plans. Performs the bespoke fill-once manifest handling first (hard-errors on an absent manifest), then plans each launcher's non-workflow (shim) mappings with producer-resolved template vars. The shim plan_mappings call is gated by required_vars so a failed producer resolution is a hard error.

#_resolve_publish_template

python
def _resolve_publish_template(target_name, pipelines, templates_root)

Resolve the publish template path for a target.

When a loaded pipeline is available, uses its template_mappings to find the publish template (so pipeline config like artifact drives template selection). Falls back to the hardcoded {target_name}/publish.yml.tpl path for targets without a loaded pipeline.

Returns the absolute path to the template file, or None when no template exists.

#_launcher_pipeline_for_target

python
def _launcher_pipeline_for_target(target_name, pipelines)

Return the launcher pipeline linked to target_name, or None.

#_generate_merged_publish

python
def _generate_merged_publish(targets, template_vars, target_paths=None, pipelines=None, ctx=None)

Generate a merged publish.yml from individual target publish templates.

Reads each target's publish template (resolved via pipeline template_mappings when pipelines is provided, falling back to the hardcoded {target_name}/publish.yml.tpl path), 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-directory injected into their jobs
  • packages-dir rewritten for PyPI publish actions
  • version-file inputs prefixed for setup actions

When pipelines is provided (the loaded pipeline dict from config), template resolution goes through each pipeline's template_mappings method, allowing pipeline config keys (e.g. Go's artifact) to drive template selection. This replaces the target-name-based template bypass.

#_rewrite_action_paths_for_jobs

python
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: sets with.packages-dir
  • actions/setup-{go,python,node}: prefixes version-file paths

#_npm_provenance_var

python
def _npm_provenance_var(config)

Return the npm.provenance template-var value from the npm pipeline config.

The {{#if npm.provenance}} blocks in the npm publish templates treat any non-empty string as truthy, so this returns "true" when an npm pipeline enables provenance and "" (falsy) otherwise. Config validation guarantees npm pipelines carry a boolean provenance key, so a missing value here means there is simply no npm pipeline. Stored under the namespaced key npm.provenance (target-dir conditionals must be namespaced per the template-lint rules).

#_overlay_target_vars

python
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

python
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

python
def _plan_merged_publish(publish_target, merged_content)

Compute a plan for the merged publish workflow (analysis only).

#run_cmd_multi

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

Search