feat: first release
build-and-release / release (push) Failing after 17m7s

This commit is contained in:
2026-06-22 16:51:18 +02:00
parent fe485dd86d
commit 2bf11dda91
29 changed files with 182 additions and 191 deletions
+74
View File
@@ -0,0 +1,74 @@
name: build-and-release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # svu needs full history + tags
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- name: Install svu
run: go install github.com/caarlos0/svu@latest
- name: Compute next version
id: ver
run: |
CURRENT=$(svu current 2>/dev/null || echo v0.0.0)
NEXT=$(svu next)
echo "current=$CURRENT" >> $GITHUB_OUTPUT
echo "next=$NEXT" >> $GITHUB_OUTPUT
if [ "$CURRENT" = "$NEXT" ]; then
echo "release=false" >> $GITHUB_OUTPUT
echo "No version-worthy commits since $CURRENT; skipping release."
else
echo "release=true" >> $GITHUB_OUTPUT
echo "Releasing $NEXT (was $CURRENT)."
fi
- name: Install UPX
if: steps.ver.outputs.release == 'true'
run: sudo apt-get update && sudo apt-get install -y upx-ucl
- name: Build binaries
if: steps.ver.outputs.release == 'true'
env:
VERSION: ${{ steps.ver.outputs.next }}
LDFLAGS: -s -w -X nadir.Version=${{ steps.ver.outputs.next }}
run: |
mkdir -p dist
GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o dist/nadir-$VERSION-linux-amd64 ./cmd/server
GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o dist/nadir-$VERSION-linux-arm64 ./cmd/server
upx --best --lzma dist/nadir-$VERSION-linux-amd64 dist/nadir-$VERSION-linux-arm64
- name: Tag and release
if: steps.ver.outputs.release == 'true'
env:
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
VERSION: ${{ steps.ver.outputs.next }}
run: |
git config user.email "ci@nadir"
git config user.name "nadir-ci"
git tag -a "$VERSION" -m "$VERSION"
git push "https://x:${GITEA_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git" "$VERSION"
# Create the release via Gitea API (works on any Gitea ≥ 1.20)
curl -sSf -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$VERSION\",\"name\":\"$VERSION\"}" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases" > release.json
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | cut -d: -f2)
for f in dist/*; do
curl -sSf -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$f" \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?name=$(basename $f)"
done
+4 -2
View File
@@ -24,5 +24,7 @@ var Favicon string
//go:embed install.sh.tmpl
var InstallScriptTemplate string
// Version is the current version of the Nadir application.
const Version = "0.0.1"
// Version is set by the linker at build time:
// go build -ldflags "-X nadir.Version=v1.2.3"
// Local dev builds leave it as "dev".
var Version = "dev"
+38 -16
View File
@@ -2,30 +2,29 @@
set -e
# Nadir bootstrap installer.
#
# Downloads the nadir binary from the SAME instance that served this script
# and installs it as a systemd service. Intended for spinning up nadir on a
# sibling host quickly (same architecture as the source instance):
# Detects the host architecture, fetches the latest release asset from the
# Gitea releases API, and installs it as a systemd service:
#
# curl -fsSL https://<existing-nadir-host>:<port>/install.sh | sudo sh
#
# This is not a general-purpose distribution channel: there is no separate
# release server, no signature, and no version pinning beyond "whatever the
# source instance is currently running." Trust it exactly as much as you
# trust the source host.
# The binary always comes from the Gitea release (not the source instance),
# so the installed copy is guaranteed to be the latest tagged build,
# independent of whatever version the serving host happens to be running.
#
# Everything is wrapped in do_install and invoked on the last line, the same
# pattern get.docker.com uses: when piped via `curl | sh`, sh executes while
# still receiving bytes from the network. Defining the whole body as a
# function before running anything means sh has fully parsed the script
# before do_install ever runs, which avoids exactly the failure mode where a
# command further down (here, the binary download) races the still-open
# script-fetch connection and fails with a write error.
# before do_install ever runs.
BASE_URL="__NADIR_BASE_URL__"
# Where to look for releases. Substituted at request time by the server
# (cmd/server/server.go) so the script knows which Gitea instance owns
# this deployment. Example: https://gitea.example.com/urania/nadir
RELEASE_REPO="__NADIR_RELEASE_REPO__"
do_install() {
if [ "$(id -u)" -ne 0 ]; then
echo "this script must be run as root (try: curl -fsSL $BASE_URL/install.sh | sudo sh)" >&2
echo "this script must be run as root (try piping through 'sudo sh')" >&2
exit 1
fi
@@ -34,10 +33,33 @@ do_install() {
exit 1
fi
echo "downloading nadir from $BASE_URL ..."
# No -s here: the progress meter is the whole point of leaving it visible,
# so you can see how much is left mid-download.
curl -f --progress-bar -L "$BASE_URL/nadir-binary" -o /usr/local/bin/nadir.tmp
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "unsupported architecture: $(uname -m) (only amd64 and arm64 are built)" >&2; exit 1 ;;
esac
api="$RELEASE_REPO/releases/latest"
api="${api%/}"
# Convert https://host/owner/repo/releases/latest into Gitea API form.
# Splits the URL once on '/' between host and path, inserts /api/v1/repos.
host=$(echo "$RELEASE_REPO" | awk -F/ '{print $1"//"$3}')
path=$(echo "$RELEASE_REPO" | awk -F/ '{print $4"/"$5}')
api="$host/api/v1/repos/$path/releases/latest"
echo "querying $api ..."
asset_url=$(curl -fsSL "$api" \
| grep -o '"browser_download_url":"[^"]*linux-'"$arch"'"' \
| head -n1 \
| cut -d'"' -f4)
if [ -z "$asset_url" ]; then
echo "no linux-$arch asset found in the latest release at $api" >&2
exit 1
fi
echo "downloading $asset_url ..."
curl -f --progress-bar -L "$asset_url" -o /usr/local/bin/nadir.tmp
mv /usr/local/bin/nadir.tmp /usr/local/bin/nadir
chmod +x /usr/local/bin/nadir
+2 -3
View File
@@ -40,9 +40,8 @@ func (a *TokenAuth) Verify(ip, raw string) (name string, ok, throttled bool) {
// reporting whether it was a Bearer scheme. Returns ("", false) for cookie-only
// or unauthenticated requests.
func BearerToken(authHeader string) (string, bool) {
const scheme = "Bearer "
if len(authHeader) <= len(scheme) || !strings.EqualFold(authHeader[:len(scheme)], scheme) {
if len(authHeader) <= 7 || !strings.EqualFold(authHeader[:7], "Bearer ") {
return "", false
}
return strings.TrimSpace(authHeader[len(scheme):]), true
return strings.TrimSpace(authHeader[7:]), true
}
-8
View File
@@ -10,20 +10,12 @@ import (
"time"
"nadir/internal/auditlog"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestEnsurePAMService(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "nadir-pam-test")
oldPath := pamServicePath
+1 -6
View File
@@ -89,11 +89,6 @@ func (s *SessionStore) GetByToken(token string) (Session, bool) {
func randomToken() string {
b := make([]byte, 32)
// crypto/rand.Read never returns an error on supported platforms; if it
// somehow does, an all-zero (guessable) token would be a security hole, so
// fail hard rather than hand one out.
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
rand.Read(b) // never fails; rand.Read panics internally on misconfigured platforms.
return hex.EncodeToString(b)
}
+5
View File
@@ -60,6 +60,11 @@ type Server struct {
TrustProxy bool `yaml:"trust_proxy"`
TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"`
// ReleaseRepo is the Gitea repo URL used by /install.sh to fetch the
// latest binary. Example: https://gitea.example.com/urania/nadir.
// Empty disables the install.sh endpoint.
ReleaseRepo string `yaml:"release_repo"`
}
// SecureCookie reports whether the session cookie should carry the Secure
-1
View File
@@ -20,7 +20,6 @@ type fakeModule struct {
}
func (f fakeModule) ID() string { return f.id }
func (f fakeModule) Name() string { return f.id }
func (f fakeModule) Permissions() []rbac.Permission { return f.perms }
func (f fakeModule) Register(huma.API) {}
+1 -1
View File
@@ -37,7 +37,7 @@ func Register(api huma.API, mods []module.Module) {
for i, p := range perms {
ps[i] = string(p)
}
infos = append(infos, ModuleInfo{ID: m.ID(), Name: m.Name(), Permissions: ps})
infos = append(infos, ModuleInfo{ID: m.ID(), Name: module.Title(m.ID()), Permissions: ps})
}
slices.SortFunc(infos, func(a, b ModuleInfo) int { return cmp.Compare(a.ID, b.ID) })
+13 -1
View File
@@ -8,7 +8,19 @@ import (
type Module interface {
ID() string
Name() string
Permissions() []rbac.Permission // permissions this module exposes (no "*")
Register(api huma.API)
}
// Title returns the display name for a module ID, capitalizing the first
// letter (modules are single lowercase words: "system" -> "System").
func Title(id string) string {
if id == "" {
return ""
}
b := id[0]
if b >= 'a' && b <= 'z' {
b -= 'a' - 'A'
}
return string(b) + id[1:]
}
-1
View File
@@ -18,7 +18,6 @@ type Module struct {
func New(store *auditlog.Store) *Module { return &Module{store: store} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Audit" }
// Permissions: read to view the audit trail. There is no write - entries are
// produced by the middleware, never by an API call.
@@ -15,13 +15,6 @@ import (
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestGroupsHandlers(t *testing.T) {
tempGroup := filepath.Join(t.TempDir(), "group")
initialContent := "root:x:0:\nwheel:x:10:alice,bob\n"
-1
View File
@@ -13,7 +13,6 @@ type Module struct{}
func New() *Module { return &Module{} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Groups" }
// Permissions: read to list/inspect groups; write to create; root to delete
// (irreversible). Group membership lives in the users module.
-1
View File
@@ -24,7 +24,6 @@ type Module struct {
func New() *Module { return &Module{be: detect()} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Networking" }
// Permissions: read to inspect interfaces/routes/DNS; write to reconfigure them
// (apply config, bring links up/down, confirm a pending change).
@@ -19,13 +19,6 @@ import (
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
type mockBackend struct {
name string
snapshotResult IfaceConfig
-1
View File
@@ -16,7 +16,6 @@ type Module struct {
func New() *Module { return &Module{pm: detect()} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Packages" }
// Permissions: read to list installed/available; write to install, remove, and
// upgrade.
@@ -3,7 +3,6 @@ package packages
import (
"encoding/json"
"net/http"
"os"
"reflect"
"strings"
"testing"
@@ -15,13 +14,6 @@ import (
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestPackagesHandlers(t *testing.T) {
managers := []string{"dnf", "apt", "pacman"}
-1
View File
@@ -17,7 +17,6 @@ type Module struct {
func New(logFiles map[string][]string) *Module { return &Module{logFiles: logFiles} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Services" }
// Permissions: read to list and inspect units; write to control them
// (start/stop/restart/enable/disable).
@@ -16,13 +16,6 @@ import (
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestServicesHandlers(t *testing.T) {
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
-1
View File
@@ -13,7 +13,6 @@ type Module struct{}
func New() *Module { return &Module{} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Storage" }
// Permissions: read to list mounts and fstab; write to mount/unmount and edit
// fstab entries.
-9
View File
@@ -6,17 +6,8 @@ import (
"reflect"
"strings"
"testing"
"nadir/internal/oscmd"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestParseFstab(t *testing.T) {
data := `# /etc/fstab
UUID=1111-2222 / ext4 defaults 0 1
-1
View File
@@ -13,7 +13,6 @@ type Module struct{}
func New() *Module { return &Module{} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "System" }
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read, rbac.Write, rbac.Root}
@@ -3,7 +3,6 @@ package system
import (
"encoding/json"
"net/http"
"os"
"reflect"
"testing"
@@ -14,13 +13,6 @@ import (
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestSystemHandlers(t *testing.T) {
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
-1
View File
@@ -46,7 +46,6 @@ func New(sessions *auth.SessionStore) module.Module {
}
func (m *terminalModule) ID() string { return "terminal" }
func (m *terminalModule) Name() string { return "Terminal" }
func (m *terminalModule) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Root}
-1
View File
@@ -13,7 +13,6 @@ type Module struct{}
func New() *Module { return &Module{} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Users" }
// Permissions: read to list/inspect accounts; write to create and change
// passwords; root to delete (irreversible).
@@ -15,13 +15,6 @@ import (
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestUsersHandlers(t *testing.T) {
tempPasswd := filepath.Join(t.TempDir(), "passwd")
initialContent := "root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice Smith:/home/alice:/bin/bash\n"
+44 -74
View File
@@ -7,7 +7,6 @@ import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -28,37 +27,51 @@ import (
const cmdTimeout = 60 * time.Second
// CommandRunner allows overriding the command execution function for testing.
// In tests, register a handler with SetMock; the runner translates the
// MockCommand into a /bin/sh script that reproduces the stdout/stderr/exit
// behavior — no helper process, no temp file.
var CommandRunner = func(ctx context.Context, name string, args ...string) *exec.Cmd {
mockMu.Lock()
handler, ok := mockCmds[name]
mockMu.Unlock()
if ok {
mockRes := handler(args)
tmpFile, err := os.CreateTemp("", "nadir-mock-*.json")
if err != nil {
log.Printf("oscmd mock: failed to create temp file: %v", err)
return exec.CommandContext(ctx, name, args...)
}
encoder := json.NewEncoder(tmpFile)
if err := encoder.Encode(mockRes); err != nil {
log.Printf("oscmd mock: failed to write json: %v", err)
tmpFile.Close()
return exec.CommandContext(ctx, name, args...)
}
tmpPath := tmpFile.Name()
tmpFile.Close()
cmd := exec.CommandContext(ctx, os.Args[0])
cmd.Env = append(os.Environ(),
"GO_WANT_HELPER_PROCESS=1",
"NADIR_MOCK_FILE="+tmpPath,
)
return cmd
if !ok {
return exec.CommandContext(ctx, name, args...)
}
return exec.CommandContext(ctx, "/bin/sh", "-c", mockScript(handler(args)))
}
return exec.CommandContext(ctx, name, args...)
// mockScript builds the shell script that emits a MockCommand's behavior.
// Uses printf so backslashes and percent signs in output pass through verbatim.
func mockScript(m MockCommand) string {
var b strings.Builder
if len(m.Lines) > 0 {
for _, line := range m.Lines {
b.WriteString("printf '%s\\n' ")
b.WriteString(shellQuote(line))
b.WriteByte('\n')
if m.DelayMs > 0 {
fmt.Fprintf(&b, "sleep %g\n", float64(m.DelayMs)/1000)
}
}
} else {
if m.Stdout != "" {
b.WriteString("printf '%s' ")
b.WriteString(shellQuote(m.Stdout))
b.WriteByte('\n')
}
if m.Stderr != "" {
b.WriteString("printf '%s' ")
b.WriteString(shellQuote(m.Stderr))
b.WriteString(" 1>&2\n")
}
}
fmt.Fprintf(&b, "exit %d\n", m.ExitCode)
return b.String()
}
// shellQuote returns s wrapped in single quotes, with embedded single quotes escaped.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// Run executes name with args and returns trimmed stdout. On failure it wraps
@@ -283,11 +296,11 @@ func OK() *StatusOutput {
// MockCommand holds the behavior for a mocked command.
type MockCommand struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Lines []string `json:"lines,omitempty"`
DelayMs int `json:"delay_ms,omitempty"`
Stdout string
Stderr string
ExitCode int
Lines []string
DelayMs int
}
var (
@@ -309,46 +322,3 @@ func ClearMocks() {
clear(mockCmds)
}
// RunHelperProcess executes the mock helper process logic if GO_WANT_HELPER_PROCESS is set.
// It returns true if it ran (and exits the process), false otherwise.
func RunHelperProcess() bool {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return false
}
mockFile := os.Getenv("NADIR_MOCK_FILE")
if mockFile == "" {
os.Exit(1)
}
defer os.Remove(mockFile)
data, err := os.ReadFile(mockFile)
if err != nil {
fmt.Fprintf(os.Stderr, "mock helper: read failed: %v\n", err)
os.Exit(1)
}
var mock MockCommand
if err := json.Unmarshal(data, &mock); err != nil {
fmt.Fprintf(os.Stderr, "mock helper: unmarshal failed: %v\n", err)
os.Exit(1)
}
if len(mock.Lines) > 0 {
for _, line := range mock.Lines {
fmt.Fprintln(os.Stdout, line)
if mock.DelayMs > 0 {
time.Sleep(time.Duration(mock.DelayMs) * time.Millisecond)
}
}
} else {
if mock.Stdout != "" {
fmt.Fprint(os.Stdout, mock.Stdout)
}
if mock.Stderr != "" {
fmt.Fprint(os.Stderr, mock.Stderr)
}
}
os.Exit(mock.ExitCode)
return true
}
-8
View File
@@ -1,19 +1,11 @@
package oscmd
import (
"os"
"reflect"
"strings"
"testing"
)
func TestMain(m *testing.M) {
if RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestRunTrimsStdout(t *testing.T) {
out, err := Run("echo", "hello")
if err != nil {
-9
View File
@@ -3,26 +3,17 @@ package rbac
import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
"nadir/internal/auditlog"
"nadir/internal/auth"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestRbacMiddleware(t *testing.T) {
tempDir := t.TempDir()
auditStore, err := auditlog.New(filepath.Join(tempDir, "audit.db"))