Skip to content
stricttoolsPublic

About

Automate releasing your software to npm, PyPI, and GitHub Releases, with a single CLI -- supports monorepos, semver bumping, changelog.md generation

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

8,842 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rlsbl

rlsbl

rlsbl is a release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, and the Go module proxy. It is for developers and AI agents who ship a repository (one project, several targets at once, or a workspace of independently versioned releasables) to public package registries. Its distinctive property is that the version is pushed first as an untagged candidate and becomes a tag only after the repository's own CI passes on that commit, so a red build leaves no tag, no GitHub Release, and nothing on any registry.

Install

rlsbl is one Go binary, from its first release built in Go on (the releases before it are a Python package).

go install github.com/stricttools/rlsbl/cmd/rlsbl@latest

From npm or PyPI, whose packages from that release on carry the prebuilt binary for Linux and macOS on x64 and arm64:

npm i -g rlsbl
uv tool install rlsbl

Quick start

# declare the repository in .strictmetadata/releasables/releasables.toml
rlsbl scaffold                                   # CI and publish workflows, git hooks
rlsbl changelog add --commits <id> --description "..." --type feature
rlsbl release init                               # write the release file
# ... set its bump and description ...
rlsbl release run --watch --approve-consequential

Commands

Command Description
check Run project checks registered via the check framework and report results
failing-checks Run project checks and report only error-level failures, exiting nonzero when any exist
check-name Check whether one or more package names are usable. npm and PyPI are asked over the network, through the package-level pages that list a package's versions, whether the name and every name that collides with it after normalization are registered; go is an offline check of the Go package name a candidate implies. Each name gets a status of available, taken, invalid (go only), discouraged (go only), or error; a similar name the registry cannot answer about makes the status error, never available. Accepts several names and repeated --target, and waits --delay milliseconds between networked requests. Exits 0 when every name is available, 2 when any check ended in an error, and 1 otherwise: taken, invalid, and discouraged all exit 1, so a discouraged Go name exits 1 even though Go accepts it.
claim-name Claim a name on a package registry by publishing a minimal placeholder package (version 0.0.0). Runs the check-name check first and publishes only a name it reports as available: a name reported taken is refused with exit 1, and a check that ended in an error or returned any other status is refused with exit 2. npm authenticates with NPM_TOKEN when it is set, otherwise with npm's own ~/.npmrc login; PyPI with UV_PUBLISH_TOKEN or PYPI_TOKEN when set, otherwise with the token in ~/.pypirc. With neither, the claim is refused naming both places. No token is ever printed or passed as an argument. Registry names are held forever: neither npm nor PyPI gives a claimed name back.
discover List the GitHub repositories carrying the rlsbl topic, most recently updated first, through the authenticated gh command line. With --mine, only the repositories the authenticated account owns. GitHub's search returns at most 1000 repositories, and a topic carried by more is refused rather than listed in part.
scaffold Render the release infrastructure of the member whose directory is the working directory, from rlsbl's templates and .strictmetadata/releasables/releasables.toml, and keep it current. Each target the member has (declared, or detected from its manifests) gets its CI workflow (ci.yml, or ci-.yml beside another target's), a Go module its VERSION and, for a go binary pipeline, .goreleaser.yml and version.go, an npm package its .npmignore (written once, then the project's), and an npm go-binary package its bin/index.js launcher. A releasable publishing from CI gets .github/workflows/publish.yml: the wait-for-ci job, then one job per pipeline publishing from CI, rendered without build attestations, Go proxy requests, or repository URLs where the lifecycle-and-license record or GitHub's visibility forbids them. Every member gets the .gitignore lines it lacks, the experiments/ and screenshots/ scratch directories, stub go.mod files keeping private directories out of a Go module (.strictmetadata/go.mod at the root), a LICENSE written once from the record's license in effect when rlsbl carries its text, and, while rlsbl:test-sandbox is on, the sandboxed test runner. pyproject.toml gains pytest's norecursedirs, its ignores of nested members, and hatchling's sdist exclusions. The pre-push and post-rewrite git hooks are installed where git runs them. An existing file is merged three ways against its merge base under .strictmetadata/.scaffold-bases/ (a missing base is rebuilt from the file's last rlsbl scaffold commit); a conflict is written with git's markers, left uncommitted, and fails the command. A file scaffold managed and no longer renders is removed through saferm while unmodified. Everything is planned and every refusal made before anything is written; the managed files and their hashes are recorded in .strictmetadata/.scaffold-state/scaffold-state.toml. While rlsbl:ecosystem-tagging is on, the npm and PyPI manifests gain the rlsbl keyword and the repository the rlsbl topic. A workspace's root member is not scaffolded: rlsbl monorepo sync --auto-commit generates its workflows. Scaffolding any other member of a workspace ends by regenerating the workspace's CI router and publish router from the members' workflows, as monorepo sync does, committed with the same choice as the scaffold; under --dry-run that step is named, not previewed.
watch Watch the GitHub Actions runs of a commit (HEAD when none is named), or of the runs --run-id names, until each concludes, through the authenticated gh command line. A run gh stops watching without a pass is a failure only when the run's own state says it completed; a run still going is watched again. A failed run is classified from its failing jobs' logs: a deterministic failure (tests, compilation, configuration, workflow syntax, a missing secret) is reported with the command running its failed jobs by hand, and any other failure is run again once (only its failed jobs for an infrastructure failure, where the run never executed). When the commit is a release commit of a releasable the release record holds, every release-triggered workflow of the tagged tree must show a run for its tag, or the command fails naming what it found. Runs started after the first listing are watched too. A commit on which no run appears within 120 seconds fails, naming the command to run again. Exits 0 when every run passed and 1 otherwise.
status Report on the member whose directory holds the working directory and one of its targets: the package name and version the target's manifest declares, the branch and whether the working tree is clean, the latest release in the releasable's release archives (annotated when this checkout does not contain it, when its commit is unrecoverable, and with the versions archived above it that were never released), whether the changelog holds the released file of the current version, changelog coverage of the commits since the nearest release this checkout contains (covered of those needing an entry, and how many are exempt), the member's own CI workflows and publish workflow, and a warning when commits needing an entry wait since the nearest release. A member with several targets needs --target naming one; a workspace root whose root member has no target is refused, naming rlsbl monorepo status. With --registry, the registry's package listing (npm's or PyPI's package document, or the Go module proxy's version list) is read for the latest published version and compared with the local one; no request names a single version, a registry that fails to answer is an error, and a member whose releasable publishes nothing is not asked about.
unreleased List the commits of the releasable the working directory selects since the nearest release this checkout contains, newest first, each marked COVERED when an unreleased changelog entry names it, EXEMPT when it needs no entry (it carries the Autogenerated: true trailer, or changes only rlsbl's own records), and MISSING otherwise, with the coverage count. Only commits changing a file of the releasable's members or its own records are listed, and in a fork the upstream's history is left out. The releasable's latest release is named when this checkout does not contain it. A member versioned under no releasable has no changelog and is refused.
targets List every release target rlsbl supports (go, npm, pypi) with the files its version lives in, and whether the member whose directory holds the working directory has it: the targets the member declares in .strictmetadata/releasables/releasables.toml, or, when it declares none, the ones its manifests are detected as.
commit Commit the named files through safegit with the Autogenerated: true trailer, marking the commit machine-made so changelog coverage exempts it. Files are named relative to the working directory or absolutely, and must lie in the repository holding it; a named file that did not change is left out of the commit, and a commit none of whose files changed is refused, as is a file git ignores. safegit must be on PATH.
rewrite Sweeping rewrites of the current working tree, each previewed before it is performed. Every command in this group observes the tree, reports a per-file plan with occurrence counts, and refuses to apply when a count moved between the preview and the write, naming what it already wrote.
rewrite go-module-path Rename one Go module path across the repository containing the working directory. Rewrites the module-path tokens in every go.mod (the module directive plus any require, replace, exclude, or retract reference from another module) and every Go import site under the old path, located with go/parser and rewritten at the bytes of the import's string literal, keeping its quote form. A module nested under the old path with a go.mod of its own is another module: its path and its packages' imports are left alone, since the longest declared module path owns every token and import, and it is renamed by an invocation of its own. The committed strictcli schema dump moves with it: the top-level project_id of every .strictmetadata/.cli-schema/schema.json under the old module path is rewritten too. Containment is boundary-aware, so a neighboring module whose path merely begins with the same letters is left alone. The files considered are what git ls-files --cached --others --exclude-standard lists, minus vendor/ trees and the experiments/ and screenshots/ directories at the root, so an ignored third-party clone is never touched, and neither are comments or any file but a go.mod, a Go source, or a schema dump. A rename that finds nothing to rewrite is refused, naming the module paths declared here, since the likely cause is a mistyped --from-module; a Go file that does not parse is refused, naming it. Use --dry-run to print the per-file plan with occurrence counts.
rewrite project-name Rename a standalone project's published identity. Rewrites the package name in every target manifest whose name is a manifest field (npm package.json "name", PyPI pyproject.toml [project].name), and for a Go target moves the module path to one whose last element is --to, through the same rewrite as rlsbl rewrite go-module-path. Records each changed identity as a pending identity in the lifecycle-and-license record (.strictmetadata/lifecycle-and-license/lifecycle-and-license.toml): package-name, once for each registry whose manifest carries the name (npm, pypi), and go-module-path when the latest release published another module path (read from go.mod at its release commit). Each pending identity takes effect from the version the next release ships, decided as the release decides it (the release file's bump from the version files, or the version as it is for a project that never released; from version files naming an unrecoverable version, the declared bump from it), keeps the tag namespace of the identity of its registry it replaces, and becomes a dated period when that version is released. The rename and the record are committed separately, so a crash between them is completed by running again; the record commit carries the Autogenerated trailer, and the rename commit does not, so changelog coverage asks for the breaking entry the closing message prints. Nothing else is touched: no source directory is moved, no command name (npm bin, PyPI [project.scripts]) is renamed, no registry is contacted, and no repository is renamed; the closing message lists those remaining steps in order. Refuses, before writing anything, in a workspace (naming rlsbl monorepo rename-releasable), when --from is not the name the manifests declare, when --from equals --to or --to is not a valid package name for every target, when the release file is missing, when the next version cannot be decided the way the release decides it, when a Go target's latest release is unrecoverable (the module path it published cannot be established), when another pending identity of the same facet (and registry, for a package name) is recorded, and on a dirty working tree. Use --dry-run to print the per-file plan with occurrence counts and the pending identities it would record.
rewrite uv-path-sources Convert the path- and workspace-sourced Python dependencies of the member whose directory holds the working directory into registry constraints floored at the version uv.lock resolves. Covers [project].dependencies, every [project.optional-dependencies] extra, and every PEP 735 [dependency-groups] group, keeping each entry's extras and environment marker, and deletes the matching [tool.uv.sources] entry so it stops overriding the new constraint (a list of sources conditioned on environment markers loses only its path and workspace elements). The lock is the one beside pyproject.toml, or the lock of the uv workspace root that claims the directory. Each converted name is added to the member's internal_dep_floors in .strictmetadata/releasables/releasables.toml, and the rlsbl:dep-floors option is switched on with an options entry (current and ideal error, scoped to the member in a workspace) when it is off. Each locked version is checked against PyPI's project document, which lists every published version: a version it does not list is refused, naming the fix (release that dependency first), and so is a document PyPI fails to answer. Use --dry-run to print the per-dependency plan with entry counts.
monorepo Commands on a workspace: a repository whose .strictmetadata/releasables/releasables.toml declares repository_layout = "workspace", holding several members versioned under one or more releasables
monorepo sync Regenerate the workspace's two routers in .github/workflows at the repository root. ci-router.yml inlines every member's own CI jobs (the member's .github/workflows/ci.yml and ci-*.yml) under keys and names prefixed with the member and file, and runs each member's jobs when its path filter matched the push or when the router is dispatched with run_all=true; each member's filter is derived from the workspace (its directory, its dependencies' directories, the root manifests and lockfiles, the router, and its releasable's changelog file), never declared. publish.yml inlines the publish jobs of every member whose releasable publishes from CI (the member's .github/workflows/publish.yml), each run only for its releasable's tags, behind one wait-for-ci job that holds every publish until the releasing project's CI passed on the release commit. A root member publishing from CI has its jobs rendered from scaffold's templates, as scaffold would render its publish.yml, since that file is the router itself. Jobs are inlined because GitHub refuses a workflow calling 20 or more reusable workflows. Both routers are written read-only and start with a generated-file header. A member whose Python package directory is named other than the member gets that name declared as its import_name. Removed through saferm: a generated router left with nothing to route, the publish workflows of members publishing nothing, and the per-member workflow copies earlier versions of this command wrote at the root. Everything is derived before anything is written, so a refusal changes nothing. --auto-commit commits what was written and removed, with the Autogenerated trailer.
monorepo init Write .strictmetadata/releasables/releasables.toml for a repository that declares nothing yet, as a workspace whose only member is the root member (path ".", named root), which owns every file no other member claims. --root-member states what the root member is: root-dev-node declares it dev-only and versioned under no releasable, so its files need no changelog entry; root-releasable versions it under the releasable --releasable names, created with the --tag-format and --publish-mode stated (neither has a default), and a root releasable publishing from CI states --publish-ci-check-pattern, the pattern of the check-run names the root's own CI reports, which publishing waits for. The root releasable also states --license, an SPDX identifier or proprietary (which requires GitHub to report the repository private, as rlsbl transition classify does), and the lifecycle-and-license record gains its active lifecycle period, its license period, and its releasable-name identity owning its tag namespace, from today. --release-branch names each branch a release may run from. A repository whose declarations exist is refused. The declarations and the record are committed unless --no-auto-commit is passed; when the commit fails, the files the init created are removed again through saferm and a record it changed is put back as it was, so running it again starts afresh.
monorepo add Declare the directory at (relative to the repository root) as a member of the workspace, then scaffold it as rlsbl scaffold does, which regenerates the workspace's CI router and publish router, and commit what the three wrote as one commit. The member is named --name, or its directory's name. It needs a release target: one detected from its manifests, or the one --target names, which the scaffold declares when it is not detected. --releasable names the releasable it is versioned under, or is false for none; naming a releasable the workspace does not declare creates it, and then --tag-format and --publish-mode are required, because a tag scheme and a publish mode are stated, never derived, and so is --license, an SPDX identifier or proprietary (which requires GitHub to report the repository private, as rlsbl transition classify does); all three are refused when no releasable is created. A created releasable gets its active lifecycle period, its license period, and its releasable-name identity owning its tag namespace in the lifecycle-and-license record, from today, written before the scaffold, which renders the member's LICENSE from it. --depends-on names a member it depends on (repeatable), --library and --dev-only mark it, and --registry-name is its name on the registries. Every refusal is made before anything is written. When the scaffold, the routers, or the commit fails, every path the add changed is put back as it was and nothing is committed. A path that had uncommitted changes before the add and is written by it is left uncommitted and named. --no-auto-commit leaves everything uncommitted. Under --dry-run the declarations are previewed and the scaffold is named, not previewed, since it renders from the declarations the preview did not write.
monorepo remove Delete the declaration of the member at , the path as releasables.toml writes it, character for character (any other spelling is refused, naming every member's path), and write the declarations. The member's files are left on disk and nothing is committed. Refused: the root member, the only member of a releasable (which would then release nothing), a member another member's depends_on names, and a member the lifecycle-and-license record holds an open or pending entry for.
monorepo list List every member the workspace declares, in declaration order: its name, its path, the releasable it is versioned under (empty for none), and the flags it declares (library, dev-only, test-only).
monorepo status Report on every releasable and member of the workspace. Each releasable's row holds its version file's version (empty before its first release), its latest release (annotated when this checkout does not contain it), its changelog coverage (the commits since the nearest release this checkout contains that touch its members or its records, covered/needing an entry, with the exempt ones counted apart, as rlsbl status counts them; a fork leaves out its upstream's history), and its members. Each member's row holds its targets, its version (its first target's, or its releasable's version file when that releasable publishes nothing, since nothing bumps its manifests), its releasable, its flags, and how many members it depends on and how many depend on it. A manifest the dependency graph cannot read is refused.
monorepo outdated List every dependency between members (from their pyproject.toml and package.json, and their depends_on), each with the version the depended-on member's first target has now. A versioned dependency's status is ok when its constraint admits that version, outdated when it excludes it, and versioned when the constraint is one this evaluation does not read (several clauses, an exclusion); a path, npm workspace, or explicit dependency pins no version and its status is its form.
monorepo graph Render the members' dependency graph as Graphviz DOT (--format dot: dev-only members gray, members nothing depends on green, development edges dashed, peer edges dotted, explicit edges bold) or as an indented tree (--format tree: each member with its dependencies below it, labelled [dev], [lib], and [leaf]). --root narrows it to a member and the members it depends on, --reverse to a member and the members depending on it, both at most --depth steps away when it is passed. --output writes the rendering to a file instead of printing it. The framework's --json prints the graph as a document: the members in topological order (each after the members it depends on) with their versions, targets, releasables, flags, dependencies, and dependents, and the edges in the same order. A cycle, a manifest that cannot be read, and a version that cannot be read are refused.
monorepo impact Report what a change reaches through the members' dependency graph: the members it touches, the members depending on them directly, and every member depending on them at most --depth steps away (any distance when it is not passed), the ones to test and to consider releasing. The change is named by arguments, each a member's name or a path relative to the repository root that exists here (the member owning it; a path that is one of rlsbl's own records is refused), or by --since , the files the commits since it changed. An argument that is neither a member nor a path, and one that names a member and lies in another, are refused.
monorepo check-names Check the name of every member that is not dev-only against one --target, with the verdicts of check-name. A member's registry_name is checked as declared; any other member's name is checked with --prefix before it and --suffix after it. npm and PyPI are asked through their package-level pages, waiting --delay milliseconds between names; go is judged offline. Exits 0 when every name is available, 2 when any check ended in an error, and 1 otherwise, as check-name does.
monorepo cleanup Remove the old layout's release-state residue through saferm, which keeps an audit trail and can undo a deletion: every .rlsbl/ directory (at the root, at a member's path, or anywhere a tracked file lies in one), .rlsbl-monorepo/, a versioned member's own CHANGELOG.md in a workspace (its releasable's changelog is generated under .strictmetadata/changelog/), and the changelog validation cache of a name no releasable is declared as. The changelog and release directories of an undeclared name are never removed: they are the record of what that subject released, and are listed as kept. The removal of what git tracked is committed with the Autogenerated trailer unless --no-auto-commit is passed. The residue is what the releasable-residue check reports. Under --dry-run the removals are listed and nothing is removed. It works in a standalone repository too, whose old-layout .rlsbl/ is residue as well.
monorepo rename-releasable Rename a releasable. Its [[releasables]] name and every member's releasable field are rewritten in place in releasables.toml; its changelog, release, and run-state directories move to the new name; a file at its reserved changelog validation cache path (which no rlsbl command writes) is removed through saferm when one is there; in the lifecycle-and-license record every open lifecycle period, license period, and identity of the old name is closed on today's date and opened again under the new name, the releasable-name identity taking the new name and each identity the tag namespace the new name renders; and the workspace's routers are regenerated. All of it is one commit, without the Autogenerated trailer, so changelog coverage decides whether it needs an entry, and when it does the closing message prints the rlsbl changelog add line for it. When the tag format holds {name}, the tags change spelling: each past release's archive records the tag it shipped under (shipped_as) where that tag stands at its release commit, committed with the Autogenerated trailer; and the current version's tag under the new name is created at the commit of its tag under the old name, recorded as a boundary alias in the transition record, and pushed to origin, the one remote write. Refused before anything is written: a new name that is declared, a member's name, or holds another subject's state; uncommitted changes; a release or batch release in progress; a batch release file naming the old releasable; a record without the releasable's open releasable-name identity, or with a pending identity of it. A run whose declarations already name the new releasable and not the old completes an interrupted rename, doing only what is left. Use --dry-run to print the plan.
monorepo extract Move the releasable <releasable_name> out of this workspace into a new repository created at <target_path>, which must not exist. Its members' history is rewritten by git-filter-repo on a fresh clone, a lone member hoisted to the repository root, and each member's tree must be the tree that left. Its changelog and release records move to the new repository's .strictmetadata/, every commit id and release commit mapped through git-filter-repo's commit map (a changelog entry none of whose commits carried over is dropped, a release commit the rewrite did not carry is left as recorded, and each is named); a recorded tree the rewrite changed stops the extract. Its tags take the new repository's scheme (v{version} for a lone member, its own tag format otherwise), the current version keeping its old name beside the new one, and another releasable's tags are deleted there. The new repository gets its declarations, a lifecycle-and-license record holding every entry of the departing subjects, and a transition record explaining the conversion, each committed. This repository then loses the members, the releasable, and its records in one commit: the departure of its tag namespace is recorded, the departing subjects' open periods and identities are closed in its lifecycle-and-license record, the departing package names join the internal_dep_floors of every member of a releasable that stays (rlsbl:dep-floors switched on where it is off), and the routers are regenerated; a failure before that commit puts every path back as it was. Deletions go through saferm unless --delete-with-rm is passed. Refused before anything is written: a releasable holding the root member; a member nested in a departing one that stays; a departing member with nothing tracked or holding a submodule; a target that exists; a missing git-filter-repo or saferm; uncommitted changes; a release in flight; options entries scoped to a departing member; a member that stays depending on one that leaves (each dependency named with the edit that severs it); a renamed tag colliding with another tag; and declarations or records the result would leave invalid. Nothing is pushed and no remote is created. Use --dry-run to print the plan.
monorepo absorb Bring the repository at <source_repo> into this workspace as a member at <dest_path>. Its history is rewritten under that path by git-filter-repo on a clone inside this repository's git directory, fetched without tags, and merged; the merge carries trailers naming the member, the source's root commit, and the releasable, by which a run completing an interrupted absorb finds it. Its version tags are created under the releasable's scheme at the rewritten commits, and its tag of the current version beside them under its own name; no tag here is ever moved or deleted. Its records move into the new layout: its changelog and release archives into the releasable's directories, every commit id and release commit mapped through the commit map and every recorded tree checked at the new commit; its lifecycle-and-license entries into this repository's record under the member's name, with the source recorded as the member's closed repository-url identity; and its transition record's events into this repository's, scoped to the releasable. The arriving changelog and release directories and its CHANGELOG.md are then deleted (through saferm unless --delete-with-rm is passed); the rest of its .strictmetadata/ is residue rlsbl monorepo cleanup removes. The member is declared and scaffolded (which regenerates the routers), and the absorb is recorded in the transition record, each committed. --releasable joins a declared releasable; without it a releasable named after the member is created with the stated --tag-format and --license (an SPDX identifier, or proprietary, which requires GitHub to report this repository private), and with --publish-mode when the source declares none (the source's own otherwise); --license is refused with --releasable. The created releasable keeps the arriving lifecycle period and license period when the source's record holds them (a different --license is refused), and is given otherwise an active lifecycle period and a license period under --license from today, and its releasable-name identity under its own name. The source may be a standalone project in the new layout or declare nothing; one in the old layout or a workspace is refused. Refused before anything is written: a dirty source or workspace, a destination path taken, a name taken, a tag or version this workspace holds already, a version the source cannot state, and records the result would leave invalid. The source repository is never changed, and nothing is pushed. Use --dry-run to print the plan.
monorepo release Release several releasables of a workspace as one batch: write the batch release file, release what it names, and report the order a batch releases them in.
monorepo release run Release the releasables the batch release file .strictmetadata/batch-releases/unreleased.toml names, one [releasables.] table each with the fields of a release file, in the order rlsbl monorepo release order reports: each after the releasables its members depend on. The whole batch runs in the release checkout, as rlsbl release run --watch does: an uncommitted change to a path the batch writes refuses it, naming the path, and every other uncommitted change is listed and left alone. The first run validates every releasable before anything is written and plans the batch (the version and tag each one ships) in .strictmetadata/.release-state/batch-plan.toml. Each releasable is then released up to its release commit; the branch tip, holding every release commit, is pushed untagged as one candidate, and CI's verdict on it is awaited once, every releasable's own CI jobs required to have run. Only when CI passes is each releasable finished on that commit: its changelog finalized, its release archived, tagged, pushed, its GitHub Release created, its local pipelines published, its deploy_command run, and its post-release hooks run. A run after a stop follows the plan: a releasable released already is skipped, one committed short of the CI verdict joins the new candidate (a red verdict is fixed forward on the branch and continued at the same versions), and one past the verdict is refused, naming rlsbl release resume --watch. The run releasing the last releasable archives the batch release file as .strictmetadata/batch-releases/batch-.toml, commits and pushes it, and removes the plan. A root member versioned under no releasable that declares selfdoc.json gets selfdoc gen and selfdoc check first. --watch and --no-watch apply to each releasable as in rlsbl release run --watch. --dry-run validates every releasable and reports the versions, tags, and order, writing nothing.
monorepo release init Write the batch release file .strictmetadata/batch-releases/unreleased.toml with one [releasables.] table per releasable (the ones --releasables names, once per releasable, or, with --all, every declared one; one of the two is required, so no set of releasables is inferred), each with bump and description blank (a batch release refuses it until both are filled in), context blank, every target of the releasable's members in include, and exclude empty, and commit it. A releasable with no commit needing a changelog entry since its latest release is written commented out, and one with no target, or on hold or retired (it or a member), is refused, naming --releasables to leave it out. A batch release file nobody filled in yet is left as it is; one somebody filled in is refused, never overwritten.
monorepo release order Report every member of the workspace, each after the members it depends on (its manifests' dependencies on other members and its depends_on), and every releasable in the order a batch release releases them: by the latest position any of its members takes, ties by name. A manifest that cannot be read and a dependency cycle are refused.
dev Developer commands for working on rlsbl projects locally: install a project the way its release targets install it (pypi with uv tool install -e, npm with npm link, go with go install), and overlay editable checkouts of sibling projects onto a member's locked Python environment.
dev install Install the project for local development by running each of its targets' own install command from the target's directory. --target names the install mode: global installs onto the machine (pypi with uv tool install -e ., npm with npm link, go with go install of the main packages the go pipeline's install_paths declares), venv into the project's own environment (pypi with uv sync --all-packages, npm with npm install); a target with nothing to run in the chosen mode is skipped with the reason. --uninstall reverses a global install where the target has a reverse (npm with npm unlink, pypi with uv tool uninstall <package name>). In a workspace, --all installs every member that is not dev-only, --include only the members it names (a dev-only member included), and --exclude every member that is not dev-only but the ones it names; a standalone repository takes none of them. Every command is planned before any runs, and a missing program, a Go module with main packages but no install_paths, or a member name the workspace does not declare refuses the run before anything is installed. A command that fails does not stop the others, and the run exits 1 once all were tried.
dev sync Overlay editable checkouts of sibling projects onto the locked environment of the member whose directory holds the working directory. Reads the member's dev-sources.toml.local-only, one [[overlay]] table (package, path) per checkout, and refuses an unknown key, a checkout that does not exist or whose [project].name differs from the package, a package a workspace member builds, and a checkout inside this repository. Then runs one uv sync --inexact leaving out every overlaid package and one uv pip install -e <checkout> per overlay, with VIRTUAL_ENV set to the member's environment, and records the overlays in dev-overlays-state.toml.local-only, which rlsbl dev status and the dev-overlay-drift check read. Requires UV_NO_SYNC=1 in the environment, so a bare uv run does not sync the overlays away. A workspace's root member is refused: run it from a member's directory.
dev status Report the dev overlays of the member whose directory holds the working directory: each package dev-overlays-state.toml.local-only records, with its checkout and version, against what the member's environment holds (editable at the recorded checkout, WIPED back to a registry wheel, or MISSING). Exits 1 when any overlay drifted, so scripts and pre-run guards see a wipe by a bare uv sync or uv run; exits 0 when every overlay is intact or none is recorded. A recorded overlay rlsbl dev sync would refuse (a workspace member's package, a checkout inside this repository) is refused here too.
upstream Manage a fork's relation to its upstream, which the fork declares in .strictmetadata/upstream/upstream.toml (host, owner, repo, and branch; a repository without that file is not a fork, and no upstream is ever inferred from a git remote). Changelog coverage in a fork leaves out every commit reachable from the upstream branch, fetched into refs/upstream////, and from the inherited tags kept under refs/tags-of////.
upstream adopt-tags Move the tags this fork inherited from its upstream out of refs/tags. A tag is inherited when the upstream (https:////, asked with git ls-remote, never through a package registry) has a tag of the same name at the same object. Each inherited tag moves, keeping its object, to refs/tags-of////: the ref is written here, one push creates it on origin and a second push deletes the tag from origin's refs/tags, each guarded by the object observed, and then the tag is deleted from refs/tags here. Every push carries one ref, because GitHub fires no events for a push deleting four or more tags, and tags move one after another, so a run that stops part-way leaves every earlier tag finished. Tags the upstream does not have are left alone. A tag carrying an inherited tag's name at a different object (here, on origin, or against a kept ref), and an inherited tag whose object is only on origin, refuse the whole run, named, before anything is written. A run after an interrupted one finishes it, and otherwise has nothing to do. Use --dry-run to print the plan and write nothing.
secrets Keep the Actions secrets CI publishes with in step with the credentials on this machine. No secret value is ever printed or passed as an argument.
secrets sync-npm-token Copy the npm token in ~/.npmrc (the //registry.npmjs.org/:_authToken= line) into the NPM_TOKEN Actions secret of repositories that already have one. Refuses when ~/.npmrc holds no token, and when npm does not accept it (asked with GET https://registry.npmjs.org/-/whoami); otherwise prints the npm user it authenticates. Without --all the target is the repository the working directory's origin remote names, and a repository without an NPM_TOKEN secret is refused: the secret is never created. With --all the targets are every repository the authenticated gh account can see (its own and those of every organization it belongs to) that has an NPM_TOKEN secret; archived repositories are named and skipped, since GitHub refuses writes to them. Every target's secret is read before any is written. Each target is set with gh secret set NPM_TOKEN --repo <owner/repo>, the token piped on stdin, and printed with its outcome. Exits 1 when any target could not be read or set, once every other target was tried. The token is never printed.
options Print rlsbl's options registry, and write this repository's entries for rlsbl's options in .strictmetadata/options/ at its git root. Every rlsbl check is an option, rlsbl:, ranked error > warn > off for an error check and warn > off for a warning check, and a few options are not checks; rlsbl options registry lists every one. rlsbl check and every release apply each entry's current value (off: the check does not run; warn: it runs and reports, never blocking; error: as registered). The options representing an adoption default to off and accept a path scope naming one workspace member. Invalid entries stop every check run and every release.
options registry Print rlsbl's options registry: one option per check, rlsbl:, plus the options that are not checks, each with the subject file its entries are filed under, the values it ranks strongest first, its default, its scope type, the options it requires, and what it governs. The TOML printed is the registry document rlsbl ships, in the shape of strictspec's built-in options-registry schema; with --json the same declarations are the payload. Needs no rlsbl project.
options set Write one rlsbl entry into .strictmetadata/options/.toml at the repository's git root, or update the entry already there for the same option and scope, keeping every other line of the document. The subject file is the one the option's registry declaration names. Creates .strictmetadata/options/ and its manifest.toml, naming strictspec as the owner, when absent. strictspec validates the result before anything is written (every document's shape, and every rlsbl entry with this one in place), and each refusal is its catalogued diagnostic: an unknown option, a value the option does not declare, a current ranked above the ideal, an entry equal to the default, an empty reason, an option switched off while an option requiring it is not. A --scope must name a workspace member's path as .strictmetadata/releasables/releasables.toml declares it, and only the options declaring the path scope type accept one. Refuses an id outside the rlsbl namespace and a manifest naming another owner. The written files are committed with the Autogenerated trailer unless --no-auto-commit is passed.
release Make releases of a releasable and act on the releases it has made.
release run Release the releasable the working directory selects (or --releasable, where the directory selects none): bump its version from the bump in .strictmetadata/releases//unreleased.toml, commit, push the commit untagged to the release branch as the candidate, wait for CI's verdict on it, and only when CI passes finalize the changelog, archive the release with the commit CI verified, tag that commit, push, create the GitHub Release, publish the local pipelines, run the deploy_command of a server releasable, and run the post-release hooks. Everything before the push runs in the release checkout, a detached checkout of the branch's committed tip under the repository's git directory: the pre-checks hooks, the strictcli help document of every member that is a strictcli program, selfdoc gen and selfdoc check told the version being released (what selfdoc writes is committed as the release's own commit), the preflight checks, the pre-release hooks, the version writes, the build, and its scans. The branch advances only by compare-and-swap from where the release left it, and only the files the release's commits change are written into the working tree; an uncommitted change to one of them refuses the release, and every other uncommitted change is listed and left alone. Commits on the branch the release did not make are refused when it starts mutating, before the candidate push, after the CI verdict, and before the final push. A stop before the candidate push discards the attempt; a stop after it keeps the state in .strictmetadata/.release-state//in-progress.toml for rlsbl release resume --watch, and a red CI verdict is fixed forward on the branch and resumed at the same version. --watch watches CI on the release commit afterwards and asks the npm and PyPI package listings whether they list the version; --no-watch says the outcome was not verified. --dry-run validates, runs the changelog checks and the pure preflight checks, records every program and write up to the release commit, and names the steps after it.
release resume Continue the release in progress of the releasable the working directory selects (from a member versioned under none, the one releasable with a release in progress) from the step it stopped at. The resume pins again at the branch tip: what was committed since the release stopped (the fix after a red CI verdict, other sessions' work) is adopted, provided the changelog describes every adopted commit, and refused otherwise before anything is written, naming the rlsbl changelog add that records each. A resume adopting commits after the CI verdict pushes the tip as the candidate and waits for CI again, so the tag is put on what CI verified; one adopting nothing past the verdict tags the commit the state records, which must still be on the branch. Once the changelog is finalized, nothing is adopted any more: the resume finishes the release on the candidate the state records, and the commits made since it stopped stay on the branch, named, for the next release. A failed deploy or post-release hook runs again. Before anything is written, the resume is refused what release validation refuses about the lifecycle and publishing: a releasable or member on hold or retired, a repository whose visibility disagrees with the lifecycle-and-license record, an output the publishing rules forbid, and a deploy command on a releasable that may not deploy. --dry-run reports what would be adopted and which steps would run, and writes nothing.
release init Write the release file of the releasable the working directory selects, .strictmetadata/releases//unreleased.toml, with bump and description blank (a release refuses it until both are filled in), context blank, every target of the releasable's members in include, and exclude empty, and commit it. A release file nobody filled in yet is left as it is; one somebody filled in is refused, never overwritten. A member versioned under no releasable is refused, naming rlsbl monorepo release init --all (or --releasables once per releasable) for a batch release.
release edit Rewrite one released version's GitHub Release in place from the record: the notes its changelog holds, the deprecate and yank notices its release archive records on top, the rlsbl-ci-sha marker naming the commit it shipped from, and the pre-release flag, set when a notice is recorded and cleared otherwise. The version defaults to the latest release the archives record. A version the record holds no release of, and one without a GitHub Release, are refused before anything is written. Run from the directory of a member versioned under the releasable.
release retry Dispatch the workflows of the releasable's latest release again at its tag, for a release whose GitHub Release exists while its publish runs never started or failed for reasons outside it. A retry publishes again, so before anything is written or dispatched it is refused what release validation refuses: a releasable or member on hold or retired, a repository whose visibility disagrees with the lifecycle-and-license record, and an output the publishing rules forbid in the declarations or the tagged tree's workflows. What is dispatched is the retry file, .strictmetadata/.release-state//retry.toml: ref, which must be the release tag (any other ref is refused, since a run there cannot be tied to the release), and the workflow files. A missing retry file is written first, naming every workflow of the tagged tree with a workflow_dispatch trigger. A workflow declaring a tag input is passed the tag. Workflows are dispatched one at a time; a dispatch that fails stops the run with the retry file rewritten to hold the workflows not yet dispatched, so running retry again never dispatches one twice, and the file is removed once every workflow was dispatched. --watch then watches the dispatched runs until they conclude and exits 1 unless every one passed; --no-watch prints the command that watches them.
release undo Revert a release. Without --version, the releasable's latest release (the highest version its release archives record as released): delete its GitHub Release and its tags here and on origin, revert its version-bump commit, restore its changelog entries to the unreleased file and its archive as the release file, regenerate the changelog, commit that as one commit, and push the release branch. With --version, an earlier release: its GitHub Release and tags are deleted and its changelog and release file restored, and its commits are not reverted, since history moved on. Either way the version must be provably unpublished: among the registries' package listings (no request names one version), origin's Go module tags, and the runs of the publish workflows its tag started, a source must have observed the version's absence and none may have seen it published or be publishing it; otherwise the undo is refused, naming release yank and release deprecate. A release in progress that the archives do not record refuses the undo, naming release resume and release abandon. Every refusal comes before anything is written, a conflict of the revert with later work included; an audit line recording the plan and its evidence is appended to .strictmetadata/releases//undo-audits.jsonl and committed before the first deletion. --dry-run prints the plan and writes nothing.
release abandon Record an abandoned release attempt's version as never released: write its release archive with never_released = true, commit it with the Autogenerated trailer, and delete the in-progress state file, as one operation. The version comes from the in-progress state, or from the version files when there is none. The attempt's commits are not reverted: the next release bumps from the abandoned version when the version files name it, and never releases under it again. When the in-progress state names a version already recorded never released, an earlier abandon stopped part-way, and this one finishes it. Refused before anything is written: nothing to abandon (no in-progress state, and the version files name a version the record already holds), a version already archived as a release, a version below the latest release, the attempt's tag here or on origin (a tag is evidence of a release, which release backfill adopts), and a GitHub Release under the attempt's tag (a published release, which release undo reverts). A question that cannot be answered is refused, never read as absence.
release deprecate Mark a release deprecated, the latest one included. The notice ("> Deprecated: . Use v instead.") is recorded first in the version's release archive (release_notices) and committed, so every later rewrite of the Release keeps it; the GitHub Release is then rewritten from the record with the notice on top and the pre-release flag set. A version the record holds no release of and one without a GitHub Release are refused before anything is written. --use must name a version the record holds as released.
release yank Remove a published version, the latest one included, from its registries the way each allows, and mark its GitHub Release. Each package of the releasable's members is looked up in its registry's listing (npm's package document, PyPI's project document, origin's Go module tag and the module proxy's version list; no request names one version), and a package whose publication cannot be read refuses the yank before anything is written. For each published package: npm deprecates the version (npm deprecate, run in the package's directory); a Go module gains a retract directive in its go.mod, committed, which the next release publishes; PyPI, which offers no yank to a tool, is yanked by hand, and the command exits 1 naming the steps until PyPI's project document shows the release's files yanked. Nothing is ever unpublished. The yank notice is recorded in the version's release archive and committed, and the Release rewritten from the record with the notice on top, marked pre-release. A releasable whose current license is proprietary is refused before anything is read, since no registry write is made for it; release deprecate marks its Release without one. A version the record holds no release of and one without a GitHub Release are refused before anything is written.
release scrub Scrub content from the repository's git history through safegit, then repair every record the rewrite renamed and publish the result. --pattern rewrites every match of a regular expression (each replaced by --replace, or by random text of the same length under --mangle), --file rewrites one repository-relative file (every past version of it replaced by the copy on disk now, or removed from every commit when there is no copy on disk), and --recipe runs a safegit scrub recipe; --from-commit or --entire-history says how much of the history is rewritten. safegit remaps the changelog's commit ids at every rewritten commit; the scrub then requires every changelog commit id that named a commit the rewrite replaced to name its replacement (remapping it through the rewrite's commit map, or safegit's rewrite journal, where it can), and lists, without refusing, the ids that named no commit before the rewrite either (left by an earlier rewrite); it then moves each archive's release commit through the rewrite (recording the rewritten trees, and a release-commit remap in the transition record), requires every generated changelog to be what generating it gives, deletes any file at the reserved changelog validation cache paths (which no rlsbl command writes), writes the rewrite's archive to .strictmetadata/history-rewrites/.toml (commit ids, tags, the mode, and --reason only, never what was removed), and commits. It then force-pushes the branch and every moved tag, each guarded by the value origin held before the rewrite, and rewrites each moved tag's GitHub Release in place from the record (creating one a tag lacks; a version released before its releasable kept a changelog file keeps its Release's own notes, with the marker moved to the rewritten release commit); a Release is never deleted. A scrub that stops is finished by running the same command again, from the step that stopped it, with its state kept in .strictmetadata/.release-state/scrub-result.json. Requires safegit 0.31.1 or newer, whose --json machine-mode document is read at interface_version 3, and a release branch. Refused while a release is stopped mid-flight. --dry-run records the safegit invocation, which prints safegit's own counts, and writes nothing.
release reconcile Reconcile one releasable's published release metadata with what its records say it released: push the refs origin lacks, force-push the ones a recorded rewrite moved, and create the GitHub Releases that are absent. Every archived version's refs (its primary tag, its companion tags, its recorded aliases) and its GitHub Release are judged, and so is every local tag origin holds that no archive claims: materialize, already-correct, re-point-with-lease (origin holds a commit safegit's rewrite journal, a release-commit remap in the transition record, or a committed history-rewrite archive maps to this one), refuse-foreign (nothing explains what origin holds), or refuse-identity-mismatch (a Go tag of a version released under a module path or repository the lifecycle-and-license record has since closed). Tags the lifecycle-and-license record keeps outside the version model, and tags a closed identity owned, are not judged. One refusal aborts the whole reconcile, and nothing anywhere is written. Archives naming a commit the repository no longer has are first moved through the records that explain the move, and committed; one nothing explains is refused. --mode plan writes the plan to .strictmetadata/.release-state//reconcile-plan.toml (an empty plan included); --mode apply observes again, refuses when origin or the Release listing moved since the plan, or when the observation names work the plan does not, and performs the plan. --releasable names the releasable where the working directory selects none, and is refused where it selects one.
release backfill Bring every releasable's release archives into the fate model from the repository's own history: record each version's release commit from its tag (or the spelling its archive names in shipped_as, or its version-bump commit), complete an archive whose required fields are missing or blank, materialize an archive for a released version that has none, and adopt a version tag no record names as the release it is evidence of. A version with no tag and no version-bump commit is recorded unrecoverable; a version never released is declared by writing its archive with never_released = true first, which the backfill then leaves alone. A reconstructed description comes from the first source that yields one: --overrides (one [versions."X.Y.Z"] table per version, with description and an optional context), the version's GitHub Release body, its CHANGELOG.md section, the commit subjects in its tag range, and otherwise a placeholder naming the obligation; each written field names its source. Every tag nothing accounts for (no archive or changelog file, no unversioned tag or closed identity in the lifecycle-and-license record) is listed first and refuses the whole apply, as does a stash. --dry-run prints the plan and writes nothing, exiting 1 when an unexplained tag would refuse the apply.
migrate The one-time conversion of a repository's rlsbl records from the layout the Python rlsbl wrote (.rlsbl/, .rlsbl-monorepo/, and each member's .rlsbl/) to the .strictmetadata/ layout this rlsbl reads.
migrate records Convert the rlsbl records of the repository holding the working directory to the .strictmetadata/ layout, once: the release declarations (releasables.toml, from every config.json, workspace.toml, and releasable.toml), the test runner's settings, each releasable's changelog (every line restamped to format_version 2, an id minted where a line has none, a batch exclusion moved onto the entry it exempts as batch_reason), its release file, archives, version file, and undo audits (a pre-release of the dropped pre-release channel, MAJOR.MINOR.PATCH-alpha.N, -beta.N, or -rc.N, is folded: its changelog entries move into the file of the stable version it preceded when that version was released, and into the unreleased file otherwise, an entry naming the commits a kept entry names left out, its archive is not carried, and its tag, when this repository holds it, is recorded as an unversioned tag; a retired subject's pre-releases are refused, naming the hand edit), the batch release files, the transition record's surgery events, the scaffold state and merge bases, customized hook scripts (each moved to .strictmetadata/release-hooks/ and run by a hooks declaration), .strictmetadata/go.mod, the options entries of renamed options and of those that moved to strictcode, and strictcode.toml (the tool declarations, the library lint lists, the dead-modules suppressions, and ruff-lint's coverage as strictcode's lint rule). It writes the lifecycle-and-license record: one active lifecycle period and one license period per releasable from today, a retired period per closed release history (whose release state moves to .strictmetadata/retired-release-histories/), the releasable-name identities with their renames, the identity transitions (pending until their version is released), the registry names of every releasable and retired subject that published from CI (a retired subject's read at its release commits), and the unversioned tags. A license comes from the package manifests or from --licenses, never from a LICENSE file. GitHub's visibility of the repository is read with gh. Every refusal is reported before anything is written, each naming its fix: a value the new layout has no place for, a target other than go, npm, and pypi, a retired key, a missing license, a private repository with no releasable declared proprietary, configs disagreeing on a repository-wide value, a release or history rewrite in progress, a file of the old layout it does not recognize, and an uncommitted change to any path it writes or removes. Otherwise it writes the new records, regenerates the changelogs, removes the old layout through saferm, and commits the paths it wrote or removed, and no others, with the Autogenerated trailer; uncommitted changes to other paths are listed and left alone. A repository already converted has nothing to do. Use --dry-run to print, per file, the source, the destination, and the change, and every refusal, writing nothing.
changelog The structured changelog: one JSONL file of entries per release of each releasable, under .strictmetadata/changelog// (unreleased.jsonl, and a read-only .jsonl per release), each entry typed feature, fix, or breaking and naming the commits it describes, with CHANGELOG.md generated from them. Every command acts on the releasable of the member whose directory holds the working directory, except remap, which rewrites every changelog of the repository.
changelog add Append an entry to the releasable's unreleased.jsonl and commit it. Each commit must resolve and change a file the releasable's changelog covers (its members' files and its own state directories). A user-facing entry (the default) needs --description and --type; --no-user-facing marks an internal change that CHANGELOG.md and the release notes leave out. A commit an entry of the same type and user-facing value already names is refused. An entry naming more than 5 commits needs --batch-reason saying why they are one change, which the entry carries and which exempts it from the per-entry limit; an entry within the limit refuses one. A commit appears in at most 5 entries. In a workspace the entry records the releasable's members the commits change. Committed unless --no-auto-commit.
changelog generate Write the releasable's CHANGELOG.md from its changelog files and release archives (the root CHANGELOG.md standalone; .strictmetadata/changelog//CHANGELOG.md and the root roll-up in a workspace), and commit what changed with the Autogenerated trailer unless --no-auto-commit. --dry-run prints the releasable's document and writes nothing.
changelog amend Append an entry to a released version's read-only .jsonl, which stays read-only, regenerate CHANGELOG.md, commit both, and rewrite that version's GitHub Release from the record (as rlsbl release edit <version> does). The entry's checks are changelog add's; --no-validate-hashes takes the commits as given, unresolved and unchecked against the scope, for an old commit a rewrite took away. A version without a release archive, or with one stating no fate, is refused before anything is written; a version recorded never released has no GitHub Release to rewrite.
changelog edit Change one existing entry, in unreleased.jsonl or a released version's file, as a sparse update: --description, --type, and --user-facing write those fields, --unset-description and --unset-type clear them, and every field not named is left as it is. The entry is named by --id or by --commits (an entry naming any of them); several entries matching --commits are narrowed to those of the type the edit writes, and otherwise refused with every match named. Making an entry user-facing needs a description and a type, held already or written by the same edit; making it internal keeps its description and type for a later flip back unless the edit clears them. An edit of a released version's file keeps it read-only, regenerates CHANGELOG.md, and rewrites that version's GitHub Release from the record. Committed unless --no-auto-commit.
changelog remove Delete one entry from unreleased.jsonl or a released version's file, named by --id or by --commits (a commit that no longer resolves is matched as written, since the entry of a commit a rewrite took away is the usual one to remove). A selector matching several entries is refused with every match named, and one matching none is refused. Removing from a released version's file keeps it read-only, regenerates CHANGELOG.md, and rewrites that version's GitHub Release from the record. Committed unless --no-auto-commit.
changelog remap Rewrite stale commit ids in every changelog file of the repository (every releasable's, and every retired release history's) through a map of old commits to new ones, read from --map-file (lines of " ", as git's post-rewrite hook prints them), from safegit's rewrite journal (--from-journal: its last rewrite), or from standard input (--stdin, which the post-rewrite hook uses); sources may be combined, and an old commit two sources map differently is refused. Every other line keeps its bytes and a released file stays read-only; an entry whose commits map to one commit names it once. An abbreviated id the map cannot decide is refused before anything is written. What was rewritten is committed with the Autogenerated trailer.
transition The lifecycle-and-license record, .strictmetadata/lifecycle-and-license/lifecycle-and-license.toml: each releasable's dated lifecycle (active, on-hold, retired), license, and identity periods, the registry names it holds, the tags that release no version: license and lifecycle facts only (confidential terms live in the confidential-term list, which a release scans for). A repository is confidential while a releasable's license is proprietary. A command that opens a period closes the subject's period in effect on today's date first. A command that changes the record validates the whole record against the declarations, writes it, and commits it with the Autogenerated trailer, together with whatever else its own help says it changes.
transition show Print the lifecycle-and-license record and every rule's verdict on today's date: whether the repository's GitHub visibility agrees with its licenses (GitHub is asked; a visibility that cannot be had is unknown, with the reason), which releasables are refused public outputs, what a confidential or private repository does not publish, which releasables a lifecycle keeps from releasing, the proprietary periods a declassification squashes, the closed identities owning tags, and whether the record keeps every registry name and closed period the record HEAD commits holds. Problems that make the record invalid against the declarations are listed. Works in a repository without declarations or without a record.
transition lifecycle Close a releasable's or member's lifecycle period on today's date and open one with --status: active, on-hold (its releases are refused), or retired (its release history is a record). The subject is declared, or one the record already holds a lifecycle period of. A status the subject already has is refused.
transition license Close a releasable's license period on today's date and open one under --license, an SPDX identifier. proprietary is refused (transition classify makes a releasable proprietary), and so is a change that would leave no releasable proprietary (transition declassify takes the repository public, squashing its proprietary history first). A license the releasable already has is refused.
transition identity Record an identity period of a subject: its releasable name, package name, project name, Go module path, tag format, or repository URL (--facet), with --value, the registry it is a name in (--registry; none for an identity that is not a registry name; a package name is a name in one registry, so its identity names one and is kept per registry), and the tag namespace it owned (--tag-pattern, repeatable, globs where * matches any run of characters). Without --from the identity starts today and the subject's open identity of the facet (in the same registry, for a package name) closes today; with --from (and --until for a closed one) it is recorded as given, a dead identity's included, whose tags are then accounted for instead of reported.
transition unversioned-tag Record a tag that releases no version (a nightly marker, an imported vendor tag), so release backfill, release reconcile, and the tag checks account for it instead of reporting it. A tag the record holds already is refused.
transition classify Make a releasable proprietary: open a proprietary license period for it on today's date, which makes the repository confidential and from then on refuses every public output of the releasable (registry packages and writes, build attestations, public docs, blog posts). GitHub must already report the repository private; making it private is the owner's action outside rlsbl, and a public repository is refused naming it. Run the quiet deprecate and yank of anything it published before classifying it: afterwards no registry write is made for it.
transition declassify Take a confidential repository public. --license names, for every releasable whose license is proprietary and no other, the public license it takes. In order: each proprietary period's commits on the release branch's first-parent history (by committer date) are squashed through safegit scrub squash into one commit carrying a fixed message that names no confidential term, keeping the public history before and after it (a period whose commits are not contiguous, or that holds a merge commit, is refused before anything is rewritten, naming the commit); the changelog's commit ids and the archives' release commits are repaired through the rewrite, and a release whose commit was folded into a commit carrying another tree is recorded unrecoverable; the proprietary licenses close today and the --license ones open; a history-rewrite archive records the run; all of it is committed; the branch and every moved tag are force-pushed with leases taken before the first squash; each moved tag's GitHub Release is rewritten from the record; and the GitHub repository is made public. The closing report lists the commits of the proprietary periods origin held before the first squash, which GitHub keeps serving by their ids after the force-push and only GitHub support can purge (the owner asks for it at the declassification); a ref origin holds at a commit this repository lacks is refused before anything is rewritten, naming the fetch. A run that stops is finished by running the same command again, from the step that stopped it (.strictmetadata/.release-state/declassify-result.json). Refused while a release or a scrub is in progress, off a release branch, without an origin remote, and when --reason carries a term of the confidential-term list. Requires safegit 0.31.1 or newer. --dry-run prints the squashes and writes nothing.
transition init-minimal-record Write the minimal lifecycle-and-license record (its format version alone, with the manifest naming strictspec) into a repository rlsbl does not manage, and commit both with the Autogenerated trailer. With no license period the repository is public, so what it publishes is scanned for confidential terms. A repository holding release declarations or an old-layout directory is rlsbl's, and is refused; so is one that already holds a record.
confidential Resolve the confidential-term hits of what this repository publishes. The confidential-term list (Projects/CONTEXT/confidential/terms.toml.age under the home directory, encrypted with age to the identity in ~/.age-key.txt, and decrypted in memory only; a missing list has no terms) names terms that must not appear in anything public. A release (and selfdoc's publishing) scans what it would push or publish, reports every hit at once, each with an id, its location, and the text around it, and refuses while a hit has no resolution in .strictmetadata/confidential-hits/resolutions.toml. Each hit is judged by the agent: one it is at least 70% certain is a false positive is recorded with judge-false-positive; every other one goes to the owner, who has the text fixed or approves publishing it with approve-hit. The release records the resolutions it used in the version's release archive. A hit id names the list entry, the matched text, and the words after it, so the same phrase has the same id wherever it appears, and a resolution resolves it everywhere. The confidential-terms check reports the hits of the next push.
confidential judge-false-positive Record the agent's judgment that a confidential-term hit is a false positive, in .strictmetadata/confidential-hits/resolutions.toml, and commit it with the Autogenerated trailer. --hit names a hit the next push or a tracked file of this repository carries (the ids the confidential-terms check and the release's refusal print); any other id is refused, naming the hits there are. --certainty is how certain, in percent, the agent is that the hit is not confidential, from 70 to 100: a hit judged less certain is the owner's to resolve, with approve-hit or a fix. --reason is one line, and is refused when it carries a confidential term, since the file is committed. The recorded location has every confidential match redacted. A hit resolved already is refused.
confidential approve-hit Record the owner's approval to publish a confidential-term hit anyway, in .strictmetadata/confidential-hits/resolutions.toml, and commit it with the Autogenerated trailer. Only the owner approves a hit: pass --approve-consequential only when the owner approved this specific hit, and give the owner's own reason in --reason (one line, refused when it carries a confidential term, since the file is committed). --hit names a hit the next push or a tracked file of this repository carries; any other id is refused, naming the hits there are. A hit resolved already is refused.

Framework flags on every command: --help, --version, --dry-run, --approve-consequential, --quiet, and --verbose, plus --json on every command that declares a payload. --dry-run records a mutating command's writes and programs as a would-do log instead of performing them. A consequential command asks for confirmation before it runs; --approve-consequential gives it, and without a terminal to ask on the missing confirmation is an error.

Declarations

A repository declares what it is in .strictmetadata/releasables/releasables.toml: its layout (standalone or workspace), its release branches, its releasables (each with a name, a tag format, and a publish mode), and its members with their targets, publish pipelines, hooks, and checks. Nothing a release depends on is derived where it could be declared. Behavior switches are options under .strictmetadata/options/, and every other record rlsbl keeps (changelogs, release archives, the transition record, scaffold state, run state) lives in its own directory under .strictmetadata/. See declarations and the on-disk layout.

Release flow

rlsbl release run --watch reads the releasable's release file, .strictmetadata/releases/<releasable>/unreleased.toml (the bump, the description, the targets), and then:

  1. validates everything before writing anything: the changelog, the lifecycle-and-license record, the publishing rules of the repository's visibility, the version decision, gh and push access, the release branch, and every uncommitted change to a path the release writes;
  2. enters the release checkout, a detached checkout of the branch tip under .git/rlsbl/release-checkout, where everything below runs;
  3. runs the pre-checks hooks, the strictcli schema dump, selfdoc gen and selfdoc check, the preflight checks, the built-in tests, and the pre-release hooks;
  4. writes the new version, builds, scans the artifacts for secrets, checks every packed file comes from the releasable's members, commits, and pushes the commit untagged: the release candidate;
  5. waits for the repository's own CI to conclude on that commit;
  6. finalizes the changelog, archives the release with the commit it shipped from, tags the CI-verified commit, pushes, and creates the GitHub Release;
  7. publishes the pipelines that publish from this machine (CI pipelines publish from the workflow the Release starts), runs a server releasable's deploy command, and runs the post-release hooks.

Everything before the candidate push is reversible; a red CI verdict leaves nothing but a commit on the branch. Fix forward on the release branch and rlsbl release resume --watch completes the same version: a failed release never burns it. See the release workflow.

Changelog

Every commit since the last release needs a JSONL changelog entry, unless it carries the Autogenerated: true trailer or changes only rlsbl's own records:

rlsbl changelog add --commits a1b2c3d --description "Add retry logic to the HTTP client" --type feature
rlsbl changelog add --commits e4f5a6b --no-user-facing

CHANGELOG.md and the GitHub Release notes are generated from the entries. See the changelog.

Check system

rlsbl includes 72 checks across 8 tags.

Checks are grouped by tag: --tag runs one tag, --name one check, --all everything, and --hook the selection a git hook or the release runs.

Tag Checks
project 28
preflight 24
workspace 20
changelog 10
preflight-changelog 9
release 9
prepush 6
quality 3

What each check verifies is in the check system. Every check is an option, rlsbl:<check name>, and an options entry softens or switches it off for a repository, stating why.

rlsbl check --all
rlsbl check --tag changelog
rlsbl failing-checks --hook pre-push

Scaffold

rlsbl scaffold renders a member's release infrastructure from releasables.toml and keeps it current: its CI workflow, its publish workflow (a wait-for-ci job, then one job per pipeline publishing from CI), goreleaser configuration for a Go binary, the npm launcher for a Go binary shipped through npm, .gitignore lines, the experiments/ and screenshots/ scratch directories, stub go.mod files keeping private directories out of a Go module, a LICENSE from the lifecycle-and-license record, and the pre-push and post-rewrite git hooks. An existing file is merged three ways against the last rendering, so edits are kept; extra jobs belong in a workflow file scaffold never renders, such as .github/workflows/ci-custom.yml. See scaffold.

Undo

rlsbl release undo --approve-consequential

Reverts the latest release when it is provably unpublished (the registries' package listings, origin's Go tags, and its publish runs must show it absent): deletes the GitHub Release and the tags here and on origin, reverts the version-bump commit, restores the changelog entries and the release file, and pushes. For a version a registry already served, rlsbl release deprecate and rlsbl release yank mark it instead.

Who writes which ref namespace

Release branches on origin are written only by releases; there is no other push path, and the pre-push hook refuses a manual push to a release branch. Tags and GitHub Releases are written by the release, and repaired from the record by rlsbl release reconcile. History is rewritten only by rlsbl release scrub (and rlsbl transition declassify), which repairs every record and Release in the same pass. The full table, with every repair and retraction command, is in the release workflow.

Workspaces

A workspace holds several members versioned under one or more releasables:

rlsbl monorepo init --root-member root-dev-node --release-branch main
rlsbl monorepo add packages/core --releasable core --tag-format '{name}@v{version}' --publish-mode ci --license MIT
rlsbl monorepo sync --auto-commit    # regenerate and commit the CI router and publish router
rlsbl monorepo graph --format tree   # the dependency graph; --json for the document
rlsbl monorepo release run --watch --approve-consequential

See workspaces and repository conversions for extract, absorb, and renames.

Lifecycle and license

Each repository keeps a record of every releasable's dated lifecycle (active, on hold, retired), license, and identities in .strictmetadata/lifecycle-and-license/lifecycle-and-license.toml. A releasable whose license is proprietary makes the repository confidential: it must be private on GitHub, publishes nothing anywhere public, and its names are kept out of every public commit, page, and artifact. The rlsbl transition commands write the record. See lifecycle and license.

Ecosystem tagging

While the rlsbl:ecosystem-tagging option is on (its default), scaffold and the release add an rlsbl keyword to npm and PyPI manifests and the rlsbl topic to the GitHub repository, so rlsbl discover finds the project. A repository opts out with an options entry:

rlsbl options set rlsbl:ecosystem-tagging --current off --ideal off --reason "..."

Environment variables

Variable Set for Meaning
RLSBL_VERSION, RLSBL_PREV_VERSION, RLSBL_BUMP_TYPE, RLSBL_DESCRIPTION release hooks The version being released, the previous one, the bump, and the description.
RLSBL_PACKAGE a member's release hooks The member's name.
RLSBL_RELEASE_BIN every process of a release The release's own directory for binaries, first on PATH.
RLSBL_PROJECT_ROOT, RLSBL_LAST_TAG, RLSBL_UNRELEASED_RANGE external checks The repository root, the releasable's latest release tag, and the commits since it.
RLSBL_PUSH_STDIN the pre-push checks git's pushed ref lines, exported by the pre-push hook.
NPM_TOKEN, PYPI_TOKEN local publishing The token a local npm or PyPI pipeline publishes with.
UV_NO_SYNC rlsbl dev sync Must be 1, so a bare uv run does not wipe the overlays.

First publish

Registry Setup Then
npm Add the NPM_TOKEN Actions secret (rlsbl secrets sync-npm-token keeps it current). The publish workflow publishes on the GitHub Release.
PyPI Register the repository as a trusted publisher; no token. The publish workflow publishes through OIDC.
Go Nothing: a Go module is published by its tag. The publish workflow asks the module proxy for a library; goreleaser attaches a binary's archives to the Release.

Requirements

  • git, and the GitHub CLI (gh), authenticated
  • safegit, through which rlsbl makes every commit, and saferm, through which it deletes
  • strictcode, which every release runs
  • gitleaks, for the release's secret scan
  • each target's own tools: go, npm, or uv
  • git-filter-repo, for monorepo extract and monorepo absorb

License

MIT

About

Automate releasing your software to npm, PyPI, and GitHub Releases, with a single CLI -- supports monorepos, semver bumping, changelog.md generation

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages