15 Commits

Author SHA1 Message Date
eric
c5f8ee6005 chore(release): v3.3.0 2026-03-15 16:55:02 +01:00
eric
9983f0b8e9 feat: expose more options for lefthook 2026-03-15 16:51:49 +01:00
eric
0d339e2de0 chore(release): v3.2.0 2026-03-15 16:31:43 +01:00
eric
7dcb0d1b3a feat: replace githooks with lefthook 2026-03-15 16:31:32 +01:00
eric
f8658265ae chore(release): v3.1.0 2026-03-15 15:50:04 +01:00
eric
c42899c89e feat: add icons and command tools (like nix) 2026-03-15 15:49:54 +01:00
eric
00fb6ef297 feat: add icons and command tools (like nix) 2026-03-15 15:48:51 +01:00
eric
dc475afcd1 chore(release): v3.0.0 2026-03-12 18:55:20 +01:00
eric
96d2d19046 chore(release): v3.0.0 2026-03-07 07:51:15 +01:00
eric
976fc8c1a7 fix: release parser 2026-03-07 07:51:08 +01:00
eric
30029e5954 fix: release parser 2026-03-07 07:49:40 +01:00
eric
9edb042e69 fix: release parser 2026-03-07 07:42:44 +01:00
eric
198b0bb1b0 feat: upgrade the lib interface 2026-03-07 07:39:39 +01:00
eric
00a9ab240a chore(release): v2.1.0 2026-03-07 06:54:41 +01:00
eric
53e498ca45 feat: add option to install tool 2026-03-07 06:54:30 +01:00
16 changed files with 3009 additions and 606 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.pre-commit-config.yaml .pre-commit-config.yaml
lefthook.yml
.direnv .direnv
result result
template/flake.lock template/flake.lock

173
README.md
View File

@@ -1,66 +1,160 @@
# repo-lib # 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`) - `mkRepo` for `devShells`, `checks`, `formatter`, and optional `packages.release`
- an optional release command (`mkRelease`) - structured tool banners driven from package-backed tool specs
- a starter template (`template/`) - structured release steps (`writeFile`, `replace`, `run`)
- a minimal starter template in [`template/`](/Users/eric/Projects/repo-lib/template)
## Prerequisites ## Prerequisites
- [Nix](https://nixos.org/download/) with flakes enabled - [Nix](https://nixos.org/download/) with flakes enabled
- [`direnv`](https://direnv.net/) (recommended) - [`direnv`](https://direnv.net/) (recommended)
## Use the template (new repo) ## Use the template
From your new project folder:
```bash ```bash
nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=v2.0.1#default' --refresh nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.3.0#default' --refresh
``` ```
## Use the library (existing repo) ## Use the library
Add this flake input: Add this flake input:
```nix ```nix
inputs.devshell-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v2.0.1"; inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.3.0";
inputs.devshell-lib.inputs.nixpkgs.follows = "nixpkgs"; inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs";
``` ```
Create your shell from `mkDevShell`: Build your repo outputs from `mkRepo`:
```nix ```nix
env = devshell-lib.lib.mkDevShell { outputs = { self, nixpkgs, repo-lib, ... }:
inherit system; repo-lib.lib.mkRepo {
src = ./.; inherit self nixpkgs;
extraPackages = [ ]; src = ./.;
tools = [ ];
additionalHooks = { }; config = {
checks.tests = {
command = "echo 'No tests defined yet.'";
stage = "pre-push";
passFilenames = false;
};
release = {
steps = [ ];
};
};
perSystem = { pkgs, system, ... }: {
tools = [
(repo-lib.lib.tools.fromCommand {
name = "Nix";
version.args = [ "--version" ];
command = "nix";
})
];
shell.packages = [
self.packages.${system}.release
];
};
};
```
`mkRepo` generates:
- `devShells.${system}.default`
- `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}`
- `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem`
Checks are installed through `lefthook`, with `pre-commit` and `pre-push` commands configured to run in parallel.
For advanced Lefthook features, use raw `config.lefthook` or `perSystem.lefthook`. Those attrsets are merged after generated checks, so you can augment a generated command with fields that the simple `checks` abstraction does not carry, such as `stage_fixed`:
```nix
config.lefthook.pre-push.commands.tests.stage_fixed = true;
```
## Tool banners
Tools are declared once. Package-backed tools are added to the shell automatically, and both package-backed and command-backed tools are 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.
When a tool should come from the host environment instead of `nixpkgs`, use `fromCommand`:
```nix
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version.args = [ "--version" ];
})
```
## 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 Structured release steps are preferred over raw `sed` snippets:
nix develop
```
## Common commands
```bash
nix fmt # format files
```
## Optional: release command
If your flake defines:
```nix ```nix
packages.${system}.release = devshell-lib.lib.mkRelease { inherit system; }; 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"
'';
};
}
];
};
``` ```
Run releases with: The generated `release` command still supports:
```bash ```bash
release release
@@ -71,5 +165,12 @@ release stable
release set 1.2.3 release set 1.2.3
``` ```
The release script uses `./VERSION` as the source of truth and creates tags like `v1.2.3`. ## Low-level APIs
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`).
`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
```

View File

@@ -1,3 +1,4 @@
2.0.1 3.3.0
stable stable
0 0

77
flake.lock generated
View File

@@ -1,79 +1,26 @@
{ {
"nodes": { "nodes": {
"flake-compat": { "lefthook-nix": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1772024342,
"narHash": "sha256-+eXlIc4/7dE6EcPs9a2DaSY3fTA9AE526hGqkNID3Wg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "6e34e97ed9788b17796ee43ccdbaf871a5c2b476",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"git-hooks",
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1709087332, "lastModified": 1770377107,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", "narHash": "sha256-/QEXSDeAo5RK81PtM0yDhmt9k3v1/pse/jsrT1yXNhU=",
"owner": "hercules-ci", "owner": "sudosubin",
"repo": "gitignore.nix", "repo": "lefthook.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394", "rev": "9cdaf7ce95ae77cbabc5b556bdd35d3cf0b849f5",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "hercules-ci", "owner": "sudosubin",
"repo": "gitignore.nix", "repo": "lefthook.nix",
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "nixpkgs": {
"locked": {
"lastModified": 1770073757,
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1772542754, "lastModified": 1772542754,
"narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=", "narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=",
@@ -89,7 +36,7 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_3": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1770107345, "lastModified": 1770107345,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
@@ -107,14 +54,14 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"git-hooks": "git-hooks", "lefthook-nix": "lefthook-nix",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
} }
}, },
"treefmt-nix": { "treefmt-nix": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs_3" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1770228511, "lastModified": 1770228511,

358
flake.nix
View File

@@ -1,10 +1,11 @@
# flake.nix — devshell-lib # flake.nix — repo-lib
{ {
description = "Shared devshell boilerplate library"; description = "Pure-first repo development platform for Nix flakes";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
git-hooks.url = "github:cachix/git-hooks.nix"; lefthook-nix.url = "github:sudosubin/lefthook.nix";
lefthook-nix.inputs.nixpkgs.follows = "nixpkgs";
treefmt-nix.url = "github:numtide/treefmt-nix"; treefmt-nix.url = "github:numtide/treefmt-nix";
}; };
@@ -13,329 +14,118 @@
self, self,
nixpkgs, nixpkgs,
treefmt-nix, treefmt-nix,
git-hooks, lefthook-nix,
... ...
}: }:
let let
supportedSystems = [ lib = nixpkgs.lib;
"x86_64-linux" repoLib = import ./packages/repo-lib/lib.nix {
"aarch64-linux" inherit nixpkgs treefmt-nix;
"x86_64-darwin" lefthookNix = lefthook-nix;
"aarch64-darwin" releaseScriptPath = ./packages/release/release.sh;
]; shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh;
forAllSystems = nixpkgs.lib.genAttrs supportedSystems; };
in supportedSystems = repoLib.systems.default;
{ importPkgs = nixpkgsInput: system: import nixpkgsInput { inherit system; };
lib = {
# ── mkDevShell ─────────────────────────────────────────────────────── projectOutputs = repoLib.mkRepo {
mkDevShell = inherit self nixpkgs;
{ src = ./.;
system, config = {
src ? ./., release = {
extraPackages ? [ ], steps = [
extraShellHook ? "", {
additionalHooks ? { }, replace = {
tools ? [ ], path = "template/flake.nix";
includeStandardPackages ? true, regex = ''^([[:space:]]*repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)'';
# tools = list of { name, bin, versionCmd, color? } replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2'';
# e.g. { name = "Bun"; bin = "${pkgs.bun}/bin/bun"; versionCmd = "--version"; color = "YELLOW"; }
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;
}; };
} }
// additionalHooks; {
}; replace = {
path = "README.md";
toolNameWidth = builtins.foldl' ( regex = ''(nix flake new myapp -t ')git\+https://git\.dgren\.dev/eric/nix-flake-lib[^']*(#default' --refresh)'';
maxWidth: t: pkgs.lib.max maxWidth (builtins.stringLength t.name) replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2'';
) 0 tools; };
toolLabelWidth = toolNameWidth + 1; }
{
toolBannerScript = pkgs.lib.concatMapStrings ( replace = {
t: path = "README.md";
let regex = ''^([[:space:]]*inputs\.repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)'';
colorVar = "$" + (t.color or "YELLOW"); replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2'';
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'
printf "\n$GREEN 🚀 Dev shell ready$RESET\n\n"
${toolBannerScript}
printf "\n"
${extraShellHook}
'';
};
}; };
};
# ── mkRelease ──────────────────────────────────────────────────────── perSystem =
mkRelease =
{ {
pkgs,
system, 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; }; tools = [
channelList = pkgs.lib.concatStringsSep " " channels; (repoLib.tools.fromCommand {
name = "Nix";
command = "nix";
version = {
args = [ "--version" ];
group = 1;
};
banner = {
color = "BLUE";
icon = "";
};
})
];
releaseStepsScript = pkgs.lib.concatMapStrings ( shell.packages = [ self.packages.${system}.release ];
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;
}; };
}; };
# ── packages ──────────────────────────────────────────────────────────── testChecks = lib.genAttrs supportedSystems (
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 (
system: system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = importPkgs nixpkgs 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";
}
];
};
in 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 = release-tests =
pkgs.runCommand "release-tests" pkgs.runCommand "release-tests"
{ {
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
bash bash
git git
nix
gnused gnused
coreutils coreutils
gnugrep gnugrep
perl
]; ];
} }
'' ''
export REPO_LIB_ROOT=${./.} export REPO_LIB_ROOT=${./.}
export NIXPKGS_FLAKE_PATH=${nixpkgs}
export HOME="$TMPDIR" export HOME="$TMPDIR"
${pkgs.bash}/bin/bash ${./tests/release.sh} ${pkgs.bash}/bin/bash ${./tests/release.sh}
touch "$out" touch "$out"
''; '';
} }
); );
in
projectOutputs
// {
lib = repoLib;
# ── formatter ───────────────────────────────────────────────────────────
formatter = forAllSystems (system: (self.lib.mkDevShell { inherit system; }).formatter);
# ── templates ───────────────────────────────────────────────────────────
templates = { templates = {
default = { default = {
path = ./template; path = ./template;
description = "Product repo using devshell-lib"; description = "Product repo using repo-lib";
}; };
}; };
checks = lib.genAttrs supportedSystems (
system: projectOutputs.checks.${system} // testChecks.${system}
);
}; };
} }

View File

@@ -1,57 +1,16 @@
# release.nix
{ {
pkgs, nixpkgs,
postVersion ? "", treefmt-nix,
release ? [ ], lefthookNix,
# Unified list, processed in declaration order: releaseScriptPath ? ./release.sh,
# { file = "path/to/file"; content = "..."; } — write file shellHookTemplatePath ? ../repo-lib/shell-hook.sh,
# { run = "shell snippet..."; } — run script
channels ? [
"alpha"
"beta"
"rc"
"internal"
],
extraRuntimeInputs ? [ ],
}: }:
let import ../repo-lib/lib.nix {
channelList = pkgs.lib.concatStringsSep " " channels; inherit
nixpkgs
releaseScript = pkgs.lib.concatMapStrings ( treefmt-nix
entry: lefthookNix
if entry ? file then releaseScriptPath
'' shellHookTemplatePath
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;
} }

View File

@@ -6,6 +6,8 @@ ROOT_DIR="$(git rev-parse --show-toplevel)"
GITLINT_FILE="$ROOT_DIR/.gitlint" GITLINT_FILE="$ROOT_DIR/.gitlint"
START_HEAD="" START_HEAD=""
CREATED_TAG="" CREATED_TAG=""
VERSION_META_LINES=()
VERSION_META_EXPORT_NAMES=()
# ── logging ──────────────────────────────────────────────────────────────── # ── logging ────────────────────────────────────────────────────────────────
@@ -168,6 +170,119 @@ compute_full_version() {
export BASE_VERSION CHANNEL PRERELEASE_NUM FULL_VERSION FULL_TAG 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 ──────────────────────────────────────────────────────────────── # ── gitlint ────────────────────────────────────────────────────────────────
get_gitlint_title_regex() { get_gitlint_title_regex() {
@@ -205,6 +320,8 @@ run_release_steps() {
# and never contaminates the stdout of do_read_version. # and never contaminates the stdout of do_read_version.
init_version_file() { init_version_file() {
if [[ -f "$ROOT_DIR/VERSION" ]]; then if [[ -f "$ROOT_DIR/VERSION" ]]; then
load_version_metadata
export_version_metadata
return 0 return 0
fi fi
@@ -233,11 +350,16 @@ init_version_file() {
n_to_write="0" n_to_write="0"
fi fi
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" >"$ROOT_DIR/VERSION" 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" log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag"
} }
do_read_version() { do_read_version() {
load_version_metadata
export_version_metadata
local base_line channel_line n_line local base_line channel_line n_line
base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')" base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')"
channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')" channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')"
@@ -257,7 +379,8 @@ do_write_version() {
channel_to_write="stable" channel_to_write="stable"
n_to_write="0" n_to_write="0"
fi fi
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" >"$ROOT_DIR/VERSION" write_version_file "$channel_to_write" "$n_to_write"
export_version_metadata
} }
# ── user-provided hook ───────────────────────────────────────────────────── # ── user-provided hook ─────────────────────────────────────────────────────

860
packages/repo-lib/lib.nix Normal file
View File

@@ -0,0 +1,860 @@
{
nixpkgs,
treefmt-nix,
lefthookNix,
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;
defaultShellBanner = {
style = "simple";
icon = "🚀";
title = "Dev shell ready";
titleColor = "GREEN";
subtitle = "";
subtitleColor = "GRAY";
borderColor = "BLUE";
};
normalizeShellBanner =
rawBanner:
let
banner = defaultShellBanner // rawBanner;
in
if
!(builtins.elem banner.style [
"simple"
"pretty"
])
then
throw "repo-lib: config.shell.banner.style must be one of simple or pretty"
else
banner;
normalizeStrictTool =
pkgs: tool:
let
version = {
args = [ "--version" ];
match = null;
regex = null;
group = 0;
line = 1;
}
// (tool.version or { });
banner = {
color = "YELLOW";
icon = null;
iconColor = null;
}
// (tool.banner or { });
executable =
if tool ? command && tool.command != null then
tool.command
else if tool ? exe && tool.exe != null then
"${lib.getExe' tool.package tool.exe}"
else
"${lib.getExe tool.package}";
in
if !(tool ? command && tool.command != null) && !(tool ? package) then
throw "repo-lib: tool '${tool.name or "<unnamed>"}' is missing 'package' or 'command'"
else
{
kind = "strict";
inherit executable version banner;
name = tool.name;
package = tool.package or null;
required = tool.required or true;
};
normalizeLegacyTool =
pkgs: tool:
if tool ? package then
normalizeStrictTool pkgs tool
else
{
kind = "legacy";
name = tool.name;
command = tool.bin;
versionCommand = tool.versionCmd or "--version";
banner = {
color = tool.color or "YELLOW";
icon = tool.icon or null;
iconColor = tool.iconColor or null;
};
required = tool.required or false;
};
checkToLefthookConfig =
pkgs: name: rawCheck:
let
check = {
stage = "pre-commit";
passFilenames = false;
runtimeInputs = [ ];
}
// rawCheck;
wrapperName = "repo-lib-check-${sanitizeName name}";
wrapper = pkgs.writeShellApplication {
name = wrapperName;
runtimeInputs = check.runtimeInputs;
text = ''
set -euo pipefail
${check.command}
'';
};
in
if !(check ? command) then
throw "repo-lib: check '${name}' is missing 'command'"
else if
!(builtins.elem check.stage [
"pre-commit"
"pre-push"
])
then
throw "repo-lib: check '${name}' has unsupported stage '${check.stage}'"
else
lib.setAttrByPath [ check.stage "commands" name ] {
run = "${wrapper}/bin/${wrapperName}${hookStageFileArgs check.stage check.passFilenames}";
};
normalizeLefthookConfig =
label: raw: if builtins.isAttrs raw then raw else throw "repo-lib: ${label} must be an attrset";
normalizeHookStage =
hookName: stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
"commit-msg"
]
then
stage
else
throw "repo-lib: hook '${hookName}' has unsupported stage '${stage}' for lefthook";
hookStageFileArgs =
stage: passFilenames:
if !passFilenames then
""
else if stage == "pre-commit" then
" {staged_files}"
else if stage == "pre-push" then
" {push_files}"
else if stage == "commit-msg" then
" {1}"
else
throw "repo-lib: unsupported lefthook stage '${stage}'";
hookToLefthookConfig =
name: hook:
let
supportedFields = [
"description"
"enable"
"entry"
"name"
"package"
"pass_filenames"
"stages"
];
unsupportedFields = builtins.filter (field: !(builtins.elem field supportedFields)) (
builtins.attrNames hook
);
stages = builtins.map (stage: normalizeHookStage name stage) (hook.stages or [ "pre-commit" ]);
passFilenames = hook.pass_filenames or false;
in
if unsupportedFields != [ ] then
throw ''
repo-lib: hook '${name}' uses unsupported fields for lefthook: ${lib.concatStringsSep ", " unsupportedFields}
''
else if !(hook ? entry) then
throw "repo-lib: hook '${name}' is missing 'entry'"
else
lib.foldl' lib.recursiveUpdate { } (
builtins.map (
stage:
lib.setAttrByPath [ stage "commands" name ] {
run = "${hook.entry}${hookStageFileArgs stage passFilenames}";
}
) stages
);
parallelHookStageConfig =
stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
]
then
lib.setAttrByPath [ stage "parallel" ] true
else
{ };
normalizeReleaseStep =
step:
if step ? writeFile then
{
kind = "writeFile";
path = step.writeFile.path;
text = step.writeFile.text;
runtimeInputs = [ ];
}
else if step ? replace then
{
kind = "replace";
path = step.replace.path;
regex = step.replace.regex;
replacement = step.replace.replacement;
runtimeInputs = [ ];
}
else if step ? run && builtins.isAttrs step.run then
{
kind = "run";
script = step.run.script;
runtimeInputs = step.run.runtimeInputs or [ ];
}
else if step ? run then
{
kind = "run";
script = step.run;
runtimeInputs = [ ];
}
else if step ? file then
{
kind = "writeFile";
path = step.file;
text = step.content;
runtimeInputs = [ ];
}
else
throw "repo-lib: release step must contain one of writeFile, replace, or run";
releaseStepScript =
step:
if step.kind == "writeFile" then
''
target_path="$ROOT_DIR/${step.path}"
mkdir -p "$(dirname "$target_path")"
cat >"$target_path" << NIXEOF
${step.text}
NIXEOF
log "Generated version file: ${step.path}"
''
else if step.kind == "replace" then
''
target_path="$ROOT_DIR/${step.path}"
REPO_LIB_STEP_REGEX=$(cat <<'NIXEOF'
${step.regex}
NIXEOF
)
REPO_LIB_STEP_REPLACEMENT=$(cat <<NIXEOF
${step.replacement}
NIXEOF
)
export REPO_LIB_STEP_REGEX REPO_LIB_STEP_REPLACEMENT
perl - "$target_path" <<'REPO_LIB_PERL_REPLACE'
use strict;
use warnings;
my $path = shift @ARGV;
my $regex_src = $ENV{"REPO_LIB_STEP_REGEX"} // q{};
my $template = $ENV{"REPO_LIB_STEP_REPLACEMENT"} // q{};
open my $in, q{<}, $path or die "failed to open $path: $!";
local $/ = undef;
my $content = <$in>;
close $in;
my $regex = qr/$regex_src/ms;
$content =~ s{$regex}{
my @cap = map { defined $_ ? $_ : q{} } ($1, $2, $3, $4, $5, $6, $7, $8, $9);
my $result = $template;
$result =~ s{\\([1-9])}{$cap[$1 - 1]}ge;
$result;
}gems;
open my $out, q{>}, $path or die "failed to open $path for write: $!";
print {$out} $content;
close $out;
REPO_LIB_PERL_REPLACE
log "Updated ${step.path}"
''
else
''
${step.script}
'';
normalizeReleaseConfig =
raw:
let
hasLegacySteps = raw ? release;
hasStructuredSteps = raw ? steps;
steps =
if hasLegacySteps && hasStructuredSteps then
throw "repo-lib: pass either 'release' or 'steps' to mkRelease, not both"
else if hasStructuredSteps then
builtins.map normalizeReleaseStep raw.steps
else if hasLegacySteps then
builtins.map normalizeReleaseStep raw.release
else
[ ];
in
{
postVersion = raw.postVersion or "";
channels = raw.channels or defaultReleaseChannels;
runtimeInputs = (raw.runtimeInputs or [ ]) ++ (raw.extraRuntimeInputs or [ ]);
steps = steps;
};
buildShellHook =
{
hooksShellHook,
shellEnvScript,
bootstrap,
shellBannerScript,
extraShellText,
toolLabelWidth,
}:
let
template = builtins.readFile shellHookTemplatePath;
in
builtins.replaceStrings
[
"@HOOKS_SHELL_HOOK@"
"@TOOL_LABEL_WIDTH@"
"@SHELL_ENV_SCRIPT@"
"@BOOTSTRAP@"
"@SHELL_BANNER_SCRIPT@"
"@EXTRA_SHELL_TEXT@"
]
[
hooksShellHook
(toString toolLabelWidth)
shellEnvScript
bootstrap
shellBannerScript
extraShellText
]
template;
buildShellArtifacts =
{
pkgs,
system,
src,
includeStandardPackages ? true,
formatting,
tools ? [ ],
shellConfig ? {
env = { };
extraShellText = "";
bootstrap = "";
banner = defaultShellBanner;
},
checkSpecs ? { },
rawHookEntries ? { },
lefthookConfig ? { },
extraPackages ? [ ],
}:
let
standardPackages = with pkgs; [
nixfmt
gitlint
gitleaks
shfmt
];
toolPackages = lib.filter (pkg: pkg != null) (builtins.map (tool: tool.package or null) tools);
selectedStandardPackages = lib.optionals includeStandardPackages standardPackages;
treefmtEval = treefmt-nix.lib.evalModule pkgs {
projectRootFile = "flake.nix";
programs = {
nixfmt.enable = true;
}
// formatting.programs;
settings.formatter = { } // formatting.settings;
};
normalizedLefthookConfig = normalizeLefthookConfig "lefthook config" lefthookConfig;
lefthookCheck = lefthookNix.lib.${system}.run {
inherit src;
config = lib.foldl' lib.recursiveUpdate { } (
[
(parallelHookStageConfig "pre-commit")
(parallelHookStageConfig "pre-push")
(lib.setAttrByPath [ "pre-commit" "commands" "treefmt" ] {
run = "${treefmtEval.config.build.wrapper}/bin/treefmt --ci {staged_files}";
})
(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 = {
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 = treefmtEval.config.build.wrapper;
shell = pkgs.mkShell {
packages = lib.unique (
selectedStandardPackages ++ extraPackages ++ toolPackages ++ [ pkgs.lefthook ]
);
shellHook = buildShellHook {
hooksShellHook = lefthookCheck.shellHook;
inherit toolLabelWidth shellEnvScript shellBannerScript;
bootstrap = shellConfig.bootstrap;
extraShellText = shellConfig.extraShellText;
};
};
}
// selectedCheckOutputs;
in
rec {
systems = {
default = supportedSystems;
};
tools = rec {
fromPackage =
{
name,
package,
exe ? null,
version ? { },
banner ? { },
required ? true,
}:
{
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);
};
}

View File

@@ -0,0 +1,341 @@
@HOOKS_SHELL_HOOK@
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'
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
repo_lib_capture_tool() {
local required="$1"
local line_no="$2"
local group_no="$3"
local regex="$4"
local match_regex="$5"
local executable="$6"
shift 6
local output=""
local selected=""
local version=""
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
if ! output="$("$executable" "$@" 2>&1)"; then
REPO_LIB_TOOL_ERROR="probe failed"
printf "%s\n" "$output" >&2
return 1
fi
if [ -n "$match_regex" ]; then
selected="$(printf '%s\n' "$output" | grep -E -m 1 "$match_regex" || true)"
else
selected="$(printf '%s\n' "$output" | sed -n "${line_no}p")"
fi
selected="$(printf '%s' "$selected" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
if [ -n "$regex" ]; then
if [[ "$selected" =~ $regex ]]; then
version="${BASH_REMATCH[$group_no]}"
else
REPO_LIB_TOOL_ERROR="version parse failed"
printf "%s\n" "$output" >&2
return 1
fi
else
version="$selected"
fi
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_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"
local title="$3"
local subtitle_color_name="$4"
local subtitle="$5"
local title_color="${!title_color_name:-$GREEN}"
local subtitle_color="${!subtitle_color_name:-$GRAY}"
printf "\n%s" "$title_color"
if [ -n "$icon" ]; then
printf "%s " "$icon"
fi
printf "%s%s" "$title" "$RESET"
if [ -n "$subtitle" ]; then
printf " %s%s%s" "$subtitle_color" "$subtitle" "$RESET"
fi
printf "\n\n"
}
repo_lib_print_simple_tool() {
local name="$1"
local color_name="$2"
local icon="$3"
local icon_color_name="$4"
local required="$5"
local line_no="$6"
local group_no="$7"
local regex="$8"
local match_regex="$9"
local executable="${10}"
shift 10
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_tool "$required" "$line_no" "$group_no" "$regex" "$match_regex" "$executable" "$@"; 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_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"
local icon="$3"
local title="$4"
local subtitle_color_name="$5"
local subtitle="$6"
local border_color="${!border_color_name:-$BLUE}"
local title_color="${!title_color_name:-$GREEN}"
local subtitle_color="${!subtitle_color_name:-$GRAY}"
printf "\n%s╭─%s %s" "$border_color" "$RESET" "$title_color"
if [ -n "$icon" ]; then
printf "%s " "$icon"
fi
printf "%s%s" "$title" "$RESET"
if [ -n "$subtitle" ]; then
printf " %s%s%s" "$subtitle_color" "$subtitle" "$RESET"
fi
printf "\n"
}
repo_lib_print_pretty_row() {
local border_color_name="$1"
local icon="$2"
local icon_color_name="$3"
local label="$4"
local value="$5"
local value_color_name="$6"
local border_color="${!border_color_name:-$BLUE}"
local icon_color="${!icon_color_name:-$WHITE}"
local value_color="${!value_color_name:-$YELLOW}"
if [ -z "$icon" ]; then
icon="•"
fi
printf "%s│%s %s%s%s ${WHITE}%-@TOOL_LABEL_WIDTH@s${RESET} %s%s${RESET}\n" \
"$border_color" "$RESET" "$icon_color" "$icon" "$RESET" "$label" "$value_color" "$value"
}
repo_lib_print_pretty_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 line_no="$7"
local group_no="$8"
local regex="$9"
local match_regex="${10}"
local executable="${11}"
shift 11
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_tool "$required" "$line_no" "$group_no" "$regex" "$match_regex" "$executable" "$@"; 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_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}"
printf "%s╰─%s\n\n" "$border_color" "$RESET"
}
@SHELL_ENV_SCRIPT@
@BOOTSTRAP@
@SHELL_BANNER_SCRIPT@
@EXTRA_SHELL_TEXT@

View File

@@ -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.

View File

@@ -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."

View File

@@ -0,0 +1,318 @@
# 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 = { };
lefthook = { };
release = null; # or attrset below
};
perSystem = { pkgs, system, lib, config }: {
tools = [ ];
shell.packages = [ ];
checks = { };
lefthook = { };
packages = { };
apps = { };
};
}
```
Generated outputs:
- `devShells.${system}.default`
- `checks.${system}.hook-check`
- `checks.${system}.lefthook-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.<name>` and `perSystem.checks.<name>` 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 `lefthook.nix`.
- `pre-commit` and `pre-push` commands are configured to run in parallel.
## Raw Lefthook config
Use `config.lefthook` or `perSystem.lefthook` for advanced Lefthook features that the built-in `checks` abstraction does not carry.
Example:
```nix
{
checks.tests = {
command = "go test ./...";
stage = "pre-push";
};
lefthook.pre-push.commands.tests.stage_fixed = true;
lefthook.commit-msg.commands.commitlint = {
run = "pnpm commitlint --edit {1}";
stage_fixed = true;
};
}
```
Rules:
- These attrsets are passed through to `lefthook.nix`.
- They are merged after generated checks, so they can extend generated commands.
- Prefer `checks` for the simple common case and `lefthook` for advanced fields such as `stage_fixed`, `files`, `glob`, `exclude`, `jobs`, or `scripts`.
## 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;
})
```
For a tool that should come from the host `PATH` instead of `nixpkgs`:
```nix
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version.args = [ "--version" ];
})
```
Helper:
```nix
repo-lib.lib.tools.simple "Go" pkgs.go [ "version" ]
```
Tool behavior:
- Tool packages are added to the shell automatically.
- Command-backed tools are probed from the existing `PATH` and are not added to the shell automatically.
- Banner probing uses absolute executable paths.
- `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`
- `lefthook`
- `additionalHooks`
- old `tools = [ { name; bin; versionCmd; color; } ]`
- `features.oxfmt`
- `formatters`
- `formatterSettings`
`mkRelease` still supports:
- `release = [ ... ]` as legacy alias for `steps`
- `extraRuntimeInputs` as legacy alias merged into `runtimeInputs`
When a repo already uses these APIs:
- preserve them unless the user asked to migrate
- do not mix old and new styles accidentally in the same call

View File

@@ -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.

3
template/.gitignore vendored
View File

@@ -1,8 +1,9 @@
.direnv/ .direnv/
.pre-commit-config.yaml .pre-commit-config.yaml
lefthook.yml
bazel-* bazel-*
build/ build/
dist/ dist/
node_modules/ node_modules/

View File

@@ -4,182 +4,169 @@
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
devshell-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v2.0.1"; repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.3.0";
devshell-lib.inputs.nixpkgs.follows = "nixpkgs"; repo-lib.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = outputs =
{ {
self, self,
nixpkgs, nixpkgs,
devshell-lib, repo-lib,
... ...
}: }:
let repo-lib.lib.mkRepo {
supportedSystems = [ inherit self nixpkgs;
"x86_64-linux" src = ./.;
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
mkDevShellConfig = pkgs: { config = {
# includeStandardPackages = false; # opt out of nixfmt/gitlint/gitleaks/shfmt defaults # includeStandardPackages = false;
extraPackages = with pkgs; [ shell = {
# add your tools here, e.g.: env = {
# go # FOO = "bar";
# 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" ];
}; };
# my-hook = {
# enable = true; extraShellText = ''
# entry = "${pkgs.some-tool}/bin/some-tool"; # any repo-specific shell setup here
# pass_filenames = false; '';
# Impure bootstrap is available as an explicit escape hatch.
# bootstrap = ''
# export GOBIN="$PWD/.tools/bin"
# export PATH="$GOBIN:$PATH"
# '';
# allowImpureBootstrap = true;
};
formatting = {
# nixfmt is enabled by default and wired into lefthook.
programs = {
# shfmt.enable = true;
# gofmt.enable = true;
};
settings = {
# shfmt.options = [ "-i" "2" "-s" "-w" ];
};
};
# These checks become lefthook commands in the generated `lefthook.yml`.
# repo-lib runs `pre-commit` and `pre-push` hook commands in parallel.
checks = {
tests = {
command = "echo 'No tests defined yet.'";
stage = "pre-push";
passFilenames = false;
};
# fmt = {
# command = "nix fmt";
# stage = "pre-commit";
# passFilenames = false;
# }; # };
}; };
tools = [ # For advanced Lefthook fields like `stage_fixed`, use raw passthrough.
# { name = "Bun"; bin = "${pkgs.bun}/bin/bun"; versionCmd = "--version"; color = "YELLOW"; } # repo-lib merges this after generated checks.
# { name = "Go"; bin = "${pkgs.go}/bin/go"; versionCmd = "version"; color = "CYAN"; } # lefthook.pre-push.commands.tests.stage_fixed = true;
# { name = "Rust"; bin = "${pkgs.rustc}/bin/rustc"; versionCmd = "--version"; color = "YELLOW"; } # lefthook.commit-msg.commands.commitlint = {
]; # run = "pnpm commitlint --edit {1}";
# stage_fixed = true;
# };
extraShellHook = '' # repo-lib also installs built-in hooks for:
# any repo-specific shell setup here # - treefmt / nixfmt on `pre-commit`
''; # - gitleaks on `pre-commit`
}; # - gitlint on `commit-msg`
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 = null;
release = devshell-lib.lib.mkRelease { release = {
inherit system; steps = [
# Write a generated version file during release.
# {
# writeFile = {
# path = "src/version.ts";
# text = ''
# export const APP_VERSION = "$FULL_VERSION" as const;
# '';
# };
# }
# Replace a version string while preserving surrounding captures.
# {
# replace = {
# path = "README.md";
# regex = ''^(version = ")[^"]*(")$'';
# replacement = ''\1$FULL_VERSION\2'';
# };
# }
# Run any extra release step with declared runtime inputs.
# {
# run = {
# runtimeInputs = [ pkgs.git ];
# script = ''
# git status --short
# '';
# };
# }
];
}; };
}); };
checks = forAllSystems ( perSystem =
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; pkgs,
} system,
); ...
}:
{
tools = [
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version = {
args = [ "--version" ];
group = 1;
};
banner = {
color = "BLUE";
icon = "";
};
})
formatter = forAllSystems ( # (repo-lib.lib.tools.fromPackage {
system: # name = "Go";
let # package = pkgs.go;
pkgs = import nixpkgs { inherit system; }; # version.args = [ "version" ];
config = mkDevShellConfig pkgs; # banner.color = "CYAN";
in # })
(devshell-lib.lib.mkDevShell ( ];
{
inherit system;
src = ./.;
}
// config
)).formatter
);
# Release command (`release`) shell.packages = [
# self.packages.${system}.release
# The release script always updates VERSION first, then: # pkgs.go
# 1) runs release steps in order (file writes and scripts) # pkgs.bun
# 2) runs postVersion hook ];
# 3) formats, stages, commits, tags, and pushes
# # checks.lint = {
# Runtime env vars available in release.run/postVersion: # command = "bun test";
# BASE_VERSION, CHANNEL, PRERELEASE_NUM, FULL_VERSION, FULL_TAG # stage = "pre-push";
# # passFilenames = false;
# To customize release behavior in your repo, edit: # runtimeInputs = [ pkgs.bun ];
# packages = forAllSystems ( # };
# system:
# { # checks.generated = {
# release = devshell-lib.lib.mkRelease { # command = "git diff --exit-code";
# inherit system; # stage = "pre-commit";
# # passFilenames = false;
# release = [ # };
# {
# file = "src/version.ts"; # packages.my-tool = pkgs.writeShellApplication {
# content = '' # name = "my-tool";
# export const APP_VERSION = "$FULL_VERSION" as const; # text = ''echo hello'';
# ''; # };
# } };
# {
# 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"
# '';
# };
# }
# );
}; };
} }

View File

@@ -4,7 +4,13 @@ set -euo pipefail
ROOT_DIR="${REPO_LIB_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" ROOT_DIR="${REPO_LIB_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
RELEASE_TEMPLATE="$ROOT_DIR/packages/release/release.sh" RELEASE_TEMPLATE="$ROOT_DIR/packages/release/release.sh"
NIXPKGS_FLAKE_PATH="${NIXPKGS_FLAKE_PATH:-}"
CURRENT_LOG="" 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() { fail() {
echo "[test] FAIL: $*" >&2 echo "[test] FAIL: $*" >&2
@@ -44,11 +50,20 @@ run_capture_ok() {
make_release_script() { make_release_script() {
local target="$1" local target="$1"
sed \ make_release_script_with_content "$target" ":" ":"
-e 's/__CHANNEL_LIST__/alpha beta rc internal/g' \ }
-e 's/__RELEASE_STEPS__/:/' \
-e 's/__POST_VERSION__/:/' \ make_release_script_with_content() {
"$RELEASE_TEMPLATE" >"$target" 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" chmod +x "$target"
} }
@@ -60,6 +75,8 @@ setup_repo() {
run_capture_ok "setup_repo: git init failed" git -C "$repo_dir" init 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.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 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' cat >"$repo_dir/flake.nix" <<'EOF'
{ {
@@ -110,6 +127,27 @@ EOF
chmod +x "$repo_dir/bin/nix" 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() { run_release() {
local repo_dir="$1" local repo_dir="$1"
shift shift
@@ -119,6 +157,329 @@ 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" <<EOF
{
description = "mkRepo ok";
inputs = {
nixpkgs.url = "path:${NIXPKGS_FLAKE_PATH}";
repo-lib.url = "path:${ROOT_DIR}";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, repo-lib, ... }:
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
config = {
checks.tests = {
command = "echo test";
stage = "pre-push";
passFilenames = false;
};
release = {
steps = [ ];
};
};
perSystem = { pkgs, system, ... }: {
tools = [
(repo-lib.lib.tools.fromPackage {
name = "Hello";
package = pkgs.hello;
exe = "hello";
version.args = [ "--version" ];
})
];
shell.packages = [
self.packages.\${system}.release
];
packages.example = pkgs.writeShellApplication {
name = "example";
text = ''
echo example
'';
};
};
};
}
EOF
}
write_mk_repo_command_tool_flake() {
local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF
{
description = "mkRepo command-backed tool";
inputs = {
nixpkgs.url = "path:${NIXPKGS_FLAKE_PATH}";
repo-lib.url = "path:${ROOT_DIR}";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, repo-lib, ... }:
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
config.release = {
steps = [ ];
};
perSystem = { system, ... }: {
tools = [
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version.args = [ "--version" ];
banner = {
color = "BLUE";
icon = "";
};
})
];
shell.packages = [
self.packages.\${system}.release
];
};
};
}
EOF
}
write_mk_repo_lefthook_flake() {
local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF
{
description = "mkRepo raw lefthook config";
inputs = {
nixpkgs.url = "path:${NIXPKGS_FLAKE_PATH}";
repo-lib.url = "path:${ROOT_DIR}";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, repo-lib, ... }:
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
config = {
checks.tests = {
command = "echo test";
stage = "pre-push";
passFilenames = false;
};
lefthook.pre-push.commands.tests.stage_fixed = true;
release.steps = [ ];
};
};
}
EOF
}
write_tool_failure_flake() {
local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF
{
description = "mkRepo tool failure";
inputs = {
nixpkgs.url = "path:${NIXPKGS_FLAKE_PATH}";
repo-lib.url = "path:${ROOT_DIR}";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, repo-lib, ... }:
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
config.release = {
steps = [ ];
};
perSystem = { pkgs, ... }: {
tools = [
(repo-lib.lib.tools.fromPackage {
name = "Hello";
package = pkgs.hello;
exe = "hello";
version.args = [ "--definitely-invalid" ];
})
];
};
};
}
EOF
}
write_impure_bootstrap_flake() {
local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF
{
description = "mkRepo bootstrap validation";
inputs = {
nixpkgs.url = "path:${NIXPKGS_FLAKE_PATH}";
repo-lib.url = "path:${ROOT_DIR}";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, repo-lib, ... }:
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
config.shell.bootstrap = ''
echo hi
'';
};
}
EOF
}
write_release_replace_backref_flake() {
local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF
{
description = "mkRepo release replace backrefs";
inputs = {
nixpkgs.url = "path:${NIXPKGS_FLAKE_PATH}";
repo-lib.url = "path:${ROOT_DIR}";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, repo-lib, ... }:
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
config.release = {
steps = [
{
replace = {
path = "template/flake.nix";
regex = ''^([[:space:]]*repo-lib\.url = ")[^"]*(";)$'';
replacement = ''\1git+https://example.invalid/repo-lib?ref=refs/tags/\$FULL_TAG\2'';
};
}
];
};
};
}
EOF
}
write_legacy_flake() {
local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF
{
description = "legacy api";
inputs = {
nixpkgs.url = "path:${NIXPKGS_FLAKE_PATH}";
repo-lib.url = "path:${ROOT_DIR}";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{ self, nixpkgs, repo-lib, ... }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
devShells = forAllSystems (
system:
let
pkgs = import nixpkgs { inherit system; };
env = repo-lib.lib.mkDevShell {
inherit system;
nixpkgsInput = nixpkgs;
src = ./.;
extraPackages = [ self.packages.\${system}.release ];
tools = [
{
name = "Nix";
bin = "\${pkgs.nix}/bin/nix";
versionCmd = "--version";
color = "YELLOW";
}
];
};
in
{
default = env.shell;
}
);
checks = forAllSystems (
system:
let
env = repo-lib.lib.mkDevShell {
inherit system;
nixpkgsInput = nixpkgs;
src = ./.;
};
in
{
inherit (env) lefthook-check;
}
);
formatter = forAllSystems (
system:
(repo-lib.lib.mkDevShell {
inherit system;
nixpkgsInput = nixpkgs;
src = ./.;
}).formatter
);
packages = forAllSystems (system: {
release = repo-lib.lib.mkRelease {
inherit system;
nixpkgsInput = nixpkgs;
};
});
};
}
EOF
}
write_template_fixture() {
local repo_dir="$1"
sed \
-e "s|git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v[0-9.]*|path:${ROOT_DIR}|" \
-e "s|github:nixos/nixpkgs?ref=nixos-unstable|path:${NIXPKGS_FLAKE_PATH}|" \
"$ROOT_DIR/template/flake.nix" >"$repo_dir/flake.nix"
}
qc_version_cmp() { qc_version_cmp() {
# Returns: 0 if equal, 1 if v1 > v2, 2 if v1 < v2 # Returns: 0 if equal, 1 if v1 > v2, 2 if v1 < v2
local v1="$1" v2="$2" local v1="$1" v2="$2"
@@ -218,6 +579,18 @@ qc_oracle_init() {
QC_STATE_BASE="1.0.0" QC_STATE_BASE="1.0.0"
QC_STATE_CHANNEL="stable" QC_STATE_CHANNEL="stable"
QC_STATE_PRE="" 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_oracle_current_full() {
@@ -298,12 +671,16 @@ qc_oracle_apply() {
if [[ $cmp_status -eq 0 || $cmp_status -eq 2 ]]; then if [[ $cmp_status -eq 0 || $cmp_status -eq 2 ]]; then
return 0 return 0
fi fi
if qc_seen_tag "v$QC_FULL_VERSION"; then
return 0
fi
QC_STATE_BASE="$QC_BASE_VERSION" QC_STATE_BASE="$QC_BASE_VERSION"
QC_STATE_CHANNEL="$QC_CHANNEL" QC_STATE_CHANNEL="$QC_CHANNEL"
QC_STATE_PRE="$QC_PRERELEASE_NUM" QC_STATE_PRE="$QC_PRERELEASE_NUM"
QC_EXPECT_SUCCESS=1 QC_EXPECT_SUCCESS=1
QC_EXPECT_VERSION="$QC_FULL_VERSION" QC_EXPECT_VERSION="$QC_FULL_VERSION"
QC_SEEN_TAGS+=("v$QC_FULL_VERSION")
return 0 return 0
fi fi
@@ -371,12 +748,16 @@ qc_oracle_apply() {
if [[ $QC_FULL_VERSION == "$current_full" ]]; then if [[ $QC_FULL_VERSION == "$current_full" ]]; then
return 0 return 0
fi fi
if qc_seen_tag "v$QC_FULL_VERSION"; then
return 0
fi
QC_STATE_BASE="$QC_BASE_VERSION" QC_STATE_BASE="$QC_BASE_VERSION"
QC_STATE_CHANNEL="$QC_CHANNEL" QC_STATE_CHANNEL="$QC_CHANNEL"
QC_STATE_PRE="$QC_PRERELEASE_NUM" QC_STATE_PRE="$QC_PRERELEASE_NUM"
QC_EXPECT_SUCCESS=1 QC_EXPECT_SUCCESS=1
QC_EXPECT_VERSION="$QC_FULL_VERSION" QC_EXPECT_VERSION="$QC_FULL_VERSION"
QC_SEEN_TAGS+=("v$QC_FULL_VERSION")
} }
run_randomized_quickcheck_cases() { run_randomized_quickcheck_cases() {
@@ -520,6 +901,38 @@ run_set_prerelease_then_full_case() {
echo "[test] PASS: $case_name" >&2 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() { run_set_stable_then_full_noop_case() {
local case_name="set stable then full fails with no-op" local case_name="set stable then full fails with no-op"
@@ -646,12 +1059,324 @@ run_patch_stable_from_prerelease_requires_full_case() {
echo "[test] PASS: $case_name" >&2 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 <<NIXEOF
version=$FULL_VERSION
NIXEOF
)
export REPO_LIB_STEP_REGEX REPO_LIB_STEP_REPLACEMENT
perl -0pi -e 'my $regex = $ENV{"REPO_LIB_STEP_REGEX"}; my $replacement = $ENV{"REPO_LIB_STEP_REPLACEMENT"}; s/$regex/$replacement/gms;' "$target_path"
log "Updated notes.txt"
printf '%s\n' "$FULL_TAG" >"$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 '\"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_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"
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 "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_case "explicit minor bump keeps requested bump" "minor beta" "1.1.0-beta.1"
run_set_prerelease_then_full_case 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_then_full_noop_case
run_set_stable_from_prerelease_requires_full_case run_set_stable_from_prerelease_requires_full_case
run_patch_stable_from_prerelease_requires_full_case run_patch_stable_from_prerelease_requires_full_case
run_randomized_quickcheck_cases 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_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 echo "[test] All release tests passed" >&2