Skip to content

ci(campaigns): every campaign in the pinned content repo builds, or the board is red (task #44) - #322

Merged
stellarfeline merged 1 commit into
mainfrom
ci/compile-every-campaign
Aug 7, 2026
Merged

ci(campaigns): every campaign in the pinned content repo builds, or the board is red (task #44)#322
stellarfeline merged 1 commit into
mainfrom
ci/compile-every-campaign

Conversation

@stellarfeline

@stellarfeline stellarfeline commented Aug 7, 2026

Copy link
Copy Markdown
Owner

The gap

Every gate in CI builds a fixture. Nothing has ever built a real campaign.

Fixtures under crates/dsl/fixtures/valid and crates/compiler/tests/fixtures
exercise one verb each. A campaign is the only place the verbs meet a real prefab
library, a real layout solve and a real translation sidecar — and that is the
surface an engine change breaks. So the product could stop compiling entirely
with the whole board green, and it did: PR #260 is 10/10 green and stops
nobodys-cave-island, the flagship released campaign, from building at all.

The job that would have caught this exists as content-repo PR #26, blocked
downstream of the gap it closes. This lands the gate here instead, against
engine main, where the campaigns that exist today do build — which breaks the
cycle.

What changed (design level)

New required status check campaign builds (every campaign in the content repo),
on every push (not PR-only: the window between push and PR is exactly where a
break would sit unseen).

  • Discovered, not listed. Any directory under <content>/campaigns/ holding a
    world.json is a campaign. No list in this repo to forget to update — a
    campaign added to content main is gated by the next re-pin.
  • Pinned and reproducible. The content checkout is versions.toml
    [content].sha via the existing checkout-content action, LFS on (a campaign
    build copies the prefab .nbt bodies). Never a moving main.
  • Every declared language. en plus every code in world.json languages.
    A campaign ships its localized output too (out-zh/), so a sidecar that stops
    satisfying the coverage checks breaks a released artifact exactly as an English
    break does.
  • --delvec is required and never inferred. The gate's whole subject is
    which engine built the campaign, so the caller names the binary.

Exclusions invert the assertion — they do not remove it

A silently skipped campaign is a vacuous green and is worse than no job. So
.github/campaign-build-exclusions.toml does not switch a campaign off:

  • it is still built, on every run;
  • it must still fail, with exactly the recorded expect_codes — any other
    code is a new break that was hiding behind the exclusion, and reds;
  • if it builds, that reds too. The exclusion has expired and must be deleted
    in the PR that fixed the campaign, or the next regression it suffers is excused
    by a stale line.

Two honest states, both printed in the job's own output: built and
known-red for exactly this reason.

Proof 1 — the control: the same gate against PR #260, RED

worker/spec-0026-foundation @ 6e446ba, built in its own detached worktree,
run through the identical script at that branch's own content pin
(5a23d90, which is what its checkout-content would fetch):

::group::nobodys-cave-island  (en+zh-cn)
    --lang en: exit 3, 1 error(s), 0 warning(s)
    ::error::nobodys-cave-island (--lang en) NO LONGER BUILDS
      DW0364 build
        flood-level proof (spec-0026): 26 standable cell(s) in placed pieces sit at
        or below the ambient flood level (y=62) — the delivered world floods them on
        first boot while every compile-time proof derives from a model that says dry
        (the #149 class). First: area `area/island`, prefab `prefab/island-gre…
    --lang zh-cn: exit 3, 1 error(s), 0 warning(s)
    ::error::nobodys-cave-island (--lang zh-cn) NO LONGER BUILDS
::endgroup::

---- binding -------------------------------------------------------
discovered : 2  (hollow-vigil, nobodys-cave-island)
built green: 0  (NONE)
known-red  : 1  (hollow-vigil)
--------------------------------------------------------------------

build-every-campaign: 2 finding(s)
  nobodys-cave-island (--lang en) no longer builds on this engine: 1 error(s),
  codes DW0364. This is a released campaign; the engine change that caused it is
  the thing to fix, not this gate.

Exit 1. The 26 DW0364 cells are in the output. A gate that has never been seen
failing is not known to bind; this one has been.

Green on this branch, at the exact pinned content SHA (87a8ade), same script,
same command CI runs:

::group::hollow-vigil  (en+zh-cn)   [EXCLUDED — expected red]
    --lang en: exit 1, 2 error(s), 1 warning(s)
    known-red as recorded: ['DW0331'] — #34
    --lang zh-cn: exit 1, 2 error(s), 1 warning(s)
    known-red as recorded: ['DW0331'] — #34
::endgroup::
::group::nobodys-cave-island  (en+zh-cn)
    --lang en: ok, 0 error(s), 23 warning(s)
    --lang zh-cn: ok, 0 error(s), 23 warning(s)
::endgroup::
build-every-campaign: OK — 1 campaign(s) build on this engine.

Proof 2 — the binding count

Discovered on content main @ 87a8ade: 2 campaigns. Both are handled; neither
is skipped.

campaign languages state
nobodys-cave-island en, zh-cn built green — this is the gate's live binding
hollow-vigil en, zh-cn known-red, pinned to DW0331 (task #34): two dialogue option labels render 179 px and 157 px wide against the 146 px a vanilla dialog button fits. Vanilla scrolls an over-wide caption rather than wrapping it, so the fix is a content rewrite of the labels, not an engine change — out of scope here, and named in the job's output on every run

Not built, and why — the honest full list. campaigns/nobodys-cave and
campaigns/the-drowned-bell appear in a local content checkout but carry no DSL
sources on content main
; they exist only on the unmerged branches
campaign/nobodys-cave and campaign/the-drowned-bell (locally, only their
gitignored out/ trees). They are not discoverable and are not excluded — they
are not there. They come under the gate automatically the day their sources reach
content main, with no change to this repo.

So the binding today is one campaign, honestly stated. That is the flagship, and it
is the one PR #260 breaks.

Proof 3 — the exclusion mechanism cannot degrade into a skip

Every way this gate could go green having proven nothing, each provoked and each
observed red (exit 1):

probe result
A. exclusion on a campaign that now builds red — BUILDS now, but is still listed … Delete the exclusion
B. excluded campaign fails with an unexpected code red — also failed with ['DW0331']. A new break was hiding behind the exclusion.
C. exclusion naming a campaign that is not there red — names no campaign … a stale exclusion pre-excuses any future campaign that takes the name
D/E. every campaign excluded red — 0 of 2 campaigns were built — every one is excluded. This job would be green having compiled no product at all.
F. content checkout did not land red — campaign sources not found at … every campaign below would be 'skipped'
G. content present, zero campaigns discovered red — discovered 0 campaigns … A gate over nothing is not a pass.

Required-status-check lockstep

.github/required-status-checks.txt gains the exact job name: string, and
tools/check-required-contexts.py is green both directions:

check-required-contexts: OK — 12 ci.yml jobs, all 12 required contexts resolve

Per that file's own procedure, adding a job is a two-part change: this merges
first, then campaign builds (every campaign in the content repo) is added to
branch protection's required contexts.
One PR may block while it first reports;
that is the accepted cost and is why the name is pinned in a comment above the job.

Wall-clock

Measured on the dev profile (the workspace's opt-level = 1, PR #313), the
binary built once and handed to the driver:

nobodys-cave-island, per language ~44 s
hollow-vigil (fails at validate) ~0.01 s
whole gate, 2 campaigns x 2 languages 92 s

On the real runner: the whole job is 3 m 32 s, cargo build included — the
third-fastest of the four jobs over a minute (rust 5 m 33 s, tier 2 4 m 22 s,
harness 3 m 50 s), so it does not extend the critical path at all.

No split is needed and nothing is sampled: the gate builds everything, every
push. If the campaign count grows enough to matter, the honest split is by
campaign into a matrix — not by sampling — and it can be made then, from a
measurement.

Its first live run, at the pinned SHA, checked out and bound exactly as designed:

content  : stellarfeline/delvewright-campaigns@87a8ade…   (versions.toml [content])
discovered 2 campaign(s): hollow-vigil, nobodys-cave-island
---- binding ----
discovered : 2  (hollow-vigil, nobodys-cave-island)
built green: 1  (nobodys-cave-island)
known-red  : 1  (hollow-vigil)
build-every-campaign: OK — 1 campaign(s) build on this engine.

Merge class

Validator-only. No crate, no emission path, no prefab, no DSL surface is
touched — git status over the diff matches zero files under crates/,
prefabs/ or packtest/. Player-facing output is byte-identical, so the merge
gate is the machine red→green demonstration above (Proof 1), not the owner's
playtest.

Out of scope, deliberately

DW0331 in hollow-vigil (task #34) is not fixed here — it is declared, and the
gate names it in its own output on every run. The shoreline/flood work on
docs/spec-shallows is untouched, and worker/spec-0026-foundation is untouched:
this PR only makes its breakage visible.

Docs

docs/reference/tools.md gains the full entry for tools/build-every-campaign.py
(tooling-sync rule). No skill workflow is touched — this gate is for engine
changes, not for generating a delve.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL

…he board is red (task #44)

Every gate in CI built a FIXTURE. Nothing ever built a real campaign, so the
product could stop compiling with the whole board green — PR #260 reached 10/10
while stopping the flagship released campaign `nobodys-cave-island` from
building at all (26 x DW0364 on standable cells at the ocean line).

New required status check `campaign builds (every campaign in the content repo)`
builds every campaign the pinned content checkout carries, in every language its
world.json declares, on every push.

Campaigns are DISCOVERED, never listed, so the next content re-pin gates a new
campaign with nobody remembering. A campaign that cannot build today is recorded
in .github/campaign-build-exclusions.toml, which INVERTS the assertion instead of
removing it: still built, must still fail, and must fail with exactly the
recorded codes — an extra code is a new break that was hiding behind the
exclusion, and a success is an expired exclusion. Both red. One entry today:
hollow-vigil, DW0331 (task #34).

Validator-only: no crate, no emission path and no prefab is touched, so
player-facing output is byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjQ5p1Kv5MrkGPumi7yXWL
@stellarfeline
stellarfeline merged commit 3ad87e9 into main Aug 7, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant