From 198b0bb1b00a43449301b34254791dc27bd13514 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 7 Mar 2026 07:39:39 +0100 Subject: [PATCH] feat: upgrade the lib interface --- README.md | 173 +++-- flake.nix | 351 ++-------- packages/release/release.nix | 67 +- packages/repo-lib/lib.nix | 625 ++++++++++++++++++ packages/repo-lib/shell-hook.sh | 122 ++++ skills/repo-lib-consumer/SKILL.md | 48 ++ skills/repo-lib-consumer/agents/openai.yaml | 4 + skills/repo-lib-consumer/references/api.md | 274 ++++++++ .../repo-lib-consumer/references/recipes.md | 197 ++++++ template/flake.nix | 275 +++----- tests/release.sh | 414 +++++++++++- 11 files changed, 1970 insertions(+), 580 deletions(-) create mode 100644 packages/repo-lib/lib.nix create mode 100644 packages/repo-lib/shell-hook.sh create mode 100644 skills/repo-lib-consumer/SKILL.md create mode 100644 skills/repo-lib-consumer/agents/openai.yaml create mode 100644 skills/repo-lib-consumer/references/api.md create mode 100644 skills/repo-lib-consumer/references/recipes.md diff --git a/README.md b/README.md index ff15e11..c90cac9 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,141 @@ # repo-lib -Simple Nix flake library for: +`repo-lib` is a pure-first Nix flake library for repo-level developer workflows: -- a shared development shell (`mkDevShell`) -- an optional release command (`mkRelease`) -- a starter template (`template/`) +- `mkRepo` for `devShells`, `checks`, `formatter`, and optional `packages.release` +- structured tool banners driven from package-backed tool specs +- structured release steps (`writeFile`, `replace`, `run`) +- a minimal starter template in [`template/`](/Users/eric/Projects/repo-lib/template) ## Prerequisites - [Nix](https://nixos.org/download/) with flakes enabled - [`direnv`](https://direnv.net/) (recommended) -## Use the template (new repo) - -From your new project folder: +## Use the template ```bash nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=v2.1.0#default' --refresh ``` -## Use the library (existing repo) +## Use the library Add this flake input: ```nix -inputs.devshell-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v2.1.0"; -inputs.devshell-lib.inputs.nixpkgs.follows = "nixpkgs"; +inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v2.1.0"; +inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs"; ``` -Create your shell from `mkDevShell`: +Build your repo outputs from `mkRepo`: ```nix -env = devshell-lib.lib.mkDevShell { - inherit system; - src = ./.; - extraPackages = [ ]; - preToolHook = ""; - tools = [ ]; - additionalHooks = { }; +outputs = { self, nixpkgs, repo-lib, ... }: + repo-lib.lib.mkRepo { + inherit self nixpkgs; + src = ./.; + + config = { + checks.tests = { + command = "echo 'No tests defined yet.'"; + stage = "pre-push"; + passFilenames = false; + }; + + release = { + steps = [ ]; + }; + }; + + perSystem = { pkgs, system, ... }: { + tools = [ + (repo-lib.lib.tools.fromPackage { + name = "Nix"; + package = pkgs.nix; + version.args = [ "--version" ]; + }) + ]; + + shell.packages = [ + self.packages.${system}.release + ]; + }; + }; +``` + +`mkRepo` generates: + +- `devShells.${system}.default` +- `checks.${system}.pre-commit-check` +- `formatter.${system}` +- `packages.${system}.release` when `config.release != null` +- merged `packages` and `apps` from `perSystem` + +## Tool banners + +Tools are declared once, from packages. They are added to the shell automatically and rendered in the startup banner. + +```nix +(repo-lib.lib.tools.fromPackage { + name = "Go"; + package = pkgs.go; + version.args = [ "version" ]; + banner.color = "CYAN"; +}) +``` + +Required tools fail shell startup if their version probe fails. This keeps banner output honest instead of silently hiding misconfiguration. + +## Purity model + +The default path is pure: declare tools and packages in Nix, then let `mkRepo` assemble the shell. + +Impure bootstrap work is still possible, but it must be explicit: + +```nix +config.shell = { + bootstrap = '' + export GOBIN="$PWD/.tools/bin" + export PATH="$GOBIN:$PATH" + ''; + allowImpureBootstrap = true; }; ``` -Expose it in `devShells` as `default` and run: +## Release steps -```bash -nix develop -``` - -Use `preToolHook` when a tool needs bootstrap work before the shell prints tool versions. This is useful for tools you install outside `nixpkgs`, as long as the hook is idempotent. +Structured release steps are preferred over raw `sed` snippets: ```nix -env = devshell-lib.lib.mkDevShell { - inherit system; - src = ./.; - - # assumes `go` is already available in PATH, for example via `extraPackages` - - preToolHook = '' - export GOBIN="$PWD/.tools/bin" - export PATH="$GOBIN:$PATH" - - if ! command -v golangci-lint >/dev/null 2>&1; then - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - fi - ''; - - tools = [ - { name = "golangci-lint"; bin = "golangci-lint"; versionCmd = "version"; color = "YELLOW"; } +config.release = { + steps = [ + { + writeFile = { + path = "src/version.ts"; + text = '' + export const APP_VERSION = "$FULL_VERSION" as const; + ''; + }; + } + { + replace = { + path = "README.md"; + regex = ''^(version = ")[^"]*(")$''; + replacement = ''\1$FULL_VERSION\2''; + }; + } + { + run = { + script = '' + echo "Released $FULL_TAG" + ''; + }; + } ]; }; ``` -## Common commands - -```bash -nix fmt # format files -``` - -## Optional: release command - -If your flake defines: - -```nix -packages.${system}.release = devshell-lib.lib.mkRelease { inherit system; }; -``` - -Run releases with: +The generated `release` command still supports: ```bash release @@ -96,5 +146,12 @@ release stable release set 1.2.3 ``` -The release script uses `./VERSION` as the source of truth and creates tags like `v1.2.3`. -When switching from stable to a prerelease channel without an explicit bump (for example, `release beta`), it applies a patch bump automatically (for example, `1.0.0` -> `1.0.1-beta.1`). +## Low-level APIs + +`mkDevShell` and `mkRelease` remain available for repos that want lower-level control or a migration path from the older library shape. + +## Common command + +```bash +nix fmt +``` diff --git a/flake.nix b/flake.nix index ae5269a..cdf4834 100644 --- a/flake.nix +++ b/flake.nix @@ -1,6 +1,6 @@ -# flake.nix — devshell-lib +# flake.nix — repo-lib { - description = "Shared devshell boilerplate library"; + description = "Pure-first repo development platform for Nix flakes"; inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; @@ -17,301 +17,70 @@ ... }: let - supportedSystems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - forAllSystems = nixpkgs.lib.genAttrs supportedSystems; - in - { - lib = { + lib = nixpkgs.lib; + repoLib = import ./packages/repo-lib/lib.nix { + inherit nixpkgs treefmt-nix git-hooks; + releaseScriptPath = ./packages/release/release.sh; + shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh; + }; + supportedSystems = repoLib.systems.default; + importPkgs = nixpkgsInput: system: import nixpkgsInput { inherit system; }; - # ── mkDevShell ─────────────────────────────────────────────────────── - mkDevShell = - { - system, - src ? ./., - extraPackages ? [ ], - preToolHook ? "", - extraShellHook ? "", - additionalHooks ? { }, - tools ? [ ], - includeStandardPackages ? true, - # tools = list of { name, bin, versionCmd, color? } - # e.g. { name = "Bun"; bin = "${pkgs.bun}/bin/bun"; versionCmd = "--version"; color = "YELLOW"; } - # preToolHook = shell snippet that runs before the ready banner and tool logs - # e.g. install tools outside nixpkgs, export PATH updates, warm caches - formatters ? { }, - # formatters = treefmt-nix programs attrset, merged over { nixfmt.enable = true; } - # e.g. { gofmt.enable = true; shfmt.enable = true; } - formatterSettings ? { }, - # formatterSettings = treefmt-nix settings.formatter attrset - # e.g. { shfmt.options = [ "-i" "2" "-s" "-w" ]; } - features ? { }, - # features.oxfmt = true → adds pkgs.oxfmt + pkgs.oxlint, enables oxfmt in treefmt - }: - let - pkgs = import nixpkgs { inherit system; }; - standardPackages = with pkgs; [ - nixfmt - gitlint - gitleaks - shfmt - ]; - selectedStandardPackages = pkgs.lib.optionals includeStandardPackages standardPackages; - - oxfmtEnabled = features.oxfmt or false; - oxfmtPackages = pkgs.lib.optionals oxfmtEnabled [ - pkgs.oxfmt - pkgs.oxlint - ]; - oxfmtFormatters = pkgs.lib.optionalAttrs oxfmtEnabled { - oxfmt.enable = true; - }; - - treefmtEval = treefmt-nix.lib.evalModule pkgs { - projectRootFile = "flake.nix"; - programs = { - nixfmt.enable = true; # always on — every repo has a flake.nix - } - // oxfmtFormatters - // formatters; - settings.formatter = { } // formatterSettings; - }; - - pre-commit-check = git-hooks.lib.${system}.run { - inherit src; - hooks = { - treefmt = { - enable = true; - entry = "${treefmtEval.config.build.wrapper}/bin/treefmt --ci"; - pass_filenames = true; - }; - gitlint.enable = true; - gitleaks = { - enable = true; - entry = "${pkgs.gitleaks}/bin/gitleaks protect --staged"; - pass_filenames = false; + projectOutputs = repoLib.mkRepo { + inherit self nixpkgs; + src = ./.; + config = { + release = { + steps = [ + { + replace = { + path = "template/flake.nix"; + regex = ''^([[:space:]]*repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)''; + replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\2''; }; } - // additionalHooks; - }; - - toolNameWidth = builtins.foldl' ( - maxWidth: t: pkgs.lib.max maxWidth (builtins.stringLength t.name) - ) 0 tools; - toolLabelWidth = toolNameWidth + 1; - - toolBannerScript = pkgs.lib.concatMapStrings ( - t: - let - colorVar = "$" + (t.color or "YELLOW"); - in - '' - if command -v ${t.bin} >/dev/null 2>&1; then - version="$(${t.bin} ${t.versionCmd} 2>/dev/null | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" - printf " $CYAN %-${toString toolLabelWidth}s$RESET ${colorVar}%s$RESET\n" "${t.name}:" "$version" - fi - '' - ) tools; - - in - { - inherit pre-commit-check; - - formatter = treefmtEval.config.build.wrapper; - - shell = pkgs.mkShell { - packages = selectedStandardPackages ++ extraPackages ++ oxfmtPackages; - - buildInputs = pre-commit-check.enabledPackages; - - shellHook = '' - ${pre-commit-check.shellHook} - - if [ -t 1 ]; then - command -v tput >/dev/null 2>&1 && tput clear || printf '\033c' - fi - - GREEN='\033[1;32m' - CYAN='\033[1;36m' - YELLOW='\033[1;33m' - BLUE='\033[1;34m' - RED='\033[1;31m' - MAGENTA='\033[1;35m' - WHITE='\033[1;37m' - GRAY='\033[0;90m' - BOLD='\033[1m' - UNDERLINE='\033[4m' - RESET='\033[0m' - - ${preToolHook} - - printf "\n$GREEN 🚀 Dev shell ready$RESET\n\n" - ${toolBannerScript} - printf "\n" - - ${extraShellHook} - ''; - }; + { + replace = { + path = "README.md"; + regex = ''(nix flake new myapp -t ')git\+https://git\.dgren\.dev/eric/nix-flake-lib[^']*(#default' --refresh)''; + replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\2''; + }; + } + { + replace = { + path = "README.md"; + regex = ''^([[:space:]]*inputs\.repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)''; + replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\2''; + }; + } + ]; }; - - # ── mkRelease ──────────────────────────────────────────────────────── - mkRelease = + }; + perSystem = { + pkgs, system, - # Source of truth is always $ROOT_DIR/VERSION. - # Format: - # line 1: X.Y.Z - # line 2: CHANNEL (stable|alpha|beta|rc|internal|...) - # line 3: N (prerelease number, 0 for stable) - postVersion ? "", - # Shell string — runs after VERSION + release steps are written/run, before git add. - # Same env vars available. - release ? [ ], - # Unified list processed in declaration order: - # { file = "path/to/file"; content = ''...$FULL_VERSION...''; } # write file - # { run = ''...shell snippet...''; } # run script - # Example: - # release = [ - # { - # file = "src/version.ts"; - # content = ''export const APP_VERSION = "$FULL_VERSION" as const;''; - # } - # { - # file = "internal/version/version.go"; - # content = '' - # package version - # - # const Version = "$FULL_VERSION" - # ''; - # } - # { - # run = '' - # sed -E -i "s#^([[:space:]]*my-lib\\.url = \")github:org/my-lib[^"]*(\";)#\\1github:org/my-lib?ref=$FULL_TAG\\2#" "$ROOT_DIR/flake.nix" - # ''; - # } - # ]; - # Runtime env includes: BASE_VERSION, CHANNEL, PRERELEASE_NUM, FULL_VERSION, FULL_TAG. - channels ? [ - "alpha" - "beta" - "rc" - "internal" - ], - # Valid release channels beyond "stable". Validated at runtime. - extraRuntimeInputs ? [ ], - # Extra packages available in the release script's PATH. + ... }: - let - pkgs = import nixpkgs { inherit system; }; - channelList = pkgs.lib.concatStringsSep " " channels; + { + tools = [ + (repoLib.tools.fromPackage { + name = "Nix"; + package = pkgs.nix; + version.args = [ "--version" ]; + }) + ]; - releaseStepsScript = pkgs.lib.concatMapStrings ( - entry: - if entry ? file then - '' - mkdir -p "$(dirname "${entry.file}")" - cat > "${entry.file}" << NIXEOF - ${entry.content} - NIXEOF - log "Generated version file: ${entry.file}" - '' - else if entry ? run then - '' - ${entry.run} - '' - else - builtins.throw "release entry must have either 'file' or 'run'" - ) release; - - script = - builtins.replaceStrings - [ - "__CHANNEL_LIST__" - "__RELEASE_STEPS__" - "__POST_VERSION__" - ] - [ - channelList - releaseStepsScript - postVersion - ] - (builtins.readFile ./packages/release/release.sh); - in - pkgs.writeShellApplication { - name = "release"; - runtimeInputs = - with pkgs; - [ - git - gnugrep - gawk - gnused - coreutils - ] - ++ extraRuntimeInputs; - text = script; + shell.packages = [ self.packages.${system}.release ]; }; - }; - # ── packages ──────────────────────────────────────────────────────────── - packages = forAllSystems (system: { - # Expose a no-op release package for the lib repo itself (dogfood) - release = self.lib.mkRelease { - inherit system; - release = [ - { - run = '' - sed -E -i "s#^([[:space:]]*devshell-lib\\.url = \")git\\+https://git\\.dgren\\.dev/eric/nix-flake-lib[^\"]*(\";)#\\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\\2#" "$ROOT_DIR/template/flake.nix" - log "Updated template/flake.nix devshell-lib ref to $FULL_TAG" - - sed -E -i "s|(nix flake new myapp -t ')git\\+https://git\\.dgren\\.dev/eric/nix-flake-lib[^']*(#default' --refresh)|\\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\\2|" "$ROOT_DIR/README.md" - sed -E -i "s#^([[:space:]]*inputs\\.devshell-lib\\.url = \")git\\+https://git\\.dgren\\.dev/eric/nix-flake-lib[^\"]*(\";)#\\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\\2#" "$ROOT_DIR/README.md" - log "Updated README.md devshell-lib refs to $FULL_TAG" - ''; - } - ]; - }; - }); - - # ── devShells ─────────────────────────────────────────────────────────── - devShells = forAllSystems ( + testChecks = lib.genAttrs supportedSystems ( system: let - pkgs = import nixpkgs { inherit system; }; - env = self.lib.mkDevShell { - inherit system; - extraPackages = with pkgs; [ - self.packages.${system}.release - ]; - tools = [ - { - name = "Nix"; - bin = "${pkgs.nix}/bin/nix"; - versionCmd = "--version"; - color = "YELLOW"; - } - ]; - }; + pkgs = importPkgs nixpkgs system; in { - default = env.shell; - } - ); - - # ── checks ────────────────────────────────────────────────────────────── - checks = forAllSystems ( - system: - let - pkgs = import nixpkgs { inherit system; }; - env = self.lib.mkDevShell { inherit system; }; - in - { - inherit (env) pre-commit-check; release-tests = pkgs.runCommand "release-tests" { @@ -321,26 +90,34 @@ gnused coreutils gnugrep + nix + perl ]; } '' export REPO_LIB_ROOT=${./.} + export NIXPKGS_FLAKE_PATH=${nixpkgs} export HOME="$TMPDIR" + export NIX_CONFIG="experimental-features = nix-command flakes" ${pkgs.bash}/bin/bash ${./tests/release.sh} touch "$out" ''; } ); + in + projectOutputs + // { + lib = repoLib; - # ── formatter ─────────────────────────────────────────────────────────── - formatter = forAllSystems (system: (self.lib.mkDevShell { inherit system; }).formatter); - - # ── templates ─────────────────────────────────────────────────────────── templates = { default = { path = ./template; - description = "Product repo using devshell-lib"; + description = "Product repo using repo-lib"; }; }; + + checks = lib.genAttrs supportedSystems ( + system: projectOutputs.checks.${system} // testChecks.${system} + ); }; } diff --git a/packages/release/release.nix b/packages/release/release.nix index d811248..3aa8df3 100644 --- a/packages/release/release.nix +++ b/packages/release/release.nix @@ -1,57 +1,16 @@ -# release.nix { - pkgs, - postVersion ? "", - release ? [ ], - # Unified list, processed in declaration order: - # { file = "path/to/file"; content = "..."; } — write file - # { run = "shell snippet..."; } — run script - channels ? [ - "alpha" - "beta" - "rc" - "internal" - ], - extraRuntimeInputs ? [ ], + nixpkgs, + treefmt-nix, + git-hooks, + releaseScriptPath ? ./release.sh, + shellHookTemplatePath ? ../repo-lib/shell-hook.sh, }: -let - channelList = pkgs.lib.concatStringsSep " " channels; - - releaseScript = pkgs.lib.concatMapStrings ( - entry: - if entry ? file then - '' - mkdir -p "$(dirname "${entry.file}")" - cat > "${entry.file}" << NIXEOF - ${entry.content} - NIXEOF - log "Generated version file: ${entry.file}" - '' - else if entry ? run then - '' - ${entry.run} - '' - else - builtins.throw "release entry must have either 'file' or 'run'" - ) release; - - script = - builtins.replaceStrings - [ "__CHANNEL_LIST__" "__RELEASE_STEPS__" "__POST_VERSION__" ] - [ channelList releaseScript postVersion ] - (builtins.readFile ./release.sh); -in -pkgs.writeShellApplication { - name = "release"; - runtimeInputs = - with pkgs; - [ - git - gnugrep - gawk - gnused - coreutils - ] - ++ extraRuntimeInputs; - text = script; +import ../repo-lib/lib.nix { + inherit + nixpkgs + treefmt-nix + git-hooks + releaseScriptPath + shellHookTemplatePath + ; } diff --git a/packages/repo-lib/lib.nix b/packages/repo-lib/lib.nix new file mode 100644 index 0000000..660e1c8 --- /dev/null +++ b/packages/repo-lib/lib.nix @@ -0,0 +1,625 @@ +{ + nixpkgs, + treefmt-nix, + git-hooks, + releaseScriptPath, + 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; + + normalizeStrictTool = + pkgs: tool: + let + version = { + args = [ "--version" ]; + regex = null; + group = 0; + line = 1; + } + // (tool.version or { }); + banner = { + color = "YELLOW"; + } + // (tool.banner or { }); + executable = + if tool ? exe && tool.exe != null then + "${lib.getExe' tool.package tool.exe}" + else + "${lib.getExe tool.package}"; + in + if !(tool ? package) then + throw "repo-lib: tool '${tool.name or ""}' is missing 'package'" + else + { + kind = "strict"; + inherit executable version banner; + name = tool.name; + package = tool.package; + 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"; + }; + required = tool.required or false; + }; + + normalizeCheck = + 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 + { + enable = true; + entry = "${wrapper}/bin/${wrapperName}"; + pass_filenames = check.passFilenames; + stages = [ check.stage ]; + }; + + 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 </dev/null 2>&1 && tput clear || printf '\033c' +fi + +GREEN=$'\033[1;32m' +CYAN=$'\033[1;36m' +YELLOW=$'\033[1;33m' +BLUE=$'\033[1;34m' +RED=$'\033[1;31m' +MAGENTA=$'\033[1;35m' +WHITE=$'\033[1;37m' +GRAY=$'\033[0;90m' +BOLD=$'\033[1m' +UNDERLINE=$'\033[4m' +RESET=$'\033[0m' + +repo_lib_probe_tool() { + local name="$1" + local color_name="$2" + local required="$3" + local line_no="$4" + local group_no="$5" + local regex="$6" + local executable="$7" + shift 7 + + local color="${!color_name:-$YELLOW}" + local output="" + local selected="" + local version="" + + if ! output="$("$executable" "$@" 2>&1)"; then + printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "probe failed" + printf "%s\n" "$output" >&2 + if [ "$required" = "1" ]; then + exit 1 + fi + return 0 + fi + + selected="$(printf '%s\n' "$output" | sed -n "${line_no}p")" + selected="$(printf '%s' "$selected" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" + + if [ -n "$regex" ]; then + if [[ "$selected" =~ $regex ]]; then + version="${BASH_REMATCH[$group_no]}" + else + printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "version parse failed" + printf "%s\n" "$output" >&2 + if [ "$required" = "1" ]; then + exit 1 + fi + return 0 + fi + else + version="$selected" + fi + + if [ -z "$version" ]; then + printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "empty version" + printf "%s\n" "$output" >&2 + if [ "$required" = "1" ]; then + exit 1 + fi + return 0 + fi + + printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$version" +} + +repo_lib_probe_legacy_tool() { + local name="$1" + local color_name="$2" + local required="$3" + local command_name="$4" + local version_command="$5" + + local color="${!color_name:-$YELLOW}" + local output="" + local version="" + + if ! command -v "$command_name" >/dev/null 2>&1; then + if [ "$required" = "1" ]; then + printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "missing command" + exit 1 + fi + return 0 + fi + + if ! output="$(sh -c "$command_name $version_command" 2>&1)"; then + printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "probe failed" + printf "%s\n" "$output" >&2 + if [ "$required" = "1" ]; then + exit 1 + fi + return 0 + fi + + version="$(printf '%s\n' "$output" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" + if [ -z "$version" ]; then + printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "empty version" + printf "%s\n" "$output" >&2 + if [ "$required" = "1" ]; then + exit 1 + fi + return 0 + fi + + printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$version" +} + +@SHELL_ENV_SCRIPT@ + +@BOOTSTRAP@ + +printf "\n$GREEN 🚀 Dev shell ready$RESET\n\n" +@TOOL_BANNER_SCRIPT@ +printf "\n" + +@EXTRA_SHELL_TEXT@ diff --git a/skills/repo-lib-consumer/SKILL.md b/skills/repo-lib-consumer/SKILL.md new file mode 100644 index 0000000..b4dcbc5 --- /dev/null +++ b/skills/repo-lib-consumer/SKILL.md @@ -0,0 +1,48 @@ +--- +name: repo-lib-consumer +description: Edit or extend repos that consume `repo-lib` through `repo-lib.lib.mkRepo`, `mkDevShell`, or `mkRelease`. Use when Codex needs to add or change tools, shell packages, checks or test phases, formatters, release steps, release channels, bootstrap hooks, or release automation in a Nix flake built on repo-lib. +--- + +# Repo Lib Consumer + +Use this skill to make idiomatic changes in a repo that already depends on `repo-lib`. + +## Workflow + +1. Detect the integration style. + Search for `repo-lib.lib.mkRepo`, `repo-lib.lib.mkDevShell`, `repo-lib.lib.mkRelease`, or `inputs.repo-lib`. + +2. Prefer the repo's current abstraction level. + If the repo already uses `mkRepo`, stay on `mkRepo`. + If the repo still uses `mkDevShell` or `mkRelease`, preserve that style unless the user asked to migrate. + +3. Load the right reference before editing. + Read `references/api.md` for exact option names, defaults, generated outputs, and limitations. + Read `references/recipes.md` for common edits such as adding a tool, adding a test phase, wiring release file updates, or handling webhooks. + +4. Follow repo-lib conventions. + Add bannered CLIs through `perSystem.tools`, not `shell.packages`. + Use `shell.packages` for packages that should be present in the shell but not shown in the banner. + Keep shells pure-first; only use `bootstrap` with `allowImpureBootstrap = true`. + Prefer structured `release.steps` over free-form shell when the task fits `writeFile` or `replace`. + +5. Verify after edits. + Run `nix flake show --json`. + Run `nix flake check` when feasible. + If local flake evaluation cannot see newly created files because the repo is being loaded as a git flake, stage the new files before rerunning checks. + +## Decision Rules + +- Prefer `repo-lib.lib.tools.fromPackage` for tools with explicit metadata. +- Use `repo-lib.lib.tools.simple` only for very simple `--version` or `version` probes. +- Put pre-commit and pre-push automation in `checks`, not shell hooks. +- Treat `postVersion` as pre-tag and pre-push. It is not a true post-tag hook. +- For a webhook that must fire after the tag exists remotely, prefer CI triggered by tag push over local release command changes. + +## References + +- `references/api.md` + Use for the exact consumer API, option matrix, generated outputs, release ordering, and legacy compatibility. + +- `references/recipes.md` + Use for concrete change patterns: add a tool, add a test phase, update release-managed files, or wire webhook behavior. diff --git a/skills/repo-lib-consumer/agents/openai.yaml b/skills/repo-lib-consumer/agents/openai.yaml new file mode 100644 index 0000000..2f8a4f6 --- /dev/null +++ b/skills/repo-lib-consumer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Repo Lib Consumer" + short_description: "Edit repos that use repo-lib safely" + default_prompt: "Use $repo-lib-consumer to update a repo that consumes repo-lib." diff --git a/skills/repo-lib-consumer/references/api.md b/skills/repo-lib-consumer/references/api.md new file mode 100644 index 0000000..1da307b --- /dev/null +++ b/skills/repo-lib-consumer/references/api.md @@ -0,0 +1,274 @@ +# repo-lib Consumer API + +## Detect the repo shape + +Look for one of these patterns in the consuming repo: + +- `repo-lib.lib.mkRepo` +- `repo-lib.lib.mkDevShell` +- `repo-lib.lib.mkRelease` +- `inputs.repo-lib` + +Prefer editing the existing style instead of migrating incidentally. + +## Preferred `mkRepo` shape + +```nix +repo-lib.lib.mkRepo { + inherit self nixpkgs; + src = ./.; + systems = repo-lib.lib.systems.default; # optional + + config = { + includeStandardPackages = true; + + shell = { + env = { }; + extraShellText = ""; + allowImpureBootstrap = false; + bootstrap = ""; + }; + + formatting = { + programs = { }; + settings = { }; + }; + + checks = { }; + + release = null; # or attrset below + }; + + perSystem = { pkgs, system, lib, config }: { + tools = [ ]; + shell.packages = [ ]; + checks = { }; + packages = { }; + apps = { }; + }; +} +``` + +Generated outputs: + +- `devShells.${system}.default` +- `checks.${system}.pre-commit-check` +- `formatter.${system}` +- `packages.${system}.release` when `config.release != null` +- merged `packages` and `apps` from `perSystem` + +## `config.shell` + +Fields: + +- `env` + Attrset of environment variables exported in the shell. +- `extraShellText` + Extra shell snippet appended after the banner. +- `bootstrap` + Shell snippet that runs before the banner. +- `allowImpureBootstrap` + Must be `true` if `bootstrap` is non-empty. + +Rules: + +- Default is pure-first. +- Do not add bootstrap work unless the user actually wants imperative setup. +- Use `bootstrap` for unavoidable local setup only. + +## `config.formatting` + +Fields: + +- `programs` + Passed to `treefmt-nix.lib.evalModule`. +- `settings` + Passed to `settings.formatter`. + +Rules: + +- `nixfmt` is always enabled. +- Use formatter settings instead of ad hoc shell formatting logic. + +## Checks + +`config.checks.` and `perSystem.checks.` use this shape: + +```nix +{ + command = "go test ./..."; + stage = "pre-push"; # or "pre-commit" + passFilenames = false; + runtimeInputs = [ pkgs.go ]; +} +``` + +Defaults: + +- `stage = "pre-commit"` +- `passFilenames = false` +- `runtimeInputs = [ ]` + +Rules: + +- Only `pre-commit` and `pre-push` are supported. +- The command is wrapped as a script and connected into `git-hooks.nix`. + +## Tools + +Preferred shape in `perSystem.tools`: + +```nix +(repo-lib.lib.tools.fromPackage { + name = "Go"; + package = pkgs.go; + exe = "go"; # optional + version = { + args = [ "version" ]; + regex = null; + group = 0; + line = 1; + }; + banner = { + color = "CYAN"; + }; + required = true; +}) +``` + +Helper: + +```nix +repo-lib.lib.tools.simple "Nix" pkgs.nix [ "--version" ] +``` + +Tool behavior: + +- Tool packages are added to the shell automatically. +- Banner probing uses absolute executable paths. +- `required = true` by default. +- Required tool probe failure aborts shell startup. + +Use `shell.packages` instead of `tools` when: + +- the package should be in the shell but not in the banner +- the package is not a CLI tool with a stable version probe + +## `config.release` + +Shape: + +```nix +{ + channels = [ "alpha" "beta" "rc" "internal" ]; + steps = [ ]; + postVersion = ""; + runtimeInputs = [ ]; +} +``` + +Defaults: + +- `channels = [ "alpha" "beta" "rc" "internal" ]` +- `steps = [ ]` +- `postVersion = ""` +- `runtimeInputs = [ ]` + +Set `release = null` to disable the generated release package. + +## Release step shapes + +### `writeFile` + +```nix +{ + writeFile = { + path = "src/version.ts"; + text = '' + export const APP_VERSION = "$FULL_VERSION" as const; + ''; + }; +} +``` + +### `replace` + +```nix +{ + replace = { + path = "README.md"; + regex = ''^(version = ")[^"]*(")$''; + replacement = ''\1$FULL_VERSION\2''; + }; +} +``` + +### `run` + +```nix +{ + run = { + script = '' + curl -fsS https://example.invalid/hook \ + -H 'content-type: application/json' \ + -d '{"tag":"'"$FULL_TAG"'"}' + ''; + runtimeInputs = [ pkgs.curl ]; + }; +} +``` + +Also accepted for compatibility: + +- `{ run = ''...''; }` +- legacy `mkRelease { release = [ { file = ...; content = ...; } ... ]; }` + +## Release ordering + +The generated `release` command does this: + +1. Update `VERSION` +2. Run `release.steps` +3. Run `postVersion` +4. Run `nix fmt` +5. `git add -A` +6. Commit +7. Tag +8. Push branch +9. Push tags + +Important consequence: + +- `postVersion` is still before commit, tag, and push. +- There is no true post-tag or post-push hook in current `repo-lib`. + +## Post-tag webhook limitation + +If the user asks for a webhook after the tag exists remotely: + +- Prefer CI triggered by pushed tags in the consuming repo. +- Do not claim `postVersion` is post-tag; it is not. +- Only extend `repo-lib` itself if the user explicitly wants a new library capability. + +## Legacy API summary + +`mkDevShell` still supports: + +- `extraPackages` +- `preToolHook` +- `extraShellHook` +- `additionalHooks` +- old `tools = [ { name; bin; versionCmd; color; } ]` +- `features.oxfmt` +- `formatters` +- `formatterSettings` + +`mkRelease` still supports: + +- `release = [ ... ]` as legacy alias for `steps` +- `extraRuntimeInputs` as legacy alias merged into `runtimeInputs` + +When a repo already uses these APIs: + +- preserve them unless the user asked to migrate +- do not mix old and new styles accidentally in the same call diff --git a/skills/repo-lib-consumer/references/recipes.md b/skills/repo-lib-consumer/references/recipes.md new file mode 100644 index 0000000..ade156b --- /dev/null +++ b/skills/repo-lib-consumer/references/recipes.md @@ -0,0 +1,197 @@ +# repo-lib Change Recipes + +## Add a new bannered tool + +Edit `perSystem.tools` in the consuming repo: + +```nix +tools = [ + (repo-lib.lib.tools.fromPackage { + name = "Go"; + package = pkgs.go; + version.args = [ "version" ]; + banner.color = "CYAN"; + }) +]; +``` + +Notes: + +- Do not also add `pkgs.go` to `shell.packages`; `tools` already adds it. +- Use `exe = "name"` only when the package exposes multiple binaries or the main program is not the desired one. + +## Add a non-banner package to the shell + +Use `shell.packages`: + +```nix +shell.packages = [ + self.packages.${system}.release + pkgs.jq +]; +``` + +Use this for: + +- helper CLIs that do not need a banner entry +- internal scripts +- the generated `release` package itself + +## Add a test phase or lint hook + +For a simple global check: + +```nix +config.checks.tests = { + command = "go test ./..."; + stage = "pre-push"; + passFilenames = false; + runtimeInputs = [ pkgs.go ]; +}; +``` + +For a system-specific check: + +```nix +perSystem = { pkgs, ... }: { + checks.lint = { + command = "bun test"; + stage = "pre-push"; + runtimeInputs = [ pkgs.bun ]; + }; +}; +``` + +Guidance: + +- Use `pre-commit` for fast format/lint work. +- Use `pre-push` for slower test suites. +- Prefer `runtimeInputs` over inline absolute paths when the command needs extra CLIs. + +## Add or change formatters + +Use `config.formatting`: + +```nix +config.formatting = { + programs = { + shfmt.enable = true; + gofmt.enable = true; + }; + + settings = { + shfmt.options = [ "-i" "2" "-s" "-w" ]; + }; +}; +``` + +## Add release-managed files + +Generate a file from the release version: + +```nix +config.release.steps = [ + { + writeFile = { + path = "src/version.ts"; + text = '' + export const APP_VERSION = "$FULL_VERSION" as const; + ''; + }; + } +]; +``` + +Update an existing file with a regex: + +```nix +config.release.steps = [ + { + replace = { + path = "README.md"; + regex = ''^(version = ")[^"]*(")$''; + replacement = ''\1$FULL_VERSION\2''; + }; + } +]; +``` + +## Add a webhook during release + +If the webhook may run before commit and tag creation, use a `run` step or `postVersion`. + +Use a `run` step when it belongs with other release mutations: + +```nix +config.release = { + runtimeInputs = [ pkgs.curl ]; + steps = [ + { + run = { + script = '' + curl -fsS https://example.invalid/release-hook \ + -H 'content-type: application/json' \ + -d '{"version":"'"$FULL_VERSION"'"}' + ''; + runtimeInputs = [ pkgs.curl ]; + }; + } + ]; +}; +``` + +Use `postVersion` when the action should happen after all `steps`: + +```nix +config.release.postVersion = '' + curl -fsS https://example.invalid/release-hook \ + -H 'content-type: application/json' \ + -d '{"version":"'"$FULL_VERSION"'","tag":"'"$FULL_TAG"'"}' +''; +config.release.runtimeInputs = [ pkgs.curl ]; +``` + +Important: + +- Both of these still run before commit, tag, and push. +- They are not true post-tag hooks. + +## Add a true post-tag webhook + +Do not fake this with `postVersion`. + +Preferred approach in the consuming repo: + +1. Keep local release generation in `repo-lib`. +2. Add CI triggered by tag push. +3. Put the webhook call in CI, where the tag is already created and pushed. + +Only change `repo-lib` itself if the user explicitly asks for a new local post-tag capability. + +## Add impure bootstrap work + +Only do this when the user actually wants imperative shell setup: + +```nix +config.shell = { + bootstrap = '' + export GOBIN="$PWD/.tools/bin" + export PATH="$GOBIN:$PATH" + ''; + allowImpureBootstrap = true; +}; +``` + +Do not add bootstrap work for normal Nix-packaged tools. + +## Migrate a legacy consumer to `mkRepo` + +Only do this if requested. + +Migration outline: + +1. Move repeated shell/check/formatter config into `config`. +2. Move old banner tools into `perSystem.tools`. +3. Move extra shell packages into `perSystem.shell.packages`. +4. Replace old `mkRelease { release = [ ... ]; }` with `config.release.steps`. +5. Keep behavior the same first; do not redesign the repo in the same change unless asked. diff --git a/template/flake.nix b/template/flake.nix index bd1d223..d43dd55 100644 --- a/template/flake.nix +++ b/template/flake.nix @@ -4,194 +4,117 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; - devshell-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v2.1.0"; - devshell-lib.inputs.nixpkgs.follows = "nixpkgs"; + repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v2.1.0"; + repo-lib.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { self, nixpkgs, - devshell-lib, + repo-lib, ... }: - let - supportedSystems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + repo-lib.lib.mkRepo { + inherit self nixpkgs; + src = ./.; - mkDevShellConfig = pkgs: { - # includeStandardPackages = false; # opt out of nixfmt/gitlint/gitleaks/shfmt defaults + config = { + # includeStandardPackages = false; - extraPackages = with pkgs; [ - # add your tools here, e.g.: - # go - # bun - # rustc - ]; - - features = { - # oxfmt = true; # enables oxfmt + oxlint from nixpkgs - }; - - formatters = { - # shfmt.enable = true; - # gofmt.enable = true; - }; - - formatterSettings = { - # shfmt.options = [ "-i" "2" "-s" "-w" ]; - # oxfmt.includes = [ "*.ts" "*.tsx" "*.js" "*.json" ]; - }; - - additionalHooks = { - tests = { - enable = true; - entry = "echo 'No tests defined yet.'"; # replace with your test command - pass_filenames = false; - stages = [ "pre-push" ]; + shell = { + env = { + # FOO = "bar"; }; - # my-hook = { - # enable = true; - # entry = "${pkgs.some-tool}/bin/some-tool"; - # pass_filenames = false; + + extraShellText = '' + # any repo-specific shell setup here + ''; + + # Impure bootstrap is available as an explicit escape hatch. + # bootstrap = '' + # export GOBIN="$PWD/.tools/bin" + # export PATH="$GOBIN:$PATH" + # ''; + # allowImpureBootstrap = true; + }; + + formatting = { + programs = { + # shfmt.enable = true; + # gofmt.enable = true; + }; + + settings = { + # shfmt.options = [ "-i" "2" "-s" "-w" ]; + }; + }; + + checks.tests = { + command = "echo 'No tests defined yet.'"; + stage = "pre-push"; + passFilenames = false; + }; + + release = { + steps = [ + # { + # writeFile = { + # path = "src/version.ts"; + # text = '' + # export const APP_VERSION = "$FULL_VERSION" as const; + # ''; + # }; + # } + # { + # replace = { + # path = "README.md"; + # regex = ''^(version = ")[^"]*(")$''; + # replacement = ''\1$FULL_VERSION\2''; + # }; + # } + ]; + }; + }; + + perSystem = + { + pkgs, + system, + ... + }: + { + tools = [ + (repo-lib.lib.tools.fromPackage { + name = "Nix"; + package = pkgs.nix; + version.args = [ "--version" ]; + }) + + # (repo-lib.lib.tools.fromPackage { + # name = "Go"; + # package = pkgs.go; + # version.args = [ "version" ]; + # banner.color = "CYAN"; + # }) + ]; + + shell.packages = [ + self.packages.${system}.release + # pkgs.go + # pkgs.bun + ]; + + # checks.lint = { + # command = "go test ./..."; + # stage = "pre-push"; + # runtimeInputs = [ pkgs.go ]; + # }; + + # packages.my-tool = pkgs.writeShellApplication { + # name = "my-tool"; + # text = ''echo hello''; # }; }; - - tools = [ - # { name = "Bun"; bin = "${pkgs.bun}/bin/bun"; versionCmd = "--version"; color = "YELLOW"; } - # { name = "Go"; bin = "${pkgs.go}/bin/go"; versionCmd = "version"; color = "CYAN"; } - # { name = "Rust"; bin = "${pkgs.rustc}/bin/rustc"; versionCmd = "--version"; color = "YELLOW"; } - # { name = "golangci-lint"; bin = "golangci-lint"; versionCmd = "version"; color = "YELLOW"; } - ]; - - preToolHook = '' - # runs before the ready banner + tool version logs - # useful for installing tools outside nixpkgs and updating PATH first - # - # export GOBIN="$PWD/.tools/bin" - # export PATH="$GOBIN:$PATH" - # if ! command -v golangci-lint >/dev/null 2>&1; then - # go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - # fi - ''; - - extraShellHook = '' - # any repo-specific shell setup here - ''; - }; - in - { - devShells = forAllSystems ( - system: - let - pkgs = import nixpkgs { inherit system; }; - config = mkDevShellConfig pkgs; - env = devshell-lib.lib.mkDevShell ( - ( - { - inherit system; - src = ./.; - } - // config - ) - // { - extraPackages = config.extraPackages ++ [ self.packages.${system}.release ]; - } - ); - in - { - default = env.shell; - } - ); - - packages = forAllSystems (system: { - release = devshell-lib.lib.mkRelease { - inherit system; - }; - }); - - checks = forAllSystems ( - system: - let - pkgs = import nixpkgs { inherit system; }; - config = mkDevShellConfig pkgs; - env = devshell-lib.lib.mkDevShell ( - { - inherit system; - src = ./.; - } - // config - ); - in - { - inherit (env) pre-commit-check; - } - ); - - formatter = forAllSystems ( - system: - let - pkgs = import nixpkgs { inherit system; }; - config = mkDevShellConfig pkgs; - in - (devshell-lib.lib.mkDevShell ( - { - inherit system; - src = ./.; - } - // config - )).formatter - ); - - # Release command (`release`) - # - # The release script always updates VERSION first, then: - # 1) runs release steps in order (file writes and scripts) - # 2) runs postVersion hook - # 3) formats, stages, commits, tags, and pushes - # - # Runtime env vars available in release.run/postVersion: - # BASE_VERSION, CHANNEL, PRERELEASE_NUM, FULL_VERSION, FULL_TAG - # - # To customize release behavior in your repo, edit: - # packages = forAllSystems ( - # system: - # { - # release = devshell-lib.lib.mkRelease { - # inherit system; - # - # release = [ - # { - # file = "src/version.ts"; - # content = '' - # export const APP_VERSION = "$FULL_VERSION" as const; - # ''; - # } - # { - # file = "internal/version/version.go"; - # content = '' - # package version - # - # const Version = "$FULL_VERSION" - # ''; - # } - # { - # run = '' - # sed -E -i "s#^([[:space:]]*my-lib\\.url = \")github:org/my-lib[^"]*(\";)#\\1github:org/my-lib?ref=$FULL_TAG\\2#" "$ROOT_DIR/flake.nix" - # ''; - # } - # ]; - # - # postVersion = '' - # echo "Released $FULL_TAG" - # ''; - # }; - # } - # ); }; } diff --git a/tests/release.sh b/tests/release.sh index 169bd62..48a25df 100755 --- a/tests/release.sh +++ b/tests/release.sh @@ -4,6 +4,7 @@ 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="" fail() { @@ -44,11 +45,20 @@ run_capture_ok() { make_release_script() { local target="$1" - sed \ - -e 's/__CHANNEL_LIST__/alpha beta rc internal/g' \ - -e 's/__RELEASE_STEPS__/:/' \ - -e 's/__POST_VERSION__/:/' \ - "$RELEASE_TEMPLATE" >"$target" + 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" } @@ -110,6 +120,27 @@ 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 @@ -119,6 +150,220 @@ run_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" +} + qc_version_cmp() { # Returns: 0 if equal, 1 if v1 > v2, 2 if v1 < v2 local v1="$1" v2="$2" @@ -646,12 +891,171 @@ run_patch_stable_from_prerelease_requires_full_case() { 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_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 "$repo_dir" + assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-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" nix develop "$repo_dir" -c hello --version + run_capture_ok "$case_name: release package should be available in shell" nix develop "$repo_dir" -c sh -c 'command -v release >/dev/null' + + 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" nix develop "$repo_dir" -c true + 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 '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-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" + + run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir" + assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" + assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" + + 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_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_mk_repo_case +run_mk_repo_tool_failure_case +run_impure_bootstrap_validation_case +run_legacy_api_eval_case +run_template_eval_case run_randomized_quickcheck_cases echo "[test] All release tests passed" >&2