From 0d6eb2aeaace37c9879d15bc61a529be80fdfc07 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 11 Jun 2026 20:36:13 +0200 Subject: [PATCH] =?UTF-8?q?Phase=205:=20scaffold=20Crucible=20(sow-tools)?= =?UTF-8?q?=20=E2=80=94=20dispatcher=20+=20builder=20shims,=20container,?= =?UTF-8?q?=20PR-first=20CI,=20fail-closed=20(D11,=20D19).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 24 ++++ .gitea/workflows/build-image.yml | 40 +++++++ .gitea/workflows/release.yml | 40 +++++++ .gitea/workflows/test.yml | 27 +++++ .gitignore | 20 ++++ .shellcheckrc | 2 + .yamllint | 8 ++ AGENTS.md | 57 ++++++++++ Makefile | 30 +++++ README.md | 68 +++++++++++ cmd/crucible-depot/main.go | 11 ++ cmd/crucible-hak/main.go | 10 ++ cmd/crucible-module/main.go | 11 ++ cmd/crucible-topdata/main.go | 11 ++ cmd/crucible-wiki/main.go | 10 ++ cmd/crucible/main.go | 10 ++ docker/Dockerfile | 36 ++++++ docs/command-surface.md | 49 ++++++++ docs/consumer-contract.md | 50 +++++++++ docs/migration-from-nwn-tool.md | 48 ++++++++ flake.lock | 27 +++++ flake.nix | 40 +++++++ go.mod | 3 + internal/buildinfo/buildinfo.go | 33 ++++++ internal/dispatch/dispatch.go | 175 +++++++++++++++++++++++++++++ internal/dispatch/dispatch_test.go | 87 ++++++++++++++ scripts/build-binaries.sh | 23 ++++ scripts/crucible-smoke.sh | 37 ++++++ 28 files changed, 987 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitea/workflows/build-image.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/test.yml create mode 100644 .gitignore create mode 100644 .shellcheckrc create mode 100644 .yamllint create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/crucible-depot/main.go create mode 100644 cmd/crucible-hak/main.go create mode 100644 cmd/crucible-module/main.go create mode 100644 cmd/crucible-topdata/main.go create mode 100644 cmd/crucible-wiki/main.go create mode 100644 cmd/crucible/main.go create mode 100644 docker/Dockerfile create mode 100644 docs/command-surface.md create mode 100644 docs/consumer-contract.md create mode 100644 docs/migration-from-nwn-tool.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/dispatch/dispatch.go create mode 100644 internal/dispatch/dispatch_test.go create mode 100755 scripts/build-binaries.sh create mode 100755 scripts/crucible-smoke.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..627327b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.go] +indent_style = tab + +[Makefile] +indent_style = tab + +[*.sh] +indent_size = 2 + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml new file mode 100644 index 0000000..92aa486 --- /dev/null +++ b/.gitea/workflows/build-image.yml @@ -0,0 +1,40 @@ +# Build the Crucible image. PR-first (D7): PRs build only; push to main also +# publishes registry.westgate.pw/sow/crucible:. Never a mutable tag. +name: build-image + +on: + push: + branches: [main] + pull_request: + +env: + REGISTRY: registry.westgate.pw + IMAGE: sow/crucible + +jobs: + build-image: + runs-on: docker + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + + - name: Resolve tag + id: tag + run: echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT" + + - name: Build image + run: | + docker build \ + --build-arg GIT_SHA=${{ steps.tag.outputs.sha }} \ + -f docker/Dockerfile \ + -t ${REGISTRY}/${IMAGE}:${{ steps.tag.outputs.sha }} . + + # Publish only on main (post-merge). PRs verify the build but never push. + - name: Publish image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + docker push ${REGISTRY}/${IMAGE}:${{ steps.tag.outputs.sha }} diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..25955e5 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,40 @@ +# 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: sow/crucible + +jobs: + release: + runs-on: docker + container: + image: nixos/nix:latest + 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 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/sow/crucible: as : + # and pin it from sow-platform releases/*.yml once the registry is live. diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..77771ee --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,27 @@ +# Lint + unit tests for the Crucible suite. PR-first: PRs + push to main (D7). +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: docker + container: + image: nixos/nix:latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - name: vet + test + lint + run: | + nix develop --command bash -c ' + set -euo pipefail + go vet ./... + go test ./... + shellcheck scripts/*.sh + yamllint .gitea + ' + - name: binary smoke (fail-closed contract) + run: nix develop --command make smoke diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cb0d90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Build outputs — Crucible binaries are CI artifacts / image layers, never +# committed (D19; replaces the old habit of committing nwn-tool/sow-toolkit). +/bin/ +*.exe +nwn-tool +sow-toolkit + +# Go / build cache +.cache/ +.cache/** + +# nix build symlink +result +result-* + +# OS / editor +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..662e204 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +external-sources=true +source-path=SCRIPTDIR diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..683037f --- /dev/null +++ b/.yamllint @@ -0,0 +1,8 @@ +extends: relaxed +rules: + line-length: disable + comments: + min-spaces-from-content: 1 + document-start: disable + truthy: + check-keys: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5f545bb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +--- +description: sow-tools / Crucible — the build/conversion/sync toolchain repo. +alwaysApply: true +--- + +# sow-tools / Crucible Agent Guide + +This repo owns the **builder logic** for the migration: one Go module +(`gitea.westgate.pw/ShadowsOverWestgate/sow-tools`) producing the `crucible` +dispatcher and the `crucible-` binaries (D11). Read +[`../AGENTS.md`](../AGENTS.md) (migration hub) and +[`../../KICKOFF_PROMPT.md`](../../KICKOFF_PROMPT.md) first. + +## What this repo owns / does not own + +Owns: build/extract/validate/compare pipeline, ERF/HAK packing, topdata 2da/tlk +compilation, wiki rendering/deploy, depot blob verify, music conversion, +changelog. Does **not** own authored game content (that is `sow-module` / +`sow-topdata` / `sow-assets-manifest`) or any production deploy authority (that +is `sow-platform`). + +## Scaffold rules (Phase 5) + +1. **Source is not transplanted.** The migration hard rules forbid copying the + `internal/` packages from `gitea/sow-tools` automatically. The operator + migrates them at cutover; see [`docs/migration-from-nwn-tool.md`](docs/migration-from-nwn-tool.md). +2. **Fail closed, never fake.** Unwired builders exit `70`. Do not stub a + builder to emit a placeholder artifact. +3. **Binaries are not committed.** They are CI artifacts / image layers (D19). + `/bin/`, `*.exe`, `nwn-tool`, `sow-toolkit` are gitignored. +4. **No home-dir / `NWN_ROOT` guessing.** Builders take roots explicitly via + flag or env. See [`docs/consumer-contract.md`](docs/consumer-contract.md). +5. **The registry is the command surface.** `internal/dispatch.Registry` is the + single source of truth; keep it in sync with `cmd/` and + [`docs/command-surface.md`](docs/command-surface.md). Adding a builder = a + `cmd/crucible-/main.go` shim + a `Registry` entry + a doc row. + +## Wiring a builder (operator cutover) + +1. Migrate the relevant `internal/` package(s) from `gitea/sow-tools`. +2. Register a handler and flip the builder off the unwired path in + `internal/dispatch`. +3. Add tests; keep outputs deterministic (same input → same bytes). +4. `make check` must stay green; `make smoke` is updated to expect a wired exit. + +## Commands + +```bash +nix develop && make check # vet + test + shellcheck + yamllint +make build # cmd/* -> ./bin +make smoke # assert fail-closed contract +make image # crucible: +``` + +## Git + +Never commit, branch, or push. Suggest a commit message; let the operator do it. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1beaec4 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +SHELL := bash +.ONESHELL: +.PHONY: check test vet build smoke fmt image + +# Lint + unit tests. Mirrors the `test` CI job; runs green inside `nix develop`. +check: vet test + shellcheck scripts/*.sh + yamllint .gitea + +vet: + go vet ./... + +test: + go test ./... + +# Build every cmd/* binary into ./bin (gitignored — binaries are CI artifacts). +build: + scripts/build-binaries.sh + +smoke: build + scripts/crucible-smoke.sh + +fmt: + gofmt -l -w cmd internal + +# Build the Crucible image locally. Requires docker; mirrors build-image CI. +IMAGE ?= crucible +GIT_SHA ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown) +image: + docker build --build-arg GIT_SHA=$(GIT_SHA) -f docker/Dockerfile -t $(IMAGE):$(GIT_SHA) . diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ddd5f9 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# sow-tools — Crucible + +Crucible is the Shadows Over Westgate build/conversion/sync toolchain: one Go +module producing several small binaries plus a `crucible` dispatcher (D11). It is +the **only** repo that owns builder logic; the artifact repos (`sow-module`, +`sow-topdata`, `sow-assets-manifest`) invoke Crucible through wrapper scripts and +never embed a toolkit. + +```text +Repos produce artifacts. sow-platform deploys artifacts. +Crucible is how the artifact repos turn source into artifacts. +``` + +## Binaries + +| Binary | Dispatcher form | Owns | +| ------------------ | ------------------ | ----------------------------------------------- | +| `crucible` | — | dispatcher: `crucible [args]` | +| `crucible-depot` | `crucible depot` | content-addressed depot blob verify/move | +| `crucible-hak` | `crucible hak` | ERF/HAK pack/unpack + hak manifests | +| `crucible-module` | `crucible module` | build/extract/validate/compare the `.mod` | +| `crucible-topdata` | `crucible topdata` | compile 2da/tlk topdata + packages | +| `crucible-wiki` | `crucible wiki` | render + deploy mechanical wiki pages | + +The dispatcher and the standalone shims share one registry +(`internal/dispatch`); the shims exist so consumer wrapper scripts can resolve a +single-token command. The full legacy `nwn-tool` command surface and where each +command lands is mapped in [`docs/command-surface.md`](docs/command-surface.md). + +## Status (Phase 5 scaffold) + +The suite **builds, vets, tests, and runs**, but every builder is **unwired**: +running one fails closed with exit `70` and never fakes an artifact. The internal +pipeline/topdata/erf/wiki/music packages from `gitea/sow-tools` are migrated by +the operator at cutover — the workspace hard rules forbid transplanting that +source automatically. See [`docs/migration-from-nwn-tool.md`](docs/migration-from-nwn-tool.md). + +This matches the downstream skeletons: `sow-module` / `sow-topdata` package +scripts already fail closed until they can resolve a Crucible binary. + +## Develop + +Self-contained (D8) — a host with only Nix can run everything: + +```bash +nix develop # Go + ffmpeg + shellcheck + yamllint + make +make check # go vet + go test + shellcheck + yamllint +make build # build every cmd/* into ./bin (gitignored) +make smoke # build + assert the fail-closed contract +make image # docker build -> crucible: +``` + +Binaries are **never committed** — they are CI artifacts / image layers (D19). +This retires the old habit of checking in `nwn-tool` / `sow-toolkit`. + +## CI + +PR-first (D7): every check runs on pull requests and on push to `main`. + +- `test.yml` — vet, test, shellcheck, yamllint, binary smoke. +- `build-image.yml` — build `registry.westgate.pw/sow/crucible:`; publish + only on `main`. PRs build but never push. No mutable tags. +- `release.yml` — tag-gated binary bundles; image re-tag is wired in Phase 6. + +## Consumers + +How the artifact repos resolve a Crucible binary (and the `NWN_ROOT` rule) is +documented in [`docs/consumer-contract.md`](docs/consumer-contract.md). diff --git a/cmd/crucible-depot/main.go b/cmd/crucible-depot/main.go new file mode 100644 index 0000000..f985654 --- /dev/null +++ b/cmd/crucible-depot/main.go @@ -0,0 +1,11 @@ +// Command crucible-depot is the standalone depot builder (equivalent to +// `crucible depot`). A single-token binary keeps consumer wrapper scripts simple. +package main + +import ( + "os" + + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/dispatch" +) + +func main() { os.Exit(dispatch.RunBuilder("depot", os.Args[1:])) } diff --git a/cmd/crucible-hak/main.go b/cmd/crucible-hak/main.go new file mode 100644 index 0000000..b91d12b --- /dev/null +++ b/cmd/crucible-hak/main.go @@ -0,0 +1,10 @@ +// Command crucible-hak is the standalone HAK builder (equivalent to `crucible hak`). +package main + +import ( + "os" + + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/dispatch" +) + +func main() { os.Exit(dispatch.RunBuilder("hak", os.Args[1:])) } diff --git a/cmd/crucible-module/main.go b/cmd/crucible-module/main.go new file mode 100644 index 0000000..9856402 --- /dev/null +++ b/cmd/crucible-module/main.go @@ -0,0 +1,11 @@ +// Command crucible-module is the standalone module builder (equivalent to +// `crucible module`). sow-module resolves this binary to package its .mod. +package main + +import ( + "os" + + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/dispatch" +) + +func main() { os.Exit(dispatch.RunBuilder("module", os.Args[1:])) } diff --git a/cmd/crucible-topdata/main.go b/cmd/crucible-topdata/main.go new file mode 100644 index 0000000..805ce40 --- /dev/null +++ b/cmd/crucible-topdata/main.go @@ -0,0 +1,11 @@ +// Command crucible-topdata is the standalone topdata builder (equivalent to +// `crucible topdata`). sow-topdata resolves this binary to compile 2da/tlk + wiki. +package main + +import ( + "os" + + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/dispatch" +) + +func main() { os.Exit(dispatch.RunBuilder("topdata", os.Args[1:])) } diff --git a/cmd/crucible-wiki/main.go b/cmd/crucible-wiki/main.go new file mode 100644 index 0000000..5af6b4e --- /dev/null +++ b/cmd/crucible-wiki/main.go @@ -0,0 +1,10 @@ +// Command crucible-wiki is the standalone wiki builder (equivalent to `crucible wiki`). +package main + +import ( + "os" + + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/dispatch" +) + +func main() { os.Exit(dispatch.RunBuilder("wiki", os.Args[1:])) } diff --git a/cmd/crucible/main.go b/cmd/crucible/main.go new file mode 100644 index 0000000..eb75b42 --- /dev/null +++ b/cmd/crucible/main.go @@ -0,0 +1,10 @@ +// Command crucible is the Crucible dispatcher: `crucible [args]`. +package main + +import ( + "os" + + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/dispatch" +) + +func main() { os.Exit(dispatch.Main(os.Args[1:])) } diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..118be50 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 +# +# Crucible toolchain image: registry.westgate.pw/sow/crucible: +# +# Reproducible multi-stage build, no on-VPS build. Produces every cmd/* binary +# and ships them on a static base. The `crucible` dispatcher is the entrypoint; +# consumer CI can also call the standalone crucible- binaries by path. +# +# NOTE (Phase 5 scaffold): the binaries are pure stdlib and fail closed until +# the operator migrates the internal pipeline. When the music pipeline is wired +# it needs ffmpeg at runtime — switch the final stage to a debian-slim base with +# ffmpeg then (see docs/migration-from-nwn-tool.md). + +FROM golang:1.26-alpine AS build +WORKDIR /src +RUN apk add --no-cache git +# go.sum is optional while the scaffold has no external deps. +COPY go.mod go.sum* ./ +RUN go mod download +COPY . . +ARG GIT_SHA=unknown +ENV CGO_ENABLED=0 +RUN set -eux; \ + mkdir -p /out; \ + for dir in ./cmd/*/; do \ + name="$(basename "${dir}")"; \ + go build -trimpath \ + -ldflags "-s -w -X gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo.Version=${GIT_SHA}" \ + -o "/out/${name}" "${dir}"; \ + done + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/ /usr/local/bin/ +USER nonroot +ENTRYPOINT ["/usr/local/bin/crucible"] +CMD ["help"] diff --git a/docs/command-surface.md b/docs/command-surface.md new file mode 100644 index 0000000..c7570e8 --- /dev/null +++ b/docs/command-surface.md @@ -0,0 +1,49 @@ +# Crucible command surface + +Crucible re-homes the single `nwn-tool` (a.k.a. `sow-toolkit`) command surface +into five builders plus a dispatcher (D11). This table maps every legacy command +to its Crucible home. It is the migration contract — keep it in sync with +`internal/dispatch.Registry`. + +## Builders + +| Builder | Legacy `nwn-tool` commands subsumed | +| ------------------ | ------------------------------------------------------------------------------------- | +| `crucible-module` | `build`, `build-module`, `extract`, `validate`, `compare`, `apply-hak-manifest`† | +| `crucible-topdata` | `validate-topdata`, `build-topdata`, `build-top-package`, `compare-topdata`, `convert-topdata` | +| `crucible-hak` | `build-haks`, `apply-hak-manifest` | +| `crucible-wiki` | `build-wiki`, `deploy-wiki` | +| `crucible-depot` | (new) depot blob verify/move — was shell `mc`/S3 in `sow-assets-manifest` | + +† `apply-hak-manifest` is HAK-list maintenance on a module; it is reachable from +both `crucible-module` and `crucible-hak`. Canonical home is `crucible-hak`. + +## Folded / global commands + +These legacy commands are **not** top-level builders: + +- `music *` (`list-datasets`, `scan`, `build`, `credits`, `validate`, `manifest`, + `normalize`) → folds into the **module/hak build pipeline** (music BMUs are + packed into HAKs at build time). Exposed as `crucible-module music ...` / + `crucible-hak music ...` when wired. Needs `ffmpeg` at runtime. +- `config *` (`validate`, `effective`, `inspect`, `explain`, `sources`) → a + **global** concern shared by every builder; exposed as a global subcommand + group, not its own binary. +- `build-changelog` → release tooling; lands as a global `crucible changelog` + rather than a content builder. + +## Global commands (implemented in the scaffold) + +```text +crucible help | -h usage + builder list +crucible version | -V build version (git sha via -ldflags) +crucible list builders, one per line: namebinsummary +``` + +`crucible list` is machine-readable so CI can enumerate builders without parsing +help text. + +## Global flags (planned, at wiring time) + +`--quiet`, `--verbose`, `--debug` (the legacy verbosity model), passed after the +builder name, e.g. `crucible topdata build-topdata --verbose`. diff --git a/docs/consumer-contract.md b/docs/consumer-contract.md new file mode 100644 index 0000000..8d06ae3 --- /dev/null +++ b/docs/consumer-contract.md @@ -0,0 +1,50 @@ +# Crucible consumer contract + +How the artifact repos (`sow-module`, `sow-topdata`, `sow-assets-manifest`) +locate and invoke a Crucible builder. The goal: **no local-machine assumptions** +(Phase 5 exit criterion). A consumer either finds a Crucible binary or fails +closed — it never fakes an artifact. + +## Resolution order (single-token command) + +Each consumer wrapper resolves its builder to one executable token, in order: + +1. **Explicit override** — `$SOW_MODULE_BUILD` / `$SOW_TOPDATA_BUILD` / + `$CRUCIBLE` (a path or name), if set and executable. Back-compat with the + pre-Crucible skeletons. +2. **Standalone Crucible binary** — `crucible-module` / `crucible-topdata` / + `crucible-hak` / `crucible-depot` on `PATH`. +3. **Legacy name** — `sow-module-build` / `sow-topdata-build` on `PATH` + (pre-D11; kept so nothing breaks during cutover). + +If none resolve, the wrapper exits non-zero with a Phase 5 message. The +`crucible` dispatcher (`crucible module …`) is for humans and CI introspection; +wrappers prefer the single-token `crucible-` shim so `"$builder" args` +quoting stays correct. + +## In CI (preferred) + +Run the consumer job inside the pinned image and the binaries are on `PATH`: + +```yaml +container: + image: registry.westgate.pw/sow/crucible: # pinned, immutable +``` + +No host install, no `$HOME` layout, no developer machine assumptions. + +## `NWN_ROOT` rule + +Crucible **never guesses `NWN_ROOT` from `$HOME`** (this was a deploy-notes +pain point). The NWN install/data root is passed explicitly: + +- env `NWN_ROOT=/path/to/nwn`, or +- flag `--nwn-root /path/to/nwn`. + +A builder that needs `NWN_ROOT` and receives neither must fail closed with a +clear message, not fall back to a home-directory default. + +## Determinism + +Same inputs → identical output bytes. Consumers may checksum artifacts across +runs; non-determinism is a builder bug. diff --git a/docs/migration-from-nwn-tool.md b/docs/migration-from-nwn-tool.md new file mode 100644 index 0000000..389b33c --- /dev/null +++ b/docs/migration-from-nwn-tool.md @@ -0,0 +1,48 @@ +# Migrating from `nwn-tool` to Crucible (operator cutover) + +The legacy toolchain lives at `gitea/sow-tools` as a single `nwn-tool` binary +(entry `cmd/nwn-tool`, logic under `internal/`). This repo is the Crucible +rewrite. Per the migration hard rules, the `internal/` source is **not** +transplanted automatically — the operator migrates it. This is the checklist. + +## Why a clean scaffold first + +- The hard rules forbid copying old-repo source into the migration tree + (`build from scratch; reference, don't transplant`). +- The downstream skeletons (`sow-module`, `sow-topdata`) already fail closed + until they can resolve a Crucible binary; the scaffold satisfies that contract + without faking artifacts. +- The scaffold pins the **shape** (module path, dispatcher, multi-binary layout, + container, CI) so the migrated logic drops into a stable frame. + +## Cutover steps + +1. **Move the internal packages.** Bring `internal/{app,pipeline,project,erf, + gff,topdata,music,changelog,validator}` from `gitea/sow-tools` into this repo. + Re-home `internal/app` command wiring behind `internal/dispatch` instead of + the old flag parser in `cmd/nwn-tool`. +2. **Restore dependencies.** Add `golang.org/x/text` and `gopkg.in/yaml.v3` back + to `go.mod`; commit the resulting `go.sum`. (`docker/Dockerfile` already + tolerates an absent `go.sum` for the scaffold; it will be present after this.) +3. **Wire each builder.** In `internal/dispatch`, register a handler per builder + and remove it from the unwired fail-closed path. Map the legacy commands per + [`command-surface.md`](command-surface.md). +4. **Kill the home-dir / `NWN_ROOT` defaulting.** Honor only explicit + env/flag roots per [`consumer-contract.md`](consumer-contract.md). +5. **Drop the committed binary.** Delete `nwn-tool` / `tools/sow-toolkit` from + the source tree; `.gitignore` already excludes them. Binaries become CI + artifacts and image layers only. +6. **Carry the determinism test.** Port + `TestBuildNativeKeepsGeneratedIDsStableAcrossRepeatedBuilds` and keep it green. +7. **Runtime base for music.** If music conversion is wired, switch the final + Docker stage from `distroless/static` to a debian-slim base that includes + `ffmpeg`. +8. **Update `make smoke`.** Once a builder is wired, change the smoke + expectation for it from exit `70` to a real success path. + +## Wrapper contract carried over + +Consumer repos keep the `SCRIPT_WRAPPER_CONTRACT` resolution behavior, now +pointed at Crucible binaries — see [`consumer-contract.md`](consumer-contract.md). +The old `SOW_TOOLS_DEV_BINARY` dev override maps to `$CRUCIBLE` / +`$SOW_MODULE_BUILD` / `$SOW_TOPDATA_BUILD`. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..14e3cb3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1780749050, + "narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a799d3e3886da994fa307f817a6bc705ae538eeb", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3d77951 --- /dev/null +++ b/flake.nix @@ -0,0 +1,40 @@ +{ + description = "sow-tools / Crucible — NWN build/conversion/sync toolchain (self-contained Go devshell)"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + in + { + # Self-contained (D8): a host with ONLY nix can enter this shell and run + # `make check`. ffmpeg is present because the migrated music pipeline needs + # it; the scaffold itself is pure stdlib Go. + devShells = forAllSystems (pkgs: { + default = pkgs.mkShell { + name = "sow-tools"; + packages = with pkgs; [ + go + gopls + gotools + ffmpeg + git + gnumake + bashInteractive + shellcheck + yamllint + jq + ]; + shellHook = '' + export GOCACHE="$PWD/.cache/go-build" + echo "sow-tools / Crucible devshell (self-contained). $(go version 2>/dev/null)" + echo " make check go vet + go test + shellcheck + yamllint" + echo " make build build every cmd/* into ./bin" + echo " make smoke build + run the binary smoke test" + ''; + }; + }); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..287e891 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.westgate.pw/ShadowsOverWestgate/sow-tools + +go 1.26.0 diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..98f0152 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,33 @@ +// Package buildinfo reports the Crucible build version. +// +// Version is empty in plain `go build`; the build scripts and Dockerfile inject +// the short git sha via -ldflags. As a fallback we read the VCS revision that +// the Go toolchain stamps into the binary, so a binary built without -ldflags +// still self-identifies. +package buildinfo + +import "runtime/debug" + +// Version is overridable at link time: +// +// -ldflags "-X gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo.Version=" +var Version = "" + +// String returns a human-readable version line, e.g. "crucible 8f31c2a". +func String() string { + return "crucible " + revision() +} + +func revision() string { + if Version != "" { + return Version + } + if bi, ok := debug.ReadBuildInfo(); ok { + for _, s := range bi.Settings { + if s.Key == "vcs.revision" && s.Value != "" { + return s.Value + } + } + } + return "devel" +} diff --git a/internal/dispatch/dispatch.go b/internal/dispatch/dispatch.go new file mode 100644 index 0000000..7e0028c --- /dev/null +++ b/internal/dispatch/dispatch.go @@ -0,0 +1,175 @@ +// Package dispatch is the Crucible command surface: a single registry of +// builders shared by the `crucible` dispatcher and the standalone +// `crucible-` shims. +// +// Scaffold contract (Phase 5): every builder is registered but UNWIRED. Running +// a builder fails closed (exit 70) with an operator-cutover message; it never +// fakes an artifact. The internal pipeline/topdata/erf/wiki/music packages from +// gitea/sow-tools are migrated by the operator at cutover (the workspace hard +// rules forbid transplanting source here). Once a builder's handler is wired, +// flip its Wired flag and register the handler. +package dispatch + +import ( + "fmt" + "io" + "os" + "text/tabwriter" + + "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo" +) + +// Exit codes follow the sysexits(3) convention so CI can distinguish +// "you called it wrong" from "the tool is not wired yet". +const ( + exitOK = 0 + exitUsage = 64 // EX_USAGE — bad invocation / unknown builder + exitUnwired = 70 // EX_SOFTWARE — builder registered but not yet implemented +) + +// Builder is one tool surface in the Crucible suite. +type Builder struct { + Name string // canonical dispatcher subcommand, e.g. "module" + Bin string // standalone binary name, e.g. "crucible-module" + Summary string // one-line description + Legacy []string // nwn-tool commands this subsumes (migration aid; see docs/command-surface.md) +} + +// Registry is the single source of truth for the Crucible command surface. +// Keep it in sync with docs/command-surface.md and the cmd/ directory. +var Registry = []Builder{ + { + Name: "depot", + Bin: "crucible-depot", + Summary: "verify/move content-addressed depot blobs (SeaweedFS)", + Legacy: nil, + }, + { + Name: "hak", + Bin: "crucible-hak", + Summary: "pack/unpack ERF/HAK archives + hak manifests", + Legacy: []string{"build-haks", "apply-hak-manifest"}, + }, + { + Name: "module", + Bin: "crucible-module", + Summary: "build/extract/validate/compare the .mod", + Legacy: []string{"build", "build-module", "extract", "validate", "compare"}, + }, + { + Name: "topdata", + Bin: "crucible-topdata", + Summary: "compile 2da/tlk topdata + packages", + Legacy: []string{"validate-topdata", "build-topdata", "build-top-package", "compare-topdata", "convert-topdata"}, + }, + { + Name: "wiki", + Bin: "crucible-wiki", + Summary: "render + deploy mechanical wiki pages", + Legacy: []string{"build-wiki", "deploy-wiki"}, + }, +} + +func find(name string) (Builder, bool) { + for _, b := range Registry { + if b.Name == name { + return b, true + } + } + return Builder{}, false +} + +// Main is the `crucible` dispatcher entrypoint. +func Main(args []string) int { return run(args, os.Stdout, os.Stderr) } + +// RunBuilder is the standalone `crucible-` entrypoint. +func RunBuilder(name string, args []string) int { + return runBuilder(name, args, os.Stdout, os.Stderr) +} + +func run(args []string, out, errw io.Writer) int { + if len(args) == 0 { + usage(out) + return exitUsage + } + switch args[0] { + case "-h", "--help", "help": + usage(out) + return exitOK + case "-V", "--version", "version": + fmt.Fprintln(out, buildinfo.String()) + return exitOK + case "list": + list(out) + return exitOK + } + return runBuilder(args[0], args[1:], out, errw) +} + +func runBuilder(name string, args []string, out, errw io.Writer) int { + b, ok := find(name) + if !ok { + fmt.Fprintf(errw, "crucible: unknown builder %q\n\n", name) + usage(errw) + return exitUsage + } + if len(args) > 0 { + switch args[0] { + case "-h", "--help", "help": + builderHelp(out, b) + return exitOK + } + } + // Every builder is unwired in the Phase 5 scaffold: fail closed, never fake. + fmt.Fprintf(errw, unwiredMsg, b.Name, b.Bin) + return exitUnwired +} + +const unwiredMsg = `crucible %[1]s: not wired yet. + +The Crucible suite is scaffolded (Phase 5). The %[2]s implementation is migrated +from gitea/sow-tools internal packages at operator cutover; the workspace hard +rules forbid transplanting that source into this tree automatically. + +This command fails closed (exit 70) rather than producing a fake artifact. +See docs/migration-from-nwn-tool.md. +` + +func usage(w io.Writer) { + fmt.Fprintf(w, "crucible — Shadows Over Westgate build/conversion/sync toolchain\n") + fmt.Fprintf(w, "%s\n\n", buildinfo.String()) + fmt.Fprintf(w, "usage:\n") + fmt.Fprintf(w, " crucible [args] dispatch to a builder\n") + fmt.Fprintf(w, " crucible- [args] equivalent standalone binary\n\n") + fmt.Fprintf(w, "builders:\n") + list(w) + fmt.Fprintf(w, "\nglobal commands:\n") + fmt.Fprintf(w, " help | -h show this help\n") + fmt.Fprintf(w, " version | -V show the build version\n") + fmt.Fprintf(w, " list list builders (one per line: namebinsummary)\n") + fmt.Fprintf(w, "\nNWN_ROOT must be passed explicitly (env or flag); crucible never guesses\n") + fmt.Fprintf(w, "from $HOME. See docs/consumer-contract.md.\n") +} + +func list(w io.Writer) { + tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0) + for _, b := range Registry { + fmt.Fprintf(tw, " %s\t%s\t%s\n", b.Name, b.Bin, b.Summary) + } + tw.Flush() +} + +func builderHelp(w io.Writer, b Builder) { + fmt.Fprintf(w, "crucible %s (%s) — %s\n\n", b.Name, b.Bin, b.Summary) + if len(b.Legacy) > 0 { + fmt.Fprintf(w, "subsumes nwn-tool commands: ") + for i, c := range b.Legacy { + if i > 0 { + fmt.Fprintf(w, ", ") + } + fmt.Fprintf(w, "%s", c) + } + fmt.Fprintf(w, "\n\n") + } + fmt.Fprintf(w, "status: scaffolded, not wired (Phase 5). See docs/command-surface.md.\n") +} diff --git a/internal/dispatch/dispatch_test.go b/internal/dispatch/dispatch_test.go new file mode 100644 index 0000000..bb46dc4 --- /dev/null +++ b/internal/dispatch/dispatch_test.go @@ -0,0 +1,87 @@ +package dispatch + +import ( + "bytes" + "strings" + "testing" +) + +func TestVersion(t *testing.T) { + var out, errw bytes.Buffer + for _, arg := range []string{"version", "-V", "--version"} { + out.Reset() + errw.Reset() + if code := run([]string{arg}, &out, &errw); code != exitOK { + t.Fatalf("%s: exit=%d want %d", arg, code, exitOK) + } + if !strings.HasPrefix(out.String(), "crucible ") { + t.Fatalf("%s: version line %q missing prefix", arg, out.String()) + } + } +} + +func TestHelpAndNoArgs(t *testing.T) { + var out, errw bytes.Buffer + if code := run([]string{"help"}, &out, &errw); code != exitOK { + t.Fatalf("help exit=%d want %d", code, exitOK) + } + if !strings.Contains(out.String(), "builders:") { + t.Fatalf("help output missing builders section:\n%s", out.String()) + } + // No args is a usage error (exit 64) but still prints help. + out.Reset() + if code := run(nil, &out, &errw); code != exitUsage { + t.Fatalf("no-args exit=%d want %d", code, exitUsage) + } +} + +func TestListCoversRegistry(t *testing.T) { + var out bytes.Buffer + list(&out) + for _, b := range Registry { + if !strings.Contains(out.String(), b.Name) || !strings.Contains(out.String(), b.Bin) { + t.Fatalf("list missing %s/%s:\n%s", b.Name, b.Bin, out.String()) + } + } +} + +func TestUnknownBuilderFailsUsage(t *testing.T) { + var out, errw bytes.Buffer + if code := run([]string{"frobnicate"}, &out, &errw); code != exitUsage { + t.Fatalf("unknown builder exit=%d want %d", code, exitUsage) + } + if !strings.Contains(errw.String(), "unknown builder") { + t.Fatalf("unknown builder stderr=%q", errw.String()) + } +} + +func TestEveryBuilderFailsClosed(t *testing.T) { + for _, b := range Registry { + var out, errw bytes.Buffer + // Via dispatcher. + if code := run([]string{b.Name}, &out, &errw); code != exitUnwired { + t.Errorf("crucible %s: exit=%d want %d (must fail closed)", b.Name, code, exitUnwired) + } + if !strings.Contains(errw.String(), "not wired") { + t.Errorf("crucible %s: stderr missing fail-closed message: %q", b.Name, errw.String()) + } + // Via standalone shim path. + out.Reset() + errw.Reset() + if code := runBuilder(b.Name, nil, &out, &errw); code != exitUnwired { + t.Errorf("%s: exit=%d want %d (must fail closed)", b.Bin, code, exitUnwired) + } + } +} + +func TestBuilderHelpIsOK(t *testing.T) { + for _, b := range Registry { + var out, errw bytes.Buffer + if code := runBuilder(b.Name, []string{"--help"}, &out, &errw); code != exitOK { + t.Errorf("%s --help: exit=%d want %d", b.Name, code, exitOK) + } + if !strings.Contains(out.String(), b.Summary) { + t.Errorf("%s --help: missing summary", b.Name) + } + } +} diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh new file mode 100755 index 0000000..07456fc --- /dev/null +++ b/scripts/build-binaries.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Build every Crucible binary in cmd/ into ./bin. +# +# Replaces the old single-binary build-tool.sh. Outputs are gitignored — they +# are CI artifacts / image layers, never committed (D19). The short git sha is +# stamped into the version via -ldflags. +set -euo pipefail + +cd "$(dirname "$0")/.." + +sha="$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)" +ldflags="-X gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo.Version=${sha}" + +mkdir -p bin .cache/go-build +export GOCACHE="${PWD}/.cache/go-build" + +for dir in ./cmd/*/; do + name="$(basename "$dir")" + echo "building ${name}" + go build -trimpath -ldflags "${ldflags}" -o "bin/${name}" "${dir}" +done + +echo "built into ./bin: $(cd bin && echo *)" diff --git a/scripts/crucible-smoke.sh b/scripts/crucible-smoke.sh new file mode 100755 index 0000000..4dc600d --- /dev/null +++ b/scripts/crucible-smoke.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Smoke test the built Crucible binaries: +# - the dispatcher reports version + lists builders +# - every builder fails closed (exit 70) until it is wired (Phase 5 scaffold) +# - each standalone shim agrees with the dispatcher +set -euo pipefail + +cd "$(dirname "$0")/.." +bin=bin + +[[ -x "${bin}/crucible" ]] || { echo "no ${bin}/crucible — run scripts/build-binaries.sh first" >&2; exit 1; } + +"${bin}/crucible" version >/dev/null +"${bin}/crucible" list >/dev/null + +builders=(depot hak module topdata wiki) +for b in "${builders[@]}"; do + [[ -x "${bin}/crucible-${b}" ]] || { echo "missing ${bin}/crucible-${b}" >&2; exit 1; } + + set +e + "${bin}/crucible" "${b}" >/dev/null 2>&1 + via_dispatch=$? + "${bin}/crucible-${b}" >/dev/null 2>&1 + via_shim=$? + set -e + + if [[ "${via_dispatch}" -ne 70 ]]; then + echo "FAIL: crucible ${b} exit=${via_dispatch}, want 70 (unwired must fail closed)" >&2 + exit 1 + fi + if [[ "${via_shim}" -ne 70 ]]; then + echo "FAIL: crucible-${b} exit=${via_shim}, want 70 (must match dispatcher)" >&2 + exit 1 + fi +done + +echo "smoke ok: dispatcher + ${#builders[@]} builders fail closed as expected"