On this page
Hook helpers: content hashing, template hash lookup, hook emptiness check, hook runner.
#rlsbl.commands.release.hooks
#rlsbl.commands.release.hooks
Hook helpers: content hashing, template hash lookup, hook emptiness check, hook runner.
Supports two levels of hooks in monorepo explicit mode:
- Per-releasable:
.rlsbl-monorepo/releasables/{name}/hooks/ - Per-package:
.rlsbl/hooks/within each member package
Hook sources (checked in order):
- Config-driven:
hookskey in project/releasable config.json - Script-based: shell scripts in
.rlsbl/hooks/directories
Config-driven hooks use dual syntax (each entry is a string or structured object):
- String shorthand:
"npm test"is equivalent to{"cmd": "npm test"} - Structured:
{"cmd": "npm test", "dir": "./submodule", "env": {"NODE_ENV": "test"}}
Execution order during release:
- Releasable pre-checks
- Per-package pre-checks (alphabetical by package name)
- Built-in tests (each member)
- Per-package pre-release (alphabetical)
- Releasable pre-release
#_compute_content_hash
def _compute_content_hash(content)SHA-256 of content with trailing whitespace stripped.
#_get_pre_release_template_hashes
def _get_pre_release_template_hashes()Return a frozenset of content hashes for known scaffold template versions of pre-release.sh.
Used by the backward compatibility bridge to detect whether an existing script-based hook has been customized or is still the default template.
#_is_hook_effectively_empty
def _is_hook_effectively_empty(hook_path)Check if a pre-release hook file is effectively empty (matches scaffold template).
Returns True (hook is boilerplate / not customized) when:
- The hook file does not exist
- The hook file's content hash matches a known scaffold template version
Returns False (hook has been customized) when:
- The hook exists and its content does not match any known template version
#warn_if_hook_needs_migration
def warn_if_hook_needs_migration(config, hook_path)Emit a warning if a customized pre-release.sh exists without config entries.
This detects projects that still use the old script-based hook system without having migrated to config-driven hooks. The warning is informational only -- no auto-conversion is performed.
Args:
config: project config dict (fromctx.config).hook_path: absolute path to the pre-release.sh script.
Returns:
- True if a warning was emitted, False otherwise.
#normalize_hook_entry
def normalize_hook_entry(entry)Convert a hook entry to structured dict form.
String entries are shorthand for {"cmd": . Dict entries are validated for required cmd field.
Args:
entry: a string or dict hook entry.
Returns:
- A dict with at least
cmd(str), and optionallydir(str) - and
env(dict).
Raises:
HookError: if entry is neither str nor dict, or if dict is missingcmd.
#_get_config_hooks
def _get_config_hooks(hook_name, config)Read hook entries from config for a given hook slot.
Args:
hook_name: hyphenated hook name (e.g."pre-checks").config: project config dict.
Returns:
- List of hook entries (strings or dicts), or None if the config
- has no entries for this hook slot.
Raises:
HookError: if the hook slot value is not a list.
#run_config_hooks
def run_config_hooks(hook_name, config, project_dir, env, timeout, *, fatal=True, display_name=None)Run config-driven hooks for a given hook slot.
Reads config["hooks"][, normalizes each entry, and runs them in order via subprocess.
Args:
hook_name: canonical hyphenated hook name for config lookup
(e.g. "pre-checks").
config: project config dict containing ahookskey.project_dir: base working directory.env: environment dict to pass to subprocesses.timeout: seconds before a command is killed.fatal: if True (default), non-zero exit raises HookError.
If False, logs a warning and continues (for post-release).
display_name: human-readable name for error messages. If None,
uses hook_name.
Returns:
- True if config had entries for this hook (even if empty list),
- False if config had no entries (caller should fall back to scripts).
Raises:
HookError: on non-zero exit or timeout (when fatal=True).
#is_hook_customized
def is_hook_customized(config, hook_path)Check if the pre-release hook is customized, using config-first detection.
Decision logic:
- If config has a non-empty
hooks.pre_releasecommands list, the hook
is considered customized (built-in tests and lint are skipped).
- If config has
hooks.pre_releaseas an empty list, the hook is NOT
customized (built-in tests and lint run).
- If config has no
hookssection or nopre_releasekey, fall back
to the script-file hash check (backward compat during migration): - Customized script file -> customized - Template/missing script file -> not customized
Args:
config: project config dict (fromctx.config).hook_path: absolute path to the pre-release.sh script.
Returns:
- True if the hook is customized (skip built-in tests/lint),
- False otherwise.
#run_release_hook
def run_release_hook(hook_name, hook_path, project_dir, env, timeout, *, config=None)Run a release hook: config-driven if available, else script-based.
When config is provided and has entries for the hook slot, runs config-driven hooks and ignores the script file. Otherwise falls back to the script at hook_path.
hook_name: human-readable name for error messages (e.g. "pre-checks", "releasable pre-checks", "pre-checks (mypkg)"). May include a prefix for display purposes; the canonical hook name for config lookup is extracted automatically. hook_path: absolute path to the shell script (fallback). project_dir: working directory for the hook. env: environment dict to pass to the subprocess. timeout: seconds before the hook is killed. config: project config dict (optional). If provided, checked for config-driven hook entries before falling back to script.
Raises HookError on non-zero exit or timeout.
#get_releasable_hook_path
def get_releasable_hook_path(workspace_root, releasable_name, hook_name)Return the absolute path to a releasable-level hook script.
Path:
Args:
workspace_root: path to the monorepo root.releasable_name: name of the releasable.hook_name: hook file name (e.g."pre-checks.sh").
Returns:
- Absolute path string. The file may or may not exist on disk.
#get_package_hook_path
def get_package_hook_path(package_dir, hook_name)Return the absolute path to a per-package hook script.
Path:
Args:
package_dir: absolute path to the package directory.hook_name: hook file name (e.g."pre-checks.sh").
Returns:
- Absolute path string. The file may or may not exist on disk.
#build_hook_env
def build_hook_env(base_env, version, *, package_name=None, bump_type=None, prev_version=None, description=None)Build the environment dict for hook execution.
Always sets RLSBL_VERSION. When package_name is provided (per-package hooks), also sets RLSBL_PACKAGE.
Args:
base_env: base environment dict (typicallyos.environ.copy()).version: the new release version string.package_name: current package name (for per-package hooks), or None.bump_type: bump type string (patch/minor/major), or None.prev_version: previous version string, or None.description: release description string, or None.
Returns:
- A new dict with hook-specific env vars added.
#run_releasable_hooks
def run_releasable_hooks(hook_name, workspace_root, releasable_name, member_packages, hook_env, timeout, log, *, project_dir=None, releasable_config=None, package_configs=None)Run hooks at both releasable and per-package levels.
For pre-checks: releasable first, then per-package (alphabetical). For pre-release: per-package first (alphabetical), then releasable. For post-release: releasable first, then per-package (alphabetical).
Each per-package hook gets RLSBL_PACKAGE added to its env.
Config-driven hooks take precedence over script files at each level independently. The releasable level uses releasable_config and per-package level uses package_configs. If config has entries for a hook slot, scripts are ignored for that level.
Args:
hook_name: hook file name without extension context, e.g."pre-checks".workspace_root: path to the monorepo root.releasable_name: name of the releasable.member_packages: list of (package_name, package_dir) tuples, sorted
alphabetically by name.
hook_env: base environment dict (without RLSBL_PACKAGE).timeout: seconds before a hook is killed.log: callable for logging messages.project_dir: fallback cwd for releasable-level hooks. If None,
uses workspace_root.
releasable_config: releasable-level config dict (optional).package_configs: dict mapping package_name to per-package config dict
(optional).
Raises:
- HookError on first failure at any level.
#_run_per_package_hooks
def _run_per_package_hooks(hook_name, hook_file, sorted_members, hook_env, timeout, log, *, package_configs=None)Run a hook for each member package that has it, in alphabetical order.
Adds RLSBL_PACKAGE to the env for each package. Config-driven hooks take precedence over script files per-package.
Args:
hook_name: human-readable hook name (e.g."pre-checks").hook_file: hook file name (e.g."pre-checks.sh").sorted_members: list of (package_name, package_dir) tuples, already sorted.hook_env: base environment dict (without RLSBL_PACKAGE).timeout: seconds before a hook is killed.log: callable for logging messages.package_configs: dict mapping package_name to per-package config dict
(optional).
Raises:
- HookError on first failure.
#is_releasable_hook_customized
def is_releasable_hook_customized(workspace_root, releasable_name, config=None)Check if a releasable's pre-release hook is customized (not scaffold boilerplate).
When config is provided, checks config-driven hooks first:
- If config has a non-empty
hooks.pre_releaselist -> customized - If config has an empty
hooks.pre_releaselist -> not customized - If config has no
hookssection -> fall back to script hash check
The "effectively empty" check is at the releasable level: if the releasable's pre-release hook is customized, built-in tests/lint are skipped for the entire releasable.
Args:
workspace_root: path to the monorepo root.releasable_name: name of the releasable.config: optional releasable-level config dict. When None, uses
script-hash check only (backward compat).
Returns:
- True if the releasable has a customized pre-release hook, False otherwise.