Interactive 3D atlas of 35 competitive moats — the geology of defensibility: rock types, depths, tools, and a survey to dig your own.
Live at moa.giglabo.com — the survey, the cross-section, the catalogue; in Russian at moa.giglabo.com/ru/.
npm install
npm run dev # dev server at localhost:4321
npm run build # static build to dist/
npm run preview # preview the production build
npm run lint # ESLint 9 (flat config)
npm run og # re-render the Open Graph cards into public/og/
npm run icons # re-render favicons, app icons, manifests, browserconfig
npm run audit # audit the agent-facing layer of a running build (see below)
npm run hooks # point git at .githooks/ — pre-commit secret scanning
npm run skill # install the /moats Claude Code skill (see below)
npm run skill:check # diff the skill's transcription against the matrix
npm run strategies:check # sheet III's gates against dist/ (rows, back-links, twins)
npm run strategy-pages:check # sheet III-b's gates against dist/ (the 80 pages, twins, links, hash)
npm run home:check # the home page's static gates against dist/ (copy, sample card, /about/, twins)
npm run home:check:browser -- http://localhost:4321 # its browser gates against a running buildRun npm run lint && npm run build before finishing any change — the build is the authoritative correctness check.
| Route | What it is |
|---|---|
/ |
The home page: what you get, the three steps, a sample result — and sheet I, the interactive cross-section of all 35 mechanics, at #section |
/about/ |
How to read the atlas — the reading guide of sheet I in full |
/moats/ |
The catalogue — all 35 mechanics as a table |
/moats/{1..35}/ |
One sheet per mechanic: passport, essence, build, bypass |
/calculator/ |
Sheet II — the survey: twelve questions, scored |
/strategies/ |
Sheet III — the 80 strategies, and which moat each leads to |
/strategies/{slug}/ |
One page per strategy: the moats it grows into, and why |
/credits/ |
Who made this — colophon |
/cookies/ |
Cookie policy + preference toggles |
/404 |
Not found |
Every route also exists at /ru/…, and all of them under
moa.giglabo.com.
The site is written twice: once as HTML for people, once as plain text for machines. Nothing about the second half is generated by hand — it is built from the same matrix and dictionaries the pages render, so it cannot fall behind.
| URL | What it is |
|---|---|
/llms.txt |
What this site is, how its URLs work, every page of both locales |
<any page>.md |
A plain-Markdown twin of that page — /moats/7.md |
<any page>/index.md |
The same twin at the form llmstxt.org specifies |
/sitemap.xml |
Canonical HTML URLs with hreflang alternates |
/robots.txt |
Crawl policy, pointing back at /llms.txt |
A twin is 8–19% of the bytes of its HTML page, before the three.js bundle the
cross-section loads. Every page also carries a JSON-LD @graph: the 35 sheets
are DefinedTerms of the atlas's DefinedTermSet, and the catalogue publishes
the matrix as a Dataset whose measured variables are the six axes.
Two things are deliberate. Twins never appear in sitemap.xml — each one points
its canonical at the HTML page, and listing both would have them contradict each
other. And nothing in this layer carries noindex, because an agent that
honours it would refuse to use the file it just fetched.
bash scripts/audit-agents.sh <base-url> checks the whole contract against a
running build — coverage in both directions, both URL forms, the head links, the
sitemap, and the shape of a .md 404. CI runs it against the container.
skills/moats/ is a Claude Code skill that
turns the atlas into an audit: it scores any project on the same ruler the
calculator uses — twelve questions, 35 mechanics, one index, one depth — and
reports the mechanics held, how each one gets bypassed, and the cheapest ones
worth digging next. Contributed back by
Ilia Dzhiubanskii as a thank-you.
Four modes: scan answers all twelve questions from the repo and its live
surfaces alone; interview walks the operator through them, probing every high
answer for evidence; both (the default) scans, then interviews only what a
repo cannot show; compare ranks several projects.
Install it either way:
# without cloning — inside Claude Code:
/plugin marketplace add GigLaboCom/moat-atlas
/plugin install moats@moat-atlas # then: /moats:moats
# from a clone:
npm run skill # copy to ~/.claude/skills — then: /moats
npm run skill -- --link # …or symlink it, so it tracks the checkout
npm run skill -- --to some-project/.claude/skills # one project onlyreferences/atlas.md and references/calculator.md inside the skill are
transcriptions of src/data/{moats,survey}.ts and the dictionaries.
npm run skill:check re-derives every checkable claim from the source and
diffs — CI runs it, so the skill cannot fall behind the matrix.
en is the default locale and is served unprefixed; ru lives under /ru/.
src/i18n/config.ts— locale list and labelssrc/i18n/index.ts—getTranslations,getLocalizedPath,getLocaleFromUrlsrc/i18n/translations/{en,ru}.ts— the shared dictionarysrc/i18n/translations/pages/*.ts— copy for a single page (cookies, credits)src/i18n/translations/moats/{en,ru}.ts— the 35 sheets, keyed by moat number
A page is written once, in src/pages/, and reads its locale from the x-locale
request header. The /ru/ routes are thin rewrites that set that header:
---
const url = new URL('/moats/', Astro.url);
const headers = new Headers({ 'x-locale': 'ru' });
return Astro.rewrite(new Request(url, { headers }));
---Astro warns that Astro.request.headers is unavailable on prerendered pages; the
header still arrives through the rewritten Request, and the built HTML is
verified per locale (<html lang>, canonical, hreflang).
Adding a locale: extend locales in src/i18n/config.ts and astro.config.mjs,
add src/i18n/translations/{locale}.ts plus the moat sheets, and mirror the
rewrite files under src/pages/{locale}/.
src/data/moats.ts holds the survey matrix — 35 mechanics scored on six axes
(rock, depth, capital, solo, AI, rent) with no human-readable strings in it.
Names and prose live in the translations, keyed by the same number that is used
for the deep link (/#moat-7) and the sheet route (/moats/7/).
/ opens on the outcome, not the drawing: a headline, one primary button to
the survey, a trust line ("2 minutes · no signup · nothing stored"), a sample
result beside it — a real result of src/data/sample-result.ts, scored and
encoded by the calculator's own code at build, so the card's link reproduces
it exactly — and the three steps. Sheet I sits a screen below at #section,
with its reading guide folded under it (?guide=1) and in full on /about/;
three.js is fetched only once the canvas comes near the viewport. Copy is in
src/i18n/translations/pages/home.ts; every count in it is filled from the
data by src/lib/home.ts. npm run home:check and
npm run home:check:browser are its gates.
src/scripts/atlas.ts is the three.js scene on the home page, ported from
the v3 prototype. One shaft per mechanic hangs from the ground plane: colour
and cross-section shape encode the rock, thickness the capital, length the
depth.
- Groupings. The six axes of the catalogue (
GROUPING_AXES) re-lay the sheet in place: shafts ease to new positions and each row gets a label with its count. Bucketing isbucketOf()fromsrc/data/moats.ts— the same function the OG card uses, so the section can never disagree with the matrix. - Selection. Clicking a shaft takes a core: the specimen card fills from the
matrix, a ring marks the shaft, the camera dives to it and the URL becomes
#moat-N.Escape, the card's ✕, or a click on empty ground clears it;hashchangeis honoured, so/#moat-7works as a link from anywhere. - The sheet. A double-click on a shaft opens its full sheet in a modal over
the section — the same modal the card's link opens, fetched from
/moats/N/. The first click of the pair takes the core, so the card fills behind it. - Isolation. Clicking a rock in the legend dims everything else; the depth
ruler on the left works the same way, and the two are mutually exclusive.
?hl=19,6,5is the third filter — shafts named outright, which is how the calculator's result hands its mechanics over ("Show my mechanics on the section"); taking a core keeps it. - First contact. A bare section — no core, no query state — says one thing in the status line and outlines (and pulses) the sample result's deepest shaft until the first hover or press. Nothing is stored to remember it.
- The depth ruler is the key to the vertical axis: level, digging tool, years and how many of the 35 lie that deep. Hovering a row lights that level's stratum in the scene, clicking isolates it.
- The guide is the page's own documentation — what the drawing encodes,
the seven rocks, the four depths, the groupings, the filters, the list, the
controls — folded under the section's heading as a
<details>, opened by the “how it works” button in the HUD, and carried in full (with sheet II and the fine print) on/about/.src/scripts/guide.tsonly opens, closes and remembers it — it is deliberately outside the 3D module so the guide survives a machine without WebGL. - The wheel is the page's until a press on the canvas engages the section; Esc, a press anywhere else, or scrolling it off the screen hands it back — so a reader wheeling down the page never stops dead at the drawing.
- Below 900px nothing is pinned: the tabs, the canvas as a band, the core
card and the legend stack in source order. The band takes
touch-action: pan-y— a thumb scrolls the page, a sideways drag turns the section, two fingers zoom it. The hint and the status line stand down — the guide and the card carry what they said. - The scene imports the matrix directly and receives every string through
window.__ATLAS__, filled byindex.astrofrom the locale dictionary — no user-visible text lives in the script. It follows the theme toggle, honoursprefers-reduced-motion, and falls back to the catalogue link if WebGL is missing.
/calculator/ measures the moat you actually have. Twelve questions in four
segments of three — Pull, Ground, Grip, Leverage — each answered on a five-rung
ladder worth 0, 25, 50, 75 or 100.
- Shape and scoring live in
src/data/survey.ts, language-neutral like the matrix: the segments, the questions, the weights,scoreSurvey(), and the hash codec. Every question also names the mechanics it probes; between them the twelve cover all 35 rows of the matrix exactly once (SURVEY_COVERS_MATRIXasserts it). - Copy lives in
src/i18n/translations/pages/calculator.ts, keyed by question id, with the five options in ascending order. Changing a question is a copy change; changing what it probes is asurvey.tschange. - The result is an index 0–100, a depth on the same 1–4 ruler as sheet I (so the verdict is the tool a rival would need), the four segment scores, and two lists drawn from the matrix: the mechanics behind your deepest answers, and the ones behind your shallowest, cheapest and quickest first. Both link straight to their sheets.
- Answers live in the URL hash (
#s=432102441032, one digit per question,-for skipped) and nowhere else. The link is the section: it survives a reload, it is shareable, and the survey touches no storage, so it needs no consent category. src/scripts/calculator.tsis the engine; like the 3D scene it takes every string throughwindow.__SURVEY__and writes none of its own. Keys1–5pick an option,←steps back. Without JavaScript the page states as much and the start button stays disabled.
/strategies/ lays out the 80 strategies of kepano's essay Many ways to win
in its 13 categories, with the atlas's overlay on each: a role (holds,
position, structure, protects, takes, morphs) and the moats of the matrix it
leads to, linked as direct, via (→ #N — a positioning choice that becomes
a moat only through that one) or conditional (#N? — only under the
condition in the row's note). Three rows are marked disputed.
- Data.
src/data/strategies.v1.jsonis the canonical dataset, checked in byte-for-byte as published;src/data/strategies.tstypes it, validates it at import (80 rows, unique slugs, every category, role, moat number and link kind known, a note on every disputed row, both names and gists non-empty — any of these fails the build) and derives what the pages need: a strategy's depth is the deepest of its direct moats, taken from the matrix; a moat's back-links are the strategies whosemoats[]name it. Nothing derived is stored. The dataset is bilingual by design, so it is the one place strings live undersrc/data/— everything the page says about the table is insrc/i18n/translations/pages/strategies.ts. - The page is server-rendered whole — 80 rows under 13 group headers in
essay order, so a crawler or a client without JavaScript gets all of it.
src/scripts/strategies.tsonly filters, sorts and counts; every fact it needs sits on the row as a data attribute, so the JSON is never shipped twice. The state lives in the query string —?cat=price,time&role=hold&q=net&sort=depth:desc— defaults blank, unknown values dropped,replaceStatenotpushState, and it composes with the row anchor:/strategies/?role=hold#usership. - Slugs are identity. A strategy's slug is its row anchor in both locales, its page and the link every moat sheet uses; slugs are never renamed.
- Back-links. Every moat sheet ends with "Strategies that lead here" —
src/components/StrategyBacklinks.astro, fed by the derived map, in three lists (direct, via, conditional with its note), each a link to the strategy's page; a moat nothing leads to says so, on purpose. The block sits inside#sheet-article, so the atlas modal shows it too. - For machines. The twin carries the whole table, one per category, with
the notes as footnotes and the roles legend; the page's JSON-LD is a
Dataset(credited to the essay it is based on) plus anItemListof the 80. - Gates.
npm run strategies:checkre-derives every expectation from the JSON and compares it withdist/: row and group counts, both link directions, every back-link set, every twin cell, locale parity, the disputed markers. CI runs it after the build.
/strategies/<slug>/ is where a strategy is explained and tied to the atlas:
the moats it grows into with one note per moat, why its role is what it is,
the signs you already have it, how to dig it, how it erodes, what a solo
builder without capital can do with it, cases, and the strategies it combines
and fights with. The table stays the index.
- Two datasets, one join.
strategies.v1.jsonis frozen — the row. The prose issrc/data/strategy-pages.v1.json, keyed by slug, written for the atlas in both locales, never a translation of the essay.src/data/strategy-pages.tsvalidates it at import: the keys are exactly the 80 slugs, every field non-empty in both locales, lists within their bounds,per_moatcovering exactly the row's moats, every combo and tension resolving to another slug, and no Russian field a copy of its English twin — any of these fails the build naming the slug and the field. Depth, moat names, the matrix attributes and the neighbours are derived, never stored. - No script. The page is rendered whole at build time and loads nothing beyond the shell; there are no parameters. Prev/next walk the category in essay order and wrap at its edges.
statuson a page is the owner's review state (draft/reviewed). It never renders;npm run strategy-pages:checkprints the queue, and the one-sided combo pairs (A lists B, B does not list A) — the seed of a future co-occurrence matrix.- For machines. Every page has a twin that carries the whole page with the
moat numbers, names and depths; llms.txt lists the 80 with their twin URLs;
the JSON-LD is a
DefinedTermlike a sheet's, with the moats as references to the sheets' own terms. - Gates.
npm run strategy-pages:checkwalksdist/: every page and both twin forms exist, every twin's Moats section equals the JSON, every block's content is in the HTML, every link on the table, the sheets and the pages resolves and every row anchor survives, the locales agree on shape and differ in words,strategies.v1.jsonstill hashes as published, the attribution is everywhere, distinct descriptions and the head links on all 160 pages. Pass--essay FILEto also check that no 12-word run of the prose appears in the essay text.
Each of the 35 mechanics has a sheet at /moats/N/: a passport straight from
the matrix, the essence, three worked examples of how the moat gets built, three
of how it gets bypassed, and a verdict. The prose lives in
src/i18n/translations/moats/{en,ru}.ts under the same number as the deep link.
- Import.
node scripts/import-catalogue.mjs <catalogue.md> --ts src/i18n/translations/moats/ru.tsparses the source catalogue and writes the Russian file: it checks that every moat has three build and three bypass examples with leads, strips the document's footnotes, rules and italics, and keeps the sheet names already in the file.--json out.jsondumps the parsed structure instead, which is what the English translation is written from. - The sample figures (share of apps, no-rate, median price) that the first thirteen mechanics carry live on the matrix as numbers, not prose, and render as their own passport block where they exist.
- For machines. Every sheet emits schema.org JSON-LD: a
DefinedTermwith the essence as its description and every axis of the matrix — plus the sample figures — asPropertyValueentries, so the catalogue can be read without scraping the tables. - In the atlas. The specimen card's link opens the sheet in a modal over the
cross-section: the modal fetches
/moats/N/and clones#sheet-articleout of it, so the page stays the single renderer. A modified click, or a failed fetch, falls through to the page itself. - Looking back. The sheet ends with the strategies of sheet III that lead to it — see "The strategies" above.
- GA4 loads only when
PUBLIC_GA_IDis set, in Consent Mode v2 with everything denied by default — cookieless until the visitor opts in. <cookie-consent-banner>(src/components/cookie-consent/) is a shadow-DOM web component; the choice is stored inmoat-atlas-consent./cookies/documents the categories and re-exposes the same toggles.- The region check is
/api-shared/ipon the same origin — the sibling sites (lookbook, stereola) call the same path, served by the shared service behind the ingress.PUBLIC_GEO_ENDPOINToverrides it for a deployment that puts it elsewhere. It has to be routed for this host too; see below. - The endpoint answers
{ status: "OK", ip: { isEu: boolean } }, and{ status: "FAIL" }when it cannot place the visitor. A static site never sees an address — the endpoint reads it from its own proxy headers (cf-connecting-ip,x-real-ip,x-forwarded-for) and only the boolean is sent back. An explicitfalselets a visitor skip the banner; a FAIL, a 404 from an unrouted deployment, a timeout or any other answer shows it, because failing the other way would auto-grant consent for European visitors. - Only a real verdict is cached (
moat-atlas-eu-check, per tab). A failure is not, so an endpoint that comes back is retried on the next page. PUBLIC_GEO_SIMULATE(eu/non-eu) stands in for the lookup innpm run devso both branches can be walked without a geo service. A production build ignores it.- GA4's automatic page view is off (
send_page_view: false);Analytics.astrosends it throughtrackPageView()so it carries the same locale and utm parameters as the rest. - Every analytics event carries the current locale (
src/lib/analytics.ts).
Everything a browser can ask for is generated from three masters in icons/ —
icon.svg, icon-maskable.svg (Android safe zone) and icon-mono.svg (Safari
pinned tab):
npm run iconsscripts/generate-icons.mjs renders it with the same Playwright Chromium the OG
cards use — no Inkscape or ImageMagick — and writes into public/:
favicon.ico (16/32/48/256, assembled by the script), favicon.svg,
favicon-*.png, android-chrome-36…512, maskable-192/512,
apple-touch-icon.png, safari-pinned-tab.svg,
assets/images/favicon-128…1024, assets/images/mstile/*, browserconfig.xml
and one manifest per locale (site.webmanifest, site.ru.webmanifest — names
and descriptions come from the dictionaries, so the RU install prompt is in
Russian). Layout.astro links all of it. Edit a master, re-run, commit the
output; see icons/README.md for the full table.
/og/og-{lang}.png — the image every page points at, one per locale, 1200×630.
They are generated, not hand-drawn:
npm run og # both locales into public/og/
npm run og -- ru # one locale
npm run og -- --html # also dump the rendered HTML next to the PNG
npm run og -- --out /tmp # render somewhere elsescripts/og/template.mjs builds a self-contained HTML card — the dark-theme
palette and system font stacks from src/layouts/Layout.astro, the copy from
the locale dictionary (og.stats, atlas.*, ui.tagline, rocks.*) and the
specimens from src/data/moats.ts: one chip per mechanic, laid into its depth
band, coloured by rock, width by the capital it takes.
scripts/generate-og-images.mjs screenshots it with Playwright.
Re-run it after changing that copy, the matrix, or the card design, and commit
the PNGs — the build only copies public/ across. It needs Node ≥ 22.18 (the
script imports the .ts sources through native type stripping) and the
Playwright Chromium build (npx playwright install chromium). Fonts are
resolved locally, so the card looks the way it does on macOS; regenerate on the
same machine family to keep the two locales consistent.
| Variable | Effect |
|---|---|
PUBLIC_SITE_URL |
Absolute base for canonical/hreflang/OG URLs |
PUBLIC_GA_ID |
Enables GA4 |
PUBLIC_GEO_ENDPOINT |
Overrides the region check (default /api-shared/ip) |
PUBLIC_GEO_SIMULATE |
Dev-only stand-in for that lookup: eu / non-eu |
.env.example lists them with their defaults — copy it to .env for a local
build. The production values are GitHub Actions variables (not secrets:
Astro inlines them into pages anyone can read), consumed by release.yml as
build arguments. PUBLIC_GA_ID is set there to the atlas's GA4 measurement id,
so a tagged release ships with analytics; leave it empty locally unless you
want your own page views in that property.
gh variable list --repo GigLaboCom/moat-atlas
gh variable set PUBLIC_GA_ID --repo GigLaboCom/moat-atlas --body G-XXXXXXXXXXThe site ships as a static bundle behind nginx — a two-stage image, Node to
build, nginx:alpine to serve:
docker build -t moat-atlas .
docker run --rm -p 8080:8080 --read-only --tmpfs /tmp moat-atlas
# or, with the PUBLIC_ vars taken from the environment / .env
docker compose up --build- The
PUBLIC_variables are build arguments, not runtime ones: Astro inlines them into the bundle, so an image is tied to the host it was built for.--build-arg PUBLIC_SITE_URL=… --build-arg PUBLIC_GA_ID=…. - Non-root on 8080. The container runs as
nginx, writes nothing outside/tmp, and therefore runs under--read-only. Port 8080 needs no privilege, which is what a proxy, Kubernetes or Cloud Run expects anyway. /healthzanswersokin plain text and is excluded from the access log; the image declares its ownHEALTHCHECKagainst it.- Caching follows the build:
/_astro/is content-hashed and servedimmutablefor a year, images and fonts for a week, HTMLno-cache. The build lays a.gztwin next to every compressible file and nginx serves those (gzip_static), so nothing is compressed per request. docker/nginx.confis the main config,docker/conf.d/moat-atlas.confthe server block,docker/snippets/headers.confthe security headers every location has to include —add_headerdoes not inherit into a location that sets one of its own./api-shared/ipis not in this image. The consent banner calls that path on its own origin for the region check, and the container has nothing to answer it with: the route belongs to whatever sits in front. On the giglabo cluster that is a TraefikIngressRoutefor the host withPathPrefix(/api-shared), astripPrefixmiddleware and theshared-servicebackend — the arrangementlookbook.giglabo.comalready has (smblob-cluster/kubernetes/apps/shared-service/). Until this host gets the same route the check 404s, which the banner reads as "cannot place the visitor" and shows itself to everyone. That is the intended failure, not a bug — but it means no visitor is ever placed, so wire the route (or pointPUBLIC_GEO_ENDPOINTat a reachable one) before expecting the non-EU path to happen.
git switch -c feat/depth-ruler main
npm install
npm run hooks # once per clone: pre-commit secret scanning
npm run devnpm run hooks sets core.hooksPath to .githooks/, whose pre-commit runs
gitleaks over the staged diff and
refuses a commit that carries a credential. Install the scanner —
brew install gitleaks — or the hook warns and waves the commit through. The
rules are the gitleaks defaults plus the allowlist in .gitleaks.toml; CI runs
the same scanner over the whole history, so a secret that slips past a machine
without the hook still fails the pull request.
Node 22 or newer — the icon and card scripts import the .ts sources through
native type stripping, which lands in 22.18.
Before pushing anything, run npm run lint && npm run build. There is no
test runner: the build renders all 81 pages and asserts the invariants the site
depends on (SURVEY_COVERS_MATRIX, the per-locale HTML), so a green build is
the correctness check. CI runs the same two commands, plus the image build and
its smoke test, on every pull request.
The conventions worth knowing before the first commit — each has its own section above:
- A page is written once, in
src/pages/, and reads its locale from thex-localeheader;/ru/is a rewrite stub. Never fork a page per locale. - No user-visible text in
.astromarkup or insrc/data/. Shared copy goes tosrc/i18n/translations/{en,ru}.ts, single-page copy totranslations/pages/, moat prose totranslations/moats/. The 3D scene and the survey engine take their strings throughwindow.__ATLAS__andwindow.__SURVEY__for the same reason. - Both locales, always. A string added to
en.tsand missing fromru.tsis a broken page, not a fallback. - A moat's number is its identity —
/#moat-7,/moats/7/, translation key7. Renumbering breaks every link ever shared. - Empty sheet copy stays empty. A sheet with no prose renders a draft notice and dashes on purpose; leave the fields blank rather than inventing text.
- Generated files are never hand-edited. Everything under
public/comes fromnpm run iconsornpm run og, andsrc/i18n/translations/moats/ru.tsfromscripts/import-catalogue.mjs. Edit the master, re-run the script, commit the output. - Anything stored in the browser needs a row on
/cookies/and a consent category. The calculator keeps its answers in the URL hash precisely to avoid that; do not add storage there. - Analytics goes through
src/lib/analytics.ts, nevergtagdirectly, so every event carries the locale. - Outbound addresses live in
src/lib/links.ts, brand glyphs come fromsrc/assets/brands/*.svgthrough<BrandIcon />. Those are Font Awesome Pro marks under a commercial licence — copied files only, never the webfont or the CDN, and never a new one without a licence to cover it.
Commits are one imperative sentence, sentence case, no prefix and no trailing
period — Make the depth ruler a real key, and a filter. Branches are
feat/… or fix/… off main.
A pull request should say what changed and what to look at; for anything visual,
a screenshot of both locales and both themes saves a round trip. CI has to be
green before it merges — lint, build, and a container that answers /healthz,
both locales, a sheet and a 404.
Changes accumulate on main; a tag cuts the release. Only a tag publishes an
image, and only a tag moves latest.
# 1. main is green and holds everything the release should carry
git switch main && git pull
# 2. the manifest version and the tag agree
npm version 0.1.0 --no-git-tag-version
git commit -am "Release 0.1.0"
git push
# 3. the tag cuts it
git tag -a v0.1.0 -m "the cross-section, the calculator, the 35 sheets"
git push origin v0.1.0.github/workflows/release.yml then, in one job:
npm ci && npm run lint && npm run build— the release re-runs the checks rather than trusting the last CI run onmain;- builds a
linux/amd64image and smoke-tests it before anything reaches the registry —/healthz,/,/ru/,/calculator/, a sheet, and a 404 that really answers 404; - rebuilds the same layers for
linux/amd64+linux/arm64(the arm64 half covers Ampere hosts, and goes through QEMU, which is where the minutes go) and pushes toghcr.io/giglabocom/moat-atlasas0.1.0,0.1,latestand the full commit sha, with provenance and an SBOM attached; - opens the GitHub release for the tag with generated notes, the
docker runline and the image digest.
Version tags are v + semver (v0.1.0); the image tags drop the v. The
{{major}} tag is deliberately suppressed while the version is 0.x — a bare
0 would promise a stability that a zero-major does not have.
Running the workflow by hand (Actions → Release → Run workflow) builds and
pushes the same image from any ref, but tags it edge and cuts no release.
That is the way to try a build without spending a version number.
Before the first release, two things need doing once in the repository settings:
- Settings → Secrets and variables → Actions → Variables: set
PUBLIC_SITE_URL, andPUBLIC_GA_ID/PUBLIC_GEO_ENDPOINTif they apply. These are build arguments — Astro inlines them into the bundle, so they decide what the published image believes it is. Without them the URL falls back to the production host and GA4 simply never loads. - GHCR creates the package private on the first push. Make it public, or give the deploy host a pull token, under the package's own settings.
Running the release:
docker pull ghcr.io/giglabocom/moat-atlas:0.1.0
docker run -d -p 8080:8080 --read-only --tmpfs /tmp \
--restart unless-stopped ghcr.io/giglabocom/moat-atlas:0.1.0Pin the version rather than latest on a host you care about; the digest in the
release notes pins it exactly.
If a release goes wrong, do not reuse the tag — cut the next patch version.
A tag that has already been pushed is a promise about bytes someone may have
pulled. Only if the workflow failed before pushing the image is it safe to
delete the tag (git push --delete origin v0.1.0), fix, and tag again.
- the appendices of the source catalogue (the seven rocks, the four depths, the cross-cutting patterns) have no page yet
sources,contactandlicenceon/credits/are still empty- the appendix pages, when they land, join
src/lib/seo/pages.ts— that is what gives them a twin, an llms.txt entry, a sitemap row and their structured data