Compare commits

..
3 Commits
Author SHA1 Message Date
archvillainette bc8fa3a6fe topdata: stop hardcoding the skills dataset location (#43)
test / test (push) Successful in 1m24s
build-binaries / build-binaries (push) Successful in 2m8s
sow-topdata moved skills into a tree (skills/core, skills/specs, skillcats, skillspecranks), which broke validate/build: the generated feat families hardcoded dataset "skills".

Resolution is now data-driven: the family spec's child_source names the dataset, child slugs come from the row key's own namespace, and the wiki loader accepts both the flat and tree layouts (topdata main still uses the flat one until the overhaul merges).

Verified against the skill-grouping-overhaul topdata branch: validate/build/wiki all clean with zero lock churn, focus feats stay on their adopted rows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #43
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-19 07:33:19 +00:00
archvillainette 24e57457b0 topdata: legacy aliases for skill-grouping renames (#42)
test / test (push) Successful in 1m27s
build-binaries / build-binaries (push) Successful in 2m11s
Extends legacyFamilyChildAliases so skill focus / greater skill focus children adopt the stock feat rows of the skills they were renamed from: medicine<-heal, investigation<-search, diplomacy<-influence/persuade, security<-open lock, intimidate<-taunt.

Existing craft_*/influence/disabledevice aliases stay: sow-topdata main still carries those skills until the overhaul merges. Needs a Crucible release before sow-topdata CI picks this up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #42
Reviewed-by: xtul <mpiasecki720@protonmail.com>
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-19 00:20:04 +00:00
archvillainette 43fe5e0373 Retire the Crucible container image (#41)
build-binaries / build-binaries (push) Successful in 2m10s
test / test (push) Successful in 1m23s
## Summary

The `registry.westgate.pw/deployment/crucible` image has no consumer: `prod.yml` no longer reserves a slot for it (contract settled in sow-platform PR #64) and no runtime or recovery path pulls it. Binaries, wrappers, and the Nix input remain the supported ways to run Crucible.

## What changed

- Deleted `.gitea/workflows/build-image.yml`, `publish-image.yml`, `test-image.yml`
- Deleted `docker/Dockerfile` and the `make image` target
- Updated README, AGENTS.md, and `docs/consumer-contract.md` to state the image is retired

Existing `deployment/crucible` images in the registry can be deleted at leisure; nothing references them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #41
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-17 07:26:58 +00:00
11 changed files with 55 additions and 192 deletions
-52
View File
@@ -1,52 +0,0 @@
# Publish the immutable crucible:<sha> image on version tags.
# PR/main test builds live in test-image.yml and never publish, so registry
# packages are only generated for releases we intend to use (not on every merge).
#
# Daemonless: the host-mode runner has no container runtime, so the image is
# built by Nix (`nix build .#image`, see flake.nix) and pushed with skopeo
# straight from the OCI tarball. No `docker build`/`docker login` involved.
name: build-image
on:
push:
tags: ['v*']
env:
REGISTRY: registry.westgate.pw
IMAGE: deployment/crucible
jobs:
publish:
runs-on: nix-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 OCI image (daemonless)
run: nix build .#image
# This workflow only runs on v* tags, so every run is a release publish.
- name: Publish image
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
nix shell nixpkgs#skopeo -c skopeo copy \
--dest-creds "${REGISTRY_USER}:${REGISTRY_PASSWORD}" \
docker-archive:result \
"docker://${REGISTRY}/${IMAGE}:${{ steps.tag.outputs.sha }}"
- name: Emit release fragment
run: |
FRAG_REPO=sow-tools \
FRAG_SHA=${{ steps.tag.outputs.sha }} \
FRAG_ARTIFACT="${REGISTRY}/${IMAGE}:${{ steps.tag.outputs.sha }}" \
FRAG_URL="${REGISTRY}/${IMAGE}" \
FRAG_RUN_ID=${{ gitea.run_id }} \
bash scripts/emit-release-fragment.sh
- uses: actions/upload-artifact@v3
with: { name: release-fragment, path: release-fragment.json }
-49
View File
@@ -1,49 +0,0 @@
# Manual compatibility publisher for the Crucible image.
# Normal release publishing happens in build-image.yml on v* tags; this is the
# break-glass / re-publish path, triggered by hand.
#
# Daemonless: built by Nix (`nix build .#image`) and pushed with skopeo from the
# OCI tarball. No `docker build`/`docker login` involved.
name: publish-image
on:
workflow_dispatch:
env:
REGISTRY: registry.westgate.pw
IMAGE: deployment/crucible
jobs:
publish:
runs-on: nix-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 OCI image (daemonless)
run: nix build .#image
- name: Publish image
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
nix shell nixpkgs#skopeo -c skopeo copy \
--dest-creds "${REGISTRY_USER}:${REGISTRY_PASSWORD}" \
docker-archive:result \
"docker://${REGISTRY}/${IMAGE}:${{ steps.tag.outputs.sha }}"
- name: Emit release fragment
run: |
FRAG_REPO=sow-tools \
FRAG_SHA=${{ steps.tag.outputs.sha }} \
FRAG_ARTIFACT="${REGISTRY}/${IMAGE}:${{ steps.tag.outputs.sha }}" \
FRAG_URL="${REGISTRY}/${IMAGE}" \
FRAG_RUN_ID=${{ gitea.run_id }} \
bash scripts/emit-release-fragment.sh
- uses: actions/upload-artifact@v3
with: { name: release-fragment, path: release-fragment.json }
-19
View File
@@ -1,19 +0,0 @@
# Test-build the Crucible image on PRs. Proves `nix build .#image` still works
# (daemonless, Nix-built OCI tarball) but does NOT publish — release publishing
# happens in build-image.yml on v* tags. PR-only: with up-to-date-before-merge
# protection, main == the tested PR head, so a throwaway post-merge rebuild that
# publishes nothing is pure waste.
name: test-image
on:
pull_request:
jobs:
build-image:
runs-on: nix-docker
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Build OCI image (daemonless, no publish)
run: nix build .#image
+4 -4
View File
@@ -41,13 +41,14 @@ section and `.gitea/workflows/`.
- Dev loop: `nix develop`, then `make check` / `make build` / `make smoke`
(see Commands below).
- Release: push a `v*` tag. CI uploads cross-built binaries + wrappers to the
Gitea release and publishes the `crucible` container image.
Gitea release. There is no container image; Crucible ships as binaries,
wrappers, and the Nix input.
- Consumers (they download released binaries via the wrapper; they never
vendor a toolkit):
- sow-module — https://git.westgate.pw/ShadowsOverWestgate/sow-module
- sow-topdata — https://git.westgate.pw/ShadowsOverWestgate/sow-topdata
- sow-assets-manifest — https://git.westgate.pw/ShadowsOverWestgate/sow-assets-manifest
- sow-platform (deploys the released image/pins) —
- sow-platform (infra/deploy authority) —
https://git.westgate.pw/ShadowsOverWestgate/sow-platform
## What this repo owns / does not own
@@ -62,7 +63,7 @@ is `sow-platform`).
1. **Fail closed, never fake.** A builder with no migrated logic yet (`depot`)
exits `70`. Do not stub a builder to emit a placeholder artifact.
2. **Binaries are not committed.** They are CI artifacts / image layers.
2. **Binaries are not committed.** They are CI artifacts.
`/bin/`, `*.exe`, `nwn-tool`, `sow-toolkit` are gitignored.
3. **The registry is the command surface.** `internal/dispatch.Registry` is the
single source of truth; keep it in sync with `cmd/` and
@@ -81,7 +82,6 @@ is `sow-platform`).
nix develop && make check # vet + test + shellcheck + yamllint
make build # cmd/* -> ./bin
make smoke # assert fail-closed contract
make image # crucible:<sha>
```
## Tests
+1 -7
View File
@@ -1,6 +1,6 @@
SHELL := bash
.ONESHELL:
.PHONY: check test vet build smoke fmt image
.PHONY: check test vet build smoke fmt
# Lint + unit tests. Mirrors the `test` CI job; runs green inside `nix develop`.
check: vet test
@@ -22,9 +22,3 @@ smoke: build
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) .
+1 -6
View File
@@ -71,10 +71,9 @@ nix develop # Go + 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).
Binaries are **never committed** — they are CI artifacts (D19).
This retires the old habit of checking in `nwn-tool` / `sow-toolkit`.
## CI
@@ -84,12 +83,8 @@ publish event is a `v*` tag (see `runbooks/ci-trigger-standard.md` in sow-docs,
https://git.westgate.pw/ShadowsOverWestgate/sow-docs).
- `test.yml` — vet, test, shellcheck, yamllint, binary smoke (PR + main).
- `test-image.yml` — build the OCI image to prove it compiles (PR + main, no push).
- `build-binaries.yml` — cross-build all targets (PR + main); on a `v*` tag, upload
the binaries, `SHA256SUMS`, and the wrappers to the Gitea release.
- `build-image.yml` — on a `v*` tag, build and publish
`registry.westgate.pw/deployment/crucible:<sha>`.
- `publish-image.yml` — manual `workflow_dispatch` break-glass republish.
- `sync-wrappers.yml` — on a `main` push that touches `wrappers/`, auto-PR the
canonical wrappers to the consumer repos in `wrappers/consumers.txt`.
Consumer drift checks run after those PRs merge to `main`, not on the PRs
-38
View File
@@ -1,38 +0,0 @@
# syntax=docker/dockerfile:1
#
# Crucible toolchain image: registry.westgate.pw/deployment/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.
#
FROM golang:1.26-alpine AS build
WORKDIR /src
RUN apk add --no-cache git
# go.sum is committed now that the migrated packages pull golang.org/x/text and
# gopkg.in/yaml.v3; the glob keeps the build working if it is ever absent.
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 git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo.Version=${GIT_SHA}" \
-o "/out/${name}" "${dir}"; \
done
FROM debian:12-slim
# ca-certificates: builders fetch published manifests over HTTPS.
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates; \
rm -rf /var/lib/apt/lists/*; \
useradd --system --create-home --uid 65532 nonroot
COPY --from=build /out/ /usr/local/bin/
USER nonroot
ENTRYPOINT ["/usr/local/bin/crucible"]
CMD ["help"]
+4 -4
View File
@@ -49,10 +49,10 @@ by `flake.lock`. CI runs the _same_ `crucible <build>` as local dev, inside the
nix devshell (binary-cache fast). No build container, no token, no CI-only
pre-steps.
The `registry.westgate.pw/deployment/crucible:<sha>` image is **deployment-only**:
`prod.yml` pins it so tools travel with the runtime host (`nwn.enable`), a
disabled placeholder until the NWN stack lands. It is **not** a build tool and no
build/CI job consumes it.
The `registry.westgate.pw/deployment/crucible:<sha>` image is **retired**
(2026-07-17): nothing consumed it, `prod.yml` no longer reserves a slot for
it, and CI no longer builds or publishes it. Binaries, wrappers, and the Nix
input are the only supported ways to run Crucible.
## Determinism
+28 -3
View File
@@ -2317,6 +2317,18 @@ func (c *featGeneratedContext) familyHasExistingRows(familyKey string) bool {
return false
}
// skillsDatasetName returns the dataset the skill_focus family sources its
// children from. The skills dataset location is data-driven via that spec;
// "skills" is only the fallback when no spec is loaded.
func (c *featGeneratedContext) skillsDatasetName() string {
for key, spec := range c.familySpecs {
if normalizeKeyIdentity(key) == "skillfocus" && spec.ChildSource.Dataset != "" {
return spec.ChildSource.Dataset
}
}
return "skills"
}
func (c *featGeneratedContext) datasetRows(name string) (map[string]map[string]any, error) {
if rows, ok := c.rowsByDataset[name]; ok {
return rows, nil
@@ -2555,7 +2567,10 @@ func buildFamilyExpansionGeneratedModule(path string, obj map[string]any, ctx *f
if !include {
continue
}
slug := strings.TrimPrefix(sourceKey, spec.ChildSource.Dataset+":")
slug := sourceKey
if idx := strings.Index(slug, ":"); idx >= 0 {
slug = slug[idx+1:]
}
featKey, rowID, hasID, err := ctx.resolveGeneratedFeatIdentity(spec, slug, row)
if err != nil {
return nil, fmt.Errorf("generated feat file %s: %w", path, err)
@@ -2655,7 +2670,7 @@ func buildRacialtypesSkillAffinityModule(ctx *featGeneratedContext) (map[string]
if len(grants) == 0 {
return nil, nil
}
skillRows, err := ctx.datasetRows("skills")
skillRows, err := ctx.datasetRows(ctx.skillsDatasetName())
if err != nil {
return nil, err
}
@@ -2854,7 +2869,7 @@ func (c *featGeneratedContext) displayNameForGeneratedSource(dataset string, row
return text
}
switch dataset {
case "skills":
case c.skillsDatasetName():
return displayNameForSkill(row)
case "baseitems":
return displayNameForBaseitem(row)
@@ -3457,10 +3472,20 @@ func legacyFamilyChildAliases(familyKey, sourceSlug string) []string {
aliases = append(aliases, "craftweapon")
case "craftwoodworking":
aliases = append(aliases, "crafttrap")
case "diplomacy":
aliases = append(aliases, "influence", "persuade")
case "disabledevice":
aliases = append(aliases, "disabletrap")
case "influence":
aliases = append(aliases, "persuade")
case "intimidate":
aliases = append(aliases, "taunt")
case "investigation":
aliases = append(aliases, "search")
case "medicine":
aliases = append(aliases, "heal")
case "security":
aliases = append(aliases, "openlock")
case "sleightofhand":
aliases = append(aliases, "pickpocket")
}
+9 -4
View File
@@ -1867,8 +1867,10 @@ func validateGeneratedFeatFamilies(dataDir string, report *ValidationReport) {
}
required := []requiredFeatFamily{
{FamilyKey: "skillfocus", Dataset: "skills", Predicate: "accessible"},
{FamilyKey: "greaterskillfocus", Dataset: "skills", Predicate: "accessible"},
// skill families: the source dataset is data-driven (family spec), only
// the accessibility predicate is required
{FamilyKey: "skillfocus", Predicate: "accessible"},
{FamilyKey: "greaterskillfocus", Predicate: "accessible"},
{FamilyKey: "weaponfocus", Dataset: "baseitems", Column: "WeaponFocusFeat"},
{FamilyKey: "weaponspecialization", Dataset: "baseitems", Column: "WeaponSpecializationFeat"},
{FamilyKey: "improvedcritical", Dataset: "baseitems", Column: "WeaponImprovedCriticalFeat"},
@@ -1882,7 +1884,7 @@ func validateGeneratedFeatFamilies(dataDir string, report *ValidationReport) {
if !ok {
continue
}
if spec.ChildSource.Dataset != requirement.Dataset ||
if (requirement.Dataset != "" && spec.ChildSource.Dataset != requirement.Dataset) ||
spec.ChildSource.Column != requirement.Column ||
spec.ChildSource.Predicate != requirement.Predicate {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
@@ -2081,7 +2083,10 @@ func validateGeneratedFeatFamilyCompleteness(path string, spec familyExpansionSp
}
if include {
if familyAllowlist {
slug := strings.TrimPrefix(sourceKey, spec.ChildSource.Dataset+":")
slug := sourceKey
if idx := strings.Index(slug, ":"); idx >= 0 {
slug = slug[idx+1:]
}
featKey, _, _, err := ctx.resolveGeneratedFeatIdentity(spec, slug, row)
if err != nil {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
+4 -2
View File
@@ -569,13 +569,15 @@ func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) {
if err != nil {
return nil, err
}
loadBase := func(name string) (nativeCollectedDataset, bool, error) {
loadBase := func(names ...string) (nativeCollectedDataset, bool, error) {
for _, name := range names {
for _, dataset := range datasets {
if dataset.Name == name {
collected, err := collectNativeDataset(dataset)
return collected, true, err
}
}
}
return nativeCollectedDataset{}, false, nil
}
@@ -583,7 +585,7 @@ func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) {
if err != nil {
return nil, err
}
skillDataset, skillOK, err := loadBase("skills")
skillDataset, skillOK, err := loadBase("skills", "skills/core")
if err != nil {
return nil, err
}