diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..18d8535 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,20 @@ +name: Checks +on: + push: + branches: [master] + pull_request: +jobs: + install-script: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: cachix/install-nix-action@v31 + - name: Run install.sh VM test + run: nix-build ./tests -A install-script-test.sandboxed + flake-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: cachix/install-nix-action@v31 + - name: Run nix flake check + run: nix flake check --keep-going diff --git a/flake.lock b/flake.lock index 9ee1bb8..0578d48 100644 --- a/flake.lock +++ b/flake.lock @@ -18,28 +18,7 @@ }, "root": { "inputs": { - "nixpkgs": "nixpkgs", - "treefmt": "treefmt" - } - }, - "treefmt": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1785945821, - "narHash": "sha256-NLSyTCW4K4ofhNBllt3omPasm6QpralXH1DBZOc91Dw=", - "owner": "numtide", - "repo": "treefmt-nix", - "rev": "ae7910970dddc408fe6ab1c8e4b277bb21d72dc0", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "treefmt-nix", - "type": "github" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index bd30081..691e293 100644 --- a/flake.nix +++ b/flake.nix @@ -1,14 +1,9 @@ { - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - treefmt = { - url = "github:numtide/treefmt-nix"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - outputs = { self, nixpkgs, ... }@inputs: + outputs = { self, nixpkgs }: let + inputs = import ./nix/tamal { bootstrap-nixpkgs = nixpkgs; }; overlays.default = import ./overlay.nix; eachSystem = f: nixpkgs.lib.genAttrs [ @@ -27,16 +22,20 @@ overlays = [ overlays.default ]; })); + treefmt = import inputs.treefmt; fmtOpts = { projectRootFile = "flake.lock"; programs = { nixpkgs-fmt.enable = true; shfmt.enable = true; + kdlfmt.enable = true; + yamlfmt.enable = true; mdformat.enable = true; }; - # The shfmt module only exposes indent_size/simplify, so append -ci - # (indent switch-case bodies, i.e. the `*)` patterns) to its args. - settings.formatter.shfmt.options = [ "-ci" ]; + settings.formatter = { + nixpkgs-fmt.excludes = [ "nix/tamal/*.nix" ]; + shfmt.options = [ "-ci" ]; + }; }; in { @@ -57,17 +56,16 @@ checks = eachSystem (pkgs: { treefmt-check = - ((import inputs.treefmt).evalModule pkgs fmtOpts).config.build.check ./.; + (treefmt.evalModule pkgs fmtOpts).config.build.check ./.; }); - formatter = eachSystem (pkgs: (inputs.treefmt.lib).mkWrapper pkgs fmtOpts); + formatter = eachSystem (pkgs: treefmt.mkWrapper pkgs fmtOpts); devShells = eachSystem (pkgs: { default = pkgs.mkShell { - inputsFrom = - [ - pkgs.git-kitten - ] ++ builtins.attrValues self.checks.${pkgs.stdenv.buildPlatform.system}; + inputsFrom = [ + pkgs.git-kitten + ] ++ builtins.attrValues self.checks.${pkgs.stdenv.buildPlatform.system}; packages = with pkgs; [ nil ]; }; }); diff --git a/nix/tamal/default.nix b/nix/tamal/default.nix new file mode 100644 index 0000000..ac9d9e9 --- /dev/null +++ b/nix/tamal/default.nix @@ -0,0 +1,116 @@ +/* +SPDX-FileCopyrightText: 2025–2026 toastal +SPDX-FileCopyrightText: 2026 Nixtamal contributors +SPDX-License-Identifier: ISC + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice & this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED “AS IS” & ISC DISCLAIMS ALL WARRANTIES WITH REGARD +TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY & +FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, +OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF +USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +OF THIS SOFTWARE. + +────────────────────────────────────────────────────────────────────────────── +┏┓╻+╻ ╱┏┳┓┏┓┏┳┓┏┓╻ +┃┃┃┃┗━┓╹┃╹┣┫┃┃┃┣┫┃ This file was generated by Nixtamal. +╹┗┛╹╱ ╹ ╹ ╹╹╹ ╹╹╹┗┛ Do not edit as it will be overwritten. +────────────────────────────────────────────────────────────────────────────── +*/ +{ + system ? builtins.currentSystem, + bootstrap-nixpkgs ? null, + bootstrap-nixpkgs-lock-name ? null, +}: + +let lock = builtins.fromJSON (builtins.readFile ./lock.json); in +assert (lock.v == "1.0.0"); +let + hash-token = { + "0" = "sha256"; + "1" = "sha512"; + "2" = "blake3"; + }; + + try-fetch = name: fetcher: + let + try-fetch' = failed-urls: url: urls: + let result = builtins.tryEval (fetcher url); in + if result.success then + result.value + else + let failed-urls' = [ url ] ++ failed-urls; in + if builtins.length urls <= 0 then + let fus = builtins.concatStringsSep " " failed-urls'; in + throw "Input 「${name}」fetchable @ [ ${fus} ]" + else + try-fetch' failed-urls' (builtins.head urls) (builtins.tail urls); + in + try-fetch' [ ]; + + builtin-fetch-tarball = {name, kind, hash}: + try-fetch name (url: + builtins.fetchTarball { + inherit url; + ${hash-token.${builtins.toString hash.al}} = hash.vl; + } + ) kind.ur kind.ms; + + builtin-to-input = name: input: + let k = builtins.head input.kd; in + if k == 1 then + builtin-fetch-tarball { + inherit name; + kind = builtins.elemAt input.kd 1; + hash = input.ha; + } + else + throw "Unsupported input kind “${builtins.toString k}”."; + + nixpkgs' = + if builtins.isNull bootstrap-nixpkgs then + builtin-to-input "nixpkgs-for-nixtamal" ( + if builtins.isString bootstrap-nixpkgs-lock-name then + lock.i.${bootstrap-nixpkgs-lock-name} + else + lock.i.nixpkgs-nixtamal or lock.i.nixpkgs + ) + else + bootstrap-nixpkgs; + + pkgs = import nixpkgs' {inherit system;}; + + inherit (pkgs) lib; + + fetch-zip = {name, kind, hash}: pkgs.fetchzip { + inherit name; + url = kind.ur; + hash = hash.vl; + } // lib.optionalAttrs (builtins.length kind.ms > 0) { urls = kind.ms; }; + + to-input = name: input: + let + k = builtins.head input.kd; + raw-input = + if k == 1 then + let + kind = builtins.elemAt input.kd 1; + fetch_time = kind.ft; + hash = input.ha; + in + if fetch_time == 0 then + fetch-zip {inherit name kind hash;} + else if fetch_time == 1 then + builtin-fetch-tarball {inherit name kind hash;} + else + throw "Unsupported fetch time ${fetch_time}." + else + throw "Unsupported input kind “${builtins.toString}”."; + in + raw-input; +in +builtins.mapAttrs to-input lock.i diff --git a/nix/tamal/lock.json b/nix/tamal/lock.json new file mode 100644 index 0000000..c60f8d4 --- /dev/null +++ b/nix/tamal/lock.json @@ -0,0 +1,7 @@ +{"v":"1.0.0" +,"i":{ +"nix-vm-test":{"kd":[1,{"ft":1,"ur":"https://github.com/numtide/nix-vm-test/archive/1a587212d2ac8b669c6c32499015f996506b6ba5.tar.gz","ms":[]}],"ha":{"al":0,"vl":"sha256-cq4YfNFGYzp0NItZP8tEC7xUI8OSgY4fj75AU/NSaPM="},"fv":"1a587212d2ac8b669c6c32499015f996506b6ba5","ps":[]} +,"treefmt":{"kd":[1,{"ft":1,"ur":"https://github.com/numtide/treefmt-nix/archive/ae7910970dddc408fe6ab1c8e4b277bb21d72dc0.tar.gz","ms":[]}],"ha":{"al":0,"vl":"sha256-NLSyTCW4K4ofhNBllt3omPasm6QpralXH1DBZOc91Dw="},"fv":"ae7910970dddc408fe6ab1c8e4b277bb21d72dc0","ps":[]} +} +,"p":{} +} diff --git a/nix/tamal/manifest.kdl b/nix/tamal/manifest.kdl new file mode 100644 index 0000000..33592ce --- /dev/null +++ b/nix/tamal/manifest.kdl @@ -0,0 +1,25 @@ +// ┏┓╻+╻ ╱┏┳┓┏┓┏┳┓┏┓╻ +// ┃┃┃┃┗━┓╹┃╹┣┫┃┃┃┣┫┃ Read the manpage: +// ╹┗┛╹╱ ╹ ╹ ╹╹╹ ╹╹╹┗┛ $ man nixtamal-manifest +version "1.0.0" +default-fetch-time eval +inputs { + treefmt { + archive { + url "https://github.com/numtide/treefmt-nix/archive/{{fresh_value}}.tar.gz" + } + fresh-cmd { + $ git ls-remote --heads "https://github.com/numtide/treefmt-nix.git" --refs "refs/heads/main" + | cut -f1 + } + } + nix-vm-test { + archive { + url "https://github.com/numtide/nix-vm-test/archive/{{fresh_value}}.tar.gz" + } + fresh-cmd { + $ git ls-remote --heads "https://github.com/numtide/nix-vm-test.git" --refs "refs/heads/main" + | cut -f1 + } + } +} diff --git a/tests/default.nix b/tests/default.nix new file mode 100644 index 0000000..8c978d6 --- /dev/null +++ b/tests/default.nix @@ -0,0 +1,99 @@ +let + /** Get a node from the flake lock file. + + getFlake' :: String -> Attrset */ + getFlake' = node: + let source = (builtins.fromJSON (builtins.readFile ../flake.lock)).nodes.${node}.locked; in + { + inherit (source) rev; + outPath = fetchTarball + ( + let inherit (source) owner repo rev narHash; in { + url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; + sha256 = narHash; + } + ); + }; +in +{ nixpkgs ? getFlake' "nixpkgs" }: +let + inputs = import ../nix/tamal { bootstrap-nixpkgs = nixpkgs; }; + pkgs = import nixpkgs { + overlays = [ (import "${inputs.nix-vm-test}/overlay.nix") ]; + }; +in +{ + install-script-test = pkgs.testers.nonNixOSDistros.ubuntu."24_04" { + # Mount the project source. + sharedDirs.gitKitten = { + source = "${../.}"; + target = "/mnt/git-kitten"; + }; + sharedDirs.kittenPkg = { + source = "${pkgs.kitty.kitten}"; + target = "/mnt/kitten-pkg"; + }; + + testScript = '' + vm.wait_for_unit("multi-user.target") + + # Run the installer into /usr/local (on the default PATH and MANPATH). + vm.succeed("PREFIX=/usr/local/bin sh /mnt/git-kitten/install.sh") + # Installing to a directory that is not on PATH warns on stderr. + out = vm.succeed("PREFIX=/opt/xyz/bin sh /mnt/git-kitten/install.sh 2>&1") + assert "not on your path" in out.lower(), f"expected PATH warning:\n{out}" + + # Installer results: binary + man page landed where expected. + vm.succeed("test -x /usr/local/bin/git-kitten") + vm.succeed("test -f /usr/local/share/man/man1/git-kitten.1") + + # Without `kitten` on PATH, git-kitten fails fast with exit 127. + # The `command -v kitten` guard runs before any argument parsing, so + # this must be checked before the real binary below is installed. + status, out = vm.execute("git kitten -h 2>&1") + assert status == 127, f"expected exit 127, got {status}:\n{out}" + assert "'kitten' not found" in out, f"expected 'kitten' not found message:\n{out}" + + # Symlink the kitten binary from the shared directory mount. + vm.succeed("ln -s /mnt/kitten-pkg/bin/kitten /usr/local/bin/kitten") + + # Check help output and man pages work correctly. + out = vm.succeed("git kitten -h") + assert "usage: git kitten diff" in out, f"expected usage text:\n{out}" + + out = vm.succeed("git kitten") + assert "usage: git kitten diff" in out, f"expected usage text:\n{out}" + + out = vm.succeed("git kitten diff -h") + assert "usage: git kitten diff" in out, f"expected usage text:\n{out}" + + out = vm.succeed("MANPAGER=cat git kitten --help | col -b") + assert "GIT-KITTEN" in out, f"expected man page title:\n{out}" + assert "SYNOPSIS" in out, f"expected man page SYNOPSIS section:\n{out}" + assert "EXAMPLES" in out, f"expected man page EXAMPLES section:\n{out}" + + # Use Ubuntu's pre-installed dash and git. + vm.succeed( + "git init -q /tmp/r && cd /tmp/r && " + "git config user.email t@t && git config user.name t && " + "printf 'a\n' > f && git add . && git commit -m c1" + ) + + # Check no-op output when there is no diff. + out = vm.succeed("cd /tmp/r && git kitten diff HEAD HEAD 2>&1") + assert "no differences between the given inputs" in out, f"expected no-differences message:\n{out}" + + # Failure cases. All three are usage errors with exit 2. + out = vm.fail("cd / && git kitten diff 2>&1") + assert "not inside a git repository" in out, f"expected not-a-repo message:\n{out}" + + out = vm.fail("git kitten frobnicate 2>&1") + assert "unknown subcommand 'frobnicate'" in out, f"expected unknown-subcommand message:\n{out}" + + vm.succeed("cd /tmp/r && touch x y") + out = vm.fail("cd /tmp/r && git kitten diff x y 2>&1") + assert "look like files, not git revisions" in out, f"expected two-plain-files message:\n{out}" + ''; + }; +} +