From 5f1819959295e5d29af1f2918dfe612060ae5235 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Thu, 22 Jan 2026 14:49:49 -0500 Subject: [PATCH 01/30] chore: make a match-dirent utility this utility returns a list of matching directory entities for a folder, an optinoal matchDirentName lambda that evaluates to true/false, an optional matchDirentType that evaluates to true/false Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 2 ++ .config/match-dirent.nix | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 .config/match-dirent.nix diff --git a/.config/.gitignore b/.config/.gitignore index 1a1f54d..4f890a0 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -5,6 +5,7 @@ !commitlintConfig.nix !configVscode.nix !importFromLanguageFolder.nix +!importFrom.nix !configZed.nix !devShell.nix !sanitizeProjectName.nix @@ -12,5 +13,6 @@ !CONTRIBUTE.md !installGitHooks.nix !lintCommit.nix +!match-dirent.nix !recurse.nix !getSemverTag.nix diff --git a/.config/match-dirent.nix b/.config/match-dirent.nix new file mode 100644 index 0000000..0bb374e --- /dev/null +++ b/.config/match-dirent.nix @@ -0,0 +1,27 @@ +{ + pkgs ? import {}, + from ? "./.", + # e.g. set to + # name: (builtins.match "language-" name) != null + # to match on dirents that contain "language-" + matchDirentName ? name: true, + # e.g. set to + # type: (builtins.match "directory" type) != null + # to match on dirents that are directories + matchDirentType ? type: true, +}: let + dirents = + pkgs.lib.mapAttrsToList + (name: value: { + name = name; + type = value; + }) + (builtins.readDir from); + matchingDirents = + builtins.filter ( + dirent: + matchDirentName dirent.name && matchDirentType dirent.type + ) + dirents; +in + matchingDirents From 4497f6848580c594661fbe5e59686cb108340cd1 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Thu, 22 Jan 2026 14:54:31 -0500 Subject: [PATCH 02/30] chore: decouple stubProject.nix decouple stubProject.nix from importFromLanguageFolder.nix Signed-off-by: Ajay Ganapathy --- .config/stubProject.nix | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.config/stubProject.nix b/.config/stubProject.nix index d34bc54..242a79d 100644 --- a/.config/stubProject.nix +++ b/.config/stubProject.nix @@ -1,6 +1,35 @@ { pkgs ? import {}, - stubProjectConfigs ? (import ./importFromLanguageFolder.nix {inherit pkgs;}).importStubProject, + stubProjectConfigs ? ( + map ( + languageFolder: ( + map ( + stubProjectNix: + (import "./${languageFolder}/${stubProjectNix.name}" {inherit pkgs;}) + // { + devShellName = pkgs.lib.removePrefix "language-" languageFolder; + } + ) ( + import ./match-dirent.nix { + pkgs = pkgs; + from = "./${languageFolder}"; + matchDirentName = name: (builtins.match "^stubProject.nix$") != null; + matchDirentType = type: (builtins.match "regular") != null; + } + ) + ) + ) ( + map (dirent: dirent.name) + ( + import ./match-dirent.nix { + pkgs = pkgs; + from = ./.; + matchDirentName = name: (builtins.match "^language-" name) != null; + matchDirentType = type: (builtins.match "directory" type) != null; + } + ) + ) + ), }: let validStubProjectConfigs = let configs = From b445535fcff3cd98f02c68051173da27470f8e6b Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Thu, 22 Jan 2026 12:47:36 -0500 Subject: [PATCH 03/30] chore: refactor importFromLanguageFolder.nix do not importStubProject in importFromLanguageFolder.nix Signed-off-by: Ajay Ganapathy --- .config/importFromLanguageFolder.nix | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.config/importFromLanguageFolder.nix b/.config/importFromLanguageFolder.nix index 12a80c7..2b10ec6 100644 --- a/.config/importFromLanguageFolder.nix +++ b/.config/importFromLanguageFolder.nix @@ -1,5 +1,5 @@ # -# import configVscode.nix, configZed.nix, stubProject.nix, devShell.nix from language-* subfolders +# import configVscode.nix, configZed.nix, devShell.nix from language-* subfolders # {pkgs ? import {}}: let # Get all language directories @@ -20,14 +20,10 @@ # Get all existing paths for each config type importConfigVscode = map (f: import f.file {inherit pkgs;}) (getExistingFiles "configVscode.nix"); importConfigZed = map (f: import f.file {inherit pkgs;}) (getExistingFiles "configZed.nix"); - importStubProject = map ( - f: - (import f.file {inherit pkgs;}) // {devShellName = f.language;} - ) (getExistingFiles "stubProject.nix"); importDevShell = map ( f: (import f.file {inherit pkgs;}) // {name = f.language;} ) (getExistingFiles "devShell.nix"); in { - inherit importConfigVscode importConfigZed importStubProject importDevShell; + inherit importConfigVscode importConfigZed importDevShell; } From 9d1d26fd67b415ed8537408217078bb56560ae18 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Thu, 22 Jan 2026 15:50:26 -0500 Subject: [PATCH 04/30] chore: rename .config/stubProject.nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .config/stubProject.nix → stub-project.nix Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 1 + .config/devShell.nix | 2 +- .config/{stubProject.nix => stub-project.nix} | 0 3 files changed, 2 insertions(+), 1 deletion(-) rename .config/{stubProject.nix => stub-project.nix} (100%) diff --git a/.config/.gitignore b/.config/.gitignore index 4f890a0..d3298cd 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -10,6 +10,7 @@ !devShell.nix !sanitizeProjectName.nix !stubProject.nix +!stub-project.nix !CONTRIBUTE.md !installGitHooks.nix !lintCommit.nix diff --git a/.config/devShell.nix b/.config/devShell.nix index e701cca..a83b712 100644 --- a/.config/devShell.nix +++ b/.config/devShell.nix @@ -472,7 +472,7 @@ (import ./configZed.nix {inherit pkgs;}) (import ./installGitHooks.nix {inherit pkgs;}) ] - ++ (import ./stubProject.nix {inherit pkgs;}) + ++ (import ./stub-project.nix {inherit pkgs;}) ++ builtins.map (cmd: pkgs.writeShellApplication { name = "${cmd}-all"; diff --git a/.config/stubProject.nix b/.config/stub-project.nix similarity index 100% rename from .config/stubProject.nix rename to .config/stub-project.nix From fb2253b5acd212d4f7702533023e643e3b088fc6 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Thu, 22 Jan 2026 15:34:49 -0500 Subject: [PATCH 05/30] chore: load stub projects from .config/ directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit instead of checking language-* for a stubProject.nix, check .config for a stub-project-.nix move language-nix/stubProject.nix → stub-project-nix.nix Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 4 +- .../stubProject.nix => stub-project-nix.nix} | 0 .config/stub-project.nix | 44 ++++++------------- 3 files changed, 16 insertions(+), 32 deletions(-) rename .config/{language-nix/stubProject.nix => stub-project-nix.nix} (100%) diff --git a/.config/.gitignore b/.config/.gitignore index d3298cd..94c74fd 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -10,10 +10,10 @@ !devShell.nix !sanitizeProjectName.nix !stubProject.nix -!stub-project.nix !CONTRIBUTE.md !installGitHooks.nix !lintCommit.nix !match-dirent.nix !recurse.nix -!getSemverTag.nix +!stub-project.nix +!stub-project-nix.nix diff --git a/.config/language-nix/stubProject.nix b/.config/stub-project-nix.nix similarity index 100% rename from .config/language-nix/stubProject.nix rename to .config/stub-project-nix.nix diff --git a/.config/stub-project.nix b/.config/stub-project.nix index 242a79d..b844aef 100644 --- a/.config/stub-project.nix +++ b/.config/stub-project.nix @@ -1,35 +1,19 @@ { pkgs ? import {}, - stubProjectConfigs ? ( - map ( - languageFolder: ( - map ( - stubProjectNix: - (import "./${languageFolder}/${stubProjectNix.name}" {inherit pkgs;}) - // { - devShellName = pkgs.lib.removePrefix "language-" languageFolder; - } - ) ( - import ./match-dirent.nix { - pkgs = pkgs; - from = "./${languageFolder}"; - matchDirentName = name: (builtins.match "^stubProject.nix$") != null; - matchDirentType = type: (builtins.match "regular") != null; - } - ) - ) - ) ( - map (dirent: dirent.name) - ( - import ./match-dirent.nix { - pkgs = pkgs; - from = ./.; - matchDirentName = name: (builtins.match "^language-" name) != null; - matchDirentType = type: (builtins.match "directory" type) != null; - } - ) - ) - ), + stubProjectConfigs ? + map (stubProjectNixDirent: + (import ./${stubProjectNixDirent.name} {inherit pkgs;}) + // { + devShellName = builtins.head (builtins.match "^stub-project-(.*).nix$" stubProjectNixDirent.name); + }) + ( + import ./match-dirent.nix { + pkgs = pkgs; + from = ./.; + matchDirentName = name: (builtins.match "^stub-project-.*\.nix$" name) != null; + matchDirentType = type: (builtins.match "^regular$" type) != null; + } + ), }: let validStubProjectConfigs = let configs = From 66b4a8b4222605810cfb39bbe0454169cf54ca00 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Thu, 22 Jan 2026 15:35:45 -0500 Subject: [PATCH 06/30] chore: update gitignore Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 1 - .config/language-nix/.gitignore | 1 - 2 files changed, 2 deletions(-) diff --git a/.config/.gitignore b/.config/.gitignore index 94c74fd..a48d00d 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -9,7 +9,6 @@ !configZed.nix !devShell.nix !sanitizeProjectName.nix -!stubProject.nix !CONTRIBUTE.md !installGitHooks.nix !lintCommit.nix diff --git a/.config/language-nix/.gitignore b/.config/language-nix/.gitignore index 1c30103..426c05f 100644 --- a/.config/language-nix/.gitignore +++ b/.config/language-nix/.gitignore @@ -3,4 +3,3 @@ !configZed.nix !configVscode.nix !devShell.nix -!stubProject.nix From 5df779fedfb726fe6afedfe73b7f25948873ff67 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Thu, 22 Jan 2026 20:27:11 -0500 Subject: [PATCH 07/30] chore: do not stub .envrc when stubbing projects Signed-off-by: Ajay Ganapathy --- .config/stub-project.nix | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.config/stub-project.nix b/.config/stub-project.nix index b844aef..c8bd980 100644 --- a/.config/stub-project.nix +++ b/.config/stub-project.nix @@ -186,10 +186,6 @@ --> EOF - cat <<-EOF > "$name/.envrc" - use flake "$FLAKE_DIR#${stubProject.devShellName}" - EOF - # run the stubProject command, pass in the $name of the project and the $FLAKE_DIR stubProject "$name" "$FLAKE_DIR" From b15b387b84fd9bc5a2567f60b7e7d0de07ffed2f Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sun, 22 Feb 2026 14:39:12 -0500 Subject: [PATCH 08/30] chore: stub nested projects Signed-off-by: Ajay Ganapathy --- .config/stub-project.nix | 143 +++++++++++++++++---------------------- 1 file changed, 62 insertions(+), 81 deletions(-) diff --git a/.config/stub-project.nix b/.config/stub-project.nix index c8bd980..c9ce570 100644 --- a/.config/stub-project.nix +++ b/.config/stub-project.nix @@ -45,6 +45,7 @@ runtimeInputs = [ pkgs.coreutils pkgs.fd + pkgs.gnugrep stubProject ]; text = '' @@ -60,9 +61,6 @@ if [[ -z "$name" ]]; then echo "Error: Project name cannot be empty" >&2 exit 1 - elif [ -e "$name" ]; then - echo "$name already exists" >&2 - exit 1 elif [[ ! "$name" =~ ^[a-z][a-z\/-]*[a-z]$ ]]; then echo "Error: Project name '$name' must be at least two characters long. it must start and end with a lowercase alphabetical character. It can only contain alphabetical characters and -" >&2 echo "Valid examples: my-project, hello-world, abc, a-b-c, my/project, my/project/a-b-c" >&2 @@ -74,28 +72,20 @@ exit 1 fi + # get path components out of name + IFS="/" read -ra path_components <<< "$name" + # update the root dir gitignore if [ -f .gitignore ]; then - echo "!$name">>.gitignore - echo "!$name/**">>.gitignore + echo "!''${path_components[0]}">>.gitignore + echo "!''${path_components[0]}/**">>.gitignore fi - # locate the flake.nix at the root of the monorepo - FLAKE_DIR="../" - - seekToRoot(){ - local parent - parent=$(dirname "$(realpath "$*")") - - if [ -d .git ]; then - return - else - FLAKE_DIR="$FLAKE_DIR../" - seekToRoot "$parent" - fi - } + FLAKE_DIR="" - seekToRoot "$(pwd)" + for (( i=0; i<''${#path_components[@]}; i++ )); do + FLAKE_DIR="''${FLAKE_DIR}../" + done # add default readme and contribute cat <<-EOF > "$name/README.md" @@ -189,23 +179,39 @@ # run the stubProject command, pass in the $name of the project and the $FLAKE_DIR stubProject "$name" "$FLAKE_DIR" - cat <<-'EOF' >"$name/.gitignore" - # ignore all - * + for (( i=0; i<''${#path_components[@]}; i++)); do - # and then whitelist what you want to track + IFS="/" + CURR_PC="''${path_components[*]:0:''$((i+1))}" + + if [ ! -s "''${CURR_PC}/.gitignore" ]; then + cat <<-'EOF' >"''${CURR_PC}/.gitignore" + # ignore all + * + + # and then whitelist what you want to track EOF + fi + + # Whitelist files + while read -r filename; do + if ! grep -Fxq "!$filename" "''${CURR_PC}/.gitignore"; then + echo "!$filename" >> "''${CURR_PC}/.gitignore" + fi + done < <(fd --type f --max-depth 1 . "$CURR_PC" --no-ignore --hidden --exec basename {} \;) + + # Whitelist directories and their contents + while read -r dirname; do + if ! grep -Fxq "!$dirname" "''${CURR_PC}/.gitignore"; then + echo "!$dirname" >> "''${CURR_PC}/.gitignore" + fi + if ! grep -Fxq "!$dirname/**" "''${CURR_PC}/.gitignore"; then + echo "!$dirname/**" >> "''${CURR_PC}/.gitignore" + fi + done < <(fd --type d --max-depth 1 . "$CURR_PC" --no-ignore --hidden --exec basename {} \;) + done - # Whitelist files - while read -r filename; do - echo "!$filename" >> "$name/.gitignore" - done < <(fd --type f --max-depth 1 . "$name" --no-ignore --hidden --exec basename {} \;) - # Whitelist directories and their contents - while read -r dirname; do - echo "!$dirname" >> "$name/.gitignore" - echo "!$dirname/**" >> "$name/.gitignore" - done < <(fd --type d --max-depth 1 . "$name" --no-ignore --hidden --exec basename {} \;) ''; }; @@ -215,24 +221,13 @@ in stubProjects # -# LANGUAGE-SPECIFIC PROJECT TEMPLATES -# -# This nix expression builds a script that stubs a different -# project for each language-* folder -# -# each project in this monorepo is created by exactly ONE -# language-*/stubProject.nix -# i.e. +# PROJECT TEMPLATES # -# nix project ......... language-nix/stubProject.nix +# This nix expression builds a script that stubs projects # -# go project ......... language-go/stubProject.nix # -# typescript language-typescript/ -# project ......... stubProject.nix -# -# each stubProject script creates the files and folders -# you need to work in the project's respective language +# each stubProject script creates the project manifests +# you need to work in a project. # # projects/ # |-- flake.nix <------. @@ -240,42 +235,30 @@ in # | imports # '-- .config/ | # | | -# |- stubProject.nix <----------, -# | | -# | imports -# | | -# |- importFromLanguageFolder.nix <----------, -# : | -# : imports -# : | -# | -, | -# |-- language-nix/ | | -# | | | | -# | : | | -# | | | | -# | '-- stubProject.nix | | -# | | | -# |-- language-go/ | | -# | | +---------' -# | : | -# | | | -# | '-- stubProject.nix | -# | | -# '-- language-typescript/ | -# | | -# '-- stubProject.nix | -# -' +# |- stub-project.nix <-------------------------------------, +# | | +# | imports +# | -, | +# |- stub-project-nix_v2.33.1.nix | | +# | | | +# |- stub-project-go_v1.26.0.nix | | +# | +------' +# |- stub-project-deno_v2.6.9.nix | +# | | +# |- stub-project-_v.nix | +# | -' +# : +# # # the root flake provides one project-stub-* command -# for each language +# for each _v # i.e. # -# project-stub-nix --- creates ---> nix project +# project-stub-nix_v2.33.1 --- creates ---> nix project # -# project-stub-go --- creates ---> go project +# project-stub-go_v1.26.0 --- creates ---> go project # -# project-stub --- creates ---> typescript -# -typescript project +# project-stub-deno_v2.6.9 --- creates ---> deno project # # each project-stub-* command updates the # monorepo as follows: @@ -300,13 +283,11 @@ in # | # '- name-of-project/ <-- create new project folder # | -# |- .envrc <-- load dev shell from root flake.nix -# | # |- README.md <-- template for a README # | # |- CONTRIBUTE.md <-- template for a CONTRIBUTE # | -# '- ... <-- any language-specific files +# '- ... <-- any project-specific manifest files (e.g. deno.json, go.mod, flake.nix) # # WHY PROJECT TEMPLATES # From 35975a85f0b55a35addceae6f3d722ed3f48d27a Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 24 Jan 2026 18:55:14 -0500 Subject: [PATCH 09/30] chore: include tool version in stub project nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stub project command can now include multiple tools, each with a version: stub-project-nix.nix → stub-project-nix_v2.33.1.nix i.e. “stub-project-[_].nix” e.g. stub-project-nix_v2.33.1-deno_v2.6.5.nix Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 1 + ...t-nix.nix => stub-project-nix_v2.33.1.nix} | 1 + .config/stub-project.nix | 44 +++++++++---------- 3 files changed, 22 insertions(+), 24 deletions(-) rename .config/{stub-project-nix.nix => stub-project-nix_v2.33.1.nix} (99%) diff --git a/.config/.gitignore b/.config/.gitignore index a48d00d..f7fad28 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -15,4 +15,5 @@ !match-dirent.nix !recurse.nix !stub-project.nix +!stub-project-nix_v2.33.1.nix !stub-project-nix.nix diff --git a/.config/stub-project-nix.nix b/.config/stub-project-nix_v2.33.1.nix similarity index 99% rename from .config/stub-project-nix.nix rename to .config/stub-project-nix_v2.33.1.nix index 9e4957e..53a28f2 100644 --- a/.config/stub-project-nix.nix +++ b/.config/stub-project-nix_v2.33.1.nix @@ -75,6 +75,7 @@ pkgs.writeShellApplication { } ); in { + nix_version = "2.33.1"; packages = forEachSupportedSystem ({pkgs}: { default = pkgs.stdenv.mkDerivation { name = "$PROJECT"; diff --git a/.config/stub-project.nix b/.config/stub-project.nix index c9ce570..097752a 100644 --- a/.config/stub-project.nix +++ b/.config/stub-project.nix @@ -4,7 +4,16 @@ map (stubProjectNixDirent: (import ./${stubProjectNixDirent.name} {inherit pkgs;}) // { - devShellName = builtins.head (builtins.match "^stub-project-(.*).nix$" stubProjectNixDirent.name); + tools = + map (toolVersion: { + name = builtins.elemAt toolVersion 0; + version = builtins.elemAt toolVersion 2; + }) + ( + map + (toolVersion: (builtins.split "_v" toolVersion)) + (builtins.split "-" (builtins.head (builtins.match "^stub-project-(.*).nix$" stubProjectNixDirent.name))) + ); }) ( import ./match-dirent.nix { @@ -15,32 +24,19 @@ } ), }: let - validStubProjectConfigs = let - configs = - builtins.map ( - projectConfig: - if builtins.isAttrs projectConfig && builtins.hasAttr "devShellName" projectConfig - then projectConfig - else builtins.throw "invalid projectConfig ${projectConfig}" - ) - stubProjectConfigs; - - # Check for duplicate devShellName values - names = builtins.map (config: config.devShellName) configs; - uniqueNames = pkgs.lib.unique names; - - # Throw error if there are duplicates - c = - if builtins.length names != builtins.length uniqueNames - then builtins.throw "Duplicate dev shell name values found: ${builtins.toJSON names}" - else configs; - in - c; + validStubProjectConfigs = + builtins.map ( + projectConfig: + if builtins.isAttrs projectConfig && builtins.hasAttr "tools" projectConfig + then projectConfig + else builtins.throw "invalid projectConfig ${projectConfig}" + ) + stubProjectConfigs; wrapStubProject = stubProject: pkgs: pkgs.writeShellApplication { - name = "project-stub-" + stubProject.devShellName; # e.g. project-stub-nix project-stub-go + name = "project-stub-" + builtins.concatStringsSep "-" (builtins.map (tool: tool.name + "_v" + tool.version) stubProject.tools); meta = { - description = "Stub a " + stubProject.devShellName + " project"; + description = "Stub a project with " + builtins.concatStringsSep ", " (builtins.map (tool: tool.name + "_v" + tool.version) stubProject.tools); }; runtimeInputs = [ pkgs.coreutils From f1a2085a5b42eed67a939fc58bc1b4c95ba04cda Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 24 Jan 2026 18:58:19 -0500 Subject: [PATCH 10/30] chore: decouple stub-project-nix_v2.33.1.nix decouple from from devShell.nix Signed-off-by: Ajay Ganapathy --- .config/stub-project-nix_v2.33.1.nix | 98 ++++++++-------------------- 1 file changed, 27 insertions(+), 71 deletions(-) diff --git a/.config/stub-project-nix_v2.33.1.nix b/.config/stub-project-nix_v2.33.1.nix index 53a28f2..e72c990 100644 --- a/.config/stub-project-nix_v2.33.1.nix +++ b/.config/stub-project-nix_v2.33.1.nix @@ -19,47 +19,28 @@ pkgs.writeShellApplication { exit 1 fi - PROJECT=''${PROJECT_DIR##*/} # Extract basename using parameter expansion + PROJECT=''${PROJECT_DIR##*/} # Extract basename using parameter expansion PROJECT=''${PROJECT//[^a-zA-Z0-9-]/_} # Replace invalid chars with underscore # make a flake.nix cat <<-EOT > "$PROJECT_DIR/flake.nix" - # 0.0.0 - # DO NOT REMOVE THE PRECEDING LINE. - # To bump the semantic version and trigger - # an auto-release when this project is merged - # to main, increment the semantic version above - # - # WHEN AND HOW TO EDIT THIS FLAKE - # - # use this flake when you need to build a project that contains code - # from multiple languages, has custom build steps, or special test - # suites. - # - # This flake inherits the project-lint-semver, project-build and - # project-test commands from the nix dev shell. It includes a custom - # project-lint command, that you can configure to lint the different - # files in the project. - # - # Edit the packages.default to define what the project-build - # command builds. - # - # Edit the checks to define the tests that the project-test - # command runs. - # - # Edit the devShells -> default -> devShellConfig -> project-lint - # to define a custom lint script. - # + # see parse-manifest-flake_nix.nix to find out how + # project-lint, project-lint-semver, project-build, and + # project-test are run against this flake { description = "build and test for $PROJECT"; inputs = { - parent-flake.url = "path:$FLAKE_DIR"; + flake-schemas.url = "https://flakehub.com/f/DeterminateSystems/flake-schemas/*"; + + nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.*"; }; outputs = { + flake-schemas, + nixpkgs, self, - parent-flake, + ... }: let supportedSystems = [ "x86_64-linux" @@ -68,14 +49,24 @@ pkgs.writeShellApplication { "aarch64-darwin" ]; forEachSupportedSystem = f: - parent-flake.inputs.nixpkgs.lib.genAttrs supportedSystems ( - system: - f { - pkgs = import parent-flake.inputs.nixpkgs {inherit system;}; - } - ); + nixpkgs.lib.genAttrs supportedSystems (system: + f { + pkgs = import nixpkgs {inherit system;}; + }); in { - nix_version = "2.33.1"; + + # https://determinate.systems/blog/flake-schemas/#defining-your-own-schemas + schemas = flake-schemas.schemas // { + nixVersion = { + version = 1; + doc = "The nix version required to run this flake"; + type = "string"; + }; + }; + + # nixVersion specifies the nix version needed to run this flake + nixVersion = "2.33.1"; + packages = forEachSupportedSystem ({pkgs}: { default = pkgs.stdenv.mkDerivation { name = "$PROJECT"; @@ -121,36 +112,6 @@ pkgs.writeShellApplication { echo "test passed" > "\$out" '''; }); - - devShells = forEachSupportedSystem ({pkgs}: { - default = let - devShellNix = pkgs.lib.head (pkgs.lib.filter (config: config.name == "nix") parent-flake.validDevShellConfigs.\''${pkgs.system}); - devShellConfig = { - packages = - (pkgs.lib.filter (pname: pname != "project-lint") devShellNix.packages) - ++ [ - (pkgs.writeShellApplication { - name = "project-lint"; - meta = { - description = "lint project files"; # list the file types the project-lint command should lint - runtimeInputs = with pkgs; [ - # include packages needed to lint project files - alejandra - ]; - }; - text = ''' - # add lint commands for non-nix files that you want to lint - - # lint all nix files in this directory - alejandra -c *.nix - '''; - }) - ]; - shellHook = devShellNix.shellHook; - }; - in - parent-flake.makeDevShell.\''${pkgs.system} devShellConfig pkgs; - }); }; } EOT @@ -161,11 +122,6 @@ pkgs.writeShellApplication { # generate flake.lock for reproducibility (cd "$PROJECT_DIR" && nix flake update) - # overwrite the default .envrc to use the custom flake - cat <<-EOF > "$PROJECT_DIR/.envrc" - use flake - EOF - # stage all project files git -C "$PROJECT_DIR" add . ''; From 12d40e1fbe6886ff2cf966ce8e939821cd1112bc Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Thu, 22 Jan 2026 20:27:51 -0500 Subject: [PATCH 11/30] chore: update .gitignore Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.config/.gitignore b/.config/.gitignore index f7fad28..4bdd4fe 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -16,4 +16,3 @@ !recurse.nix !stub-project.nix !stub-project-nix_v2.33.1.nix -!stub-project-nix.nix From d467fe6cde2e59a1778b9ae714bb07fe73fbf20f Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 24 Jan 2026 15:07:03 -0500 Subject: [PATCH 12/30] chore: make tool-_.nix make a tool.nix that imports tool-_.nix. The goal is to maintain links to versions of tools that are needed to run project-* commands Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 1 + .config/tool-nix_v2.33.1.nix | 68 ++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 .config/tool-nix_v2.33.1.nix diff --git a/.config/.gitignore b/.config/.gitignore index 4bdd4fe..0c31dd9 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -16,3 +16,4 @@ !recurse.nix !stub-project.nix !stub-project-nix_v2.33.1.nix +!tool-nix_v2.33.1.nix diff --git a/.config/tool-nix_v2.33.1.nix b/.config/tool-nix_v2.33.1.nix new file mode 100644 index 0000000..4b0b491 --- /dev/null +++ b/.config/tool-nix_v2.33.1.nix @@ -0,0 +1,68 @@ +{pkgs ? import {}}: +pkgs.writeShellApplication { + name = "nix"; + meta = { + description = "Use the existing nix version on the build system, if it is >=2.33.1 and <=3.0.0, else error. does not pull nix from nixpkgs, because nix relies on a system-specific daemon"; + }; + text = '' + # THIS script is included in $PATH. we have to remove it, so that it doesn't invoke itself + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + PATH="''${PATH//$SCRIPT_DIR:/}" + + compare(){ + if (( $1 < $2 )); then + echo "-1" + return 0 + elif (( $1 > $2 )); then + echo "1" + return 0 + else + echo "0" + return 0 + fi + } + + compare_semver(){ + IFS='.' + + read -ra left <<< "$1" + read -ra right <<< "$2" + + local i + local cmp + for (( i=0; i<3; i++ )); do + cmp=$(compare "''${left[$i]}" "''${right[$i]}") + if (( cmp != 0)); then + echo "$cmp" + return 0 + fi + done + echo "0" + return 0 + } + + if ! type nix > /dev/null 2>&1; then + echo "nix not installed. Please install it from https://determinate.systems/nix/" >&2 + fi + + VERSION=$(nix --version) + if ! [[ "$VERSION" =~ ^.*([0-9]+\.[0-9]+\.[0-9]+$) ]]; then + echo "nix does not have a valid semver. Expected .. >= 2.33.1 and < 3.0.0, received \"$VERSION\"" >&2 + exit 1 + fi + VERSION="''${BASH_REMATCH[-1]}" + + if (( $(compare_semver "$VERSION" "2.33.1") < 0 || $(compare_semver "$VERSION" "3.0.0") > -1 )); then + echo "expected nix version >= 2.33.1 and < 3.0.0, received \"$VERSION\"" >&2 + exit 1 + fi + + nix "$@" + ''; +} +# +# intercept calls to nix, and return the correct nix binary for nix >=2.33.1 <3.0.0 +# +# unlike most tool-*_v.nix files, this file just validates the version of +# whatever nix is installed on the build system. + From 393f1237ab684720b52b902ca7372a8ff20942a6 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 24 Jan 2026 15:16:29 -0500 Subject: [PATCH 13/30] chore: make tool.nix tool.nix imports tool-_v...nix files and arranges them into an attrset in which name.major.minor.patch points to the bin with the tool Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 1 + .config/tool.nix | 179 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 .config/tool.nix diff --git a/.config/.gitignore b/.config/.gitignore index 0c31dd9..8d791f9 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -16,4 +16,5 @@ !recurse.nix !stub-project.nix !stub-project-nix_v2.33.1.nix +!tool.nix !tool-nix_v2.33.1.nix diff --git a/.config/tool.nix b/.config/tool.nix new file mode 100644 index 0000000..b485f91 --- /dev/null +++ b/.config/tool.nix @@ -0,0 +1,179 @@ +{pkgs ? import {}}: let + tools = builtins.mapAttrs (name: value: + builtins.sort ( + left: right: + if left.major < right.major + then true + else if left.major > right.major + then false + else if left.minor < right.minor + then true + else if left.minor > right.minor + then false + else if left.patch < right.patch + then true + else if left.patch > right.patch + then false + else builtins.throw "two versions of ${name} are both ${left.major}.${left.minor}.${left.patch}: \"${left.path}\" \"${right.path}\"" + ) + value) (pkgs.lib.foldl' ( + acc: curr: let + toolVersion = with curr; {inherit path major minor patch;}; + in + if builtins.hasAttr curr.tool acc + then acc // {${curr.tool} = acc.${curr.tool} ++ [toolVersion];} + else acc // {${curr.tool} = [toolVersion];} + ) {} ( + map ( + dirent: { + path = import ./${dirent.name} {inherit pkgs;}; + tool = builtins.head (builtins.match "tool-(.*)_v[0-9]+\.[0-9]+\.[0-9]+\.nix$" dirent.name); # e.g. tool-nix_v2.33.1.nix -> nix + major = builtins.head (builtins.match "^tool-.*_v([0-9]+)\.[0-9]+\.[0-9]+\.nix$" dirent.name); # e.g. tool-nix_v2.33.1.nix -> 2 + minor = builtins.head (builtins.match "^tool-.*_v[0-9]+\.([0-9]+)\.[0-9]+\.nix$" dirent.name); # e.g. tool-nix_v2.33.1.nix -> 33 + patch = builtins.head (builtins.match "^tool-.*_v[0-9]+\.[0-9]+\.([0-9]+)\.nix$" dirent.name); # e.g. tool-nix_v2.33.1.nix -> 1 + } + ) + (import ./match-dirent.nix { + inherit pkgs; + from = ./.; + matchDirentName = name: (builtins.match "^tool-.*_v[0-9]+\.[0-9]+\.[0-9]+\.nix$" name) != null; + matchDirentType = type: (builtins.match "^regular$" type) != null; + }) + )); + getSemverFromTool = toolVersion: "${toolVersion.major}.${toolVersion.minor}.${toolVersion.patch}"; + getToolForSemver = pkgs.writeShellApplication { + name = "tool"; + runtimeInputs = builtins.concatMap (toolVersions: map (toolVersion: toolVersion.path) toolVersions) (builtins.attrValues tools); + text = '' + TOOL_NAME="$1" + TOOL_VERSION="''${2:-LATEST}" + + if [ -z "$TOOL_NAME" ]; then + echo "no tool name provided" >&2 + exit 1 + fi + + # handle cases where semver is double-quoted + if [[ "$TOOL_VERSION" =~ ^\".+\"$ ]]; then + TOOL_VERSION="''${TOOL_VERSION:1:-1}" + fi + + # handle cases where semver is single quoted + if [[ "$TOOL_VERSION" =~ ^\'.+\'$ ]]; then + TOOL_VERSION="''${TOOL_VERSION:1:-1}" + fi + + # we don't bother handling cases that are quoted multiple times e.g. "'"'2.1.1'"'" because those cases + # should be fixed at the source + + if ! [[ "$TOOL_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || "$TOOL_VERSION" == "LATEST" ]]; then + echo "expected tool version .., received \"$TOOL_VERSION\"" >&2 + exit 1 + fi + + ERR_MSG="tools must be one of ${builtins.concatStringsSep ", " (builtins.attrNames tools)}, received \"$TOOL_NAME\"" + TOOL_PATH="" + + ${ + builtins.concatStringsSep "\n" ( + map ( + toolName: '' + if [[ "$TOOL_NAME" == ${toolName} ]]; then + if [[ "$TOOL_VERSION" == "LATEST" ]]; then + ERR_MSG="" + echo "no tool version specified for \"''${TOOL_NAME}\", using latest version, which is ${getSemverFromTool (pkgs.lib.last tools.${toolName})}" + TOOL_PATH="${(pkgs.lib.last tools.${toolName}).path}/bin/${toolName}" + ${ + builtins.concatStringsSep "\n" ( + map ( + toolVersion: '' + elif [[ "$TOOL_VERSION" == ${getSemverFromTool toolVersion} ]]; then + ERR_MSG="" + TOOL_PATH="${toolVersion.path}/bin/${toolName}" + '' + ) + tools.${toolName} + ) + } + else + ERR_MSG="No matching tool version found for ${toolName}. Expected one of ${builtins.concatStringsSep ", " (map (toolVersion: getSemverFromTool toolVersion) tools.${toolName})}, received \"$TOOL_VERSION\"" + fi + fi + '' + ) + (builtins.attrNames tools) + ) + } + + if [ -n "$ERR_MSG" ]; then + echo "$ERR_MSG" >&2 + exit 1 + fi + + "$TOOL_PATH" "''${@:3}" + ''; + }; +in + getToolForSemver +# +# tool retrieves the tool at the specified version, and runs it with +# e.g. tool nix 2.33.1 build --> retrieve nix 2.33.1 or compatible version --> run nix build +# +# HOW DOES THIS EXPRESSION WORK? +# +# the tool.nix script scans ./ and looks for tool-_v.nix files. Each of these files +# loads the version of tool with , or errors if no matching version can be found. +# +# the tool command is effectively a tool version manager, like asdf (https://asdf-vm.com/) +# +# _________ _______ +# / tool.nix / _______ +# | | | / _______ +# | +------------------------------- loads ---------------------> | / parse-manifest-_.nix +# | | | | | | +# |____^____| | | | +# | |_______| +# | +# loads ________ +# | / tool-nix_v2.33.1.nix +# | | | +# +----- +---- finds nix 2.33.1 or compatible version +# | | | +# | |________| +# | +# | ________ +# | / tool-go_v1.26.0.nix +# | | | +# +----- +---- finds go 1.26.0 or compatible version +# | | | +# | |________| +# | +# : +# +# parse-manifest-_.nix scripts load the tool.nix script. Then, they alias the devtools they +# provide, i.e. +# _______ +# / parse-manifest-_.nix +# | | +# | | +# |___,___| +# | +# '--- +# | +# '--- detects of tool, required by manifest +# | +# '--- calls tool +# | +# ___|___ +# / tool.nix +# | | +# | | +# |___,___| +# | +# '--- retrieves of +# | +# '-- runs +# +# it is the responsibility of the parse-manifest-_.nix script to alias tool and determine +# the tool version. it is the responsibility of tool.nix to retrieve the tool binary for that version. + From d2fc5941b01216c5e3f5742da0ffa71cb5c9822f Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Fri, 23 Jan 2026 15:09:08 -0500 Subject: [PATCH 14/30] chore: make parse-manifest-flake_nix.nix parse-manifest-* receives a manifest file and returns tool, version, and the corresponding project-lint, project-lint-semver, project-build, project-test, project-publish commands Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 1 + .config/parse-manifest-flake_nix.nix | 269 +++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 .config/parse-manifest-flake_nix.nix diff --git a/.config/.gitignore b/.config/.gitignore index 8d791f9..eb97959 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -12,6 +12,7 @@ !CONTRIBUTE.md !installGitHooks.nix !lintCommit.nix +!parse-manifest-flake_nix.nix !match-dirent.nix !recurse.nix !stub-project.nix diff --git a/.config/parse-manifest-flake_nix.nix b/.config/parse-manifest-flake_nix.nix new file mode 100644 index 0000000..394432d --- /dev/null +++ b/.config/parse-manifest-flake_nix.nix @@ -0,0 +1,269 @@ +{ + pkgs ? import {}, + tool ? import ./tool.nix {inherit pkgs;}, +}: let + paths = [ + (pkgs.writeShellApplication + { + name = "nix"; + meta = { + description = "the version of nix to use in the current working directory"; + }; + runtimeInputs = [ + tool + pkgs.coreutils + ]; + text = '' + # THIS script is included in $PATH. we have to remove it, so that it doesn't invoke itself + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + PATH="''${PATH//$SCRIPT_DIR:/}" + + if ! type nix >/dev/null ; then + + echo "nix is not installed. Please install it from https://determinate.systems/nix/" >&2 + exit 1 + fi + + NIX_VERSION=$(nix --version) + + if ! VERSION=$(nix eval "$PWD#nixVersion"); then + echo "nix flake does not have a nix_version in its outputs, using ''${NIX_VERSION}" >&2 + nix "$@" + else + tool "nix" "$VERSION" "$@" + fi + ''; + }) + (pkgs.writeShellApplication + { + name = "project-lint"; + meta = { + description = "lint all .nix files in current working directory, with alejandra"; + }; + runtimeInputs = with pkgs; [alejandra]; + text = '' + alejandra "''${PWD}/*" "$@" || exit 1 + ''; + }) + (pkgs.writeShellApplication + { + name = "project-lint-semver"; + meta = { + description = "lint the semantic versions of all packages in a nix flake, if they exist"; + }; + runtimeInputs = with pkgs; [git jq]; + text = '' + # usage + # project-lint-semver [ ] + # + # run this command without any arguments to lint semver from HEAD to BASE + # run this command with two commit hashes, and to compare semvers + # at the two hashes + + if [ -n "$(git status --short)" ]; then + echo "working directory contains uncommitted changes, cannot project-lint-semver. stash or discard changes and then try again" >&2 + exit 1 + fi + + FROM_HASH="" + TO_HASH="" + + for arg in "$@"; do + if [[ "$arg" == "--all" || "$arg" == "--changed" ]]; then + continue + fi + + if [ -z "$FROM_HASH" ]; then + FROM_HASH="$arg" + elif [ -z "$TO_HASH" ]; then + TO_HASH="$arg" + else + echo "too many arguments" >&2 + exit 1 + fi + done + + if [ -n "$FROM_HASH" ] && [ -z "$TO_HASH" ]; then + echo "you must provide two hashes to compare semantic versions at each" >&2 + exit 1 + fi + + function get_package_versions(){ + nix eval .#packages --apply 'p: let + isPackage = i: builtins.hasAttr "type" i && i.type == "derivation"; + attrsToList = a: builtins.attrValues (builtins.mapAttrs (name: value: {inherit name value;}) a); + version = i: if builtins.hasAttr "version" i then i.version else ""; + unwrapPackage = distribution: package: [{name=package.name; version=version package; distribution=distribution;}]; + unwrapDistribution = distributionName: distributionPackages: builtins.concatMap (package: unwrapPackage distributionName package) (builtins.attrValues distributionPackages); + packageVersions = builtins.concatMap (p: if isPackage p.value then unwrapPackage "" p.value else unwrapDistribution p.name p.value) (attrsToList p); + formattedPackageVersions = builtins.toJSON packageVersions; + in formattedPackageVersions' 2>/dev/null || return 0 + } + + # given two lists of packages from get_package_versions, $1 and $2, iterate through each list, verifying that matching packages semvers decrease from $1 to $2, and accumulating + # unmatched packages into a returned list + function lint_package_semvers(){ + + if [ -z "$2" ]; then + echo "$1" + return 0 + fi + + if nix eval --expr " + let + curr = builtins.fromJSON ''${1}; + prev = builtins.fromJSON ''${2}; + match = currEl: prev: builtins.filter(prevEl: prevEl.name == currEl.name && prevEl.distribution == currEl.distribution) prev; + matchSingle = matched: if builtins.length matched > 1 then builtins.throw \"duplicate packages found: \''${builtins.toString matched}\" else if builtins.length matched == 1 then builtins.head matched else null; + parseMajorMinorPatch = s: builtins.match \"([0-9]+)\\.([0-9]+)\\.([0-9]+)\" s; + parseMajorMinor = s: builtins.match \"([0-9]+)\\.([0-9]+)\" s; + parseMajor = s: builtins.match \"([0-9]+)\" s; + parseSemver = s: if s == \"\" then [ \"0\" \"0\" \"0\" ] else if parseMajorMinorPatch s != null then parseMajorMinorPatch s else if parseMajorMinor s != null then (parseMajorMinor s) ++ [\"0\"] else if parseMajor s != null then (parseMajor s) ++ [ \"0\" \"0\" ] else builtins.throw \"\''${s} is not a valid semantic version\"; + getSemverComponent = l: i: builtins.fromJSON (builtins.elemAt l i); + compareSemvers = left: right: builtins.foldl' (acc: curr: if acc != 0 then acc else curr) 0 (map (i: if (getSemverComponent left i) > (getSemverComponent right i) then 1 else if (getSemverComponent left i) < (getSemverComponent right i) then -1 else 0) (builtins.genList(i: i) 3)); + replacePackage = curr: prev: if prev == null then curr else if compareSemvers (parseSemver curr.version) (parseSemver prev.version) < 0 then builtins.throw \"\''${curr.name} semver decreased from \''${prev.version} to \''${curr.version}\" else prev; + mergeCurrPrev = curr: prev: (map(p: replacePackage p (matchSingle(match p prev))) curr) ++ (builtins.filter(p: builtins.length(builtins.filter(c: c.name == p.name) curr) == 0) prev); + in + builtins.toJSON (mergeCurrPrev curr prev) + "; then + return 0 + fi + return 1 + } + + function fileExistsAtHash(){ + local hash="$1" + if ! git cat-file -e "''${hash}:flake.nix" 2>/dev/null; then + return 1 + fi + } + + # handle case where we lint semver from HEAD all the way to BASE + if [ -z "$FROM_HASH" ] && [ -z "$TO_HASH" ]; then + + CURR_BRANCH=$(git rev-parse --abbrev-ref HEAD) + + readarray -t commits < <(git log --pretty=format:"%H" -- "''${PWD}/flake.nix") + + LEN="''${#commits[@]}" + + if (( LEN == 1 )); then + echo "''${PWD}/flake.nix has never changed since it was introduced in ''${commits[0]}" >&2 + exit 0 + elif (( LEN == 0 )) then + echo "''${PWD} has never had a flake.nix" >&2 + exit 0 + fi + + ACC_PKGS="" + CURR_PKGS="" + + for ((i=0; i/dev/null + + CURR_PKGS=$(get_package_versions) + + if [ -z "$ACC_PKGS" ]; then + ACC_PKGS="$CURR_PKGS" + else + if ! ACC_PKGS=$(lint_package_semvers "$ACC_PKGS" "$CURR_PKGS" 2>/dev/null); then + echo "project-lint-semver failed for ''${PWD}/flake.nix because package semantic versions decreased after ''${commits[i]}." >&2 + git -c advice.detachedHead=false checkout "$CURR_BRANCH" 2>/dev/null + exit 1 + fi + fi + done + git -c advice.detachedHead=false checkout "$CURR_BRANCH" 2>/dev/null + exit 0 + fi + + # handle case where we compare flake.nix package semvers at two hashes + if ! fileExistsAtHash "$FROM_HASH"; then + echo "''${PWD}/flake.nix does not exist at ''${FROM_HASH}" >&2; + exit 1 + fi + + if ! fileExistsAtHash "$TO_HASH"; then + echo "''${PWD}/flake.nix does not exist at ''${TO_HASH}" >&2; + exit 1 + fi + + TO_PACKAGES="" + FROM_PACKAGES="" + + if ! git -c advice.detachedHead=false checkout "$TO_HASH" 2>/dev/null; then + echo "Failed to checkout ''${TO_HASH}" >&2; + exit 1 + fi + + TO_PACKAGES=$(get_package_versions 2>/dev/null) + + if ! git -c advice.detachedHead=false checkout "$FROM_HASH" 2>/dev/null; then + echo "Failed to checkout ''${FROM_HASH}" >&2; + exit 1 + fi + + FROM_PACKAGES=$(get_package_versions 2>/dev/null) + + if [ -z "$TO_PACKAGES" ] && [ -z "$FROM_PACKAGES" ]; then + echo "''${PWD}/flake.nix does not contain any packages at ''${TO_HASH} or ''${FROM_HASH}. Nothing to compare." >&2; + exit 0 + fi + + if [ -z "$TO_PACKAGES" ]; then + echo "''${PWD}/flake.nix does not contain any packages at ''${TO_HASH}. Cannot compare to ''${FROM_HASH}" >&2; + exit 0 + fi + + if [ -z "$FROM_PACKAGES" ]; then + echo "''${PWD}/flake.nix does not contain any packages at ''${FROM_HASH}. Cannot compare to ''${TO_HASH}" >&2; + exit 0 + fi + + if ! lint_package_semvers "$TO_PACKAGES" "$FROM_PACKAGES"; then + echo "lint_package_semvers failed from ''${FROM_HASH} to ''${TO_HASH}" >&2 + exit 1 + fi + ''; + }) + (pkgs.writeShellApplication + { + name = "project-build"; + meta = { + description = "build all packages in the nix flake"; + }; + runtimeInputs = [pkgs.jq]; + text = '' + function package_names(){ + nix eval .#packages --apply "p: let + platform = ''${1}; + packages = if builtins.hasAttr platform p then p.\''${platform} else p; + packageNames = builtins.attrNames packages; + in + builtins.toJSON packageNames" 2>/dev/null || echo "\"[]\"" + } + + CURR_SYSTEM=$(nix eval --impure --expr "builtins.currentSystem") + + while read -r packageName; do + if ! nix build ".#''${packageName}" --no-link --print-out-paths; then + echo "failed to build ''${PWD}/flake.nix package ''${CURR_SYSTEM}.''${packageName}" >&2 + exit 1 + fi + done < <(package_names "$CURR_SYSTEM" | jq 'fromjson | .[]') + ''; + }) + (pkgs.writeShellApplication + { + name = "project-test"; + meta = { + description = "run all tests in the nix flake"; + }; + text = '' + nix flake check + ''; + }) + ]; +in + paths From 3138674f529bd9dc31642a1e388ef44644d50dd2 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 24 Jan 2026 08:06:20 -0500 Subject: [PATCH 15/30] chore: make dev-shell.nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev-shell.nix will replace devShell.nix it provides a simpler definition for project-lint project-lint-semver project-build project-test note: we don’t provide a project-bump-semver because CI will never run this command! note: we don’t provide a project-publish, because each manifest file gets its own project-publish github action Signed-off-by: Ajay Ganapathy --- .config/.gitignore | 1 + .config/dev-shell.nix | 411 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 .config/dev-shell.nix diff --git a/.config/.gitignore b/.config/.gitignore index eb97959..4eab995 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -8,6 +8,7 @@ !importFrom.nix !configZed.nix !devShell.nix +!dev-shell.nix !sanitizeProjectName.nix !CONTRIBUTE.md !installGitHooks.nix diff --git a/.config/dev-shell.nix b/.config/dev-shell.nix new file mode 100644 index 0000000..0a6ab63 --- /dev/null +++ b/.config/dev-shell.nix @@ -0,0 +1,411 @@ +{pkgs ? import {}}: let + manifests = + map ( + dirent: { + name = pkgs.lib.replaceStrings ["_"] ["."] (builtins.head (builtins.match "^parse-manifest-(.*)\.nix$" dirent.name)); + value = import ./${dirent.name} {inherit pkgs;}; + } + ) ( + import ./match-dirent.nix { + inherit pkgs; + from = ./.; + matchDirentName = name: (builtins.match "^parse-manifest-.*\.nix$" name) != null; + matchDirentType = type: (builtins.match "regular" type) != null; + } + ); + manifestNames = builtins.concatStringsSep ", " (map (m: m.name) manifests); + manifestsForCmd = cmdName: builtins.filter (manifest: builtins.elem cmdName (map (bins: bins.name) manifest.value)) manifests; + getCmd = cmdName: manifestValue: "${builtins.head (builtins.filter (shellApplication: shellApplication.name == cmdName) manifestValue)}/bin/${cmdName}"; + wrap = cmdName: + pkgs.writeShellApplication { + name = cmdName; + meta = { + description = "detect ${manifestNames} and run the corresponding ${cmdName} for each"; + }; + runtimeInputs = with pkgs; [ + fd + coreutils + ]; + text = '' + CHANGED=0 + ALL=0 + + did_change(){ + local dir="$1" + + # Return 0 (true) if any changes found, 1 (false) if none + if git diff --quiet "$dir" && \ + git diff --cached --quiet "$dir" && \ + git diff --quiet HEAD~1 HEAD -- "$dir"; then + return 1 # No changes + else + return 0 # Has changes + fi + } + + # scoop the first --changed and --all flags passed into args + ARGS=() + + for arg in "$@"; do + if [[ "$arg" = "--changed" && "$CHANGED" = 0 ]]; then + CHANGED=1 + elif [[ "$arg" = "--all" && "$ALL" = 0 ]]; then + ALL=1 + else + ARGS+=("$arg") + fi + done + + if (( CHANGED == 1 && ALL == 1 )); then + echo "cannot submit both --changed and --all, as --changed runs ${cmdName} on only ${manifestNames} that have changed between staging area and the commit directly prior to HEAD in ''${PWD} and its subdirectories, while --all runs ${cmdName} on all ${manifestNames} Ωin ''${PWD} and its subdirectories" >&2 + exit 1 + fi + + if (( CHANGED == 0 && ALL == 0)); then + ALL=1 + fi + + run_cmd(){ + local cmdPath="$1" + local manifestName="$2" + local returnCode=0 + local manifestPath="" + local d="" + + mapfile -t all_manifests < <(fd -t f -H -F "$manifestName") + + # iterate over manifests from subdirs all the way back up to PWD + for (( i = "''${#all_manifests[@]}" - 1; i >= 0; i-- )); do + manifestPath=$(realpath "''${all_manifests[$i]}") + d=$(dirname "$manifestPath") + + if (( ALL==1 )) || did_change "$d"; then + cd "$d" + "$cmdPath" "''${ARGS[@]}" || returnCode=1 + fi + + if (( returnCode == 1)); then + echo "${cmdName} failed at $manifestPath" >&2 + return 1 + fi + done + } + + WORKDIR="$PWD" + EXIT_CODE=1 + + ${ + builtins.concatStringsSep " && \\\n" (( + map (manifest: ''run_cmd "${getCmd cmdName manifest.value}" "${manifest.name}" "$@"'') (manifestsForCmd cmdName) + ) + ++ ["EXIT_CODE=0"]) + } + + cd "$WORKDIR" + + exit "$EXIT_CODE" + ''; + }; + bins = builtins.concatMap (manifest: + builtins.filter (derivation: + derivation.name + != "project-lint" + && derivation.name != "project-lint-semver" + && derivation.name != "project-build" + && derivation.name != "project-test") + manifest.value) + manifests; + uniqueBins = let + binNames = map (bin: bin.name) bins; + uniqueBinNames = pkgs.lib.unique binNames; + in + if builtins.length binNames == builtins.length uniqueBinNames + then bins + else throw "Duplicate binaries found in ${manifestNames}: ${binNames}"; + project-lint = wrap "project-lint"; + project-lint-semver = wrap "project-lint-semver"; + project-build = wrap "project-build"; + project-test = wrap "project-test"; + stubProjects = import ./stub-project.nix {inherit pkgs;}; + default = pkgs.mkShell { + packages = + [ + pkgs.glow + pkgs.git + (import ./configVscode.nix {inherit pkgs;}) + (import ./configZed.nix {inherit pkgs;}) + (import ./installGitHooks.nix {inherit pkgs;}) + project-lint + project-lint-semver + project-build + project-test + ] + ++ uniqueBins ++ stubProjects; + shellHook = '' + # vscode config, zed config, git hooks have to be run in monorepo root + WORKDIR="$PWD" + cd $(git rev-parse --path-format=relative --show-toplevel) + + project-install-vscode-configuration + project-install-zed-configuration + project-install-git-hooks + + cd "$WORKDIR" + + glow <<-'EOF' >&2 + # project-lint + recurse through the working directory and subdirectories, linting all projects that have a ${builtins.concatStringsSep ", " (map (manifest: manifest.name) (manifestsForCmd "project-lint"))} + + - use flag --changed to skip projects that have not changed in the latest commit + + # project-lint-semver + recurse through the working directory and subdirectories, validating the semantic version of projects that have a ${builtins.concatStringsSep ", " (map (manifest: manifest.name) (manifestsForCmd "project-lint-semver"))} + + - use flag --changed to skip projects that have not changed in the latest commit + + # project-build + recurse through the working directory and subdirectories, building projects that have a ${builtins.concatStringsSep ", " (map (manifest: manifest.name) (manifestsForCmd "project-build"))} + + - use flag --changed to skip projects that have not changed in the latest commit + + # project-test + recurse through the working directory and subdirectories, testing projects that have a ${builtins.concatStringsSep ", " (map (manifest: manifest.name) (manifestsForCmd "project-test"))} + + - use flag --changed to skip projects that have not changed in the latest commit + + # project-install-vscode-configuration + symlink the .vscode configuration folder into the root of this repository. Automatically run when this shell starts + + # project-install-zed-configuration + symlink the .zed configuration folder into the root ofthis repository. Automatically run when this shell starts + + ${builtins.concatStringsSep '' + + '' ( + (map (bin: + '' + # ${bin.name} + '' + + ( + if builtins.hasAttr "meta" bin + then + if builtins.hasAttr "description" bin.meta + then bin.meta.description + else '''' + else '''' + )) + uniqueBins) + ++ (map (p: '' + # ${p.name} + ${p.meta.description} + '') + stubProjects) + )} + EOF + ''; + }; +in {inherit project-lint project-lint-semver project-build project-test default;} +# +# The DEVELOPMENT SHELL +# +# when you `nix develop`, or install `nix-direnv` and `cd` into this repo, this dev shell activates +# +# This dev shell not only installs all of the development tools, it also manages the tool versions +# for you. You don't need node version manager, asdf, python virtual environments, etc. Just `cd` +# into the folder of your choice, and develop. +# +# +# HOW THE DEVELOPMENT SHELL WORKS +# +# The development shell patches the $PATH with scripts that intercept calls to dev tools. e.g. +# +# +# `go run main.go` _______ +# '-,--------------' / parse-manifest-go_mod.nix +# | | | +# '----- points to ----> +-----------, +# |_______| | +# ^ passes go +# | version, +# reads go command to +# version | +# .---._____ from ___|___ +# | workdir | | / tool.nix +# | | ___|___ | | +# '____,____' / go.mod| | | +# | | | |___,___| +# '----------> | | +# |_______| | +# selects +# matching go +# version from +# | +# .---._____ | +# | .config | | +# | | ___|___ +# '____,____' / _______ +# | | / _______ +# '----------> | / tool-go_v.nix +# | | | | +# | | | +# |___,___| +# | +# | +# runs original command +# +# The development shell detects manifest files in the working directory, and uses them +# to determine which _version_ of a dev tool to run. Then, it loads the correct version +# of the dev tool. +# +# The development shell also provides the following helper commands: +# - `project-lint` +# - `project-lint-semver` +# - `project-build` +# - `project-test` +# +# Each of these commands is recursive: when you run them, the dev shell will traverse +# the working directory, and all subdirectories, running these commands against all +# manifest files that it finds. +# +# e.g. +# +# if you run `project-lint` in projects/project-B +# +# projects/ +# | +# |-- project-A/ +# | | +# | '-- package.json +# | _______ +# '-- project-B/ / dev-shell.nix +# | | | +# |-- flake.nix <----------,---- detects -----+ | +# | | |___,___| +# |-- go.mod <----------| | +# | | invokes `project-lint` in +# '-- project-C/ | | +# | | | +# '-- cargo.toml <----' | +# | +# | _______ +# | / parse-manifest-flake_nix.nix +# | | | +# |-----+ | +# | |___,___| +# | | +# | '---- runs `project-lint` +# | | +# | '-- runs nix-specific format +# | and lint commands +# | +# | +# | _______ +# | / parse-manifest-go_mod.nix +# | | | +# |-----+ | +# | |___,___| +# | | +# | '---- runs `project-lint` +# | | +# | '-- runs go-specific format +# | and lint commands +# | +# | _______ +# | / parse-manifest-cargo_toml.nix +# | | | +# '-----+ | +# |___,___| +# | +# '---- runs `project-lint` +# | +# '-- runs rust-specific format +# and lint commands +# +# WHY IS THE DEVELOPMENT SHELL SET UP THIS WAY? +# +# The development shell provides ONE $PATH and development environment for +# all projects. This makes it trivial to nest projects in one another, and +# to share project folders between multiple manifests. +# +# +# HOW DO I ADD SUPPORT FOR ANOTHER MANIFEST FILE? +# +# create a parse-manifest-_.nix +# e.g. pyproject.toml -> parse-manifest-pyproject_toml.nix, +# +# The file must contain the following: +# +# ``` +# { +# pkgs ? import {}, +# tool ? import ./tool.nix {inherit pkgs;}, # optionally import the tool script, which selects the correct tool version for a flake.nix +# ... +# }: let +# paths = [ # list of scripts and binaries to load into PATH. these must all take the form of (pkgs.writeShellApplication {...}) +# (pkgs.writeShellApplication # The parentheses () around pkgs.writeShellApplication is REALLY important. It tells nix to turn the pkgs.writeShellApplication into a derivation before adding it to list of paths +# { +# name = "project-lint"; # OPTIONAL command to run when project-lint is called in directories containing this manifest +# meta = { +# description = "..."; # description of what lint commands will be run when project-lint is called on directories containing this manifest file +# }; +# runtimeInputs = [...]; # bins needed to run lint commands +# text = '' +# ... # the lint commands +# ''; +# } +# ) +# (pkgs.writeShellApplication +# { +# name = "project-lint-semver"; # OPTIONAL command to run when project-lint-semver is called in directories containing this manifest +# meta = { +# description = "..."; # description of what lint-semver commands will be run when project-lint-semver is called on directories containing this manifest file +# }; +# runtimeInputs = [...]; # bins needed to run lint-semver commands +# text = '' +# ... # the lint-semver commands +# ''; +# } +# ) +# (pkgs.writeShellApplication +# { +# name = "project-build"; # OPTIONAL command to run when project-build is called in directories containing this manifest +# meta = { +# description = "..."; # description of what build commands will be run when project-build is called on directories containing this manifest file +# }; +# runtimeInputs = [...]; # bins needed to run build commands +# text = '' +# ... # the build commands +# ''; +# } +# ) +# (pkgs.writeShellApplication +# { +# name = "project-test"; # OPTIONAL command to run when project-test is called in directories containing this manifest +# meta = { +# description = "..."; # description of what build commands will be run when project-test is called on directories containing this manifest file +# }; +# runtimeInputs = [...]; # bins needed to run test commands +# text = '' +# ... # the test commands +# ''; +# } +# ) +# (pkgs.writeShellApplication +# { +# name = "..."; # name of bin used in other tools. e.g. `python`, `cargo` etc. +# meta = { +# description = "..."; # description of the bin +# }; +# runtimeInputs = [...]; # bin that is being aliased +# text = '' +# ... # commands used to READ the project manifest, detect if a specific version of the command is required, and then select that +# ''; # version, using the tool.nix script +# } +# ) +# ] +# in +# paths +# ``` +# +# see parse-manifest-flake_nix.nix for an example + From 8acd39ca231b2adfbaef1b2582324724a5c3ebf3 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Tue, 3 Feb 2026 19:16:32 -0500 Subject: [PATCH 16/30] chore: make installGitHooks.nix use dev-shell.nix Signed-off-by: Ajay Ganapathy --- .config/installGitHooks.nix | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.config/installGitHooks.nix b/.config/installGitHooks.nix index 2f07004..a84eaea 100644 --- a/.config/installGitHooks.nix +++ b/.config/installGitHooks.nix @@ -3,12 +3,14 @@ commitMsg = "${lintCommit}/bin/lintCommit"; prePush = "${pkgs.writeShellApplication { name = "prePush"; + runtimeInputs = let + devShell = import ./dev-shell.nix {inherit pkgs;}; + in [devShell.project-lint devShell.project-lint-semver devShell.project-build devShell.project-test]; text = '' - # lint, lint-semver, build, test EVERYTHING before pushing - "${(import ./recurse.nix { - inherit pkgs; - steps = ["project-lint" "project-lint-semver" "project-build" "project-test"]; - })}/bin/recurse" false; + project-lint --changed && \ + project-lint-semver --changed && \ + project-build --changed && \ + project-test --changed ''; }}/bin/prePush"; # Create the installer script From 5d22a6142101ddcd811ccdce30e78d58561e8226 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Tue, 3 Feb 2026 19:27:02 -0500 Subject: [PATCH 17/30] chore: snake case dev shell nix files --- .config/.gitignore | 3 +++ .config/{configVscode.nix => config-vscode.nix} | 0 .config/{configZed.nix => config-zed.nix} | 0 .config/dev-shell.nix | 7 ++++--- .config/devShell.nix | 6 +++--- .config/{installGitHooks.nix => install-git-hooks.nix} | 0 6 files changed, 10 insertions(+), 6 deletions(-) rename .config/{configVscode.nix => config-vscode.nix} (100%) rename .config/{configZed.nix => config-zed.nix} (100%) rename .config/{installGitHooks.nix => install-git-hooks.nix} (100%) diff --git a/.config/.gitignore b/.config/.gitignore index 4eab995..194f447 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -4,14 +4,17 @@ !.gitignore !commitlintConfig.nix !configVscode.nix +!config-vscode.nix !importFromLanguageFolder.nix !importFrom.nix !configZed.nix +!config-zed.nix !devShell.nix !dev-shell.nix !sanitizeProjectName.nix !CONTRIBUTE.md !installGitHooks.nix +!install-git-hooks.nix !lintCommit.nix !parse-manifest-flake_nix.nix !match-dirent.nix diff --git a/.config/configVscode.nix b/.config/config-vscode.nix similarity index 100% rename from .config/configVscode.nix rename to .config/config-vscode.nix diff --git a/.config/configZed.nix b/.config/config-zed.nix similarity index 100% rename from .config/configZed.nix rename to .config/config-zed.nix diff --git a/.config/dev-shell.nix b/.config/dev-shell.nix index 0a6ab63..0132c34 100644 --- a/.config/dev-shell.nix +++ b/.config/dev-shell.nix @@ -132,9 +132,10 @@ [ pkgs.glow pkgs.git - (import ./configVscode.nix {inherit pkgs;}) - (import ./configZed.nix {inherit pkgs;}) - (import ./installGitHooks.nix {inherit pkgs;}) + (import ./config-vscode.nix {inherit pkgs;}) + (import ./config-zed.nix {inherit pkgs;}) + (import ./stub-project.nix {inherit pkgs;}) + (import ./install-git-hooks.nix {inherit pkgs;}) project-lint project-lint-semver project-build diff --git a/.config/devShell.nix b/.config/devShell.nix index a83b712..c2621f8 100644 --- a/.config/devShell.nix +++ b/.config/devShell.nix @@ -468,9 +468,9 @@ default = let p = [ - (import ./configVscode.nix {inherit pkgs;}) - (import ./configZed.nix {inherit pkgs;}) - (import ./installGitHooks.nix {inherit pkgs;}) + (import ./config-vscode.nix {inherit pkgs;}) + (import ./config-zed.nix {inherit pkgs;}) + (import ./install-git-hooks.nix {inherit pkgs;}) ] ++ (import ./stub-project.nix {inherit pkgs;}) ++ builtins.map (cmd: diff --git a/.config/installGitHooks.nix b/.config/install-git-hooks.nix similarity index 100% rename from .config/installGitHooks.nix rename to .config/install-git-hooks.nix From 8b3f8debf09962e1b50fdacf0d4a43e435aab8a6 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Tue, 3 Feb 2026 19:30:17 -0500 Subject: [PATCH 18/30] chore: update .gitignore --- .config/.gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.config/.gitignore b/.config/.gitignore index 194f447..d449d8f 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -3,17 +3,13 @@ !.envrc !.gitignore !commitlintConfig.nix -!configVscode.nix !config-vscode.nix !importFromLanguageFolder.nix -!importFrom.nix -!configZed.nix !config-zed.nix !devShell.nix !dev-shell.nix !sanitizeProjectName.nix !CONTRIBUTE.md -!installGitHooks.nix !install-git-hooks.nix !lintCommit.nix !parse-manifest-flake_nix.nix From af3e9091a23a77fb4ec1ae65eeb1426293844656 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 21 Feb 2026 14:51:20 -0500 Subject: [PATCH 19/30] chore: rename lintCommit.nix commitlintConfig.nix --- .config/.gitignore | 2 ++ .config/{commitlintConfig.nix => commitlint-config.nix} | 0 .config/install-git-hooks.nix | 2 +- .config/{lintCommit.nix => lint-commit.nix} | 2 +- .github/workflows/push.yml | 2 +- 5 files changed, 5 insertions(+), 3 deletions(-) rename .config/{commitlintConfig.nix => commitlint-config.nix} (100%) rename .config/{lintCommit.nix => lint-commit.nix} (99%) diff --git a/.config/.gitignore b/.config/.gitignore index d449d8f..f47b638 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -3,6 +3,7 @@ !.envrc !.gitignore !commitlintConfig.nix +!commitlint-config.nix !config-vscode.nix !importFromLanguageFolder.nix !config-zed.nix @@ -12,6 +13,7 @@ !CONTRIBUTE.md !install-git-hooks.nix !lintCommit.nix +!lint-commit.nix !parse-manifest-flake_nix.nix !match-dirent.nix !recurse.nix diff --git a/.config/commitlintConfig.nix b/.config/commitlint-config.nix similarity index 100% rename from .config/commitlintConfig.nix rename to .config/commitlint-config.nix diff --git a/.config/install-git-hooks.nix b/.config/install-git-hooks.nix index a84eaea..4f0094c 100644 --- a/.config/install-git-hooks.nix +++ b/.config/install-git-hooks.nix @@ -1,5 +1,5 @@ {pkgs ? import {}, ...}: let - lintCommit = import ./lintCommit.nix {inherit pkgs;}; + lintCommit = import ./lint-commit.nix {inherit pkgs;}; commitMsg = "${lintCommit}/bin/lintCommit"; prePush = "${pkgs.writeShellApplication { name = "prePush"; diff --git a/.config/lintCommit.nix b/.config/lint-commit.nix similarity index 99% rename from .config/lintCommit.nix rename to .config/lint-commit.nix index 71db107..3084cb1 100644 --- a/.config/lintCommit.nix +++ b/.config/lint-commit.nix @@ -17,7 +17,7 @@ # ./result/bin/commitlint --help # {pkgs ? import {}}: let - config = import ./commitlintConfig.nix {inherit pkgs;}; + config = import ./commitlint-config.nix {inherit pkgs;}; bin = pkgs.buildGoModule { # see https://nixos.org/manual/nixpkgs/stable/#ssec-language-go diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 7d60f62..838047e 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -99,7 +99,7 @@ jobs: - name: Build lintCommit from Nix run: | # Build the lintCommit tool from the Nix expression - nix-build .config/lintCommit.nix + nix-build .config/lint-commit.nix - name: Lint Commit Messages id: lint-commit-messages From 572fdc99ca64f31b336f22f4edc0c64c58b1565d Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 21 Feb 2026 14:51:50 -0500 Subject: [PATCH 20/30] chore: update .gitignore --- .config/.gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.config/.gitignore b/.config/.gitignore index f47b638..2537957 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -2,17 +2,14 @@ !language-nix !.envrc !.gitignore -!commitlintConfig.nix !commitlint-config.nix !config-vscode.nix !importFromLanguageFolder.nix !config-zed.nix !devShell.nix !dev-shell.nix -!sanitizeProjectName.nix !CONTRIBUTE.md !install-git-hooks.nix -!lintCommit.nix !lint-commit.nix !parse-manifest-flake_nix.nix !match-dirent.nix From e4bddca278cfe384bce9258c1d73aa6d69700ddd Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 21 Feb 2026 13:50:39 -0500 Subject: [PATCH 21/30] chore: update flake.nix to use dev-shell.nix chore: make github actions use dev-shell.nix cmds --- .config/.envrc | 1 - .config/devShell.nix | 723 ------------------------------------ .config/language-nix/.envrc | 1 - .github/workflows/push.yml | 117 ++---- flake.nix | 21 +- 5 files changed, 43 insertions(+), 820 deletions(-) delete mode 100644 .config/.envrc delete mode 100644 .config/devShell.nix delete mode 100644 .config/language-nix/.envrc diff --git a/.config/.envrc b/.config/.envrc deleted file mode 100644 index 9de2cce..0000000 --- a/.config/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake ../#nix diff --git a/.config/devShell.nix b/.config/devShell.nix deleted file mode 100644 index c2621f8..0000000 --- a/.config/devShell.nix +++ /dev/null @@ -1,723 +0,0 @@ -{ - pkgs ? import {}, - devShellConfigs ? (import ./importFromLanguageFolder.nix {inherit pkgs;}).importDevShell, -}: let - validateConfigAttrs = c: - if !builtins.hasAttr "name" c - then throw "invalid config, missing name: ${c}" - else if !builtins.hasAttr "packages" c - then throw "invalid config, missing packages: ${c}" - else if !builtins.hasAttr "shellHook" c - then throw "invalid config, missing shellHook: ${c}" - else true; - validatePackage = p: - if !builtins.isAttrs p - then throw "invalid package, not an attrset: ${p}" - else if !builtins.hasAttr "name" p - then throw "invalid package, missing name: ${p}" - else if !builtins.hasAttr "meta" p - then throw "invalid package ${p.name}, missing meta: ${p}" - else if !builtins.hasAttr "description" p.meta - then throw "invalid package ${p.name}, missing description: ${p}" - else if !builtins.pathExists "${p}/bin" - then throw "invalid package ${p.name}, missing /bin dir: ${p}" - else if builtins.readDir "${p}/bin" == {} - then throw "invalid package ${p.name}, empty /bin dir: ${p}" - else true; - validateUniquePackages = packages: let - packageNames = map (package: package.name) packages; - grouped = pkgs.lib.groupBy (name: name) packageNames; - duplicates = builtins.attrNames (pkgs.lib.filterAttrs (name: names: builtins.length names > 1) grouped); - in - duplicates == [] || throw "Duplicate package names found: ${toString duplicates}"; - validDevShellConfigs = map (c: - if - builtins.isAttrs c - && validateConfigAttrs c - && (builtins.all (x: x) (map (package: validatePackage package) c.packages)) - && validateUniquePackages c.packages - then c - else builtins.throw "invalid devShellConfig ${c}") - devShellConfigs; - wrappedPackages = devShellConfig: - pkgs.lib.fix ( - self: let - packages = builtins.listToAttrs ( - builtins.map (package: { - name = package.name; - value = package; - }) - devShellConfig.packages - ); - name = devShellConfig.name; - getChanged = pkgs.writeShellApplication { - name = "getChanged"; - runtimeInputs = [pkgs.git]; - text = '' - # Get union of files changed in HEAD and uncommitted changes - # Limited to current working directory - - # Use associative array for deduplication - declare -A seen_files=() - - # Files changed in HEAD commit - while IFS= read -r -d "" file; do - seen_files["$file"]=1 - done < <(git diff --name-only -z HEAD~1 HEAD -- .) - - # Files with uncommitted changes (staged + unstaged) - while IFS= read -r -d "" file; do - seen_files["$file"]=1 - done < <(git diff --name-only -z HEAD -- .) - - # Get nested projects (directories with .envrc files) - declare -A nested_projects - while IFS= read -r -d "" subdir; do - nested_projects["$subdir"]=1 - done < <(fd --type f --hidden '.envrc' . --exec printf '%s\0' '{//}') - - # Remove files that are in nested projects - declare -A filtered_files - for file in "''${!seen_files[@]}"; do - file_dir=$(dirname "$file") - if [[ -z "''${nested_projects["$file_dir"]:-}" ]]; then - filtered_files["$file"]=1 - fi - done - - # Output unique filenames with null-byte separation for xargs -0 - printf "%s\0" "''${!filtered_files[@]}" - ''; - }; - getAll = pkgs.writeShellApplication { - name = "getAll"; - runtimeInputs = [pkgs.fd]; - text = '' - # list nested projects - declare -A nested_projects - while IFS= read -r -d "" subdir; do - nested_projects["$subdir"]=1 - done < <(fd --type f --hidden '.envrc' . --exec printf '%s\0' '{//}') - - # Build exclude arguments from nested_projects - exclude_args=() - for project in "''${!nested_projects[@]}"; do - exclude_args+=(--exclude "$project") - done - - # Associative array to store all files in project - declare -A files_in_project - - # Get all files excluding nested projects and gitignored files - while IFS= read -r -d "" file; do - files_in_project["$file"]=1 - done < <(fd "''${exclude_args[@]}" --type f --hidden --print0 .) - - # Output all collected files with null separator for xargs - printf "%s\0" "''${!files_in_project[@]}" - ''; - }; - in - packages - // ( - if builtins.hasAttr "project-lint" packages - then { - # wraps the project lint script. - # - # accepts $1 with "true" or "false", defaults to "false" - # - # $1="true": lint CHANGED files in project. this includes - # any uncommitted change - # - # $1="false": lint ALL files in project - # - # exits 0 if lint succeeds, 1 if it fails - project-lint = pkgs.writeShellApplication { - name = "project-lint"; - meta = packages.project-lint.meta; - runtimeInputs = [pkgs.git pkgs.findutils packages.project-lint getAll getChanged]; - text = '' - IGNORE_UNCHANGED="''${1:-false}" - - # project lint expects a list of files to lint as arguments - if [ "$IGNORE_UNCHANGED" = "true" ]; then - getChanged | xargs -0 -r project-lint || (echo "failed to lint $(realpath .)" >&2 && exit 1) - else - getAll | xargs -0 -r project-lint || (echo "failed to lint $(realpath .)" >&2 && exit 1) - fi - ''; - }; - } - else throw "devShellConfig ${name} missing project-lint" - ) - // ( - if builtins.hasAttr "project-lint-semver" packages - then { - # wraps project-lint-semver script, which checks to make sure - # that the project's semantic version does not decrease from - # one commit to the next - # - # this script always prints the semantic version of each commit - # in the project to stderr e.g. - # - # version │ commit │ message - # ─────────┼───────────────────────────────┼──────────────────────────────── - # 0.0.0 │ 3b11f0b5ded8867c30e016b937e3e │ chore: add project-lint-semver - # │ 9bf1a3afb63 │ command - # 0.0.0 │ 46ac2a2488b8b72c49673e51a17d6 │ chore: set up recursive lint, - # │ 4a27b89c00d │ build, test - # 0.0.0 │ 1822aac4b623ef53fb087b88ee084 │ chore: make project-stub-nix - # │ 46121b1cd6d │ command - # 0.0.0 │ 31570c596591824dceed4192a3106 │ chore: set up zed - # │ 1f0317d3e6d │ configuration - # ... - # - # if the semantic version was bumped at HEAD, then this script - # prints the tag of the project i.e. - # - # path/to/project/vMAJOR.MINOR.PATCH - # - project-lint-semver = pkgs.writeShellApplication { - name = "project-lint-semver"; - meta = packages.project-lint-semver.meta; - runtimeInputs = [pkgs.git pkgs.glow packages.project-lint-semver]; - text = '' - # this script ALWAYS checks from HEAD commit - # all the way back to the base commit for the - # project - - SEMVER_LATEST="" - BUMPED=0 - - # $1 is semver - # return 0 if valid, 1 if invalid - # echoes $1 back if valid - function valid_semver(){ - if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - return 1 - fi - echo "$1" - } - - # $1 and $2 are semvers to compare - # returns 1 and echoes err msg if $1, $2 or both are invalid - # echo -1 if $1 less than $2 - # echo 0 if $1 equal to $2 - # echo 1 if $1 greater than $2 - function compare_semver(){ - - local left=() - local right=() - - if ! IFS="." read -ra left <<< "$(valid_semver "$1")" ; then - echo "error: \"$1\" is not a valid semantic version" - return 1 - fi - - if ! IFS="." read -ra right <<< "$(valid_semver "$2")" ; then - echo "error: \"$2\" is not a valid semantic version" - return 1 - fi - - if (( left[0] < right[0] )); then - echo -1 - return 0 - fi - - if (( left[0] > right[0] )); then - echo 1 - return 0 - fi - - if (( left[1] < right[1] )); then - echo -1 - return 0 - fi - - if (( left[1] > right[1] )); then - echo 1 - return 0 - fi - - if (( left[2] < right[2] )); then - echo -1 - return 0 - fi - - if (( left[2] > right[2] )); then - echo 1 - return 0 - fi - - echo 0 - return 0 - } - - function get_semver(){ - local sha="''${1:-}" - local sv - - if [ -z "$sha" ]; then - echo "expected a commit hash, received \"\"" - return 1 - fi - - if ! sv=$(project-lint-semver "$sha"); then - echo "$sv" - return 1 - fi - - echo "$sv" - } - - commits=() - ERR_SV="" - SV_PREV="" - - while IFS= read -r sha; do - if ! SV=$(get_semver "$sha"); then - ERR_SV="$SV" - break - fi - - if [ -n "$SV_PREV" ]; then - BUMPED=$(compare_semver "$SV_PREV" "$SV") - - if (( BUMPED < 0 )); then - ERR_SV="semantic version out of order: ''${SV_PREV} less than ''${SV}" - break - fi - fi - - SV_PREV="$SV" - - commits+=("$SV_PREV") - commits+=("$sha") - commits+=("$(git log -1 --pretty=%s "''$sha")") - - done < <(git rev-list HEAD -- .) - - SEMVER_LATEST="''${commits[0]}" - - if (( ''${#commits[@]} < 4 )); then - # only ONE commit, bumped must be 0 - BUMPED=0 - fi - - # Loop through commits array in groups of 3 - # - # Array structure: - # ┌─────────┬─────┬─────┬─────────┬─────┬─────┬─────────┬─────┬─────┐ - # │ semver1 │sha1 │msg1 │ semver2 │sha2 │msg2 │ semver3 │sha3 │msg3 │ - # └─────────┴─────┴─────┴─────────┴─────┴─────┴─────────┴─────┴─────┘ - # [0] [1] [2] [3] [4] [5] [6] [7] [8] - # - # Loop iterations: - # i=0: semver="''${commits [0]}", sha="''${commits [1]}", msg="''${commits [2]}" - # i=3: semver="''${commits [3]}", sha="''${commits [4]}", msg="''${commits [5]}" - # i=6: semver="''${commits [6]}", sha="''${commits [7]}", msg="''${commits [8]}" - - COMMITS_TABLE="" - - # Loop through commits array in groups of 3 - for (( i = 0; i < ''${#commits[@]}; i += 3 )); do - semver="''${commits[i]}" - sha="''${commits[i+1]}" - msg="''${commits[i+2]}" - - # Format each row as markdown table - COMMITS_TABLE+="| $semver | $sha | $msg |"$'\n' - done - - glow <<- EOF >&2 - | version | commit | message | - |:--------|:-------|:--------| - $COMMITS_TABLE - EOF - - if [ -n "$ERR_SV" ]; then - echo "^^^^" >&2 - echo "$ERR_SV" >&2 - fi - - if (( BUMPED > 0 )); then - echo "$(git rev-parse --show-prefix)''${SEMVER_LATEST}" - fi - - if [ -n "$ERR_SV" ]; then - exit 1 - fi - ''; - }; - } - else throw "devShellConfig ${name} missing project-lint-semver" - ) - // ( - if builtins.hasAttr "project-build" packages - then { - # wraps the project build script. - # - # accepts $1 with "true" or "false", defaults to "false" - # - # $1="true": build CHANGED files in project. this includes - # any uncommitted change - # - # $1="false": build ALL files in project - # - # exits 0 if build succeeds, 1 if it fails - project-build = pkgs.writeShellApplication { - name = "project-build"; - meta = packages.project-build.meta; - runtimeInputs = [pkgs.git pkgs.findutils packages.project-build getAll getChanged]; - text = '' - IGNORE_UNCHANGED="''${1:-false}" - - # project-build expects a list of files to build as arguments - if [ "$IGNORE_UNCHANGED" = "true" ]; then - getChanged | xargs -0 -r project-build || (echo "failed to build $(realpath .)" >&2 && exit 1) - else - getAll | xargs -0 -r project-build || (echo "failed to build $(realpath .)" >&2 && exit 1) - fi - ''; - }; - } - else throw "devShellConfig ${name} missing project-build" - ) - // ( - if builtins.hasAttr "project-test" packages - then { - # wraps the project test script. - # - # accepts $1 with "true" or "false", defaults to "false" - # - # $1="true": test CHANGED files in project. this includes - # any uncommitted change - # - # $1="false": test ALL files in project - # - # Does not invoke the project test script if nothing has changed. - # Prints path to test artifacts, such as coverage reports, to stdout. - project-test = pkgs.writeShellApplication { - name = "project-test"; - meta = packages.project-test.meta; - runtimeInputs = [pkgs.git pkgs.findutils packages.project-test getAll getChanged]; - text = '' - IGNORE_UNCHANGED="''${1:-false}" - - # project-test expects a list of files to test as arguments - if [ "$IGNORE_UNCHANGED" = "true" ]; then - getChanged | xargs -0 -r project-test || (echo "failed to test $(realpath .)" >&2 && exit 1) - else - getAll | xargs -0 -r project-test || (echo "failed to test $(realpath .)" >&2 && exit 1) - fi - ''; - }; - } - else throw "devShellConfig ${name} missing project-test" - ) - ); - listBins = package: builtins.map (p: p.name) (builtins.filter (dirent: dirent.value != "directory") (pkgs.lib.attrsToList (builtins.readDir "${package}/bin"))); - hasBins = package: pkgs.lib.pathExists "${package}/bin" && (builtins.length (listBins package) > 0); - filterPackagesWithBins = packages: builtins.filter (package: hasBins package) packages; - writeCommandDescription = package: - ( - if builtins.length (listBins package) == 1 - then '' - `${package.name}` - '' - else '' - ${builtins.concatStringsSep ", " (builtins.map (bin: "`${bin}`") (listBins package))} - '' - ) - + '' - > ${package.meta.description} - - ''; - writeCommandDescriptions = packages: - map (package: writeCommandDescription package) (filterPackagesWithBins packages); - makeDevShell = devShellConfig: pkgs: - pkgs.mkShell { - # make the packages available in the dev shell - packages = with pkgs; - [coreutils glow] - ++ builtins.attrValues ( - # read the packages in devShellConfig, get the project-lint, project-build, project-test packages and wrap them before re-emitting them into the list of packages - wrappedPackages devShellConfig - ); - shellHook = let - commandDescriptions = writeCommandDescriptions (builtins.attrValues (wrappedPackages devShellConfig)); - in - # run any hooks specific to this dev shell - devShellConfig.shellHook - # ... and then print the list of available commands with their descriptions - + '' - ${pkgs.glow}/bin/glow <<-'EOF' >&2 - ${builtins.concatStringsSep "\n" commandDescriptions} - EOF - ''; - }; - devShells = - (builtins.listToAttrs ( - map (config: { - name = config.name; - value = makeDevShell config pkgs; - }) - validDevShellConfigs - )) - // { - default = let - p = - [ - (import ./config-vscode.nix {inherit pkgs;}) - (import ./config-zed.nix {inherit pkgs;}) - (import ./install-git-hooks.nix {inherit pkgs;}) - ] - ++ (import ./stub-project.nix {inherit pkgs;}) - ++ builtins.map (cmd: - pkgs.writeShellApplication { - name = "${cmd}-all"; - meta = { - description = "${cmd} all projects"; - }; - runtimeInputs = [ - (import - ./recurse.nix - { - inherit pkgs; - steps = [cmd]; - }) - ]; - text = '' - IGNORE_UNCHANGED="''${1:-"true"}" - recurse "$IGNORE_UNCHANGED" - ''; - }) ["project-lint" "project-lint-semver" "project-build" "project-test"]; - commandDescriptions = writeCommandDescriptions p; - in - pkgs.mkShell { - packages = [pkgs.glow pkgs.git] ++ p; - shellHook = '' - if [ ! -d .git ] - then - echo "no .git/ found, are you in the root of the repository?" >&2 - exit 1 - fi - - project-install-vscode-configuration - project-install-zed-configuration - project-install-git-hooks - - ${pkgs.glow}/bin/glow <<-'EOF' >&2 - ${builtins.concatStringsSep "\n" commandDescriptions} - EOF - ''; - }; - }; -in {inherit validDevShellConfigs makeDevShell devShells;} -# -# LANGUAGE-SPECIFIC DEVELOPMENT SHELLS -# -# This nix expression builds specialized development shells, one for -# each language-* folder -# -# each project in this monorepo uses exactly ONE dev shell -# i.e. -# -# nix project ......... nix dev shell -# -# go project ......... go dev shell -# -# typescript typescript -# project ......... dev shell -# -# each dev shell sets up the dev tools you need to -# work in its respective language -# -# projects/ -# |-- flake.nix <---. -# : | -# | imports -# '-- .config/ | -# | | -# |- devShell.nix <-- imports --, -# | | -# |- importFromLanguageFolder.nix <----------, -# : | -# : imports -# : | -# | -, | -# |-- language-nix/ | | -# | | | | -# | : | | -# | | | | -# | '-- devShell.nix | | -# | | | -# |-- language-go/ | | -# | | +---------' -# | : | -# | | | -# | '-- devShell.nix | -# | | -# '-- language-typescript/ | -# | | -# '-- devShell.nix | -# -' -# -# all languages are added to the root flake.nix's development -# shells. -# -# To use a development shell, you can run nix develop ./# -# e.g. `nix develop ./#nix` to load the `language-nix` dev shell or -# `nix develop ./#go` to load the `language-go` dev shell -# -# When you stub a project, using one of the project-stub-* -# commands, the new project includes an .envrc file that -# loads the project's language's dev shell -# i.e. -# -# ,---._____ -# stub-project-nix ----> | project | _________ -# | +-------> / .envrc | -# '_________' | | -# | use | -# | ../#nix | -# |_________| -# -# ,---._____ -# stub-project-go ----> | project | _________ -# | +-------> / .envrc | -# '_________' | | -# | use | -# | ../#go | -# |_________| -# -# ,---._____ -# stub-project- ----> | project | _________ -# typescript | +-------> / .envrc | -# '_________' | | -# | use | -# | ../#typescript -# |_________| -# -# WHY DEV SHELLS -# -# Project tooling is the catch-22 of learning a new language. You -# need a DEEP understanding of a language in order to set up its -# tooling correctly, but you CAN'T gain a deep understanding of the -# language without first trying it out! Nix dev shells install -# project tooling for you, so you can get straight to learning. -# Every time you use a nix dev shell, you skip over the 3+ weeks -# of work you would have needed to spend to get to "hello world" -# -# Dev shells scale across the projects in the monorepo. All projects -# of a language use the SAME EXACT VERSION and CONFIGURATION of -# the project tools. This eliminates version-mismatch bugs from -# the codebase. -# -# HOW TO SET UP A LANGUAGE-SPECIFIC DEV SHELL -# -# Each language-specific folder contains a devShell.nix. This -# nix file must contain -# the following nix expression -# -# { pkgs ? import {}}: let -# devShellConfig = { -# packages = [ -# (pkgs.writeShellApplication { -# name = "project-lint"; -# meta = { -# description = "..." # description of what gets linted -# }; -# runtimeInputs = with pkgs; [ -# ... # packages used to lint project files -# ]; -# text = '' -# for file in "$@"; do # $@ is the list of files that have -# # changed since previous commit -# ... # command used to lint project files -# done -# ''; -# }) -# -# (pkgs.writeShellApplication { -# name = "project-build"; -# meta = { -# description = "..." # description of what gets built -# }; -# runtimeInputs = with pkgs; [ -# for file in "$@"; do # $@ is list of files that have changed -# # since previous commit -# ... # packages used to build project files -# done -# ]; -# text = '' -# -# ... # command used to build project files -# # command used to print project files -# # to stdout, separated by null bytes -# ''; -# }) -# -# (pkgs.writeShellApplication { -# name = "project-test"; -# meta = { -# description = "..." # description of what gets tested -# }; -# runtimeInputs = with pkgs; [ -# ... # packages used to test project files -# ]; -# text = '' -# ... # command used to test project files -# ''; -# }) -# -# (pkgs.writeShellApplication { -# name = "project-*"; # any other project-specific script -# meta = { # -# description = "..." # MAKE SURE YOU PREPEND "project-" -# }; # TO THE NAME OF ANY BUILD SCRIPT -# runtimeInputs = with pkgs; [ # this makes it easy to tab-complete -# ... # all project-* specific commands -# ]; -# text = '' -# ... -# ''; -# }) -# ... # any other packages that need to be -# # available in the dev environment -# ]; -# shellHook = '' # any commands you want to run on -# ... # entry into the project environment -# ''; # (e.g. dependency installation -# # or cleanup commands) -# } -# in -# devShellConfig -# -# this devShell.nix composes the contents of the language-specific devShell.nix: -# ________________________ ________________________ -# / devShell.nix | / language-* | -# / | / devShell.nix | -# | ----------------------- | | ----------------------- | -# | packages | | packages | -# | project-lint <------ wrapped by ------ project-lint | -# | | | | -# | project-build <---- wrapped by ------ project-build | -# | | | | -# | project-test <----- wrapped by ------ project-test | -# | | | | -# | ... <---- directly imported into ----- ... | -# | | | | -# | shellHook <----- runs before -------- shellHook | -# |_________________________| |_________________________| -# -# -# Every dev shell provides the following commands: -# -# project-lint -# project-build -# project-test -# -# While the command names do not vary across dev shells, their implementations do. -# These commands provide git hooks and CI a common interface for running project-specific tools. - diff --git a/.config/language-nix/.envrc b/.config/language-nix/.envrc deleted file mode 100644 index fb193de..0000000 --- a/.config/language-nix/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake ../../#nix diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 838047e..e018909 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -131,8 +131,7 @@ jobs: git log -1 --pretty=%B "$commit" > commit_message.txt ./result/bin/lintCommit commit_message.txt - echo "✅ Commit $commit passed validation" - echo "" + echo "✅ Commit $commit passed validation" >&2 done # Semantic version validation job @@ -146,59 +145,22 @@ jobs: fetch-depth: 0 - uses: DeterminateSystems/nix-installer-action@main - - name: Build recurse.nix script with project-lint-semver step only - run: | - # Get nixpkgs directly from flake inputs - nix-build .config/recurse.nix --arg pkgs "import (builtins.getFlake \"path:$(pwd)\").inputs.nixpkgs {}" --arg steps '["project-lint-semver"]' - - name: Lint semantic versions for each commit run: | # Exit at first failure set -e - # we have to lint semantic versions at EVERY commit in a push - # to ensure that projects that were DELETED between base and - # HEAD have valid semantic versions - - # Get all commits from GitHub event - COMMITS=$(echo '${{ toJson(github.event.commits) }}' | jq -r '(. // []) | .[].id') + # project lint semver command already iterates from HEAD all the way to BASE, + # accumulating packages that may have been deleted along the way. No need to + # iterate backward through commits here. - # Handle empty pushes - if [[ -z "$COMMITS" ]]; then - echo "No commits to validate" - exit 0 + if ! nix develop --command project-lint-semver --all; then + echo "❌ Commit $commit failed project-lint-semver" >&2 + exit 1 + else + echo "✅ Commit $commit passed project-lint-semver" >&2 fi - # Loop through each commit - for commit in $COMMITS; do - echo "============================================================" - echo "🔍 Linting semantic versions: $commit" - echo "📝 Message: $(git log -1 --pretty=%B $commit)" - echo "📅 Date: $(git log -1 --pretty=%ad --date=iso $commit)" - echo "🧑‍💻 Author: $(git log -1 --pretty=%an $commit)" - echo "============================================================" - - # Check out this specific commit - git checkout -q $commit - - # Run the recurse script which will lint semantic versions - echo "🔍 Running semantic version validation on projects..." - temp_stderr=$(mktemp) - ./result/bin/recurse 2>"$temp_stderr" - exit_code=$? - while IFS= read -r line; do - echo " $line" >&2 - done < "$temp_stderr" - rm "$temp_stderr" - if [ $exit_code -ne 0 ]; then exit $exit_code; fi - - echo "✅ Commit $commit passed semantic version validation" - echo "" - done - - # Return to the original branch/commit - git checkout -q ${{ github.sha }} - # Lint job - only needs to run on one platform since it's not platform-specific lint-projects: name: Lint Projects @@ -213,11 +175,6 @@ jobs: fetch-depth: 0 - uses: DeterminateSystems/nix-installer-action@main - - name: Build recurse.nix script with project-lint step only - run: | - # Get nixpkgs directly from flake inputs - nix-build .config/recurse.nix --arg pkgs "import (builtins.getFlake \"path:$(pwd)\").inputs.nixpkgs {}" --arg steps '["project-lint"]' - - name: Lint each commit run: | # Exit at first failure @@ -244,24 +201,14 @@ jobs: # Check out this specific commit git checkout -q $commit - # Run the recurse script which will lint - echo "🔍 Running lint on projects..." - temp_stderr=$(mktemp) - ./result/bin/recurse 2>"$temp_stderr" - exit_code=$? - while IFS= read -r line; do - echo " $line" >&2 - done < "$temp_stderr" - rm "$temp_stderr" - if [ $exit_code -ne 0 ]; then exit $exit_code; fi - - echo "✅ Commit $commit passed project linting" - echo "" + if ! nix develop --command project-lint --changed; then + echo "❌ Commit $commit failed project-lint" >&2 + exit 1 + else + echo "✅ Commit $commit passed project-lint" >&2 + fi done - # Return to the original branch/commit - git checkout -q ${{ github.sha }} - # Build and test job - needs to run on all platforms build-test: name: Build & test on ${{ matrix.os-name }} (${{ matrix.platform }}) @@ -293,11 +240,6 @@ jobs: fetch-depth: 0 - uses: DeterminateSystems/nix-installer-action@main - - name: Build recurse.nix script with project-build and project-test steps - run: | - # Get nixpkgs directly from flake inputs - nix-build .config/recurse.nix --arg pkgs "import (builtins.getFlake \"path:$(pwd)\").inputs.nixpkgs {}" --arg steps '["project-build" "project-test"]' - - name: Build and test each commit run: | # Exit at first failure @@ -324,20 +266,19 @@ jobs: # Check out this specific commit git checkout -q $commit - # Run the recurse script which will build and test echo "🏗️ Running build and test on ${{ matrix.platform }}..." - temp_stderr=$(mktemp) - ./result/bin/recurse 2>"$temp_stderr" - exit_code=$? - while IFS= read -r line; do - echo " $line" >&2 - done < "$temp_stderr" - rm "$temp_stderr" - if [ $exit_code -ne 0 ]; then exit $exit_code; fi - - echo "✅ Commit $commit passed build and test on ${{ matrix.os-name }} (${{ matrix.platform }})" - echo "" - done - # Return to the original branch/commit - git checkout -q ${{ github.sha }} + if ! (nix develop --command project-build --changed); then + echo "❌ Commit $commit failed project-build on ${{ matrix.os-name }} (${{ matrix.platform }}" >&2 + exit 1 + else + echo "✅ Commit $commit passed project-build on ${{ matrix.os-name }} (${{ matrix.platform }}" >&2 + fi + + if ! (nix develop --command project-test --changed); then + echo "❌ Commit $commit failed project-test on ${{ matrix.os-name }} (${{ matrix.platform }}" >&2 + exit 1 + else + echo "✅ Commit $commit passed project-test on ${{ matrix.os-name }} (${{ matrix.platform }}" >&2 + fi + done diff --git a/flake.nix b/flake.nix index d78d610..3e622ea 100644 --- a/flake.nix +++ b/flake.nix @@ -62,16 +62,23 @@ }); in { # Schemas tell Nix about the structure of your flake's outputs - schemas = flake-schemas.schemas; - makeDevShell = forEachSupportedSystem ({pkgs}: (import ./.config/devShell.nix {inherit pkgs;}).makeDevShell); - validDevShellConfigs = forEachSupportedSystem ({pkgs}: (import ./.config/devShell.nix {inherit pkgs;}).validDevShellConfigs); + # https://determinate.systems/blog/flake-schemas/#defining-your-own-schemas + schemas = flake-schemas.schemas // { + nixVersion = { + version = 1; + doc = "The nix version required to run this flake"; + type = "string"; + }; + }; + + # nixVersion specifies the nix version needed to run this flake + nixVersion = "2.33.1"; # Development environments devShells = forEachSupportedSystem ( - {pkgs}: let - languageDevShells = (import ./.config/devShell.nix {inherit pkgs;}).devShells; - in - languageDevShells + {pkgs}: { + default = (import .config/dev-shell.nix {inherit pkgs;}).default; + } ); }; } From eadeda806f6f34e4b4882fd1d2d43867064af786 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 21 Feb 2026 15:08:38 -0500 Subject: [PATCH 22/30] chore: update .gitignore --- .config/.gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.config/.gitignore b/.config/.gitignore index 2537957..5d75a65 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -1,12 +1,10 @@ * !language-nix -!.envrc !.gitignore !commitlint-config.nix !config-vscode.nix !importFromLanguageFolder.nix !config-zed.nix -!devShell.nix !dev-shell.nix !CONTRIBUTE.md !install-git-hooks.nix From 1ef0ada062790b756a87f66b05c586c864a18777 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 21 Feb 2026 15:27:36 -0500 Subject: [PATCH 23/30] chore: make config-vscode-.nix --- .config/.gitignore | 1 + .config/config-vscode-nix.nix | 22 ++++++++++++++++++++++ .config/config-vscode.nix | 21 ++++++++++++++------- 3 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 .config/config-vscode-nix.nix diff --git a/.config/.gitignore b/.config/.gitignore index 5d75a65..8cae000 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -3,6 +3,7 @@ !.gitignore !commitlint-config.nix !config-vscode.nix +!config-vscode-nix.nix !importFromLanguageFolder.nix !config-zed.nix !dev-shell.nix diff --git a/.config/config-vscode-nix.nix b/.config/config-vscode-nix.nix new file mode 100644 index 0000000..c3fb329 --- /dev/null +++ b/.config/config-vscode-nix.nix @@ -0,0 +1,22 @@ +{pkgs ? import {}}: { + vscodeSettings = { + "nix.enableLanguageServer" = true; + "nix.serverPath" = "${pkgs.nixd}/bin/nixd"; + "nix.serverSettings" = { + "nixd" = { + "formatting" = { + "command" = [ + "${pkgs.alejandra}/bin/alejandra" + ]; + }; + }; + }; + }; + vscodeExtensions = { + "recommendations" = [ + "jnoortheen.nix-ide" + ]; + }; + vscodeLaunch = {}; + vscodeTasks = {}; +} diff --git a/.config/config-vscode.nix b/.config/config-vscode.nix index a0af102..a986f61 100644 --- a/.config/config-vscode.nix +++ b/.config/config-vscode.nix @@ -1,6 +1,14 @@ { pkgs ? import {}, - vscodeConfigs ? (import ./importFromLanguageFolder.nix {inherit pkgs;}).importConfigVscode, + vscodeConfigs ? + map (configVscodeDirent: (import ./${configVscodeDirent.name} {inherit pkgs;})) ( + import ./match-dirent.nix { + pkgs = pkgs; + from = ./.; + matchDirentName = name: (builtins.match "^config-vscode-.*\.nix$" name) != null; + matchDirentType = type: (builtins.match "^regular$" type) != null; + } + ), }: let validVscodeConfigs = builtins.map (vsc: if @@ -83,10 +91,9 @@ }; in project-install-vscode-configuration -# HOW TO SET UP A LANGUAGE-SPECIFIC VSCODE CONFIGURATION +# HOW TO SET UP A TOOL-SPECIFIC VSCODE CONFIGURATION # -# Each language-specific folder contains a configVscode.nix. This -# nix file must contain the following nix expression: +# create a config-vscode-.nix in this directory, with the following: # # { pkgs ? import {} }: { # vscodeSettings = { @@ -120,11 +127,11 @@ in # }; # } # -# this configVscode.nix merges the contents of all language-specific configVscode.nix: +# this config-vscode.nix merges the contents of all config-vscode-.nix: # # ________________________ ________________________ -# / .vscode/ | / language-* | -# / settings.json | / configVscode.nix | +# / .vscode/ | / config-vscode- | +# / settings.json | / .nix | # | ----------------------- | | ----------------------- | # | { | | vscodeSettings = { | # | "nix.enable": true,<---- merged ------ "nix.enable" = true; | From b5c89b6841371b42c08c32050d19268ef98dbf67 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 21 Feb 2026 15:36:12 -0500 Subject: [PATCH 24/30] chore: make config-zed-.nix --- .config/.gitignore | 1 + .config/config-zed-nix.nix | 34 ++++++++++++++++++++++++++++++++++ .config/config-zed.nix | 16 ++++++++++++---- 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 .config/config-zed-nix.nix diff --git a/.config/.gitignore b/.config/.gitignore index 8cae000..54c6e05 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -6,6 +6,7 @@ !config-vscode-nix.nix !importFromLanguageFolder.nix !config-zed.nix +!config-zed-nix.nix !dev-shell.nix !CONTRIBUTE.md !install-git-hooks.nix diff --git a/.config/config-zed-nix.nix b/.config/config-zed-nix.nix new file mode 100644 index 0000000..00d68fa --- /dev/null +++ b/.config/config-zed-nix.nix @@ -0,0 +1,34 @@ +{pkgs ? import {}}: { + zedSettings = { + "auto_install_extensions" = { + "Nix" = true; + }; + "languages" = { + "Nix" = { + "language_servers" = [ + "nixd" + "!nil" + ]; + "formatter" = { + "external" = { + "command" = "${pkgs.alejandra}/bin/alejandra"; + "arguments" = [ + "--quiet" + "--" + ]; + }; + }; + }; + }; + "lsp" = { + "nixd" = { + # see: https://zed.dev/docs/configuring-languages + "binary" = { + "ignore_system_version" = false; + "path" = "${pkgs.nixd}/bin/nixd"; + }; + }; + }; + }; + zedDebug = {}; # there are no debuggers for nix +} diff --git a/.config/config-zed.nix b/.config/config-zed.nix index e250ed1..dfa24cb 100644 --- a/.config/config-zed.nix +++ b/.config/config-zed.nix @@ -1,6 +1,14 @@ { pkgs ? import {}, - zedConfigs ? (import ./importFromLanguageFolder.nix {inherit pkgs;}).importConfigZed, + zedConfigs ? + map (configZedDirent: (import ./${configZedDirent.name} {inherit pkgs;})) ( + import ./match-dirent.nix { + pkgs = pkgs; + from = ./.; + matchDirentName = name: (builtins.match "^config-zed-.*\.nix$" name) != null; + matchDirentType = type: (builtins.match "^regular$" type) != null; + } + ), }: let validZedConfigs = builtins.map (zc: if (builtins.isAttrs zc) && (builtins.hasAttr "zedSettings" zc) && (builtins.hasAttr "zedDebug" zc) @@ -112,9 +120,9 @@ }; in project-install-zed-configuration -# HOW TO SET UP A LANGUAGE-SPECIFIC ZED CONFIGURATION +# HOW TO SET UP A TOOL-SPECIFIC ZED CONFIGURATION # -# Each language-specific folder contains a configZed.nix. This +# create a config-zed-.nix in the current directory. This # nix file must contain the following nix expression: # # { pkgs ? import {} }: { @@ -149,7 +157,7 @@ in # }; # use {} if no debugger for language # } # -# this configZed.nix merges the contents of all language-specific configZed.nix: +# this config-zed.nix merges the contents of all config-zed-.nix: # # ________________________ ________________________ # / .zed/ | / language-* | From 648dbb881e5503876e435ed65c1bd9e7b1f2509c Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 21 Feb 2026 15:37:17 -0500 Subject: [PATCH 25/30] chore: delete language-nix folder we no longer need language-* folders because now we just put all config in the .config folder, with no nesting --- .config/importFromLanguageFolder.nix | 29 ------- .config/language-nix/.gitignore | 5 -- .config/language-nix/configVscode.nix | 22 ----- .config/language-nix/configZed.nix | 34 -------- .config/language-nix/devShell.nix | 114 -------------------------- 5 files changed, 204 deletions(-) delete mode 100644 .config/importFromLanguageFolder.nix delete mode 100644 .config/language-nix/.gitignore delete mode 100644 .config/language-nix/configVscode.nix delete mode 100644 .config/language-nix/configZed.nix delete mode 100644 .config/language-nix/devShell.nix diff --git a/.config/importFromLanguageFolder.nix b/.config/importFromLanguageFolder.nix deleted file mode 100644 index 2b10ec6..0000000 --- a/.config/importFromLanguageFolder.nix +++ /dev/null @@ -1,29 +0,0 @@ -# -# import configVscode.nix, configZed.nix, devShell.nix from language-* subfolders -# -{pkgs ? import {}}: let - # Get all language directories - configContents = builtins.readDir ./.; - languageDirs = - builtins.filter - (name: pkgs.lib.hasPrefix "language-" name) - (pkgs.lib.attrNames (pkgs.lib.filterAttrs (name: type: type == "directory") configContents)); - - getExistingFiles = configFile: - builtins.filter (path: builtins.pathExists path.file) - (map (dir: { - language = pkgs.lib.removePrefix "language-" dir; - file = ./. + "/${dir}/${configFile}"; - }) - languageDirs); - - # Get all existing paths for each config type - importConfigVscode = map (f: import f.file {inherit pkgs;}) (getExistingFiles "configVscode.nix"); - importConfigZed = map (f: import f.file {inherit pkgs;}) (getExistingFiles "configZed.nix"); - importDevShell = map ( - f: - (import f.file {inherit pkgs;}) // {name = f.language;} - ) (getExistingFiles "devShell.nix"); -in { - inherit importConfigVscode importConfigZed importDevShell; -} diff --git a/.config/language-nix/.gitignore b/.config/language-nix/.gitignore deleted file mode 100644 index 426c05f..0000000 --- a/.config/language-nix/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -* -!.gitignore -!configZed.nix -!configVscode.nix -!devShell.nix diff --git a/.config/language-nix/configVscode.nix b/.config/language-nix/configVscode.nix deleted file mode 100644 index c3fb329..0000000 --- a/.config/language-nix/configVscode.nix +++ /dev/null @@ -1,22 +0,0 @@ -{pkgs ? import {}}: { - vscodeSettings = { - "nix.enableLanguageServer" = true; - "nix.serverPath" = "${pkgs.nixd}/bin/nixd"; - "nix.serverSettings" = { - "nixd" = { - "formatting" = { - "command" = [ - "${pkgs.alejandra}/bin/alejandra" - ]; - }; - }; - }; - }; - vscodeExtensions = { - "recommendations" = [ - "jnoortheen.nix-ide" - ]; - }; - vscodeLaunch = {}; - vscodeTasks = {}; -} diff --git a/.config/language-nix/configZed.nix b/.config/language-nix/configZed.nix deleted file mode 100644 index 00d68fa..0000000 --- a/.config/language-nix/configZed.nix +++ /dev/null @@ -1,34 +0,0 @@ -{pkgs ? import {}}: { - zedSettings = { - "auto_install_extensions" = { - "Nix" = true; - }; - "languages" = { - "Nix" = { - "language_servers" = [ - "nixd" - "!nil" - ]; - "formatter" = { - "external" = { - "command" = "${pkgs.alejandra}/bin/alejandra"; - "arguments" = [ - "--quiet" - "--" - ]; - }; - }; - }; - }; - "lsp" = { - "nixd" = { - # see: https://zed.dev/docs/configuring-languages - "binary" = { - "ignore_system_version" = false; - "path" = "${pkgs.nixd}/bin/nixd"; - }; - }; - }; - }; - zedDebug = {}; # there are no debuggers for nix -} diff --git a/.config/language-nix/devShell.nix b/.config/language-nix/devShell.nix deleted file mode 100644 index 6a144a1..0000000 --- a/.config/language-nix/devShell.nix +++ /dev/null @@ -1,114 +0,0 @@ -{pkgs ? import {}}: let - devShellConfig = { - packages = [ - # make nix package manager available in the dev env - pkgs.nix - # receives a newline-separated list of files to lint - (pkgs.writeShellApplication - { - name = "project-lint"; - meta = { - description = "lint all .nix files"; - }; - runtimeInputs = with pkgs; [ - alejandra - gnugrep - findutils - ]; - text = '' - # Filter arguments to only .nix files and pass to alejandra - printf '%s\0' "$@" | grep -z '\.nix$' | xargs -0 -r alejandra -c - ''; - }) - (pkgs.writeShellApplication { - name = "project-lint-semver"; - meta = { - description = "ensure the semantic version of a nix flake increases over time"; - runtimeInputs = with pkgs; [ - git - ]; - }; - text = '' - SHA="''${1:-}" - FIRST_LINE="" - PARSED_SEMVER="0.0.0" - - function parse_semver() { - if [[ "$FIRST_LINE" =~ ^#[[:space:]]+([0-9]+\.[0-9]+\.[0-9]+) ]]; then - PARSED_SEMVER="''${BASH_REMATCH[1]}" - fi - } - - # Get relative path from git root to current directory - RELATIVE_PATH=$(git rev-parse --show-prefix) - FLAKE_PATH="''${RELATIVE_PATH}flake.nix" - - if [ -n "$SHA" ]; then - # Get first line of flake.nix at specific SHA without changing working directory - if git cat-file -e "$SHA:$FLAKE_PATH" 2>/dev/null; then - FIRST_LINE=$(git show "$SHA:$FLAKE_PATH" | head -n1) - else - echo "No flake.nix found at SHA $SHA, using $PARSED_SEMVER" >&2 - FIRST_LINE="" - fi - else - FIRST_LINE=$(head -n1 flake.nix 2>/dev/null || echo "") - fi - - parse_semver - - echo "$PARSED_SEMVER" - ''; - }) - (pkgs.writeShellApplication - { - name = "project-build"; - meta = { - description = "build the default package in the project's flake.nix"; - }; - runtimeInputs = with pkgs; [ - coreutils - fd - nix - ]; - text = '' - # Run nix build and capture output - if [ ! -f "flake.nix" ]; then - echo "no flake.nix in ''${PWD}. Nothing to build" >&2 - exit 0 - fi - if ! nix build; then - echo "error" >&2 - exit 1 - fi - - # nix build will always output result* symlinks e.g. result/, result-dev/, result-docs/ ... - # print absolute path to each, split paths by null bytes - fd --max-depth 1 --type l "result*" -0 --absolute-path - ''; - }) - # run all checks in the current project's flake - (pkgs.writeShellApplication - { - name = "project-test"; - meta = { - description = "run all checks in a project's flake.nix"; - }; - runtimeInputs = with pkgs; [ - nix - ]; - text = '' - if [ ! -f "flake.nix" ]; then - echo "no flake.nix ''${PWD}, nothing to flake check" >&2 - exit 0 - fi - - echo "🧪 Running nix flake check..." >&2 - nix flake check - ''; - }) - ]; - shellHook = ''''; - }; -in - devShellConfig From 51e34e0515d2df89b0c6a683d1d31d4b5b7d9a06 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 21 Feb 2026 15:38:00 -0500 Subject: [PATCH 26/30] chore: update .gitignore --- .config/.gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.config/.gitignore b/.config/.gitignore index 54c6e05..38001e0 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -1,10 +1,8 @@ * -!language-nix !.gitignore !commitlint-config.nix !config-vscode.nix !config-vscode-nix.nix -!importFromLanguageFolder.nix !config-zed.nix !config-zed-nix.nix !dev-shell.nix From 4bfe8a8abbeac19d6e6ee5bfce87df711f93c9dc Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sat, 21 Feb 2026 20:37:31 -0500 Subject: [PATCH 27/30] chore: update documentation --- .config/CONTRIBUTE.md | 38 ++-------- CONTRIBUTE.md | 164 ++++++++++++++++++++++++++++-------------- 2 files changed, 115 insertions(+), 87 deletions(-) diff --git a/.config/CONTRIBUTE.md b/.config/CONTRIBUTE.md index 4cc1953..ad5d053 100644 --- a/.config/CONTRIBUTE.md +++ b/.config/CONTRIBUTE.md @@ -1,37 +1,9 @@ -The .config folder contains all of the code needed to support projects of various languages +The .config folder contains all of the code needed to run commands defined in manifest files, such as `package.json`, `go.mod`, `cargo.toml`, `flake.nix` -In this monorepo, we assume that each project is written in ONE language. +To add support for a manifest file, see [dev-shell.nix](./dev-shell.nix) line ~332 -To add support for projects in a new language, create a `language-*/` folder containing: +commands use specific versions of dev tools: e.g. nix 2.33.1, go 1.26, etc. -``` -language-go/ - | - |-- .envrc # direnv integration: use flake ../../#go - | - |-- .gitignore # exclude everything except .nix files - | - |-- devShell.nix # project-lint, project-build, project-test commands - | - |-- configVscode.nix # LSP, formatter, extension settings for VSCode - | - |-- configZed.nix # LSP, formatter, extension settings for Zed - | - |-- stubProject.nix # script to scaffold new projects - | - '-- templateProject/ # files copied by stubProject.nix -``` +To register a tool version, see [tool.nix](./tool.nix) line ~153 -Each nix expression points editors to nix-installed dev tools rather than -system-installed ones, ensuring consistent tooling across machines. - -**Reference Documentation:** - -| File | Documentation | Example | -| ------------------ | ------------------------------------------------------ | -------------------------------------------------------------- | -| `.envrc` | use `flake ../../#nix` | [language-nix/.envrc](language-nix/.envrc) | -| `.gitignore` | exclude all except `.nix` files | [language-nix/.gitignore](language-nix/.gitignore) | -| `devShell.nix` | see [devShell.nix](devShell.nix) lines 617-722 | [language-nix/devShell.nix](language-nix/devShell.nix) | -| `configVscode.nix` | see [configVscode.nix](configVscode.nix) lines 119-189 | [language-nix/configVscode.nix](language-nix/configVscode.nix) | -| `configZed.nix` | see [configZed.nix](configZed.nix) lines 146-213 | [language-nix/configZed.nix](language-nix/configZed.nix) | -| `stubProject.nix` | see [stubProject.nix](stubProject.nix) | [language-nix/stubProject.nix](language-nix/stubProject.nix) | +The config folder also contains commands to stub new projects. To add support for stubbing a new project, see [stub-project.nix](./stub-project.nix) line ~214 diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index b600231..791cbc8 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -56,51 +56,70 @@ Nix-direnv automatically loads these dev tools to your $PATH, when you `cd` into ## Develop -`cd` into the root of the repository. `nix-direnv` will load `.envrc`, which will install git hooks, dev tools, and print a list of helper commands. If you are developing in [vscode](https://code.visualstudio.com/download) or [zed](https://zed.dev), `nix-direnv` will also configure your editor settings and extensions. +`cd` into the root of the repository. `nix-direnv` will load `.envrc`, which will load the dev shell in [`flake.nix`](./flake.nix). This dev shell will +- install git hooks +- install dev tools, and load them into your $PATH +- automatically install EVERY project's dependencies +- symlink a [.vscode](https://code.visualstudio.com/download) configuration folder +- symlink a [.zed](https://zed.dev) configuration folder -If you have installed [nix](https://docs.determinate.systems) and nix-direnv, you should see the following output: +If you have installed [nix](https://docs.determinate.systems) and nix-direnv, you should see output like the following: ``` ✅ linked /nix/store/a6154vsavsldv23wwdwgb5q1hx7kly78-vscodeConfiguration to ./.vscode ✅ linked /nix/store/8s2f27ikvyxqf8mdzs4aa3p5jaa9cr05-zedConfiguration to ./.zed -✅ linked commit-msg hook -✅ linked pre-push hook +✅ commit-msg hook replaced +✅ pre-push hook already linked - project-install-vscode-configuration + project-lint - │ install .vscode/ configuration folder, if .vscode/ is not already present. - │ Automatically run when this shell is opened. + recurse through the working directory and subdirectories, linting all + projects that have a flake.nix - project-install-zed-configuration + • use flag --changed to skip projects that have not changed in the latest + commit + + project-lint-semver + + recurse through the working directory and subdirectories, validating the + semantic version of projects that have a flake.nix - │ install .zed/ configuration folder, if .zed/ is not already present. - │ Automatically run when this shell is opened + • use flag --changed to skip projects that have not changed in the latest + commit - project-install-git-hooks + project-build - │ Install commit-msg and pre-push hooks in this project. Automatically run - │ when - │ this shell is opened + recurse through the working directory and subdirectories, building projects + that have a flake.nix - project-stub-nix + • use flag --changed to skip projects that have not changed in the latest + commit - │ Stub a nix project + project-test - project-lint-all + recurse through the working directory and subdirectories, testing projects + that have a flake.nix - │ project-lint all projects + • use flag --changed to skip projects that have not changed in the latest + commit - project-lint-semver-all + project-install-vscode-configuration + + symlink the .vscode configuration folder into the root of this repository. + Automatically run when this shell starts + + project-install-zed-configuration - │ project-lint-semver all projects + symlink the .zed configuration folder into the root ofthis repository. + Automatically run when this shell starts - project-build-all + nix - │ project-build all projects + the version of nix to use in the current working directory - project-test-all + project-stub-nix_v2.33.1 - │ project-test all projects + Stub a project with nix_v2.33.1 ``` > [!TIP] @@ -112,21 +131,20 @@ That's it! There's no super-complicated, error prone setup. No asking "what vers ### Repository Structure: -This monorepo is split into several projects. Each project has a language, and bootstraps the dev tools you need to code in that language. E.g. some projects use go, and ship with the `go` command suite. Other projects use typescript and ship with `bun`. - -To load the dev tools, `cd` into the project. `nix-direnv` will read the `.envrc` in the project, and add the devtools to your $PATH. Then, it will print a list of commands you can use to lint, build and test the project. - -All projects will contain a `project-lint`, `project-build`, and `project-test` command. Some projects may contain additional commands. These helper commands wrap the language-specific commands required to lint, build and test the project. +This monorepo is split into several projects. A project is any folder that contains a **manifest file** e.g. a `deno.json`, `go.mod`, `flake.nix`. +- Project folders can be nested. +- A project folder can contain more than one manifest file. +- You can run any of the `project-*` commands in any project folder. The command will automatically detect the manifest files in the folder _and_ _subfolders_, and run the corresponding command. E.g. `project-lint` in a folder with a `deno.json` and a `go.mod` will run both `deno-lint` and `golangci-lint`. > [!TIP] > **Why not directly run the language-specific lint, build and test commands?** -> You *can* run the project's language-specific lint, build and test commands! The project-lint, project-build, and project-test commands give the [git hooks](.config/installGitHooks.nix), [`project-lint-all`](.config/devShell.nix), [`project-lint-semver-all`](.config/devShell.nix), [`project-build-all`](.config/devShell.nix), [`project-test-all`](.config/devShell.nix), and [github actions](.github/workflows/push.yml) a project-agnostic command to call when they [recurse](.config/recurse.nix) through the projects in the monorepo. +> You *can* run the project's language-specific lint, build and test commands! The project-lint, project-build, and project-test commands give the [git hooks](.config/installGitHooks.nix), [`project-lint`](.config/dev-shell.nix), [`project-lint-semver`](.config/dev-shell.nix), [`project-build`](.config/dev-shell.nix), [`project-test`](.config/dev-shell.nix), and [github actions](.github/workflows/push.yml) a project-agnostic command to call when they recurse through the projects in the monorepo. All projects contain a `project-lint-semver` command. This command checks the semantic version of a project, and verifies that its semantic versions do not decrease over the course of the project's git history. **This repository will prevent you from pushing obviously broken code to Github.** -This repository [automatically runs](./.config/installGitHooks.nix) the `project-lint`, `project-lint-semver`, `project-build`, and `project-test` commands in every project in the *latest* commit, [before you push](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) commits to any remote, using the [pre-push hook](.config/installGitHooks.nix). It also runs these hooks on *every* commit you push in [github actions](.github/workflows/push.yml), every time you push to github. +This repository [automatically runs](./.config/install-git-hooks.nix) the `project-lint`, `project-lint-semver`, `project-build`, and `project-test` commands in every project in the *latest* commit, [before you push](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) commits to any remote, using the [pre-push hook](.config/installGitHooks.nix). It also runs these hooks on *every* commit you push in [github actions](.github/workflows/push.yml), every time you push to github. > [!WARN] > This repository will NOT prevent you from pushing semantically incorrect code to Github. Your job is to test and bench your code thoroughly *before* you push. Don't make it the next programmer's responsibility to find out and fix your code's nasty side effects. @@ -158,15 +176,15 @@ This repository [automatically runs](./.config/installGitHooks.nix) the `project |- flake.lock | editor configuration folders. | -' | -, - |- go-starter | + |- .../ |- folders that contain projects + | -' + | -, + |- go.work | | | - |- typescript- |- example projects. Do not modify these. - | starter | + |- cargo.toml |- workspace configuration files | | + |- deno.json | | -' - | -, - |- infrastructure |- project that contains NixOS, NixOps, and Kubernetes code - | -' used to configure and deploy development hardware : : ``` @@ -224,11 +242,44 @@ I will only merge a PR with a project if ### How to make a new project (if you *really* need to) -`cd` to the root of this repository, and run one of the `project-stub-*` commands. For example, to create a nix project, run the `project-stub-nix` command. The command [will create a new project directory](./.config/stubProject.nix), complete with a development environment and all of the files needed to develop the project. +`cd` to the root of this repository, and run one of the `project-stub-*` commands. For example, to create a nix project, run the `project-stub-nix_v2.33.1` command. The command [will create a new project directory](./.config/stub-project-nix_v2.33.1.nix), complete with a development environment and all of the files needed to develop the project. > [!TIP] > If you need to make a project that combines multiple languages, or has a complicated build process, you can create a nix project. The nix project lets you define your own custom lint, build and test scripts. +### How to use one project in another +Go, deno, python and nix all support workspaces. Workspaces _alias_ package imports to their corresponding local directory: + +Each of these languages includes a workspace configuration file: + +| language | workspace configuration file | +|:---------|:----------------------------------------------------------------------------| +| Go | [`go.work`](https://go.dev/doc/tutorial/workspaces) | +| Deno | [`deno.json`](https://docs.deno.com/runtime/fundamentals/workspaces/) | +| Python | [`pyproject.toml`](https://docs.astral.sh/uv/concepts/projects/workspaces/) | +| Nix | [`overlay.nix`](https://nixos.wiki/wiki/Overlays) | + +When you run a `project-stub-*` command, it automatically adds your new project to all applicable workspaces. (e.g. a project with a go.mod will be added to [`./go.work`](./go.work), a project with both a `deno.json` and a `pyproject.toml` will be added to _both_ [`deno.json`](./deno.json) and [`pyproject.toml`](./pyproject.toml)) + +> [!TIP] +> A single project can contain manifests for more than one language, and can therefore be added to more than one language's workspace. + +> [!TIP] +> Nix doesn't actually support workspaces + +### How to delete a project: + +TRY NOT TO DO THIS. You cannot erase a project from git history, and you cannot un-tag it if it has been semantically versioned. However, you CAN delete manifest files out of folders, or even move them from one folder to another. + +- If you delete a manifest file, the project will no longer be auto-tagged for release, because there is no manifest off of which to determine its semantic version. + +>[!WARN] +> You cannot delete a project in the HEAD of a branch. If you delete a project, you must author another commit on top of it. This is because the `project-lint-semver` script will detect the deletion of the project as a change. Then, it will try to read the project manifest, fail to find it, and error. However, if you author another commit, the commit will hide the deletion from the `project-lint-semver` script +> If you version a project, and then delete it, you can never re-create it. This is because the `project-lint-semver` script will detect the deletion, and fail to read the manifest at the commit in which it was deleted. + +>[!WARN] +> If you move a manifest from one directory to another, the `project-lint-semver` script will detect it as a new project. + ### How to structure your code: Each file should contain ONE class, interface, or function. If your file exceeds 500 lines of code, your class, interface or function is probably doing too much. @@ -241,15 +292,18 @@ In general, [follow design patterns](https://refactoring.guru) and the conventio - [typescript style guide](https://google.github.io/styleguide/tsguide.html) - [go style guide](https://go.dev/doc/effective_go) +- [python style guide](https://peps.python.org/pep-0008/) If you do NOT follow these style guides, I will reject your PR and show you how to change your code so that it matches. -Organize your code according to import scope. No code should ever import from a parent folder +Organize the code within each project according to import scope. No code should ever import from a parent folder ``` GOOD: import code from ./path/to/code +import code from "some-package" +import code from "@incremental.design/some/project" <- aliases to ./some/project BAD: @@ -258,6 +312,10 @@ import code from ../../../code When your code only imports from child folders, it prevents import cycles, and makes it easy for other contributors to reason about the dependencies. +> [!TIP] +> It is OK to import code from another project, as long as you don't import from the relative path to the project. Use the project's package manager name instead. + + ### How to author a commit: See [`commitlint-config.nix`](.config/commitlintConfig.nix) @@ -328,23 +386,20 @@ You must [sign all commits](https://docs.github.com/en/authentication/managing-c ## Lint: -- Run `project-lint-all` in the root of this repo to run ALL tests - -- Run `project-lint` inside a project to run the project's tests. +- Run `project-lint` in the root of this repo to run ALL tests in all projects in this directory and its subdirectories. +- Run `project-lint --changed` in the root of this repo to run tests in the projects that changed between the previous commit and HEAD in this directory and its subdirectories. ## Build: -- Run `project-build-all` in the root of this repo to run ALL tests - -- Run `project-build` inside a project to run the project's tests. +- Run `project-build` in the root of this repo to run ALL tests in all projects in this directory and its subdirectories. +- Run `project-build --changed` in the root of this repo to build the projects that changed between the previous commit and HEAD in this directory and its subdirectories. ## Test: -- Run `project-test-all` in the root of this repo to run ALL tests - -- Run `project-test` inside a project to run the project's tests. +- Run `project-test` in the root of this repo to run ALL tests in all projects in this directory and its subdirectories. +- Run `project-test --changed` in the root of this repo to run tests in the projects that changed between the previous commit and HEAD in this directory and its subdirectories. -Every exported function should have a unit test attached to it. +**Make sure every exported function has a deterministic test** ## Document: @@ -364,10 +419,11 @@ Once github actions tags the commits, it will publish projects to the registries e.g. -| manifest file | registry | -|:---------------|:--------------------------------------------------------| -| `flake.nix` | [flakehub](https://flakehub.com/flakes) | -| `package.json` | [npm](https://www.npmjs.com/) | -| `go.mod` | [pkg.go.dev](https://pkg.go.dev/about#adding-a-package) | +| manifest file | registry | +|:----------------|:--------------------------------------------------------| +| `flake.nix` | [flakehub](https://flakehub.com/flakes) | +| `deno.json` | [jsr](https://www.jsr.io/) | +| `go.mod` | [pkg.go.dev](https://pkg.go.dev/about#adding-a-package) | +| `pyproject.toml`| [pypi](https://pypi.org) | You cannot manually publish a project from your terminal. Only Github Actions has the keys to package registries. From 10a22582f0e734eb31e53ecf5bc686aa6851cc08 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Thu, 26 Feb 2026 09:29:41 -0500 Subject: [PATCH 28/30] chore: project-lint-semver output jsonl of semvers --- .config/dev-shell.nix | 64 ++++-- .config/parse-manifest-flake_nix.nix | 282 ++++++++++++++++++--------- 2 files changed, 240 insertions(+), 106 deletions(-) diff --git a/.config/dev-shell.nix b/.config/dev-shell.nix index 0132c34..509d8cd 100644 --- a/.config/dev-shell.nix +++ b/.config/dev-shell.nix @@ -43,16 +43,11 @@ fi } - # scoop the first --changed and --all flags passed into args - ARGS=() - for arg in "$@"; do if [[ "$arg" = "--changed" && "$CHANGED" = 0 ]]; then CHANGED=1 elif [[ "$arg" = "--all" && "$ALL" = 0 ]]; then ALL=1 - else - ARGS+=("$arg") fi done @@ -66,8 +61,9 @@ fi run_cmd(){ - local cmdPath="$1" - local manifestName="$2" + local workdir="$1" + local cmdPath="$2" + local manifestName="$3" local returnCode=0 local manifestPath="" local d="" @@ -76,17 +72,37 @@ # iterate over manifests from subdirs all the way back up to PWD for (( i = "''${#all_manifests[@]}" - 1; i >= 0; i-- )); do + cd "$workdir" manifestPath=$(realpath "''${all_manifests[$i]}") d=$(dirname "$manifestPath") if (( ALL==1 )) || did_change "$d"; then cd "$d" - "$cmdPath" "''${ARGS[@]}" || returnCode=1 + + echo "" >&2 + echo "| " >&2 + echo "| command attempting:" >&2 + echo "| ''${d}" >&2 + echo "| ''${cmdPath} ''${*:4}" >&2 + echo "' " >&2 + + "$cmdPath" "''${@:4}" || returnCode=1 fi if (( returnCode == 1)); then - echo "${cmdName} failed at $manifestPath" >&2 + echo "| " >&2 + echo "| command failed:" >&2 + echo "| " >&2 + echo "| ''${d}" >&2 + echo "| ''${cmdPath} ''${*:4}" >&2 + echo "' " >&2 return 1 + else + echo "| " >&2 + echo "| command succeeded" >&2 + echo "| ''${d}" >&2 + echo "| ''${cmdPath} ''${*:4}" >&2 + echo "' " >&2 fi done } @@ -96,7 +112,7 @@ ${ builtins.concatStringsSep " && \\\n" (( - map (manifest: ''run_cmd "${getCmd cmdName manifest.value}" "${manifest.name}" "$@"'') (manifestsForCmd cmdName) + map (manifest: ''run_cmd "$WORKDIR" "${getCmd cmdName manifest.value}" "${manifest.name}" "$@"'') (manifestsForCmd cmdName) ) ++ ["EXIT_CODE=0"]) } @@ -154,22 +170,36 @@ cd "$WORKDIR" glow <<-'EOF' >&2 - # project-lint + # project-lint [ --changed | --all ] recurse through the working directory and subdirectories, linting all projects that have a ${builtins.concatStringsSep ", " (map (manifest: manifest.name) (manifestsForCmd "project-lint"))} - use flag --changed to skip projects that have not changed in the latest commit - # project-lint-semver + # project-lint-semver [ --changed | --all ] [[FROM TO]] recurse through the working directory and subdirectories, validating the semantic version of projects that have a ${builtins.concatStringsSep ", " (map (manifest: manifest.name) (manifestsForCmd "project-lint-semver"))} - - use flag --changed to skip projects that have not changed in the latest commit + - limit the range of commits to lint with [[FROM TO]] + - provide a pair of commit hashes semantic version commits between and including the two hashes + - omit the pair of commit hashes to lint from BASE to HEAD + - note: order matters! FROM should be a commit hash that is before TO, and both hashes should be on the same branch + - use flag --changed to omit projects who's manifest file has NOT changed in the directly preceding commit + + This command outputs a JSON-line-separated list of records, where each record contains a hash, path and version, a : e.g. + + { "hash": 57b8f5ac4840b415dd3d4319d8e7493a4345eaef, "path": "path/to/project-manifest-file", "version": "MAJOR.MINOR.PATCH"} + { "hash": 57b8f5ac4840b415dd3d4319d8e7493a4345eaef, "path": "path/to/other-project-manifest-file", "version": "MAJOR.MINOR.PATCH"} + { "hash": 81268c1a87ef56822fc02ccfbb0621418964dc12, "path": "path/to/project-manifest-file", "version": "MAJOR.MINOR.PATCH"} + + - This list contains the version of every package in a manifest, at each commit in the linted range + - if you pass --changed, this list will only print the semantic versions of packages within manifests + that have changed in the current commit - # project-build + # project-build [ --changed | --all ] recurse through the working directory and subdirectories, building projects that have a ${builtins.concatStringsSep ", " (map (manifest: manifest.name) (manifestsForCmd "project-build"))} - use flag --changed to skip projects that have not changed in the latest commit - # project-test + # project-test [ --changed | --all ] recurse through the working directory and subdirectories, testing projects that have a ${builtins.concatStringsSep ", " (map (manifest: manifest.name) (manifestsForCmd "project-test"))} - use flag --changed to skip projects that have not changed in the latest commit @@ -178,7 +208,7 @@ symlink the .vscode configuration folder into the root of this repository. Automatically run when this shell starts # project-install-zed-configuration - symlink the .zed configuration folder into the root ofthis repository. Automatically run when this shell starts + symlink the .zed configuration folder into the root of this repository. Automatically run when this shell starts ${builtins.concatStringsSep '' @@ -364,6 +394,8 @@ in {inherit project-lint project-lint-semver project-build project-test default; # runtimeInputs = [...]; # bins needed to run lint-semver commands # text = '' # ... # the lint-semver commands +# # this command must print json lines as follows: +# # [ "commit": "", "path": "", "version": " | | | null"] # ''; # } # ) diff --git a/.config/parse-manifest-flake_nix.nix b/.config/parse-manifest-flake_nix.nix index 394432d..c1b1815 100644 --- a/.config/parse-manifest-flake_nix.nix +++ b/.config/parse-manifest-flake_nix.nix @@ -40,9 +40,38 @@ meta = { description = "lint all .nix files in current working directory, with alejandra"; }; - runtimeInputs = with pkgs; [alejandra]; + runtimeInputs = with pkgs; [alejandra git]; text = '' - alejandra "''${PWD}/*" "$@" || exit 1 + CHANGED=0 + ALL=0 + + ARGS=() + + for arg in "$@"; do + if [[ "$arg" == "--changed" ]]; then + CHANGED=1 + elif [[ "$arg" == "--all" ]]; then + ALL=1 + else + ARGS+=("$arg") + fi + done + + if (( ALL == 1 && CHANGED == 1 )); then + echo "cannot submit both --all and --changed" >&2 + exit 1 + elif (( CHANGED == 1 )); then + readarray -t changed < <(git log HEAD^..HEAD --name-only --pretty=format: -- '*.nix') + LEN_CHANGED="''${#changed[@]}" + + if (( LEN_CHANGED == 0 )); then + echo "no .nix files changed in ''${PWD}, nothing to lint." >&2 + exit 0 + fi + alejandra "''${changed[@]}" "''${ARGS[@]}" || exit 1 + else + alejandra "''${PWD}/*" "''${ARGS[@]}" || exit 1 + fi ''; }) (pkgs.writeShellApplication @@ -54,11 +83,102 @@ runtimeInputs = with pkgs; [git jq]; text = '' # usage - # project-lint-semver [ ] + # project-lint-semver [ --all | --changed ] [ ] # # run this command without any arguments to lint semver from HEAD to BASE # run this command with two commit hashes, and to compare semvers - # at the two hashes + # between the two hashes + # + # nix flakes are different from most other manifest files. Most manifest files contain ONE + # semantic version, but nix flakes can contain zero or more. + # + # A nix flake contains packages, each of which might have a semantic version + # + # e.g. + # _______ + # / flake.nix + # | | + # | | + # |___,___| + # | + # |- inputs + # | | + # | |- flake-schemas + # | | | + # | | '- url + # | | + # | '- nixpkgs + # | | + # | '- url + # | + # '- outputs + # | + # |- schemas + # | + # |- nixVersion + # | + # |- packages + # : | + # |- package-A + # | | + # | '- version + # | + # |- package-B + # | + # '- package-C + # | + # '- version + # + # The packages within a flake can vary from one commit to another. This script + # checks ALL commits within a range of commits, and gets the set of packages that + # existed for part or all of that range. Then, it verifies that the semantic version + # of each package that existed did not decrease in subsequent commits within that + # range. + # + # COMMIT + # | 0.0.9 < 0.1 + # * 83ebda... "2.1.1" "1.0.1" "0.0.9" < a package version cannot decrease in a + # | : : : | subsequent commit + # | : : : + # | : : package-C : + # | : : deleted : + # | : : : : + # * 45bd33... "2.1.1" "1.0.1" "0.1" "0.1" + # | : : : : + # | : package-B : : + # | : created : : + # | : : : + # | : : : | is filled in as 0.0.0 + # * aa3ee3... "2.1.0" "0.0.1" < a package cannot be versioned in one commit + # | : : : | and then unversioned in a subsequent commit + # | : package-C : + # | : package-B created : + # | : deleted : + # | : : : + # * bbee23... "2.1.0" "0.1" "0.1" + # | : : : + # | : : : + # | : : : + # | : : : + # | : : : + # * eabc21... "2.0.1" + # | : : : + # | package-A package-B package-D + # | created created created + # : ------^------ + # : a package can be + # created and then + # deleted in a + # subsequent commit + # and then even + # created again. + # Its semantic version + # is valid as long + # as it does not + # decrease in + # subsequent + # commits + if [ -n "$(git status --short)" ]; then echo "working directory contains uncommitted changes, cannot project-lint-semver. stash or discard changes and then try again" >&2 @@ -69,11 +189,17 @@ TO_HASH="" for arg in "$@"; do - if [[ "$arg" == "--all" || "$arg" == "--changed" ]]; then + if [[ "$arg" == "--all" && -z "$FROM_HASH" ]]; then continue - fi - - if [ -z "$FROM_HASH" ]; then + elif [[ "$arg" == "--all" ]]; then + echo "if --all is passed, it must appear before $FROM_HASH in args list" >&2 + exit 1 + elif [[ "$arg" == "--changed" && -z "$FROM_HASH" ]]; then + continue + elif [[ "$arg" == "--changed" ]]; then + echo "if --changed is passed, it must appear before $FROM_HASH in args list" >&2 + exit 1 + elif [ -z "$FROM_HASH" ]; then FROM_HASH="$arg" elif [ -z "$TO_HASH" ]; then TO_HASH="$arg" @@ -83,11 +209,20 @@ fi done - if [ -n "$FROM_HASH" ] && [ -z "$TO_HASH" ]; then - echo "you must provide two hashes to compare semantic versions at each" >&2 + if [[ -n "$FROM_HASH" && -z "$TO_HASH" ]]; then + echo "you must provide two hashes to lint semantic versions between them" >&2 exit 1 fi + FIRST_COMMIT="$(git rev-list --max-parents=0 HEAD)" + + if [ -z "$FROM_HASH" ] && [ -z "$TO_HASH" ]; then + FROM_HASH="$FIRST_COMMIT" # very first commit of all time + TO_HASH=$(git rev-parse HEAD) # latest commit on current branch + elif [[ "$FROM_HASH" != "$FIRST_COMMIT" ]]; then + FROM_HASH="''${FROM_HASH}^" # FROM_HASH parent, so that FROM_HASH is included in comparison + fi + function get_package_versions(){ nix eval .#packages --apply 'p: let isPackage = i: builtins.hasAttr "type" i && i.type == "derivation"; @@ -100,8 +235,9 @@ in formattedPackageVersions' 2>/dev/null || return 0 } - # given two lists of packages from get_package_versions, $1 and $2, iterate through each list, verifying that matching packages semvers decrease from $1 to $2, and accumulating - # unmatched packages into a returned list + # given two lists of packages from get_package_versions, $1 and $2, iterate through each list, + # verifying that matching packages semvers decrease from $1 to $2. Then, return the full outer + # join with right-side-preference function lint_package_semvers(){ if [ -z "$2" ]; then @@ -131,100 +267,66 @@ return 1 } - function fileExistsAtHash(){ - local hash="$1" - if ! git cat-file -e "''${hash}:flake.nix" 2>/dev/null; then - return 1 - fi + function render_packages_jsonl(){ + echo "$3" | jq -r . | jq -c --arg pwd "$2" --arg hash "$1" '.[] | {path: "\($pwd)#packages.\(.distribution).\(.name)", version: .version, hash: $hash}' } - # handle case where we lint semver from HEAD all the way to BASE - if [ -z "$FROM_HASH" ] && [ -z "$TO_HASH" ]; then + COMMITS_WITH_CHANGE=() + readarray -t COMMITS_WITH_CHANGE < <(git log "$FROM_HASH..$TO_HASH" --pretty=format:"%H" -- "''${PWD}/flake.nix") # only commits in which ./flake.nix changed + COMMITS_WITH_CHANGE_LEN="''${#COMMITS_WITH_CHANGE[@]}" - CURR_BRANCH=$(git rev-parse --abbrev-ref HEAD) + ALL_COMMITS=() + readarray -t ALL_COMMITS < <(git log "$FROM_HASH..$TO_HASH" --pretty=format:"%H") # all commits + ALL_COMMITS_LEN="''${#ALL_COMMITS[@]}" - readarray -t commits < <(git log --pretty=format:"%H" -- "''${PWD}/flake.nix") + CURR_BRANCH=$(git rev-parse --abbrev-ref HEAD) - LEN="''${#commits[@]}" + CHANGED_INDEX=0 + ALL_INDEX=0 - if (( LEN == 1 )); then - echo "''${PWD}/flake.nix has never changed since it was introduced in ''${commits[0]}" >&2 - exit 0 - elif (( LEN == 0 )) then - echo "''${PWD} has never had a flake.nix" >&2 - exit 0 - fi + ACC_PKGS="$(get_package_versions)" + CURR_PKGS="$ACC_PKGS" + RELPATH="$(git rev-parse --show-prefix)" - ACC_PKGS="" - CURR_PKGS="" - - for ((i=0; i/dev/null - - CURR_PKGS=$(get_package_versions) - - if [ -z "$ACC_PKGS" ]; then - ACC_PKGS="$CURR_PKGS" - else - if ! ACC_PKGS=$(lint_package_semvers "$ACC_PKGS" "$CURR_PKGS" 2>/dev/null); then - echo "project-lint-semver failed for ''${PWD}/flake.nix because package semantic versions decreased after ''${commits[i]}." >&2 - git -c advice.detachedHead=false checkout "$CURR_BRANCH" 2>/dev/null - exit 1 - fi - fi - done - git -c advice.detachedHead=false checkout "$CURR_BRANCH" 2>/dev/null - exit 0 - fi - # handle case where we compare flake.nix package semvers at two hashes - if ! fileExistsAtHash "$FROM_HASH"; then - echo "''${PWD}/flake.nix does not exist at ''${FROM_HASH}" >&2; - exit 1 - fi - - if ! fileExistsAtHash "$TO_HASH"; then - echo "''${PWD}/flake.nix does not exist at ''${TO_HASH}" >&2; - exit 1 - fi - - TO_PACKAGES="" - FROM_PACKAGES="" + for (( ALL_INDEX = 0; ALL_INDEX < ALL_COMMITS_LEN; ALL_INDEX++)); do - if ! git -c advice.detachedHead=false checkout "$TO_HASH" 2>/dev/null; then - echo "Failed to checkout ''${TO_HASH}" >&2; - exit 1 + if (( CHANGED_INDEX == COMMITS_WITH_CHANGE_LEN )); then + break + # the FIRST commit at which the flake.nix changed is always the commit at which it was created fi - TO_PACKAGES=$(get_package_versions 2>/dev/null) + if [[ "''${ALL_COMMITS[$ALL_INDEX]}" == "''${COMMITS_WITH_CHANGE[$CHANGED_INDEX]}" ]]; then + echo "| " >&2 + echo "| ''${COMMITS_WITH_CHANGE[$CHANGED_INDEX]} - ''${PWD}/flake.nix changed " >&2 + echo "| linting package semantic versions" >&2 + echo "' " >&2 - if ! git -c advice.detachedHead=false checkout "$FROM_HASH" 2>/dev/null; then - echo "Failed to checkout ''${FROM_HASH}" >&2; - exit 1 - fi + CHANGED_INDEX=$((CHANGED_INDEX + 1)) - FROM_PACKAGES=$(get_package_versions 2>/dev/null) - - if [ -z "$TO_PACKAGES" ] && [ -z "$FROM_PACKAGES" ]; then - echo "''${PWD}/flake.nix does not contain any packages at ''${TO_HASH} or ''${FROM_HASH}. Nothing to compare." >&2; - exit 0 - fi + git -c advice.detachedHead=false checkout "''${ALL_COMMITS[$ALL_INDEX]}" 2>/dev/null + CURR_PKGS="$(get_package_versions)" - if [ -z "$TO_PACKAGES" ]; then - echo "''${PWD}/flake.nix does not contain any packages at ''${TO_HASH}. Cannot compare to ''${FROM_HASH}" >&2; - exit 0 - fi + if ! ACC_PKGS="$(lint_package_semvers "$ACC_PKGS" "$CURR_PKGS")"; then + echo "package semvers decreased from ''${ALL_COMMITS[$ALL_INDEX]} to ''${ALL_COMMITS[0]}" >&2 + git -c advice.detachedHead=false checkout "$CURR_BRANCH" &>/dev/null + exit 1 + fi + else + echo "| " >&2 + echo "| ''${ALL_COMMITS[$ALL_INDEX]} - ''${PWD}/flake.nix did not change" >&2 + echo "' " >&2 + fi - if [ -z "$FROM_PACKAGES" ]; then - echo "''${PWD}/flake.nix does not contain any packages at ''${FROM_HASH}. Cannot compare to ''${TO_HASH}" >&2; - exit 0 - fi + render_packages_jsonl "''${ALL_COMMITS[$ALL_INDEX]}" "$RELPATH" "$CURR_PKGS" + done - if ! lint_package_semvers "$TO_PACKAGES" "$FROM_PACKAGES"; then - echo "lint_package_semvers failed from ''${FROM_HASH} to ''${TO_HASH}" >&2 - exit 1 - fi + for ((; ALL_INDEX < ALL_COMMITS_LEN; ALL_INDEX++)); do + echo "| " >&2 + echo "| ''${ALL_COMMITS[$ALL_INDEX]} - ''${PWD}/flake.nix did not exist, skipping" >&2 + echo "' " >&2 + done + git -c advice.detachedHead=false checkout "$CURR_BRANCH" &>/dev/null ''; }) (pkgs.writeShellApplication From aebc26751582e451b27aafe1fa35b65edb08d991 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sun, 22 Feb 2026 19:27:42 -0500 Subject: [PATCH 29/30] chore: remove recurse.nix --- .config/recurse.nix | 150 ----------------------- .github/workflows/tagMain.yml | 219 ++++++++++++++++++++++++---------- 2 files changed, 153 insertions(+), 216 deletions(-) delete mode 100644 .config/recurse.nix diff --git a/.config/recurse.nix b/.config/recurse.nix deleted file mode 100644 index 4f05a35..0000000 --- a/.config/recurse.nix +++ /dev/null @@ -1,150 +0,0 @@ -# recurse through the monorepo, linting, testing, building, and publishing every folder with an .envrc file in it -# -# this calls the lint, test, build and publish commands provided by a folder's respective .envrc -# -# ignore the root of the monorepo, when running this command, because root flake.nix also calls this command -# -# pass "false" as $1 to recurse through ALL projects, regardless of whether they have changed -# -# pass any additional arguments as $2... and they will be directly passed to steps as $1... -# -# if this script is built without any steps, it just prints the directories on which it would have run the steps -# to stdout -{ - pkgs ? import {}, - steps ? ["project-lint" "project-lint-semver" "project-build" "project-test"], -}: let - # remove any invalid steps, and preserve the order of steps - validSteps = builtins.filter (step: - builtins.elem step [ - "project-lint" - "project-build" - "project-test" - "project-lint-semver" - ]) - steps; - msg = - if builtins.length validSteps > 0 - then - builtins.concatStringsSep ", " (builtins.map ( - step: - if step == "project-test" - then "testing" - else if step == "project-lint" - then "linting" - else if step == "project-lint-semver" - then "linting semantic version of" - else "building" - ) - validSteps) - else ""; - recurse = pkgs.writeShellApplication { - name = "recurse"; - runtimeInputs = with pkgs; [ - fd - coreutils - git - direnv - glow - ]; - text = '' - if [ ! -d .git ]; then - echo "please run this script from the root of the monorepo" >&2 && exit 1 - fi - - IGNORE_UNCHANGED=''${1:-"true"} - - CWD=$(pwd) - - function check() { - local dir="$*" - cd "$dir" - - direnv allow - echo "${msg} $dir" >&2 - - # force rebuild of env flake - if [ -d ".direnv" ]; then - rm -rf ".direnv" - fi - - local failAt="" - - ${ - builtins.concatStringsSep "" ( - builtins.map ( - step: '' - if [ -z "$failAt" ] && ! direnv exec ./ ${step} "''${@:2}"; then - failAt="${step}" - fi - '' - ) - validSteps - ) - } - - # clear any .direnv so that other processes - # have a clean working dir - if [ -d ".direnv" ]; then - rm -rf ".direnv" - fi - - cd "$CWD" - - if [ -n "$failAt" ]; then - echo "error: ''${failAt} failed in ''${dir}" >&2 - return 1 - fi - } - - DIRS=() - - PROJECTS="projects that have changed" - - if [ "$IGNORE_UNCHANGED" = "true" ]; then - # Get all directories with .envrc files and check each for changes - while IFS= read -r -d "" envrc_file; do - envrc_dir="$(dirname "$envrc_file")" - # Check if anything changed in this directory between HEAD~1 and HEAD - if git diff --quiet HEAD~1 HEAD -- "$envrc_dir" && git diff --quiet HEAD -- "$envrc_dir"; then - # No changes in this directory - continue - else - # Directory has changes, add to array - if [ "$envrc_dir" != "$CWD" ]; then - DIRS+=("$envrc_dir") - fi - fi - done < <(fd --type f --hidden '.envrc' . --absolute-path --print0) - - else - while IFS= read -r -d "" envrc_file; do - envrc_dir="$(dirname "$envrc_file")" - if [ "$envrc_dir" != "$CWD" ]; then - DIRS+=("$envrc_dir") - fi - done < <(fd --type f --hidden '.envrc' . --absolute-path --print0) - PROJECTS="all projects" - fi - - - glow <<-EOF >&2 - ${msg} $PROJECTS: - - $(printf "%s\n" "''${DIRS[@]}") - - EOF - - # Process each directory - for dir in "''${DIRS[@]}"; do - if [ -n "$dir" ]; then - if ! check "$dir"; then - exit 1 - fi - fi - done - - ''; - }; -in - recurse diff --git a/.github/workflows/tagMain.yml b/.github/workflows/tagMain.yml index 72bd62e..761c607 100644 --- a/.github/workflows/tagMain.yml +++ b/.github/workflows/tagMain.yml @@ -2,11 +2,50 @@ name: Tag Main # Tag Main Pipeline Flow: # -# This workflow iterates through commits from -# merge base to HEAD. For each commit, it runs recurse -# which outputs tags that should be created, then -# creates and pushes them atomically. - +# ┌─────────────────────────────────────────┐ +# │ Push to main branch │ +# └────────────────┬────────────────────────┘ +# │ +# ▼ +# ┌─────────────────────────────────────────┐ +# │ 1. Check out repository │ +# │ 2. Generate GitHub App token │ +# └────────────────┬────────────────────────┘ +# │ +# ▼ +# ┌─────────────────────────────────────────┐ +# │ Run project-lint-semver on commits │ +# │ (from merge base to HEAD) │ +# └────────────────┬────────────────────────┘ +# │ +# ▼ +# ┌─────────────────────────────────────────┐ +# │ Parse semver output and deduplicate │ +# │ consecutive versions per package │ +# └────────────────┬────────────────────────┘ +# │ +# ▼ +# ┌─────────────────────────────────────────┐ +# │ For each package version change: │ +# │ Create tag: path@version at commit │ +# │ (with exponential backoff retry) │ +# └─────────────────────────────────────────┘ +# +# NOTE: THIS SCRIPT WILL FAIL IF THE BRANCH +# THAT YOU ARE MERGING INTO MAIN CONTAINS +# ANY CHANGES TO THE WORKFLOW FILES IN THE +# .github FOLDER. +# +# This is because github does not allow +# workflows to update the workflow +# configuration. +# +# Unfortunately, as of spring 2026 if you +# try to push a tag onto a branch that has +# a change to a workflow file, you will get +# a very unhelpful error 403. I spent about +# 12 hours debugging this issue. +# on: push: branches: [main] @@ -23,6 +62,9 @@ jobs: with: fetch-depth: 0 + - name: Fetch all branches + run: git fetch --all + - name: Generate GitHub App Token id: app-token uses: actions/create-github-app-token@v1 @@ -30,10 +72,6 @@ jobs: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - - name: Build recurse.nix script - run: | - nix-build .config/recurse.nix --arg pkgs "import (builtins.getFlake \"path:$(pwd)\").inputs.nixpkgs {}" --arg steps '["project-lint-semver"]' - - name: Collect and push tags id: collect-tags env: @@ -41,75 +79,124 @@ jobs: run: | set -e - echo "============================================================" - echo "🔍 Processing commits from merge base to HEAD" - echo "============================================================" - - MERGE_BASE=${{ github.event.before }} - COMMITS=$(git rev-list --reverse $MERGE_BASE..HEAD) - - if [[ -z "$COMMITS" ]]; then - echo "ℹ️ No commits to process" - exit 0 - fi + mangle_path() { + local path="$1" + + # Git-tag discoverable ecosystems (auto-index from Git tags) + + # Nix: FlakeHub auto-discovers from Git tags matching semver + if [[ "$path" =~ \#packages ]]; then + echo "$(dirname "$path")/${path#*\#packages.}/v" + # Go: pkg.go.dev auto-discovers from Git tags with format path/package/vX.Y.Z + elif [[ "$path" =~ go\.mod ]]; then + echo "$(dirname "$path")/v" + # + # Explicit CI/CD publish ecosystems (require manual publish) + # the tag format doesn't matter for these package managers, but we keep + # the format consistent for ease of readability + # + # Python: PyPI requires explicit upload via CI/CD + elif [[ "$path" =~ pyproject\.toml ]]; then + echo "$(dirname "$path")/v" + # Node.js/Deno: JSR requires explicit publish via deno publish + elif [[ "$path" =~ package\.json ]] || [[ "$path" =~ deno\.json ]]; then + echo "$(dirname "$path")/v" + # Rust: crates.io requires explicit publish via cargo publish + elif [[ "$path" =~ Cargo\.toml ]]; then + echo "$(dirname "$path")/v" + else + dirname "$path" + fi + } + + create_tag_at_hash() { + local path="$(mangle_path $1)" + local version="$2" + local sha="$3" + local initial_backoff="${4:-1}" + local tag="${path}${version}" + + if (( initial_backoff > 60 )); then + echo " ❌ Backoff seconds ($initial_backoff) exceeds maximum of 60" >&2 + return 1 + fi + + echo "Creating tag: $tag for commit: $sha" + + local attempt=1 + local backoff=$initial_backoff + + while (( attempt <= 7 )); do + if gh api --method POST repos/${{ github.repository }}/git/refs \ + -f ref="refs/tags/$tag" \ + -f sha="$sha" 2>&1; then + echo "✅ Created tag: $tag" + return 0 + fi - declare -a TAGS_TO_CREATE=() + # Creation failed, check if tag already exists + if gh api repos/${{ github.repository }}/git/refs/tags/$tag &>/dev/null; then + echo " ℹ️ Tag already exists: $tag" + return 0 + fi - for commit in $COMMITS; do - echo "============================================================" - echo "🔍 Processing commit: $commit" - echo "📝 Message: $(git log -1 --pretty=%B $commit)" - echo "============================================================" + # If this was the last attempt, fail + if (( attempt == 5 )); then + echo " ❌ Failed to create tag: $tag (after 5 attempts)" >&2 + return 1 + fi - git checkout -q $commit + echo " ❌ Failed to create tag: $tag (attempt $attempt/5)" >&2 + echo " ⏳ Retrying in ${backoff}s..." + sleep "$backoff" - echo "🏃 Running recurse for commit $commit..." + backoff=$((backoff * 2)) + (( attempt++ )) - # recurse outputs tags (one per line) - while IFS= read -r tag; do - if [ -n "$tag" ]; then - # Store both tag and commit sha in array (alternating: tag, sha, tag, sha, ...) - TAGS_TO_CREATE+=("$tag" "$commit") - echo " 📌 Collected tag: $tag for commit: $commit" + # Check if next backoff would exceed 60 seconds + if (( backoff > 60 )); then + echo " ❌ Backoff seconds ($backoff) would exceed maximum of 60" >&2 + return 1 fi - done < <(./result/bin/recurse) + done + } - echo "✅ Commit $commit processed" - echo "" - done + MERGE_BASE=${{ github.event.before }} + HEAD="$(git rev-parse HEAD)" - git checkout -q ${{ github.sha }} + if [[ "$MERGE_BASE" == "$HEAD" ]]; then + echo "ℹ️ No commits to process" + exit 0 + fi - if [ -L result ]; then - unlink result + if ! nix develop --command project-lint-semver $MERGE_BASE $HEAD | tee /tmp/semver_output.jsonl; then + echo "❌ project-lint-semver failed" >&2 + exit 1 fi - if [ ${#TAGS_TO_CREATE[@]} -gt 0 ]; then - echo "============================================================" - echo "📤 Creating ${#TAGS_TO_CREATE[@]} tags via API..." - echo "============================================================" + # we write to tmp and read back in so that we can catch error in project-lint-semver command - # Iterate through pairs: tag at index i, sha at index i+1 - for ((i=0; i<${#TAGS_TO_CREATE[@]}; i+=2)); do - tag="${TAGS_TO_CREATE[$i]}" - sha="${TAGS_TO_CREATE[$((i+1))]}" + jq -Rs 'split("\n") | map(select(length > 0) | fromjson) | reduce .[] as $item ({}; + .[$item.path] //= [] | + if (.[$item.path] | length) == 0 then + .[$item.path] += [{hash: $item.hash, version: $item.version}] + elif .[$item.path][-1].version != $item.version then + .[$item.path] += [{hash: $item.hash, version: $item.version}] + else + . + end + ) | [to_entries[] | .key as $path | .value[] | select(.version | length > 0) | {path: $path, hash: .hash, version: .version}]' /tmp/semver_output.jsonl > /tmp/package_versions.json - echo "Creating tag: $tag for commit: $sha" + cat /tmp/package_versions.json - if gh api repos/${{ github.repository }}/git/refs \ - -f ref="refs/tags/$tag" \ - -f sha="$sha" 2>&1; then - echo "✅ Created tag: $tag" - else - # Creation failed, check if tag already exists - if gh api repos/${{ github.repository }}/git/refs/tags/$tag &>/dev/null; then - echo " ℹ️ Tag already exists: $tag" - else - echo " ❌ Failed to create tag: $tag" >&2 - exit 1 - fi - fi - done - else - echo "ℹ️ No tags to create" + if [ ! -s /tmp/package_versions.json ]; then + echo "no tags to create" >&2 + exit 0 fi + + # traverse the map of tags tmp package versions.json , and create a tag for each version + + jq -r '.[] | "\(.path) \(.version) \(.hash)"' /tmp/package_versions.json | while read path version hash; do + path="$path" + create_tag_at_hash "$path" "$version" "$hash" 1 + done From aeac3c930b3a19ddcbe78d72f848a8ed211507f6 Mon Sep 17 00:00:00 2001 From: Ajay Ganapathy Date: Sun, 22 Feb 2026 19:28:46 -0500 Subject: [PATCH 30/30] chore: update .gitignore --- .config/.gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.config/.gitignore b/.config/.gitignore index 38001e0..245dfc6 100644 --- a/.config/.gitignore +++ b/.config/.gitignore @@ -11,7 +11,6 @@ !lint-commit.nix !parse-manifest-flake_nix.nix !match-dirent.nix -!recurse.nix !stub-project.nix !stub-project-nix_v2.33.1.nix !tool.nix