fuzzyworld

tower / commit

fix(ci): move Vite's static dir off the GitLab Pages collision

624586728d6838f025f4f363d9428c8bac115640 · Pavel Safronov · 2026-09-06 20:21:49 -0700 · parents 4a8aed841781

fix(ci): move Vite's static dir off the GitLab Pages collision

Pages requires its artifact to be a directory named `public`, and Vite's
static directory defaults to the same name. `mv dist public` therefore did
not rename anything — mv nests when the destination exists, so the built
site landed at public/dist/ and the artifact root had no index.html. The
deploy succeeded and served a 404.

Vite's static directory is now `static/`, leaving `public` free to mean only
"the thing Pages publishes". The job also asserts index.html is at the
artifact root afterwards, so this fails the pipeline instead of shipping a
404 next time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 .gitlab-ci.yml                          |   3 +++
 README.md                               |  10 +++++-----
 decisions.md                            |   2 +-
 design.md                               |   2 +-
 index.html                              |   2 +-
 src/ui/app.ts                           |   2 +-
 {public => static}/terrain-color.png    | Bin
 {public => static}/terrain-corridor.png | Bin
 {public => static}/terrain-height.png   | Bin
 {public => static}/world.json           |   0
 test/world.test.ts                      |   2 +-
 tools/terrain.mjs                       |  10 +++++-----
 vite.config.ts                          |  10 +++++++++-
 13 files changed, 27 insertions(+), 16 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8c1d701..cd728c4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -15,6 +15,9 @@ pages:
     - npm ci
     - npm run build
     - mv dist public
+    # Fail loudly rather than publishing a 404. `mv` silently nests when the
+    # destination exists, which is exactly how this broke the first time.
+    - test -f public/index.html || (echo "no index.html at artifact root" && exit 1)
   artifacts:
     paths: [public]
   rules:
diff --git a/README.md b/README.md
index 8cb6ca1..fc9cc94 100644
--- a/README.md
+++ b/README.md
@@ -56,10 +56,10 @@ shell. `npm run terrain` renders it, with no dependencies beyond Node:
 
 | | |
 |---|---|
-| `public/terrain-height.png` | 16-bit greyscale, 0–5,000 m |
-| `public/terrain-color.png` | the whole world, 264 m/px |
-| `public/terrain-corridor.png` | the developed corridor, 122 m/px |
-| `public/world.json` | seed places for the app |
+| `static/terrain-height.png` | 16-bit greyscale, 0–5,000 m |
+| `static/terrain-color.png` | the whole world, 264 m/px |
+| `static/terrain-corridor.png` | the developed corridor, 122 m/px |
+| `static/world.json` | seed places for the app |
 
 It reports each stage as it goes — every relief op, colourisation, and PNG encode —
 and takes about 6 seconds. Flags:
@@ -104,4 +104,4 @@ Everything autosaves to this browser's localStorage. Two exports:
 | | |
 |---|---|
 | **Export backup** | everything, private notes included. Keep out of the public repo — `.gitignore` already excludes `tower-backup-*.json`. |
-| **Export static map content** | public places only, `notes` stripped. This is `public/world.json`, the world readers see, and the seed for a browser with no local draft. |
+| **Export static map content** | public places only, `notes` stripped. This is `static/world.json`, the world readers see, and the seed for a browser with no local draft. |
diff --git a/decisions.md b/decisions.md
index 24a0105..d1a9359 100644
--- a/decisions.md
+++ b/decisions.md
@@ -671,7 +671,7 @@ polyline only needs two to go somewhere — and the kind list grows from six to
 thirteen (`mountain`, `river`, `canyon`, `forest`, `desert`, `tower`, `town`), with
 `AREA_KINDS` / `LINE_KINDS` deciding whether a traced shape closes. Before this the
 seed silently dropped The Long Water and The Narrows. A test now loads
-`public/world.json` and asserts nothing is lost, so the two files cannot drift apart
+`static/world.json` and asserts nothing is lost, so the two files cannot drift apart
 again unnoticed.
 
 **The rendered terrain is drawn under the map**, which is what the whole tool was
diff --git a/design.md b/design.md
index aee40ed..99430ee 100644
--- a/design.md
+++ b/design.md
@@ -488,7 +488,7 @@ Hollow Station — around 2,100 m, with the runs above them.
   a control panel, and a framework would not earn its weight.
 - **Three.js** for the exterior and interior 3D views.
 - **Canvas 2D** for the map view.
-- **GitLab CI → GitLab Pages**, publishing `dist/` as `public/`.
+- **GitLab CI → GitLab Pages**, publishing `dist/` as `static/`.
 
 ### 2.2 Module layout
 
diff --git a/index.html b/index.html
index baeab99..ffbb7ac 100644
--- a/index.html
+++ b/index.html
@@ -84,7 +84,7 @@
       <input id="import-file" type="file" accept="application/json" hidden />
       <p class="hint">
         Backups carry your private notes; keep them out of a public repo.
-        The static export is <code>public/world.json</code> — the default readers see.
+        The static export is <code>static/world.json</code> — the default readers see.
       </p>
     </section>
   </aside>
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 4093618..3bf5ffa 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -3,7 +3,7 @@
  *
  * The loop this serves: place and name things on the map, autosaved to localStorage
  * continuously; export a full backup whenever; export the static public content
- * once to seed `public/world.json` in the repo.
+ * once to seed `static/world.json` in the repo.
  */
 import { SLAT_COUNT_MAX, SLAT_COUNT_MIN } from '../canon/constants.js'
 import { landStripWidth, slatCount, setSlatCount, slatWidth } from '../canon/slats.js'
diff --git a/public/terrain-color.png b/static/terrain-color.png
similarity index 100%
rename from public/terrain-color.png
rename to static/terrain-color.png
diff --git a/public/terrain-corridor.png b/static/terrain-corridor.png
similarity index 100%
rename from public/terrain-corridor.png
rename to static/terrain-corridor.png
diff --git a/public/terrain-height.png b/static/terrain-height.png
similarity index 100%
rename from public/terrain-height.png
rename to static/terrain-height.png
diff --git a/public/world.json b/static/world.json
similarity index 100%
rename from public/world.json
rename to static/world.json
diff --git a/test/world.test.ts b/test/world.test.ts
index 671971f..c1a55e8 100644
--- a/test/world.test.ts
+++ b/test/world.test.ts
@@ -90,7 +90,7 @@ describe('geometry kinds', () => {
 })
 
 describe('the seeded world loads without losing anything', () => {
-  const seed = JSON.parse(readFileSync('public/world.json', 'utf8'))
+  const seed = JSON.parse(readFileSync('static/world.json', 'utf8'))
 
   it('keeps every place the terrain tool emits', () => {
     expect(parseWorld(seed).places).toHaveLength(seed.places.length)
diff --git a/tools/terrain.mjs b/tools/terrain.mjs
index b63e391..267a93b 100644
--- a/tools/terrain.mjs
+++ b/tools/terrain.mjs
@@ -2,9 +2,9 @@
  * Terrain generator. `npm run terrain`
  *
  * Reads content/geography.json — the authored source of truth — and writes:
- *   public/terrain-height.png   16-bit greyscale, 0..maxElevationM
- *   public/terrain-color.png    8-bit RGB, hypsometric tint + hillshade
- *   public/world.json           seed places for the app
+ *   static/terrain-height.png   16-bit greyscale, 0..maxElevationM
+ *   static/terrain-color.png    8-bit RGB, hypsometric tint + hillshade
+ *   static/world.json           seed places for the app
  *
  * The map is the cylinder unrolled: x is z (sun cap at the left), y is theta.
  * y wraps; x does not. Every distance below is in metres, converted to pixels only
@@ -608,7 +608,7 @@ line(1, 'encode')
 const write = (name, make) =>
   step(2, name, () => {
     const buf = make()
-    writeFileSync(`public/${name}`, buf)
+    writeFileSync(`static/${name}`, buf)
     return buf
   }, (b) => `${mb(b.length).padEnd(10)}`)
 
@@ -655,7 +655,7 @@ const places = geo.features
     geometry: toPlaceGeometry(f.geometry), blurb: '',
   }))
 
-const target = 'public/world.json'
+const target = 'static/world.json'
 const existing = existsSync(target) ? JSON.parse(readFileSync(target, 'utf8')) : { places: [] }
 const authored = existing.places?.filter((p) => !places.some((q) => q.id === p.id)) ?? []
 if (authored.length && !process.argv.includes('--force')) {
diff --git a/vite.config.ts b/vite.config.ts
index 53fdc40..852945a 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -14,4 +14,12 @@ import { defineConfig } from 'vite'
  */
 const base = process.env.BASE || './'
 
-export default defineConfig({ base, build: { target: 'es2022' } })
+export default defineConfig({
+  base,
+  // Vite's static directory is `static/`, not the default `public/`, because
+  // GitLab Pages requires its artifact to be a directory named `public` — and
+  // `mv dist public` does not rename when `public/` already exists, it moves dist
+  // *inside* it. The collision is silent and produces a 404 at the site root.
+  publicDir: 'static',
+  build: { target: 'es2022' },
+})