This is the single authoritative guide for updating Localize to a new CLDR release. It covers the whole cycle: refreshing the vendored sources, regenerating locale and supplemental data, updating conformance code against the TR35 changes, verifying, and shipping. A new maintainer should be able to run an update end-to-end from this document alone.
Release-specific work items live in a per-release plan (plans/cldr-49.md for the October 2026 cycle); this guide is the repeatable process those plans hang off.
At runtime Localize reads only compiled ETF files under priv/localize/ (plus Unicode Character Database tables under priv/unicode/). Those ETFs are generated by the build-time pipeline in data/ from raw CLDR sources vendored under priv/cldr/ (gitignored, reproducible from upstream). Only en.etf and und.etf locale files ship in the hex package; every other locale is downloaded at runtime from the CDN (Cloudflare R2, https://elixir-localize.com/locales/v<version>/<locale>.etf) and verified against a SHA-256 hash manifest bundled in the package (priv/localize/locale_hashes.etf). Updating CLDR means: refresh sources → regenerate ETFs → adapt code to spec changes → verify → upload to CDN → regenerate the hash manifest from the CDN bytes → release.
priv/
├── cldr/ # Build-time source data (gitignored except essentials)
│ ├── bcp47/ # BCP 47 extension key definitions (XML)
│ ├── collation/ # Collation tailoring (XML) + FractionalUCA.txt
│ ├── external_sources/ # Script_Metadata.csv, iso_currencies.xml
│ ├── locales/<locale>/ # Per-locale JSON from CLDR production data
│ ├── supplemental_data/ # Supplemental JSON and XML
│ ├── validity/ # Validity XML for subtag validation
│ └── FractionalUCA.txt # Fractional UCA table (committed — collation + reorder data)
│
├── localize/ # Runtime data (ETF) — committed except locales/
│ ├── locales/<locale>.etf # Only en.etf and und.etf committed / packaged
│ ├── supplemental_data/*.etf
│ ├── validity/*.etf
│ ├── locale_hashes.etf # SHA-256 manifest of CDN locale bytes (packaged)
│ ├── collation_table.etf, all_locale_names.etf, known_territories.etf, …
│ ├── version # CLDR version (e.g. "48.2")
│ └── localize_patch_version # "{cldr_version}:{patch}" (e.g. "48.2:1")
│
├── unicode/ # UCD tables (combining_class.txt, general_category.txt)
data/ # Pipeline (compiles in :dev/:test only, not in the package)
├── data.ex # Entry points (Localize.Data.*)
├── locale.ex # Per-locale generation (incl. loader transforms)
├── locale_transformer.ex # Struct transforms applied during generation
├── supplemental.ex, validity.ex, collation.ex, plural_rules.ex, …
├── normalize/ # Per-domain JSON normalizers
└── mix/tasks/ # Mix tasks (see reference table below)
scripts/
├── ldml2json # Legacy LDML→JSON wrapper (deprecated; misses annotations)
└── ldml2json_v2 # Wraps unicode-org/cldr-json's cldr-generate-json.sh (use this)
-
Toolchain. Elixir/OTP pinned by
.mise.toml. Non-interactive shells do not pick up mise activation — prepend the pinned bin dirs toPATHbefore anymixcommand (see the toolchain section of~/.claude/CLAUDE.mdormise which elixir). -
CLDR repository — a checkout of
github.com/unicode-org/cldrat the release tag. Location:$CLDR_REPO, default../cldr_repo(on the primary workstation:~/Development/cldr/cldr_repo). -
CLDR production data — the pre-built JSON locale files, produced by
scripts/ldml2json_v2(requires Java/Maven; wrapscldr-generate-json.shand emits all packages includingannotations). Location:$CLDR_PRODUCTION_DATA, default../cldr_production_data. -
R2 credentials (upload/release phase only):
R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET. In CI these are repository secrets used by.github/workflows/upload-locales.yml.
| Task / script | Purpose |
|---|---|
mix localize.update_cldr |
Orchestrates phases 1–3 (copy sources → generate supplemental → compile gate → generate locales → test gate), each step in a fresh VM. --check runs the preflight and prints the plan without changing anything; --locales en,fr trials a subset. |
scripts/ldml2json_v2 |
Converts the CLDR XML repo to production JSON (all packages). Run once per CLDR drop. |
mix localize.copy_sources |
Copies raw CLDR sources into priv/cldr/ and writes the CLDR version to priv/localize/version. --supplemental / --locales scope the copy. |
mix localize.generate_supplemental |
Regenerates supplemental, validity, collation and top-level ETFs under priv/localize/. |
mix localize.generate_locales |
Regenerates per-locale ETFs (priv/localize/locales/). Positional args scope to specific locales. |
mix localize.generate_locale_hashes |
Regenerates the download-integrity manifest. Use --from-cdn after uploading so the manifest hashes the exact bytes consumers download (see "Hash manifest and the OTP encoding trap"). |
mix localize.update_mf2_conformance |
Re-vendors the 16 MessageFormat 2 working-group conformance files into test/support/data/mf2_conformance/. Run when the WG suite moves. |
mix localize.bump_patch_version |
Bumps the Localize-side patch counter ({cldr_version}:{patch}); auto-resets to 0 when the CLDR version changes. Never bumped automatically. |
mix localize.download_unicode_data |
Refreshes UCD tables in priv/unicode/ (own cadence — on Unicode releases, not every CLDR drop). |
mix localize.download_iso_currencies |
Refreshes ISO 4217 data from SIX Group (own cadence). |
mix localize.upload_locale |
Generates and uploads specific locales to R2 (--version required). For ad-hoc fixes; full releases go through the tag-triggered workflow. |
.github/workflows/upload-locales.yml |
On version-tag push: regenerates all locales in CI and uploads to R2. Runs on a pinned CI OTP — its ETF bytes are the canonical CDN bytes. |
Run the phases in order. Every phase ends with a verification gate; do not proceed past a red gate.
- Fork the CLDR release page (e.g. https://cldr.unicode.org/downloads/cldr-49) into
plans/cldr-<N>-changes.md, one row per change, scored cosmetic / output-changing / API-affecting / data-only. - Walk the translators' guide (https://cldr.unicode.org/translation) section by section into
plans/cldr-<N>-translator-guide-checklist.md, marking each section correct / partial / missing / N/A against our implementation. - Diff the conformance fixtures:
diff -r $CLDR_REPO_old/common/testData $CLDR_REPO_new/common/testData --brief. For each delta: re-import updated fixtures we already ingest, add loaders for new ones in directories we cover, and log new directories as work items. - Diff the TR35 spec chapters relevant to shipped features (dates, numbers, collation, messageFormat) and file work items for behavioural changes. After release, walk the TR35 §Modifications log into
plans/cldr-<N>-retrospective.md(template:plans/cldr-48-retrospective.md) classifying every entry as data-only / code-applied / code-pending / skipped-with-reason.
Once the upstream checkouts are refreshed (the first two commands of Phase 1), the copy → generate → verify sequence can run as a single orchestrated task:
mix localize.update_cldr --check # preflight: verify sources, report versions, print the plan
mix localize.update_cldr # copy sources, generate supplemental (compile gate), generate locales (test gate)The task stops after Phase 3 and prints the remaining manual phases. The sections below describe what it runs, for when a phase needs to be run or debugged individually.
# 1. Update the upstream checkouts
(cd $CLDR_REPO && git fetch && git checkout release-<N>)
scripts/ldml2json_v2 # regenerates $CLDR_PRODUCTION_DATA (Java/Maven)
# 2. Copy into the project (writes priv/localize/version)
mix localize.copy_sources
# 3. Auxiliary sources — only when their upstreams moved
mix localize.download_unicode_data # UCD (Unicode release cadence)
mix localize.download_iso_currencies # ISO 4217 (SIX Group cadence)
mix localize.update_mf2_conformance # MF2 WG test suite (tracks the WG repo)Also refresh priv/cldr/FractionalUCA.txt from the CLDR repo when the UCA version bumps — it feeds the collation table, the reorder-group data, and the variable-weight boundaries.
Gate: git status shows the expected source deltas and priv/localize/version carries the new CLDR version. The patch counter auto-resets to 0 at the next generation.
mix localize.generate_supplementalReads priv/cldr/{supplemental_data,validity,bcp47,collation}/ and writes the ETFs under priv/localize/. Every module that embeds this data at compile time declares it with @external_resource, so the regeneration automatically recompiles the timezone, time-preference, validity, script-mapping and collation modules — a stale-module bug class that existed before 0.46.0. Do not hand-edit or hand-generate any ETF; everything comes from the pipeline.
Gate: mix compile --warnings-as-errors then mix test. Failures here usually mean a CLDR data-shape change that a normalizer in data/normalize/ must accommodate — fix the normalizer, never the generated file.
mix localize.generate_locales # all localesWrites priv/localize/locales/<locale>.etf (only en.etf/und.etf are committed). The pipeline change-review rule: when any normalizer, @required_modules, or Localize.Data.Locale transform changes, regenerate all locales so the committed pair and the CDN set stay in step.
Attributing churn. ETF files are binary; a byte diff tells you nothing. To distinguish real data changes from encoding noise, compare decoded terms: load old and new ETFs with :erlang.binary_to_term/1 and diff the terms (a ~10-line script; the technique is called a semantic diff in past changelogs). From the October 2026 cycle onwards ETFs are encoded deterministically, so byte-identical means term-identical and this distinction matters less — but the semantic diff remains the tool for reviewing what the new CLDR version actually changed.
Gate: full mix test (the test helper downloads its 39 working locales from the freshly generated set in dev/test — they are generated on the fly, not downloaded, when the pipeline is newer).
Data regeneration alone is not an update. Work through the Phase 0 artifacts:
- Spec-driven fixes. For each code-pending item from the changes log and translators' checklist, implement against the TR35 text (the authoritative checkout lives beside the repo:
$CLDR_REPO/docs/ldml/tr35-*.md— always the file, never a possibly-stale web copy). - Fixture-driven fixes. Wire the new/updated
common/testDatafixtures intotest/conformance suites and fix what they expose. Record the CLDR version + git SHA in each conformance file header. - Parser/lexer changes. If the RBNF (or any) source text format changed, adapt
src/*.xrl/*.yrland the normalizers. Verify lexer changes with a token-stream diff: dumpinspect(tokens)for every rule string in every locale with the old lexer, rebuild, dump again,diff— the streams must be identical unless the format change is intentional (harness precedent: the 0.46.0rbnf_lexerfix validated 6,584 rules across 48 locales this way). - MF2. If the WG suite was re-vendored, run the formatter conformance tests and triage new cases: implement, or add a documented per-case exclusion.
Gate: the full six-gate stack — mix format --check-formatted, mix compile --warnings-as-errors, mix test, mix credo --strict (0), mix dialyzer (0), MIX_ENV=release mix docs (0 warnings) — plus all conformance suites green.
-
Update
mix.exs@version, the CHANGELOG (a "CLDR base data" entry linking toplans/cldr-<N>-changes.md, plus user-visible output changes), and the README if the install snippet's~>floor needs moving. -
Commit and push; tag the release (
vX.Y.Z). The tag push triggers.github/workflows/upload-locales.yml, which regenerates all locales in CI and uploads them to R2 under the version prefix. -
After the upload completes, regenerate the hash manifest from the CDN:
mix localize.generate_locale_hashes --from-cdn
Commit the updated
priv/localize/locale_hashes.etf. See the trap below for why this must hash CDN bytes and must happen after the upload. -
Run the release-review checklist (README install snippet, hexdocs links, executed doc examples, changelog claims) and
mix hex.publish. -
Tag hygiene: the repo convention is a lightweight
vX.Y.Ztag on the commit that was published.
- Complete the modifications retrospective (
plans/cldr-<N>-retrospective.md). - Roll unfinished items forward into the next release plan; date-stamp a closing entry in the current plan's change log.
- Sweep
plans/quarterly for stale items.
The download-integrity manifest (priv/localize/locale_hashes.etf, packaged in the hex release) maps locale → SHA-256 of the CDN object bytes. Localize.Locale.Provider verifies every download against it before decode; a mismatch fails closed with LocaleIntegrityError.
The trap: term_to_binary/1 output historically differed across OTP releases (map key ordering), and the CDN bytes are produced by CI's OTP (see the pinned version in upload-locales.yml), not your workstation's. Hashing locally generated files therefore produced a manifest that failed against the CDN — this broke CI in the 0.44.0 cycle and is why --from-cdn exists and why the manifest step comes after the upload. From October 2026 the pipeline encodes ETFs deterministically, making local and CI bytes identical — but keep using --from-cdn: it verifies what consumers actually download, which is the property the manifest exists to guarantee.
- Never write or delete through
_build/*/lib/localize/priv— it is a symlink to the realpriv/. Anrm -rf"in the build directory" destroys source data (this has happened). - Never hand-generate or hand-edit an ETF. Data flows only through the pipeline tasks; a hand-built file will differ from regenerated output and poison the manifest or the semantic diff.
- Do not run
gitstate-changing commands as part of the update — the maintainer commits, tags, and pushes explicitly at the points the runbook calls out. - The dev/test locale set is generated on the fly; only
en.etf/und.etfare committed. If local tests suddenly download from the CDN, your generated set is stale relative topriv/localize/version. - Patch releases within a CLDR version: pipeline changes without a CLDR bump use
mix localize.bump_patch_version({cldr_version}:{patch}), then regenerate + re-upload + re-manifest exactly as in Phase 5.
These land with the CLDR 49 cycle and then disappear from this guide. Detail and status live in plans/cldr-49.md and the task list.
- Deterministic ETF encoding. Encode all generated ETFs with deterministic map ordering (e.g.
term_to_binary(term, deterministic: true)) so regeneration is byte-stable across OTP releases. Accept the one-time full-file churn in the same commit as the CLDR 49 data churn. - Collation ETF: reorder-group starts. Emit the FDD1 group-marker fractions (SPACE/PUNCTUATION/SYMBOL/CURRENCY/DIGIT first-primary
{lead, sub}bytes) fromFractionalUCA.txtinto the collation ETF; read them inLocalize.Collation.Variable.primary_range/1and delete the pinned@group_startsconstants there. - Remove the
:altshim. The pipeline fix for the"alt"(Southern Altai) language-code atomization is already indata/locale.ex(restore_alt_language_code/1); once CLDR 49 data is regenerated, deletefix_alt_language_key/1fromLocalize.Locale.Provider.PersistentTerm. - Adopt
scripts/ldml2json_v2as the only documented conversion path; deprecate and later removescripts/ldml2json(v2 emits the annotations packages that the emoji work needs). - RBNF source-format change. CLDR 49 reshapes RBNF rule text (including the remove rule from ruleset mechanic). Adapt
data/normalize/rbnf.ex(and the lexer/parser if the rule text itself changed — use the token-diff harness), keeping the runtime ETF shape stable where possible. - Remaining feature items from
plans/cldr-49.md: semantic skeletons (child planplans/semantic-skeletons.md), date-time append items (needs the newdateFieldsingestion), minimal pairs, min/max significant digits (gated on the CLDR 49 decimal fixtures), thecommon/testDataaudit, and the two 48-retrospective follow-ups (gmtUnknownFormatconsumer,FractionalUCA_blanked.txtingestion).
Item 13 of the original CLDR 49 plan (CDN checksum manifests) shipped early, in Localize 0.44.0, as the hash-manifest system described above.