@frmlabz/release
v0.2.0
Published
Fragment-based release versioning for frmlabz repositories: fragments, cuts, admission checks and release PRs.
Readme
description: The frm-release CLI (@frmlabz/release) — fragment-based release versioning shared by frmlabz repos; install, commands, config schema, CI wiring, version policy, and adoption. updated_at: 2026-10-05
@frmlabz/release
One implementation of the fragment-based release model (release-versioning.md) for every frmlabz repo on the dev -> staging -> main ladder. It allocates versions, renders changelogs from fragments, validates that admission and promotions are correct, and opens release PRs. Deploying (images, digests, manifests, Flux, signing, SDK publishing) stays in each repo.
Zero runtime dependencies; Node 24+, git, and gh for pr. cut runs pnpm ls to list affected workspaces.
Install
pnpm add -D -E @frmlabz/releasePin it exactly. Exempt it from the release-age gate, because the tool and the repos move together:
# pnpm-workspace.yaml
minimumReleaseAgeExclude:
- "@frmlabz/release"Add a matching Renovate packageRules entry with minimumReleaseAge: null for @frmlabz/release. A bump is a reviewed PR like any other.
Commands
| Command | What it does |
| --- | --- |
| frm-release fragment <slug> [--category c] [--components a,b] [--breaking] [--body text] | Creates release/unreleased/YYYYMMDD-HHMM-<slug>.md. Without --body the fragment stays invalid until its outcome is written. |
| frm-release cut <major\|minor\|patch> | On dev or hotfix/*: fetches origin, allocates the next version above everything on the ladder, renders release/changelogs/vX.Y.Z.md, updates release/version.txt, consumes the fragments, validates and commits. Never pushes. |
| frm-release validate [--mode m] [--base ref] [--head ref] [--source ref] [--staged] [--fetch] [--allow-cut] | Runs one validation (see below). --staged validates the index, for pre-commit; --fetch first fetches the configured branches. |
| frm-release pr <staging\|prod> [--draft] [--dry-run] [-- <gh flags>] | Opens release(staging): vX (dev → staging) or release(prod): vX (staging → main). The body is the changelogs the base lacks. Extra gh flags are presentation only (--reviewer, --label); routing flags are refused. |
Typical justfile:
fragment SLUG *FLAGS:
pnpm exec frm-release fragment {{SLUG}} {{FLAGS}}
release KIND:
pnpm exec frm-release cut {{KIND}}
release-pr ENV *FLAGS:
pnpm exec frm-release pr {{ENV}} {{FLAGS}}Validation modes
| Mode | Use | Checks |
| --- | --- | --- |
| auto (default) | hooks, PR CI | In a PR (GITHUB_BASE_REF), enforces the ladder (staging ← dev; main ← staging or hotfix/*) and picks cut, promote, hotfix or feature. Locally: feature, or hotfix on a hotfix/* branch with a new version. A branch other than dev is judged from its single latest base shared with origin/dev and local dev (from origin/dev alone when that base is ambiguous), so a cut on local dev that is not pushed yet is inherited, not owned. |
| feature | feature branches, dev | Release-relevant changes carry a new or edited fragment; version.txt and changelogs are untouched (except cuts on dev/hotfix); migration rules. |
| cut | PR into staging, push to staging | No fragments left; cut changelogs immutable; new version above the floor; changelog re-renders byte-for-byte from its cut parent; main's latest cut merged; nothing release-relevant after the cut; migration rules. |
| hotfix | PR from hotfix/* into main | cut against main, plus: no new migration while staging holds migrations main lacks. |
| promote | PR staging → main | Same version as staging and above main's; every cut on main kept; no fragments; changelog and promoteFiles identical to staging; no new changelogs; no other tree difference except promoteIgnorePaths. |
| main-push | push to main | The pushed commit must be a merge whose first parent is --base (the previous main head). Second parent on origin/staging → promote; otherwise hotfix against --base. Squash, rebase and direct pushes are refused. Skip bot deploy commits in the workflow. |
| migrations | ad hoc | Migration rules only. |
Migration rules: files on the baseline (staging, or main for hotfixes) are frozen; new files are <migrationsDir>/YYYYMMDDHHMMSS_name.sql, flat, carry no release version, and sort above the baseline's highest timestamp. Historical files in version folders stay valid.
Configuration: release/config.json
{
"releasePaths": ["apps/**", "packages/**", "db/migrations/**", "package.json", "pnpm-lock.yaml"],
"ignorePaths": ["**/*.md", "**/test/**", "**/*.test.*"],
"components": ["api", "web", "database", "deployment", "tooling", "deps"],
"migrationsDir": "db/migrations",
"branches": ["dev", "staging", "main"],
"timezone": "Europe/Belgrade",
"promoteFiles": ["deployment/releases/{version}.yaml"],
"promoteIgnorePaths": ["deployment/manifests/prod/**", "deployment/releases/prod-current.txt"]
}Validation reads the config committed with the tree it judges (the index for --staged), so a local edit cannot loosen a check.
| Key | Required | Meaning |
| --- | --- | --- |
| releasePaths, ignorePaths | yes | Globs (path.matchesGlob) deciding which changes need a fragment. |
| components | yes | Allowed fragment components. Removing one later does not invalidate older cuts. |
| migrationsDir | yes | Migration root for the ordering rules. |
| branches | no | ["dev","staging","main"] (default) or ["dev","staging"] for a project without production: hotfixes, promotions and pr prod are refused. |
| timezone | no | Time zone for fragment stamps and cut dates. Default Europe/Belgrade. |
| promoteFiles | no | Files staging must have deployed before promotion, identical on main. {version} is substituted. Default shown. |
| promoteIgnorePaths | for promote | Main-only deploy state the promotion may differ in; list exactly what the prod deploy workflow commits. Everything else must equal staging. |
CI wiring
Hooks (husky or core.hooksPath):
# pre-commit
pnpm exec frm-release validate --staged
# pre-push: fetch first so the floor and merge bases are current
pnpm exec frm-release validate --fetchWorkflows that do not install dependencies can run the pinned version directly:
- name: Validate release
env:
npm_config_min_release_age: "0"
run: npx --yes "@frmlabz/release@$(node -p 'require("./package.json").devDependencies["@frmlabz/release"]')" validate --fetch- PR into any branch:
validate --fetch(auto). Check out withfetch-depth: 0. - Push to staging (skip the bot's
[skip deploy]commits):validate --mode cut --base "${{ github.event.before }}" --head "$GITHUB_SHA". - Push to main (skip bot commits):
validate --mode main-push --base "${{ github.event.before }}"; branch the deploy on the printed mode or reuse the sameHEAD^2-on-staging test.
Renovate PRs get one generated deps fragment from the reusable workflow in this repository; its PAT push re-runs the PR checks:
on:
pull_request:
branches: [dev]
types: [opened, reopened, synchronize]
permissions:
contents: read
jobs:
fragment:
uses: frmlabz/tools/.github/workflows/renovate-fragment.yml@<sha> # main
with:
runner: '"self-hosted"'
secrets:
bot-token: ${{ secrets.DEPLOY_BOT_PAT }}Add "gitIgnoredAuthors": ["41898282+github-actions[bot]@users.noreply.github.com"] to renovate.json so Renovate keeps rebasing the PR, and do not use branch automerge: it lands updates without a PR, so no fragment.
To skip CI on Renovate PRs, give each pull-request job if: ${{ !(github.base_ref == 'dev' && startsWith(github.head_ref, 'renovate/')) }}. Keep the skip scoped to dev: a skipped job satisfies a required status check, so an unscoped skip lets a renovate/* branch through the staging and main rulesets. Nothing else then validates the PR, so merge it only after its fragment / fragment check passes.
Version policy
Different branches can pin different tool versions, and every validation re-renders the cuts in its range, so the output format is a compatibility contract:
- Only cuts added in the validated range are re-rendered; older changelogs are compared by blob and never re-rendered.
- Fragments are parsed with the config committed at the cut parent.
- Patch: fixes that never reject a history an earlier version accepted. Minor: new modes, optional config keys, new checks on new commits only. Major: any change to rendered output or the fragment grammar, or a check that rejects existing valid history. A rendering change must add a format marker to new changelogs and keep rendering unmarked ones as format 1.
Adoption
- Add
release/config.json, the devDependency, the release-age exclusions, hooks and justfile recipes. release/version.txtmust exist on dev (seedv0.0.0for a new project). Every branch'sversion.txtcounts toward the floor, so the first cut continues the existing numbering.- Move migrations that are not yet on staging out of version folders:
git mv <dir>/vX.Y.Z/<ts>_name.sql <dir>/<ts>_name.sql. Point migration creation at the flat directory. - The config (with
promoteIgnorePaths) must reach staging and main through the normal train before the first promotion is validated, because validation reads it from the judged tree. - Replace bespoke release scripts (intent validation, migration order, release PR, PR source branch) with the commands above, wire the workflows, then
frm-release fragment+frm-release cut minorfor the first release.
Development
npm test # node:test against temporary git repos; no networkPublishing runs from .github/workflows/release-package.yml when package.json carries a version not yet on npm.
