rlsbl v0.113.0 /rlsbl.utils
On this page

Shared utilities: subprocess runner, tool detection, project root discovery, git helpers, version bumping, changelog extraction, and commit tooling.

#rlsbl.utils

#rlsbl.utils

Shared utilities: subprocess runner, git helpers, version bumping, changelog extraction, commit tooling, and GitHub API queries.

#run

python
def run(cmd, args=None, timeout=120, env=None, cwd=None)

Run a command with args, return trimmed stdout. Raise on failure.

Under --dry-run a non-observe command is recorded rather than executed, and there is no stdout to trim: the carrier standing in for the run is returned instead. A caller that ignores the return value (every mutation that only needed to happen) previews cleanly; a caller that reads the string truncates the preview, which is the honest answer for output that was never produced.

#warn_exception

python
def warn_exception(context: str, exc: Exception) -> None

Print a warning with optional traceback for non-fatal errors.

#require_tool

python
def require_tool(name, purpose=None, fatal=True)

Check that a CLI tool is available on PATH.

Args:

  • name: command name (e.g., "uv", "npm", "go").
  • purpose: optional human-readable reason ("for editable install"),

included in the error message when fatal.

  • fatal: if True, raise FileNotFoundError on missing tool; if False,

return None silently.

Returns the resolved path to the tool, or None if missing and not fatal.

#find_project_root

python
def find_project_root(start=None)

Walk up from start (default: cwd) to find .rlsbl/ or .rlsbl-monorepo/.

Returns the directory path containing the marker, or None if not found. Prefers the nearest ancestor with either marker.

#detect_uv_workspace_root

python
def detect_uv_workspace_root(project_dir: str) -> str | None

Walk up from project_dir to find a uv workspace root that includes it as a member.

Checks each ancestor for a pyproject.toml with [tool.uv.workspace.members]. If found, expands member globs and excludes, then checks if project_dir is in the resolved member set.

Returns the workspace root directory, or None if project_dir is not a member of any uv workspace.

#is_virtual_uv_root

python
def is_virtual_uv_root(project_dir: str) -> bool

True iff project_dir is a virtual uv workspace root.

A virtual root has a pyproject.toml declaring a [tool.uv.workspace] but no [project] table. It aggregates member packages for editable installs but is not itself a distributable package -- it has no name and no version, so pypi.detect() must not claim it and version/name/ publish checks do not apply to it.

Returns False when there is no pyproject.toml, when it cannot be parsed, when there is no [tool.uv.workspace], or when a [project] table is present (a real package that also happens to define a workspace).

#is_clean_tree

python
def is_clean_tree()

Returns True if the git working tree is clean (no uncommitted changes).

#get_last_version_tag

python
def get_last_version_tag(tag_glob: str='v*', *, cwd=None) -> str | None

Get the most recent version tag reachable from HEAD.

When tag_glob is set (e.g. mylib@v* in monorepo mode), uses it as the --match pattern so each project resolves its own last release tag.

cwd selects the repository the git commands run in; None means the process cwd. Callers that resolve a tag for a specific project (external checks, which may run with a cwd override) must pass it.

Returns the tag string on success. On failure, checks whether the repo is a shallow clone:

  • If shallow: raises GitError (shallow clones lack the history needed

for changelog validation).

  • If not shallow: returns None (no tags exist -- genuine first release).

#get_current_branch

python
def get_current_branch(*, cwd)

Returns the current git branch name for the repo at cwd.

cwd is REQUIRED (keyword-only) and must be the project/repo directory: there is intentionally no default that falls back to the process cwd. A default-to-process-cwd let a test run this (and the subsequent push_if_needed) against whatever repo the test process happened to be in -- the real dev repo -- causing a stray git push. Every caller now declares which repo it means.

Raises GitError when HEAD is detached (git returns the literal string "HEAD"), since callers like push_if_needed would silently misbehave by operating on origin/HEAD.

#_resolve_timeout

python
def _resolve_timeout(config, key, default, *, override=None)

Resolve a timeout: explicit override > config key > shipped default.

There is deliberately NO environment-variable layer. A timeout is either declared in the project's config or passed explicitly on the command line; an ambient env var configuring release behavior is exactly the kind of invisible state this tool refuses to have.

A present-but-invalid value (config or override) is a hard error naming the key and the value -- never silently ignored, never silently defaulted.

#get_push_timeout

python
def get_push_timeout(config=None, *, override=None)

Return the push timeout in seconds.

Precedence: override (the --push-timeout CLI flag) > config dict push_timeout > :data:DEFAULT_PUSH_TIMEOUT.

config may be None (override > default only).

#get_check_timeout

python
def get_check_timeout(config=None, *, override=None)

Return the check timeout in seconds.

Precedence: override > config dict check_timeout > :data:DEFAULT_CHECK_TIMEOUT.

config may be None (override > default only).

#get_ci_timeout

python
def get_ci_timeout(config=None, *, override=None)

Return the release CI-wait timeout in seconds.

Precedence: override (the --ci-timeout CLI flag) > config dict ci_timeout > :data:DEFAULT_CI_TIMEOUT.

config may be None (override > default only).

#get_hook_timeout

python
def get_hook_timeout(config=None, *, override=None)

Return the release-hook timeout in seconds, or None for no timeout.

Precedence: override > config dict hook_timeout > None. The default is deliberately "no timeout": release hooks legitimately run whole test suites and deploys, and killing one mid-flight is worse than waiting.

A present-but-invalid value is a hard error (:class:ConfigError).

#validate_timeout_override

python
def validate_timeout_override(config_key, value)

Validate a CLI timeout override, naming the FLAG in the error.

--check-timeout and --hook-timeout reach their consumers by being written into the in-memory config (see apply_timeout_overrides), which means an invalid flag value used to surface much later as "Invalid check_timeout in .rlsbl/config.json" -- pointing the operator at a file they never touched. Validating at the argv boundary keeps the blame where it belongs.

#remote_branch_exists

python
def remote_branch_exists(branch, cwd=None)

Check whether origin/{branch} exists as a valid ref.

#tag_exists_locally

python
def tag_exists_locally(tag, cwd=None)

Check whether a git tag exists in the local repository.

Answers False when the question is unanswerable -- a preview past its first recorded mutation, where the framework replies to every observe with a stale carrier. "The tag is not there yet" is the state a preview is describing anyway: a preview creates no tags, so one it cannot see is one the run it previews would still have to create.

#tag_exists_on_remote

python
def tag_exists_on_remote(tag, cwd=None)

Check whether a git tag exists on the origin remote.

False when unanswerable, for the same reason as :func:tag_exists_locally.

#RemoteTagState

Tri-state outcome of a commit-aware remote tag lookup.

#RemoteTagResult

Result of resolving a tag to its peeled commit on a remote.

Attributes:

  • state: PRESENT (tag found; commit is set), ABSENT (ls-remote

succeeded but no matching ref), or INCONCLUSIVE (ls-remote failed due to network/auth/timeout; error carries the underlying text).

  • commit: the peeled commit SHA the tag points to. Only set when state

is PRESENT; None otherwise. For annotated tags this is the ^{} peeled line (the commit), not the tag-object SHA.

  • error: underlying error text when state is INCONCLUSIVE; None otherwise.

#remote_tag_commit

python
def remote_tag_commit(tag, cwd=None, remote='origin', timeout=30)

Resolve a tag to the commit it points to on remote (default origin).

Runs git ls-remote --tags <remote> <tag> and returns a tri-state RemoteTagResult:

  • PRESENT with the peeled commit SHA. For annotated tags, ls-remote prints

two lines -- <tag-object-sha> refs/tags/<tag> and <commit-sha> refs/tags/<tag>^{}. The ^{} peeled line is the commit and is preferred. For lightweight tags there is a single line whose SHA is already the commit.

  • ABSENT when ls-remote succeeds but returns no matching ref.
  • INCONCLUSIVE when ls-remote fails (network, auth, timeout); the failure

text is carried in error.

Both refs/tags/<tag> and refs/tags/<tag>^{} are passed as explicit match patterns: a bare <tag> pattern matches only the direct ref and suppresses the peeled ^{} line, which would make annotated tags false-negative to the tag-object SHA instead of the commit.

#resolve_tag_push_plan

python
def resolve_tag_push_plan(tags, cwd=None, remote='origin')

Commit-aware decision for pushing one or more release tags.

Each tag in tags must exist locally; its peeled commit is resolved via git rev-parse refs/tags/<tag>^{} (which yields the commit for both lightweight and annotated tags). For each tag the remote state is resolved with :func:remote_tag_commit:

  • INCONCLUSIVE (ls-remote failed): raise :class:GitError carrying the

underlying error. A release must never push blind after an inconclusive remote probe -- the old bare except Exception: pass skip that pushed anyway is exactly the bug this replaces.

  • PRESENT at a different commit than the local tag: raise :class:GitError

naming the tag, the local SHA, and the divergent remote SHA. A tag must never be force-moved by a release.

  • PRESENT at the same commit: idempotent -- already pushed.
  • ABSENT: needs pushing.

Returns True when at least one tag needs pushing (the caller runs the push; git no-ops the already-present-identical refs pushed alongside the missing ones -- verified: git push origin <present-identical> <absent> exits 0). Returns False when every tag is already present at the matching commit (the caller skips the push entirely -- the idempotent resume/re-entry case).

#push_if_needed

python
def push_if_needed(branch, *, config, cwd, sha=None)

Push the branch to origin if local is ahead of remote.

The push runs with --no-verify: this is a release-internal push, and the pre-push hook exists to catch MANUAL pushes to release branches. There is no environment-variable handshake -- bypassing the hook is expressed by not running it.

Args:

  • branch: branch name to push.
  • config: project config dict forwarded to get_push_timeout.
  • cwd: REQUIRED (keyword-only) repo directory the git commands run from.

There is deliberately no process-cwd default: an unanchored push once executed a real git push from the test-runner's own repo. Callers must pass the project root explicitly.

  • sha: optional explicit commit to publish as <sha>:refs/heads/<branch>

instead of pushing the branch ref by name. The release flow always passes it: the commit that CI verified is the commit that is published, and a ride-in that landed on the local branch after the candidate was pinned can never be swept along by the push.

#extract_changelog_entry_from_text

python
def extract_changelog_entry_from_text(content, version)

Extract a changelog entry for a specific version from a markdown string.

Looks for a heading like '## 1.2.3' and captures everything until the next heading or EOF.

#extract_changelog_entry

python
def extract_changelog_entry(changelog_path, version)

Extract a changelog entry for a specific version.

Looks for a heading like '## 1.2.3' and captures everything until the next heading or EOF.

#check_gh_installed

python
def check_gh_installed()

Check that the gh CLI is installed.

#check_gh_auth

python
def check_gh_auth()

Check that the gh CLI is authenticated.

#find_commit_tool

python
def find_commit_tool()

Detect safegit or fall back to git for committing.

Returns "safegit" if available on PATH, otherwise "git". Prints a one-time warning to stderr when falling back to git.

#has_staged_or_modified

python
def has_staged_or_modified(paths: list[str], cwd: str | None=None) -> bool

Check if any of the given paths have staged or unstaged changes.

When cwd is set, git commands run from that directory and os.path.exists checks are resolved relative to it. Paths must be relative to cwd (or absolute).

#assert_git_toplevel

python
def assert_git_toplevel(cwd: str | None, expected_root: str) -> None

Hard-error if the git repo discovered from cwd is not expected_root.

Closes the junk-commit class: a commit whose cwd is a non-git fixture directory (e.g. a TMPDIR created inside the repo) makes git walk UP to the nearest enclosing repo -- the real dev repo -- and commit there. Comparing the resolved git rev-parse --show-toplevel against the caller's declared project root and refusing on mismatch makes that impossible.

Silent no-op when cwd is not inside any git repo (callers gate commits on :func:is_git_repo separately); a mismatch is a hard :class:GitError.

#commit_files

python
def commit_files(message: str, files: list[str], allow_failure: bool=False, autogenerated: bool=True, cwd: str | None=None, expected_root: str | None=None, return_result: bool=False) -> bool

Commit specific files using safegit (preferred) or git.

When autogenerated is True, passes --trailer "Autogenerated: true" to the commit command (supported by git 2.32+ and safegit 0.10.0+).

When cwd is set, the commit tool runs from that directory. File paths must be relative to cwd (or absolute). This is needed in monorepo mode where paths are relative to the repo root but the process CWD is a sub-project directory.

When expected_root is set, the git repo discovered from cwd must be that project root (:func:assert_git_toplevel) or the commit is refused with a hard error -- the guard against committing into the wrong repository.

Returns True on success. When allow_failure is True, catches errors and returns False with a warning to stderr. When False, exceptions propagate.

return_result returns the commit run's own result instead of True. Under a preview that result is the framework's carrier standing in for the commit that was recorded rather than made -- the Phase-A executor forwards it into the candidate push, which is how the preview renders the push of a commit that does not exist. Callers that only need success must NOT set it: branching on a carrier truncates the preview.

#is_git_repo

python
def is_git_repo(path: str | None=None) -> bool

Return True if path (default: process cwd) is inside a git work tree.

#commit_scaffold_file

python
def commit_scaffold_file(message: str, files: list[str], *, cwd: str | None=None, expected_root: str | None=None) -> None

Commit a freshly-scaffolded file, failing loudly on commit error.

The scaffold write has already succeeded by the time this is called. Outside a git repository there is nothing to commit, so this is a no-op (matching the project convention: never commit or git-init in a non-git directory). Inside a git repository, a commit failure is a hard error with an actionable message -- it is never silently swallowed, because a scaffolded release file that is on disk but not committed can silently block or corrupt a later release.

Args:

  • message: commit message.
  • files: file paths to commit (relative to cwd or absolute).
  • cwd: directory the commit tool runs from; also the directory whose

git-repo membership is checked.

  • expected_root: when set, the git repo discovered from cwd must be

this project root or the commit is refused (hard error). Guards against a mis-anchored cwd walking up into the wrong repo.

#commit_files_if_changed

python
def commit_files_if_changed(message: str, files: list[str], skip_message: str='No changes to commit.', autogenerated: bool=True, cwd: str | None=None) -> bool

Commit files only if they have actual changes, otherwise print skip_message.

Returns True if a commit was made, False if nothing changed. Raises on commit failure (never uses allow_failure=True).

#_parse_prerelease_suffix

python
def _parse_prerelease_suffix(version)

Parse the pre-release suffix from a semver version string.

Returns (preid, counter) if the version has a suffix like "-alpha.0", or (None, None) if no suffix is present.

Raises VersionError if the suffix format is invalid.

#bump_version

python
def bump_version(version, bump_type, preid='')

Bump a semver version string by the given type.

Supported bump types: patch, minor, major, infra, prerelease.

When preid is set with a standard bump (patch/minor/major), the bumped base version gets a pre-release suffix appended: e.g. minor + alpha on "0.42.0" produces "0.43.0-alpha.0".

When bump_type is "prerelease":

  • The current version must have a pre-release suffix.
  • If preid is empty or matches the current preid: increment the counter.
  • If preid is "stable": strip the suffix, return the base version.
  • If preid is higher in the ordering (alpha < beta < rc < stable): promote.
  • If preid is lower: error (cannot demote).

Infra with preid is a hard error.

Without preid, the existing behavior is preserved: strip any pre-release suffix and bump the base version normally.

#is_private_repo

python
def is_private_repo()

Detect if the current repo is private via GitHub API.

Returns True if private, False if public, None if detection fails.

The query goes through gh api, which resolves and applies the credential inside its own process. rlsbl deliberately never asks for the token itself: a raw credential on a captured stdout pipe is exactly what the observe standard forbids (see :mod:rlsbl.observe_allowlist).

#extract_github_repo_from_remote

python
def extract_github_repo_from_remote(remote_url: str) -> str | None

Extract owner/repo from a git remote URL.

Supports:

  • SCP-style: [email protected]:owner/repo.git, git@gw:owner/repo.git, gp:owner/repo.git
  • HTTPS: https://github.com/owner/repo.git

Returns "owner/repo" or None if the URL doesn't match.

#get_origin_repo

python
def get_origin_repo() -> str | None

Get owner/repo for the origin remote of the current git repo.

Returns None on any error (no remote, not a git repo, unparseable URL).

#get_github_repo

python
def get_github_repo(config: dict | None=None) -> str | None

Resolve the GitHub owner/repo slug from config or the git remote.

Precedence:

  1. config["github_repo"] if config is provided and the key is set.
  2. get_origin_repo() to parse the origin remote URL.

Returns "owner/repo" or None if neither source provides a slug.

#run_gh

python
def run_gh(args: list, config: dict | None=None, **kwargs) -> str

Run a gh CLI command with automatic GH_REPO resolution.

Resolves the repo slug via get_github_repo(config) and, if found, sets GH_REPO in a per-call env dict so gh targets the correct repository. Does NOT mutate os.environ (critical for thread-safety in watch.py's ThreadPoolExecutor).

Accepts timeout, env and cwd; anything else is a hard error. The call goes through effects.gh, the chokepoint's named entry point for the gh CLI, so every GitHub verb in the codebase passes one identifiable seam.

#run_gh_unscoped

python
def run_gh_unscoped(args: list, *, timeout: int=120, cwd: str | None=None) -> str

Invoke gh WITHOUT injecting GH_REPO; return trimmed stdout.

For the gh calls that must not be scoped to the current project: the repo-independent ones (--version, auth status) and the ones that name their own --repo explicitly. They deliberately skip :func:run_gh, but they still route through effects.gh, so the gh family remains one enumerable surface.

Contract matches :func:run: capture, text, check=True, 120s default.

#read_go_module_path

python
def read_go_module_path(project_dir: str) -> str | None

Read the module path from go.mod.

Returns None if go.mod does not exist or cannot be parsed.

Search