On this page
Validation helpers for release: test runner, lint, selfdoc gen/check, scaffold conflict detection, strictcli schema dump, secret scan, and blog check.
#rlsbl.commands.release.validate
#rlsbl.commands.release.validate
Validation helpers for release: test runner, lint, selfdoc gen/check, scaffold conflict detection, strictcli schema dump, and blog post body validation.
Also contains extracted validation steps from run_cmd: target validation, OTA mode, config integrity, pipeline config, gh CLI, clean tree, branch/remote, monorepo context, version/tag computation, and changelog state validation.
#ReleaseValidationError
Raised when a pre-release validation check fails.
#HookError
Raised when a built-in hook (tests, lint, selfdoc) fails.
#validate_release_targets
def validate_release_targets(release_config, project_root, *, member_dirs=None, releasable_config_dir=None)Validate include/exclude targets in the release config.
Checks:
- include list is non-empty
- all named targets are known
- include + exclude exhaustively covers detected targets
When member_dirs is provided (releasable mode), detected targets are the union of targets across all member directories instead of the single project root. If releasable_config_dir is also set, its config.json targets key takes precedence (the releasable is the source of truth in explicit mode).
Returns the primary registry name (first item in include). Raises ReleaseValidationError on failure.
#validate_ota_mode
def validate_ota_mode(release_config, project_root, config)Validate Flutter OTA mode: check for native changes since last build release.
Raises ReleaseValidationError if OTA is requested but native files changed.
#validate_config_integrity
def validate_config_integrity(config)Validate publish_mode and that suppressed repos have no local pipelines.
Raises ReleaseValidationError on failure.
#validate_pipeline_config
def validate_pipeline_config(config)Validate pipeline configuration and required env vars.
Returns loaded pipelines dict. Raises ReleaseValidationError on failure.
#validate_gh_cli
def validate_gh_cli()Validate that gh CLI is installed and authenticated.
Raises ReleaseValidationError on failure.
#validate_gh_push_access
def validate_gh_push_access(config=None)Validate that the authenticated gh user has push access to the repo.
Uses the GitHub API to check push permissions. If push access is denied, raises ReleaseValidationError with a diagnostic message that includes the authenticated user, repo slug, and a suggestion to unset GH_TOKEN/GITHUB_TOKEN if either is set in the environment.
Gracefully skips (warning only) on network/API errors. Silently returns if the repo cannot be determined.
#is_tool_owned_state_path
def is_tool_owned_state_path(path) -> boolTrue if path is one of rlsbl's own untracked release-state files.
rlsbl writes in-progress.json (release state) and scrub-result.json (scrub resume state) into the releases dir and leaves them there precisely when a release failed and must be resumed. They are the tool's own scratch state, not the operator's work, so the clean-tree gate must not treat them as uncommitted changes -- otherwise rlsbl blocks its own release resume.
The match is STRUCTURAL (path-shaped), never gitignore-derived: a stale consumer .gitignore must not be able to defeat the exemption.
path is a repo-root-relative path as git status --porcelain reports it. The two canonical homes (see :func:rlsbl.release_file.get_releases_dir) are:
[<member>/].rlsbl/releases/<file>-- standalone projects and
implicit-monorepo packages
.rlsbl-monorepo/releasables/<name>/releases/<file>-- releasables
unreleased.plan.json and the unreleased.toml family share those directories and are DELIBERATELY committed; they are not exempt.
#validate_clean_tree
def validate_clean_tree(flags)Validate working tree is clean (or record pre-existing dirty files).
Returns set of pre-existing dirty file paths. Raises ReleaseValidationError if tree is dirty and --allow-dirty not set.
#validate_branch_and_remote
def validate_branch_and_remote(flags, *, config=None, cwd)Validate branch state and return the release branch name.
A release may only be started from a release branch (listed in the release_branches config, default ["main", "master"]). Any other branch is a hard error: dev-branch releases (and the fast-forward merge they required) no longer exist. Land your work on the release branch first, then release from there.
On a release branch this validates that local is not behind origin and returns the branch name.
cwd is REQUIRED (keyword-only): the repo directory all git operations (branch lookup, fetch, ancestry, rev-list) run from. No process-cwd default.
Returns the release branch name as a string. Raises :class:ReleaseValidationError on failure.
#resolve_monorepo_context
def resolve_monorepo_context(monorepo_root, project_root, log)Resolve monorepo project context if inside a monorepo.
Returns (monorepo_name, monorepo_project_path, is_library, is_non_releasable, releasable_name). All values are None/False/None when not in a monorepo. releasable_name is a string when the project explicitly belongs to a named releasable (releasable = "name"), or None in implicit mode. Raises ReleaseValidationError if inside a monorepo but not a recognized project, or if the project is non-releasable.
#_format_releasable_tag
def _format_releasable_tag(releasable_tag_format, releasable_name, version)Format a tag using the releasable's tag_format template.
Supports {name} and {version} placeholders. E.g.::
"{name}@v{version}" -> "[email protected]" "v{version}" -> "v2.0.0"
#_releasable_tag_glob
def _releasable_tag_glob(releasable_tag_format, releasable_name)Derive a glob pattern from a releasable's tag_format.
Thin wrapper over the shared :func:rlsbl.tag_glob.releasable_tag_glob kept for the many existing callers in the release/status commands.
#compute_release_version
def compute_release_version(target, primary_path, bump_arg, monorepo_name, monorepo_project_path, log, *, workspace_root=None, releasable_name=None, releasable_tag_fmt=None, preid='', project_dir=None)Compute current and new version, bump type, and tag.
In explicit releasable mode (when workspace_root and releasable_name are both provided), the version is read from the releasable's version file at .rlsbl-monorepo/releasables/<name>/version instead of from the target's manifest file. This is the canonical version source for multi-package releasables.
When releasable_tag_fmt is provided (explicit mode), tags are constructed from the releasable's tag format instead of the target's monorepo tag format.
In implicit mode (the default, when either parameter is None), the version is read from the target's manifest as before.
project_dir locates the project's .rlsbl/changes/ directory for the destroyed-tag guard (see :func:_abort_on_destroyed_tag). When omitted it falls back to primary_path, which coincides with the project root for standalone repos and implicit-mode monorepo projects.
Returns (current_version, new_version, bump_type, tag). Raises ReleaseValidationError on invalid bump type or duplicate tag.
#resolve_changes_dir
def resolve_changes_dir(project_dir, releasable_name=None, workspace_root=None)Resolve the JSONL changelog changes directory path.
In explicit releasable mode (when both releasable_name and workspace_root are provided), returns the releasable-level changes directory. Otherwise returns the per-project .rlsbl/changes/ directory.
Returns the changes_dir path. Raises ReleaseValidationError if the directory does not exist.
#_abort_on_destroyed_tag
def _abort_on_destroyed_tag(project_dir, current_version, tag, *, releasable_name=None, workspace_root=None)Abort a "first release" that is really a re-release of a destroyed tag.
compute_release_version enters the first-release path whenever the current version's tag does not exist locally. But "never tagged", "tag existed and was later destroyed" (e.g. by an interrupted or undone release), and "tag exists under an old format after a tag_format change" are indistinguishable to that check. A finalized, immutable .rlsbl/changes/<version>.jsonl for the exact current version proves the version is NOT new: it was released once and its changelog was locked at release time -- only the tag went missing.
Without this guard the release would run the entire pipeline (checks, tests, secret scan, version-bump commit) and only crash at the finalize step ("refusing to finalize changelog ... already exists"), triggering a rollback. This guard fires PRE-MUTATION -- nothing has been modified yet when it aborts, so no rollback is needed.
The changes directory is resolved via :func:resolve_changes_dir, so releasable-mode monorepos resolve to the releasable's changes directory. A project with no changes directory at all cannot have a finalized version file, so the guard is a no-op there.
#validate_changelog_state
def validate_changelog_state(project_dir, target, monorepo_name, monorepo_project_path, config, monorepo_project=None, releasable_name=None, releasable_tag_fmt=None, workspace_root=None, bump_type=None)Resolve the JSONL changelog changes directory path.
Thin wrapper around :func:resolve_changes_dir that preserves the existing call signature for backward compatibility. Changelog validation is now handled by the preflight-changelog check tag in the release flow.
Returns the changes_dir path. Raises ReleaseValidationError if the directory does not exist.
#print_dry_run_summary
def print_dry_run_summary(log, registry, monorepo_name, monorepo_project_path, bump_type, current_version, new_version, tag, commit_msg, branch, target_paths, project_dir, changelog_entry, monorepo_root=None, member_package_paths=None, releasable_config_dir=None)Print the release's identity summary: which release this is.
The first of the preview's three parts. It answers "what release is this?" -- registry, bump, tag, branch, changelog -- from values the release flow has already resolved, before Phase A issues anything. The recorded Phase-A log, the boundary line and the declared Phase-B table follow it (see :func:print_release_preview), except on the library path, where there is no effects handle to record onto and this summary is the whole preview.
#_phase_b_rows
def _phase_b_rows(state, *, registry)The declared Phase-B plan: one (step, what it does) row per release step.
Phase B stays imperative -- it waits on CI, then publishes -- so it has no plan to issue. What it CAN do honestly is declare itself: name each step and what that step would do to this particular release, with the operands the builder already resolved (the version, the tag, the changes dir). No row here is recorded, and the boundary line above says so.
#print_release_preview
def print_release_preview(log, plan, state, *, registry, files_to_commit)Render the release preview: recorded Phase A, the boundary, declared Phase B.
The order is the point. Phase A's steps really were issued -- as recorded effects, which the framework's would-do log lists verbatim at the end of the run -- so they are reported first, as the plan that produced them. Then the boundary line. Then Phase B, which is declared and not recorded, because nothing below the line is knowable until CI has judged the candidate.
plan is None on the one path where Phase A is not owed at all: a resume past the CI gate, or a batch member the orchestrator already gated. Nothing was built and nothing was issued, so there is no table to print -- and an empty one would read as "Phase A does nothing" rather than the truth, "Phase A is already done". The preview says which.
#print_resume_dry_run_summary
def print_resume_dry_run_summary(log, saved_state, *, verified_sha=None, head=None)Print what rlsbl release resume WOULD do, and change nothing.
The fresh-release preview (:func:print_dry_run_summary) describes a release that has not started. A resume is the opposite situation: part of the release already happened, and the only useful preview is which steps are left, which commit the tag would land on, and whether the CI gate is already satisfied.
This exists because the dry-run gate used to live ONLY in the fresh-release entry point. rlsbl release resume --dry-run therefore executed the whole release for real -- commits, tag, push to the release branch, GitHub Release, publish dispatches.
#parse_porcelain_paths
def parse_porcelain_paths(porcelain_output)Parse file paths from git status --porcelain output.
Handles the case where run() strips stdout, potentially removing a leading space from the first line. Uses lstrip().split(None, 1) to robustly extract the status code and path regardless.
Returns a set of file paths found in the output.
#_selfdoc_version_args
def _selfdoc_version_args(version)Return the --version-override argv fragment for version, if any.
selfdoc resolves version-bearing content (the CLI index's Version line, root-file version directives) from the project's CURRENT version. During a release the version on disk is still the OLD one when selfdoc runs -- the bump happens later, in the mutating phase -- so every generated version-bearing line shipped exactly one release stale, and the same churn tripped the doc-staleness check on the very next release.
Passing the about-to-be-released version closes that loop: generated content is written for the version this release is producing.
#_run_selfdoc_gen
def _run_selfdoc_gen(flags, project_dir=None, version=None)Run selfdoc gen if selfdoc.json exists in the project directory.
Regenerates documentation pages from source before the selfdoc check step, ensuring the check validates fresh content rather than stale pages.
version is the version this release is producing; it is forwarded as --version-override so version-bearing generated content is written for the new version rather than the (still un-bumped) one on disk.
#_run_selfdoc_check
def _run_selfdoc_check(flags, project_dir=None, version=None)Run selfdoc check if selfdoc.json exists in the project directory.
Checks documentation consistency before releasing. Non-fatal if selfdoc is not installed; fatal if it is installed and the check fails. When project_dir is set (monorepo mode), checks are resolved relative to it.
version is forwarded as --version-override so the check judges the generated content against the version the gen step just wrote, not the still-un-bumped one on disk.
#_abort_on_scaffold_conflicts
def _abort_on_scaffold_conflicts(project_dir)Abort the release if scaffold-managed files contain unresolved merge conflict markers.
Scaffold's three-way merge (git merge-file) intentionally leaves conflict markers for manual resolution; releasing with them would publish corrupted workflows/hooks. Runs PRE-MUTATION: nothing has been modified yet when this aborts.
#_abort_on_cross_repo_sources
def _abort_on_cross_repo_sources(project_dir, *, boundary_root=None, member_dirs=None)Abort the release if any committed pyproject.toml declares a [tool.uv.sources] path entry that resolves outside the repository.
Cross-repo path sources make lockfiles and CI builds depend on sibling checkouts that only exist on the developer's machine. Local overrides belong in dev-sources.toml.local-only (gitignored), never in the committed pyproject.toml. Runs PRE-MUTATION: nothing has been modified yet when this aborts. Runs unconditionally (unlike the preflight tag, which is skipped when the pre-release hook is customized).
member_dirs (releasable mode) adds member package directories to the scan; boundary_root is the repository/workspace root that in-repo paths must stay within.
#_abort_on_version_skew
def _abort_on_version_skew(project_dir, *, workspace_root=None)Abort the release when a dev-sources overlay checkout is ahead of the registry.
Reads dev-sources.toml.local-only at the project root (falling back to the workspace root in a monorepo). For each declared overlay, the local checkout's [project].version is compared against the latest PyPI release: local ahead means the release was developed and tested against unreleased dependency code, so the dependency must be released first. Equal or behind passes. An unpublished dependency, a registry error, or an unreadable overlay file is a hard error -- never a silent skip. No overlays file means nothing is declared, so nothing to check.
Runs PRE-MUTATION and unconditionally (like the other release guards, not the hook-skippable preflight tag).
#_npm_provenance_requested
def _npm_provenance_requested(configs)Return True if any config in configs has an npm pipeline with provenance: true.
Config validation guarantees npm pipelines carry a boolean provenance key, so this reads it directly. configs is an iterable of project/member config dicts.
#_abort_on_npm_provenance
def _abort_on_npm_provenance(configs, *, gh_config)Abort the release if an npm pipeline requests provenance on a repo that cannot support it.
npm build-provenance attestations (npm publish --provenance) require a PUBLIC GitHub source repository and GitHub Actions OIDC. When any config in configs declares an npm pipeline with provenance: true, this probes the repository visibility via gh repo view --json isPrivate (using gh_config for GH_REPO resolution) and aborts when the repo is private or when visibility cannot be resolved (e.g. a non-GitHub remote).
When no npm pipeline requests provenance, NO network call is made at all.
Runs PRE-MUTATION: nothing has been modified yet when this aborts.
#_schema_dump_command
def _schema_dump_command(entry_point: str, lang: str) -> list[str]Build the command list for running --dump-schema based on language.
#_run_strictcli_schema_dump
def _run_strictcli_schema_dump(flags, log, project_dir='.', version=None)Run --dump-schema for strictcli projects to regenerate .strictcli/schema.json.
Detects strictcli usage via pyproject.toml or go.mod, runs the entry point with --dump-schema, and logs the result. The generated file is picked up by the hook-generated file mechanism (pre/post hook dirty snapshots).
When version is given, the version key in the generated schema.json is replaced with version after a successful dump (atomic write).
A project that requires strictcli but whose entry point cannot be detected aborts validation (ReleaseValidationError) -- a silent skip would ship a stale schema.
#_patch_schema_version
def _patch_schema_version(project_dir, version)Replace the version key in .strictcli/schema.json with version.
Writes atomically via a temp file + os.replace.
#validate_blog_body
def validate_blog_body(project_dir, blog_enabled, *, releases_dir=None)Validate the blog body file for a release.
releases_dir overrides the default .rlsbl/releases/ location -- releasable releases keep the blog body (unreleased.md) in the releasable's own releases dir, alongside unreleased.toml.
Returns (body_path, warning_message) where body_path is the path if it exists and warning_message is set if the file is missing. Raises ReleaseValidationError if blog_enabled and file is empty.