Translation freeze windows and release trains
A release slips by a week because three locales are at ninety-one percent coverage. Nobody did anything wrong: strings were added right up to the release branch being cut, translators received a queue that grew every day, and the coverage gate — correctly — refused to let an untranslated interface ship.
Translation has a lead time. A release process that does not account for it either ships untranslated copy or blocks on a queue that was never given a chance to drain.
Root cause: a moving target cannot be finished
Translation is asynchronous work performed by people who are not on your team and do not see your branch. It has a throughput, measured in words per day, and a latency, measured in days between a string appearing and its translation arriving.
A pipeline with continuous string additions and a coverage gate at release creates a race between the two. If strings are added faster than they are translated, coverage falls as the release approaches, and the gate blocks. The team’s usual responses are both bad: lower the threshold, which ships untranslated copy, or override the gate, which teaches everyone the gate is advisory.
A freeze resolves it by making the queue finite. After the freeze date, the set of strings that must be translated for this release stops changing, so the window has a known amount of work and translators can finish it. That is the entire mechanism, and it is why a freeze is a scheduling instrument rather than a restriction.
Sizing the window from real numbers
The freeze date is not a convention to be copied; it is derived from two numbers your own pipeline can report.
Volume: how many new or changed words a typical release contains. Countable from the catalogue diff between the last two releases.
Throughput: how many words per day the translation process actually completes, per locale. This includes queue time, not only translation time, and it is the number that differs most between an in-house team and an agency.
The window is volume divided by the slowest locale’s throughput, plus a margin for review. On most products this produces something between three days and two weeks, and it is worth recomputing occasionally because both numbers move — a locale added, a vendor changed, a product area that generates more copy.
The value of deriving it rather than guessing is that the freeze becomes defensible. “Ten days because Japanese takes that long at our volume” is a statement someone can argue with using evidence; “ten days because that is the process” is not.
Enforcing it without stopping the trunk
A freeze that stops all development is unacceptable and unnecessary. Freezing a release branch while the trunk keeps accepting strings gives both properties: the release queue is fixed, and the next cycle’s work continues.
# .github/workflows/i18n-freeze.yml — runs on PRs targeting a release branch
on:
pull_request:
branches: ['release/**']
jobs:
freeze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: No new source strings during the freeze
run: |
added=$(git diff "origin/${{ github.base_ref }}"...HEAD -- 'locales/en/*.json' | grep -E '^\+\s+"[a-z0-9.-]+":' | wc -l)
if [ "$added" -gt 0 ] && ! ${{ contains(github.event.pull_request.labels.*.name, 'i18n-exception') }}; then
echo "::error::$added new source string(s) on a frozen branch."
echo "Land them on main, or label this PR 'i18n-exception' with a reason."
exit 1
fi
The label is deliberate. Genuine exceptions exist — a legal correction, a security-related message — and the useful behaviour is to make them visible and deliberate rather than impossible. A freeze with no escape hatch gets bypassed by pushing directly to the branch, which is worse than an exception nobody can miss in the pull request list.
What the gate should check on a frozen branch
The coverage gate described in GitHub Actions i18n CI gates runs against the release branch with release thresholds, which are usually stricter than the trunk’s.
Two refinements matter during a freeze. The gate should report the gap, not only pass or fail: naming the locales below threshold and the number of missing strings turns a blocked release into a task list someone can act on. And it should run daily during the window rather than only at the end, because a locale that is stuck — a vendor has not started, a file failed to sync — is recoverable on day two and not on day nine.
That daily signal is the part teams most often skip, and it is the difference between a freeze that works and one that merely relocates the surprise to the end of the window.
The continuous alternative: gate the feature, not the release
Teams shipping several times a day cannot freeze on a calendar, and the goal — never showing a reader an untranslated interface — is achievable without one. The mechanism moves from the release to the feature.
A feature’s strings are extracted when it merges, translated over the following days, and the feature is enabled per locale as its coverage reaches the threshold. English readers see it immediately; German readers see it when German is ready. The release never blocks, because releasing and revealing have been separated.
Three things have to exist for that to work. The flag must be locale-aware, which is a small extension to most flag systems: an enabled-locales list rather than a boolean. The coverage check must be per feature, which means strings are namespaced by feature — the namespace boundaries described in string catalog governance doing double duty. And something must flip the flag when coverage arrives, whether that is a scheduled job or a person reading a report.
The trade is that a feature is live in some languages before others, which is visible to anyone comparing markets and is usually preferable to the alternatives — delaying it everywhere, or shipping it untranslated somewhere.
It is worth noting that the two approaches are not exclusive. A product can run continuous delivery for most work and still freeze around a major launch where simultaneous availability across markets is the point. The freeze is then a deliberate exception with a stated reason rather than a standing process.
Verification
# During the window: where is each locale?
npx tsx scripts/coverage.ts --branch release/2026.3 --format table
# locale translated threshold status
# de 100.0% 100% ok
# fr 99.8% 100% 2 strings
# ja 91.2% 100% 118 strings ← visible on day two, not day nine
# After the freeze date: adding a source string must fail
git checkout release/2026.3
echo '{"new.key":"Hello"}' >> locales/en/common.json
git commit -am 'test' && gh pr create --base release/2026.3 --fill
# ✗ i18n-freeze — 1 new source string on a frozen branch
When to escalate
If the window is always too short, the numbers say so — recompute it from the current volume and throughput rather than negotiating the date. Frequently the honest answer is that the release cadence and the translation capacity are mismatched, and the fix is one of those two rather than the freeze.
If one locale is consistently the blocker, it is a vendor or capacity issue rather than a process one. A per-locale threshold, allowing a newer market to ship at a lower coverage while established ones hold at full, is a legitimate and explicit answer.
If exceptions become routine, the freeze date is wrong or the trunk is not actually where work lands. Counting labelled exceptions per release makes that visible, and a rising count is the signal to revisit the schedule rather than the enforcement.
FAQ
Does continuous deployment make freezes impossible?
It makes calendar freezes awkward and the underlying idea still applies: a string that has not been translated should not reach users. The continuous equivalent is to gate the feature rather than the release — ship behind a flag, and enable the flag per locale when its strings arrive.
Should the freeze cover copy edits?
Yes, when the edit changes meaning, because the existing translations are then answers to a different question. A pure typo fix that leaves the meaning intact is safe, and drawing that line explicitly avoids a debate in every exception request.
What about strings behind a feature flag?
They do not block, because they are not user-visible in the release. Excluding flagged-off namespaces from the release coverage calculation is worth doing explicitly, or a large unreleased feature can hold up a release it has nothing to do with.
How does this interact with a hotfix?
A hotfix branch has its own, much shorter cycle, and a user-visible string in one is the clearest case for the exception label. Where the fix must ship immediately, shipping it in the source language and following with translations is a defensible choice — provided the follow-up is tracked rather than assumed.
Related
- GitHub Actions i18n CI Gates — the coverage gate a freeze exists to satisfy.
- GitHub Actions matrix jobs per locale — reporting the gap per locale rather than in aggregate.
- Crowdin Integration for Dev Teams — the sync that fills the queue during the window.
- Versioning a shared message package — releasing catalogues on a cadence consumers can plan around.
Part of GitHub Actions i18n CI Gates.