9 Commits

Author SHA1 Message Date
eric
ac9f784602 chore(release): v4.0.0 2026-03-21 01:52:51 +01:00
eric
4565a71863 chore: update template 2026-03-21 01:52:22 +01:00
eric
3b38bc8f38 chore(release): v3.6.2 2026-03-21 01:43:45 +01:00
eric
a5ab22f426 fix: update steps 2026-03-21 01:43:20 +01:00
eric
54b54fdece fix: typo 2026-03-21 01:42:48 +01:00
eric
60d0c27db7 fix: typo 2026-03-21 01:42:38 +01:00
eric
b6528466e0 feat: update tui 2026-03-21 01:40:42 +01:00
eric
146b1e9501 chore(release): v3.5.1 2026-03-21 01:27:56 +01:00
eric
8fec37023f feat: modernize 2026-03-21 01:27:42 +01:00
44 changed files with 3795 additions and 3332 deletions

View File

@@ -4,9 +4,11 @@
- `mkRepo` for `devShells`, `checks`, `formatter`, and optional `packages.release`
- structured tool banners driven from package-backed tool specs
- structured release steps (`writeFile`, `replace`, `run`)
- structured release steps (`writeFile`, `replace`, `versionMetaSet`, `versionMetaUnset`)
- a Bun-only Moonrepo + TypeScript + Varlock template in [`template/`](/Users/eric/Projects/repo-lib/template)
Audit and replacement review: [`docs/reviews/2026-03-21-repo-lib-audit.md`](/Users/eric/Projects/repo-lib/docs/reviews/2026-03-21-repo-lib-audit.md)
## Prerequisites
- [Nix](https://nixos.org/download/) with flakes enabled
@@ -15,7 +17,7 @@
## Use the template
```bash
nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.5.0#default' --refresh
+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.1#default' --refresh
```
The generated repo includes:
@@ -32,7 +34,7 @@ The generated repo includes:
Add this flake input:
```nix
inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.5.0";
+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.6.1";
inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs";
```
@@ -154,10 +156,9 @@ config.release = {
};
}
{
run = {
script = ''
echo "Released $FULL_TAG"
'';
versionMetaSet = {
key = "desktop_binary_version_max";
value = "$FULL_VERSION";
};
}
];
@@ -168,16 +169,30 @@ The generated `release` command still supports:
```bash
release
release select
release --dry-run patch
release patch
release patch --commit
release patch --commit --tag
release patch --commit --tag --push
release beta
release minor beta
release stable
release set 1.2.3
```
By default, `release` updates repo files, runs structured release steps, executes `postVersion`, and runs `nix fmt`, but it does not commit, tag, or push unless you opt in with flags.
- `--commit` stages all changes and creates `chore(release): <tag>`
- `--tag` creates the Git tag after commit
- `--push` pushes the current branch and, when tagging is enabled, pushes tags too
- `--dry-run` resolves and prints the plan without mutating the repo
When `release` runs with no args in an interactive terminal, it opens a Bubble Tea picker so you can preview the exact command, flags, and resolved next version before executing it. Use `release select` to force that picker explicitly.
## Low-level APIs
`mkDevShell` and `mkRelease` remain available for repos that want lower-level control or a migration path from the older library shape.
`mkRelease` remains available for repos that want lower-level control over release automation.
## Common command

View File

@@ -1,4 +1,3 @@
3.5.0
4.0.0
stable
0

View File

@@ -0,0 +1,292 @@
# Repo-Lib Audit
Date: 2026-03-21
## Direct Answers
### 1. Does it work today?
Partially.
- `nix flake show --all-systems` succeeds from the repo root.
- Before this audit, `nix flake check` failed because the old shell-based release test harness relied on a brittle regex over `nix derivation show` internals instead of asserting against stable JSON structure.
- `mkRepo`, the template flake, the dev shell, and the formatter/check outputs all evaluate. The primary failure was test-harness fragility, not a clear functional break in the library itself.
- The release path still carries real operational risk because [`packages/release/release.sh`](../../../packages/release/release.sh) combines mutation, formatting, commit, tag, and push in one command and uses destructive rollback.
### 2. Is the code organization maintainable?
Not in its current shape.
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix) is a single 879-line module that owns unrelated concerns:
- tool schema normalization
- hook/check config synthesis
- shell banner generation
- release step normalization
- public API assembly
- [`packages/repo-lib/shell-hook.sh`](../../../packages/repo-lib/shell-hook.sh) is not just presentation. It contains tool probing, parsing, error handling, and shell-failure behavior.
- `mkDevShell` and `mkRepo` overlap conceptually and preserve legacy paths that make the main implementation harder to reason about.
### 3. Is the public API readable and usable for consumers?
Usable, but underspecified and harder to learn than the README suggests.
- [`README.md`](../../../README.md) presents `mkRepo` as a compact abstraction, but the real behavior is distributed across `lib.nix`, `shell-hook.sh`, generated Lefthook config, and the release script.
- The boundaries between `config`, `perSystem`, `checks`, `lefthook`, `shell`, `tools`, and `release` are not obvious from the docs alone.
- Raw `config.lefthook` / `perSystem.lefthook` passthrough is effectively required for advanced use, which means the higher-level `checks` abstraction is incomplete.
### 4. Which parts should be kept, split, or replaced?
Keep:
- the high-level `mkRepo` consumer entrypoint, if compatibility matters
- the template
- the structured release-step idea
Split:
- release tooling from repo shell/hook wiring
- shell banner rendering from shell/package assembly
- hook/check generation from the rest of `mkRepo`
Replace:
- custom flake output composition with `flake-parts`
- custom hook glue with `lefthook.nix`
- keep `treefmt-nix` as the formatting layer rather than wrapping it deeper
### 5. What is the lowest-complexity target architecture?
Option A: keep `repo-lib.lib.mkRepo` as a thin compatibility wrapper, but rebase its internals on established components:
- `flake-parts` for flake structure and `perSystem`
- `treefmt-nix` for formatting
- `lefthook.nix` for hooks
- a separate `mkRelease` package for release automation, with explicit opt-ins for commit/tag/push
That preserves migration cost for consumers while removing most of the custom orchestration burden from this repo.
## Correctness Findings
### High: self-checks were failing because the test harness depended on unstable derivation internals
Files:
- the removed shell-based release test harness
Details:
- The repo did not pass `nix flake check` on the host system before this audit because the tests around `lefthook-check` assumed `nix derivation show` would expose `"/nix/store/...-lefthook.yml.drv"` as a quoted string.
- Current Nix emits input derivations in a different JSON shape, so the regex broke even though the underlying derivation still existed.
- This is a release blocker because the repos own baseline was red.
Assessment:
- Fixed in this audit by replacing the ad hoc scrape with a helper that locates the relevant input derivation from the JSON more defensibly.
### High: release rollback is destructive
Files:
- [`packages/release/release.sh`](../../../packages/release/release.sh)
Details:
- `revert_on_failure` runs `git reset --hard "$START_HEAD"` after any trapped error.
- That will discard all working tree changes created during the release flow, including user-visible file changes that might be useful for debugging or manual recovery.
Assessment:
- This is too aggressive for a library-provided command.
- Rollback should be opt-in, staged to a temp branch/worktree, or replaced with a safer failure mode that leaves artifacts visible.
### Medium: release performs too many side effects in one irreversible flow
Files:
- [`packages/release/release.sh`](../../../packages/release/release.sh)
Details:
- The default flow updates version state, runs release steps, formats, stages, commits, tags, and pushes.
- There is no dry-run mode.
- There is no `--no-push`, `--no-tag`, or `--no-commit` mode.
- The command is framed as a package generated by the library, so consumers inherit a strong opinionated workflow whether they want it or not.
Assessment:
- Release should be separated from repo shell wiring and broken into explicit phases or flags.
## Organization And Readability Findings
### High: `lib.nix` is a monolith
Files:
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix)
Details:
- One file owns normalization helpers, shell assembly, banner formatting inputs, Lefthook synthesis, release templating, compatibility APIs, and top-level outputs.
- The public API is therefore not separable from its implementation detail.
Assessment:
- This is the main maintainability problem in the repo.
- Even if behavior is mostly correct, the cost of safely changing it is too high.
### Medium: shell UX logic is coupled to operational behavior
Files:
- [`packages/repo-lib/shell-hook.sh`](../../../packages/repo-lib/shell-hook.sh)
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix)
Details:
- Tool banners do more than render text. They probe commands, parse versions, print failures, and may exit the shell startup for required tools.
- That behavior is not obvious from the README example and is spread across generated shell script fragments.
Assessment:
- The banner feature is nice, but it is expensive in complexity and debugging surface relative to the value it adds.
- If retained, it should be optional and isolated behind a smaller interface.
### Medium: legacy compatibility paths dominate the core implementation
Files:
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix)
Details:
- `mkDevShell` uses legacy tool normalization and its own feature toggles.
- `mkRepo` carries a newer strict tool shape.
- Both flows feed the same shell-artifact builder, which means the common implementation has to keep both mental models alive.
Assessment:
- Deprecate `mkDevShell` once a thin `mkRepo` wrapper exists over standard components.
## Public API And Usability Findings
### High: README underspecifies the real API
Files:
- [`README.md`](../../../README.md)
Details:
- The README explains the happy-path shape of `mkRepo`, but not the actual behavioral contract.
- It does not provide a reference for:
- tool spec fields
- shell banner behavior
- exact merge order between `config` and `perSystem`
- what the `checks` abstraction cannot express
- what `release` is allowed to mutate by default
Assessment:
- Consumers can start quickly, but they cannot predict behavior well without reading the implementation.
### Medium: abstraction boundaries are blurry
Files:
- [`README.md`](../../../README.md)
- [`template/flake.nix`](../../../template/flake.nix)
- [`packages/repo-lib/lib.nix`](../../../packages/repo-lib/lib.nix)
Details:
- `checks` looks like the high-level hook API, but advanced usage requires raw Lefthook passthrough.
- `shell.bootstrap` is documented as the purity escape hatch, but the template uses it for tool bootstrapping and operational setup.
- `release` is presented as optional packaging, but it is operational automation with repo mutation and remote side effects.
Assessment:
- These concepts should be separate modules with narrower contracts.
## Replacement Options
### Option A: thin compatibility layer
Keep `repo-lib.lib.mkRepo`, but make it a wrapper over standard components.
Use:
- `flake-parts` for top-level flake assembly and `perSystem`
- `treefmt-nix` for formatting
- `lefthook.nix` for Git hooks
- a standalone `mkRelease` output for release automation
Pros:
- lower migration cost
- preserves existing entrypoint
- reduces bespoke glue
Cons:
- some compatibility debt remains
- requires a staged migration plan
### Option B: full replacement
Stop positioning this as a general-purpose Nix library and keep only:
- the template
- any repo-specific release helper
- migration docs to standard tools
Pros:
- lowest long-term maintenance burden
- clearest product boundary
Cons:
- highest consumer migration cost
- discards the existing `mkRepo` API
## Final Recommendation
Choose **Option A**.
Rationale:
- `mkRepo` has enough consumer value to keep as a compatibility surface.
- Most of the complexity is not unique value. It is custom orchestration around capabilities already provided by better-maintained ecosystem tools.
- The release flow should be split out regardless of which option is chosen.
Concrete target:
1. Rebase flake structure on `flake-parts`.
2. Replace custom hook synthesis with `lefthook.nix`.
3. Keep `treefmt-nix` directly exposed instead of deeply wrapped.
4. Make shell banners optional or move them behind a very small isolated module.
5. Move release automation into a separate package with explicit side-effect flags.
6. Mark `mkDevShell` deprecated once `mkRepo` is stable on the new internals.
## Migration Cost And Compatibility Notes
- A thin compatibility wrapper keeps consumer migration reasonable.
- The biggest compatibility risk is release behavior, because some consumers may depend on the current commit/tag/push flow.
- Introduce safer release behavior behind new flags first, then deprecate the old all-in-one default.
- Keep template output working during the transition; it is currently the clearest example of intended usage.
## Required Validation For Follow-Up Work
- `nix flake show --all-systems`
- `nix flake check`
- minimal consumer repo using `mkRepo`
- template repo evaluation
- release smoke test in a temporary git repo
- hook assertions that do not depend on private derivation naming/layout
## Sources
- `flake-parts`: https://flake.parts/
- `treefmt-nix`: https://github.com/numtide/treefmt-nix
- `lefthook.nix`: https://github.com/cachix/lefthook.nix
- `devenv`: https://github.com/cachix/devenv

View File

@@ -1,99 +0,0 @@
# TypeScript Monorepo Template Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the minimal starter template with a Bun-only Moonrepo + TypeScript + Varlock monorepo template exposed through the existing flake template.
**Architecture:** Expand `template/` into a complete repository skeleton while keeping `repo-lib.lib.mkRepo` as the integration point. Adapt the strict TypeScript config layout and Varlock command pattern from `../moon`, and update release tests so they evaluate the full template contents.
**Tech Stack:** Nix flakes, repo-lib, Bun, Moonrepo, Varlock, TypeScript
---
## Chunk 1: Documentation Baseline
### Task 1: Update public template docs
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Write the failing expectation mentally against current docs**
Current docs describe only a minimal starter template and do not mention Bun, Moonrepo, or Varlock.
- [ ] **Step 2: Update the README to describe the new template**
Document the generated workspace shape and first-run commands.
- [ ] **Step 3: Verify the README content is consistent with the template files**
Check all commands and filenames against the final template layout.
## Chunk 2: Template Skeleton
### Task 2: Replace the minimal template with a real monorepo skeleton
**Files:**
- Modify: `template/flake.nix`
- Create: `template/package.json`
- Create: `template/bunfig.toml`
- Create: `template/moon.yml`
- Create: `template/tsconfig.json`
- Create: `template/tsconfig.options.json`
- Create: `template/tsconfig/browser.json`
- Create: `template/tsconfig/bun.json`
- Create: `template/tsconfig/package.json`
- Create: `template/tsconfig/runtime.json`
- Create: `template/.env.schema`
- Modify: `template/.gitignore`
- Create: `template/README.md`
- Create: `template/apps/.gitkeep`
- Create: `template/packages/.gitkeep`
- [ ] **Step 1: Add or update template files**
Use `../moon` as the source for Moonrepo, Varlock, and TypeScript patterns, removing product-specific details.
- [ ] **Step 2: Verify the template tree is coherent**
Check that all referenced files exist and that scripts reference only template-safe commands.
## Chunk 3: Test Coverage
### Task 3: Update release tests for the full template
**Files:**
- Modify: `tests/release.sh`
- [ ] **Step 1: Add a failing test expectation**
The current template fixture copies only `template/flake.nix`, which is insufficient for the new template layout.
- [ ] **Step 2: Update fixture creation to copy the full template**
Rewrite template URL references in copied files as needed for local test evaluation.
- [ ] **Step 3: Verify the existing template evaluation case now uses the real skeleton**
Confirm `nix flake show` runs against the expanded template fixture.
## Chunk 4: Verification
### Task 4: Run template verification
**Files:**
- Verify: `README.md`
- Verify: `template/**/*`
- Verify: `tests/release.sh`
- [ ] **Step 1: Run the release test suite**
Run: `nix develop -c bash tests/release.sh`
- [ ] **Step 2: Inspect the template file tree**
Run: `find template -maxdepth 3 -type f | sort`
- [ ] **Step 3: Verify the README examples still match the tagged template release pattern**
Check that versioned `repo-lib` URLs remain in the documented commands and release replacements.

View File

@@ -1,88 +0,0 @@
# TypeScript Monorepo Template Design
## Goal
Add a new default template to this repository that generates a Bun-only TypeScript monorepo using Moonrepo, Varlock, and the shared TypeScript configuration pattern from `../moon`.
## Scope
The generated template should include:
- a Nix flake wired through `repo-lib.lib.mkRepo`
- Bun-only JavaScript tooling
- Moonrepo root configuration
- strict shared TypeScript configs adapted from `../moon`
- Varlock enabled from day one
- a committed `.env.schema`
- empty `apps/` and `packages/` directories
- minimal documentation for first-run setup
The template should not include:
- demo apps or packages
- product-specific environment variables or OpenBao paths from `../moon`
- Node or pnpm support
## Architecture
The existing `template/` directory remains the exported flake template. Instead of containing only a starter `flake.nix`, it will become a complete repository skeleton.
The generated repository will keep the current `repo-lib` integration pattern:
- `template/flake.nix` calls `repo-lib.lib.mkRepo`
- the shell provisions Bun, Moonrepo CLI, Varlock, and supporting tooling
- repo checks remain driven through `mkRepo` and Lefthook
Moonrepo and Varlock will be configured at the workspace root. The template will expose root tasks and scripts that work even before any projects are added.
## Template Contents
The template should contain:
- `flake.nix`
- `package.json`
- `bunfig.toml`
- `moon.yml`
- `tsconfig.json`
- `tsconfig.options.json`
- `tsconfig/browser.json`
- `tsconfig/bun.json`
- `tsconfig/package.json`
- `tsconfig/runtime.json`
- `.env.schema`
- `.gitignore`
- `README.md`
- `apps/.gitkeep`
- `packages/.gitkeep`
It may also keep generic repo support files already useful in templates, such as `.envrc`, `.gitlint`, `.gitleaks.toml`, `.vscode/settings.json`, and `flake.lock`, as long as they remain template-safe.
## Data And Command Flow
On first use:
1. the user creates a repo from the flake template
2. the shell provides Bun, Moonrepo, Varlock, and release support
3. `bun install` installs `@moonrepo/cli`, `varlock`, and TypeScript-related dependencies
4. entering the repo loads `varlock/auto-load`
5. root commands like `bun run env:check`, `bun run env:scan`, and `moon run :typecheck` work without any sample projects
## Varlock Design
The template will include a minimal `.env.schema` with:
- one canonical environment selector
- safe local defaults where practical
- placeholders for OpenBao-backed secrets using generic template paths
Root scripts in `package.json` will follow the `../moon` pattern for `env:check` and `env:scan`, including `BAO_*` and `OPENBAO_*` compatibility exports. The template will not encode any product-specific namespace names.
## Testing
Existing release tests must continue to validate the exported template. The template fixture helper in `tests/release.sh` will need to copy the full template directory, not only `template/flake.nix`, so `nix flake show` exercises the real generated repository structure.
## Risks
- Moonrepo root task behavior must remain valid with no projects present.
- Template-safe Varlock defaults must avoid broken first-run behavior while still demonstrating the intended pattern.
- The release test harness must not accidentally preserve upstream URLs inside the copied template.

34
flake.lock generated
View File

@@ -1,5 +1,23 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"lefthook-nix": {
"inputs": {
"nixpkgs": [
@@ -36,6 +54,21 @@
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1772328832,
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1770107345,
@@ -54,6 +87,7 @@
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"lefthook-nix": "lefthook-nix",
"nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix"

View File

@@ -3,6 +3,7 @@
description = "Pure-first repo development platform for Nix flakes";
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
lefthook-nix.url = "github:sudosubin/lefthook.nix";
lefthook-nix.inputs.nixpkgs.follows = "nixpkgs";
@@ -12,6 +13,7 @@
outputs =
{
self,
flake-parts,
nixpkgs,
treefmt-nix,
lefthook-nix,
@@ -20,7 +22,7 @@
let
lib = nixpkgs.lib;
repoLib = import ./packages/repo-lib/lib.nix {
inherit nixpkgs treefmt-nix;
inherit flake-parts nixpkgs treefmt-nix;
lefthookNix = lefthook-nix;
releaseScriptPath = ./packages/release/release.sh;
shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh;
@@ -94,20 +96,16 @@
pkgs.runCommand "release-tests"
{
nativeBuildInputs = with pkgs; [
bash
go
git
nix
gnused
coreutils
gnugrep
perl
];
}
''
export REPO_LIB_ROOT=${./.}
export NIXPKGS_FLAKE_PATH=${nixpkgs}
export HOME="$TMPDIR"
${pkgs.bash}/bin/bash ${./tests/release.sh}
export HOME="$PWD/.home"
export GOCACHE="$PWD/.go-cache"
mkdir -p "$GOCACHE" "$HOME"
cd ${./packages/release}
go test ./...
touch "$out"
'';
}

1
packages/release/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
vendor/

View File

@@ -0,0 +1,127 @@
package main
import (
"fmt"
"os"
"strings"
release "repo-lib/packages/release/internal/release"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) > 0 && args[0] == "version-meta" {
return runVersionMeta(args[1:])
}
releaseArgs, execution, selectMode, err := parseReleaseCLIArgs(args)
if err != nil {
return err
}
config := release.Config{
RootDir: os.Getenv("REPO_LIB_RELEASE_ROOT_DIR"),
AllowedChannels: splitEnvList("REPO_LIB_RELEASE_CHANNELS"),
ReleaseStepsJSON: os.Getenv("REPO_LIB_RELEASE_STEPS_JSON"),
PostVersion: os.Getenv("REPO_LIB_RELEASE_POST_VERSION"),
Execution: execution,
Env: os.Environ(),
Stdout: os.Stdout,
Stderr: os.Stderr,
}
if shouldRunInteractiveSelector(releaseArgs, selectMode) {
if !release.IsInteractiveTerminal(os.Stdin, os.Stdout) {
return fmt.Errorf("interactive release selector requires a terminal")
}
selectedArgs, confirmed, err := release.SelectCommand(config)
if err != nil {
return err
}
if !confirmed {
return nil
}
releaseArgs = selectedArgs
}
r := &release.Runner{Config: config}
return r.Run(releaseArgs)
}
func shouldRunInteractiveSelector(args []string, selectMode bool) bool {
if selectMode {
return true
}
if len(args) == 0 {
return release.IsInteractiveTerminal(os.Stdin, os.Stdout)
}
return false
}
func parseReleaseCLIArgs(args []string) ([]string, release.ExecutionOptions, bool, error) {
var releaseArgs []string
execution := release.ExecutionOptions{}
selectMode := false
for _, arg := range args {
switch arg {
case "select":
selectMode = true
default:
if strings.HasPrefix(arg, "--") {
return nil, release.ExecutionOptions{}, false, fmt.Errorf("unknown flag %q", arg)
}
releaseArgs = append(releaseArgs, arg)
}
}
if selectMode && len(releaseArgs) > 0 {
return nil, release.ExecutionOptions{}, false, fmt.Errorf("select does not take a release argument")
}
return releaseArgs, execution.Normalize(), selectMode, nil
}
func runVersionMeta(args []string) error {
if len(args) < 2 {
return fmt.Errorf("version-meta requires an action and key")
}
rootDir := os.Getenv("ROOT_DIR")
if rootDir == "" {
return fmt.Errorf("ROOT_DIR is required")
}
versionPath := rootDir + "/VERSION"
file, err := release.ReadVersionFile(versionPath)
if err != nil {
return err
}
switch args[0] {
case "set":
if len(args) != 3 {
return fmt.Errorf("version-meta set requires key and value")
}
file.Metadata.Set(args[1], args[2])
case "unset":
if len(args) != 2 {
return fmt.Errorf("version-meta unset requires key")
}
file.Metadata.Unset(args[1])
default:
return fmt.Errorf("unknown version-meta action %q", args[0])
}
return file.Write(versionPath)
}
func splitEnvList(name string) []string {
raw := strings.Fields(os.Getenv(name))
if len(raw) == 0 {
return nil
}
return raw
}

29
packages/release/go.mod Normal file
View File

@@ -0,0 +1,29 @@
module repo-lib/packages/release
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
golang.org/x/term v0.41.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

45
packages/release/go.sum Normal file
View File

@@ -0,0 +1,45 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

View File

@@ -0,0 +1,91 @@
package release
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
func requireCleanGit(rootDir string) error {
if _, err := runCommand(rootDir, nil, io.Discard, io.Discard, "git", "diff", "--quiet"); err != nil {
return errors.New("git working tree is not clean. Commit or stash changes first")
}
if _, err := runCommand(rootDir, nil, io.Discard, io.Discard, "git", "diff", "--cached", "--quiet"); err != nil {
return errors.New("git working tree is not clean. Commit or stash changes first")
}
return nil
}
func gitOutput(dir string, args ...string) (string, error) {
return runCommand(dir, nil, nil, nil, "git", args...)
}
func runCommand(dir string, env []string, stdout io.Writer, stderr io.Writer, name string, args ...string) (string, error) {
resolvedName, err := resolveExecutable(name, env)
if err != nil {
return "", err
}
cmd := exec.Command(resolvedName, args...)
if dir != "" {
cmd.Dir = dir
}
if env != nil {
cmd.Env = env
} else {
cmd.Env = os.Environ()
}
var out bytes.Buffer
cmd.Stdout = io.MultiWriter(&out, writerOrDiscard(stdout))
cmd.Stderr = io.MultiWriter(&out, writerOrDiscard(stderr))
err = cmd.Run()
if err != nil {
output := strings.TrimSpace(out.String())
if output == "" {
return out.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return out.String(), fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, output)
}
return out.String(), nil
}
func resolveExecutable(name string, env []string) (string, error) {
if strings.ContainsRune(name, os.PathSeparator) {
return name, nil
}
pathValue := os.Getenv("PATH")
for _, entry := range env {
if strings.HasPrefix(entry, "PATH=") {
pathValue = strings.TrimPrefix(entry, "PATH=")
}
}
for _, dir := range filepath.SplitList(pathValue) {
if dir == "" {
dir = "."
}
candidate := filepath.Join(dir, name)
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
continue
}
if info.Mode()&0o111 == 0 {
continue
}
return candidate, nil
}
return "", fmt.Errorf("executable %q not found in PATH", name)
}
func writerOrDiscard(w io.Writer) io.Writer {
if w == nil {
return io.Discard
}
return w
}

View File

@@ -0,0 +1,64 @@
package release
import (
"encoding/json"
"fmt"
)
type ReleaseStep struct {
Kind string `json:"kind"`
Path string `json:"path,omitempty"`
Text string `json:"text,omitempty"`
Regex string `json:"regex,omitempty"`
Replacement string `json:"replacement,omitempty"`
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
}
func decodeReleaseSteps(raw string) ([]ReleaseStep, error) {
if raw == "" {
return nil, nil
}
var steps []ReleaseStep
if err := json.Unmarshal([]byte(raw), &steps); err != nil {
return nil, fmt.Errorf("decode release steps: %w", err)
}
for i, step := range steps {
if err := validateReleaseStep(step); err != nil {
return nil, fmt.Errorf("release step %d: %w", i+1, err)
}
}
return steps, nil
}
func validateReleaseStep(step ReleaseStep) error {
switch step.Kind {
case "writeFile":
if step.Path == "" {
return fmt.Errorf("writeFile.path is required")
}
return nil
case "replace":
if step.Path == "" {
return fmt.Errorf("replace.path is required")
}
if step.Regex == "" {
return fmt.Errorf("replace.regex is required")
}
return nil
case "versionMetaSet":
if step.Key == "" {
return fmt.Errorf("versionMetaSet.key is required")
}
return nil
case "versionMetaUnset":
if step.Key == "" {
return fmt.Errorf("versionMetaUnset.key is required")
}
return nil
default:
return fmt.Errorf("unsupported release step kind %q", step.Kind)
}
}

View File

@@ -0,0 +1,39 @@
package release
import (
"fmt"
"io"
)
func (r *Runner) runReleaseSteps(rootDir string, versionPath string, versionFile *VersionFile, version Version, stdout io.Writer, stderr io.Writer) error {
steps, err := decodeReleaseSteps(r.Config.ReleaseStepsJSON)
if err != nil {
return err
}
if len(steps) == 0 {
return nil
}
ctx := newReleaseStepContext(rootDir, versionPath, versionFile, version, r.Config.Env)
for i, step := range steps {
if err := applyReleaseStep(ctx, step); err != nil {
return fmt.Errorf("release step %d (%s): %w", i+1, step.Kind, err)
}
}
return nil
}
func applyReleaseStep(ctx *ReleaseStepContext, step ReleaseStep) error {
switch step.Kind {
case "writeFile":
return applyWriteFileStep(ctx, step)
case "replace":
return applyReplaceStep(ctx, step)
case "versionMetaSet":
return applyVersionMetaSetStep(ctx, step)
case "versionMetaUnset":
return applyVersionMetaUnsetStep(ctx, step)
default:
return fmt.Errorf("unsupported release step kind %q", step.Kind)
}
}

View File

@@ -0,0 +1,73 @@
package release
import (
"os"
"path/filepath"
"strconv"
"strings"
)
type ReleaseStepContext struct {
RootDir string
VersionPath string
Version Version
VersionFile *VersionFile
Env map[string]string
}
func newReleaseStepContext(rootDir string, versionPath string, versionFile *VersionFile, version Version, env []string) *ReleaseStepContext {
return &ReleaseStepContext{
RootDir: rootDir,
VersionPath: versionPath,
Version: version,
VersionFile: versionFile,
Env: buildReleaseEnv(rootDir, versionFile, version, env),
}
}
func buildReleaseEnv(rootDir string, versionFile *VersionFile, version Version, baseEnv []string) map[string]string {
env := make(map[string]string, len(baseEnv)+8+len(versionFile.Metadata.lines))
if len(baseEnv) == 0 {
baseEnv = os.Environ()
}
for _, entry := range baseEnv {
key, value, ok := strings.Cut(entry, "=")
if ok {
env[key] = value
}
}
env["ROOT_DIR"] = rootDir
env["BASE_VERSION"] = version.BaseString()
env["CHANNEL"] = version.Channel
env["FULL_VERSION"] = version.String()
env["FULL_TAG"] = version.Tag()
if version.Channel == "stable" {
env["PRERELEASE_NUM"] = ""
} else {
env["PRERELEASE_NUM"] = strconv.Itoa(version.Prerelease)
}
for _, line := range versionFile.Metadata.lines {
key, value, ok := strings.Cut(line, "=")
if !ok || key == "" {
continue
}
env[sanitizeMetaEnvName(key)] = value
}
return env
}
func (c *ReleaseStepContext) expand(raw string) string {
return os.Expand(raw, func(name string) string {
return c.Env[name]
})
}
func (c *ReleaseStepContext) resolvePath(path string) string {
expanded := c.expand(path)
if filepath.IsAbs(expanded) {
return expanded
}
return filepath.Join(c.RootDir, expanded)
}

View File

@@ -0,0 +1,45 @@
package release
import (
"fmt"
"os"
"regexp"
"strings"
)
func applyReplaceStep(ctx *ReleaseStepContext, step ReleaseStep) error {
targetPath := ctx.resolvePath(step.Path)
content, err := os.ReadFile(targetPath)
if err != nil {
return fmt.Errorf("read %s: %w", targetPath, err)
}
pattern, err := regexp.Compile("(?m)" + ctx.expand(step.Regex))
if err != nil {
return fmt.Errorf("compile regex for %s: %w", targetPath, err)
}
replacement := translateReplacementBackrefs(ctx.expand(step.Replacement))
updated := pattern.ReplaceAllString(string(content), replacement)
if err := os.WriteFile(targetPath, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write %s: %w", targetPath, err)
}
return nil
}
func translateReplacementBackrefs(raw string) string {
var b strings.Builder
b.Grow(len(raw))
for i := 0; i < len(raw); i++ {
if raw[i] == '\\' && i+1 < len(raw) && raw[i+1] >= '1' && raw[i+1] <= '9' {
b.WriteString("${")
b.WriteByte(raw[i+1])
b.WriteByte('}')
i++
continue
}
b.WriteByte(raw[i])
}
return b.String()
}

View File

@@ -0,0 +1,13 @@
package release
import "testing"
func TestTranslateReplacementBackrefsWrapsCaptureNumbers(t *testing.T) {
t.Parallel()
got := translateReplacementBackrefs(`\1git+https://example.test/ref\2`)
want := `${1}git+https://example.test/ref${2}`
if got != want {
t.Fatalf("translateReplacementBackrefs() = %q, want %q", got, want)
}
}

View File

@@ -0,0 +1,21 @@
package release
import "fmt"
func applyVersionMetaSetStep(ctx *ReleaseStepContext, step ReleaseStep) error {
ctx.VersionFile.Metadata.Set(step.Key, ctx.expand(step.Value))
ctx.Env[sanitizeMetaEnvName(step.Key)] = ctx.VersionFile.Metadata.Get(step.Key)
if err := ctx.VersionFile.Write(ctx.VersionPath); err != nil {
return fmt.Errorf("write VERSION: %w", err)
}
return nil
}
func applyVersionMetaUnsetStep(ctx *ReleaseStepContext, step ReleaseStep) error {
ctx.VersionFile.Metadata.Unset(step.Key)
delete(ctx.Env, sanitizeMetaEnvName(step.Key))
if err := ctx.VersionFile.Write(ctx.VersionPath); err != nil {
return fmt.Errorf("write VERSION: %w", err)
}
return nil
}

View File

@@ -0,0 +1,18 @@
package release
import (
"fmt"
"os"
"path/filepath"
)
func applyWriteFileStep(ctx *ReleaseStepContext, step ReleaseStep) error {
targetPath := ctx.resolvePath(step.Path)
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(targetPath), err)
}
if err := os.WriteFile(targetPath, []byte(ctx.expand(step.Text)), 0o644); err != nil {
return fmt.Errorf("write %s: %w", targetPath, err)
}
return nil
}

View File

@@ -0,0 +1,372 @@
package release
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestResolveNextVersion(t *testing.T) {
t.Parallel()
allowed := []string{"alpha", "beta", "rc", "internal"}
tests := []struct {
name string
current string
args []string
want string
wantErr string
}{
{
name: "channel only from stable bumps patch",
current: "1.0.0",
args: []string{"beta"},
want: "1.0.1-beta.1",
},
{
name: "explicit minor bump keeps requested bump",
current: "1.0.0",
args: []string{"minor", "beta"},
want: "1.1.0-beta.1",
},
{
name: "full promotes prerelease to stable",
current: "1.1.5-beta.1",
args: []string{"full"},
want: "1.1.5",
},
{
name: "set stable from prerelease requires full",
current: "1.1.5-beta.1",
args: []string{"set", "1.1.5"},
wantErr: "promote using 'stable' or 'full' only",
},
{
name: "patch stable from prerelease requires full",
current: "1.1.5-beta.1",
args: []string{"patch", "stable"},
wantErr: "promote using 'stable' or 'full' only",
},
{
name: "full no-op fails",
current: "1.1.5",
args: []string{"full"},
wantErr: "Version 1.1.5 is already current; nothing to do.",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
current, err := ParseVersion(tc.current)
if err != nil {
t.Fatalf("ParseVersion(%q): %v", tc.current, err)
}
got, err := ResolveNextVersion(current, tc.args, allowed)
if tc.wantErr != "" {
if err == nil {
t.Fatalf("ResolveNextVersion(%q, %v) succeeded, want error", tc.current, tc.args)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("ResolveNextVersion(%q, %v) error = %q, want substring %q", tc.current, tc.args, err.Error(), tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("ResolveNextVersion(%q, %v): %v", tc.current, tc.args, err)
}
if got.String() != tc.want {
t.Fatalf("ResolveNextVersion(%q, %v) = %q, want %q", tc.current, tc.args, got.String(), tc.want)
}
})
}
}
func TestVersionFileMetadataRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "VERSION")
content := strings.Join([]string{
"1.0.0",
"stable",
"0",
"desktop_backend_change_scope=bindings",
"desktop_release_mode=binary",
"desktop_unused=temporary",
"",
}, "\n")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
file, err := ReadVersionFile(path)
if err != nil {
t.Fatalf("ReadVersionFile: %v", err)
}
if got := file.Current().String(); got != "1.0.0" {
t.Fatalf("Current() = %q, want 1.0.0", got)
}
if got := file.Metadata.Get("desktop_backend_change_scope"); got != "bindings" {
t.Fatalf("Metadata.Get(scope) = %q, want bindings", got)
}
file.Version = MustParseVersion(t, "1.0.1")
file.Metadata.Set("desktop_release_mode", "codepush")
file.Metadata.Set("desktop_binary_version_min", "1.0.0")
file.Metadata.Set("desktop_binary_version_max", "1.0.1")
file.Metadata.Set("desktop_backend_compat_id", "compat-123")
file.Metadata.Unset("desktop_unused")
if err := file.Write(path); err != nil {
t.Fatalf("Write(VERSION): %v", err)
}
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(VERSION): %v", err)
}
got := string(gotBytes)
for _, needle := range []string{
"1.0.1\nstable\n0\n",
"desktop_backend_change_scope=bindings",
"desktop_release_mode=codepush",
"desktop_binary_version_min=1.0.0",
"desktop_binary_version_max=1.0.1",
"desktop_backend_compat_id=compat-123",
} {
if !strings.Contains(got, needle) {
t.Fatalf("VERSION missing %q:\n%s", needle, got)
}
}
if strings.Contains(got, "desktop_unused=temporary") {
t.Fatalf("VERSION still contains removed metadata:\n%s", got)
}
}
func TestRunnerExecutesReleaseFlow(t *testing.T) {
t.Parallel()
root := t.TempDir()
remote := filepath.Join(t.TempDir(), "remote.git")
mustRun(t, root, "git", "init")
mustRun(t, root, "git", "config", "user.name", "Release Test")
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
mustRun(t, root, "git", "config", "commit.gpgsign", "false")
mustRun(t, root, "git", "config", "tag.gpgsign", "false")
if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil {
t.Fatalf("WriteFile(flake.nix): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.0.0\nstable\n0\ndesktop_backend_change_scope=bindings\ndesktop_release_mode=binary\ndesktop_unused=temporary\n"), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("version=old\n"), 0o644); err != nil {
t.Fatalf("WriteFile(notes.txt): %v", err)
}
mustRun(t, root, "git", "add", "-A")
mustRun(t, root, "git", "commit", "-m", "init")
mustRun(t, root, "git", "init", "--bare", remote)
mustRun(t, root, "git", "remote", "add", "origin", remote)
mustRun(t, root, "git", "push", "-u", "origin", "HEAD")
binDir := t.TempDir()
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(bin): %v", err)
}
nixPath := filepath.Join(binDir, "nix")
nixScript := "#!/usr/bin/env bash\nif [[ \"${1-}\" == \"fmt\" ]]; then\n exit 0\nfi\necho \"unexpected nix invocation: $*\" >&2\nexit 1\n"
if err := os.WriteFile(nixPath, []byte(nixScript), 0o755); err != nil {
t.Fatalf("WriteFile(bin/nix): %v", err)
}
r := &Runner{
Config: Config{
RootDir: root,
AllowedChannels: []string{"alpha", "beta", "rc", "internal"},
ReleaseStepsJSON: mustJSON(t, []ReleaseStep{
{Kind: "writeFile", Path: "generated/version.txt", Text: "$FULL_VERSION\n"},
{Kind: "replace", Path: "notes.txt", Regex: "^version=.*$", Replacement: "version=$FULL_VERSION"},
{Kind: "writeFile", Path: "release.tag", Text: "$FULL_TAG\n"},
{Kind: "writeFile", Path: "metadata/scope.txt", Text: "$VERSION_META_DESKTOP_BACKEND_CHANGE_SCOPE\n"},
{Kind: "writeFile", Path: "metadata/mode-before.txt", Text: "$VERSION_META_DESKTOP_RELEASE_MODE\n"},
{Kind: "versionMetaSet", Key: "desktop_release_mode", Value: "codepush"},
{Kind: "versionMetaSet", Key: "desktop_binary_version_min", Value: "1.0.0"},
{Kind: "versionMetaSet", Key: "desktop_binary_version_max", Value: "$FULL_VERSION"},
{Kind: "versionMetaSet", Key: "desktop_backend_compat_id", Value: "compat-123"},
{Kind: "versionMetaUnset", Key: "desktop_unused"},
}),
PostVersion: "printf '%s\\n' \"$FULL_VERSION\" >\"$ROOT_DIR/post-version.txt\"",
Execution: ExecutionOptions{
Commit: true,
Tag: true,
Push: true,
},
Env: append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")),
},
}
if err := r.Run([]string{"patch"}); err != nil {
t.Fatalf("Runner.Run: %v", err)
}
versionFile, err := ReadVersionFile(filepath.Join(root, "VERSION"))
if err != nil {
t.Fatalf("ReadVersionFile(after): %v", err)
}
if got := versionFile.Current().String(); got != "1.0.1" {
t.Fatalf("Current() after release = %q, want 1.0.1", got)
}
assertFileEquals(t, filepath.Join(root, "generated/version.txt"), "1.0.1\n")
assertFileEquals(t, filepath.Join(root, "notes.txt"), "version=1.0.1\n")
assertFileEquals(t, filepath.Join(root, "release.tag"), "v1.0.1\n")
assertFileEquals(t, filepath.Join(root, "metadata/scope.txt"), "bindings\n")
assertFileEquals(t, filepath.Join(root, "metadata/mode-before.txt"), "binary\n")
assertFileEquals(t, filepath.Join(root, "post-version.txt"), "1.0.1\n")
versionBytes, err := os.ReadFile(filepath.Join(root, "VERSION"))
if err != nil {
t.Fatalf("ReadFile(VERSION after): %v", err)
}
versionText := string(versionBytes)
for _, needle := range []string{
"desktop_backend_change_scope=bindings",
"desktop_release_mode=codepush",
"desktop_binary_version_min=1.0.0",
"desktop_binary_version_max=1.0.1",
"desktop_backend_compat_id=compat-123",
} {
if !strings.Contains(versionText, needle) {
t.Fatalf("VERSION missing %q:\n%s", needle, versionText)
}
}
if strings.Contains(versionText, "desktop_unused=temporary") {
t.Fatalf("VERSION still contains removed metadata:\n%s", versionText)
}
tagList := strings.TrimSpace(mustOutput(t, root, "git", "tag", "--list", "v1.0.1"))
if tagList != "v1.0.1" {
t.Fatalf("git tag --list v1.0.1 = %q, want v1.0.1", tagList)
}
}
func TestRunnerAlwaysCommitsTagsAndPushes(t *testing.T) {
t.Parallel()
root := t.TempDir()
remote := filepath.Join(t.TempDir(), "remote.git")
mustRun(t, root, "git", "init")
mustRun(t, root, "git", "config", "user.name", "Release Test")
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
mustRun(t, root, "git", "config", "commit.gpgsign", "false")
mustRun(t, root, "git", "config", "tag.gpgsign", "false")
if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil {
t.Fatalf("WriteFile(flake.nix): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.0.0\nstable\n0\n"), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
mustRun(t, root, "git", "add", "-A")
mustRun(t, root, "git", "commit", "-m", "init")
mustRun(t, root, "git", "init", "--bare", remote)
mustRun(t, root, "git", "remote", "add", "origin", remote)
mustRun(t, root, "git", "push", "-u", "origin", "HEAD")
binDir := t.TempDir()
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(bin): %v", err)
}
nixPath := filepath.Join(binDir, "nix")
nixScript := "#!/usr/bin/env bash\nif [[ \"${1-}\" == \"fmt\" ]]; then\n exit 0\nfi\necho \"unexpected nix invocation: $*\" >&2\nexit 1\n"
if err := os.WriteFile(nixPath, []byte(nixScript), 0o755); err != nil {
t.Fatalf("WriteFile(bin/nix): %v", err)
}
r := &Runner{
Config: Config{
RootDir: root,
AllowedChannels: []string{"alpha", "beta", "rc", "internal"},
Env: append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")),
},
}
if err := r.Run([]string{"patch"}); err != nil {
t.Fatalf("Runner.Run: %v", err)
}
assertFileEquals(t, filepath.Join(root, "VERSION"), "1.0.1\nstable\n0\n")
status := strings.TrimSpace(mustOutput(t, root, "git", "status", "--short"))
if status != "" {
t.Fatalf("git status --short = %q, want empty", status)
}
tagList := strings.TrimSpace(mustOutput(t, root, "git", "tag", "--list", "v1.0.1"))
if tagList != "v1.0.1" {
t.Fatalf("git tag --list v1.0.1 = %q, want v1.0.1", tagList)
}
branch := strings.TrimSpace(mustOutput(t, root, "git", "branch", "--show-current"))
remoteHead := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "origin/"+branch))
localHead := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD"))
if remoteHead != localHead {
t.Fatalf("origin/%s = %q, want %q", branch, remoteHead, localHead)
}
}
func MustParseVersion(t *testing.T, raw string) Version {
t.Helper()
v, err := ParseVersion(raw)
if err != nil {
t.Fatalf("ParseVersion(%q): %v", raw, err)
}
return v
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return string(data)
}
func mustRun(t *testing.T, dir string, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, string(out))
}
}
func mustOutput(t *testing.T, dir string, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, string(out))
}
return string(out)
}
func assertFileEquals(t *testing.T, path string, want string) {
t.Helper()
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
if got := string(gotBytes); got != want {
t.Fatalf("%s = %q, want %q", path, got, want)
}
}

View File

@@ -0,0 +1,129 @@
package release
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type Config struct {
RootDir string
AllowedChannels []string
ReleaseStepsJSON string
PostVersion string
Execution ExecutionOptions
Env []string
Stdout io.Writer
Stderr io.Writer
}
type ExecutionOptions struct {
Commit bool
Tag bool
Push bool
}
type Runner struct {
Config Config
}
func (o ExecutionOptions) Normalize() ExecutionOptions {
return ExecutionOptions{
Commit: true,
Tag: true,
Push: true,
}
}
func (r *Runner) Run(args []string) error {
rootDir, err := r.rootDir()
if err != nil {
return err
}
stdout := writerOrDiscard(r.Config.Stdout)
stderr := writerOrDiscard(r.Config.Stderr)
execution := r.Config.Execution.Normalize()
versionFile, versionPath, err := r.loadVersionFile(rootDir)
if err != nil {
return err
}
nextVersion, err := ResolveNextVersion(versionFile.Version, args, r.Config.AllowedChannels)
if err != nil {
return err
}
if err := requireCleanGit(rootDir); err != nil {
return err
}
versionFile.Version = nextVersion
if err := versionFile.Write(versionPath); err != nil {
return err
}
if err := r.runReleaseSteps(rootDir, versionPath, versionFile, nextVersion, stdout, stderr); err != nil {
return err
}
if err := r.runShell(rootDir, versionFile, nextVersion, r.Config.PostVersion, stdout, stderr); err != nil {
return err
}
if err := r.finalizeRelease(rootDir, nextVersion, execution, stdout, stderr); err != nil {
return err
}
return nil
}
func (r *Runner) rootDir() (string, error) {
if r.Config.RootDir != "" {
return r.Config.RootDir, nil
}
rootDir, err := gitOutput("", "rev-parse", "--show-toplevel")
if err != nil {
return "", err
}
return strings.TrimSpace(rootDir), nil
}
func (r *Runner) loadVersionFile(rootDir string) (*VersionFile, string, error) {
versionPath := filepath.Join(rootDir, "VERSION")
if _, err := os.Stat(versionPath); err != nil {
return nil, "", fmt.Errorf("VERSION file not found at %s", versionPath)
}
versionFile, err := ReadVersionFile(versionPath)
if err != nil {
return nil, "", err
}
return versionFile, versionPath, nil
}
func (r *Runner) finalizeRelease(rootDir string, version Version, execution ExecutionOptions, stdout io.Writer, stderr io.Writer) error {
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "nix", "fmt"); err != nil {
return err
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "add", "-A"); err != nil {
return err
}
commitMsg := "chore(release): " + version.Tag()
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "commit", "-m", commitMsg); err != nil {
return err
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "tag", version.Tag()); err != nil {
return err
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push"); err != nil {
return err
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push", "--tags"); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,90 @@
package release
import (
"io"
"strings"
)
const shellPrelude = `
log() { echo "[release] $*" >&2; }
version_meta_get() {
local key="${1-}"
local line
while IFS= read -r line; do
[[ $line == "$key="* ]] && printf '%s\n' "${line#*=}" && return 0
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
return 1
}
version_meta_set() {
local key="${1-}"
local value="${2-}"
local tmp
tmp="$(mktemp)"
awk -v key="$key" -v value="$value" '
NR <= 3 { print; next }
$0 ~ ("^" key "=") { print key "=" value; updated=1; next }
{ print }
END { if (!updated) print key "=" value }
' "$ROOT_DIR/VERSION" >"$tmp"
mv "$tmp" "$ROOT_DIR/VERSION"
export_version_meta_env
}
version_meta_unset() {
local key="${1-}"
local tmp
tmp="$(mktemp)"
awk -v key="$key" '
NR <= 3 { print; next }
$0 ~ ("^" key "=") { next }
{ print }
' "$ROOT_DIR/VERSION" >"$tmp"
mv "$tmp" "$ROOT_DIR/VERSION"
export_version_meta_env
}
export_version_meta_env() {
local line key value env_key
while IFS= read -r line; do
[[ $line == *=* ]] || continue
key="${line%%=*}"
value="${line#*=}"
env_key="$(printf '%s' "$key" | tr -c '[:alnum:]' '_' | tr '[:lower:]' '[:upper:]')"
export "VERSION_META_${env_key}=$value"
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
}
export_version_meta_env
`
func (r *Runner) runShell(rootDir string, versionFile *VersionFile, version Version, script string, stdout io.Writer, stderr io.Writer) error {
if strings.TrimSpace(script) == "" {
return nil
}
env := r.shellEnv(rootDir, versionFile, version)
_, err := runCommand(rootDir, env, stdout, stderr, "bash", "-euo", "pipefail", "-c", shellPrelude+"\n"+script)
return err
}
func (r *Runner) shellEnv(rootDir string, versionFile *VersionFile, version Version) []string {
envMap := buildReleaseEnv(rootDir, versionFile, version, r.Config.Env)
env := make([]string, 0, len(envMap))
for key, value := range envMap {
env = append(env, key+"="+value)
}
return env
}
func sanitizeMetaEnvName(key string) string {
var b strings.Builder
b.WriteString("VERSION_META_")
for _, r := range key {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r - 32)
case r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}

View File

@@ -0,0 +1,10 @@
package release
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,505 @@
package release
import (
"fmt"
"io"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
type CommandOption struct {
Title string
Description string
Command string
Args []string
NextVersion Version
Preview string
}
func IsInteractiveTerminal(stdin io.Reader, stdout io.Writer) bool {
in, inOK := stdin.(*os.File)
out, outOK := stdout.(*os.File)
if !inOK || !outOK {
return false
}
return term.IsTerminal(int(in.Fd())) && term.IsTerminal(int(out.Fd()))
}
func SelectCommand(config Config) ([]string, bool, error) {
r := &Runner{Config: config}
rootDir, err := r.rootDir()
if err != nil {
return nil, false, err
}
versionFile, _, err := r.loadVersionFile(rootDir)
if err != nil {
return nil, false, err
}
options := BuildCommandOptions(config, versionFile.Version)
if len(options) == 0 {
return nil, false, fmt.Errorf("no release commands available for current version %s", versionFile.Version.String())
}
model := newCommandPickerModel(config, versionFile.Version)
finalModel, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
if err != nil {
return nil, false, err
}
result := finalModel.(commandPickerModel)
if !result.confirmed {
return nil, false, nil
}
return append([]string(nil), result.selected.Args...), true, nil
}
func BuildCommandOptions(config Config, current Version) []CommandOption {
var options []CommandOption
seen := map[string]struct{}{}
for _, args := range candidateCommandArgs(current, config.AllowedChannels) {
command := formatReleaseCommand(args)
if _, exists := seen[command]; exists {
continue
}
next, err := ResolveNextVersion(current, args, config.AllowedChannels)
if err != nil {
continue
}
options = append(options, CommandOption{
Title: titleForArgs(args),
Description: descriptionForArgs(current, args, next),
Command: command,
Args: append([]string(nil), args...),
NextVersion: next,
Preview: buildPreview(config, current, args, next),
})
seen[command] = struct{}{}
}
return options
}
func candidateCommandArgs(current Version, allowedChannels []string) [][]string {
candidates := [][]string{
{"patch"},
{"minor"},
{"major"},
}
if current.Channel != "stable" {
candidates = append([][]string{{"stable"}}, candidates...)
}
for _, channel := range allowedChannels {
candidates = append(candidates,
[]string{channel},
[]string{"minor", channel},
[]string{"major", channel},
)
}
return candidates
}
func formatReleaseCommand(args []string) string {
return formatReleaseCommandWithExecution(args, ExecutionOptions{})
}
func formatReleaseCommandWithExecution(args []string, execution ExecutionOptions) string {
var parts []string
parts = append(parts, "release")
if len(args) == 0 {
return strings.Join(parts, " ")
}
return strings.Join(append(parts, args...), " ")
}
func titleForArgs(args []string) string {
if len(args) == 0 {
return "Patch release"
}
switch len(args) {
case 1:
switch args[0] {
case "patch":
return "Patch release"
case "minor":
return "Minor release"
case "major":
return "Major release"
case "stable":
return "Promote to stable"
default:
return strings.ToUpper(args[0][:1]) + args[0][1:] + " prerelease"
}
case 2:
return capitalize(args[0]) + " " + args[1]
default:
return strings.Join(args, " ")
}
}
func descriptionForArgs(current Version, args []string, next Version) string {
switch len(args) {
case 1:
switch args[0] {
case "patch":
return "Bump patch and keep the current channel."
case "minor":
return "Bump minor and keep the current channel."
case "major":
return "Bump major and keep the current channel."
case "stable":
return "Promote the current prerelease to a stable release."
default:
if current.Channel == args[0] && current.Channel != "stable" {
return "Advance the current prerelease number."
}
return "Switch to the " + args[0] + " channel."
}
case 2:
return fmt.Sprintf("Bump %s and publish to %s.", args[0], args[1])
default:
return "Release " + next.String() + "."
}
}
func buildPreview(config Config, current Version, args []string, next Version) string {
execution := config.Execution.Normalize()
var lines []string
lines = append(lines,
"Command",
" "+formatReleaseCommandWithExecution(args, execution),
"",
"Version",
" Current: "+current.String(),
" Next: "+next.String(),
" Tag: "+next.Tag(),
"",
"Flow",
" Release steps: "+yesNo(strings.TrimSpace(config.ReleaseStepsJSON) != ""),
" Post-version: "+yesNo(strings.TrimSpace(config.PostVersion) != ""),
" nix fmt: yes",
" git commit: "+yesNo(execution.Commit),
" git tag: "+yesNo(execution.Tag),
" git push: "+yesNo(execution.Push),
)
return strings.Join(lines, "\n")
}
func yesNo(v bool) string {
if v {
return "yes"
}
return "no"
}
func capitalize(s string) string {
if s == "" {
return s
}
runes := []rune(s)
first := runes[0]
if first >= 'a' && first <= 'z' {
runes[0] = first - 32
}
return string(runes)
}
type commandPickerModel struct {
config Config
current Version
width int
height int
focusSection int
focusIndex int
bumpOptions []selectionOption
channelOptions []selectionOption
bumpCursor int
channelCursor int
confirmed bool
selected CommandOption
err string
}
type selectionOption struct {
Label string
Value string
}
func newCommandPickerModel(config Config, current Version) commandPickerModel {
return commandPickerModel{
config: config,
current: current,
bumpOptions: buildBumpOptions(current),
channelOptions: buildChannelOptions(current, config.AllowedChannels),
}
}
func (m commandPickerModel) Init() tea.Cmd {
return nil
}
func (m commandPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k", "shift+tab":
m.moveFocus(-1)
case "down", "j", "tab":
m.moveFocus(1)
case " ":
m.selectFocused()
case "enter":
option, err := m.selectedOption()
if err != nil {
m.err = err.Error()
return m, nil
}
m.confirmed = true
m.selected = option
return m, tea.Quit
}
}
return m, nil
}
func (m commandPickerModel) View() string {
if len(m.bumpOptions) == 0 || len(m.channelOptions) == 0 {
return "No release commands available.\n"
}
preview := m.preview()
header := fmt.Sprintf("Release command picker\nCurrent version: %s\nUse up/down to move through options, Space to select, Enter to run, q to cancel.\n", m.current.String())
sections := strings.Join([]string{
m.renderSection("Bump type", m.bumpOptions, m.bumpCursor, m.focusSection == 0, m.focusedOptionIndex()),
"",
m.renderSection("Channel", m.channelOptions, m.channelCursor, m.focusSection == 1, m.focusedOptionIndex()),
}, "\n")
if m.width >= 100 {
return header + "\n" + renderColumns(sections, preview, m.width)
}
return header + "\n" + sections + "\n\n" + preview + "\n"
}
func buildBumpOptions(current Version) []selectionOption {
options := []selectionOption{
{Label: "Patch", Value: "patch"},
{Label: "Minor", Value: "minor"},
{Label: "Major", Value: "major"},
}
if current.Channel != "stable" {
options = append(options, selectionOption{
Label: "None",
Value: "",
})
return options
}
options = append(options, selectionOption{
Label: "None",
Value: "",
})
return options
}
func buildChannelOptions(current Version, allowedChannels []string) []selectionOption {
options := []selectionOption{{
Label: "Current",
Value: current.Channel,
}}
if current.Channel != "stable" {
options = append(options, selectionOption{
Label: "Stable",
Value: "stable",
})
}
for _, channel := range allowedChannels {
if channel == current.Channel {
continue
}
options = append(options, selectionOption{
Label: capitalize(channel),
Value: channel,
})
}
return options
}
func (m *commandPickerModel) moveFocus(delta int) {
total := len(m.bumpOptions) + len(m.channelOptions)
if total == 0 {
return
}
index := wrapIndex(m.focusIndex+delta, total)
m.focusIndex = index
if index < len(m.bumpOptions) {
m.focusSection = 0
return
}
m.focusSection = 1
}
func (m *commandPickerModel) selectFocused() {
if m.focusSection == 0 {
m.bumpCursor = m.focusedOptionIndex()
return
}
m.channelCursor = m.focusedOptionIndex()
}
func wrapIndex(idx int, size int) int {
if size == 0 {
return 0
}
for idx < 0 {
idx += size
}
return idx % size
}
func (m commandPickerModel) focusedOptionIndex() int {
if m.focusSection == 0 {
return m.focusIndex
}
return m.focusIndex - len(m.bumpOptions)
}
func (m commandPickerModel) renderSection(title string, options []selectionOption, cursor int, focused bool, focusedIndex int) string {
lines := []string{title}
for i, option := range options {
pointer := " "
if focused && i == focusedIndex {
pointer = ">"
}
radio := "( )"
if i == cursor {
radio = "(*)"
}
lines = append(lines, fmt.Sprintf("%s %s %s", pointer, radio, option.Label))
}
return strings.Join(lines, "\n")
}
func (m commandPickerModel) selectedArgs() []string {
bump := m.bumpOptions[m.bumpCursor].Value
channel := m.channelOptions[m.channelCursor].Value
if bump == "" {
if channel == "stable" {
return []string{"stable"}
}
if channel == m.current.Channel {
if channel == "stable" {
return nil
}
return []string{channel}
}
return []string{channel}
}
if channel == m.current.Channel || (channel == "stable" && m.current.Channel == "stable") {
return []string{bump}
}
return []string{bump, channel}
}
func (m commandPickerModel) selectedOption() (CommandOption, error) {
args := m.selectedArgs()
next, err := ResolveNextVersion(m.current, args, m.config.AllowedChannels)
if err != nil {
return CommandOption{}, err
}
return CommandOption{
Title: titleForArgs(args),
Description: descriptionForArgs(m.current, args, next),
Command: formatReleaseCommand(args),
Args: append([]string(nil), args...),
NextVersion: next,
Preview: buildPreview(m.config, m.current, args, next),
}, nil
}
func (m commandPickerModel) preview() string {
option, err := m.selectedOption()
if err != nil {
lines := []string{
"Command",
" " + formatReleaseCommand(m.selectedArgs()),
"",
"Selection",
" " + err.Error(),
}
if m.err != "" {
lines = append(lines, "", "Error", " "+m.err)
}
return strings.Join(lines, "\n")
}
return option.Preview
}
func renderColumns(left string, right string, width int) string {
if width < 40 {
return left + "\n\n" + right
}
leftWidth := width / 2
rightWidth := width - leftWidth - 3
leftLines := strings.Split(left, "\n")
rightLines := strings.Split(right, "\n")
maxLines := len(leftLines)
if len(rightLines) > maxLines {
maxLines = len(rightLines)
}
var b strings.Builder
for i := 0; i < maxLines; i++ {
leftLine := ""
if i < len(leftLines) {
leftLine = leftLines[i]
}
rightLine := ""
if i < len(rightLines) {
rightLine = rightLines[i]
}
b.WriteString(padRight(trimRunes(leftLine, leftWidth), leftWidth))
b.WriteString(" | ")
b.WriteString(trimRunes(rightLine, rightWidth))
b.WriteByte('\n')
}
return b.String()
}
func padRight(s string, width int) string {
missing := width - len([]rune(s))
if missing <= 0 {
return s
}
return s + strings.Repeat(" ", missing)
}
func trimRunes(s string, width int) string {
runes := []rune(s)
if len(runes) <= width {
return s
}
if width <= 1 {
return string(runes[:width])
}
if width <= 3 {
return string(runes[:width])
}
return string(runes[:width-3]) + "..."
}

View File

@@ -0,0 +1,212 @@
package release
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestBuildCommandOptionsForStableVersion(t *testing.T) {
t.Parallel()
current := MustParseVersion(t, "1.0.0")
options := BuildCommandOptions(Config{
AllowedChannels: []string{"alpha", "beta"},
ReleaseStepsJSON: `[{"kind":"writeFile","path":"VERSION.txt","text":"$FULL_VERSION\n"}]`,
PostVersion: "echo post",
Execution: ExecutionOptions{
Commit: true,
Tag: true,
Push: true,
},
}, current)
want := map[string]string{
"release patch": "1.0.1",
"release minor": "1.1.0",
"release major": "2.0.0",
"release alpha": "1.0.1-alpha.1",
"release minor beta": "1.1.0-beta.1",
}
for command, nextVersion := range want {
option, ok := findOptionByCommand(options, command)
if !ok {
t.Fatalf("expected command %q in options", command)
}
if option.NextVersion.String() != nextVersion {
t.Fatalf("%s next version = %q, want %q", command, option.NextVersion.String(), nextVersion)
}
if !strings.Contains(option.Preview, "Release steps: yes") {
t.Fatalf("%s preview missing release steps marker:\n%s", command, option.Preview)
}
if !strings.Contains(option.Preview, "Post-version: yes") {
t.Fatalf("%s preview missing post-version marker:\n%s", command, option.Preview)
}
if !strings.Contains(option.Preview, "git push: yes") {
t.Fatalf("%s preview missing git push marker:\n%s", command, option.Preview)
}
}
}
func TestBuildCommandOptionsForPrereleaseVersion(t *testing.T) {
t.Parallel()
current := MustParseVersion(t, "1.2.3-beta.2")
options := BuildCommandOptions(Config{
AllowedChannels: []string{"alpha", "beta", "rc"},
}, current)
stableOption, ok := findOptionByCommand(options, "release stable")
if !ok {
t.Fatalf("expected release stable option")
}
if stableOption.NextVersion.String() != "1.2.3" {
t.Fatalf("release stable next version = %q, want 1.2.3", stableOption.NextVersion.String())
}
betaOption, ok := findOptionByCommand(options, "release beta")
if !ok {
t.Fatalf("expected release beta option")
}
if betaOption.NextVersion.String() != "1.2.3-beta.3" {
t.Fatalf("release beta next version = %q, want 1.2.3-beta.3", betaOption.NextVersion.String())
}
patchOption, ok := findOptionByCommand(options, "release patch")
if !ok {
t.Fatalf("expected release patch option")
}
if patchOption.NextVersion.String() != "1.2.4-beta.1" {
t.Fatalf("release patch next version = %q, want 1.2.4-beta.1", patchOption.NextVersion.String())
}
}
func TestCommandPickerSelectionForStableVersion(t *testing.T) {
t.Parallel()
model := newCommandPickerModel(Config{
AllowedChannels: []string{"alpha", "beta"},
}, MustParseVersion(t, "1.2.3"))
model.bumpCursor = 1
model.channelCursor = 0
option, err := model.selectedOption()
if err != nil {
t.Fatalf("selectedOption(): %v", err)
}
if got := strings.Join(option.Args, " "); got != "minor" {
t.Fatalf("selected args = %q, want %q", got, "minor")
}
if option.NextVersion.String() != "1.3.0" {
t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.3.0")
}
model.bumpCursor = 3
model.channelCursor = 1
option, err = model.selectedOption()
if err != nil {
t.Fatalf("selectedOption(channel only): %v", err)
}
if got := strings.Join(option.Args, " "); got != "alpha" {
t.Fatalf("selected args = %q, want %q", got, "alpha")
}
if option.NextVersion.String() != "1.2.4-alpha.1" {
t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.2.4-alpha.1")
}
}
func TestCommandPickerSelectionForPrereleaseVersion(t *testing.T) {
t.Parallel()
model := newCommandPickerModel(Config{
AllowedChannels: []string{"alpha", "beta", "rc"},
}, MustParseVersion(t, "1.2.3-beta.2"))
model.bumpCursor = 3
model.channelCursor = 0
option, err := model.selectedOption()
if err != nil {
t.Fatalf("selectedOption(current prerelease): %v", err)
}
if got := strings.Join(option.Args, " "); got != "beta" {
t.Fatalf("selected args = %q, want %q", got, "beta")
}
if option.NextVersion.String() != "1.2.3-beta.3" {
t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.2.3-beta.3")
}
model.channelCursor = 1
option, err = model.selectedOption()
if err != nil {
t.Fatalf("selectedOption(promote): %v", err)
}
if got := strings.Join(option.Args, " "); got != "stable" {
t.Fatalf("selected args = %q, want %q", got, "stable")
}
if option.NextVersion.String() != "1.2.3" {
t.Fatalf("next version = %q, want %q", option.NextVersion.String(), "1.2.3")
}
model.bumpCursor = 0
if _, err := model.selectedOption(); err == nil {
t.Fatalf("selectedOption(patch stable from prerelease) succeeded, want error")
}
}
func TestCommandPickerFocusMovesAcrossSections(t *testing.T) {
t.Parallel()
model := newCommandPickerModel(Config{
AllowedChannels: []string{"alpha", "beta"},
}, MustParseVersion(t, "1.2.3"))
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(commandPickerModel)
if model.focusSection != 0 || model.focusIndex != 1 || model.bumpCursor != 0 {
t.Fatalf("after first down: focusSection=%d focusIndex=%d bumpCursor=%d", model.focusSection, model.focusIndex, model.bumpCursor)
}
next, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(commandPickerModel)
next, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(commandPickerModel)
next, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(commandPickerModel)
if model.focusSection != 1 || model.focusIndex != 4 || model.channelCursor != 0 {
t.Fatalf("after moving into channel section: focusSection=%d focusIndex=%d channelCursor=%d", model.focusSection, model.focusIndex, model.channelCursor)
}
next, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = next.(commandPickerModel)
if model.channelCursor != 0 {
t.Fatalf("space changed selection unexpectedly: channelCursor=%d", model.channelCursor)
}
next, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp})
model = next.(commandPickerModel)
if model.focusSection != 0 || model.focusIndex != 3 || model.bumpCursor != 0 {
t.Fatalf("after moving back up: focusSection=%d focusIndex=%d bumpCursor=%d", model.focusSection, model.focusIndex, model.bumpCursor)
}
next, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = next.(commandPickerModel)
if model.bumpCursor != 3 {
t.Fatalf("space did not update bump selection: bumpCursor=%d", model.bumpCursor)
}
}
func findOptionByCommand(options []CommandOption, command string) (CommandOption, bool) {
for _, option := range options {
if option.Command == command {
return option, true
}
}
return CommandOption{}, false
}

View File

@@ -0,0 +1,261 @@
package release
import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
var versionPattern = regexp.MustCompile(`^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([A-Za-z]+)\.([0-9]+))?$`)
type Version struct {
Major int
Minor int
Patch int
Channel string
Prerelease int
}
func ParseVersion(raw string) (Version, error) {
match := versionPattern.FindStringSubmatch(raw)
if match == nil {
return Version{}, fmt.Errorf("invalid version %q (expected x.y.z or x.y.z-channel.N)", raw)
}
major, _ := strconv.Atoi(match[1])
minor, _ := strconv.Atoi(match[2])
patch, _ := strconv.Atoi(match[3])
v := Version{
Major: major,
Minor: minor,
Patch: patch,
Channel: "stable",
}
if match[4] != "" {
pre, _ := strconv.Atoi(match[5])
v.Channel = match[4]
v.Prerelease = pre
}
return v, nil
}
func (v Version) String() string {
if v.Channel == "" || v.Channel == "stable" {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
return fmt.Sprintf("%d.%d.%d-%s.%d", v.Major, v.Minor, v.Patch, v.Channel, v.Prerelease)
}
func (v Version) BaseString() string {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
func (v Version) Tag() string {
return "v" + v.String()
}
func (v Version) cmp(other Version) int {
if v.Major != other.Major {
return compareInt(v.Major, other.Major)
}
if v.Minor != other.Minor {
return compareInt(v.Minor, other.Minor)
}
if v.Patch != other.Patch {
return compareInt(v.Patch, other.Patch)
}
if v.Channel == "stable" && other.Channel != "stable" {
return 1
}
if v.Channel != "stable" && other.Channel == "stable" {
return -1
}
if v.Channel == "stable" && other.Channel == "stable" {
return 0
}
if v.Channel != other.Channel {
return comparePrerelease(v.Channel, other.Channel)
}
return compareInt(v.Prerelease, other.Prerelease)
}
func ResolveNextVersion(current Version, args []string, allowedChannels []string) (Version, error) {
currentFull := current.String()
action := ""
rest := args
if len(rest) > 0 {
action = rest[0]
rest = rest[1:]
}
if action == "set" {
return resolveSetVersion(current, currentFull, rest, allowedChannels)
}
part := ""
targetChannel := ""
wasChannelOnly := false
switch action {
case "":
part = "patch"
case "major", "minor", "patch":
part = action
if len(rest) > 0 {
targetChannel = rest[0]
rest = rest[1:]
}
case "stable", "full":
if len(rest) > 0 {
return Version{}, fmt.Errorf("%q takes no second argument", action)
}
targetChannel = "stable"
default:
if contains(allowedChannels, action) {
if len(rest) > 0 {
return Version{}, errors.New("channel-only bump takes no second argument")
}
targetChannel = action
wasChannelOnly = true
} else {
return Version{}, fmt.Errorf("unknown argument %q", action)
}
}
if targetChannel == "" {
targetChannel = current.Channel
}
if err := validateChannel(targetChannel, allowedChannels); err != nil {
return Version{}, err
}
if current.Channel != "stable" && targetChannel == "stable" && action != "stable" && action != "full" {
return Version{}, fmt.Errorf("from prerelease channel %q, promote using 'stable' or 'full' only", current.Channel)
}
if part == "" && wasChannelOnly && current.Channel == "stable" && targetChannel != "stable" {
part = "patch"
}
next := current
oldBase := next.BaseString()
oldChannel := next.Channel
oldPre := next.Prerelease
if part != "" {
bumpVersion(&next, part)
}
if targetChannel == "stable" {
next.Channel = "stable"
next.Prerelease = 0
} else {
if next.BaseString() == oldBase && targetChannel == oldChannel && oldPre > 0 {
next.Prerelease = oldPre + 1
} else {
next.Prerelease = 1
}
next.Channel = targetChannel
}
if next.String() == currentFull {
return Version{}, fmt.Errorf("Version %s is already current; nothing to do.", next.String())
}
return next, nil
}
func resolveSetVersion(current Version, currentFull string, args []string, allowedChannels []string) (Version, error) {
if len(args) == 0 {
return Version{}, errors.New("'set' requires a version argument")
}
next, err := ParseVersion(args[0])
if err != nil {
return Version{}, err
}
if err := validateChannel(next.Channel, allowedChannels); err != nil {
return Version{}, err
}
if current.Channel != "stable" && next.Channel == "stable" {
return Version{}, fmt.Errorf("from prerelease channel %q, promote using 'stable' or 'full' only", current.Channel)
}
switch next.cmp(current) {
case 0:
return Version{}, fmt.Errorf("Version %s is already current; nothing to do.", next.String())
case -1:
return Version{}, fmt.Errorf("%s is lower than current %s", next.String(), currentFull)
}
return next, nil
}
func validateChannel(channel string, allowedChannels []string) error {
if channel == "" || channel == "stable" {
return nil
}
if contains(allowedChannels, channel) {
return nil
}
return fmt.Errorf("unknown channel %q", channel)
}
func bumpVersion(v *Version, part string) {
switch part {
case "major":
v.Major++
v.Minor = 0
v.Patch = 0
case "minor":
v.Minor++
v.Patch = 0
case "patch":
v.Patch++
default:
panic("unknown bump part: " + part)
}
}
func compareInt(left int, right int) int {
switch {
case left > right:
return 1
case left < right:
return -1
default:
return 0
}
}
func comparePrerelease(left string, right string) int {
values := []string{left, right}
sort.Slice(values, func(i int, j int) bool {
return semverLikeLess(values[i], values[j])
})
switch {
case left == right:
return 0
case values[len(values)-1] == left:
return 1
default:
return -1
}
}
func semverLikeLess(left string, right string) bool {
leftParts := strings.FieldsFunc(left, func(r rune) bool { return r == '.' || r == '-' })
rightParts := strings.FieldsFunc(right, func(r rune) bool { return r == '.' || r == '-' })
for i := 0; i < len(leftParts) && i < len(rightParts); i++ {
li, lerr := strconv.Atoi(leftParts[i])
ri, rerr := strconv.Atoi(rightParts[i])
switch {
case lerr == nil && rerr == nil:
if li != ri {
return li < ri
}
default:
if leftParts[i] != rightParts[i] {
return leftParts[i] < rightParts[i]
}
}
}
return len(leftParts) < len(rightParts)
}

View File

@@ -0,0 +1,112 @@
package release
import (
"bytes"
"fmt"
"os"
"strconv"
"strings"
)
type Metadata struct {
lines []string
}
func (m Metadata) Lines() []string {
return append([]string(nil), m.lines...)
}
func (m Metadata) Get(key string) string {
for _, line := range m.lines {
if strings.HasPrefix(line, key+"=") {
return strings.TrimPrefix(line, key+"=")
}
}
return ""
}
func (m *Metadata) Set(key string, value string) {
for i, line := range m.lines {
if strings.HasPrefix(line, key+"=") {
m.lines[i] = key + "=" + value
return
}
}
m.lines = append(m.lines, key+"="+value)
}
func (m *Metadata) Unset(key string) {
filtered := make([]string, 0, len(m.lines))
for _, line := range m.lines {
if strings.HasPrefix(line, key+"=") {
continue
}
filtered = append(filtered, line)
}
m.lines = filtered
}
type VersionFile struct {
Version Version
Metadata Metadata
}
func (f VersionFile) Current() Version {
return f.Version
}
func ReadVersionFile(path string) (*VersionFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
lines := strings.Split(text, "\n")
if len(lines) < 3 {
return nil, fmt.Errorf("invalid VERSION file %q", path)
}
base := strings.TrimSpace(lines[0])
channel := strings.TrimSpace(lines[1])
preRaw := strings.TrimSpace(lines[2])
if channel == "" {
channel = "stable"
}
rawVersion := base
if channel != "stable" {
rawVersion = fmt.Sprintf("%s-%s.%s", base, channel, preRaw)
}
version, err := ParseVersion(rawVersion)
if err != nil {
return nil, err
}
metaLines := make([]string, 0, len(lines)-3)
for _, line := range lines[3:] {
if line == "" {
continue
}
metaLines = append(metaLines, line)
}
return &VersionFile{
Version: version,
Metadata: Metadata{lines: metaLines},
}, nil
}
func (f *VersionFile) Write(path string) error {
channel := f.Version.Channel
pre := strconv.Itoa(f.Version.Prerelease)
if channel == "" || channel == "stable" {
channel = "stable"
pre = "0"
}
var buf bytes.Buffer
fmt.Fprintf(&buf, "%s\n%s\n%s\n", f.Version.BaseString(), channel, pre)
for _, line := range f.Metadata.lines {
fmt.Fprintln(&buf, line)
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}

View File

@@ -1,4 +1,5 @@
{
flake-parts,
nixpkgs,
treefmt-nix,
lefthookNix,
@@ -7,6 +8,7 @@
}:
import ../repo-lib/lib.nix {
inherit
flake-parts
nixpkgs
treefmt-nix
lefthookNix

View File

@@ -2,539 +2,18 @@
set -euo pipefail
ROOT_DIR="$(git rev-parse --show-toplevel)"
GITLINT_FILE="$ROOT_DIR/.gitlint"
START_HEAD=""
CREATED_TAG=""
VERSION_META_LINES=()
VERSION_META_EXPORT_NAMES=()
# ── logging ────────────────────────────────────────────────────────────────
log() { echo "[release] $*" >&2; }
usage() {
local cmd
cmd="$(basename "$0")"
printf '%s\n' \
"Usage:" \
" ${cmd} [major|minor|patch] [stable|__CHANNEL_LIST__]" \
" ${cmd} set <version>" \
"" \
"Bump types:" \
" (none) bump patch, keep current channel" \
" major/minor/patch bump the given part, keep current channel" \
" stable / full remove prerelease suffix (only opt-in path to promote prerelease -> stable)" \
" __CHANNEL_LIST__ switch channel (from stable, auto-bumps patch unless bump is specified)" \
"" \
"Safety rule:" \
" If current version is prerelease (e.g. x.y.z-beta.N), promotion to stable is allowed only via 'stable' or 'full'." \
" Commands like '${cmd} set x.y.z' or '${cmd} patch stable' are blocked from prerelease channels." \
"" \
"Examples:" \
" ${cmd} # patch bump on current channel" \
" ${cmd} minor # minor bump on current channel" \
" ${cmd} beta # from stable: patch bump + beta.1" \
" ${cmd} patch beta # patch bump, switch to beta channel" \
" ${cmd} rc # switch to rc channel" \
" ${cmd} stable # promote prerelease to stable (opt-in)" \
" ${cmd} set 1.2.3" \
" ${cmd} set 1.2.3-beta.1"
}
# ── git ────────────────────────────────────────────────────────────────────
require_clean_git() {
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Error: git working tree is not clean. Commit or stash changes first." >&2
exit 1
fi
}
revert_on_failure() {
local status=$?
if [[ -n $START_HEAD ]]; then
log "Release failed — reverting to $START_HEAD"
git reset --hard "$START_HEAD"
fi
if [[ -n $CREATED_TAG ]]; then
git tag -d "$CREATED_TAG" >/dev/null 2>&1 || true
fi
exit $status
}
# ── version parsing ────────────────────────────────────────────────────────
parse_base_version() {
local v="$1"
if [[ ! $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Error: invalid base version '$v' (expected x.y.z)" >&2
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
}
parse_full_version() {
local v="$1"
CHANNEL="stable"
PRERELEASE_NUM=""
if [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)-([a-zA-Z]+)\.([0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
CHANNEL="${BASH_REMATCH[2]}"
PRERELEASE_NUM="${BASH_REMATCH[3]}"
elif [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
BASE_VERSION="${BASH_REMATCH[1]}"
else
echo "Error: invalid version '$v' (expected x.y.z or x.y.z-channel.N)" >&2
exit 1
fi
parse_base_version "$BASE_VERSION"
}
validate_channel() {
local ch="$1"
[[ $ch == "stable" ]] && return 0
local valid_channels="__CHANNEL_LIST__"
for c in $valid_channels; do
[[ $ch == "$c" ]] && return 0
done
echo "Error: unknown channel '$ch'. Valid channels: stable $valid_channels" >&2
exit 1
}
version_cmp() {
# Returns: 0 if equal, 1 if v1 > v2, 2 if v1 < v2
# Stable > prerelease for same base version
local v1="$1" v2="$2"
[[ $v1 == "$v2" ]] && return 0
local base1="" pre1="" base2="" pre2=""
if [[ $v1 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
base1="${BASH_REMATCH[1]}"
pre1="${BASH_REMATCH[2]}"
else
base1="$v1"
fi
if [[ $v2 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
base2="${BASH_REMATCH[1]}"
pre2="${BASH_REMATCH[2]}"
else
base2="$v2"
fi
if [[ $base1 != "$base2" ]]; then
local highest_base
highest_base=$(printf '%s\n%s\n' "$base1" "$base2" | sort -V | tail -n1)
[[ $highest_base == "$base1" ]] && return 1 || return 2
fi
[[ -z $pre1 && -n $pre2 ]] && return 1 # stable > prerelease
[[ -n $pre1 && -z $pre2 ]] && return 2 # prerelease < stable
[[ -z $pre1 && -z $pre2 ]] && return 0 # both stable
local highest_pre
highest_pre=$(printf '%s\n%s\n' "$pre1" "$pre2" | sort -V | tail -n1)
[[ $highest_pre == "$pre1" ]] && return 1 || return 2
}
bump_base_version() {
case "$1" in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch) PATCH=$((PATCH + 1)) ;;
*)
echo "Error: unknown bump part '$1'" >&2
exit 1
;;
esac
BASE_VERSION="${MAJOR}.${MINOR}.${PATCH}"
}
compute_full_version() {
if [[ $CHANNEL == "stable" || -z $CHANNEL ]]; then
FULL_VERSION="$BASE_VERSION"
else
FULL_VERSION="${BASE_VERSION}-${CHANNEL}.${PRERELEASE_NUM:-1}"
fi
FULL_TAG="v$FULL_VERSION"
export BASE_VERSION CHANNEL PRERELEASE_NUM FULL_VERSION FULL_TAG
}
meta_env_name() {
local key="$1"
key="${key//[^[:alnum:]]/_}"
key="$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')"
printf 'VERSION_META_%s\n' "$key"
}
clear_version_meta_exports() {
local export_name
for export_name in "${VERSION_META_EXPORT_NAMES[@]:-}"; do
unset "$export_name"
done
VERSION_META_EXPORT_NAMES=()
}
load_version_metadata() {
VERSION_META_LINES=()
[[ ! -f "$ROOT_DIR/VERSION" ]] && return 0
while IFS= read -r line || [[ -n $line ]]; do
VERSION_META_LINES+=("$line")
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
}
export_version_metadata() {
clear_version_meta_exports
local line key value export_name
for line in "${VERSION_META_LINES[@]:-}"; do
[[ $line != *=* ]] && continue
key="${line%%=*}"
value="${line#*=}"
[[ -z $key ]] && continue
export_name="$(meta_env_name "$key")"
printf -v "$export_name" '%s' "$value"
export "${export_name?}=$value"
VERSION_META_EXPORT_NAMES+=("$export_name")
done
}
write_version_file() {
local channel_to_write="$1"
local n_to_write="$2"
{
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write"
local line
for line in "${VERSION_META_LINES[@]:-}"; do
printf '%s\n' "$line"
done
} >"$ROOT_DIR/VERSION"
}
version_meta_get() {
local key="${1-}"
local line
for line in "${VERSION_META_LINES[@]:-}"; do
if [[ $line == "$key="* ]]; then
printf '%s\n' "${line#*=}"
return 0
fi
done
return 1
}
version_meta_set() {
local key="${1-}"
local value="${2-}"
[[ -z $key ]] && echo "Error: version_meta_set requires a key" >&2 && exit 1
local updated=0
local index
for index in "${!VERSION_META_LINES[@]}"; do
if [[ ${VERSION_META_LINES[index]} == "$key="* ]]; then
VERSION_META_LINES[index]="$key=$value"
updated=1
break
fi
done
if [[ $updated -eq 0 ]]; then
VERSION_META_LINES+=("$key=$value")
fi
export_version_metadata
version_meta_write
}
version_meta_unset() {
local key="${1-}"
[[ -z $key ]] && echo "Error: version_meta_unset requires a key" >&2 && exit 1
local filtered=()
local line
for line in "${VERSION_META_LINES[@]:-}"; do
[[ $line == "$key="* ]] && continue
filtered+=("$line")
done
VERSION_META_LINES=("${filtered[@]}")
export_version_metadata
version_meta_write
}
version_meta_write() {
local channel_to_write="$CHANNEL"
local n_to_write="${PRERELEASE_NUM:-1}"
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
channel_to_write="stable"
n_to_write="0"
fi
write_version_file "$channel_to_write" "$n_to_write"
}
# ── gitlint ────────────────────────────────────────────────────────────────
get_gitlint_title_regex() {
[[ ! -f $GITLINT_FILE ]] && return 0
awk '
/^\[title-match-regex\]$/ { in_section=1; next }
/^\[/ { in_section=0 }
in_section && /^regex=/ { sub(/^regex=/, ""); print; exit }
' "$GITLINT_FILE"
}
validate_commit_message() {
local msg="$1"
local regex
regex="$(get_gitlint_title_regex)"
if [[ -n $regex && ! $msg =~ $regex ]]; then
echo "Error: commit message does not match .gitlint title-match-regex" >&2
echo "Regex: $regex" >&2
echo "Message: $msg" >&2
exit 1
fi
}
# ── version file generation ────────────────────────────────────────────────
run_release_steps() {
:
__RELEASE_STEPS__
}
# ── version source (built-in) ──────────────────────────────────────────────
# Initializes $ROOT_DIR/VERSION from git tags if it doesn't exist.
# Must be called outside of any subshell so log output stays on stderr
# and never contaminates the stdout of do_read_version.
init_version_file() {
if [[ -f "$ROOT_DIR/VERSION" ]]; then
load_version_metadata
export_version_metadata
return 0
fi
local highest_tag=""
while IFS= read -r raw_tag; do
local tag="${raw_tag#v}"
[[ $tag =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$ ]] || continue
if [[ -z $highest_tag ]]; then
highest_tag="$tag"
continue
fi
local cmp_status=0
version_cmp "$tag" "$highest_tag" || cmp_status=$?
[[ $cmp_status -eq 1 ]] && highest_tag="$tag"
done < <(git tag --list)
[[ -z $highest_tag ]] && highest_tag="0.0.1"
parse_full_version "$highest_tag"
local channel_to_write="$CHANNEL"
local n_to_write="${PRERELEASE_NUM:-1}"
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
channel_to_write="stable"
n_to_write="0"
fi
VERSION_META_LINES=()
write_version_file "$channel_to_write" "$n_to_write"
export_version_metadata
log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag"
}
do_read_version() {
load_version_metadata
export_version_metadata
local base_line channel_line n_line
base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')"
channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')"
n_line="$(sed -n '3p' "$ROOT_DIR/VERSION" | tr -d '\r')"
if [[ -z $channel_line || $channel_line == "stable" ]]; then
printf '%s\n' "$base_line"
else
printf '%s-%s.%s\n' "$base_line" "$channel_line" "$n_line"
fi
}
do_write_version() {
local channel_to_write="$CHANNEL"
local n_to_write="${PRERELEASE_NUM:-1}"
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
channel_to_write="stable"
n_to_write="0"
fi
write_version_file "$channel_to_write" "$n_to_write"
export_version_metadata
}
# ── user-provided hook ─────────────────────────────────────────────────────
do_post_version() {
:
__POST_VERSION__
}
# ── main ───────────────────────────────────────────────────────────────────
main() {
[[ ${1-} == "-h" || ${1-} == "--help" ]] && usage && exit 0
require_clean_git
START_HEAD="$(git rev-parse HEAD)"
trap revert_on_failure ERR
# Initialize VERSION file outside any subshell so log lines never
# bleed into the stdout capture below.
init_version_file
local raw_version
raw_version="$(do_read_version | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$' | tail -n1)"
if [[ -z $raw_version ]]; then
echo "Error: could not determine current version from VERSION source" >&2
exit 1
fi
parse_full_version "$raw_version"
compute_full_version
local current_full="$FULL_VERSION"
log "Current: base=$BASE_VERSION channel=$CHANNEL pre=${PRERELEASE_NUM:-}"
local action="${1-}"
shift || true
if [[ $action == "set" ]]; then
local newv="${1-}"
local current_channel="$CHANNEL"
[[ -z $newv ]] && echo "Error: 'set' requires a version argument" >&2 && exit 1
parse_full_version "$newv"
validate_channel "$CHANNEL"
if [[ $current_channel != "stable" && $CHANNEL == "stable" ]]; then
echo "Error: from prerelease channel '$current_channel', promote using 'stable' or 'full' only" >&2
exit 1
fi
compute_full_version
local cmp_status=0
version_cmp "$FULL_VERSION" "$current_full" || cmp_status=$?
case $cmp_status in
0)
echo "Version $FULL_VERSION is already current; nothing to do." >&2
exit 1
;;
2)
echo "Error: $FULL_VERSION is lower than current $current_full" >&2
exit 1
;;
esac
else
local part="" target_channel="" was_channel_only=0
case "$action" in
"") part="patch" ;;
major | minor | patch)
part="$action"
target_channel="${1-}"
;;
stable | full)
[[ -n ${1-} ]] && echo "Error: '$action' takes no second argument" >&2 && usage && exit 1
target_channel="stable"
;;
*)
# check if action is a valid channel
local is_channel=0
for c in __CHANNEL_LIST__; do
[[ $action == "$c" ]] && is_channel=1 && break
done
if [[ $is_channel == 1 ]]; then
[[ -n ${1-} ]] && echo "Error: channel-only bump takes no second argument" >&2 && usage && exit 1
target_channel="$action"
was_channel_only=1
else
echo "Error: unknown argument '$action'" >&2
usage
exit 1
fi
;;
esac
[[ -z $target_channel ]] && target_channel="$CHANNEL"
[[ $target_channel == "full" ]] && target_channel="stable"
validate_channel "$target_channel"
if [[ $CHANNEL != "stable" && $target_channel == "stable" && $action != "stable" && $action != "full" ]]; then
echo "Error: from prerelease channel '$CHANNEL', promote using 'stable' or 'full' only" >&2
exit 1
fi
if [[ -z $part && $was_channel_only -eq 1 && $CHANNEL == "stable" && $target_channel != "stable" ]]; then
part="patch"
fi
local old_base="$BASE_VERSION" old_channel="$CHANNEL" old_pre="$PRERELEASE_NUM"
[[ -n $part ]] && bump_base_version "$part"
if [[ $target_channel == "stable" ]]; then
CHANNEL="stable"
PRERELEASE_NUM=""
else
if [[ $BASE_VERSION == "$old_base" && $target_channel == "$old_channel" && -n $old_pre ]]; then
PRERELEASE_NUM=$((old_pre + 1))
else
PRERELEASE_NUM=1
fi
CHANNEL="$target_channel"
fi
fi
compute_full_version
if [[ $FULL_VERSION == "$current_full" ]]; then
echo "Version $FULL_VERSION is already current; nothing to do." >&2
exit 1
fi
log "Releasing $FULL_VERSION"
do_write_version
log "Updated version source"
run_release_steps
log "Release steps done"
do_post_version
log "Post-version hook done"
(cd "$ROOT_DIR" && nix fmt)
log "Formatted files"
git add -A
local commit_msg="chore(release): v$FULL_VERSION"
validate_commit_message "$commit_msg"
git commit -m "$commit_msg"
log "Created commit"
git tag "$FULL_TAG"
CREATED_TAG="$FULL_TAG"
log "Tagged $FULL_TAG"
git push
git push --tags
log "Done — released $FULL_TAG"
trap - ERR
}
main "$@"
REPO_LIB_RELEASE_ROOT_DIR="$(git rev-parse --show-toplevel)"
export REPO_LIB_RELEASE_ROOT_DIR
export REPO_LIB_RELEASE_CHANNELS='__CHANNEL_LIST__'
REPO_LIB_RELEASE_STEPS_JSON="$(cat <<'EOF'
__RELEASE_STEPS_JSON__
EOF
)"
export REPO_LIB_RELEASE_STEPS_JSON
REPO_LIB_RELEASE_POST_VERSION="$(cat <<'EOF'
__POST_VERSION__
EOF
)"
export REPO_LIB_RELEASE_POST_VERSION
exec __RELEASE_RUNNER__ "$@"

View File

@@ -1,4 +1,5 @@
{
flake-parts,
nixpkgs,
treefmt-nix,
lefthookNix,
@@ -6,58 +7,12 @@
shellHookTemplatePath,
}:
let
lib = nixpkgs.lib;
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
defaultReleaseChannels = [
"alpha"
"beta"
"rc"
"internal"
];
importPkgs = nixpkgsInput: system: import nixpkgsInput { inherit system; };
duplicateStrings =
names:
lib.unique (
builtins.filter (
name: builtins.length (builtins.filter (candidate: candidate == name) names) > 1
) names
);
mergeUniqueAttrs =
label: left: right:
let
overlap = builtins.attrNames (lib.intersectAttrs left right);
in
if overlap != [ ] then
throw "repo-lib: duplicate ${label}: ${lib.concatStringsSep ", " overlap}"
else
left // right;
sanitizeName = name: lib.strings.sanitizeDerivationName name;
defaultShellBanner = {
style = "simple";
icon = "🚀";
title = "Dev shell ready";
titleColor = "GREEN";
subtitle = "";
subtitleColor = "GRAY";
borderColor = "BLUE";
};
defaults = import ./lib/defaults.nix { };
common = import ./lib/common.nix { inherit nixpkgs; };
normalizeShellBanner =
rawBanner:
let
banner = defaultShellBanner // rawBanner;
banner = defaults.defaultShellBanner // rawBanner;
in
if
!(builtins.elem banner.style [
@@ -68,812 +23,78 @@ let
throw "repo-lib: config.shell.banner.style must be one of simple or pretty"
else
banner;
normalizeStrictTool =
pkgs: tool:
let
version = {
args = [ "--version" ];
match = null;
regex = null;
group = 0;
line = 1;
}
// (tool.version or { });
banner = {
color = "YELLOW";
icon = null;
iconColor = null;
}
// (tool.banner or { });
executable =
if tool ? command && tool.command != null then
tool.command
else if tool ? exe && tool.exe != null then
"${lib.getExe' tool.package tool.exe}"
else
"${lib.getExe tool.package}";
in
if !(tool ? command && tool.command != null) && !(tool ? package) then
throw "repo-lib: tool '${tool.name or "<unnamed>"}' is missing 'package' or 'command'"
else
{
kind = "strict";
inherit executable version banner;
name = tool.name;
package = tool.package or null;
required = tool.required or true;
};
normalizeLegacyTool =
pkgs: tool:
if tool ? package then
normalizeStrictTool pkgs tool
else
{
kind = "legacy";
name = tool.name;
command = tool.bin;
versionCommand = tool.versionCmd or "--version";
banner = {
color = tool.color or "YELLOW";
icon = tool.icon or null;
iconColor = tool.iconColor or null;
};
required = tool.required or false;
};
checkToLefthookConfig =
pkgs: name: rawCheck:
let
check = {
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [ ];
}
// rawCheck;
wrapperName = "repo-lib-check-${sanitizeName name}";
wrapper = pkgs.writeShellApplication {
name = wrapperName;
runtimeInputs = check.runtimeInputs;
text = ''
set -euo pipefail
${check.command}
'';
};
in
if !(check ? command) then
throw "repo-lib: check '${name}' is missing 'command'"
else if
!(builtins.elem check.stage [
"pre-commit"
"pre-push"
])
then
throw "repo-lib: check '${name}' has unsupported stage '${check.stage}'"
else
lib.setAttrByPath [ check.stage "commands" name ] {
run = "${wrapper}/bin/${wrapperName}${hookStageFileArgs check.stage check.passFilenames}";
};
normalizeLefthookConfig =
label: raw: if builtins.isAttrs raw then raw else throw "repo-lib: ${label} must be an attrset";
normalizeHookStage =
hookName: stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
"commit-msg"
]
then
stage
else
throw "repo-lib: hook '${hookName}' has unsupported stage '${stage}' for lefthook";
hookStageFileArgs =
stage: passFilenames:
if !passFilenames then
""
else if stage == "pre-commit" then
" {staged_files}"
else if stage == "pre-push" then
" {push_files}"
else if stage == "commit-msg" then
" {1}"
else
throw "repo-lib: unsupported lefthook stage '${stage}'";
hookToLefthookConfig =
name: hook:
let
supportedFields = [
"description"
"enable"
"entry"
"name"
"package"
"pass_filenames"
"stages"
];
unsupportedFields = builtins.filter (field: !(builtins.elem field supportedFields)) (
builtins.attrNames hook
);
stages = builtins.map (stage: normalizeHookStage name stage) (hook.stages or [ "pre-commit" ]);
passFilenames = hook.pass_filenames or false;
in
if unsupportedFields != [ ] then
throw ''
repo-lib: hook '${name}' uses unsupported fields for lefthook: ${lib.concatStringsSep ", " unsupportedFields}
''
else if !(hook ? entry) then
throw "repo-lib: hook '${name}' is missing 'entry'"
else
lib.foldl' lib.recursiveUpdate { } (
builtins.map (
stage:
lib.setAttrByPath [ stage "commands" name ] {
run = "${hook.entry}${hookStageFileArgs stage passFilenames}";
}
) stages
);
parallelHookStageConfig =
stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
]
then
lib.setAttrByPath [ stage "parallel" ] true
else
{ };
normalizeReleaseStep =
step:
if step ? writeFile then
{
kind = "writeFile";
path = step.writeFile.path;
text = step.writeFile.text;
runtimeInputs = [ ];
}
else if step ? replace then
{
kind = "replace";
path = step.replace.path;
regex = step.replace.regex;
replacement = step.replace.replacement;
runtimeInputs = [ ];
}
else if step ? run && builtins.isAttrs step.run then
{
kind = "run";
script = step.run.script;
runtimeInputs = step.run.runtimeInputs or [ ];
}
else if step ? run then
{
kind = "run";
script = step.run;
runtimeInputs = [ ];
}
else if step ? file then
{
kind = "writeFile";
path = step.file;
text = step.content;
runtimeInputs = [ ];
}
else
throw "repo-lib: release step must contain one of writeFile, replace, or run";
releaseStepScript =
step:
if step.kind == "writeFile" then
''
target_path="$ROOT_DIR/${step.path}"
mkdir -p "$(dirname "$target_path")"
cat >"$target_path" << NIXEOF
${step.text}
NIXEOF
log "Generated version file: ${step.path}"
''
else if step.kind == "replace" then
''
target_path="$ROOT_DIR/${step.path}"
REPO_LIB_STEP_REGEX=$(cat <<'NIXEOF'
${step.regex}
NIXEOF
)
REPO_LIB_STEP_REPLACEMENT=$(cat <<NIXEOF
${step.replacement}
NIXEOF
)
export REPO_LIB_STEP_REGEX REPO_LIB_STEP_REPLACEMENT
perl - "$target_path" <<'REPO_LIB_PERL_REPLACE'
use strict;
use warnings;
my $path = shift @ARGV;
my $regex_src = $ENV{"REPO_LIB_STEP_REGEX"} // q{};
my $template = $ENV{"REPO_LIB_STEP_REPLACEMENT"} // q{};
open my $in, q{<}, $path or die "failed to open $path: $!";
local $/ = undef;
my $content = <$in>;
close $in;
my $regex = qr/$regex_src/ms;
$content =~ s{$regex}{
my @cap = map { defined $_ ? $_ : q{} } ($1, $2, $3, $4, $5, $6, $7, $8, $9);
my $result = $template;
$result =~ s{\\([1-9])}{$cap[$1 - 1]}ge;
$result;
}gems;
open my $out, q{>}, $path or die "failed to open $path for write: $!";
print {$out} $content;
close $out;
REPO_LIB_PERL_REPLACE
log "Updated ${step.path}"
''
else
''
${step.script}
'';
normalizeReleaseConfig =
raw:
let
hasLegacySteps = raw ? release;
hasStructuredSteps = raw ? steps;
steps =
if hasLegacySteps && hasStructuredSteps then
throw "repo-lib: pass either 'release' or 'steps' to mkRelease, not both"
else if hasStructuredSteps then
builtins.map normalizeReleaseStep raw.steps
else if hasLegacySteps then
builtins.map normalizeReleaseStep raw.release
else
[ ];
in
{
postVersion = raw.postVersion or "";
channels = raw.channels or defaultReleaseChannels;
runtimeInputs = (raw.runtimeInputs or [ ]) ++ (raw.extraRuntimeInputs or [ ]);
steps = steps;
};
buildShellHook =
{
hooksShellHook,
shellEnvScript,
bootstrap,
shellBannerScript,
extraShellText,
toolLabelWidth,
}:
let
template = builtins.readFile shellHookTemplatePath;
in
builtins.replaceStrings
[
"@HOOKS_SHELL_HOOK@"
"@TOOL_LABEL_WIDTH@"
"@SHELL_ENV_SCRIPT@"
"@BOOTSTRAP@"
"@SHELL_BANNER_SCRIPT@"
"@EXTRA_SHELL_TEXT@"
]
[
hooksShellHook
(toString toolLabelWidth)
shellEnvScript
bootstrap
shellBannerScript
extraShellText
]
template;
buildShellArtifacts =
{
pkgs,
system,
src,
includeStandardPackages ? true,
formatting,
tools ? [ ],
shellConfig ? {
env = { };
extraShellText = "";
bootstrap = "";
banner = defaultShellBanner;
},
checkSpecs ? { },
rawHookEntries ? { },
lefthookConfig ? { },
extraPackages ? [ ],
}:
let
standardPackages = with pkgs; [
nixfmt
gitlint
gitleaks
shfmt
];
toolPackages = lib.filter (pkg: pkg != null) (builtins.map (tool: tool.package or null) tools);
selectedStandardPackages = lib.optionals includeStandardPackages standardPackages;
treefmtEval = treefmt-nix.lib.evalModule pkgs {
projectRootFile = "flake.nix";
programs = {
nixfmt.enable = true;
}
// formatting.programs;
settings.formatter = { } // formatting.settings;
};
treefmtWrapper = treefmtEval.config.build.wrapper;
lefthookBinWrapper = pkgs.writeShellScript "lefthook-dumb-term" ''
exec env TERM=dumb ${lib.getExe pkgs.lefthook} "$@"
'';
normalizedLefthookConfig = normalizeLefthookConfig "lefthook config" lefthookConfig;
lefthookCheck = lefthookNix.lib.${system}.run {
inherit src;
config = lib.foldl' lib.recursiveUpdate { } (
[
{
output = [
"failure"
"summary"
];
}
(parallelHookStageConfig "pre-commit")
(parallelHookStageConfig "pre-push")
(lib.setAttrByPath [ "pre-commit" "commands" "treefmt" ] {
run = "${treefmtWrapper}/bin/treefmt --no-cache {staged_files}";
stage_fixed = true;
})
(lib.setAttrByPath [ "pre-commit" "commands" "gitleaks" ] {
run = "${pkgs.gitleaks}/bin/gitleaks protect --staged";
})
(lib.setAttrByPath [ "commit-msg" "commands" "gitlint" ] {
run = "${pkgs.gitlint}/bin/gitlint --staged --msg-filename {1}";
})
]
++ lib.mapAttrsToList (name: check: checkToLefthookConfig pkgs name check) checkSpecs
++ lib.mapAttrsToList hookToLefthookConfig rawHookEntries
++ [ normalizedLefthookConfig ]
);
};
selectedCheckOutputs = {
formatting-check = treefmtEval.config.build.check src;
hook-check = lefthookCheck;
lefthook-check = lefthookCheck;
};
toolNames = builtins.map (tool: tool.name) tools;
toolNameWidth =
if toolNames == [ ] then
0
else
builtins.foldl' (maxWidth: name: lib.max maxWidth (builtins.stringLength name)) 0 toolNames;
toolLabelWidth = toolNameWidth + 1;
shellEnvScript = lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value: "export ${name}=${lib.escapeShellArg (toString value)}"
) shellConfig.env
);
banner = normalizeShellBanner (shellConfig.banner or { });
shellBannerScript =
if banner.style == "pretty" then
''
repo_lib_print_pretty_header \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg banner.title} \
${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg banner.subtitle}
''
+ lib.concatMapStrings (
tool:
if tool.kind == "strict" then
''
repo_lib_print_pretty_tool \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg (toString tool.version.line)} \
${lib.escapeShellArg (toString tool.version.group)} \
${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \
${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \
${lib.escapeShellArg tool.executable} \
${lib.escapeShellArgs tool.version.args}
''
else
''
repo_lib_print_pretty_legacy_tool \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg tool.command} \
${lib.escapeShellArg tool.versionCommand}
''
) tools
+ ''
repo_lib_print_pretty_footer \
${lib.escapeShellArg banner.borderColor}
''
else
''
repo_lib_print_simple_header \
${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg banner.title} \
${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg banner.subtitle}
''
+ lib.concatMapStrings (
tool:
if tool.kind == "strict" then
''
repo_lib_print_simple_tool \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg (toString tool.version.line)} \
${lib.escapeShellArg (toString tool.version.group)} \
${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \
${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \
${lib.escapeShellArg tool.executable} \
${lib.escapeShellArgs tool.version.args}
''
else
''
repo_lib_print_simple_legacy_tool \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg tool.command} \
${lib.escapeShellArg tool.versionCommand}
''
) tools
+ ''
printf "\n"
'';
in
{
checks = selectedCheckOutputs;
formatter = treefmtWrapper;
shell = pkgs.mkShell {
LEFTHOOK_BIN = builtins.toString lefthookBinWrapper;
packages = lib.unique (
selectedStandardPackages
++ extraPackages
++ toolPackages
++ [
pkgs.lefthook
treefmtWrapper
]
);
shellHook = buildShellHook {
hooksShellHook = lefthookCheck.shellHook;
inherit toolLabelWidth shellEnvScript shellBannerScript;
bootstrap = shellConfig.bootstrap;
extraShellText = shellConfig.extraShellText;
};
};
}
// selectedCheckOutputs;
toolsModule = import ./lib/tools.nix {
lib = common.lib;
};
hooksModule = import ./lib/hooks.nix {
inherit (common) lib sanitizeName;
};
shellModule = import ./lib/shell.nix {
inherit (common)
lib
;
inherit
treefmt-nix
lefthookNix
shellHookTemplatePath
;
inherit (defaults)
defaultShellBanner
;
inherit normalizeShellBanner;
inherit (hooksModule)
normalizeLefthookConfig
parallelHookStageConfig
checkToLefthookConfig
hookToLefthookConfig
;
};
releaseModule = import ./lib/release.nix {
inherit (common)
lib
importPkgs
;
inherit
nixpkgs
releaseScriptPath
;
inherit (defaults)
defaultReleaseChannels
;
};
repoModule = import ./lib/repo.nix {
inherit
flake-parts
nixpkgs
;
inherit (common)
lib
importPkgs
duplicateStrings
mergeUniqueAttrs
;
inherit (defaults)
supportedSystems
defaultReleaseChannels
;
inherit (toolsModule)
normalizeStrictTool
;
inherit (hooksModule)
normalizeLefthookConfig
;
inherit normalizeShellBanner;
inherit (shellModule)
buildShellArtifacts
;
inherit (releaseModule)
mkRelease
;
};
in
rec {
systems = {
default = supportedSystems;
};
tools = rec {
fromPackage =
{
name,
package,
exe ? null,
version ? { },
banner ? { },
required ? true,
}:
{
inherit
name
package
exe
version
banner
required
;
};
fromCommand =
{
name,
command,
version ? { },
banner ? { },
required ? true,
}:
{
inherit
name
command
version
banner
required
;
};
simple =
name: package: args:
fromPackage {
inherit name package;
version.args = args;
};
};
normalizeRepoConfig =
rawConfig:
let
merged = lib.recursiveUpdate {
includeStandardPackages = true;
shell = {
env = { };
extraShellText = "";
allowImpureBootstrap = false;
bootstrap = "";
banner = { };
};
formatting = {
programs = { };
settings = { };
};
checks = { };
lefthook = { };
release = null;
} rawConfig;
release =
if merged.release == null then
null
else
{
channels = defaultReleaseChannels;
steps = [ ];
postVersion = "";
runtimeInputs = [ ];
}
// merged.release;
in
if merged.shell.bootstrap != "" && !merged.shell.allowImpureBootstrap then
throw "repo-lib: config.shell.bootstrap requires config.shell.allowImpureBootstrap = true"
else
merged
// {
inherit release;
shell = merged.shell // {
banner = normalizeShellBanner merged.shell.banner;
};
};
mkDevShell =
{
system,
src ? ./.,
nixpkgsInput ? nixpkgs,
extraPackages ? [ ],
preToolHook ? "",
extraShellHook ? "",
additionalHooks ? { },
lefthook ? { },
tools ? [ ],
includeStandardPackages ? true,
formatters ? { },
formatterSettings ? { },
features ? { },
}:
let
pkgs = importPkgs nixpkgsInput system;
oxfmtEnabled = features.oxfmt or false;
legacyTools = builtins.map (tool: normalizeLegacyTool pkgs tool) tools;
duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) legacyTools);
normalizedFormatting = {
programs =
(lib.optionalAttrs oxfmtEnabled {
oxfmt.enable = true;
})
// formatters;
settings = formatterSettings;
};
shellConfig = {
env = { };
extraShellText = extraShellHook;
allowImpureBootstrap = true;
bootstrap = preToolHook;
banner = defaultShellBanner;
};
in
if duplicateToolNames != [ ] then
throw "repo-lib: duplicate tool names: ${lib.concatStringsSep ", " duplicateToolNames}"
else
buildShellArtifacts {
inherit
pkgs
system
src
includeStandardPackages
;
formatting = normalizedFormatting;
rawHookEntries = additionalHooks;
lefthookConfig = lefthook;
shellConfig = shellConfig;
tools = legacyTools;
extraPackages =
extraPackages
++ lib.optionals oxfmtEnabled [
pkgs.oxfmt
pkgs.oxlint
];
};
mkRelease =
{
system,
nixpkgsInput ? nixpkgs,
...
}@rawArgs:
let
pkgs = importPkgs nixpkgsInput system;
release = normalizeReleaseConfig rawArgs;
channelList = lib.concatStringsSep " " release.channels;
releaseStepsScript = lib.concatMapStrings releaseStepScript release.steps;
script =
builtins.replaceStrings
[
"__CHANNEL_LIST__"
"__RELEASE_STEPS__"
"__POST_VERSION__"
]
[
channelList
releaseStepsScript
release.postVersion
]
(builtins.readFile releaseScriptPath);
in
pkgs.writeShellApplication {
name = "release";
runtimeInputs =
with pkgs;
[
git
gnugrep
gawk
gnused
coreutils
perl
]
++ release.runtimeInputs
++ lib.concatMap (step: step.runtimeInputs or [ ]) release.steps;
text = script;
};
mkRepo =
{
self,
nixpkgs,
src ? ./.,
systems ? supportedSystems,
config ? { },
perSystem ? (
{
pkgs,
system,
lib,
config,
}:
{ }
),
}:
let
normalizedConfig = normalizeRepoConfig config;
systemResults = lib.genAttrs systems (
system:
let
pkgs = importPkgs nixpkgs system;
perSystemResult = {
tools = [ ];
shell = { };
checks = { };
lefthook = { };
packages = { };
apps = { };
}
// perSystem {
inherit pkgs system;
lib = nixpkgs.lib;
config = normalizedConfig;
};
strictTools = builtins.map (tool: normalizeStrictTool pkgs tool) perSystemResult.tools;
duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) strictTools);
mergedChecks = mergeUniqueAttrs "check" normalizedConfig.checks perSystemResult.checks;
mergedLefthookConfig =
lib.recursiveUpdate (normalizeLefthookConfig "config.lefthook" normalizedConfig.lefthook)
(normalizeLefthookConfig "perSystem.lefthook" (perSystemResult.lefthook or { }));
shellConfig = lib.recursiveUpdate normalizedConfig.shell (perSystemResult.shell or { });
env =
if duplicateToolNames != [ ] then
throw "repo-lib: duplicate tool names: ${lib.concatStringsSep ", " duplicateToolNames}"
else
buildShellArtifacts {
inherit
pkgs
system
src
;
includeStandardPackages = normalizedConfig.includeStandardPackages;
formatting = normalizedConfig.formatting;
tools = strictTools;
checkSpecs = mergedChecks;
lefthookConfig = mergedLefthookConfig;
shellConfig = shellConfig;
extraPackages = perSystemResult.shell.packages or [ ];
};
releasePackages =
if normalizedConfig.release == null then
{ }
else
{
release = mkRelease {
inherit system;
nixpkgsInput = nixpkgs;
channels = normalizedConfig.release.channels;
steps = normalizedConfig.release.steps;
postVersion = normalizedConfig.release.postVersion;
runtimeInputs = normalizedConfig.release.runtimeInputs;
};
};
in
{
inherit env;
packages = mergeUniqueAttrs "package" releasePackages perSystemResult.packages;
apps = perSystemResult.apps;
}
);
in
{
devShells = lib.genAttrs systems (system: {
default = systemResults.${system}.env.shell;
});
checks = lib.genAttrs systems (system: systemResults.${system}.env.checks);
formatter = lib.genAttrs systems (system: systemResults.${system}.env.formatter);
packages = lib.genAttrs systems (system: systemResults.${system}.packages);
apps = lib.genAttrs systems (system: systemResults.${system}.apps);
};
{
systems.default = defaults.supportedSystems;
inherit (toolsModule) tools;
inherit (repoModule) normalizeRepoConfig mkRepo;
inherit (releaseModule) mkRelease;
}

View File

@@ -0,0 +1,29 @@
{ nixpkgs }:
let
lib = nixpkgs.lib;
in
{
inherit lib;
importPkgs = nixpkgsInput: system: import nixpkgsInput { inherit system; };
duplicateStrings =
names:
lib.unique (
builtins.filter (
name: builtins.length (builtins.filter (candidate: candidate == name) names) > 1
) names
);
mergeUniqueAttrs =
label: left: right:
let
overlap = builtins.attrNames (lib.intersectAttrs left right);
in
if overlap != [ ] then
throw "repo-lib: duplicate ${label}: ${lib.concatStringsSep ", " overlap}"
else
left // right;
sanitizeName = name: lib.strings.sanitizeDerivationName name;
}

View File

@@ -0,0 +1,26 @@
{ }:
{
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
defaultReleaseChannels = [
"alpha"
"beta"
"rc"
"internal"
];
defaultShellBanner = {
style = "simple";
icon = "🚀";
title = "Dev shell ready";
titleColor = "GREEN";
subtitle = "";
subtitleColor = "GRAY";
borderColor = "BLUE";
};
}

View File

@@ -0,0 +1,116 @@
{
lib,
sanitizeName,
}:
let
hookStageFileArgs =
stage: passFilenames:
if !passFilenames then
""
else if stage == "pre-commit" then
" {staged_files}"
else if stage == "pre-push" then
" {push_files}"
else if stage == "commit-msg" then
" {1}"
else
throw "repo-lib: unsupported lefthook stage '${stage}'";
normalizeHookStage =
hookName: stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
"commit-msg"
]
then
stage
else
throw "repo-lib: hook '${hookName}' has unsupported stage '${stage}' for lefthook";
in
{
inherit hookStageFileArgs normalizeHookStage;
checkToLefthookConfig =
pkgs: name: rawCheck:
let
check = {
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [ ];
}
// rawCheck;
wrapperName = "repo-lib-check-${sanitizeName name}";
wrapper = pkgs.writeShellApplication {
name = wrapperName;
runtimeInputs = check.runtimeInputs;
text = ''
set -euo pipefail
${check.command}
'';
};
in
if !(check ? command) then
throw "repo-lib: check '${name}' is missing 'command'"
else if
!(builtins.elem check.stage [
"pre-commit"
"pre-push"
])
then
throw "repo-lib: check '${name}' has unsupported stage '${check.stage}'"
else
lib.setAttrByPath [ check.stage "commands" name ] {
run = "${wrapper}/bin/${wrapperName}${hookStageFileArgs check.stage check.passFilenames}";
};
normalizeLefthookConfig =
label: raw: if builtins.isAttrs raw then raw else throw "repo-lib: ${label} must be an attrset";
hookToLefthookConfig =
name: hook:
let
supportedFields = [
"description"
"enable"
"entry"
"name"
"package"
"pass_filenames"
"stages"
];
unsupportedFields = builtins.filter (field: !(builtins.elem field supportedFields)) (
builtins.attrNames hook
);
stages = builtins.map (stage: normalizeHookStage name stage) (hook.stages or [ "pre-commit" ]);
passFilenames = hook.pass_filenames or false;
in
if unsupportedFields != [ ] then
throw ''
repo-lib: hook '${name}' uses unsupported fields for lefthook: ${lib.concatStringsSep ", " unsupportedFields}
''
else if !(hook ? entry) then
throw "repo-lib: hook '${name}' is missing 'entry'"
else
lib.foldl' lib.recursiveUpdate { } (
builtins.map (
stage:
lib.setAttrByPath [ stage "commands" name ] {
run = "${hook.entry}${hookStageFileArgs stage passFilenames}";
}
) stages
);
parallelHookStageConfig =
stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
]
then
lib.setAttrByPath [ stage "parallel" ] true
else
{ };
}

View File

@@ -0,0 +1,105 @@
{
lib,
nixpkgs,
releaseScriptPath,
defaultReleaseChannels,
importPkgs,
}:
let
normalizeReleaseStep =
step:
if step ? writeFile then
{
kind = "writeFile";
path = step.writeFile.path;
text = step.writeFile.text;
}
else if step ? replace then
{
kind = "replace";
path = step.replace.path;
regex = step.replace.regex;
replacement = step.replace.replacement;
}
else if step ? versionMetaSet then
{
kind = "versionMetaSet";
key = step.versionMetaSet.key;
value = step.versionMetaSet.value;
}
else if step ? versionMetaUnset then
{
kind = "versionMetaUnset";
key = step.versionMetaUnset.key;
}
else
throw "repo-lib: release step must contain one of writeFile, replace, versionMetaSet, or versionMetaUnset";
normalizeReleaseConfig =
raw:
let
steps = if raw ? steps then builtins.map normalizeReleaseStep raw.steps else [ ];
in
{
postVersion = raw.postVersion or "";
channels = raw.channels or defaultReleaseChannels;
runtimeInputs = raw.runtimeInputs or [ ];
steps = steps;
};
mkRelease =
{
system,
nixpkgsInput ? nixpkgs,
...
}@rawArgs:
let
pkgs = importPkgs nixpkgsInput system;
release = normalizeReleaseConfig rawArgs;
channelList = lib.concatStringsSep " " release.channels;
releaseStepsJson = builtins.toJSON release.steps;
releaseRunner = pkgs.buildGoModule {
pname = "repo-lib-release-runner";
version = "0.0.0";
src = ../../release;
vendorHash = "sha256-fGFteYruAda2MBHkKgbTeCpIgO30tKCa+tzF6HcUvWM=";
subPackages = [ "cmd/release" ];
};
script =
builtins.replaceStrings
[
"__CHANNEL_LIST__"
"__RELEASE_STEPS_JSON__"
"__POST_VERSION__"
"__RELEASE_RUNNER__"
]
[
channelList
releaseStepsJson
release.postVersion
(lib.getExe' releaseRunner "release")
]
(builtins.readFile releaseScriptPath);
in
pkgs.writeShellApplication {
name = "release";
runtimeInputs =
with pkgs;
[
git
gnugrep
gawk
gnused
coreutils
]
++ release.runtimeInputs;
text = script;
};
in
{
inherit
normalizeReleaseStep
normalizeReleaseConfig
mkRelease
;
}

View File

@@ -0,0 +1,195 @@
{
flake-parts,
nixpkgs,
lib,
importPkgs,
duplicateStrings,
mergeUniqueAttrs,
supportedSystems,
defaultReleaseChannels,
normalizeStrictTool,
normalizeLefthookConfig,
normalizeShellBanner,
buildShellArtifacts,
mkRelease,
}:
let
normalizeRepoConfig =
rawConfig:
let
merged = lib.recursiveUpdate {
includeStandardPackages = true;
shell = {
env = { };
extraShellText = "";
allowImpureBootstrap = false;
bootstrap = "";
banner = { };
};
formatting = {
programs = { };
settings = { };
};
checks = { };
lefthook = { };
release = null;
} rawConfig;
release =
if merged.release == null then
null
else
{
channels = defaultReleaseChannels;
steps = [ ];
postVersion = "";
runtimeInputs = [ ];
}
// merged.release;
in
if merged.shell.bootstrap != "" && !merged.shell.allowImpureBootstrap then
throw "repo-lib: config.shell.bootstrap requires config.shell.allowImpureBootstrap = true"
else
merged
// {
inherit release;
shell = merged.shell // {
banner = normalizeShellBanner merged.shell.banner;
};
};
buildRepoSystemOutputs =
{
pkgs,
system,
src,
nixpkgsInput,
normalizedConfig,
userPerSystem,
}:
let
perSystemResult = {
tools = [ ];
shell = { };
checks = { };
lefthook = { };
packages = { };
apps = { };
}
// userPerSystem {
inherit pkgs system;
lib = nixpkgs.lib;
config = normalizedConfig;
};
strictTools = builtins.map (tool: normalizeStrictTool pkgs tool) perSystemResult.tools;
duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) strictTools);
mergedChecks = mergeUniqueAttrs "check" normalizedConfig.checks perSystemResult.checks;
mergedLefthookConfig =
lib.recursiveUpdate (normalizeLefthookConfig "config.lefthook" normalizedConfig.lefthook)
(normalizeLefthookConfig "perSystem.lefthook" (perSystemResult.lefthook or { }));
shellConfig = lib.recursiveUpdate normalizedConfig.shell (perSystemResult.shell or { });
env =
if duplicateToolNames != [ ] then
throw "repo-lib: duplicate tool names: ${lib.concatStringsSep ", " duplicateToolNames}"
else
buildShellArtifacts {
inherit
pkgs
system
src
;
includeStandardPackages = normalizedConfig.includeStandardPackages;
formatting = normalizedConfig.formatting;
tools = strictTools;
checkSpecs = mergedChecks;
lefthookConfig = mergedLefthookConfig;
shellConfig = shellConfig;
extraPackages = perSystemResult.shell.packages or [ ];
};
releasePackages =
if normalizedConfig.release == null then
{ }
else
{
release = mkRelease {
inherit system;
nixpkgsInput = nixpkgsInput;
channels = normalizedConfig.release.channels;
steps = normalizedConfig.release.steps;
postVersion = normalizedConfig.release.postVersion;
runtimeInputs = normalizedConfig.release.runtimeInputs;
};
};
in
{
checks = env.checks;
formatter = env.formatter;
shell = env.shell;
packages = mergeUniqueAttrs "package" releasePackages perSystemResult.packages;
apps = perSystemResult.apps;
};
in
{
inherit normalizeRepoConfig;
mkRepo =
{
self,
nixpkgs,
src ? ./.,
systems ? supportedSystems,
config ? { },
perSystem ? (
{
pkgs,
system,
lib,
config,
}:
{ }
),
}:
let
normalizedConfig = normalizeRepoConfig config;
userPerSystem = perSystem;
in
flake-parts.lib.mkFlake
{
inputs = {
inherit self nixpkgs;
flake-parts = flake-parts;
};
}
{
inherit systems;
perSystem =
{
pkgs,
system,
...
}:
let
systemOutputs = buildRepoSystemOutputs {
inherit
pkgs
system
src
normalizedConfig
;
nixpkgsInput = nixpkgs;
userPerSystem = userPerSystem;
};
in
{
devShells.default = systemOutputs.shell;
inherit (systemOutputs)
apps
checks
formatter
packages
;
};
};
}

View File

@@ -0,0 +1,221 @@
{
lib,
treefmt-nix,
lefthookNix,
shellHookTemplatePath,
defaultShellBanner,
normalizeShellBanner,
normalizeLefthookConfig,
parallelHookStageConfig,
checkToLefthookConfig,
hookToLefthookConfig,
}:
let
buildShellHook =
{
hooksShellHook,
shellEnvScript,
bootstrap,
shellBannerScript,
extraShellText,
toolLabelWidth,
}:
let
template = builtins.readFile shellHookTemplatePath;
in
builtins.replaceStrings
[
"@HOOKS_SHELL_HOOK@"
"@TOOL_LABEL_WIDTH@"
"@SHELL_ENV_SCRIPT@"
"@BOOTSTRAP@"
"@SHELL_BANNER_SCRIPT@"
"@EXTRA_SHELL_TEXT@"
]
[
hooksShellHook
(toString toolLabelWidth)
shellEnvScript
bootstrap
shellBannerScript
extraShellText
]
template;
in
{
inherit buildShellHook;
buildShellArtifacts =
{
pkgs,
system,
src,
includeStandardPackages ? true,
formatting,
tools ? [ ],
shellConfig ? {
env = { };
extraShellText = "";
bootstrap = "";
banner = defaultShellBanner;
},
checkSpecs ? { },
rawHookEntries ? { },
lefthookConfig ? { },
extraPackages ? [ ],
}:
let
standardPackages = with pkgs; [
nixfmt
gitlint
gitleaks
shfmt
];
toolPackages = lib.filter (pkg: pkg != null) (builtins.map (tool: tool.package or null) tools);
selectedStandardPackages = lib.optionals includeStandardPackages standardPackages;
treefmtEval = treefmt-nix.lib.evalModule pkgs {
projectRootFile = "flake.nix";
programs = {
nixfmt.enable = true;
}
// formatting.programs;
settings.formatter = { } // formatting.settings;
};
treefmtWrapper = treefmtEval.config.build.wrapper;
lefthookBinWrapper = pkgs.writeShellScript "lefthook-dumb-term" ''
exec env TERM=dumb ${lib.getExe pkgs.lefthook} "$@"
'';
normalizedLefthookConfig = normalizeLefthookConfig "lefthook config" lefthookConfig;
lefthookCheck = lefthookNix.lib.${system}.run {
inherit src;
config = lib.foldl' lib.recursiveUpdate { } (
[
{
output = [
"failure"
"summary"
];
}
(parallelHookStageConfig "pre-commit")
(parallelHookStageConfig "pre-push")
(lib.setAttrByPath [ "pre-commit" "commands" "treefmt" ] {
run = "${treefmtWrapper}/bin/treefmt --no-cache {staged_files}";
stage_fixed = true;
})
(lib.setAttrByPath [ "pre-commit" "commands" "gitleaks" ] {
run = "${pkgs.gitleaks}/bin/gitleaks protect --staged";
})
(lib.setAttrByPath [ "commit-msg" "commands" "gitlint" ] {
run = "${pkgs.gitlint}/bin/gitlint --staged --msg-filename {1}";
})
]
++ lib.mapAttrsToList (name: check: checkToLefthookConfig pkgs name check) checkSpecs
++ lib.mapAttrsToList hookToLefthookConfig rawHookEntries
++ [ normalizedLefthookConfig ]
);
};
selectedCheckOutputs = {
formatting-check = treefmtEval.config.build.check src;
hook-check = lefthookCheck;
lefthook-check = lefthookCheck;
};
toolNames = builtins.map (tool: tool.name) tools;
toolNameWidth =
if toolNames == [ ] then
0
else
builtins.foldl' (maxWidth: name: lib.max maxWidth (builtins.stringLength name)) 0 toolNames;
toolLabelWidth = toolNameWidth + 1;
shellEnvScript = lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value: "export ${name}=${lib.escapeShellArg (toString value)}"
) shellConfig.env
);
banner = normalizeShellBanner (shellConfig.banner or { });
shellBannerScript =
if banner.style == "pretty" then
''
repo_lib_print_pretty_header \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg banner.title} \
${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg banner.subtitle}
''
+ lib.concatMapStrings (tool: ''
repo_lib_print_pretty_tool \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg (toString tool.version.line)} \
${lib.escapeShellArg (toString tool.version.group)} \
${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \
${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \
${lib.escapeShellArg tool.executable} \
${lib.escapeShellArgs tool.version.args}
'') tools
+ ''
repo_lib_print_pretty_footer \
${lib.escapeShellArg banner.borderColor}
''
else
''
repo_lib_print_simple_header \
${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg banner.title} \
${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg banner.subtitle}
''
+ lib.concatMapStrings (tool: ''
repo_lib_print_simple_tool \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg (toString tool.version.line)} \
${lib.escapeShellArg (toString tool.version.group)} \
${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \
${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \
${lib.escapeShellArg tool.executable} \
${lib.escapeShellArgs tool.version.args}
'') tools
+ ''
printf "\n"
'';
in
{
checks = selectedCheckOutputs;
formatter = treefmtWrapper;
shell = pkgs.mkShell {
LEFTHOOK_BIN = builtins.toString lefthookBinWrapper;
packages = lib.unique (
selectedStandardPackages
++ extraPackages
++ toolPackages
++ [
pkgs.lefthook
treefmtWrapper
]
);
shellHook = buildShellHook {
hooksShellHook = lefthookCheck.shellHook;
inherit toolLabelWidth shellEnvScript shellBannerScript;
bootstrap = shellConfig.bootstrap;
extraShellText = shellConfig.extraShellText;
};
};
}
// selectedCheckOutputs;
}

View File

@@ -0,0 +1,90 @@
{
lib,
}:
let
normalizeStrictTool =
pkgs: tool:
let
version = {
args = [ "--version" ];
match = null;
regex = null;
group = 0;
line = 1;
}
// (tool.version or { });
banner = {
color = "YELLOW";
icon = null;
iconColor = null;
}
// (tool.banner or { });
executable =
if tool ? command && tool.command != null then
tool.command
else if tool ? exe && tool.exe != null then
"${lib.getExe' tool.package tool.exe}"
else
"${lib.getExe tool.package}";
in
if !(tool ? command && tool.command != null) && !(tool ? package) then
throw "repo-lib: tool '${tool.name or "<unnamed>"}' is missing 'package' or 'command'"
else
{
kind = "strict";
inherit executable version banner;
name = tool.name;
package = tool.package or null;
required = tool.required or true;
};
in
{
inherit normalizeStrictTool;
tools = rec {
fromPackage =
{
name,
package,
exe ? null,
version ? { },
banner ? { },
required ? true,
}:
{
inherit
name
package
exe
version
banner
required
;
};
fromCommand =
{
name,
command,
version ? { },
banner ? { },
required ? true,
}:
{
inherit
name
command
version
banner
required
;
};
simple =
name: package: args:
fromPackage {
inherit name package;
version.args = args;
};
};
}

View File

@@ -70,39 +70,6 @@ repo_lib_capture_tool() {
return 0
}
repo_lib_capture_legacy_tool() {
local required="$1"
local command_name="$2"
local version_command="$3"
local output=""
local version=""
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
if ! command -v "$command_name" >/dev/null 2>&1; then
REPO_LIB_TOOL_ERROR="missing command"
return 1
fi
if ! output="$(sh -c "$command_name $version_command" 2>&1)"; then
REPO_LIB_TOOL_ERROR="probe failed"
printf "%s\n" "$output" >&2
return 1
fi
version="$(printf '%s\n' "$output" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
if [ -z "$version" ]; then
REPO_LIB_TOOL_ERROR="empty version"
printf "%s\n" "$output" >&2
return 1
fi
REPO_LIB_TOOL_VERSION="$version"
return 0
}
repo_lib_print_simple_header() {
local title_color_name="$1"
local icon="$2"
@@ -164,42 +131,6 @@ repo_lib_print_simple_tool() {
fi
}
repo_lib_print_simple_legacy_tool() {
local name="$1"
local color_name="$2"
local icon="$3"
local icon_color_name="$4"
local required="$5"
local command_name="$6"
local version_command="$7"
local color="${!color_name:-$YELLOW}"
local effective_icon_color_name="$icon_color_name"
local icon_color=""
if [ -z "$effective_icon_color_name" ]; then
effective_icon_color_name="$color_name"
fi
if repo_lib_capture_legacy_tool "$required" "$command_name" "$version_command"; then
icon_color="${!effective_icon_color_name:-$color}"
printf " "
if [ -n "$icon" ]; then
printf "%s%s%s " "$icon_color" "$icon" "$RESET"
fi
printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$REPO_LIB_TOOL_VERSION"
else
printf " "
if [ -n "$icon" ]; then
printf "%s%s%s " "$RED" "$icon" "$RESET"
fi
printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "$REPO_LIB_TOOL_ERROR"
if [ "$required" = "1" ]; then
exit 1
fi
fi
}
repo_lib_print_pretty_header() {
local border_color_name="$1"
local title_color_name="$2"
@@ -286,45 +217,6 @@ repo_lib_print_pretty_tool() {
fi
}
repo_lib_print_pretty_legacy_tool() {
local border_color_name="$1"
local name="$2"
local color_name="$3"
local icon="$4"
local icon_color_name="$5"
local required="$6"
local command_name="$7"
local version_command="$8"
local effective_icon_color_name="$icon_color_name"
local value_color_name="$color_name"
local value=""
if [ -z "$effective_icon_color_name" ]; then
effective_icon_color_name="$color_name"
fi
if repo_lib_capture_legacy_tool "$required" "$command_name" "$version_command"; then
value="$REPO_LIB_TOOL_VERSION"
else
value="$REPO_LIB_TOOL_ERROR"
effective_icon_color_name="RED"
value_color_name="RED"
fi
repo_lib_print_pretty_row \
"$border_color_name" \
"$icon" \
"$effective_icon_color_name" \
"$name" \
"$value" \
"$value_color_name"
if [ "$value_color_name" = "RED" ] && [ "$required" = "1" ]; then
exit 1
fi
}
repo_lib_print_pretty_footer() {
local border_color_name="$1"
local border_color="${!border_color_name:-$BLUE}"

View File

@@ -1,48 +1,54 @@
---
name: repo-lib-consumer
description: Edit or extend repos that consume `repo-lib` through `repo-lib.lib.mkRepo`, `mkDevShell`, or `mkRelease`. Use when Codex needs to add or change tools, shell packages, checks or test phases, formatters, release steps, release channels, bootstrap hooks, or release automation in a Nix flake built on repo-lib.
description: Edit or extend repos that consume `repo-lib` through `repo-lib.lib.mkRepo`, `repo-lib.lib.mkRelease`, or a template generated from this library. Use when Codex needs to add or change tools, shell banner or bootstrap behavior, shell packages, checks, raw lefthook config, formatters, release steps, version metadata, release channels, or release automation in a Nix flake built on repo-lib.
---
# Repo Lib Consumer
Use this skill to make idiomatic changes in a repo that already depends on `repo-lib`.
Use this skill when changing a repo that already depends on `repo-lib`.
## Workflow
1. Detect the integration style.
Search for `repo-lib.lib.mkRepo`, `repo-lib.lib.mkDevShell`, `repo-lib.lib.mkRelease`, or `inputs.repo-lib`.
Search for `repo-lib.lib.mkRepo`, `repo-lib.lib.mkRelease`, or `inputs.repo-lib`.
2. Prefer the repo's current abstraction level.
If the repo already uses `mkRepo`, stay on `mkRepo`.
If the repo still uses `mkDevShell` or `mkRelease`, preserve that style unless the user asked to migrate.
If the repo uses `mkRepo`, keep edits inside `config` and `perSystem`.
If the repo uses `mkRelease` directly, preserve that style unless the user asked to migrate.
3. Load the right reference before editing.
Read `references/api.md` for exact option names, defaults, generated outputs, and limitations.
Read `references/recipes.md` for common edits such as adding a tool, adding a test phase, wiring release file updates, or handling webhooks.
Read `references/api.md` for exact option names, merge points, generated outputs, hook limitations, and release behavior.
Read `references/recipes.md` for concrete change patterns such as adding a tool, adding a check, wiring `commit-msg`, changing the banner, or updating release-managed files.
4. Follow repo-lib conventions.
Add bannered CLIs through `perSystem.tools`, not `shell.packages`.
Use `shell.packages` for packages that should be present in the shell but not shown in the banner.
Use `shell.packages` for packages that should exist in the shell but not in the banner.
Keep shells pure-first; only use `bootstrap` with `allowImpureBootstrap = true`.
Prefer structured `release.steps` over free-form shell when the task fits `writeFile` or `replace`.
Prefer `config.checks` for simple `pre-commit` and `pre-push` commands.
Use raw `config.lefthook` or `perSystem.lefthook` when the task needs `commit-msg` or extra lefthook fields.
Prefer structured `release.steps` over shell hooks; current step kinds are `writeFile`, `replace`, `versionMetaSet`, and `versionMetaUnset`.
5. Verify after edits.
Run `nix flake show --json`.
Run `nix flake check` when feasible.
If local flake evaluation cannot see newly created files because the repo is being loaded as a git flake, stage the new files before rerunning checks.
If local flake evaluation cannot see newly created files because the repo is loaded as a git flake, stage the new files before rerunning checks.
## Decision Rules
- Prefer `repo-lib.lib.tools.fromPackage` for tools with explicit metadata.
- Use `repo-lib.lib.tools.simple` only for very simple `--version` or `version` probes.
- Put pre-commit and pre-push automation in `checks`, not shell hooks.
- Treat `postVersion` as pre-tag and pre-push. It is not a true post-tag hook.
- For a webhook that must fire after the tag exists remotely, prefer CI triggered by tag push over local release command changes.
- Prefer `repo-lib.lib.tools.fromPackage` for packaged CLIs and `fromCommand` only when the tool should come from the host environment.
- Use `repo-lib.lib.tools.simple` only for very small package-backed probes that only need `version.args`.
- Required tools fail shell startup if their probe fails. Do not mark a tool optional unless that is intentional.
- `config.checks` supports only `pre-commit` and `pre-push`. `commit-msg` must go through raw lefthook config.
- Generated checks include `formatting-check`, `hook-check`, and `lefthook-check`.
- `config.shell.banner.style` must be `simple` or `pretty`.
- Treat `postVersion` as pre-format, pre-commit, pre-tag, and pre-push.
- Do not model a true post-tag webhook inside `repo-lib`; prefer CI triggered by tag push.
- The current generated `release` command is destructive and opinionated: it formats, stages, commits, tags, and pushes as part of the flow. Document that clearly when editing consumer release automation.
## References
- `references/api.md`
Use for the exact consumer API, option matrix, generated outputs, release ordering, and legacy compatibility.
Use for the exact consumer API, generated outputs, hook and banner behavior, and current release semantics.
- `references/recipes.md`
Use for concrete change patterns: add a tool, add a test phase, update release-managed files, or wire webhook behavior.
Use for common changes: add a tool, add a check, add a `commit-msg` hook, customize the shell banner, or update release-managed files.

View File

@@ -1,4 +1,4 @@
interface:
display_name: "Repo Lib Consumer"
short_description: "Edit repos that use repo-lib safely"
default_prompt: "Use $repo-lib-consumer to update a repo that consumes repo-lib."
short_description: "Edit mkRepo or mkRelease consumers safely"
default_prompt: "Use $repo-lib-consumer to update a repo that consumes repo-lib through mkRepo, mkRelease, or the repo-lib template."

View File

@@ -5,7 +5,6 @@
Look for one of these patterns in the consuming repo:
- `repo-lib.lib.mkRepo`
- `repo-lib.lib.mkDevShell`
- `repo-lib.lib.mkRelease`
- `inputs.repo-lib`
@@ -27,6 +26,7 @@ repo-lib.lib.mkRepo {
extraShellText = "";
allowImpureBootstrap = false;
bootstrap = "";
banner = { };
};
formatting = {
@@ -54,12 +54,35 @@ repo-lib.lib.mkRepo {
Generated outputs:
- `devShells.${system}.default`
- `checks.${system}.formatting-check`
- `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}`
- `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem`
Merge points:
- `config.checks` merges with `perSystem.checks`
- `config.lefthook` recursively merges with `perSystem.lefthook`
- `config.shell` recursively merges with `perSystem.shell`
- generated release packages merge with `perSystem.packages`
Conflicts on `checks`, `packages`, and `apps` names throw.
## `config.includeStandardPackages`
Default: `true`
When enabled, the shell includes:
- `nixfmt`
- `gitlint`
- `gitleaks`
- `shfmt`
Use `false` only when the consumer explicitly wants to own the full shell package list.
## `config.shell`
Fields:
@@ -71,13 +94,38 @@ Fields:
- `bootstrap`
Shell snippet that runs before the banner.
- `allowImpureBootstrap`
Must be `true` if `bootstrap` is non-empty.
Must be `true` when `bootstrap` is non-empty.
- `banner`
Shell banner configuration.
Rules:
- Default is pure-first.
- Do not add bootstrap work unless the user actually wants imperative setup.
- Use `bootstrap` for unavoidable local setup only.
- Do not add bootstrap work unless the user actually wants imperative local setup.
- The template uses bootstrap intentionally for Bun global install paths and Moon bootstrapping; do not generalize that into normal package setup unless the repo already wants that behavior.
### `config.shell.banner`
Defaults:
```nix
{
style = "simple";
icon = "🚀";
title = "Dev shell ready";
titleColor = "GREEN";
subtitle = "";
subtitleColor = "GRAY";
borderColor = "BLUE";
}
```
Rules:
- `style` must be `simple` or `pretty`.
- `borderColor` matters only for `pretty`.
- Tool rows can also set `banner.color`, `banner.icon`, and `banner.iconColor`.
- Required tool probe failures abort shell startup.
## `config.formatting`
@@ -91,7 +139,7 @@ Fields:
Rules:
- `nixfmt` is always enabled.
- Use formatter settings instead of ad hoc shell formatting logic.
- Use formatter settings instead of shell hooks for formatting behavior.
## Checks
@@ -99,10 +147,10 @@ Rules:
```nix
{
command = "go test ./...";
command = "bun test";
stage = "pre-push"; # or "pre-commit"
passFilenames = false;
runtimeInputs = [ pkgs.go ];
runtimeInputs = [ pkgs.bun ];
}
```
@@ -114,37 +162,53 @@ Defaults:
Rules:
- Only `pre-commit` and `pre-push` are supported.
- The command is wrapped as a script and connected into `lefthook.nix`.
- `pre-commit` and `pre-push` commands are configured to run in parallel.
- Only `pre-commit` and `pre-push` are supported here.
- The command is wrapped with `writeShellApplication`.
- `pre-commit` and `pre-push` stages are configured to run in parallel.
- `passFilenames = true` maps to `{staged_files}` for `pre-commit` and `{push_files}` for `pre-push`.
## Raw Lefthook config
Use `config.lefthook` or `perSystem.lefthook` for advanced Lefthook features that the built-in `checks` abstraction does not carry.
Use `config.lefthook` or `perSystem.lefthook` when the task needs advanced Lefthook features or unsupported stages.
Example:
Pass-through attrset example:
```nix
{
checks.tests = {
command = "go test ./...";
command = "bun test";
stage = "pre-push";
runtimeInputs = [ pkgs.bun ];
};
lefthook.pre-push.commands.tests.stage_fixed = true;
lefthook.commit-msg.commands.commitlint = {
run = "pnpm commitlint --edit {1}";
run = "bun commitlint --edit {1}";
stage_fixed = true;
};
}
```
Structured hook-entry example in a raw hook list:
```nix
perSystem = { pkgs, ... }: {
lefthook.biome = {
entry = "${pkgs.biome}/bin/biome check";
pass_filenames = true;
stages = [ "pre-commit" "pre-push" ];
};
};
```
Rules:
- These attrsets are passed through to `lefthook.nix`.
- They are merged after generated checks, so they can extend generated commands.
- Prefer `checks` for the simple common case and `lefthook` for advanced fields such as `stage_fixed`, `files`, `glob`, `exclude`, `jobs`, or `scripts`.
- `config.lefthook` and `perSystem.lefthook` are recursive attrset passthroughs merged after generated checks.
- Structured hook entries support only:
`description`, `enable`, `entry`, `name`, `package`, `pass_filenames`, `stages`
- `stages` may include `pre-commit`, `pre-push`, or `commit-msg`.
- `pass_filenames = true` maps to `{1}` for `commit-msg`.
## Tools
@@ -152,17 +216,19 @@ Preferred shape in `perSystem.tools`:
```nix
(repo-lib.lib.tools.fromPackage {
name = "Go";
package = pkgs.go;
exe = "go"; # optional
name = "Bun";
package = pkgs.bun;
version = {
args = [ "version" ];
args = [ "--version" ];
match = null;
regex = null;
group = 0;
line = 1;
};
banner = {
color = "CYAN";
color = "YELLOW";
icon = "";
iconColor = null;
};
required = true;
})
@@ -174,7 +240,10 @@ For a tool that should come from the host `PATH` instead of `nixpkgs`:
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version.args = [ "--version" ];
version = {
args = [ "--version" ];
group = 1;
};
})
```
@@ -186,10 +255,11 @@ repo-lib.lib.tools.simple "Go" pkgs.go [ "version" ]
Tool behavior:
- Tool packages are added to the shell automatically.
- Package-backed tools are added to the shell automatically.
- Command-backed tools are probed from the existing `PATH` and are not added to the shell automatically.
- Banner probing uses absolute executable paths.
- Banner probing uses the resolved executable path.
- `required = true` by default.
- When `version.match` is set, the first matching output line is selected before `regex` extraction.
- Required tool probe failure aborts shell startup.
Use `shell.packages` instead of `tools` when:
@@ -246,73 +316,66 @@ Set `release = null` to disable the generated release package.
}
```
### `run`
### `versionMetaSet`
```nix
{
run = {
script = ''
curl -fsS https://example.invalid/hook \
-H 'content-type: application/json' \
-d '{"tag":"'"$FULL_TAG"'"}'
'';
runtimeInputs = [ pkgs.curl ];
versionMetaSet = {
key = "desktop_binary_version_max";
value = "$FULL_VERSION";
};
}
```
Also accepted for compatibility:
### `versionMetaUnset`
- `{ run = ''...''; }`
- legacy `mkRelease { release = [ { file = ...; content = ...; } ... ]; }`
```nix
{
versionMetaUnset = {
key = "desktop_unused";
};
}
```
Rules:
- Current supported step kinds are only `writeFile`, `replace`, `versionMetaSet`, and `versionMetaUnset`.
- Do not document or implement a `run` step in consumer repos unless the library itself gains that feature.
## Release ordering
The generated `release` command does this:
The generated `release` command currently does this:
1. Update `VERSION`
2. Run `release.steps`
3. Run `postVersion`
4. Run `nix fmt`
5. `git add -A`
6. Commit
7. Tag
8. Push branch
9. Push tags
1. Require a clean git worktree
2. Update `VERSION`
3. Run `release.steps`
4. Run `postVersion`
5. Run `nix fmt`
6. `git add -A`
7. Commit with `chore(release): <tag>`
8. Tag
9. Push branch
10. Push tags
Important consequence:
Important consequences:
- `postVersion` is still before commit, tag, and push.
- `postVersion` is before formatting, commit, tag, and push.
- There is no true post-tag or post-push hook in current `repo-lib`.
- The current release runner is opinionated and performs commit, tag, and push as part of the flow.
## Post-tag webhook limitation
## `mkRelease`
If the user asks for a webhook after the tag exists remotely:
`repo-lib.lib.mkRelease` remains available when a repo wants only the release package:
- Prefer CI triggered by pushed tags in the consuming repo.
- Do not claim `postVersion` is post-tag; it is not.
- Only extend `repo-lib` itself if the user explicitly wants a new library capability.
```nix
repo-lib.lib.mkRelease {
system = system;
nixpkgsInput = nixpkgs; # optional
channels = [ "alpha" "beta" "rc" "internal" ];
steps = [ ];
postVersion = "";
runtimeInputs = [ ];
}
```
## Legacy API summary
`mkDevShell` still supports:
- `extraPackages`
- `preToolHook`
- `extraShellHook`
- `lefthook`
- `additionalHooks`
- old `tools = [ { name; bin; versionCmd; color; } ]`
- `features.oxfmt`
- `formatters`
- `formatterSettings`
`mkRelease` still supports:
- `release = [ ... ]` as legacy alias for `steps`
- `extraRuntimeInputs` as legacy alias merged into `runtimeInputs`
When a repo already uses these APIs:
- preserve them unless the user asked to migrate
- do not mix old and new styles accidentally in the same call
Use the same release-step rules as `config.release`.

View File

@@ -7,18 +7,22 @@ Edit `perSystem.tools` in the consuming repo:
```nix
tools = [
(repo-lib.lib.tools.fromPackage {
name = "Go";
package = pkgs.go;
version.args = [ "version" ];
banner.color = "CYAN";
name = "Bun";
package = pkgs.bun;
version.args = [ "--version" ];
banner = {
color = "YELLOW";
icon = "";
};
})
];
```
Notes:
- Do not also add `pkgs.go` to `shell.packages`; `tools` already adds it.
- Use `exe = "name"` only when the package exposes multiple binaries or the main program is not the desired one.
- Do not also add the same package to `shell.packages`; `tools` already adds package-backed tools to the shell.
- Use `exe = "name"` only when the package exposes multiple binaries or the default binary is not the right one.
- Use `fromCommand` when the executable should come from the host environment instead of `nixpkgs`.
## Add a non-banner package to the shell
@@ -37,16 +41,38 @@ Use this for:
- internal scripts
- the generated `release` package itself
## Add a test phase or lint hook
## Customize the shell banner
For a simple global check:
Use `config.shell.banner`:
```nix
config.checks.tests = {
command = "go test ./...";
config.shell.banner = {
style = "pretty";
icon = "";
title = "Moonrepo shell ready";
titleColor = "GREEN";
subtitle = "Bun + TypeScript + Varlock";
subtitleColor = "GRAY";
borderColor = "BLUE";
};
```
Guidance:
- Use `style = "pretty"` when the repo already has a styled shell banner.
- Keep icons and colors consistent with the repo's current shell UX.
- Remember that required tool probe failures will still abort shell startup.
## Add a test phase or lint hook
For a simple shared check:
```nix
config.checks.typecheck = {
command = "bun run typecheck";
stage = "pre-push";
passFilenames = false;
runtimeInputs = [ pkgs.go ];
runtimeInputs = [ pkgs.bun ];
};
```
@@ -54,20 +80,44 @@ For a system-specific check:
```nix
perSystem = { pkgs, ... }: {
checks.lint = {
command = "bun test";
stage = "pre-push";
runtimeInputs = [ pkgs.bun ];
checks.format = {
command = "oxfmt --check .";
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [ pkgs.oxfmt ];
};
};
```
Guidance:
- Use `pre-commit` for fast format/lint work.
- Use `pre-commit` for fast format or lint work.
- Use `pre-push` for slower test suites.
- Prefer `runtimeInputs` over inline absolute paths when the command needs extra CLIs.
## Add a `commit-msg` hook
`config.checks` cannot target `commit-msg`. Use raw Lefthook config:
```nix
config.lefthook.commit-msg.commands.gitlint = {
run = "${pkgs.gitlint}/bin/gitlint --staged --msg-filename {1}";
stage_fixed = true;
};
```
Or use a structured hook entry:
```nix
perSystem = { pkgs, ... }: {
lefthook.commitlint = {
entry = "${pkgs.nodejs}/bin/node scripts/commitlint.mjs";
pass_filenames = true;
stages = [ "commit-msg" ];
};
};
```
## Add or change formatters
Use `config.formatting`:
@@ -76,11 +126,12 @@ Use `config.formatting`:
config.formatting = {
programs = {
shfmt.enable = true;
gofmt.enable = true;
oxfmt.enable = true;
};
settings = {
shfmt.options = [ "-i" "2" "-s" "-w" ];
oxfmt.excludes = [ "*.md" "*.yml" ];
};
};
```
@@ -116,31 +167,27 @@ config.release.steps = [
];
```
## Add a webhook during release
If the webhook may run before commit and tag creation, use a `run` step or `postVersion`.
Use a `run` step when it belongs with other release mutations:
Update metadata inside `VERSION`:
```nix
config.release = {
runtimeInputs = [ pkgs.curl ];
steps = [
{
run = {
script = ''
curl -fsS https://example.invalid/release-hook \
-H 'content-type: application/json' \
-d '{"version":"'"$FULL_VERSION"'"}'
'';
runtimeInputs = [ pkgs.curl ];
};
}
];
};
config.release.steps = [
{
versionMetaSet = {
key = "desktop_binary_version_max";
value = "$FULL_VERSION";
};
}
{
versionMetaUnset = {
key = "desktop_unused";
};
}
];
```
Use `postVersion` when the action should happen after all `steps`:
## Add a webhook during release
Current `repo-lib` does not expose a `run` release step. If the action must happen during local release execution, put it in `postVersion`:
```nix
config.release.postVersion = ''
@@ -153,8 +200,8 @@ config.release.runtimeInputs = [ pkgs.curl ];
Important:
- Both of these still run before commit, tag, and push.
- They are not true post-tag hooks.
- `postVersion` still runs before `nix fmt`, commit, tag, and push.
- This is not a true post-tag hook.
## Add a true post-tag webhook
@@ -162,11 +209,11 @@ Do not fake this with `postVersion`.
Preferred approach in the consuming repo:
1. Keep local release generation in `repo-lib`.
2. Add CI triggered by tag push.
3. Put the webhook call in CI, where the tag is already created and pushed.
1. Keep local version generation in `repo-lib`.
2. Trigger CI from tag push.
3. Put the webhook call in CI, where the tag already exists remotely.
Only change `repo-lib` itself if the user explicitly asks for a new local post-tag capability.
Only change `repo-lib` itself if the user explicitly asks for a new library capability.
## Add impure bootstrap work
@@ -175,8 +222,9 @@ Only do this when the user actually wants imperative shell setup:
```nix
config.shell = {
bootstrap = ''
export GOBIN="$PWD/.tools/bin"
export PATH="$GOBIN:$PATH"
export BUN_INSTALL_GLOBAL_DIR="$PWD/.tools/bun/install/global"
export BUN_INSTALL_BIN="$PWD/.tools/bun/bin"
export PATH="$BUN_INSTALL_BIN:$PATH"
'';
allowImpureBootstrap = true;
};
@@ -184,14 +232,14 @@ config.shell = {
Do not add bootstrap work for normal Nix-packaged tools.
## Migrate a legacy consumer to `mkRepo`
## Move from direct `mkRelease` to `mkRepo`
Only do this if requested.
Migration outline:
1. Move repeated shell/check/formatter config into `config`.
2. Move old banner tools into `perSystem.tools`.
3. Move extra shell packages into `perSystem.shell.packages`.
4. Replace old `mkRelease { release = [ ... ]; }` with `config.release.steps`.
1. Move release package config into `config.release`.
2. Move shell setup into `config.shell` and `perSystem.shell.packages`.
3. Move bannered CLIs into `perSystem.tools`.
4. Move hook commands into `config.checks` or raw `lefthook`.
5. Keep behavior the same first; do not redesign the repo in the same change unless asked.

View File

@@ -3,7 +3,7 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.5.0";
repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v4.0.0";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
@@ -109,46 +109,49 @@
})
];
shell.packages = [
self.packages.${system}.release
pkgs.bun
pkgs.openbao
pkgs.oxfmt
pkgs.oxlint
];
checks.format = {
command = "oxfmt --check .";
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [ pkgs.oxfmt ];
};
checks.typecheck = {
command = "bun run typecheck";
stage = "pre-push";
passFilenames = false;
runtimeInputs = [ pkgs.bun ];
};
checks.env-check = {
command = "bun run env:check";
stage = "pre-push";
passFilenames = false;
runtimeInputs = [
pkgs.bun
shell = {
packages = [
self.packages.${system}.release
pkgs.openbao
pkgs.oxfmt
pkgs.oxlint
];
};
checks.env-scan = {
command = "bun run env:scan";
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [
pkgs.bun
pkgs.openbao
];
checks = {
format = {
command = "oxfmt --check .";
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [ pkgs.oxfmt ];
};
typecheck = {
command = "bun run typecheck";
stage = "pre-push";
passFilenames = false;
runtimeInputs = [ pkgs.bun ];
};
env-check = {
command = "bun run env:check";
stage = "pre-push";
passFilenames = false;
runtimeInputs = [
pkgs.bun
pkgs.openbao
];
};
env-scan = {
command = "bun run env:scan";
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [
pkgs.bun
pkgs.openbao
];
};
};
};
};

File diff suppressed because it is too large Load Diff