From 404d0e45b331716cba4e3358b0f88cef08977e24 Mon Sep 17 00:00:00 2001 From: Jon Rogers <67245+devnullvoid@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:41:31 -0400 Subject: [PATCH] Fix CI coverage and Nix build validation Fixes #172 --- .github/workflows/ci.yml | 2 +- .github/workflows/update-nix-hash.yml | 97 ++++++----------- AGENTS.md | 3 +- CHANGELOG.md | 4 + README.md | 17 +++ flake.lock | 8 +- flake.nix | 19 ++-- internal/tools/nixhash/update_test.go | 135 ++++++++++++++++++++++++ internal/tools/nixhash/workflow_test.go | 60 +++++++++++ scripts/update-nix-hash.sh | 77 ++++++++++++++ 10 files changed, 345 insertions(+), 77 deletions(-) create mode 100644 internal/tools/nixhash/update_test.go create mode 100644 internal/tools/nixhash/workflow_test.go create mode 100644 scripts/update-nix-hash.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e3c4fc..0944a1a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [master, develop] pull_request: - branches: [master] + branches: [master, develop] # Set default permissions for all jobs permissions: diff --git a/.github/workflows/update-nix-hash.yml b/.github/workflows/update-nix-hash.yml index 0a0bc8a4..32699bed 100644 --- a/.github/workflows/update-nix-hash.yml +++ b/.github/workflows/update-nix-hash.yml @@ -2,96 +2,67 @@ name: Update Nix Vendor Hash on: pull_request: + branches: [master, develop] paths: - 'go.mod' - 'go.sum' + - '.go-version' + - 'flake.nix' + - 'flake.lock' + - 'scripts/update-nix-hash.sh' + - 'internal/tools/nixhash/**' + - '.github/workflows/update-nix-hash.yml' push: - branches: - - master + branches: [master, develop] paths: - 'go.mod' - 'go.sum' + - '.go-version' + - 'flake.nix' + - 'flake.lock' + - 'scripts/update-nix-hash.sh' + - 'internal/tools/nixhash/**' + - '.github/workflows/update-nix-hash.yml' permissions: contents: write +concurrency: + group: nix-hash-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: update-nix-hash: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.head_ref || github.ref_name }} + persist-credentials: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} - uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} - - name: Update vendorHash + - name: Check flake outputs on all supported systems + run: nix flake check --all-systems --no-build + + - name: Update and validate vendorHash id: update + env: + FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} run: | - echo "Attempting to build and check for hash mismatch..." - - # Try to build. If it fails, capture the output. - set +e - OUTPUT=$(nix build --no-link 2>&1) - EXIT_CODE=$? - set -e - - if [ $EXIT_CODE -eq 0 ]; then - echo "Build successful, no hash update needed." - exit 0 - fi - - # Check if failure is due to hash mismatch - if echo "$OUTPUT" | grep -q "hash mismatch"; then - echo "Detected hash mismatch. Extracting new hash..." - - # Extract the 'got:' hash. - # The output format usually contains: - # got: sha256-...........................................= - # or - # got: sha256-...........................................= - # Handle variable whitespace before and after "got:" - NEW_HASH=$(echo "$OUTPUT" | grep -E "^\s*got:" | head -n1 | sed 's/.*got:\s*//' | xargs) - - if [ -n "$NEW_HASH" ]; then - echo "Found new hash: $NEW_HASH" - - # Read current hash for comparison log - CURRENT_HASH=$(grep "vendorHash =" flake.nix | cut -d'"' -f2) - echo "Current hash: $CURRENT_HASH" - - if [ "$NEW_HASH" != "$CURRENT_HASH" ]; then - # Update flake.nix - # Only match lines starting with optional whitespace followed by "vendorHash =" - # This prevents accidentally matching comments or other occurrences - sed -i '/^\s*vendorHash = /s|vendorHash = ".*"|vendorHash = "'$NEW_HASH'"|' flake.nix - echo "flake.nix updated." - echo "updated=true" >> $GITHUB_OUTPUT - else - echo "Hash extracted matches current hash. Weird." - exit 1 - fi - else - echo "Could not extract new hash from output." - echo "Full output:" - echo "$OUTPUT" - exit 1 - fi + if [ "$FORK_PR" = 'true' ]; then + bash scripts/update-nix-hash.sh --check else - echo "Build failed for reason other than hash mismatch." - echo "Full output:" - echo "$OUTPUT" - # Don't fail the workflow if it's a legitimate build error, - # as this workflow's sole purpose is updating hashes. - # Real CI will catch actual build errors. - exit 0 + bash scripts/update-nix-hash.sh fi - - name: Commit changes - if: steps.update.outputs.updated == 'true' + - name: Commit verified hash + if: steps.update.outputs.updated == 'true' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) uses: stefanzweifel/git-auto-commit-action@v7 with: - commit_message: "chore(nix): update vendorHash" + commit_message: "chore(nix): update verified vendorHash" file_pattern: flake.nix diff --git a/AGENTS.md b/AGENTS.md index b376f190..6b79e2a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENT INSTRUCTIONS -**Last Updated:** June 2026 (Cloudsmith packaging) | **For:** pvetui - Proxmox TUI +**Last Updated:** October 2026 (CI and Nix validation) | **For:** pvetui - Proxmox TUI ## Table of Contents - [Initial Setup](#initial-setup) @@ -276,6 +276,7 @@ Key architectural decisions and rationale: - **LXC exec via SSH + pct exec**: LXC containers have no API exec equivalent (unlike QEMU guest agent). Use `SSHClientImpl.ExecuteContainerCommandDetailed` from the command-runner package to run `pct exec -- ` over SSH to the Proxmox node. SSH credentials are resolved per-VM via `vm.SourceProfile` → global config fallback. - **Agent skill packaging**: Skills for the skills.sh ecosystem live in `skills//SKILL.md` at the repo root. Install via `npx skills add owner/repo`. The `skills/` path is auto-discovered; `docs/skills/` is not. - **Cloudsmith DEB/RPM publishing**: GitHub release workflows publish nfpm-generated `.deb` and `.rpm` artifacts through `scripts/publish-cloudsmith.sh` to `devnullvoid/pvetui`. CI authentication uses the `CLOUDSMITH_API_KEY` repository secret; local seeding can use an authenticated Cloudsmith CLI. Keep the README Cloudsmith attribution and link in place to satisfy the OSS hosting policy. +- **Nix dependency validation**: Keep the explicit Go toolchain in `flake.nix` aligned with `.go-version` and `go.mod`, including the development shell. Refresh the pinned `nixpkgs` input when the required compiler is unavailable, preserving all advertised systems: the 26.05 stable branch retains Intel macOS support removed in 26.11 unstable. Run `nix flake check --all-systems --no-build` and `bash scripts/update-nix-hash.sh` after dependency changes; the helper repairs only pvetui Go-module hash mismatches and verifies the rebuilt binary before emitting `updated=true`. Fork PRs use `--check` and must commit their own hash updates. Never suppress unexpected Nix errors or commit an unverified hash. Helper regression tests live in `internal/tools/nixhash` and run with `make test`; ordinary PR CI must cover both `master` and `develop`. ## Common Pitfalls diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd8b2cf..3ca48299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Nix builds**: Updated the pinned Nix package set and aligned the build and development-shell toolchains with Go 1.26. Dependency hash updates now verify the rebuilt binary and fail on unexpected errors rather than reporting false success ([#172](https://github.com/devnullvoid/pvetui/issues/172)). + ## [1.4.4] - 2026-10-03 ### Added diff --git a/README.md b/README.md index 03655860..adfd9449 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,23 @@ make install # Build and install from source # or: make install-go # Install via Go toolchain ``` +**Nix (From Source):** + +```bash +nix run github:devnullvoid/pvetui -- --version +# From a local checkout: +nix build +nix develop +``` + +The flake pins the 26.05 stable package set, retaining Intel macOS support, and +uses Go 1.26 for builds and development. +After changing Go dependencies or the pinned package set, run +`bash scripts/update-nix-hash.sh` to update `vendorHash` and verify the build. +Use `bash scripts/update-nix-hash.sh --check` for validation without modifying it. +Commit the verified hash with the dependency changes; fork PRs must provide their +own hash updates because CI does not write to forks. + ## 🔧 Configuration ### First Run & Interactive Config Wizard diff --git a/flake.lock b/flake.lock index 5850e1a0..69ced858 100644 --- a/flake.lock +++ b/flake.lock @@ -20,16 +20,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1791883866, + "narHash": "sha256-yvkDdToRZ4IgPc9tSBzzCYJvTLf8n5eE3rgUkpCuD5A=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "a033e66ab94a6bedf2c3d58d93a36319d3cbbbaf", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-26.05", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index b11934a4..5b42de13 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,8 @@ description = "A Terminal User Interface for Proxmox Virtual Environment"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # Nixpkgs 26.11 drops Intel macOS, which this flake still supports. + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; flake-utils.url = "github:numtide/flake-utils"; }; @@ -10,14 +11,16 @@ flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; + go = pkgs.go_1_26; + buildGoModule = pkgs.buildGoModule.override { inherit go; }; - pvetui = pkgs.buildGoModule { + pvetui = buildGoModule { pname = "pvetui"; version = if (self ? rev) then self.shortRev else "dev"; src = self; - vendorHash = "sha256-pN8hDeDQ6or9QakMWWQBru5vdJHmxhL8euKJ5w8ycK0="; + vendorHash = "sha256-ZxJs5j0zSREmsqfufCpQKRhhxaH7mpFW9goMk1Wg63w="; subPackages = [ "cmd/pvetui" ]; @@ -48,11 +51,11 @@ }; devShells.default = pkgs.mkShell { - buildInputs = with pkgs; [ - go_1_25 - gopls - gotools - golangci-lint + buildInputs = [ + go + pkgs.gopls + pkgs.gotools + pkgs.golangci-lint ]; shellHook = '' diff --git a/internal/tools/nixhash/update_test.go b/internal/tools/nixhash/update_test.go new file mode 100644 index 00000000..de53c42c --- /dev/null +++ b/internal/tools/nixhash/update_test.go @@ -0,0 +1,135 @@ +package nixhash + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" +) + +func TestUpdateVendorHash(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Nix helper requires a Unix shell") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is unavailable") + } + script, err := filepath.Abs("../../../scripts/update-nix-hash.sh") + if err != nil { + t.Fatal(err) + } + const oldHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + const newHash = "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" + for _, tc := range []struct { + name string + scenario string + checkOnly bool + failSmoke bool + wantError bool + wantUpdate bool + wantCalls int + }{ + {name: "current hash", scenario: "success", wantCalls: 1}, + {name: "current hash read only", scenario: "success", checkOnly: true, wantCalls: 1}, + {name: "repair and verify", scenario: "mismatch", wantUpdate: true, wantCalls: 2}, + {name: "fork stale hash", scenario: "mismatch", checkOnly: true, wantError: true, wantCalls: 1}, + {name: "unexpected build failure", scenario: "build-error", wantError: true, wantCalls: 1}, + {name: "unrelated hash mismatch", scenario: "foreign", wantError: true, wantCalls: 1}, + {name: "missing computed hash", scenario: "missing", wantError: true, wantCalls: 1}, + {name: "unchanged hash", scenario: "same", wantError: true, wantCalls: 1}, + {name: "rebuild fails and restores", scenario: "verify-error", wantError: true, wantCalls: 2}, + {name: "smoke fails and restores", scenario: "mismatch", failSmoke: true, wantError: true, wantCalls: 2}, + {name: "current hash smoke fails", scenario: "success", failSmoke: true, wantError: true, wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + write := func(name, body string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), mode); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Join(dir, "package", "bin"), 0o700); err != nil { + t.Fatal(err) + } + original := "{\n vendorHash = \"" + oldHash + "\";\n}\n" + write("flake.nix", original, 0o600) + write("output", "", 0o600) + write("package/bin/pvetui", "#!"+bash+"\n[[ $1 == --version ]] || exit 7\n[[ $SMOKE_FAIL == false ]] || exit 8\necho 'pvetui version test'\n", 0o700) + write("nix", "#!"+bash+"\n"+`set -euo pipefail +[[ "$*" == 'build .#default --no-link --print-out-paths' ]] || exit 9 +count=0 +if [[ -f "$TEST_DIR/calls" ]]; then count=$(<"$TEST_DIR/calls"); fi +count=$((count + 1)) +printf '%s' "$count" > "$TEST_DIR/calls" +if [[ "$SCENARIO" == build-error || ("$SCENARIO" == verify-error && "$count" == 2) ]]; then + echo 'go: incompatible toolchain' >&2 + exit 42 +fi +if [[ "$SCENARIO" != success && "$count" == 1 ]]; then + drv=pvetui-test-go-modules + if [[ "$SCENARIO" == foreign ]]; then drv=other-source; fi + echo "error: hash mismatch in fixed-output derivation '/nix/store/test-$drv.drv':" >&2 + if [[ "$SCENARIO" != missing ]]; then + hash=$NEW_HASH + if [[ "$SCENARIO" == same ]]; then hash=$OLD_HASH; fi + printf ' specified: %s\n got: %s\n' "$OLD_HASH" "$hash" >&2 + fi + exit 1 +fi +printf '%s/package\n' "$TEST_DIR" +`, 0o700) + args := []string{script} + if tc.checkOnly { + args = append(args, "--check") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // #nosec G204 -- runs the repository helper with test-controlled arguments and a mock Nix executable. + cmd := exec.CommandContext(ctx, bash, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), + "TEST_DIR="+dir, "SCENARIO="+tc.scenario, + "OLD_HASH="+oldHash, "NEW_HASH="+newHash, + "SMOKE_FAIL="+strconv.FormatBool(tc.failSmoke), + "GITHUB_OUTPUT="+filepath.Join(dir, "output")) + output, runErr := cmd.CombinedOutput() + if (runErr != nil) != tc.wantError { + t.Fatalf("error = %v, wantError = %t\n%s", runErr, tc.wantError, output) + } + read := func(name string) string { + t.Helper() + // #nosec G304 -- fixture names below are literals within this test's private temporary directory. + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + return string(data) + } + if calls := read("calls"); calls != strconv.Itoa(tc.wantCalls) { + t.Errorf("build calls = %s, want %d", calls, tc.wantCalls) + } + wantFlake := original + if tc.wantUpdate { + wantFlake = strings.ReplaceAll(original, oldHash, newHash) + } + if got := read("flake.nix"); got != wantFlake { + t.Errorf("flake = %q, want %q", got, wantFlake) + } + wantOutput := "" + if !tc.wantError { + wantOutput = "updated=" + strconv.FormatBool(tc.wantUpdate) + "\n" + } + if got := read("output"); got != wantOutput { + t.Errorf("workflow output = %q, want %q", got, wantOutput) + } + }) + } +} diff --git a/internal/tools/nixhash/workflow_test.go b/internal/tools/nixhash/workflow_test.go new file mode 100644 index 00000000..5d52da13 --- /dev/null +++ b/internal/tools/nixhash/workflow_test.go @@ -0,0 +1,60 @@ +package nixhash + +import ( + "os" + "slices" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestWorkflowCoverage(t *testing.T) { + for _, name := range []string{"ci.yml", "update-nix-hash.yml"} { + t.Run(name, func(t *testing.T) { + // #nosec G304 -- names are fixed repository workflow fixtures, not external input. + data, err := os.ReadFile("../../../.github/workflows/" + name) + if err != nil { + t.Fatal(err) + } + var workflow struct { + On map[string]struct { + Branches []string `yaml:"branches"` + Paths []string `yaml:"paths"` + } `yaml:"on"` + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + for _, event := range []string{"pull_request", "push"} { + for _, branch := range []string{"master", "develop"} { + if !slices.Contains(workflow.On[event].Branches, branch) { + t.Errorf("%s must cover %s on %s", name, event, branch) + } + } + if name == "update-nix-hash.yml" { + for _, path := range []string{"go.mod", "go.sum", ".go-version", "flake.nix", "flake.lock", "scripts/update-nix-hash.sh", "internal/tools/nixhash/**", ".github/workflows/update-nix-hash.yml"} { + if !slices.Contains(workflow.On[event].Paths, path) { + t.Errorf("%s must validate changes to %s", event, path) + } + } + } + } + }) + } +} + +func TestGoToolchainAlignment(t *testing.T) { + version, err := os.ReadFile("../../../.go-version") + if err != nil { + t.Fatal(err) + } + flake, err := os.ReadFile("../../../flake.nix") + if err != nil { + t.Fatal(err) + } + want := "go = pkgs.go_" + strings.ReplaceAll(strings.TrimSpace(string(version)), ".", "_") + ";" + if !strings.Contains(string(flake), want) { + t.Errorf("flake.nix must select the toolchain matching .go-version: %s", want) + } +} diff --git a/scripts/update-nix-hash.sh b/scripts/update-nix-hash.sh new file mode 100644 index 00000000..e021f588 --- /dev/null +++ b/scripts/update-nix-hash.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +check_only=false +case "${1:-}" in + --check) check_only=true ;; + '') ;; + *) printf 'Usage: bash scripts/update-nix-hash.sh [--check]\n' >&2; exit 2 ;; +esac +if (( $# > 1 )); then + printf 'Usage: bash scripts/update-nix-hash.sh [--check]\n' >&2 + exit 2 +fi + +tmp=$(mktemp -d) +restore=false +cleanup() { + if [ "$restore" = true ]; then + cp "$tmp/flake.nix" flake.nix + fi + rm -rf "$tmp" +} +trap cleanup EXIT + +build() { + nix build .#default --no-link --print-out-paths > "$tmp/output" 2> "$tmp/log" +} + +updated=false +if ! build; then + cat "$tmp/log" >&2 + # Only pvetui's Go dependency hash may be repaired, never a flake input hash. + new_hash=$(awk ' + /hash mismatch in fixed-output derivation .*pvetui-.*-go-modules/ { dependency = 1; next } + dependency && /^[[:space:]]*got:[[:space:]]*sha256-[A-Za-z0-9+\/=]+[[:space:]]*$/ { + sub(/^[[:space:]]*got:[[:space:]]*/, "") + sub(/[[:space:]]*$/, "") + print + exit + } + ' "$tmp/log") + if [ -z "$new_hash" ]; then + printf 'Nix build failed without a repairable pvetui dependency hash mismatch.\n' >&2 + exit 1 + fi + if [ "$check_only" = true ]; then + printf 'Stale vendorHash; run bash scripts/update-nix-hash.sh and commit flake.nix.\n' >&2 + exit 1 + fi + if [ "$(grep -Ec '^[[:space:]]*vendorHash = "[^"]*";[[:space:]]*$' flake.nix)" != 1 ]; then + printf 'Expected exactly one vendorHash assignment in flake.nix.\n' >&2 + exit 1 + fi + cp flake.nix "$tmp/flake.nix" + restore=true + sed -E "s|^([[:space:]]*vendorHash = \")[^\"]*(\";[[:space:]]*)$|\1${new_hash}\2|" flake.nix > "$tmp/updated.nix" + cp "$tmp/updated.nix" flake.nix + if cmp -s "$tmp/flake.nix" flake.nix; then + printf 'Dependency hash did not change; refusing to claim success.\n' >&2 + exit 1 + fi + printf 'Verifying updated vendorHash: %s\n' "$new_hash" + if ! build; then + cat "$tmp/log" >&2 + exit 1 + fi + updated=true +fi + +cat "$tmp/log" >&2 +output=$(<"$tmp/output") +"$output/bin/pvetui" --version +if [ -n "${GITHUB_OUTPUT:-}" ]; then + printf 'updated=%s\n' "$updated" >> "$GITHUB_OUTPUT" +fi +restore=false +printf 'Nix build and binary smoke test passed.\n'