spec
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
# Crucible build parity — local == CI, zero-config reproducible
|
||||
|
||||
Date: 2026-06-21
|
||||
Status: approved (design); implementation pending
|
||||
Repos touched: `sow-tools` (Crucible + contract doc + image notes),
|
||||
`sow-topdata` (config, flake, wrapper, CI), `sow-assets-manifest` (flake pin),
|
||||
and the consumer wrapper/flake pattern shared by every Crucible repo.
|
||||
|
||||
Supersedes the resolver-script approach in
|
||||
`2026-06-21-topdata-vfx-autogen-channel-design.md` (the channel→tag→`vfxs.yml`
|
||||
resolution moves out of a bash wrapper and into Crucible).
|
||||
|
||||
## Problem
|
||||
|
||||
`crucible topdata build-topdata` run by hand does **not** reproduce the CI
|
||||
build. CI runs `scripts/build-topdata.sh`, which resolves the accessory-VFX
|
||||
model list with `scripts/resolve-accessory-vfx.sh` and writes
|
||||
`.cache/sow-accessory-vfx-manifest.json` **before** Crucible runs; Crucible only
|
||||
reads that pre-staged file (`nwn-tool.yaml: manifest_file:`). Run Crucible
|
||||
directly and the file never exists, the `optional` consumer skips, and
|
||||
`visualeffects.2da` ships **zero accessory rows** — exactly the local build the
|
||||
operator observed (637 rows in CI, 0 locally).
|
||||
|
||||
Root cause: a **build-output-affecting input is resolved outside Crucible**, in
|
||||
a wrapper script. The wrapper, not Crucible, is authoritative for that input.
|
||||
|
||||
## Goal
|
||||
|
||||
Crucible is the single authority for everything that lands in a build artifact.
|
||||
A clone of any consumer repo, on any system, builds the same bytes CI builds —
|
||||
with no secret beyond what cloning already required and no hand-written
|
||||
configuration.
|
||||
|
||||
### Zero-config reproducibility requirements
|
||||
|
||||
- **R1 — No extra secrets.** All build-input reads are anonymous: the asset
|
||||
channel pointer (`releases/haks/channels.json`) and `vfxs.yml` are public CDN
|
||||
text. The crucible binary is fetchable anonymously — **verified**: the
|
||||
sow-tools release assets return HTTP 200 without auth, and the bootstrap tries
|
||||
anon before any token, so `CRUCIBLE_TOKEN` in consumer CI is **vestigial and is
|
||||
removed**. Git-LFS asset bytes use the **same** credentials the clone already
|
||||
used — never a separate key.
|
||||
- **R2 — No hand-written config.** Every default needed to build is baked into
|
||||
committed `nwn-tool.yaml` and Crucible defaults: CDN base, channel selection,
|
||||
source paths. The user types `crucible topdata build-topdata` and nothing else.
|
||||
- **R3 — No host data assumptions, no `NWN_ROOT`.** Builders read every input
|
||||
from the project tree (`nwn-tool.yaml` + committed `data/**/base.json`); no NWN
|
||||
install is required. `NWN_ROOT` is **dead** today — nothing in Crucible reads it
|
||||
(see *NWN_ROOT cleanup* below). The accessory build reads
|
||||
`data/visualeffects/base.json`. (Enforced by the CI parity guard, which runs in
|
||||
a bare checkout with no `NWN_ROOT`.)
|
||||
- **R4 — Nix is convenience, not a requirement.** The flake devshell provides
|
||||
`crucible`, `git-lfs`, `jq`, `yq`, etc. on `PATH` for Nix users. Non-Nix users
|
||||
(incl. Windows) get the same Crucible via the `crucible.sh`/`crucible.ps1`
|
||||
bootstrap and a host `git-lfs`; the build path through Crucible is identical.
|
||||
- **R5 — Same Crucible, both worlds.** Local dev and CI invoke the same Crucible
|
||||
subcommand against the same committed config. No CI-only pre-steps that change
|
||||
output.
|
||||
|
||||
## Parity contract (prevents recurrence)
|
||||
|
||||
Added to `sow-tools/docs/consumer-contract.md`. A consumer build wrapper may only:
|
||||
|
||||
1. **validate** — a pre-build gate that does not change output;
|
||||
2. **publish** — post-build, on a separate artifact;
|
||||
3. **resolve the crucible binary** — the bootstrap in `consumer-contract.md`.
|
||||
|
||||
A wrapper MUST NOT fetch, resolve, generate, or stage any input Crucible reads
|
||||
to produce the artifact. If Crucible needs an input, Crucible acquires it. This
|
||||
is the existing "no local-machine assumptions" + "same inputs → identical
|
||||
output" rule, made explicit: **the wrapper is not allowed to be authoritative
|
||||
for build inputs.**
|
||||
|
||||
## Design
|
||||
|
||||
### A. Accessory-VFX resolution moves into Crucible
|
||||
|
||||
**Config (`sow-topdata/nwn-tool.yaml`).** Drop `manifest_file:`; declare the
|
||||
source on the consumer. The 4-group + `mdl` filter is **not** re-declared — it
|
||||
is derived from the keys already present under `accessory_visualeffects.groups`:
|
||||
|
||||
```yaml
|
||||
autogen:
|
||||
consumers:
|
||||
- id: accessory_visualeffects
|
||||
producer: accessory_visualeffects
|
||||
dataset: visualeffects
|
||||
mode: accessory_visualeffects
|
||||
optional: true
|
||||
source:
|
||||
kind: cdn_channel
|
||||
cdn_base_env: BUNNY_CDN_BASE # default https://depot.westgate.pw
|
||||
channels_path: releases/haks/channels.json
|
||||
manifest_path: releases/haks/{tag}/vfxs.yml
|
||||
release_marker_path: releases/haks/{tag}/haks.json # the broken-release guard probe
|
||||
channel_env: SOW_TOPDATA_ASSET_CHANNEL # else derive from git tag
|
||||
offline_override_env: SOW_VFXS_MANIFEST # dev/air-gapped vfxs.yml path
|
||||
accessory_visualeffects:
|
||||
groups: { chest_accessories: {...}, head_accessories: {...},
|
||||
head_decorations: {...}, head_features: {...} }
|
||||
# ... rest of the policy block unchanged
|
||||
```
|
||||
|
||||
New struct field on `AutogenConsumerConfig` (sow-tools
|
||||
`internal/project/project.go`): `Source AutogenSourceConfig` with the keys
|
||||
above. Validation: when `source.kind == cdn_channel`, `channels_path` and
|
||||
`manifest_path` are required and `manifest_path` must contain `{tag}`.
|
||||
|
||||
**Resolution (`internal/topdata/autogen.go`,
|
||||
`resolveAutogenConsumerManifest`).** A new branch for `source.kind ==
|
||||
cdn_channel`, reusing the existing `fetchJSON` HTTP client and the project YAML
|
||||
parser:
|
||||
|
||||
1. **Offline override.** If `offline_override_env` names a readable file (or a
|
||||
manifest-repo checkout root containing `assets/vfxs.yml`), parse it and skip
|
||||
the network — the dev/air-gapped path.
|
||||
2. **Channel.** `channel_env` if set; else from the git tag / `GITHUB_REF_NAME`
|
||||
(`v*-*` → `testing`, `v*` → `current`, otherwise `current`).
|
||||
3. **CDN base.** `cdn_base_env` value, else the baked default.
|
||||
4. `GET {cdn}/{channels_path}` → JSON → `tag = channels[channel]`.
|
||||
5. `GET {cdn}/{manifest_path with {tag}}` → parse YAML `assets[]`.
|
||||
6. **Filter** to `restype == mdl` whose `path` is under `vfxs/<g>/` for `<g>` in
|
||||
the `accessory_visualeffects.groups` keys. Derive each entry's `source`
|
||||
(leading `vfxs/` stripped), `group`, `subgroup`, `model_stem`. Sort by
|
||||
`source`. Hand the entries to the existing accessory augmentor unchanged.
|
||||
|
||||
**Fail policy (ported verbatim from the resolver — this is the v0.1.4-bug
|
||||
guard).** Distinguish *unreachable* (fail open) from *broken* (hard fail):
|
||||
|
||||
- channels.json unreachable / not JSON / channel absent → **fail open**: the
|
||||
`optional` consumer contributes no rows and `data/visualeffects/lock.json` IDs
|
||||
are preserved.
|
||||
- vfxs.yml network error, or 404 **and** the `release_marker_path` (haks.json)
|
||||
is **absent** for that tag (no published release) → **fail open**.
|
||||
- vfxs.yml 404 **and** haks.json **present** → **HARD fail** ("release `<tag>`
|
||||
has haks.json but no vfxs.yml — accessory rows would silently vanish").
|
||||
- vfxs.yml present but malformed / no `assets` array → **HARD fail**.
|
||||
|
||||
Fail-open writes nothing and lets the optional consumer skip; hard-fail aborts
|
||||
the build with the message above.
|
||||
|
||||
**Deleted:** `sow-topdata/scripts/resolve-accessory-vfx.sh`,
|
||||
`tests/resolve-accessory-vfx-contract.sh`, the `manifest_file:` key, and the
|
||||
resolver pre-step at `build-topdata.sh:19`. The four bash contract cases
|
||||
(offline override, unreachable fail-open, broken-release hard-fail, no-release
|
||||
fail-open) are re-expressed as Go tests in `internal/topdata`.
|
||||
|
||||
### B. Git-LFS materialization moves into Crucible
|
||||
|
||||
So a bare clone + `crucible` packs real bytes, not pointer stubs (today only
|
||||
`build-topdata.sh` pulls LFS, so direct Crucible would ship stubs in CI's
|
||||
non-smudging checkout).
|
||||
|
||||
Before packing assets into a hak, Crucible: detects git-LFS pointer stubs under
|
||||
the asset tree; if any, runs `git lfs install --local` then `git lfs pull` in
|
||||
the repo; re-checks and **hard-fails** if stubs remain ("refusing to build from
|
||||
pointer stubs"). Uses the clone's own credentials (R1). Requires `git` +
|
||||
`git-lfs` on `PATH` — provided by the Nix devshell, a standard host install
|
||||
otherwise (a clone without git-lfs already yields stubs, so this is the existing
|
||||
host expectation, now enforced loudly by Crucible instead of a wrapper).
|
||||
|
||||
A skip control replaces `TOPDATA_SKIP_LFS` for the maintain-tree flow, which
|
||||
regenerates the `data/` tree and **discards** the package: a Crucible flag/env
|
||||
(e.g. `--skip-lfs` / `CRUCIBLE_SKIP_LFS`) so that one job can opt out. Default
|
||||
is fail-closed (pull).
|
||||
|
||||
### C. Wrappers thin out
|
||||
|
||||
`scripts/build-topdata.sh` (and the shared pattern in every Crucible repo)
|
||||
reduces to: validate (gate) → resolve crucible → `crucible <build>`. No input
|
||||
resolution, no LFS pull. `sow-module/scripts/package-module.sh` already matches
|
||||
this shape (validate → resolve → build); it stays the reference.
|
||||
|
||||
### D. CI parity guard (catches the next regression)
|
||||
|
||||
A `sow-tools` integration test builds the topdata package via Crucible **alone**
|
||||
in a bare checkout (no wrapper, clean `.cache`, no `NWN_ROOT`, anonymous CDN)
|
||||
and asserts `visualeffects.2da` contains accessory rows. This is the test that
|
||||
would have caught today's bug: it fails if any build-output-affecting step ever
|
||||
drifts back out of Crucible into a wrapper, and it enforces R2/R3/R5 mechanically.
|
||||
|
||||
The contract rule in `consumer-contract.md` is the written half; this test is
|
||||
the enforced half.
|
||||
|
||||
### E. NWN_ROOT cleanup (stale, contradicts the goal)
|
||||
|
||||
`NWN_ROOT` is unused — no `os.Getenv`, no `--nwn-root` flag, no consumer. The
|
||||
only traces are documentation/help-text describing a rule for a thing that does
|
||||
not exist: the `## NWN_ROOT rule` section in `consumer-contract.md`, the help
|
||||
text in `internal/dispatch/dispatch.go` ("if a builder ever needs an NWN
|
||||
root… crucible never guesses from $HOME"), and the `README.md` pointer. That
|
||||
old rule — *never guess, require an explicit env* — directly contradicts the
|
||||
zero-config goal (R2): it would make a future builder demand a hand-set path.
|
||||
|
||||
- **Remove** the `NWN_ROOT` references: the `consumer-contract.md` section, the
|
||||
dispatch help-text lines, the README pointer. Leave the
|
||||
`migration-from-nwn-tool.md` bullet as historical record.
|
||||
- **Replace the forward rule.** Should a future builder ever need NWN game data,
|
||||
it **auto-detects** it (the NWN home — containing `modules/`, `hak/`, `tlk/` —
|
||||
and the Steam install path are reliably discoverable), with an optional
|
||||
explicit override for non-standard layouts. Auto-detect first, never a required
|
||||
hand-set env. This keeps "clone → run → Just Work" intact even for a builder
|
||||
that one day reads game data. None do today.
|
||||
|
||||
### F. Toolchain convergence — one Crucible, three modes
|
||||
|
||||
Parity needs everyone to run the *same* Crucible. Today three mechanisms
|
||||
diverge: non-Nix uses the `crucible.sh` bootstrap download; sow-assets-manifest's
|
||||
flake pins `crucible v0.2.5` via `fetchurl`+hash; sow-topdata's flake ships no
|
||||
crucible at all (falls back to the bootstrap, with a needless `CRUCIBLE_TOKEN`).
|
||||
The pin is scattered (a hardcoded flake version, none in another flake, an image
|
||||
sha in `prod.yml`, a HEAD-floating CI binary build).
|
||||
|
||||
Converge on **one binary, one pin, three modes — all anonymous, all identical**:
|
||||
|
||||
1. **Non-Nix (local + CI fallback).** `crucible.sh` / `crucible.ps1` bootstrap
|
||||
downloads the release binary anonymously. `CRUCIBLE_TOKEN` is dropped from
|
||||
consumer CI (R1).
|
||||
2. **Nix (local + CI optimized).** Every consumer flake provides `crucible` in
|
||||
its devshell from a **sow-tools flake input**, pinned by that repo's
|
||||
`flake.lock`. Idiomatic, reproducible, no manual hashes; bump with
|
||||
`nix flake update`. This replaces sow-assets-manifest's manual `fetchurl`
|
||||
pin and **adds crucible to sow-topdata's devshell** (today it has none).
|
||||
3. **CI** runs the *same* `crucible <build>` as local, inside the nix devshell
|
||||
(binary-cache fast). No build container, no token, no CI-only pre-steps.
|
||||
|
||||
**Pin source of truth = `flake.lock` per repo**, bumped deliberately in a PR. We
|
||||
**reject floating "latest"**: it would let local drift from CI between releases
|
||||
and break reproducibility. The non-Nix bootstrap, which resolves "latest" by
|
||||
default, is the convenience path; reproducible/parity-critical builds pin (nix
|
||||
devshell, or `CRUCIBLE_*` pin env for the bootstrap).
|
||||
|
||||
### G. The crucible container is deployment-only
|
||||
|
||||
`sow-tools` builds and publishes `registry.westgate.pw/deployment/crucible:<sha>`
|
||||
on `v*` tags (`nix build .#image` → `skopeo`). It exists for **deployment** —
|
||||
`prod.yml` pins it so tools travel with the runtime host (`nwn.enable`), and it
|
||||
is a **disabled placeholder** until the NWN stack lands. No build/CI job consumes
|
||||
it, and the host-mode runners have no container runtime to run it. It is **not a
|
||||
build tool.**
|
||||
|
||||
- Keep the image build (cheap, `v*`-gated) for its deployment purpose.
|
||||
- **Fix `consumer-contract.md`:** remove the crossed-out "run CI inside the
|
||||
pinned image" guidance and its OPERATOR NOTE; state plainly that builds use the
|
||||
binary (bootstrap or nix devshell) and the image is deployment-only. This kills
|
||||
the "why do we build a container we don't use?" confusion.
|
||||
|
||||
### H. Autogen lock identity — model-anchored, immutable
|
||||
|
||||
**General lock contract (hard rule, all datasets).** A dataset lock maps a stable
|
||||
key to a row id. The build MUST: reuse the existing id for a key that still
|
||||
exists; allocate the first free id (lexicographic 0→1→2…) for a new key; and free
|
||||
an id only when its key is gone everywhere. An id, once assigned to a live key,
|
||||
never changes. (Already implemented for the accessory consumer via
|
||||
`historicalLockData` reuse + `nextAvailableAutogenID`; this section makes it the
|
||||
written rule and fixes the autogen-specific anchor below.)
|
||||
|
||||
**The autogen problem.** Autogen rows describe models that do **not** exist in
|
||||
`sow-topdata/data/` — they live in the asset `vfxs.yml` manifest. Today the lock
|
||||
key is the *config-derived presentation key*:
|
||||
`KeyFormat = {dataset}:{group}{delimiter}{category_segment}{stem}`. Group token,
|
||||
category, delimiter, case, and prefix-stripping all come from `nwn-tool.yaml`, so
|
||||
a **config-only** change (`key_format`, `delimiter`, `case`, a group rename,
|
||||
`category_from`, `strip_model_prefixes`) changes the key → the lock no longer
|
||||
matches → a new id is allocated → **every row shifts**. That is a config edit
|
||||
silently reshuffling ids for models that never changed.
|
||||
|
||||
**Fix — anchor the lock identity on the dataset + model, not the presentation.**
|
||||
The lock key for an autogen row becomes `{dataset}:{model_source_path}` — the
|
||||
stable `dataset` namespace prefix (kept as the 2da/namespace linker developers use
|
||||
instead of raw ids) plus the **model source path** from `vfxs.yml`, e.g.
|
||||
`visualeffects:head_accessories/hat/hfx_bandana.mdl` (leading `vfxs/` stripped —
|
||||
the path is the value already on `entry.Source`). Dropped from the key are only
|
||||
the config-derived parts (group token, `category_segment`, `delimiter`, case,
|
||||
prefix-stripping). The row's `key`/`label` *columns* still derive from config via
|
||||
`KeyFormat`/`LabelFormat` — only the **lock identity** is decoupled and
|
||||
model-anchored. Consequences:
|
||||
|
||||
- A config-only change keeps every id (the path is unchanged).
|
||||
- A model re-export (same path, new `sha256`) keeps its id — a content change is
|
||||
not an identity change. (Audit trail comes free from `git diff` on the committed
|
||||
`vfxs.yml`, which carries `path` + `sha256`; the lock stays a flat `string→int`
|
||||
map — no schema change, no fork of the shared lock format. A future `id`+`sha`
|
||||
object form is a clean later add if observability ever needs it, without
|
||||
changing id semantics.)
|
||||
- A renamed/moved/removed model is a genuinely different (or absent) identity.
|
||||
|
||||
**One-time cutover remap.** On the first build under `{dataset}:{path}` keys, for
|
||||
each current model compute its *old* config-key (with the current policy) and its
|
||||
new `{dataset}:{path}` key; if the new key is absent but the old key carries an id
|
||||
in the lock, adopt that id under the new key. So ids carry over with zero shift on
|
||||
cutover; the now-unreferenced old keys fall stale and are pruned (below).
|
||||
|
||||
**Stale pruning.** A lock key absent from the resolved entry set is dropped and
|
||||
its id freed for first-free reuse — but **only on a successful, non-empty
|
||||
resolution**. The fail-open paths (section A) contribute no entries and skip the
|
||||
augmentor entirely, so an unreachable/missing manifest can never mass-prune the
|
||||
lock. This realizes the general "free a fully-stale id" rule without risking the
|
||||
lock on a transient source outage.
|
||||
|
||||
**Testing.** Go tests in `internal/topdata`: config-only change (rename a group
|
||||
token, change `key_format`/`delimiter`/`case`) → ids unchanged; model re-export
|
||||
(same path, different sha) → id unchanged; model rename/remove → old id freed and
|
||||
reusable; cutover remap → existing ids preserved across the key-scheme switch;
|
||||
fail-open → no pruning, lock untouched.
|
||||
|
||||
## Determinism vs. channel mutability (decision)
|
||||
|
||||
The committed topdata commit pins everything **except** the asset channel
|
||||
pointer. `current` resolves through `channels.json`, which is intentionally
|
||||
mutable, so "reproducible" here means **same commit + same channel state →
|
||||
identical bytes**, not "this commit is frozen forever." This is deliberate: the
|
||||
accessory rows must track the asset HAKs shipped beside `sow_top.hak`, which is
|
||||
exactly what the channel pointer expresses. A given asset release **tag** is
|
||||
immutable; the pointer moving is a real input change, and `lock.json` preserves
|
||||
accessory IDs across it (no reshuffle). Builds against an explicit tag
|
||||
(`SOW_TOPDATA_ASSET_CHANNEL` set, or a `v*` ref) are fully pinned.
|
||||
|
||||
This satisfies R1–R5: no secret, no config, deterministic for a fixed input
|
||||
set, on any system.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Go unit tests (`sow-tools/internal/topdata`)** for the `cdn_channel`
|
||||
resolution: offline-override path; unreachable channels.json → fail open;
|
||||
channel absent → fail open; vfxs.yml 404 + no haks.json → fail open; vfxs.yml
|
||||
404 + haks.json present → hard fail (asserts message); malformed vfxs.yml →
|
||||
hard fail; happy path → correct filtered/sorted entries (mdl-only, 4-groups-
|
||||
only, `vfxs/` stripped, group/subgroup/stem). Mirrors the deleted bash
|
||||
contract cases.
|
||||
- **Go config validation test** for the new `source` block.
|
||||
- **CI parity guard** (section D) in the topdata build workflow.
|
||||
- Existing accessory augmentor / lock-preserve tests unchanged.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. sow-tools: add `AutogenSourceConfig`, the `cdn_channel` resolution branch,
|
||||
LFS materialization, the model-anchored lock identity + cutover remap + stale
|
||||
pruning (section H), the parity-guard test; update `consumer-contract.md`
|
||||
(add the parity contract, remove the `NWN_ROOT` rule per section E, replace the
|
||||
image guidance with deployment-only per section G).
|
||||
2. Release Crucible (assets already anon-downloadable — R1).
|
||||
3. Toolchain convergence (section F): expose `crucible` from a sow-tools flake
|
||||
input in each consumer flake — add it to sow-topdata's devshell, switch
|
||||
sow-assets-manifest off its manual `fetchurl` pin; drop `CRUCIBLE_TOKEN` from
|
||||
consumer CI; CI builds inside the nix devshell.
|
||||
4. sow-topdata: swap `manifest_file:` → `source:` in `nwn-tool.yaml`; delete the
|
||||
resolver script + bash contract test; thin `build-topdata.sh` (validate →
|
||||
resolve crucible → build); pin Crucible via `flake.lock`.
|
||||
5. Verify: bare clone → `crucible topdata build-topdata` → 637 accessory rows,
|
||||
no env, no key; and the same command inside `nix develop`.
|
||||
|
||||
## Follow-ups (not this work)
|
||||
|
||||
- Parts `2da` / `cachedmodels` reinstatement (separate spec) can reuse the same
|
||||
`cdn_channel` source type.
|
||||
- Roll the thinned-wrapper pattern review across `sow-module` / `sow-codebase`.
|
||||
Reference in New Issue
Block a user