On this page
Validates JSONL changelog entries against git history: hash resolution, range checking, commit coverage with monorepo filtering, orphans, and schema.
#rlsbl.changelog.validate
#rlsbl.changelog.validate
Validates JSONL changelog entries against git history: hash resolution, range checking, commit coverage, orphan detection, and schema conformance.
#_get_batch_limits_config
def _get_batch_limits_config(config) -> dictReturn the resolved batch_limits config with defaults applied.
config is the project config dict (already loaded). Reads the raw batch_limits section via :func:get_changelog_validation_config and guarantees that all three expected keys are present with sane types:
max_commits_per_entry(int)max_entries_per_commit(int)exclusions(list)
A missing key keeps its documented default. A key that is present but has the wrong type is a hard error (:class:ConfigError) naming the key, the offending value, and the accepted type -- invalid config must never be silently replaced with a default.
#_git_head
def _git_head() -> str | NoneGet the current HEAD commit hash.
#filter_exempt_commits
def filter_exempt_commits(commits: list[str]) -> tuple[list[str], ExemptionStats]Filter out commits exempt from changelog coverage.
Checks each commit against two predicates in canonical order:
is_autogenerated(commit hasAutogenerated: truetrailer)is_changelog_only_commit(commit only touches changelog files)
First predicate match wins for stats counting: a commit that is both autogenerated and changelog-only is counted as autogenerated only.
Returns a tuple of (non-exempt commits, stats with per-predicate counts).
#_cache_path
def _cache_path(changes_dir: str) -> strReturn path to the .validated cache file.
#_read_cache
def _read_cache(changes_dir: str) -> str | NoneRead the .validated file. Return the cached HEAD hash or None.
#_write_cache
def _write_cache(changes_dir: str) -> NoneWrite the current HEAD hash to the .validated cache file.
#_is_cache_valid
def _is_cache_valid(changes_dir: str) -> boolCheck if the validation cache is still valid.
Valid when:
- .validated exists and contains a 40-char SHA
- That SHA is an ancestor of (or equal to) HEAD
- unreleased.jsonl's mtime is older than .validated's mtime
The one place in rlsbl where an unanswerable ancestry question is NOT a hard error: this is a cache, and the safe answer to "I cannot tell whether the cached commit is still on this branch" is to re-run the validation, not to refuse. Nothing is skipped and nothing is trusted -- the cost is one recomputation.
#check_hashes_resolve
def check_hashes_resolve(entries: list[ChangelogEntry]) -> tuple[bool, list[str]]Check that every hash in every entry resolves via git rev-parse.
#_foreign_owner_description
def _foreign_owner_description(sha: str, scope) -> strWho owns the files of a commit the asking scope does not claim.
Named for a message, so it answers in the workspace's own vocabulary: a member (with the releasable it belongs to), or a releasable's own state directory, which belongs to no member at all. Returns a short phrase, or a plain statement when nothing in the workspace claims the files.
#_out_of_scope_detail
def _out_of_scope_detail(raw: str, sha: str, entry: ChangelogEntry, scope) -> strThe finding for an entry naming a commit this changelog does not cover.
Deliberately NOT the out-of-range sentence: this commit IS between this checkout's nearest release commit and HEAD. What is wrong is where the entry was filed, and the remedy is to move it, not to repair a hash.
Two remedies, because there are two cases. When another member owns the commit's files the entry belongs in that member's changelog, so it is removed here and added there. When NOTHING owns them -- every path the commit touches is tool-owned, rlsbl's own bookkeeping -- there is no owning member's directory to add it from, and telling the reader to go to one names a place that does not exist. Such a commit needs no changelog coverage anywhere, so the entry is simply removed.
#check_in_range
def check_in_range(entries: list[ChangelogEntry], releases_dir: str, tag_glob: str | None=None, scope=None) -> tuple[bool, list[str]]Check that every resolved hash is in the unreleased range.
Unreleased range: the commits since the release the release record binds this checkout to -- the highest archived version whose candidate_sha this history contains -- or all commits when the release record records none. releases_dir is that release record; tag_glob names the tag scheme, used to detect a tag that disagrees with a release commit.
When scope is set (monorepo mode), only commits touching files owned by the scope's members are considered in-range. scope is an :class:~rlsbl.ownership.OwnershipScope.
A hash outside that set fails for one of two DIFFERENT reasons, and they get different messages. Out of range means the commit is not between this checkout's nearest release commit and HEAD -- a stale hash, or work that already shipped. Out of scope means it is in the range and belongs to another releasable's territory: a cross-filed entry, which used to be reported as out of range and sent its reader hunting for a rewrite that never happened.
#check_coverage
def check_coverage(entries: list[ChangelogEntry], releases_dir: str, tag_glob: str | None=None, scope=None) -> tuple[bool, list[str]]Check that every unreleased commit appears in at least one entry.
The unreleased range comes from the RELEASE RECORD at releases_dir -- see :func:check_in_range. Commits with the Autogenerated: true trailer are automatically exempted -- they are release infrastructure and don't need coverage. When scope is set (monorepo mode), only commits touching files the scope's members own require coverage -- a commit whose every file belongs to some other member is filtered out. scope is an :class:~rlsbl.ownership.OwnershipScope.
#_removal_remedy
def _removal_remedy(entry: ChangelogEntry, stale_hashes: list[str]) -> strThe changelog remove invocation that deletes entry.
Printed verbatim into a finding, so it has to be runnable as written. The entry's ULID id addresses it exactly and survives every unrelated edit to the file, so it is preferred. A legacy line written before ids existed carries none and is addressed by a commit it names instead -- which changelog remove accepts even when git cannot resolve that commit, since an unresolvable hash is the very condition this remedy is printed under.
#check_no_orphans
def check_no_orphans(entries: list[ChangelogEntry], releases_dir: str, tag_glob: str | None=None, scope=None) -> tuple[bool, list[str]]Flag entries where all commits are stale (unresolvable or out of range).
An entry is fully orphaned when ALL its hashes are unresolvable. An entry is effectively orphaned when every hash is either unresolvable or outside the unreleased range (the RELEASE RECORD's range at releases_dir — see :func:check_in_range) — no commit is both valid and in range. scope is an :class:~rlsbl.ownership.OwnershipScope.
The two ways a hash can be outside the scoped range are counted apart and named apart, exactly as :func:check_in_range names them: a commit another releasable owns is out of scope, and calling it out of range would say the history no longer contains it, which is false.
#check_schema
def check_schema(entries: list[ChangelogEntry]) -> tuple[bool, list[str]]Check that every entry passes schema validation.
#check_has_user_facing
def check_has_user_facing(entries: list[ChangelogEntry]) -> tuple[bool, list[str]]Check that at least one entry is user-facing.
#check_batch_size_commits
def check_batch_size_commits(entries: list[ChangelogEntry], config: dict, version: str='unreleased') -> tuple[bool, list[str]]Check that no entry has more commits than max_commits_per_entry.
config is the resolved batch_limits config (see :func:_get_batch_limits_config). Per-entry exclusions (matched by version + 1-based line number) silence the check for those entries.
#check_batch_size_entries
def check_batch_size_entries(entries_by_version: dict[str, list[ChangelogEntry]], config: dict) -> tuple[bool, list[str]]Check that no commit appears in more than max_entries_per_commit entries.
entries_by_version maps version label (e.g. "unreleased" or "0.32.0") to its entry list, so the check spans across ALL JSONL files, not just unreleased. Per-commit exclusions in config silence specific hashes.
#_read_all_versioned_entries
def _read_all_versioned_entries(changes_dir: str) -> dict[str, list[ChangelogEntry]]Read entries from unreleased.jsonl AND every x.y.z.jsonl in changes_dir.
Returns a mapping {version_label: entries} where version_label is "unreleased" or the bare semver string (e.g. "0.32.0"). Files that fail to parse are skipped silently -- other checks surface such errors separately.
#validate_unreleased
def validate_unreleased(changes_dir: str, tag_glob: str | None=None, scope=None, *, config: dict, bump_type: str | None=None) -> dictRun all 8 validation checks on unreleased.jsonl.
Returns a dict with:
- check names as keys, (passed, details) tuples as values
- "passed": overall bool (True only if all checks pass)
Uses validation cache: if the cache is valid and HEAD hasn't changed, skips full revalidation. The unreleased range comes from the RELEASE RECORD -- the release archives beside changes_dir -- and tag_glob names the tag scheme those archives are checked against (e.g. mylib@v* or go/v*). When scope is set (monorepo mode), coverage and range checks only consider commits touching files the scope's members own. scope is an :class:~rlsbl.ownership.OwnershipScope.
The two batch_limits checks (batch_size_commits and batch_size_entries) read configuration via :func:_get_batch_limits_config from the provided config dict. The cross-version batch_size_entries check reads every x.y.z.jsonl file in changes_dir so it can detect commits that appear in too many entries across versions.