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.

The five points of a string freeze A freeze date stops new source strings entering the release. A translation window follows, during which the queue is stable and its size is known. A coverage gate runs against the release branch a couple of days before shipping. The release ships with whatever cleared the gate, and the trunk continues accepting strings for the next cycle. A release train with a string freeze T-10 days String freeze no new source strings for this release T-9 to T-3 Translation window the queue is stable and finite T-2 days Coverage gate per locale, against the release branch T-0 Release ships with what cleared the gate T+0 Trunk unfreezes new strings land for the next train
The freeze exists so the window is finite. Without it, translators are aiming at a target that moves.

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.

What a string freeze covers A freeze stops new source strings and edits to frozen ones, because both leave translations that cannot be completed in time. Typo fixes are judged individually, since a fix that changes meaning is a new string. Translations arriving are the purpose of the window. And strings behind an unreleased feature flag do not block the release. What a freeze does and does not stop Frozen? Because New source strings yes they cannot be translated in time Copy edits to frozen strings yes a changed source invalidates its translations Typo fixes case by case a typo fix that changes meaning is a new string Translations arriving no — that is the point the window exists for them New features behind a flag no unreleased strings do not block a release
A freeze is about the source side only. Nothing about it slows the translators down.

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.

Five mechanisms that make a freeze real The release branch carries the freeze while the trunk continues accepting strings. A diff-scoped check fails any source key added on the release branch. A labelled exception exists for genuine hotfixes and makes them visible. The coverage gate runs against the release branch with the release thresholds. And a daily queue report during the window surfaces a stuck locale early. Enforcing the freeze in CI 1 Branch the release, freeze on the branch the trunk keeps moving 2 Fail added source keys on the release branch a diff-scoped check, not a whole-file one 3 Allow a labelled exception hotfix strings happen; make them visible 4 Run the coverage gate against the branch per locale, with the release thresholds 5 Report the queue daily during the window so a stuck locale is seen on day two
Without the second step, a freeze is a calendar entry that everyone respects until the week they do not.

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.

Part of GitHub Actions i18n CI Gates.