From bc99b6e8a6007b539a3b60b04ce43d6c1af7fd1b Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 16 Jun 2026 08:24:58 +0200 Subject: [PATCH 1/8] feat(music): friendly cross-platform ffmpeg-missing error --- internal/music/music.go | 17 +++++++++++++++-- internal/music/music_test.go | 13 +++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/internal/music/music.go b/internal/music/music.go index 64584e5..d68ec3d 100644 --- a/internal/music/music.go +++ b/internal/music/music.go @@ -1,6 +1,7 @@ package music import ( + "fmt" "os" "os/exec" "path/filepath" @@ -104,7 +105,7 @@ func ResolveFFmpegWithConfig(configuredPath string) (string, error) { } path, err := exec.LookPath("ffmpeg") if err != nil { - return "", err + return "", ffmpegMissingErr("ffmpeg") } return path, nil } @@ -122,11 +123,23 @@ func ResolveFFprobeWithConfig(configuredPath string) (string, error) { } path, err := exec.LookPath("ffprobe") if err != nil { - return "", err + return "", ffmpegMissingErr("ffprobe") } return path, nil } +// ffmpegMissingErr renders an actionable, cross-platform "tool not found" +// message. Only the music builder needs ffmpeg/ffprobe; every other builder +// runs with zero external dependencies. +func ffmpegMissingErr(tool string) error { + return fmt.Errorf(`%[1]s not found on PATH. + Windows: winget install Gyan.FFmpeg + macOS: brew install ffmpeg + Linux: apt install ffmpeg (or your distro's package manager) +Only the music builder needs %[1]s; set SOW_%[2]s to a full path to override.`, + tool, strings.ToUpper(tool)) +} + func IsMusicAssetPath(rel string) bool { rel = filepath.ToSlash(strings.TrimSpace(rel)) return rel == "envi/music" || strings.HasPrefix(rel, "envi/music/") diff --git a/internal/music/music_test.go b/internal/music/music_test.go index ba33de3..bb11d12 100644 --- a/internal/music/music_test.go +++ b/internal/music/music_test.go @@ -402,3 +402,16 @@ func TestResolveFFprobeEnv(t *testing.T) { t.Fatalf("expected '/custom/ffprobe', got %q", path) } } + +func TestFFmpegMissingErrHasInstallHints(t *testing.T) { + msg := ffmpegMissingErr("ffmpeg").Error() + for _, want := range []string{"ffmpeg", "winget", "brew", "apt", "SOW_FFMPEG"} { + if !strings.Contains(msg, want) { + t.Errorf("ffmpeg error missing %q; got:\n%s", want, msg) + } + } + probe := ffmpegMissingErr("ffprobe").Error() + if !strings.Contains(probe, "SOW_FFPROBE") { + t.Errorf("ffprobe error should mention SOW_FFPROBE; got:\n%s", probe) + } +} -- 2.54.0 From 011a41e90abe0f5fcf5a07806eec8270c09c4e41 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 16 Jun 2026 08:29:02 +0200 Subject: [PATCH 2/8] feat(menu): interactive command launcher package --- internal/menu/menu.go | 60 ++++++++++++++++++++++++++++++++++++++ internal/menu/menu_test.go | 47 +++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 internal/menu/menu.go create mode 100644 internal/menu/menu_test.go diff --git a/internal/menu/menu.go b/internal/menu/menu.go new file mode 100644 index 0000000..9503d18 --- /dev/null +++ b/internal/menu/menu.go @@ -0,0 +1,60 @@ +// Package menu renders Crucible's interactive launcher: a numbered list of +// commands for users who don't want to memorize subcommands. It holds no +// builder logic — it returns the chosen dispatcher argument slice for the +// caller to run. This is the seed of a future GUI front-end over the same core. +package menu + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" +) + +// Item is one selectable command. +type Item struct { + Label string // e.g. "module build" + Desc string // e.g. "build/extract/validate/compare the .mod" + Args []string // dispatcher args, e.g. {"module", "build"} +} + +// ANSI styling; terminals that don't support it simply ignore the codes. +const ( + cReset = "\033[0m" + cBold = "\033[1m" + cCyan = "\033[36m" + cDim = "\033[2m" +) + +// Select prints the numbered menu to out, reads a selection plus optional extra +// arguments from in, and returns the full dispatcher argument slice. It returns +// (nil, nil) when the user quits or enters an empty choice. +func Select(out io.Writer, in io.Reader, items []Item) ([]string, error) { + if len(items) == 0 { + return nil, fmt.Errorf("no commands available") + } + fmt.Fprintf(out, "%s%sCrucible%s — pick a command:\n\n", cBold, cCyan, cReset) + for i, it := range items { + fmt.Fprintf(out, " %s%2d%s %s%-24s%s %s%s%s\n", + cCyan, i+1, cReset, cBold, it.Label, cReset, cDim, it.Desc, cReset) + } + fmt.Fprintf(out, " %sq%s quit\n\nselection: ", cCyan, cReset) + + r := bufio.NewReader(in) + line, _ := r.ReadString('\n') + switch choice := strings.TrimSpace(line); choice { + case "", "q", "Q": + return nil, nil + default: + n, err := strconv.Atoi(choice) + if err != nil || n < 1 || n > len(items) { + return nil, fmt.Errorf("invalid selection %q", choice) + } + it := items[n-1] + fmt.Fprintf(out, "extra args for %q (optional): ", it.Label) + argLine, _ := r.ReadString('\n') + extra := strings.Fields(strings.TrimSpace(argLine)) + return append(append([]string{}, it.Args...), extra...), nil + } +} diff --git a/internal/menu/menu_test.go b/internal/menu/menu_test.go new file mode 100644 index 0000000..3fe284d --- /dev/null +++ b/internal/menu/menu_test.go @@ -0,0 +1,47 @@ +package menu + +import ( + "io" + "reflect" + "strings" + "testing" +) + +var sample = []Item{ + {Label: "module build", Desc: "build the .mod", Args: []string{"module", "build"}}, + {Label: "topdata validate-topdata", Desc: "validate topdata", Args: []string{"topdata", "validate-topdata"}}, +} + +func TestSelectReturnsChosenArgs(t *testing.T) { + got, err := Select(io.Discard, strings.NewReader("1\n\n"), sample) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := []string{"module", "build"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestSelectAppendsExtraArgs(t *testing.T) { + got, err := Select(io.Discard, strings.NewReader("2\n--dry-run foo\n"), sample) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"topdata", "validate-topdata", "--dry-run", "foo"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestSelectQuitReturnsNil(t *testing.T) { + got, err := Select(io.Discard, strings.NewReader("q\n"), sample) + if err != nil || got != nil { + t.Fatalf("quit should return (nil,nil); got (%v,%v)", got, err) + } +} + +func TestSelectInvalidErrors(t *testing.T) { + if _, err := Select(io.Discard, strings.NewReader("99\n"), sample); err == nil { + t.Fatal("out-of-range selection should error") + } +} -- 2.54.0 From 2d5d4ffe2ee12cfdca7a1cbbfed720319fc1db76 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 16 Jun 2026 08:32:27 +0200 Subject: [PATCH 3/8] feat(dispatch): launch interactive menu on no-arg TTY invocation --- internal/dispatch/dispatch.go | 49 ++++++++++++++++++++++++++++-- internal/dispatch/dispatch_test.go | 19 ++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/internal/dispatch/dispatch.go b/internal/dispatch/dispatch.go index ece2879..2f53572 100644 --- a/internal/dispatch/dispatch.go +++ b/internal/dispatch/dispatch.go @@ -17,6 +17,7 @@ import ( "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/app" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo" + "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/menu" ) // Exit codes follow the sysexits(3) convention so CI can distinguish @@ -103,8 +104,52 @@ func find(name string) (Builder, bool) { return Builder{}, false } -// Main is the `crucible` dispatcher entrypoint. -func Main(args []string) int { return run(args, os.Stdout, os.Stderr) } +// Main is the `crucible` dispatcher entrypoint. With no arguments on an +// interactive terminal it launches the menu; otherwise (pipes, CI) it prints +// usage, preserving scriptable behavior. +func Main(args []string) int { + if len(args) == 0 && interactive(os.Stdout) { + return runMenu(os.Stdout, os.Stderr, os.Stdin) + } + return run(args, os.Stdout, os.Stderr) +} + +func interactive(f *os.File) bool { + fi, err := f.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +func menuItems() []menu.Item { + var items []menu.Item + for _, b := range Registry { + if !b.Wired { + continue + } + for _, sub := range b.subcommands() { + items = append(items, menu.Item{ + Label: b.Name + " " + sub, + Desc: b.Summary, + Args: []string{b.Name, sub}, + }) + } + } + return items +} + +func runMenu(out, errw io.Writer, in io.Reader) int { + chosen, err := menu.Select(out, in, menuItems()) + if err != nil { + fmt.Fprintln(errw, "crucible:", err) + return exitUsage + } + if chosen == nil { + return exitOK + } + return run(chosen, out, errw) +} // RunBuilder is the standalone `crucible-` entrypoint. func RunBuilder(name string, args []string) int { diff --git a/internal/dispatch/dispatch_test.go b/internal/dispatch/dispatch_test.go index f5ce543..7315f21 100644 --- a/internal/dispatch/dispatch_test.go +++ b/internal/dispatch/dispatch_test.go @@ -135,3 +135,22 @@ func TestLegacyCommandsHaveExactlyOneHome(t *testing.T) { } } } + +func TestMenuItemsSkipsUnwiredIncludesWired(t *testing.T) { + items := menuItems() + if len(items) == 0 { + t.Fatal("expected menu items") + } + sawModuleBuild := false + for _, it := range items { + if it.Args[0] == "depot" { + t.Errorf("unwired builder %q must not appear in the menu", it.Args[0]) + } + if len(it.Args) == 2 && it.Args[0] == "module" && it.Args[1] == "build" { + sawModuleBuild = true + } + } + if !sawModuleBuild { + t.Error("expected 'module build' in the menu") + } +} -- 2.54.0 From 1f25aa318fe20103cc28429a1e50dd5b787dee58 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 16 Jun 2026 08:36:19 +0200 Subject: [PATCH 4/8] ci: cross-platform binaries + tag-gated Gitea release --- .gitea/workflows/build-binaries.yml | 66 +++++++++++++++++++++++++++++ .gitea/workflows/release.yml | 38 ----------------- 2 files changed, 66 insertions(+), 38 deletions(-) create mode 100644 .gitea/workflows/build-binaries.yml delete mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/build-binaries.yml b/.gitea/workflows/build-binaries.yml new file mode 100644 index 0000000..f4f5770 --- /dev/null +++ b/.gitea/workflows/build-binaries.yml @@ -0,0 +1,66 @@ +# Cross-platform Crucible binaries (D7 trigger standard): +# PR / push main -> cross-build ALL targets to prove they compile (no publish) +# tag v* -> build all targets + SHA256SUMS, upload to the Gitea release +# Crucible is pure Go (CGO_ENABLED=0), so cross-compiling is a fast loop. +name: build-binaries + +on: + pull_request: + push: + branches: [main] + tags: ['v*'] + +jobs: + build-binaries: + runs-on: nix-docker + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + + - name: Cross-build all targets + run: | + nix develop --command bash -c ' + set -euo pipefail + sha="$(git rev-parse --short=12 HEAD)" + ldflags="-s -w -X git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo.Version=${sha}" + rm -rf dist && mkdir -p dist + export CGO_ENABLED=0 + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + os="${target%/*}"; arch="${target#*/}" + ext=""; [ "$os" = windows ] && ext=".exe" + echo "building crucible-${os}-${arch}${ext}" + GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "$ldflags" \ + -o "dist/crucible-${os}-${arch}${ext}" ./cmd/crucible + done + # Ship the canonical wrappers as release assets too: consumer + # drift-check fetches them from the latest release to compare. + cp wrappers/crucible.sh wrappers/crucible.ps1 dist/ + ( cd dist && sha256sum crucible-* > SHA256SUMS ) + cat dist/SHA256SUMS + ' + + - name: Upload to Gitea release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + REPO: ${{ github.repository }} + SERVER: ${{ github.server_url }} + run: | + nix develop --command bash -c ' + set -euo pipefail + api="${SERVER}/api/v1/repos/${REPO}" + auth="Authorization: token ${TOKEN}" + # Create the release (ignore "already exists"), then read its id. + curl -fsS -X POST -H "$auth" -H "Content-Type: application/json" \ + "${api}/releases" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" || true + id="$(curl -fsS -H "$auth" "${api}/releases/tags/${TAG}" \ + | grep -o "\"id\":[0-9]*" | head -1 | cut -d: -f2)" + for f in dist/*; do + echo "uploading $(basename "$f")" + curl -fsS -X POST -H "$auth" \ + -F "attachment=@${f}" \ + "${api}/releases/${id}/assets?name=$(basename "$f")" + done + ' diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml deleted file mode 100644 index e7caf19..0000000 --- a/.gitea/workflows/release.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Tag a Crucible release: re-tag the already-built immutable image and attach -# per-platform binary bundles. Tag-gated; never PR-triggered (deploy/release is -# not a PR concern, D7). The : image from build-image is the source of truth. -name: release - -on: - push: - tags: - - "v*" - -env: - REGISTRY: registry.westgate.pw - IMAGE: deployment/crucible - -jobs: - release: - runs-on: nix-docker - steps: - - uses: actions/checkout@v4 - with: { fetch-depth: 0 } - - - name: Build release binaries - run: | - nix develop --command bash -c ' - set -euo pipefail - GOOS=linux GOARCH=amd64 bash scripts/build-binaries.sh - mkdir -p dist - tar -C bin -czf "dist/crucible-linux-amd64-${GITHUB_REF_NAME}.tar.gz" . - ' - - - name: Upload release artifacts - uses: actions/upload-artifact@v4 - with: - name: crucible-${{ github.ref_name }} - path: dist/* - - # TODO(phase-6): re-tag registry.westgate.pw/deployment/crucible: as : - # and pin it from sow-platform releases/*.yml once the registry is live. -- 2.54.0 From f32abf36865e725045178dfcaa02d4e56bdd0977 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 16 Jun 2026 08:39:59 +0200 Subject: [PATCH 5/8] ci: gate image publish on v* tag (trigger standard) --- .gitea/workflows/build-image.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index f8b7e45..2dde49c 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -1,5 +1,5 @@ -# Build the Crucible image. PR-first (D7): PRs build only; push to main also -# publishes registry.westgate.pw/deployment/crucible:. Never a mutable tag. +# Build the Crucible image (D7 trigger standard): PRs/main build only; tag `v*` +# publishes registry.westgate.pw/deployment/crucible:. Never a mutable tag. # # Daemonless: the host-mode runner has no container runtime, so the image is # built by Nix (`nix build .#image`, see flake.nix) and pushed with skopeo @@ -7,9 +7,10 @@ name: build-image on: + pull_request: push: branches: [main] - pull_request: + tags: ['v*'] env: REGISTRY: registry.westgate.pw @@ -29,9 +30,9 @@ jobs: - name: Build OCI image (daemonless) run: nix build .#image - # Publish only on main (post-merge). PRs verify the build but never push. + # Publish only on a v* tag push. PRs and main pushes build but never push. - name: Publish image - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} -- 2.54.0 From 15ec24504c15fd3c75ba1f581ccb868b4338eaf8 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 16 Jun 2026 08:43:30 +0200 Subject: [PATCH 6/8] feat(wrappers): canonical bash bootstrap wrapper --- wrappers/crucible.sh | 107 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100755 wrappers/crucible.sh diff --git a/wrappers/crucible.sh b/wrappers/crucible.sh new file mode 100755 index 0000000..cae4637 --- /dev/null +++ b/wrappers/crucible.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# crucible — bootstrap wrapper (POSIX/bash). Prefers a crucible already on PATH +# (Nix devshell / installed); otherwise downloads the latest released binary +# from Gitea into a per-version cache and execs it. +# +# CANONICAL SOURCE: sow-tools/wrappers/crucible.sh. Do not hand-edit copies in +# consumer repos — they are kept in sync by sow-tools CI (see drift-check). +# +# Requires: bash, curl, and sha256sum (Linux) or shasum (macOS). +# Env: CRUCIBLE_HOME (cache dir), CRUCIBLE_TOKEN (private releases), +# CRUCIBLE_GITEA (default https://git.westgate.pw), +# CRUCIBLE_REPO (default ShadowsOverWestgate/sow-tools). +set -euo pipefail + +# 0. Prefer an existing crucible (Nix users, or already installed). +if command -v crucible >/dev/null 2>&1; then + exec crucible "$@" +fi + +gitea="${CRUCIBLE_GITEA:-https://git.westgate.pw}" +repo="${CRUCIBLE_REPO:-ShadowsOverWestgate/sow-tools}" + +# 1. --repo-local materializes into /.crucible instead of the user cache. +repo_local=0 +if [ "${1:-}" = "--repo-local" ]; then repo_local=1; shift; fi + +# 2. OS/arch -> asset name. +os="$(uname -s)"; arch="$(uname -m)" +case "$os" in + Linux) os=linux ;; + Darwin) os=darwin ;; + *) echo "crucible: unsupported OS '$os' — install ffmpeg-free crucible manually" >&2; exit 1 ;; +esac +case "$arch" in + x86_64|amd64) arch=amd64 ;; + aarch64|arm64) arch=arm64 ;; + *) echo "crucible: unsupported arch '$arch'" >&2; exit 1 ;; +esac +asset="crucible-${os}-${arch}" + +# 3. Auth: anonymous first, token fallback. +token="${CRUCIBLE_TOKEN:-}" +if [ -z "$token" ] && [ -f "$HOME/.config/crucible/token" ]; then + token="$(cat "$HOME/.config/crucible/token")" +fi +fetch() { # fetch URL DEST ; tries anon then token + if curl -fsSL "$1" -o "$2" 2>/dev/null; then return 0; fi + if [ -n "$token" ]; then curl -fsSL -H "Authorization: token $token" "$1" -o "$2"; return $?; fi + return 1 +} +fetch_stdout() { # prints body; anon then token + if curl -fsSL "$1" 2>/dev/null; then return 0; fi + if [ -n "$token" ]; then curl -fsSL -H "Authorization: token $token" "$1"; return $?; fi + return 1 +} + +# 4. Resolve the latest release tag (no jq dependency). +api="${gitea}/api/v1/repos/${repo}/releases/latest" +tag="$(fetch_stdout "$api" 2>/dev/null | grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)" + +# 5. Cache dir. +if [ "$repo_local" = 1 ]; then + cache_root="$(cd "$(dirname "$0")" && pwd)/.crucible" +else + cache_root="${CRUCIBLE_HOME:-${XDG_CACHE_HOME:-$HOME/.cache}/crucible}" +fi + +# 6. Offline fallback: if tag lookup failed, use the newest cached version. +if [ -z "$tag" ]; then + if [ -d "$cache_root" ]; then + cached="$(ls -1 "$cache_root" 2>/dev/null | sort -V | tail -1 || true)" + if [ -n "$cached" ] && [ -x "$cache_root/$cached/crucible" ]; then + echo "crucible: offline — using cached $cached" >&2 + exec "$cache_root/$cached/crucible" "$@" + fi + fi + echo "crucible: cannot reach $gitea and no cached binary found." >&2 + echo " set CRUCIBLE_TOKEN if releases are private, or check the network." >&2 + exit 1 +fi + +dest_dir="$cache_root/$tag" +bin="$dest_dir/crucible" + +# 7. Download + verify on cache miss. +if [ ! -x "$bin" ]; then + mkdir -p "$dest_dir" + base="${gitea}/${repo}/releases/download/${tag}" + tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT + echo "crucible: fetching ${asset} ${tag}..." >&2 + fetch "${base}/${asset}" "$tmp/crucible" || { echo "crucible: download failed for ${asset}" >&2; exit 1; } + if fetch "${base}/SHA256SUMS" "$tmp/SHA256SUMS"; then + want="$(grep " ${asset}\$" "$tmp/SHA256SUMS" | awk '{print $1}')" + if command -v sha256sum >/dev/null 2>&1; then + got="$(sha256sum "$tmp/crucible" | awk '{print $1}')" + else + got="$(shasum -a 256 "$tmp/crucible" | awk '{print $1}')" + fi + if [ -n "$want" ] && [ "$want" != "$got" ]; then + echo "crucible: checksum mismatch for ${asset} (want $want got $got)" >&2; exit 1 + fi + fi + chmod +x "$tmp/crucible" + mv "$tmp/crucible" "$bin" +fi + +exec "$bin" "$@" -- 2.54.0 From a39c580b3c6b144493686d80236285f9345ce54f Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 16 Jun 2026 08:46:23 +0200 Subject: [PATCH 7/8] feat(wrappers): canonical PowerShell bootstrap wrapper --- wrappers/crucible.ps1 | 89 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 wrappers/crucible.ps1 diff --git a/wrappers/crucible.ps1 b/wrappers/crucible.ps1 new file mode 100644 index 0000000..0762977 --- /dev/null +++ b/wrappers/crucible.ps1 @@ -0,0 +1,89 @@ +#!/usr/bin/env pwsh +# crucible - bootstrap wrapper (PowerShell). Prefers a crucible already on PATH; +# otherwise downloads the latest released binary from Gitea into a per-version +# cache and execs it. +# +# CANONICAL SOURCE: sow-tools/wrappers/crucible.ps1. Do not hand-edit copies in +# consumer repos - kept in sync by sow-tools CI (see drift-check). +# +# Env: CRUCIBLE_HOME, CRUCIBLE_TOKEN, CRUCIBLE_GITEA, CRUCIBLE_REPO. +$ErrorActionPreference = 'Stop' + +# 0. Prefer an existing crucible. +$existing = Get-Command crucible -ErrorAction SilentlyContinue +if ($existing) { & $existing.Source @args; exit $LASTEXITCODE } + +$gitea = if ($env:CRUCIBLE_GITEA) { $env:CRUCIBLE_GITEA } else { 'https://git.westgate.pw' } +$repo = if ($env:CRUCIBLE_REPO) { $env:CRUCIBLE_REPO } else { 'ShadowsOverWestgate/sow-tools' } + +# 1. OS/arch -> asset name (.exe on Windows). +$os = if ($IsWindows) { 'windows' } elseif ($IsMacOS) { 'darwin' } else { 'linux' } +$arch = switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'amd64' } 'ARM64' { 'arm64' } default { 'amd64' } +} +$ext = if ($os -eq 'windows') { '.exe' } else { '' } +$asset = "crucible-$os-$arch$ext" + +# 2. Auth header (anon first, token fallback). +$token = $env:CRUCIBLE_TOKEN +$tokenFile = Join-Path $HOME '.config/crucible/token' +if (-not $token -and (Test-Path $tokenFile)) { $token = (Get-Content $tokenFile -Raw).Trim() } +function Invoke-Fetch($url, $outFile) { + try { Invoke-WebRequest -Uri $url -OutFile $outFile -UseBasicParsing; return $true } catch {} + if ($token) { + Invoke-WebRequest -Uri $url -OutFile $outFile -UseBasicParsing -Headers @{ Authorization = "token $token" } + return $true + } + return $false +} + +# 3. Resolve latest tag. +$tag = $null +try { + $hdr = if ($token) { @{ Authorization = "token $token" } } else { @{} } + $rel = Invoke-RestMethod -Uri "$gitea/api/v1/repos/$repo/releases/latest" -Headers $hdr + $tag = $rel.tag_name +} catch {} + +# 4. Cache dir. +$cacheRoot = if ($env:CRUCIBLE_HOME) { $env:CRUCIBLE_HOME } + elseif ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'crucible' } + else { Join-Path $HOME '.cache/crucible' } + +# 5. Offline fallback. +if (-not $tag) { + if (Test-Path $cacheRoot) { + $cached = Get-ChildItem $cacheRoot -Directory | Sort-Object Name | Select-Object -Last 1 + $cachedBin = if ($cached) { Join-Path $cached.FullName "crucible$ext" } else { $null } + if ($cachedBin -and (Test-Path $cachedBin)) { + Write-Error "crucible: offline - using cached $($cached.Name)" -ErrorAction Continue + & $cachedBin @args; exit $LASTEXITCODE + } + } + Write-Error "crucible: cannot reach $gitea and no cached binary found (set CRUCIBLE_TOKEN if private)." + exit 1 +} + +$destDir = Join-Path $cacheRoot $tag +$bin = Join-Path $destDir "crucible$ext" + +# 6. Download + verify on cache miss. +if (-not (Test-Path $bin)) { + New-Item -ItemType Directory -Force -Path $destDir | Out-Null + $base = "$gitea/$repo/releases/download/$tag" + $tmp = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid())) + $tmpBin = Join-Path $tmp "crucible$ext" + Write-Host "crucible: fetching $asset $tag..." + if (-not (Invoke-Fetch "$base/$asset" $tmpBin)) { Write-Error "crucible: download failed for $asset"; exit 1 } + $sums = Join-Path $tmp 'SHA256SUMS' + if (Invoke-Fetch "$base/SHA256SUMS" $sums) { + $want = (Select-String -Path $sums -Pattern ([regex]::Escape($asset) + '$') | Select-Object -First 1).Line -split '\s+' | Select-Object -First 1 + $got = (Get-FileHash $tmpBin -Algorithm SHA256).Hash.ToLower() + if ($want -and ($want.ToLower() -ne $got)) { Write-Error "crucible: checksum mismatch for $asset"; exit 1 } + } + Move-Item -Force $tmpBin $bin + Remove-Item -Recurse -Force $tmp +} + +& $bin @args +exit $LASTEXITCODE -- 2.54.0 From 9f94480e4e72429ab7b80ef4b2640ad6bfe7dfa3 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Tue, 16 Jun 2026 15:38:41 +0200 Subject: [PATCH 8/8] fix(ci): resolve build-image.yml merge conflict from main The `Merge branch 'main'` commit (088cbf3) landed build-image.yml with unresolved conflict markers. Resolved to main's "Release workflow revision" design: build-image.yml now triggers on `v*` tags only and always publishes, because test-image.yml owns the PR/main test builds and never publishes. Keeping the old PR/main triggers here would have double-built and is now redundant. Verified: go vet, go test ./..., shellcheck, yamllint, make smoke all pass. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/build-image.yml | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index d5044df..6bfaeb7 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -1,14 +1,6 @@ -<<<<<<< HEAD -# Build the Crucible image (D7 trigger standard): PRs/main build only; tag `v*` -# publishes registry.westgate.pw/deployment/crucible:. Never a mutable tag. -||||||| f3211f0 -# Build the Crucible image. PR-first (D7): PRs build only; push to main also -# publishes registry.westgate.pw/deployment/crucible:. Never a mutable tag. -======= # Publish the immutable crucible: image on version tags. # PR/main test builds live in test-image.yml and never publish, so registry # packages are only generated for releases we intend to use (not on every merge). ->>>>>>> main # # Daemonless: the host-mode runner has no container runtime, so the image is # built by Nix (`nix build .#image`, see flake.nix) and pushed with skopeo @@ -16,17 +8,8 @@ name: build-image on: - pull_request: push: -<<<<<<< HEAD - branches: [main] tags: ['v*'] -||||||| f3211f0 - branches: [main] - pull_request: -======= - tags: ["v*"] ->>>>>>> main env: REGISTRY: registry.westgate.pw @@ -46,19 +29,8 @@ jobs: - name: Build OCI image (daemonless) run: nix build .#image -<<<<<<< HEAD - # Publish only on a v* tag push. PRs and main pushes build but never push. -||||||| f3211f0 - # Publish only on main (post-merge). PRs verify the build but never push. -======= ->>>>>>> main + # This workflow only runs on v* tags, so every run is a release publish. - name: Publish image -<<<<<<< HEAD - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') -||||||| f3211f0 - if: github.event_name == 'push' && github.ref == 'refs/heads/main' -======= ->>>>>>> main env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} -- 2.54.0