diff --git a/README.md b/README.md index c18ac68..0665e65 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` 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 diff --git a/docs/reviews/2026-03-21-repo-lib-audit.md b/docs/reviews/2026-03-21-repo-lib-audit.md new file mode 100644 index 0000000..36c2ea5 --- /dev/null +++ b/docs/reviews/2026-03-21-repo-lib-audit.md @@ -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 repo’s 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 diff --git a/docs/superpowers/plans/2026-03-16-typescript-monorepo-template.md b/docs/superpowers/plans/2026-03-16-typescript-monorepo-template.md deleted file mode 100644 index 0b2dbf8..0000000 --- a/docs/superpowers/plans/2026-03-16-typescript-monorepo-template.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-03-16-typescript-monorepo-template-design.md b/docs/superpowers/specs/2026-03-16-typescript-monorepo-template-design.md deleted file mode 100644 index 4cf3d34..0000000 --- a/docs/superpowers/specs/2026-03-16-typescript-monorepo-template-design.md +++ /dev/null @@ -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. diff --git a/flake.lock b/flake.lock index b48d1cd..148fa77 100644 --- a/flake.lock +++ b/flake.lock @@ -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" diff --git a/flake.nix b/flake.nix index 5587636..8c6ebd3 100644 --- a/flake.nix +++ b/flake.nix @@ -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" ''; } diff --git a/packages/release/.gitignore b/packages/release/.gitignore new file mode 100644 index 0000000..a725465 --- /dev/null +++ b/packages/release/.gitignore @@ -0,0 +1 @@ +vendor/ \ No newline at end of file diff --git a/packages/release/cmd/release/main.go b/packages/release/cmd/release/main.go new file mode 100644 index 0000000..842b42d --- /dev/null +++ b/packages/release/cmd/release/main.go @@ -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 +} diff --git a/packages/release/go.mod b/packages/release/go.mod new file mode 100644 index 0000000..abf9e20 --- /dev/null +++ b/packages/release/go.mod @@ -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 +) diff --git a/packages/release/go.sum b/packages/release/go.sum new file mode 100644 index 0000000..91f3ef9 --- /dev/null +++ b/packages/release/go.sum @@ -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= diff --git a/packages/release/internal/release/exec.go b/packages/release/internal/release/exec.go new file mode 100644 index 0000000..0ca14be --- /dev/null +++ b/packages/release/internal/release/exec.go @@ -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 +} diff --git a/packages/release/internal/release/release_step.go b/packages/release/internal/release/release_step.go new file mode 100644 index 0000000..351ea55 --- /dev/null +++ b/packages/release/internal/release/release_step.go @@ -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) + } +} diff --git a/packages/release/internal/release/release_step_apply.go b/packages/release/internal/release/release_step_apply.go new file mode 100644 index 0000000..6e621d3 --- /dev/null +++ b/packages/release/internal/release/release_step_apply.go @@ -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) + } +} diff --git a/packages/release/internal/release/release_step_context.go b/packages/release/internal/release/release_step_context.go new file mode 100644 index 0000000..6f5dfca --- /dev/null +++ b/packages/release/internal/release/release_step_context.go @@ -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) +} diff --git a/packages/release/internal/release/release_step_replace.go b/packages/release/internal/release/release_step_replace.go new file mode 100644 index 0000000..5ed8151 --- /dev/null +++ b/packages/release/internal/release/release_step_replace.go @@ -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() +} diff --git a/packages/release/internal/release/release_step_version_meta.go b/packages/release/internal/release/release_step_version_meta.go new file mode 100644 index 0000000..be9dbc3 --- /dev/null +++ b/packages/release/internal/release/release_step_version_meta.go @@ -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 +} diff --git a/packages/release/internal/release/release_step_write_file.go b/packages/release/internal/release/release_step_write_file.go new file mode 100644 index 0000000..2a4cd5a --- /dev/null +++ b/packages/release/internal/release/release_step_write_file.go @@ -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 +} diff --git a/packages/release/internal/release/release_test.go b/packages/release/internal/release/release_test.go new file mode 100644 index 0000000..d6216bf --- /dev/null +++ b/packages/release/internal/release/release_test.go @@ -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) + } +} diff --git a/packages/release/internal/release/runner.go b/packages/release/internal/release/runner.go new file mode 100644 index 0000000..ad55c03 --- /dev/null +++ b/packages/release/internal/release/runner.go @@ -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)) +} diff --git a/packages/release/internal/release/shell.go b/packages/release/internal/release/shell.go new file mode 100644 index 0000000..3cadd21 --- /dev/null +++ b/packages/release/internal/release/shell.go @@ -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() +} diff --git a/packages/release/internal/release/slices.go b/packages/release/internal/release/slices.go new file mode 100644 index 0000000..7318c52 --- /dev/null +++ b/packages/release/internal/release/slices.go @@ -0,0 +1,10 @@ +package release + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/packages/release/internal/release/ui.go b/packages/release/internal/release/ui.go new file mode 100644 index 0000000..76fbbed --- /dev/null +++ b/packages/release/internal/release/ui.go @@ -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]) + "..." +} diff --git a/packages/release/internal/release/ui_test.go b/packages/release/internal/release/ui_test.go new file mode 100644 index 0000000..ec2a82e --- /dev/null +++ b/packages/release/internal/release/ui_test.go @@ -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 +} diff --git a/packages/release/internal/release/version.go b/packages/release/internal/release/version.go new file mode 100644 index 0000000..bc50992 --- /dev/null +++ b/packages/release/internal/release/version.go @@ -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) +} diff --git a/packages/release/internal/release/version_file.go b/packages/release/internal/release/version_file.go new file mode 100644 index 0000000..0529e47 --- /dev/null +++ b/packages/release/internal/release/version_file.go @@ -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) +} diff --git a/packages/release/release.nix b/packages/release/release.nix index be985b3..d7801b6 100644 --- a/packages/release/release.nix +++ b/packages/release/release.nix @@ -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 diff --git a/packages/release/release.sh b/packages/release/release.sh index 605d141..28ce744 100644 --- a/packages/release/release.sh +++ b/packages/release/release.sh @@ -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 " \ - "" \ - "Bump types:" \ - " (none) bump patch, keep current channel" \ - " major/minor/patch bump the given part, keep current channel" \ - " stable / full remove prerelease suffix (only opt-in path to promote prerelease -> stable)" \ - " __CHANNEL_LIST__ switch channel (from stable, auto-bumps patch unless bump is specified)" \ - "" \ - "Safety rule:" \ - " If current version is prerelease (e.g. x.y.z-beta.N), promotion to stable is allowed only via 'stable' or 'full'." \ - " Commands like '${cmd} set x.y.z' or '${cmd} patch stable' are blocked from prerelease channels." \ - "" \ - "Examples:" \ - " ${cmd} # patch bump on current channel" \ - " ${cmd} minor # minor bump on current channel" \ - " ${cmd} beta # from stable: patch bump + beta.1" \ - " ${cmd} patch beta # patch bump, switch to beta channel" \ - " ${cmd} rc # switch to rc channel" \ - " ${cmd} stable # promote prerelease to stable (opt-in)" \ - " ${cmd} set 1.2.3" \ - " ${cmd} set 1.2.3-beta.1" -} - -# ── git ──────────────────────────────────────────────────────────────────── - -require_clean_git() { - if ! git diff --quiet || ! git diff --cached --quiet; then - echo "Error: git working tree is not clean. Commit or stash changes first." >&2 - exit 1 - fi -} - -revert_on_failure() { - local status=$? - if [[ -n $START_HEAD ]]; then - log "Release failed — reverting to $START_HEAD" - git reset --hard "$START_HEAD" - fi - if [[ -n $CREATED_TAG ]]; then - git tag -d "$CREATED_TAG" >/dev/null 2>&1 || true - fi - exit $status -} - -# ── version parsing ──────────────────────────────────────────────────────── - -parse_base_version() { - local v="$1" - if [[ ! $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - echo "Error: invalid base version '$v' (expected x.y.z)" >&2 - exit 1 - fi - MAJOR="${BASH_REMATCH[1]}" - MINOR="${BASH_REMATCH[2]}" - PATCH="${BASH_REMATCH[3]}" -} - -parse_full_version() { - local v="$1" - CHANNEL="stable" - PRERELEASE_NUM="" - - if [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)-([a-zA-Z]+)\.([0-9]+)$ ]]; then - BASE_VERSION="${BASH_REMATCH[1]}" - CHANNEL="${BASH_REMATCH[2]}" - PRERELEASE_NUM="${BASH_REMATCH[3]}" - elif [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - BASE_VERSION="${BASH_REMATCH[1]}" - else - echo "Error: invalid version '$v' (expected x.y.z or x.y.z-channel.N)" >&2 - exit 1 - fi - parse_base_version "$BASE_VERSION" -} - -validate_channel() { - local ch="$1" - [[ $ch == "stable" ]] && return 0 - local valid_channels="__CHANNEL_LIST__" - for c in $valid_channels; do - [[ $ch == "$c" ]] && return 0 - done - echo "Error: unknown channel '$ch'. Valid channels: stable $valid_channels" >&2 - exit 1 -} - -version_cmp() { - # Returns: 0 if equal, 1 if v1 > v2, 2 if v1 < v2 - # Stable > prerelease for same base version - local v1="$1" v2="$2" - [[ $v1 == "$v2" ]] && return 0 - - local base1="" pre1="" base2="" pre2="" - if [[ $v1 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then - base1="${BASH_REMATCH[1]}" - pre1="${BASH_REMATCH[2]}" - else - base1="$v1" - fi - if [[ $v2 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then - base2="${BASH_REMATCH[1]}" - pre2="${BASH_REMATCH[2]}" - else - base2="$v2" - fi - - if [[ $base1 != "$base2" ]]; then - local highest_base - highest_base=$(printf '%s\n%s\n' "$base1" "$base2" | sort -V | tail -n1) - [[ $highest_base == "$base1" ]] && return 1 || return 2 - fi - - [[ -z $pre1 && -n $pre2 ]] && return 1 # stable > prerelease - [[ -n $pre1 && -z $pre2 ]] && return 2 # prerelease < stable - [[ -z $pre1 && -z $pre2 ]] && return 0 # both stable - - local highest_pre - highest_pre=$(printf '%s\n%s\n' "$pre1" "$pre2" | sort -V | tail -n1) - [[ $highest_pre == "$pre1" ]] && return 1 || return 2 -} - -bump_base_version() { - case "$1" in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - patch) PATCH=$((PATCH + 1)) ;; - *) - echo "Error: unknown bump part '$1'" >&2 - exit 1 - ;; - esac - BASE_VERSION="${MAJOR}.${MINOR}.${PATCH}" -} - -compute_full_version() { - if [[ $CHANNEL == "stable" || -z $CHANNEL ]]; then - FULL_VERSION="$BASE_VERSION" - else - FULL_VERSION="${BASE_VERSION}-${CHANNEL}.${PRERELEASE_NUM:-1}" - fi - FULL_TAG="v$FULL_VERSION" - export BASE_VERSION CHANNEL PRERELEASE_NUM FULL_VERSION FULL_TAG -} - -meta_env_name() { - local key="$1" - key="${key//[^[:alnum:]]/_}" - key="$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')" - printf 'VERSION_META_%s\n' "$key" -} - -clear_version_meta_exports() { - local export_name - for export_name in "${VERSION_META_EXPORT_NAMES[@]:-}"; do - unset "$export_name" - done - VERSION_META_EXPORT_NAMES=() -} - -load_version_metadata() { - VERSION_META_LINES=() - [[ ! -f "$ROOT_DIR/VERSION" ]] && return 0 - - while IFS= read -r line || [[ -n $line ]]; do - VERSION_META_LINES+=("$line") - done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true) -} - -export_version_metadata() { - clear_version_meta_exports - - local line key value export_name - for line in "${VERSION_META_LINES[@]:-}"; do - [[ $line != *=* ]] && continue - key="${line%%=*}" - value="${line#*=}" - [[ -z $key ]] && continue - export_name="$(meta_env_name "$key")" - printf -v "$export_name" '%s' "$value" - export "${export_name?}=$value" - VERSION_META_EXPORT_NAMES+=("$export_name") - done -} - -write_version_file() { - local channel_to_write="$1" - local n_to_write="$2" - { - printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" - local line - for line in "${VERSION_META_LINES[@]:-}"; do - printf '%s\n' "$line" - done - } >"$ROOT_DIR/VERSION" -} - -version_meta_get() { - local key="${1-}" - local line - for line in "${VERSION_META_LINES[@]:-}"; do - if [[ $line == "$key="* ]]; then - printf '%s\n' "${line#*=}" - return 0 - fi - done - return 1 -} - -version_meta_set() { - local key="${1-}" - local value="${2-}" - [[ -z $key ]] && echo "Error: version_meta_set requires a key" >&2 && exit 1 - - local updated=0 - local index - for index in "${!VERSION_META_LINES[@]}"; do - if [[ ${VERSION_META_LINES[index]} == "$key="* ]]; then - VERSION_META_LINES[index]="$key=$value" - updated=1 - break - fi - done - - if [[ $updated -eq 0 ]]; then - VERSION_META_LINES+=("$key=$value") - fi - - export_version_metadata - version_meta_write -} - -version_meta_unset() { - local key="${1-}" - [[ -z $key ]] && echo "Error: version_meta_unset requires a key" >&2 && exit 1 - - local filtered=() - local line - for line in "${VERSION_META_LINES[@]:-}"; do - [[ $line == "$key="* ]] && continue - filtered+=("$line") - done - VERSION_META_LINES=("${filtered[@]}") - - export_version_metadata - version_meta_write -} - -version_meta_write() { - local channel_to_write="$CHANNEL" - local n_to_write="${PRERELEASE_NUM:-1}" - if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then - channel_to_write="stable" - n_to_write="0" - fi - write_version_file "$channel_to_write" "$n_to_write" -} - -# ── gitlint ──────────────────────────────────────────────────────────────── - -get_gitlint_title_regex() { - [[ ! -f $GITLINT_FILE ]] && return 0 - awk ' - /^\[title-match-regex\]$/ { in_section=1; next } - /^\[/ { in_section=0 } - in_section && /^regex=/ { sub(/^regex=/, ""); print; exit } - ' "$GITLINT_FILE" -} - -validate_commit_message() { - local msg="$1" - local regex - regex="$(get_gitlint_title_regex)" - if [[ -n $regex && ! $msg =~ $regex ]]; then - echo "Error: commit message does not match .gitlint title-match-regex" >&2 - echo "Regex: $regex" >&2 - echo "Message: $msg" >&2 - exit 1 - fi -} - -# ── version file generation ──────────────────────────────────────────────── - -run_release_steps() { - : - __RELEASE_STEPS__ -} - -# ── version source (built-in) ────────────────────────────────────────────── - -# Initializes $ROOT_DIR/VERSION from git tags if it doesn't exist. -# Must be called outside of any subshell so log output stays on stderr -# and never contaminates the stdout of do_read_version. -init_version_file() { - if [[ -f "$ROOT_DIR/VERSION" ]]; then - load_version_metadata - export_version_metadata - return 0 - fi - - local highest_tag="" - while IFS= read -r raw_tag; do - local tag="${raw_tag#v}" - [[ $tag =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$ ]] || continue - - if [[ -z $highest_tag ]]; then - highest_tag="$tag" - continue - fi - - local cmp_status=0 - version_cmp "$tag" "$highest_tag" || cmp_status=$? - [[ $cmp_status -eq 1 ]] && highest_tag="$tag" - done < <(git tag --list) - - [[ -z $highest_tag ]] && highest_tag="0.0.1" - - parse_full_version "$highest_tag" - local channel_to_write="$CHANNEL" - local n_to_write="${PRERELEASE_NUM:-1}" - if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then - channel_to_write="stable" - n_to_write="0" - fi - - VERSION_META_LINES=() - write_version_file "$channel_to_write" "$n_to_write" - export_version_metadata - log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag" -} - -do_read_version() { - load_version_metadata - export_version_metadata - - local base_line channel_line n_line - base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')" - channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')" - n_line="$(sed -n '3p' "$ROOT_DIR/VERSION" | tr -d '\r')" - - if [[ -z $channel_line || $channel_line == "stable" ]]; then - printf '%s\n' "$base_line" - else - printf '%s-%s.%s\n' "$base_line" "$channel_line" "$n_line" - fi -} - -do_write_version() { - local channel_to_write="$CHANNEL" - local n_to_write="${PRERELEASE_NUM:-1}" - if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then - channel_to_write="stable" - n_to_write="0" - fi - write_version_file "$channel_to_write" "$n_to_write" - export_version_metadata -} - -# ── user-provided hook ───────────────────────────────────────────────────── - -do_post_version() { - : - __POST_VERSION__ -} - -# ── main ─────────────────────────────────────────────────────────────────── - -main() { - [[ ${1-} == "-h" || ${1-} == "--help" ]] && usage && exit 0 - - require_clean_git - START_HEAD="$(git rev-parse HEAD)" - trap revert_on_failure ERR - - # Initialize VERSION file outside any subshell so log lines never - # bleed into the stdout capture below. - init_version_file - - local raw_version - raw_version="$(do_read_version | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$' | tail -n1)" - if [[ -z $raw_version ]]; then - echo "Error: could not determine current version from VERSION source" >&2 - exit 1 - fi - parse_full_version "$raw_version" - compute_full_version - local current_full="$FULL_VERSION" - - log "Current: base=$BASE_VERSION channel=$CHANNEL pre=${PRERELEASE_NUM:-}" - - local action="${1-}" - shift || true - - if [[ $action == "set" ]]; then - local newv="${1-}" - local current_channel="$CHANNEL" - [[ -z $newv ]] && echo "Error: 'set' requires a version argument" >&2 && exit 1 - parse_full_version "$newv" - validate_channel "$CHANNEL" - if [[ $current_channel != "stable" && $CHANNEL == "stable" ]]; then - echo "Error: from prerelease channel '$current_channel', promote using 'stable' or 'full' only" >&2 - exit 1 - fi - compute_full_version - local cmp_status=0 - version_cmp "$FULL_VERSION" "$current_full" || cmp_status=$? - case $cmp_status in - 0) - echo "Version $FULL_VERSION is already current; nothing to do." >&2 - exit 1 - ;; - 2) - echo "Error: $FULL_VERSION is lower than current $current_full" >&2 - exit 1 - ;; - esac - - else - local part="" target_channel="" was_channel_only=0 - - case "$action" in - "") part="patch" ;; - major | minor | patch) - part="$action" - target_channel="${1-}" - ;; - stable | full) - [[ -n ${1-} ]] && echo "Error: '$action' takes no second argument" >&2 && usage && exit 1 - target_channel="stable" - ;; - *) - # check if action is a valid channel - local is_channel=0 - for c in __CHANNEL_LIST__; do - [[ $action == "$c" ]] && is_channel=1 && break - done - if [[ $is_channel == 1 ]]; then - [[ -n ${1-} ]] && echo "Error: channel-only bump takes no second argument" >&2 && usage && exit 1 - target_channel="$action" - was_channel_only=1 - else - echo "Error: unknown argument '$action'" >&2 - usage - exit 1 - fi - ;; - esac - - [[ -z $target_channel ]] && target_channel="$CHANNEL" - [[ $target_channel == "full" ]] && target_channel="stable" - validate_channel "$target_channel" - if [[ $CHANNEL != "stable" && $target_channel == "stable" && $action != "stable" && $action != "full" ]]; then - echo "Error: from prerelease channel '$CHANNEL', promote using 'stable' or 'full' only" >&2 - exit 1 - fi - - if [[ -z $part && $was_channel_only -eq 1 && $CHANNEL == "stable" && $target_channel != "stable" ]]; then - part="patch" - fi - - local old_base="$BASE_VERSION" old_channel="$CHANNEL" old_pre="$PRERELEASE_NUM" - [[ -n $part ]] && bump_base_version "$part" - - if [[ $target_channel == "stable" ]]; then - CHANNEL="stable" - PRERELEASE_NUM="" - else - if [[ $BASE_VERSION == "$old_base" && $target_channel == "$old_channel" && -n $old_pre ]]; then - PRERELEASE_NUM=$((old_pre + 1)) - else - PRERELEASE_NUM=1 - fi - CHANNEL="$target_channel" - fi - fi - - compute_full_version - if [[ $FULL_VERSION == "$current_full" ]]; then - echo "Version $FULL_VERSION is already current; nothing to do." >&2 - exit 1 - fi - log "Releasing $FULL_VERSION" - - do_write_version - log "Updated version source" - - run_release_steps - log "Release steps done" - - do_post_version - log "Post-version hook done" - - (cd "$ROOT_DIR" && nix fmt) - log "Formatted files" - - git add -A - local commit_msg="chore(release): v$FULL_VERSION" - validate_commit_message "$commit_msg" - git commit -m "$commit_msg" - log "Created commit" - - git tag "$FULL_TAG" - CREATED_TAG="$FULL_TAG" - log "Tagged $FULL_TAG" - - git push - git push --tags - log "Done — released $FULL_TAG" - - trap - ERR -} - -main "$@" +REPO_LIB_RELEASE_ROOT_DIR="$(git rev-parse --show-toplevel)" +export REPO_LIB_RELEASE_ROOT_DIR +export REPO_LIB_RELEASE_CHANNELS='__CHANNEL_LIST__' +REPO_LIB_RELEASE_STEPS_JSON="$(cat <<'EOF' +__RELEASE_STEPS_JSON__ +EOF +)" +export REPO_LIB_RELEASE_STEPS_JSON +REPO_LIB_RELEASE_POST_VERSION="$(cat <<'EOF' +__POST_VERSION__ +EOF +)" +export REPO_LIB_RELEASE_POST_VERSION + +exec __RELEASE_RUNNER__ "$@" diff --git a/packages/repo-lib/lib.nix b/packages/repo-lib/lib.nix index 86902f0..aba2ddc 100644 --- a/packages/repo-lib/lib.nix +++ b/packages/repo-lib/lib.nix @@ -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 ""}' is missing 'package' or 'command'" - else - { - kind = "strict"; - inherit executable version banner; - name = tool.name; - package = tool.package or null; - required = tool.required or true; - }; - - normalizeLegacyTool = - pkgs: tool: - if tool ? package then - normalizeStrictTool pkgs tool - else - { - kind = "legacy"; - name = tool.name; - command = tool.bin; - versionCommand = tool.versionCmd or "--version"; - banner = { - color = tool.color or "YELLOW"; - icon = tool.icon or null; - iconColor = tool.iconColor or null; - }; - required = tool.required or false; - }; - - checkToLefthookConfig = - pkgs: name: rawCheck: - let - check = { - stage = "pre-commit"; - passFilenames = false; - runtimeInputs = [ ]; - } - // rawCheck; - wrapperName = "repo-lib-check-${sanitizeName name}"; - wrapper = pkgs.writeShellApplication { - name = wrapperName; - runtimeInputs = check.runtimeInputs; - text = '' - set -euo pipefail - ${check.command} - ''; - }; - in - if !(check ? command) then - throw "repo-lib: check '${name}' is missing 'command'" - else if - !(builtins.elem check.stage [ - "pre-commit" - "pre-push" - ]) - then - throw "repo-lib: check '${name}' has unsupported stage '${check.stage}'" - else - lib.setAttrByPath [ check.stage "commands" name ] { - run = "${wrapper}/bin/${wrapperName}${hookStageFileArgs check.stage check.passFilenames}"; - }; - - normalizeLefthookConfig = - label: raw: if builtins.isAttrs raw then raw else throw "repo-lib: ${label} must be an attrset"; - - normalizeHookStage = - hookName: stage: - if - builtins.elem stage [ - "pre-commit" - "pre-push" - "commit-msg" - ] - then - stage - else - throw "repo-lib: hook '${hookName}' has unsupported stage '${stage}' for lefthook"; - - hookStageFileArgs = - stage: passFilenames: - if !passFilenames then - "" - else if stage == "pre-commit" then - " {staged_files}" - else if stage == "pre-push" then - " {push_files}" - else if stage == "commit-msg" then - " {1}" - else - throw "repo-lib: unsupported lefthook stage '${stage}'"; - - hookToLefthookConfig = - name: hook: - let - supportedFields = [ - "description" - "enable" - "entry" - "name" - "package" - "pass_filenames" - "stages" - ]; - unsupportedFields = builtins.filter (field: !(builtins.elem field supportedFields)) ( - builtins.attrNames hook - ); - stages = builtins.map (stage: normalizeHookStage name stage) (hook.stages or [ "pre-commit" ]); - passFilenames = hook.pass_filenames or false; - in - if unsupportedFields != [ ] then - throw '' - repo-lib: hook '${name}' uses unsupported fields for lefthook: ${lib.concatStringsSep ", " unsupportedFields} - '' - else if !(hook ? entry) then - throw "repo-lib: hook '${name}' is missing 'entry'" - else - lib.foldl' lib.recursiveUpdate { } ( - builtins.map ( - stage: - lib.setAttrByPath [ stage "commands" name ] { - run = "${hook.entry}${hookStageFileArgs stage passFilenames}"; - } - ) stages - ); - - parallelHookStageConfig = - stage: - if - builtins.elem stage [ - "pre-commit" - "pre-push" - ] - then - lib.setAttrByPath [ stage "parallel" ] true - else - { }; - - normalizeReleaseStep = - step: - if step ? writeFile then - { - kind = "writeFile"; - path = step.writeFile.path; - text = step.writeFile.text; - runtimeInputs = [ ]; - } - else if step ? replace then - { - kind = "replace"; - path = step.replace.path; - regex = step.replace.regex; - replacement = step.replace.replacement; - runtimeInputs = [ ]; - } - else if step ? run && builtins.isAttrs step.run then - { - kind = "run"; - script = step.run.script; - runtimeInputs = step.run.runtimeInputs or [ ]; - } - else if step ? run then - { - kind = "run"; - script = step.run; - runtimeInputs = [ ]; - } - else if step ? file then - { - kind = "writeFile"; - path = step.file; - text = step.content; - runtimeInputs = [ ]; - } - else - throw "repo-lib: release step must contain one of writeFile, replace, or run"; - - releaseStepScript = - step: - if step.kind == "writeFile" then - '' - target_path="$ROOT_DIR/${step.path}" - mkdir -p "$(dirname "$target_path")" - cat >"$target_path" << NIXEOF - ${step.text} - NIXEOF - log "Generated version file: ${step.path}" - '' - else if step.kind == "replace" then - '' - target_path="$ROOT_DIR/${step.path}" - REPO_LIB_STEP_REGEX=$(cat <<'NIXEOF' - ${step.regex} - NIXEOF - ) - REPO_LIB_STEP_REPLACEMENT=$(cat <; - close $in; - - my $regex = qr/$regex_src/ms; - $content =~ s{$regex}{ - my @cap = map { defined $_ ? $_ : q{} } ($1, $2, $3, $4, $5, $6, $7, $8, $9); - my $result = $template; - $result =~ s{\\([1-9])}{$cap[$1 - 1]}ge; - $result; - }gems; - - open my $out, q{>}, $path or die "failed to open $path for write: $!"; - print {$out} $content; - close $out; - REPO_LIB_PERL_REPLACE - log "Updated ${step.path}" - '' - else - '' - ${step.script} - ''; - - normalizeReleaseConfig = - raw: - let - hasLegacySteps = raw ? release; - hasStructuredSteps = raw ? steps; - steps = - if hasLegacySteps && hasStructuredSteps then - throw "repo-lib: pass either 'release' or 'steps' to mkRelease, not both" - else if hasStructuredSteps then - builtins.map normalizeReleaseStep raw.steps - else if hasLegacySteps then - builtins.map normalizeReleaseStep raw.release - else - [ ]; - in - { - postVersion = raw.postVersion or ""; - channels = raw.channels or defaultReleaseChannels; - runtimeInputs = (raw.runtimeInputs or [ ]) ++ (raw.extraRuntimeInputs or [ ]); - steps = steps; - }; - - buildShellHook = - { - hooksShellHook, - shellEnvScript, - bootstrap, - shellBannerScript, - extraShellText, - toolLabelWidth, - }: - let - template = builtins.readFile shellHookTemplatePath; - in - builtins.replaceStrings - [ - "@HOOKS_SHELL_HOOK@" - "@TOOL_LABEL_WIDTH@" - "@SHELL_ENV_SCRIPT@" - "@BOOTSTRAP@" - "@SHELL_BANNER_SCRIPT@" - "@EXTRA_SHELL_TEXT@" - ] - [ - hooksShellHook - (toString toolLabelWidth) - shellEnvScript - bootstrap - shellBannerScript - extraShellText - ] - template; - - buildShellArtifacts = - { - pkgs, - system, - src, - includeStandardPackages ? true, - formatting, - tools ? [ ], - shellConfig ? { - env = { }; - extraShellText = ""; - bootstrap = ""; - banner = defaultShellBanner; - }, - checkSpecs ? { }, - rawHookEntries ? { }, - lefthookConfig ? { }, - extraPackages ? [ ], - }: - let - standardPackages = with pkgs; [ - nixfmt - gitlint - gitleaks - shfmt - ]; - toolPackages = lib.filter (pkg: pkg != null) (builtins.map (tool: tool.package or null) tools); - selectedStandardPackages = lib.optionals includeStandardPackages standardPackages; - - treefmtEval = treefmt-nix.lib.evalModule pkgs { - projectRootFile = "flake.nix"; - programs = { - nixfmt.enable = true; - } - // formatting.programs; - settings.formatter = { } // formatting.settings; - }; - treefmtWrapper = treefmtEval.config.build.wrapper; - lefthookBinWrapper = pkgs.writeShellScript "lefthook-dumb-term" '' - exec env TERM=dumb ${lib.getExe pkgs.lefthook} "$@" - ''; - - normalizedLefthookConfig = normalizeLefthookConfig "lefthook config" lefthookConfig; - lefthookCheck = lefthookNix.lib.${system}.run { - inherit src; - config = lib.foldl' lib.recursiveUpdate { } ( - [ - { - output = [ - "failure" - "summary" - ]; - } - (parallelHookStageConfig "pre-commit") - (parallelHookStageConfig "pre-push") - (lib.setAttrByPath [ "pre-commit" "commands" "treefmt" ] { - run = "${treefmtWrapper}/bin/treefmt --no-cache {staged_files}"; - stage_fixed = true; - }) - (lib.setAttrByPath [ "pre-commit" "commands" "gitleaks" ] { - run = "${pkgs.gitleaks}/bin/gitleaks protect --staged"; - }) - (lib.setAttrByPath [ "commit-msg" "commands" "gitlint" ] { - run = "${pkgs.gitlint}/bin/gitlint --staged --msg-filename {1}"; - }) - ] - ++ lib.mapAttrsToList (name: check: checkToLefthookConfig pkgs name check) checkSpecs - ++ lib.mapAttrsToList hookToLefthookConfig rawHookEntries - ++ [ normalizedLefthookConfig ] - ); - }; - selectedCheckOutputs = { - formatting-check = treefmtEval.config.build.check src; - hook-check = lefthookCheck; - lefthook-check = lefthookCheck; - }; - - toolNames = builtins.map (tool: tool.name) tools; - toolNameWidth = - if toolNames == [ ] then - 0 - else - builtins.foldl' (maxWidth: name: lib.max maxWidth (builtins.stringLength name)) 0 toolNames; - toolLabelWidth = toolNameWidth + 1; - - shellEnvScript = lib.concatStringsSep "\n" ( - lib.mapAttrsToList ( - name: value: "export ${name}=${lib.escapeShellArg (toString value)}" - ) shellConfig.env - ); - - banner = normalizeShellBanner (shellConfig.banner or { }); - - shellBannerScript = - if banner.style == "pretty" then - '' - repo_lib_print_pretty_header \ - ${lib.escapeShellArg banner.borderColor} \ - ${lib.escapeShellArg banner.titleColor} \ - ${lib.escapeShellArg banner.icon} \ - ${lib.escapeShellArg banner.title} \ - ${lib.escapeShellArg banner.subtitleColor} \ - ${lib.escapeShellArg banner.subtitle} - '' - + lib.concatMapStrings ( - tool: - if tool.kind == "strict" then - '' - repo_lib_print_pretty_tool \ - ${lib.escapeShellArg banner.borderColor} \ - ${lib.escapeShellArg tool.name} \ - ${lib.escapeShellArg tool.banner.color} \ - ${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \ - ${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \ - ${lib.escapeShellArg (if tool.required then "1" else "0")} \ - ${lib.escapeShellArg (toString tool.version.line)} \ - ${lib.escapeShellArg (toString tool.version.group)} \ - ${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \ - ${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \ - ${lib.escapeShellArg tool.executable} \ - ${lib.escapeShellArgs tool.version.args} - '' - else - '' - repo_lib_print_pretty_legacy_tool \ - ${lib.escapeShellArg banner.borderColor} \ - ${lib.escapeShellArg tool.name} \ - ${lib.escapeShellArg tool.banner.color} \ - ${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \ - ${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \ - ${lib.escapeShellArg (if tool.required then "1" else "0")} \ - ${lib.escapeShellArg tool.command} \ - ${lib.escapeShellArg tool.versionCommand} - '' - ) tools - + '' - repo_lib_print_pretty_footer \ - ${lib.escapeShellArg banner.borderColor} - '' - else - '' - repo_lib_print_simple_header \ - ${lib.escapeShellArg banner.titleColor} \ - ${lib.escapeShellArg banner.icon} \ - ${lib.escapeShellArg banner.title} \ - ${lib.escapeShellArg banner.subtitleColor} \ - ${lib.escapeShellArg banner.subtitle} - '' - + lib.concatMapStrings ( - tool: - if tool.kind == "strict" then - '' - repo_lib_print_simple_tool \ - ${lib.escapeShellArg tool.name} \ - ${lib.escapeShellArg tool.banner.color} \ - ${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \ - ${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \ - ${lib.escapeShellArg (if tool.required then "1" else "0")} \ - ${lib.escapeShellArg (toString tool.version.line)} \ - ${lib.escapeShellArg (toString tool.version.group)} \ - ${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \ - ${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \ - ${lib.escapeShellArg tool.executable} \ - ${lib.escapeShellArgs tool.version.args} - '' - else - '' - repo_lib_print_simple_legacy_tool \ - ${lib.escapeShellArg tool.name} \ - ${lib.escapeShellArg tool.banner.color} \ - ${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \ - ${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \ - ${lib.escapeShellArg (if tool.required then "1" else "0")} \ - ${lib.escapeShellArg tool.command} \ - ${lib.escapeShellArg tool.versionCommand} - '' - ) tools - + '' - printf "\n" - ''; - in - { - checks = selectedCheckOutputs; - formatter = treefmtWrapper; - shell = pkgs.mkShell { - LEFTHOOK_BIN = builtins.toString lefthookBinWrapper; - packages = lib.unique ( - selectedStandardPackages - ++ extraPackages - ++ toolPackages - ++ [ - pkgs.lefthook - treefmtWrapper - ] - ); - shellHook = buildShellHook { - hooksShellHook = lefthookCheck.shellHook; - inherit toolLabelWidth shellEnvScript shellBannerScript; - bootstrap = shellConfig.bootstrap; - extraShellText = shellConfig.extraShellText; - }; - }; - } - // selectedCheckOutputs; + toolsModule = import ./lib/tools.nix { + lib = common.lib; + }; + hooksModule = import ./lib/hooks.nix { + inherit (common) lib sanitizeName; + }; + shellModule = import ./lib/shell.nix { + inherit (common) + lib + ; + inherit + treefmt-nix + lefthookNix + shellHookTemplatePath + ; + inherit (defaults) + defaultShellBanner + ; + inherit normalizeShellBanner; + inherit (hooksModule) + normalizeLefthookConfig + parallelHookStageConfig + checkToLefthookConfig + hookToLefthookConfig + ; + }; + releaseModule = import ./lib/release.nix { + inherit (common) + lib + importPkgs + ; + inherit + nixpkgs + releaseScriptPath + ; + inherit (defaults) + defaultReleaseChannels + ; + }; + repoModule = import ./lib/repo.nix { + inherit + flake-parts + nixpkgs + ; + inherit (common) + lib + importPkgs + duplicateStrings + mergeUniqueAttrs + ; + inherit (defaults) + supportedSystems + defaultReleaseChannels + ; + inherit (toolsModule) + normalizeStrictTool + ; + inherit (hooksModule) + normalizeLefthookConfig + ; + inherit normalizeShellBanner; + inherit (shellModule) + buildShellArtifacts + ; + inherit (releaseModule) + mkRelease + ; + }; in -rec { - systems = { - default = supportedSystems; - }; - - tools = rec { - fromPackage = - { - name, - package, - exe ? null, - version ? { }, - banner ? { }, - required ? true, - }: - { - inherit - name - package - exe - version - banner - required - ; - }; - - fromCommand = - { - name, - command, - version ? { }, - banner ? { }, - required ? true, - }: - { - inherit - name - command - version - banner - required - ; - }; - - simple = - name: package: args: - fromPackage { - inherit name package; - version.args = args; - }; - }; - - normalizeRepoConfig = - rawConfig: - let - merged = lib.recursiveUpdate { - includeStandardPackages = true; - shell = { - env = { }; - extraShellText = ""; - allowImpureBootstrap = false; - bootstrap = ""; - banner = { }; - }; - formatting = { - programs = { }; - settings = { }; - }; - checks = { }; - lefthook = { }; - release = null; - } rawConfig; - release = - if merged.release == null then - null - else - { - channels = defaultReleaseChannels; - steps = [ ]; - postVersion = ""; - runtimeInputs = [ ]; - } - // merged.release; - in - if merged.shell.bootstrap != "" && !merged.shell.allowImpureBootstrap then - throw "repo-lib: config.shell.bootstrap requires config.shell.allowImpureBootstrap = true" - else - merged - // { - inherit release; - shell = merged.shell // { - banner = normalizeShellBanner merged.shell.banner; - }; - }; - - mkDevShell = - { - system, - src ? ./., - nixpkgsInput ? nixpkgs, - extraPackages ? [ ], - preToolHook ? "", - extraShellHook ? "", - additionalHooks ? { }, - lefthook ? { }, - tools ? [ ], - includeStandardPackages ? true, - formatters ? { }, - formatterSettings ? { }, - features ? { }, - }: - let - pkgs = importPkgs nixpkgsInput system; - oxfmtEnabled = features.oxfmt or false; - legacyTools = builtins.map (tool: normalizeLegacyTool pkgs tool) tools; - duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) legacyTools); - normalizedFormatting = { - programs = - (lib.optionalAttrs oxfmtEnabled { - oxfmt.enable = true; - }) - // formatters; - settings = formatterSettings; - }; - shellConfig = { - env = { }; - extraShellText = extraShellHook; - allowImpureBootstrap = true; - bootstrap = preToolHook; - banner = defaultShellBanner; - }; - in - if duplicateToolNames != [ ] then - throw "repo-lib: duplicate tool names: ${lib.concatStringsSep ", " duplicateToolNames}" - else - buildShellArtifacts { - inherit - pkgs - system - src - includeStandardPackages - ; - formatting = normalizedFormatting; - rawHookEntries = additionalHooks; - lefthookConfig = lefthook; - shellConfig = shellConfig; - tools = legacyTools; - extraPackages = - extraPackages - ++ lib.optionals oxfmtEnabled [ - pkgs.oxfmt - pkgs.oxlint - ]; - }; - - mkRelease = - { - system, - nixpkgsInput ? nixpkgs, - ... - }@rawArgs: - let - pkgs = importPkgs nixpkgsInput system; - release = normalizeReleaseConfig rawArgs; - channelList = lib.concatStringsSep " " release.channels; - releaseStepsScript = lib.concatMapStrings releaseStepScript release.steps; - script = - builtins.replaceStrings - [ - "__CHANNEL_LIST__" - "__RELEASE_STEPS__" - "__POST_VERSION__" - ] - [ - channelList - releaseStepsScript - release.postVersion - ] - (builtins.readFile releaseScriptPath); - in - pkgs.writeShellApplication { - name = "release"; - runtimeInputs = - with pkgs; - [ - git - gnugrep - gawk - gnused - coreutils - perl - ] - ++ release.runtimeInputs - ++ lib.concatMap (step: step.runtimeInputs or [ ]) release.steps; - text = script; - }; - - mkRepo = - { - self, - nixpkgs, - src ? ./., - systems ? supportedSystems, - config ? { }, - perSystem ? ( - { - pkgs, - system, - lib, - config, - }: - { } - ), - }: - let - normalizedConfig = normalizeRepoConfig config; - systemResults = lib.genAttrs systems ( - system: - let - pkgs = importPkgs nixpkgs system; - perSystemResult = { - tools = [ ]; - shell = { }; - checks = { }; - lefthook = { }; - packages = { }; - apps = { }; - } - // perSystem { - inherit pkgs system; - lib = nixpkgs.lib; - config = normalizedConfig; - }; - - strictTools = builtins.map (tool: normalizeStrictTool pkgs tool) perSystemResult.tools; - duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) strictTools); - mergedChecks = mergeUniqueAttrs "check" normalizedConfig.checks perSystemResult.checks; - mergedLefthookConfig = - lib.recursiveUpdate (normalizeLefthookConfig "config.lefthook" normalizedConfig.lefthook) - (normalizeLefthookConfig "perSystem.lefthook" (perSystemResult.lefthook or { })); - shellConfig = lib.recursiveUpdate normalizedConfig.shell (perSystemResult.shell or { }); - env = - if duplicateToolNames != [ ] then - throw "repo-lib: duplicate tool names: ${lib.concatStringsSep ", " duplicateToolNames}" - else - buildShellArtifacts { - inherit - pkgs - system - src - ; - includeStandardPackages = normalizedConfig.includeStandardPackages; - formatting = normalizedConfig.formatting; - tools = strictTools; - checkSpecs = mergedChecks; - lefthookConfig = mergedLefthookConfig; - shellConfig = shellConfig; - extraPackages = perSystemResult.shell.packages or [ ]; - }; - - releasePackages = - if normalizedConfig.release == null then - { } - else - { - release = mkRelease { - inherit system; - nixpkgsInput = nixpkgs; - channels = normalizedConfig.release.channels; - steps = normalizedConfig.release.steps; - postVersion = normalizedConfig.release.postVersion; - runtimeInputs = normalizedConfig.release.runtimeInputs; - }; - }; - in - { - inherit env; - packages = mergeUniqueAttrs "package" releasePackages perSystemResult.packages; - apps = perSystemResult.apps; - } - ); - in - { - devShells = lib.genAttrs systems (system: { - default = systemResults.${system}.env.shell; - }); - - checks = lib.genAttrs systems (system: systemResults.${system}.env.checks); - - formatter = lib.genAttrs systems (system: systemResults.${system}.env.formatter); - packages = lib.genAttrs systems (system: systemResults.${system}.packages); - apps = lib.genAttrs systems (system: systemResults.${system}.apps); - }; +{ + systems.default = defaults.supportedSystems; + inherit (toolsModule) tools; + inherit (repoModule) normalizeRepoConfig mkRepo; + inherit (releaseModule) mkRelease; } diff --git a/packages/repo-lib/lib/common.nix b/packages/repo-lib/lib/common.nix new file mode 100644 index 0000000..ce3e93a --- /dev/null +++ b/packages/repo-lib/lib/common.nix @@ -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; +} diff --git a/packages/repo-lib/lib/defaults.nix b/packages/repo-lib/lib/defaults.nix new file mode 100644 index 0000000..0bf7f96 --- /dev/null +++ b/packages/repo-lib/lib/defaults.nix @@ -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"; + }; +} diff --git a/packages/repo-lib/lib/hooks.nix b/packages/repo-lib/lib/hooks.nix new file mode 100644 index 0000000..93bb458 --- /dev/null +++ b/packages/repo-lib/lib/hooks.nix @@ -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 + { }; +} diff --git a/packages/repo-lib/lib/release.nix b/packages/repo-lib/lib/release.nix new file mode 100644 index 0000000..5583921 --- /dev/null +++ b/packages/repo-lib/lib/release.nix @@ -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 + ; +} diff --git a/packages/repo-lib/lib/repo.nix b/packages/repo-lib/lib/repo.nix new file mode 100644 index 0000000..e0d7b56 --- /dev/null +++ b/packages/repo-lib/lib/repo.nix @@ -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 + ; + }; + }; +} diff --git a/packages/repo-lib/lib/shell.nix b/packages/repo-lib/lib/shell.nix new file mode 100644 index 0000000..e5722aa --- /dev/null +++ b/packages/repo-lib/lib/shell.nix @@ -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; +} diff --git a/packages/repo-lib/lib/tools.nix b/packages/repo-lib/lib/tools.nix new file mode 100644 index 0000000..81c8c00 --- /dev/null +++ b/packages/repo-lib/lib/tools.nix @@ -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 ""}' 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; + }; + }; +} diff --git a/packages/repo-lib/shell-hook.sh b/packages/repo-lib/shell-hook.sh index ce892f0..2f395c6 100644 --- a/packages/repo-lib/shell-hook.sh +++ b/packages/repo-lib/shell-hook.sh @@ -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}" diff --git a/tests/release.sh b/tests/release.sh deleted file mode 100755 index 23dbe20..0000000 --- a/tests/release.sh +++ /dev/null @@ -1,1441 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT_DIR="${REPO_LIB_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" -RELEASE_TEMPLATE="$ROOT_DIR/packages/release/release.sh" -NIXPKGS_FLAKE_PATH="${NIXPKGS_FLAKE_PATH:-}" -CURRENT_LOG="" -QC_SEEN_TAGS=() - -if [[ -z "$NIXPKGS_FLAKE_PATH" ]]; then - NIXPKGS_FLAKE_PATH="$(nix eval --raw --impure --expr "(builtins.getFlake (toString ${ROOT_DIR})).inputs.nixpkgs.outPath")" -fi - -fail() { - echo "[test] FAIL: $*" >&2 - if [[ -n "$CURRENT_LOG" && -f "$CURRENT_LOG" ]]; then - echo "[test] ---- captured output ----" >&2 - cat "$CURRENT_LOG" >&2 - echo "[test] -------------------------" >&2 - fi - exit 1 -} - -assert_eq() { - local expected="$1" - local actual="$2" - local message="$3" - if [[ "$expected" != "$actual" ]]; then - fail "$message (expected '$expected', got '$actual')" - fi -} - -assert_contains() { - local needle="$1" - local haystack_file="$2" - local message="$3" - if ! grep -Fq -- "$needle" "$haystack_file"; then - fail "$message (missing '$needle')" - fi -} - -run_capture_ok() { - local description="$1" - shift - if ! "$@" >>"$CURRENT_LOG" 2>&1; then - fail "$description" - fi -} - -make_release_script() { - local target="$1" - make_release_script_with_content "$target" ":" ":" -} - -make_release_script_with_content() { - local target="$1" - local release_steps="$2" - local post_version="$3" - local script - - script="$(cat "$RELEASE_TEMPLATE")" - script="${script//__CHANNEL_LIST__/alpha beta rc internal}" - script="${script//__RELEASE_STEPS__/$release_steps}" - script="${script//__POST_VERSION__/$post_version}" - printf '%s' "$script" >"$target" - chmod +x "$target" -} - -setup_repo() { - local repo_dir="$1" - local remote_dir="$2" - - mkdir -p "$repo_dir" - run_capture_ok "setup_repo: git init failed" git -C "$repo_dir" init - run_capture_ok "setup_repo: git config user.name failed" git -C "$repo_dir" config user.name "Release Test" - run_capture_ok "setup_repo: git config user.email failed" git -C "$repo_dir" config user.email "release-test@example.com" - run_capture_ok "setup_repo: git config commit.gpgsign failed" git -C "$repo_dir" config commit.gpgsign false - run_capture_ok "setup_repo: git config tag.gpgsign failed" git -C "$repo_dir" config tag.gpgsign false - - cat >"$repo_dir/flake.nix" <<'EOF' -{ - description = "release test"; - outputs = { self }: { }; -} -EOF - - printf '1.0.0\nstable\n0\n' >"$repo_dir/VERSION" - run_capture_ok "setup_repo: git add failed" git -C "$repo_dir" add -A - run_capture_ok "setup_repo: git commit failed" git -C "$repo_dir" commit -m "init" - - run_capture_ok "setup_repo: git init --bare failed" git init --bare "$remote_dir" - run_capture_ok "setup_repo: git remote add failed" git -C "$repo_dir" remote add origin "$remote_dir" - run_capture_ok "setup_repo: initial push failed" git -C "$repo_dir" push -u origin HEAD -} - -version_from_file() { - local repo_dir="$1" - local base channel n - base="$(sed -n '1p' "$repo_dir/VERSION" | tr -d '\r')" - channel="$(sed -n '2p' "$repo_dir/VERSION" | tr -d '\r')" - n="$(sed -n '3p' "$repo_dir/VERSION" | tr -d '\r')" - - if [[ -z "$channel" || "$channel" == "stable" ]]; then - echo "$base" - else - echo "$base-$channel.$n" - fi -} - -prepare_case_repo() { - local repo_dir="$1" - local remote_dir="$2" - - setup_repo "$repo_dir" "$remote_dir" - make_release_script "$repo_dir/release" - - mkdir -p "$repo_dir/bin" - cat >"$repo_dir/bin/nix" <<'EOF' -#!/usr/bin/env bash -if [[ "${1-}" == "fmt" ]]; then - exit 0 -fi -echo "unexpected nix invocation: $*" >&2 -exit 1 -EOF - chmod +x "$repo_dir/bin/nix" -} - -prepare_case_repo_with_release_script() { - local repo_dir="$1" - local remote_dir="$2" - local release_steps="$3" - local post_version="$4" - - setup_repo "$repo_dir" "$remote_dir" - make_release_script_with_content "$repo_dir/release" "$release_steps" "$post_version" - - mkdir -p "$repo_dir/bin" - cat >"$repo_dir/bin/nix" <<'EOF' -#!/usr/bin/env bash -if [[ "${1-}" == "fmt" ]]; then - exit 0 -fi -echo "unexpected nix invocation: $*" >&2 -exit 1 -EOF - chmod +x "$repo_dir/bin/nix" -} - -run_release() { - local repo_dir="$1" - shift - ( - cd "$repo_dir" - PATH="$repo_dir/bin:$PATH" ./release "$@" - ) -} - -run_expect_failure() { - local description="$1" - shift - if "$@" >>"$CURRENT_LOG" 2>&1; then - fail "$description (expected failure)" - fi -} - -write_mk_repo_flake() { - local repo_dir="$1" - cat >"$repo_dir/flake.nix" <"$repo_dir/flake.nix" <"$repo_dir/flake.nix" <"$repo_dir/flake.nix" <"$repo_dir/flake.nix" <"$repo_dir/flake.nix" <"$repo_dir/flake.nix" < v2, 2 if v1 < v2 - 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 - [[ -n $pre1 && -z $pre2 ]] && return 2 - [[ -z $pre1 && -z $pre2 ]] && return 0 - - local highest_pre - highest_pre=$(printf '%s\n%s\n' "$pre1" "$pre2" | sort -V | tail -n1) - [[ $highest_pre == "$pre1" ]] && return 1 || return 2 -} - -qc_parse_base_version() { - local v="$1" - if [[ ! $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - return 1 - fi - QC_MAJOR="${BASH_REMATCH[1]}" - QC_MINOR="${BASH_REMATCH[2]}" - QC_PATCH="${BASH_REMATCH[3]}" - return 0 -} - -qc_parse_full_version() { - local v="$1" - QC_CHANNEL="stable" - QC_PRERELEASE_NUM="" - - if [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)-([a-zA-Z]+)\.([0-9]+)$ ]]; then - QC_BASE_VERSION="${BASH_REMATCH[1]}" - QC_CHANNEL="${BASH_REMATCH[2]}" - QC_PRERELEASE_NUM="${BASH_REMATCH[3]}" - elif [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - QC_BASE_VERSION="${BASH_REMATCH[1]}" - else - return 1 - fi - - qc_parse_base_version "$QC_BASE_VERSION" -} - -qc_validate_channel() { - local channel="$1" - [[ $channel == "stable" || $channel == "alpha" || $channel == "beta" || $channel == "rc" || $channel == "internal" ]] -} - -qc_compute_full_version() { - if [[ $QC_CHANNEL == "stable" || -z $QC_CHANNEL ]]; then - QC_FULL_VERSION="$QC_BASE_VERSION" - else - QC_FULL_VERSION="$QC_BASE_VERSION-$QC_CHANNEL.${QC_PRERELEASE_NUM:-1}" - fi -} - -qc_bump_base_version() { - qc_parse_base_version "$QC_BASE_VERSION" - case "$1" in - major) - QC_MAJOR=$((QC_MAJOR + 1)) - QC_MINOR=0 - QC_PATCH=0 - ;; - minor) - QC_MINOR=$((QC_MINOR + 1)) - QC_PATCH=0 - ;; - patch) - QC_PATCH=$((QC_PATCH + 1)) - ;; - esac - QC_BASE_VERSION="$QC_MAJOR.$QC_MINOR.$QC_PATCH" -} - -qc_oracle_init() { - QC_STATE_BASE="1.0.0" - QC_STATE_CHANNEL="stable" - QC_STATE_PRE="" - QC_SEEN_TAGS=() -} - -qc_seen_tag() { - local tag="$1" - local existing - for existing in "${QC_SEEN_TAGS[@]:-}"; do - if [[ "$existing" == "$tag" ]]; then - return 0 - fi - done - return 1 -} - -qc_oracle_current_full() { - QC_BASE_VERSION="$QC_STATE_BASE" - QC_CHANNEL="$QC_STATE_CHANNEL" - QC_PRERELEASE_NUM="$QC_STATE_PRE" - qc_compute_full_version - echo "$QC_FULL_VERSION" -} - -qc_pick_channel() { - local channels=(alpha beta rc internal) - echo "${channels[RANDOM % ${#channels[@]}]}" -} - -qc_build_random_command() { - local current_full="$1" - QC_CMD_ARGS=() - - local mode=$((RANDOM % 7)) - case "$mode" in - 0) - QC_CMD_ARGS=(patch) - ;; - 1) - local bumps=(major minor patch) - QC_CMD_ARGS=("${bumps[RANDOM % ${#bumps[@]}]}") - ;; - 2) - local bumps=(major minor patch) - QC_CMD_ARGS=("${bumps[RANDOM % ${#bumps[@]}]}" "$(qc_pick_channel)") - ;; - 3) - QC_CMD_ARGS=("$(qc_pick_channel)") - ;; - 4) - if (( RANDOM % 2 == 0 )); then - QC_CMD_ARGS=(stable) - else - QC_CMD_ARGS=(full) - fi - ;; - 5) - QC_CMD_ARGS=(set "$current_full") - ;; - 6) - qc_parse_base_version "$QC_STATE_BASE" - if (( RANDOM % 2 == 0 )); then - QC_CMD_ARGS=(set "$((QC_MAJOR + 1)).0.0") - else - QC_CMD_ARGS=(set "$QC_STATE_BASE-$(qc_pick_channel).1") - fi - ;; - esac -} - -qc_oracle_apply() { - local current_full - current_full="$(qc_oracle_current_full)" - - QC_EXPECT_SUCCESS=0 - QC_EXPECT_VERSION="$current_full" - - local action="${1-}" - shift || true - - if [[ $action == "set" ]]; then - local newv="${1-}" - [[ -z $newv ]] && return 0 - qc_parse_full_version "$newv" || return 0 - qc_validate_channel "$QC_CHANNEL" || return 0 - if [[ $QC_STATE_CHANNEL != "stable" && $QC_CHANNEL == "stable" ]]; then - return 0 - fi - qc_compute_full_version - local cmp_status=0 - qc_version_cmp "$QC_FULL_VERSION" "$current_full" || cmp_status=$? - if [[ $cmp_status -eq 0 || $cmp_status -eq 2 ]]; then - return 0 - fi - if qc_seen_tag "v$QC_FULL_VERSION"; then - return 0 - fi - - QC_STATE_BASE="$QC_BASE_VERSION" - QC_STATE_CHANNEL="$QC_CHANNEL" - QC_STATE_PRE="$QC_PRERELEASE_NUM" - QC_EXPECT_SUCCESS=1 - QC_EXPECT_VERSION="$QC_FULL_VERSION" - QC_SEEN_TAGS+=("v$QC_FULL_VERSION") - return 0 - fi - - local part="" target_channel="" was_channel_only=0 - case "$action" in - "") - part="patch" - ;; - major | minor | patch) - part="$action" - target_channel="${1-}" - if [[ -n ${1-} ]]; then - shift || true - [[ -n ${1-} ]] && return 0 - fi - ;; - stable | full) - [[ -n ${1-} ]] && return 0 - target_channel="stable" - ;; - alpha | beta | rc | internal) - [[ -n ${1-} ]] && return 0 - target_channel="$action" - was_channel_only=1 - ;; - *) - return 0 - ;; - esac - - [[ -z $target_channel ]] && target_channel="$QC_STATE_CHANNEL" - [[ $target_channel == "full" ]] && target_channel="stable" - qc_validate_channel "$target_channel" || return 0 - if [[ $QC_STATE_CHANNEL != "stable" && $target_channel == "stable" && $action != "stable" && $action != "full" ]]; then - return 0 - fi - - if [[ -z $part && $was_channel_only -eq 1 && $QC_STATE_CHANNEL == "stable" && $target_channel != "stable" ]]; then - part="patch" - fi - - QC_BASE_VERSION="$QC_STATE_BASE" - QC_CHANNEL="$QC_STATE_CHANNEL" - QC_PRERELEASE_NUM="$QC_STATE_PRE" - - local old_base="$QC_BASE_VERSION" - local old_channel="$QC_CHANNEL" - local old_pre="$QC_PRERELEASE_NUM" - - [[ -n $part ]] && qc_bump_base_version "$part" - - if [[ $target_channel == "stable" ]]; then - QC_CHANNEL="stable" - QC_PRERELEASE_NUM="" - else - if [[ $QC_BASE_VERSION == "$old_base" && $target_channel == "$old_channel" && -n $old_pre ]]; then - QC_PRERELEASE_NUM=$((old_pre + 1)) - else - QC_PRERELEASE_NUM=1 - fi - QC_CHANNEL="$target_channel" - fi - - qc_compute_full_version - if [[ $QC_FULL_VERSION == "$current_full" ]]; then - return 0 - fi - if qc_seen_tag "v$QC_FULL_VERSION"; then - return 0 - fi - - QC_STATE_BASE="$QC_BASE_VERSION" - QC_STATE_CHANNEL="$QC_CHANNEL" - QC_STATE_PRE="$QC_PRERELEASE_NUM" - QC_EXPECT_SUCCESS=1 - QC_EXPECT_VERSION="$QC_FULL_VERSION" - QC_SEEN_TAGS+=("v$QC_FULL_VERSION") -} - -run_randomized_quickcheck_cases() { - local case_name="randomized quickcheck transitions" - local trials="${QUICKCHECK_TRIALS:-20}" - local max_steps="${QUICKCHECK_MAX_STEPS:-7}" - - local trial - for ((trial = 1; trial <= trials; trial++)); do - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - local setup_log="$workdir/setup.log" - CURRENT_LOG="$setup_log" - - prepare_case_repo "$repo_dir" "$remote_dir" - qc_oracle_init - - local steps=$((1 + RANDOM % max_steps)) - local step - for ((step = 1; step <= steps; step++)); do - local step_log="$workdir/trial-${trial}-step-${step}.log" - local before_version - before_version="$(version_from_file "$repo_dir")" - local cmd_display="" - local step_result="" - local before_head - before_head="$(git -C "$repo_dir" rev-parse HEAD)" - - local oracle_before - oracle_before="$(qc_oracle_current_full)" - qc_build_random_command "$oracle_before" - qc_oracle_apply "${QC_CMD_ARGS[@]}" - cmd_display="${QC_CMD_ARGS[*]}" - - { - echo "[test] randomized trial=$trial/$trials step=$step/$steps" - echo "[test] command: ${QC_CMD_ARGS[*]}" - echo "[test] expect_success=$QC_EXPECT_SUCCESS expect_version=$QC_EXPECT_VERSION" - } >"$step_log" - CURRENT_LOG="$step_log" - - set +e - run_release "$repo_dir" "${QC_CMD_ARGS[@]}" >>"$step_log" 2>&1 - local status=$? - set -e - - if [[ $QC_EXPECT_SUCCESS -eq 1 ]]; then - if [[ $status -ne 0 ]]; then - fail "$case_name: trial $trial step $step expected success for '${QC_CMD_ARGS[*]}'" - fi - - local got_version - got_version="$(version_from_file "$repo_dir")" - assert_eq "$QC_EXPECT_VERSION" "$got_version" "$case_name: trial $trial step $step VERSION mismatch for '${QC_CMD_ARGS[*]}'" - step_result="$got_version" - - if ! git -C "$repo_dir" tag --list | grep -qx "v$QC_EXPECT_VERSION"; then - fail "$case_name: trial $trial step $step expected tag v$QC_EXPECT_VERSION for '${QC_CMD_ARGS[*]}'" - fi - else - if [[ $status -eq 0 ]]; then - fail "$case_name: trial $trial step $step expected failure for '${QC_CMD_ARGS[*]}'" - fi - - local got_version - got_version="$(version_from_file "$repo_dir")" - assert_eq "$before_version" "$got_version" "$case_name: trial $trial step $step VERSION changed on failure for '${QC_CMD_ARGS[*]}'" - step_result="fail (unchanged: $got_version)" - - local after_head - after_head="$(git -C "$repo_dir" rev-parse HEAD)" - assert_eq "$before_head" "$after_head" "$case_name: trial $trial step $step HEAD changed on failure for '${QC_CMD_ARGS[*]}'" - fi - - echo "[test] PASS: randomized quickcheck trial $trial/$trials step $step/$steps from $before_version run '$cmd_display' -> $step_result" >&2 - done - - echo "[test] PASS: randomized quickcheck trial $trial/$trials" >&2 - - rm -rf "$workdir" - CURRENT_LOG="" - done - - echo "[test] PASS: $case_name ($trials trials)" >&2 -} - -run_case() { - local case_name="$1" - local command_args="$2" - local expected_version="$3" - - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - CURRENT_LOG="$workdir/case.log" - - prepare_case_repo "$repo_dir" "$remote_dir" - - run_capture_ok "$case_name: release command failed ($command_args)" run_release "$repo_dir" $command_args - - local got_version - got_version="$(version_from_file "$repo_dir")" - assert_eq "$expected_version" "$got_version" "$case_name: VERSION mismatch" - - if ! git -C "$repo_dir" tag --list | grep -qx "v$expected_version"; then - fail "$case_name: expected tag v$expected_version was not created" - fi - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_set_prerelease_then_full_case() { - local case_name="set prerelease then full promotes to stable" - - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - CURRENT_LOG="$workdir/case.log" - - prepare_case_repo "$repo_dir" "$remote_dir" - - run_capture_ok "$case_name: release set failed" run_release "$repo_dir" set 1.1.5-beta.1 - run_capture_ok "$case_name: release full failed" run_release "$repo_dir" full - - local got_version - got_version="$(version_from_file "$repo_dir")" - assert_eq "1.1.5" "$got_version" "$case_name: VERSION mismatch" - - if ! git -C "$repo_dir" tag --list | grep -qx "v1.1.5"; then - fail "$case_name: expected tag v1.1.5 was not created" - fi - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_stable_then_beta_cannot_reuse_same_base_case() { - local case_name="stable release cannot go back to same-base beta" - - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - CURRENT_LOG="$workdir/case.log" - - prepare_case_repo "$repo_dir" "$remote_dir" - - run_capture_ok "$case_name: initial beta release failed" run_release "$repo_dir" beta - run_capture_ok "$case_name: stable promotion failed" run_release "$repo_dir" full - run_capture_ok "$case_name: second beta release failed" run_release "$repo_dir" beta - - local got_version - got_version="$(version_from_file "$repo_dir")" - assert_eq "1.0.2-beta.1" "$got_version" "$case_name: VERSION mismatch" - - if ! git -C "$repo_dir" tag --list | grep -qx "v1.0.1"; then - fail "$case_name: expected stable tag v1.0.1 was not created" - fi - - if ! git -C "$repo_dir" tag --list | grep -qx "v1.0.2-beta.1"; then - fail "$case_name: expected tag v1.0.2-beta.1 was not created" - fi - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_set_stable_then_full_noop_case() { - local case_name="set stable then full fails with no-op" - - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - CURRENT_LOG="$workdir/case.log" - - prepare_case_repo "$repo_dir" "$remote_dir" - - run_capture_ok "$case_name: release set failed" run_release "$repo_dir" set 1.1.5 - - local before_head - before_head="$(git -C "$repo_dir" rev-parse HEAD)" - - local err_file="$workdir/full.err" - set +e - run_release "$repo_dir" full >"$err_file" 2>&1 - local status=$? - set -e - cat "$err_file" >>"$CURRENT_LOG" - - if [[ $status -eq 0 ]]; then - fail "$case_name: expected release full to fail on no-op version" - fi - - assert_contains "Version 1.1.5 is already current; nothing to do." "$err_file" "$case_name: missing no-op message" - - local after_head - after_head="$(git -C "$repo_dir" rev-parse HEAD)" - assert_eq "$before_head" "$after_head" "$case_name: HEAD changed despite no-op failure" - - local got_version - got_version="$(version_from_file "$repo_dir")" - assert_eq "1.1.5" "$got_version" "$case_name: VERSION changed after no-op failure" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_set_stable_from_prerelease_requires_full_case() { - local case_name="set stable from prerelease requires full" - - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - CURRENT_LOG="$workdir/case.log" - - prepare_case_repo "$repo_dir" "$remote_dir" - - run_capture_ok "$case_name: release set prerelease failed" run_release "$repo_dir" set 1.1.5-beta.1 - - local before_head - before_head="$(git -C "$repo_dir" rev-parse HEAD)" - - local err_file="$workdir/set-stable.err" - set +e - run_release "$repo_dir" set 1.1.5 >"$err_file" 2>&1 - local status=$? - set -e - cat "$err_file" >>"$CURRENT_LOG" - - if [[ $status -eq 0 ]]; then - fail "$case_name: expected release set stable to fail from prerelease" - fi - - assert_contains "promote using 'stable' or 'full' only" "$err_file" "$case_name: missing guardrail message" - - local after_head - after_head="$(git -C "$repo_dir" rev-parse HEAD)" - assert_eq "$before_head" "$after_head" "$case_name: HEAD changed despite guardrail failure" - - local got_version - got_version="$(version_from_file "$repo_dir")" - assert_eq "1.1.5-beta.1" "$got_version" "$case_name: VERSION changed after guardrail failure" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_patch_stable_from_prerelease_requires_full_case() { - local case_name="patch stable from prerelease requires full" - - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - CURRENT_LOG="$workdir/case.log" - - prepare_case_repo "$repo_dir" "$remote_dir" - - run_capture_ok "$case_name: release set prerelease failed" run_release "$repo_dir" set 1.1.5-beta.1 - - local before_head - before_head="$(git -C "$repo_dir" rev-parse HEAD)" - - local err_file="$workdir/patch-stable.err" - set +e - run_release "$repo_dir" patch stable >"$err_file" 2>&1 - local status=$? - set -e - cat "$err_file" >>"$CURRENT_LOG" - - if [[ $status -eq 0 ]]; then - fail "$case_name: expected release patch stable to fail from prerelease" - fi - - assert_contains "promote using 'stable' or 'full' only" "$err_file" "$case_name: missing guardrail message" - - local after_head - after_head="$(git -C "$repo_dir" rev-parse HEAD)" - assert_eq "$before_head" "$after_head" "$case_name: HEAD changed despite guardrail failure" - - local got_version - got_version="$(version_from_file "$repo_dir")" - assert_eq "1.1.5-beta.1" "$got_version" "$case_name: VERSION changed after guardrail failure" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_structured_release_steps_case() { - local case_name="structured release steps update files" - local release_steps - local post_version - - read -r -d '' release_steps <<'EOF' || true -target_path="$ROOT_DIR/generated/version.txt" -mkdir -p "$(dirname "$target_path")" -cat >"$target_path" << NIXEOF -$FULL_VERSION -NIXEOF -log "Generated version file: generated/version.txt" - -target_path="$ROOT_DIR/notes.txt" -REPO_LIB_STEP_REGEX=$(cat <<'NIXEOF' -^version=.*$ -NIXEOF -) -REPO_LIB_STEP_REPLACEMENT=$(cat <"$ROOT_DIR/release.tag" -EOF - - read -r -d '' post_version <<'EOF' || true -printf '%s\n' "$FULL_VERSION" >"$ROOT_DIR/post-version.txt" -EOF - - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - CURRENT_LOG="$workdir/case.log" - - prepare_case_repo_with_release_script "$repo_dir" "$remote_dir" "$release_steps" "$post_version" - printf 'version=old\n' >"$repo_dir/notes.txt" - run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" add notes.txt - run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" commit -m "chore: add notes" - - run_capture_ok "$case_name: release command failed" run_release "$repo_dir" patch - - assert_eq "1.0.1" "$(version_from_file "$repo_dir")" "$case_name: VERSION mismatch" - assert_eq "1.0.1" "$(tr -d '\r' <"$repo_dir/generated/version.txt")" "$case_name: generated version file mismatch" - assert_eq "version=1.0.1" "$(tr -d '\r' <"$repo_dir/notes.txt")" "$case_name: replace step mismatch" - assert_eq "v1.0.1" "$(tr -d '\r' <"$repo_dir/release.tag")" "$case_name: run step mismatch" - assert_eq "1.0.1" "$(tr -d '\r' <"$repo_dir/post-version.txt")" "$case_name: postVersion mismatch" - - if ! git -C "$repo_dir" tag --list | grep -qx "v1.0.1"; then - fail "$case_name: expected tag v1.0.1 was not created" - fi - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_version_metadata_case() { - local case_name="release metadata is preserved and exported" - local release_steps - - read -r -d '' release_steps <<'EOF' || true -if [[ "$(version_meta_get desktop_backend_change_scope)" != "bindings" ]]; then - echo "metadata getter mismatch" >&2 - exit 1 -fi -if [[ "${VERSION_META_DESKTOP_BACKEND_CHANGE_SCOPE:-}" != "bindings" ]]; then - echo "metadata export mismatch" >&2 - exit 1 -fi -if [[ "${VERSION_META_DESKTOP_RELEASE_MODE:-}" != "binary" ]]; then - echo "metadata export mismatch" >&2 - exit 1 -fi - - version_meta_set desktop_release_mode codepush - version_meta_set desktop_binary_version_min 1.0.0 - version_meta_set desktop_binary_version_max "$FULL_VERSION" - version_meta_set desktop_backend_compat_id compat-123 - version_meta_unset desktop_unused -EOF - - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - CURRENT_LOG="$workdir/case.log" - - prepare_case_repo_with_release_script "$repo_dir" "$remote_dir" "$release_steps" ":" - cat >"$repo_dir/VERSION" <<'EOF' -1.0.0 -stable -0 -desktop_backend_change_scope=bindings -desktop_release_mode=binary -desktop_unused=temporary -EOF - run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" add VERSION - run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" commit -m "chore: seed metadata" - run_capture_ok "$case_name: release command failed" run_release "$repo_dir" patch - - assert_eq "1.0.1" "$(version_from_file "$repo_dir")" "$case_name: VERSION mismatch" - assert_contains "desktop_backend_change_scope=bindings" "$repo_dir/VERSION" "$case_name: missing preserved scope" - assert_contains "desktop_release_mode=codepush" "$repo_dir/VERSION" "$case_name: missing updated mode" - assert_contains "desktop_binary_version_min=1.0.0" "$repo_dir/VERSION" "$case_name: missing min version" - assert_contains "desktop_binary_version_max=1.0.1" "$repo_dir/VERSION" "$case_name: missing max version" - assert_contains "desktop_backend_compat_id=compat-123" "$repo_dir/VERSION" "$case_name: missing compat id" - if grep -Fq "desktop_unused=temporary" "$repo_dir/VERSION"; then - fail "$case_name: unset metadata key was preserved" - fi - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_mk_repo_case() { - local case_name="mkRepo exposes outputs and auto-installs tools" - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/mk-repo" - mkdir -p "$repo_dir" - write_mk_repo_flake "$repo_dir" - CURRENT_LOG="$workdir/mk-repo.log" - - run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir" - assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check" - assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" - assert_contains '"example"' "$CURRENT_LOG" "$case_name: missing merged package" - - run_capture_ok "$case_name: tool package should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c hello --version' _ "$repo_dir" - run_capture_ok "$case_name: release package should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c sh -c "command -v release >/dev/null"' _ "$repo_dir" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_mk_repo_command_tool_case() { - local case_name="mkRepo supports command-backed tools from PATH" - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/mk-repo-command-tool" - mkdir -p "$repo_dir" - write_mk_repo_command_tool_flake "$repo_dir" - CURRENT_LOG="$workdir/mk-repo-command-tool.log" - - run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir" - assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check" - assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" - - run_capture_ok "$case_name: system nix should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c nix --version' _ "$repo_dir" - assert_contains "" "$CURRENT_LOG" "$case_name: missing tool icon in banner" - run_capture_ok "$case_name: release package should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c sh -c "command -v release >/dev/null"' _ "$repo_dir" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_mk_repo_lefthook_case() { - local case_name="mkRepo exposes raw lefthook config for advanced hook fields" - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/mk-repo-lefthook" - local system - local derivation_json="$workdir/lefthook-run.drv.json" - local lefthook_yml_drv - local lefthook_yml_json="$workdir/lefthook-yml.drv.json" - mkdir -p "$repo_dir" - write_mk_repo_lefthook_flake "$repo_dir" - CURRENT_LOG="$workdir/mk-repo-lefthook.log" - - system="$(nix eval --raw --impure --expr 'builtins.currentSystem')" - run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir" - run_capture_ok "$case_name: lefthook derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$repo_dir#checks.${system}.lefthook-check" "$derivation_json" - - lefthook_yml_drv="$(perl -0ne 'print "/nix/store/$1\n" if /"([a-z0-9]{32}-lefthook\.yml\.drv)"/' "$derivation_json")" - if [[ -z "$lefthook_yml_drv" ]]; then - fail "$case_name: could not locate lefthook.yml derivation" - fi - - run_capture_ok "$case_name: lefthook.yml derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$lefthook_yml_drv" "$lefthook_yml_json" - assert_contains '\"pre-push\":{\"commands\":{\"tests\":{' "$lefthook_yml_json" "$case_name: generated check missing from pre-push" - assert_contains 'repo-lib-check-tests' "$lefthook_yml_json" "$case_name: generated check command missing from lefthook config" - assert_contains '\"output\":[\"failure\",\"summary\"]' "$lefthook_yml_json" "$case_name: lefthook output config missing" - assert_contains '\"stage_fixed\":true' "$lefthook_yml_json" "$case_name: stage_fixed missing from lefthook config" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_mk_repo_treefmt_hook_case() { - local case_name="mkRepo configures treefmt and lefthook for dev shell hooks" - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/mk-repo-treefmt" - local system - local derivation_json="$workdir/treefmt-hook.drv.json" - local lefthook_yml_drv - local lefthook_yml_json="$workdir/treefmt-hook-yml.drv.json" - mkdir -p "$repo_dir" - write_mk_repo_flake "$repo_dir" - CURRENT_LOG="$workdir/mk-repo-treefmt.log" - - init_git_repo "$repo_dir" - - run_capture_ok "$case_name: treefmt should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c sh -c '"'"'printf "%s\n" "$LEFTHOOK_BIN" && command -v treefmt'"'"'' _ "$repo_dir" - assert_contains 'lefthook-dumb-term' "$CURRENT_LOG" "$case_name: LEFTHOOK_BIN wrapper missing" - assert_contains '/bin/treefmt' "$CURRENT_LOG" "$case_name: treefmt missing from shell" - - system="$(nix eval --raw --impure --expr 'builtins.currentSystem')" - run_capture_ok "$case_name: formatting check derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$repo_dir#checks.${system}.formatting-check" "$workdir/formatting-check.drv.json" - run_capture_ok "$case_name: lefthook derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$repo_dir#checks.${system}.lefthook-check" "$derivation_json" - - lefthook_yml_drv="$(perl -0ne 'print "/nix/store/$1\n" if /"([a-z0-9]{32}-lefthook\.yml\.drv)"/' "$derivation_json")" - if [[ -z "$lefthook_yml_drv" ]]; then - fail "$case_name: could not locate lefthook.yml derivation" - fi - - run_capture_ok "$case_name: lefthook.yml derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$lefthook_yml_drv" "$lefthook_yml_json" - assert_contains '--no-cache {staged_files}' "$lefthook_yml_json" "$case_name: treefmt hook missing staged-file format command" - assert_contains '\"stage_fixed\":true' "$lefthook_yml_json" "$case_name: treefmt hook should re-stage formatted files" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_mk_repo_tool_failure_case() { - local case_name="mkRepo required tools fail shell startup" - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/tool-failure" - mkdir -p "$repo_dir" - write_tool_failure_flake "$repo_dir" - CURRENT_LOG="$workdir/tool-failure.log" - - run_expect_failure "$case_name: shell startup should fail" bash -c 'cd "$1" && nix develop . -c true' _ "$repo_dir" - assert_contains "probe failed" "$CURRENT_LOG" "$case_name: failure reason missing" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_impure_bootstrap_validation_case() { - local case_name="mkRepo rejects bootstrap without explicit opt-in" - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/bootstrap-validation" - mkdir -p "$repo_dir" - write_impure_bootstrap_flake "$repo_dir" - CURRENT_LOG="$workdir/bootstrap-validation.log" - - run_expect_failure "$case_name: evaluation should fail" nix flake show --json "$repo_dir" - assert_contains "allowImpureBootstrap" "$CURRENT_LOG" "$case_name: validation message missing" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_legacy_api_eval_case() { - local case_name="legacy mkDevShell and mkRelease still evaluate" - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/legacy" - mkdir -p "$repo_dir" - write_legacy_flake "$repo_dir" - CURRENT_LOG="$workdir/legacy.log" - - run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir" - assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check" - assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_template_eval_case() { - local case_name="template flake evaluates with mkRepo" - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/template" - mkdir -p "$repo_dir" - write_template_fixture "$repo_dir" - CURRENT_LOG="$workdir/template.log" - - if [[ ! -f "$repo_dir/package.json" ]]; then - fail "$case_name: template fixture missing package.json" - fi - if [[ ! -f "$repo_dir/.moon/workspace.yml" ]]; then - fail "$case_name: template fixture missing .moon/workspace.yml" - fi - - run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir" - assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check" - assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_release_replace_backref_case() { - local case_name="mkRepo release replace supports sed-style backrefs" - local workdir - workdir="$(mktemp -d)" - local repo_dir="$workdir/repo" - local remote_dir="$workdir/remote.git" - CURRENT_LOG="$workdir/release-backref.log" - - setup_repo "$repo_dir" "$remote_dir" - mkdir -p "$repo_dir/template" - cat >"$repo_dir/template/flake.nix" <<'EOF' -{ - inputs = { - repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v0.0.0"; - }; -} -EOF - write_release_replace_backref_flake "$repo_dir" - run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" add flake.nix template/flake.nix - run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" commit -m "chore: add replace fixture" - - run_capture_ok "$case_name: nix run release failed" bash -c 'cd "$1" && nix run --no-write-lock-file .#release -- patch' _ "$repo_dir" - - assert_contains 'repo-lib.url = "git+https://example.invalid/repo-lib?ref=refs/tags/v1.0.1";' "$repo_dir/template/flake.nix" "$case_name: replacement did not preserve captures" - if grep -Fq '\1git+https://example.invalid/repo-lib?ref=refs/tags/v1.0.1\2' "$repo_dir/template/flake.nix"; then - fail "$case_name: replacement left literal backreferences in output" - fi - - rm -rf "$workdir" - CURRENT_LOG="" - echo "[test] PASS: $case_name" >&2 -} - -run_case "channel-only from stable bumps patch" "beta" "1.0.1-beta.1" -run_case "explicit minor bump keeps requested bump" "minor beta" "1.1.0-beta.1" -run_set_prerelease_then_full_case -run_stable_then_beta_cannot_reuse_same_base_case -run_set_stable_then_full_noop_case -run_set_stable_from_prerelease_requires_full_case -run_patch_stable_from_prerelease_requires_full_case -run_structured_release_steps_case -run_version_metadata_case -run_mk_repo_case -run_mk_repo_command_tool_case -run_mk_repo_lefthook_case -run_mk_repo_treefmt_hook_case -run_mk_repo_tool_failure_case -run_impure_bootstrap_validation_case -run_legacy_api_eval_case -run_template_eval_case -run_release_replace_backref_case -if [[ "${QUICKCHECK:-0}" == "1" ]]; then - run_randomized_quickcheck_cases -fi - -echo "[test] All release tests passed" >&2