diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
new file mode 100644
index 0000000..2d27051
--- /dev/null
+++ b/.github/workflows/build-and-test.yml
@@ -0,0 +1,132 @@
+# Build and test @urnetwork/localizations on every push and pull request.
+#
+# There is nothing to compile here -- the "build" IS the codegen. `npm run gen`
+# loads and validates all 1,224 keys/*.yaml through gen/store.mjs validate()
+# (snake_case ids; a required source/description/en; known locales; one
+# xcstrings key per source text on apple; every declared placeholder present in
+# every localization; exactly the CLDR plural categories a locale actually has;
+# product names never translated) and then runs all four emitters: android
+# strings.xml, apple Localizable.xcstrings, windows Resources.resw, linux
+# .po/.pot/LINGUAS. A key that breaks any of that is a real break, and today
+# nothing catches it until the release build regenerates the app trees.
+#
+# WHAT THIS DELIBERATELY DOES NOT DO
+#
+# * Publish. package.json's release:patch/minor/major run `npm publish` and
+# need an npm token. Nothing here reaches them; this workflow uses no
+# secret at all.
+#
+# * Diff against the sibling app checkouts. `npm test` is
+# `node gen/generate.mjs --check`, which compares the generated files with
+# $URNETWORK_ROOT/{android,apple,windows,linux}/... -- four OTHER
+# repositories' working trees. This job points URNETWORK_ROOT at the tree
+# the build step just wrote, so `npm test` gates on the codegen being
+# complete and deterministic without making this repo's CI a function of
+# four other repos' latest commits (server/test.yml documents the same
+# trade-off where it is forced to take it). See the PR for how to opt in.
+#
+# * Run gen/verify-lossless.mjs. It needs `--baseline
`, a snapshot of
+# the pre-migration app tree that is not in this repo; run bare -- which is
+# all the `verify-lossless` script does -- it exits 2.
+name: Build and test — localizations
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: npm run gen + npm test
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ # A real pin. web/build.yml uses `node-version: latest`, which floats
+ # onto whatever shipped this week. 24 is the major the release build
+ # runs from (build/all/run.sh gates on node v24.14.1) and the one
+ # urnetwork/extension's CI uses. js-yaml is the only dependency and
+ # package.json declares no `engines`, so nothing narrower is implied.
+ node-version: '24'
+ cache: npm
+
+ # npm ci, not `npm i`: it installs package-lock.json exactly and fails
+ # when the lockfile and package.json have drifted, which is one of the
+ # things a first CI is for. `npm i` rewrites the lockfile in place and can
+ # green-light a tree nobody else can reproduce.
+ - name: Install
+ run: npm ci
+
+ # gen/store.mjs defaults URNETWORK_ROOT to `..`, the sibling app
+ # checkouts. CI has none, so send the output to a scratch tree instead --
+ # see the header for why this stays a single-repo check.
+ - name: Point the codegen at a scratch tree
+ run: echo "URNETWORK_ROOT=$RUNNER_TEMP/generated" >> "$GITHUB_ENV"
+
+ # The real work. Both failure modes are hard: validate() exits 1 naming
+ # the offending key[locale], and an emitter that throws fails the step.
+ # The file assertions are here because `gen` reports how many files it
+ # wrote but does not fail on writing a file that is empty or absent.
+ - name: Build (validate the store, generate every platform)
+ run: |
+ npm run gen
+ for f in \
+ android/app/app/src/main/res/values/strings.xml \
+ apple/app/network/Shared/Resources/Localizable.xcstrings \
+ windows/app/src/App/Strings/en/Resources.resw \
+ linux/app/po/urnetwork.pot \
+ linux/app/po/LINGUAS
+ do
+ if [ ! -s "$URNETWORK_ROOT/$f" ]; then
+ echo "::error::codegen produced no $f"
+ exit 1
+ fi
+ done
+
+ # `npm test` is `node gen/generate.mjs --check`. Pointed at the tree the
+ # step above just wrote, it regenerates everything and asserts byte
+ # equality -- so a generator that is not deterministic (sort order, object
+ # key order, Intl collation) fails here, rather than surfacing later as a
+ # phantom diff in an app repo.
+ - name: Test (npm test -- regenerate and assert byte-for-byte equality)
+ run: npm test
+
+ # index.js is the package's entire public API: it is what the web
+ # extension imports, and no other step touches it. Exercise the three
+ # promises it makes -- the platform filter, alias resolution
+ # ("continue" -> continue_txt), and every value being a string (a plural
+ # key collapses to its `other` form).
+ - name: Test the published API (index.js)
+ run: |
+ node --input-type=module -e '
+ import { loadAllKeys, getSupportedLanguages } from "./index.js";
+ const fail = (m) => { console.error("::error::" + m); process.exit(1); };
+ const web = loadAllKeys();
+ const all = loadAllKeys({ platform: null });
+ const langs = getSupportedLanguages({ platform: null });
+ console.log("web " + Object.keys(web).length + " keys, store " +
+ Object.keys(all).length + " keys, " + langs.length + " locales");
+ if (Object.keys(web).length < 1) fail("loadAllKeys() returned nothing");
+ if (Object.keys(all).length <= Object.keys(web).length)
+ fail("loadAllKeys({ platform: null }) did not return the whole store");
+ if (!web["continue"]) fail("the alias \"continue\" no longer resolves");
+ if (!langs.includes("en")) fail("en is missing");
+ for (const [id, k] of Object.entries(all))
+ if (typeof k.localizations?.en !== "string") fail(id + ": en is not a string");
+ '