Skip to main content
← Back to Blog
·Oscar Green

Two merges, one declined check, and a bug in my own checker: good_job's Russian and Ukrainian plurals

good_job's dashboard said \"1 минута\" for 21 minutes. Two small PRs fixed 30 strings, the maintainer declined a CI check for a good reason, and that reason exposed a false negative in the checker that found the bug.

i18ncase-studyopen-sourcerailsplurals

Key takeaway

good_job's dashboard said \"1 минута\" for 21 minutes. Two small PRs fixed 30 strings, the maintainer declined a CI check for a good reason, and that reason exposed a false negative in the checker that found the bug.

good_job is a Postgres-based Active Job backend for Ruby on Rails, with about 3,000 GitHub stars. It ships a web dashboard translated into 13 languages besides English, in hand-maintained Rails YAML under config/locales/. It already lints those files with i18n-tasks, which its maintainer, Ben Sheldon, added in #559.

In October I sent it two small PRs. Both merged within a day. I also offered a CI check, and Ben declined it. His reason was correct, and it pointed at a bug in my own checker.

The first PR: one Ukrainian string

Rails plurals pick a form by count. Ukrainian has four forms, and few covers 2–4, 22–24, 32–34 and so on. In uk.yml, x_minutes.few was the literal text 1 хвилини, so the dashboard's relative times said "1 хвилини" ("1 minute") for 3 minutes and for 22 minutes.

I found it while testing a release of my checker on 74 open-source repos. The fix, #1842, was one line: %{count} хвилини, matching the other few entries in the same file. I opened it on October 5. Ben labelled it localization and merged it the next day.

The second PR: the one form is not "one" in Russian

That first bug sent me back to the same files. In English, the one form is only ever used for 1, so writing "1 minute" without %{count} is fine:

# en.yml
x_minutes:
  one: 1 minute
  other: "%{count} minutes"

In Russian and Ukrainian, one also covers 21, 31, 41… ("21 минута", "21 хвилина"). Both files had copied the English shape. Twelve distance_in_words keys plus apply_to_all hard-coded "1" in their one form, 26 strings across the two files. The result: 21 minutes rendered as "1 минута" / "1 хвилина", and 21 hours as "около 1 часа" ("about 1 hour").

Reading the file turned up a second bug that no tool would have caught. The Russian jobs.table.actions.apply_to_all, the label on the jobs page's bulk action, held a copy of about_x_hours. Instead of "Apply to all 1234 jobs" it said "около 1234 часов" ("about 1234 hours"). The placeholders matched, so it was invisible to any placeholder check.

#1848 fixed both: %{count} in the 26 one forms, and a real Russian label ("Применить ко всем %{count} заданиям."). I asked for a native speaker's look in the PR. I opened it on October 6, and Ben merged it at 14:32 UTC the same day.

The declined check

good_job's linter hadn't caught any of this, and the PR body explained why. i18n-tasks' inconsistent_interpolations only walks keys that exist in English, so few and many are never compared. The repo's i18n-tasks.yml also ignores *.one. I offered a small spec that would check every plural form except an English-style one.

Ben replied seventeen seconds before merging:

I vaguely remember this being there because different languages have different rules about whether they require an interpolation or not.

I'm ok with this and limping forward on language-by-language fixes.

That is a reasonable call for a project with 13 translations, and I didn't push it. The *.one ignore is still in i18n-tasks.yml today.

What his reason said about my checker

He was right that languages differ on whether one needs the count. In English, German or French, one means exactly 1, and "1 minute" with no %{count} is correct. That's why the ignore exists.

My checker made the same assumption, the other way round. It compared each one form with the English one, which has no %{count}, so a Ukrainian one of "1 хвилина" passed. It flagged a hard-coded count in few (that's how it found #1842), but it couldn't see the 26 strings in #1848. I had found those by reading the file.

The fix shipped the next day as @shipi18n/core 2.15.1. Where a language's one covers more than 1 (Russian, Ukrainian, Belarusian, Croatian, Bosnian, Serbian, Lithuanian, Latvian, Icelandic…), the one form is now held to the source's other, and a missing count is a warning that names the numbers ("uk "one" also covers 21, 31…"). Where one means exactly 1, nothing changes. Its changelog records the before and after on good_job itself: 0 → 26 warnings on the files before #1848, exactly the 26 strings fixed upstream, and 0 after.

So the distinction Ben gave for not adding a check is the one the check needed to learn.

What held

  • Held: on 2026-10-13, main (a7f4452) still has %{count} in every ru/uk one form, the Ukrainian few fix, and the Russian apply_to_all label. The checker (2.16.1) finds 0 placeholder or plural problems in any of the 13 translations.
  • No CI check, by choice. Further fixes will come language by language, as Ben said.

Timeline

date (UTC)event
Oct 5 10:37#1842 opened: uk x_minutes.few hard-coded "1"
Oct 6 01:58#1842 merged
Oct 6 10:03#1848 opened: 26 ru/uk one forms + ru apply_to_all, with a CI-check offer
Oct 6 14:31Ben declines the check
Oct 6 14:32#1848 merged
Oct 7 08:51Shipi18n 2.15.1 released: one checked against other where one ⊃ 21, 31…
Oct 13re-checked on main: still clean

I maintain Shipi18n, the open-source checker that found the first of these bugs and learned to find the second. Every finding was verified by hand against main before filing. The full list of repos and strings is at shipi18n.com/oss.

O

Oscar Green

The Shipi18n team builds tools that help developers ship multilingual apps faster. We write about i18n best practices, localization automation, and translation engineering.