Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [master, develop]
pull_request:
branches: [master]
branches: [master, develop]

# Set default permissions for all jobs
permissions:
Expand Down
97 changes: 34 additions & 63 deletions .github/workflows/update-nix-hash.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 <ctid> -- <cmd>` 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-name>/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

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 11 additions & 8 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@
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";
};

outputs = { self, nixpkgs, flake-utils }:
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" ];

Expand Down Expand Up @@ -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 = ''
Expand Down
135 changes: 135 additions & 0 deletions internal/tools/nixhash/update_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading