2 Commits

Author SHA1 Message Date
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
39 changed files with 3274 additions and 3150 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
nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.5.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";
inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.5.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
3.5.1
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,135 @@
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
case "--dry-run":
execution.DryRun = true
case "--commit":
execution.Commit = true
case "--tag":
execution.Tag = true
case "--push":
execution.Push = 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,44 @@
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.WriteByte('$')
b.WriteByte(raw[i+1])
i++
continue
}
b.WriteByte(raw[i])
}
return b.String()
}

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,404 @@
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 TestRunnerLeavesChangesUncommittedByDefault(t *testing.T) {
t.Parallel()
root := t.TempDir()
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")
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")
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 != "M VERSION" {
t.Fatalf("git status --short = %q, want %q", status, "M VERSION")
}
tagList := strings.TrimSpace(mustOutput(t, root, "git", "tag", "--list"))
if tagList != "" {
t.Fatalf("git tag --list = %q, want empty", tagList)
}
}
func TestRunnerDryRunDoesNotModifyRepo(t *testing.T) {
t.Parallel()
root := t.TempDir()
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")
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")
var stdout strings.Builder
r := &Runner{
Config: Config{
RootDir: root,
AllowedChannels: []string{"alpha", "beta", "rc", "internal"},
Execution: ExecutionOptions{
DryRun: true,
Commit: true,
Tag: true,
Push: true,
},
Stdout: &stdout,
},
}
if err := r.Run([]string{"patch"}); err != nil {
t.Fatalf("Runner.Run: %v", err)
}
assertFileEquals(t, filepath.Join(root, "VERSION"), "1.0.0\nstable\n0\n")
status := strings.TrimSpace(mustOutput(t, root, "git", "status", "--short"))
if status != "" {
t.Fatalf("git status --short = %q, want empty", status)
}
if !strings.Contains(stdout.String(), "Dry run: 1.0.1") {
t.Fatalf("dry-run output missing next version:\n%s", stdout.String())
}
}
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,160 @@
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 {
DryRun bool
Commit bool
Tag bool
Push bool
}
type Runner struct {
Config Config
}
func (o ExecutionOptions) Normalize() ExecutionOptions {
if o.Push {
o.Commit = true
}
if o.Tag {
o.Commit = true
}
return o
}
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 execution.DryRun {
printReleasePlan(stdout, nextVersion, execution, strings.TrimSpace(r.Config.ReleaseStepsJSON) != "", strings.TrimSpace(r.Config.PostVersion) != "")
return nil
}
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 !execution.Commit {
return nil
}
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 execution.Tag {
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "tag", version.Tag()); err != nil {
return err
}
}
if !execution.Push {
return nil
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push"); err != nil {
return err
}
if execution.Tag {
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push", "--tags"); err != nil {
return err
}
}
return nil
}
func printReleasePlan(stdout io.Writer, version Version, execution ExecutionOptions, hasReleaseSteps bool, hasPostVersion bool) {
fmt.Fprintf(stdout, "Dry run: %s\n", version.String())
fmt.Fprintf(stdout, "Tag: %s\n", version.Tag())
fmt.Fprintf(stdout, "Release steps: %s\n", yesNo(hasReleaseSteps))
fmt.Fprintf(stdout, "Post-version: %s\n", yesNo(hasPostVersion))
fmt.Fprintf(stdout, "nix fmt: yes\n")
fmt.Fprintf(stdout, "git commit: %s\n", yesNo(execution.Commit))
fmt.Fprintf(stdout, "git tag: %s\n", yesNo(execution.Tag))
fmt.Fprintf(stdout, "git push: %s\n", yesNo(execution.Push))
}

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,350 @@
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(versionFile.Version, options)
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 execution.DryRun {
parts = append(parts, "--dry-run")
}
if execution.Commit {
parts = append(parts, "--commit")
}
if execution.Tag {
parts = append(parts, "--tag")
}
if execution.Push {
parts = append(parts, "--push")
}
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),
" dry run: "+yesNo(execution.DryRun),
)
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 {
current Version
options []CommandOption
cursor int
width int
height int
confirmed bool
selected CommandOption
}
func newCommandPickerModel(current Version, options []CommandOption) commandPickerModel {
return commandPickerModel{
current: current,
options: options,
}
}
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":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.options)-1 {
m.cursor++
}
case "enter":
m.confirmed = true
m.selected = m.options[m.cursor]
return m, tea.Quit
}
}
return m, nil
}
func (m commandPickerModel) View() string {
if len(m.options) == 0 {
return "No release commands available.\n"
}
preview := m.options[m.cursor].Preview
header := fmt.Sprintf("Release command picker\nCurrent version: %s\nUse up/down or j/k to choose, Enter to run, q to cancel.\n", m.current.String())
listLines := make([]string, 0, len(m.options)+1)
listLines = append(listLines, "Commands")
for i, option := range m.options {
cursor := " "
if i == m.cursor {
cursor = "> "
}
listLines = append(listLines, fmt.Sprintf("%s%s\n %s", cursor, option.Command, option.Description))
}
list := strings.Join(listLines, "\n")
if m.width >= 100 {
return header + "\n" + renderColumns(list, preview, m.width)
}
return header + "\n" + list + "\n\n" + preview + "\n"
}
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,91 @@
package release
import (
"strings"
"testing"
)
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 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() {
:
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
# ── 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 "$@"
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;
toolsModule = import ./lib/tools.nix {
lib = common.lib;
};
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;
hooksModule = import ./lib/hooks.nix {
inherit (common) lib sanitizeName;
};
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;
in
rec {
systems = {
default = supportedSystems;
};
tools = rec {
fromPackage =
{
name,
package,
exe ? null,
version ? { },
banner ? { },
required ? true,
}:
{
shellModule = import ./lib/shell.nix {
inherit (common)
lib
;
inherit
name
package
exe
version
banner
required
treefmt-nix
lefthookNix
shellHookTemplatePath
;
inherit (defaults)
defaultShellBanner
;
inherit normalizeShellBanner;
inherit (hooksModule)
normalizeLefthookConfig
parallelHookStageConfig
checkToLefthookConfig
hookToLefthookConfig
;
};
fromCommand =
{
name,
command,
version ? { },
banner ? { },
required ? true,
}:
{
releaseModule = import ./lib/release.nix {
inherit (common)
lib
importPkgs
;
inherit
name
command
version
banner
required
nixpkgs
releaseScriptPath
;
inherit (defaults)
defaultReleaseChannels
;
};
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 {
repoModule = import ./lib/repo.nix {
inherit
pkgs
system
src
includeStandardPackages
flake-parts
nixpkgs
;
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
inherit (common)
lib
importPkgs
duplicateStrings
mergeUniqueAttrs
;
inherit (defaults)
supportedSystems
defaultReleaseChannels
;
inherit (toolsModule)
normalizeStrictTool
;
inherit (hooksModule)
normalizeLefthookConfig
;
inherit normalizeShellBanner;
inherit (shellModule)
buildShellArtifacts
;
inherit (releaseModule)
mkRelease
;
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

@@ -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/v3.5.1";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};

File diff suppressed because it is too large Load Diff