Phase 5: scaffold Crucible (sow-tools) — dispatcher + builder shims, container, PR-first CI, fail-closed (D11, D19).
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Build the Crucible image. PR-first (D7): PRs build only; push to main also
|
||||||
|
# publishes registry.westgate.pw/sow/crucible:<git-sha>. 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 }}
|
||||||
@@ -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 :<sha> 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:<sha> as :<tag>
|
||||||
|
# and pin it from sow-platform releases/*.yml once the registry is live.
|
||||||
@@ -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
|
||||||
+20
@@ -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/
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
external-sources=true
|
||||||
|
source-path=SCRIPTDIR
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
extends: relaxed
|
||||||
|
rules:
|
||||||
|
line-length: disable
|
||||||
|
comments:
|
||||||
|
min-spaces-from-content: 1
|
||||||
|
document-start: disable
|
||||||
|
truthy:
|
||||||
|
check-keys: false
|
||||||
@@ -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-<name>` 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-<name>/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:<sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
Never commit, branch, or push. Suggest a commit message; let the operator do it.
|
||||||
@@ -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) .
|
||||||
@@ -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 <builder> [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:<sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
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:<sha>`; 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).
|
||||||
@@ -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:])) }
|
||||||
@@ -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:])) }
|
||||||
@@ -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:])) }
|
||||||
@@ -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:])) }
|
||||||
@@ -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:])) }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Command crucible is the Crucible dispatcher: `crucible <builder> [args]`.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/dispatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() { os.Exit(dispatch.Main(os.Args[1:])) }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# Crucible toolchain image: registry.westgate.pw/sow/crucible:<git-sha>
|
||||||
|
#
|
||||||
|
# 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-<name> 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"]
|
||||||
@@ -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: name<TAB>bin<TAB>summary
|
||||||
|
```
|
||||||
|
|
||||||
|
`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`.
|
||||||
@@ -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-<name>` 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:<sha> # 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.
|
||||||
@@ -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`.
|
||||||
Generated
+27
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module gitea.westgate.pw/ShadowsOverWestgate/sow-tools
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
@@ -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=<sha>"
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -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-<name>` 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-<name>` 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 <builder> [args] dispatch to a builder\n")
|
||||||
|
fmt.Fprintf(w, " crucible-<builder> [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: name<TAB>bin<TAB>summary)\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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+23
@@ -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 *)"
|
||||||
Executable
+37
@@ -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"
|
||||||
Reference in New Issue
Block a user