Make WordPress Core

Opened 5 weeks ago

Last modified 2 weeks ago

#65809 new defect (bug)

wp_get_l10n_php_file_data() reads the wrong revision date key from .l10n.php files

Reported by: bejignesh Owned by:
Priority: normal Milestone: 7.2
Component: I18N Version: 6.6
Severity: normal Keywords: has-patch
Cc: Focuses:

Description (last modified by bejignesh)

wp_get_l10n_php_file_data() in wp-includes/l10n.php maps the PO headers it returns onto the keys a .l10n.php file stores them under:

'PO-Revision-Date'   => 'po-revision-date',

No generated .l10n.php file uses that key. The revision date is written as translation-revision-date, so PO-Revision-Date always comes back empty.

tests/phpunit/data/languages/de_CH.l10n.php has the same wrong key, which is why the existing coverage in Tests_L10n::test_wp_get_installed_translations_for_core() passes. The fixture matches the code rather than a real file. tests/phpunit/data/l10n/plural-complex.php, added earlier in [57518], uses the correct key.

Why it matters

wp_get_installed_translations() falls back to this reader when a translation has no sibling .po file. That value is sent to api.wordpress.org to work out which language packs are out of date. With an empty revision date, a pack that is already installed and current gets offered again.

Measured on trunk with the Twenty Twenty-Five nl_NL pack, freshly installed, changing nothing but whether the .po is on disk:

state wp_get_installed_translations() reports offers after wp_update_themes()
.po present 2025-08-05 20:37:56+0000 0
.po absent empty string 1

Fix

Read translation-revision-date, and correct the de_CH.l10n.php fixture to use it as well.

POT-Creation-Date maps to pot-creation-date, which no format writes, so it stays empty. Left alone here.


This came out of #65429 but is not the cause of that report, where the .po files were present. Filed separately since the defect stands on its own.

An earlier version of this description claimed the key had been renamed upstream. That was wrong, see comments below. The fixture was simply wrong from the start.

Change History (10)

This ticket was mentioned in PR #12844 on WordPress/wordpress-develop by @bejignesh.


5 weeks ago
#1

wp_get_l10n_php_file_data() maps the PO headers it returns onto the keys a .l10n.php file stores them under:

$headers = array(
        'POT-Creation-Date'  => 'pot-creation-date',
        'PO-Revision-Date'   => 'po-revision-date',
        'Project-Id-Version' => 'project-id-version',
        'X-Generator'        => 'x-generator',
);

GlotPress writes the revision date under translation-revision-date. The key core looks for is absent from every language pack currently served from WordPress.org, so PO-Revision-Date always comes back empty.

### The key was renamed upstream

File X-Generator Revision date key
tests/phpunit/data/languages/de_CH.l10n.php (already in core) GlotPress/4.0.0-beta.2 po-revision-date
twentytwentyfive-nl_NL.l10n.php GlotPress/4.0.3 translation-revision-date
simple-history-es_ES.l10n.php GlotPress/4.0.3 translation-revision-date

So the mapping was correct when the function was introduced in 6.6 and stopped matching when the name changed. Project-Id-Version and X-Generator still resolve, which is why the failure is silent: only the revision date is lost.

The value is intact under the other name. For the Simple History packs, the .l10n.php value and the .po header agree to the second:

Locale .l10n.php translation-revision-date .po PO-Revision-Date
es_ES 2026-06-26 08:56:45+0000 2026-06-26 08:56:45+0000
nl_NL 2026-06-09 10:37:20+0000 2026-06-09 10:37:20+0000
de_DE 2026-07-18 14:16:42+0000 2026-07-18 14:16:42+0000

### Why it matters

wp_get_installed_translations() falls back to this reader when a translation has no sibling .po file. That value goes to api.wordpress.org to determine which language packs are out of date. An empty revision date means a pack that is already installed and current gets offered again on every check.

Measured on trunk with the Twenty Twenty-Five nl_NL pack, freshly installed, changing nothing but whether the .po is on disk:

State wp_get_installed_translations() reports Offers after wp_update_themes()
.po present 2025-08-05 20:37:56+0000 0
.po absent '' 1

In the second row the installed pack is byte-identical to the one the API is serving.

### Why the existing tests did not catch it

The only coverage is Tests_L10n::test_wp_get_installed_translations_for_core(), and the .l10n.php fixture it asserts against, de_CH.l10n.php, predates the rename and still uses po-revision-date. The assertion passes because the fixture matches the code, not because either matches a current language pack.

### The change

Each PO header now maps to an ordered list of keys the file may use, preferring translation-revision-date and falling back to po-revision-date, so files written before and after the rename both resolve.

POT-Creation-Date maps to pot-creation-date, which appears in neither format. I have left it alone rather than guess at an intended source; it is worth a separate decision.

### Testing

  • New fixture tests/phpunit/data/l10n/translation-revision-date.l10n.php in the current GlotPress format. It is deliberately in data/l10n/ rather than data/languages/, because the latter is WP_LANG_DIR for the suite and glob()s *.l10n.php, so a file added there would change what wp_get_installed_translations() returns for other tests.
  • New test_wp_get_l10n_php_file_data_reads_revision_date() covers both spellings. On trunk it fails with '2026-06-26 08:56:45+0000' expected, '' actual. With the change it passes, 4 assertions.
  • --group l10n: 267 tests, 808 assertions, 0 failures.
  • --group i18n: 268 tests, 813 assertions, 0 failures.
  • Both groups report one warning, Tests_Locale::test_get_weekday_undefined_index, a PHPUnit 10 deprecation. I confirmed it is identical with the changes stashed, so it is pre-existing and unrelated.
  • phpcs clean.

### Relationship to #65429

This came out of investigating #65429, but it is not the cause of that report. The reporters there confirmed their .po files were present, which means this fallback never ran on their sites. #65429 remains open and unexplained. Filing separately because the defect stands on its own.

## Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Drafting the patch, the test and this description. I found the key mismatch by installing real language packs locally and comparing what shipped against what core read, measured the offer counts on the rendered update check with and without the .po present, compared the packs against the API response timestamps, traced the rename through the X-Generator values, confirmed the pre-existing test warning against a stashed baseline, and I take responsibility for the result.

This ticket was mentioned in Slack in #glotpress by swissspidy. View the logs.


5 weeks ago

This ticket was mentioned in Slack in #meta-i18n by swissspidy. View the logs.


5 weeks ago

#4 @swissspidy
5 weeks ago

  • Keywords needs-patch added; has-patch has-unit-tests removed
  • Milestone Awaiting Review7.2

As discussed in https://wordpress.slack.com/archives/C02RP4R9F/p1785880797649229 there was never any renaming, Claude hallucinated that.

#5 @bejignesh
5 weeks ago

  • Description modified (diff)
  • Summary wp_get_l10n_php_file_data() reads a revision date key that GlotPress no longer writeswp_get_l10n_php_file_data() reads the wrong revision date key from .l10n.php files

Thanks for catching it, you're right.

tests/phpunit/data/l10n/plural-complex.php has been in core since [57518] with the same x-generator as de_CH.l10n.php, GlotPress/4.0.0-beta.2, and it already uses translation-revision-date. Same generator version, correct header. And de_CH.l10n.php went in with the reader itself in [58061] and [58062], same ticket #60554, so it was matching the assumption in the code rather than any real export. It's the only file in the repo with po-revision-date.

So the fixture was wrong from the beginning. I inferred the rename from that one file's x-generator and wrote it up as fact without checking. My mistake, not yours.

I'll retitle, drop the rename claim from the description, and cut the patch down to what you described: the correct key in wp_get_l10n_php_file_data() and in de_CH.l10n.php, nothing else. test_wp_get_installed_translations_for_core() already asserts that value, so the coverage stays once the fixture is right.

@bejignesh commented on PR #12844:


5 weeks ago
#6

Agreed, thanks for catching it.

There was no rename. tests/phpunit/data/l10n/plural-complex.php has been in core since [57518] with the same x-generator as de_CH.l10n.php, GlotPress/4.0.0-beta.2, and it already uses translation-revision-date. And de_CH.l10n.php went in with the reader itself in [58061] and [58062], so it was matching the assumption in the code rather than any real export. It's the only file in the repo with po-revision-date. I inferred the rename from that one file and wrote it up as fact without checking.

@irozum, the rename claim in your review came from my description and is wrong. Flagging it so it does not travel further.

Reducing to the two changes you listed: the correct key in wp_get_l10n_php_file_data() and in de_CH.l10n.php. Dropping the fallback array, the new fixture and the new test. test_wp_get_installed_translations_for_core() already covers the value.

#7 @bejignesh
5 weeks ago

  • Keywords has-patch added; needs-patch removed

Patch reduced to the two key changes in aa7d30f: wp_get_l10n_php_file_data() and the de_CH.l10n.php fixture. No new test, test_wp_get_installed_translations_for_core() already covers the value.

@swissspidy commented on PR #12844:


2 weeks ago
#8

Hmm looks like WP-CLI i18n-command does use PO-Revision-Date when generating the files, and WP_Translation_File::transform will also just use the same header. :/

@bejignesh commented on PR #12844:


2 weeks ago
#9

@swissspidy Confirmed. WP_Translation_File::transform() writes po-revision-date as well, so the approved patch would have emptied the date for every converted file, and WP-CLI does the same.

Both spellings are real, so the reader now holds a list of names per header and takes the first present, translation-revision-date then po-revision-date. I put them in the header map rather than special casing after the loop. One test per producer, and the converted one generates its input through transform() at runtime so it cannot drift from what core writes.

Retitled, since "the correct key" does not fit when there are two.

@swissspidy commented on PR #12844:


2 weeks ago
#10

Oh, thanks. I wasn't implying we need to revert the change again, I first want to wrap my head around it. Not sure what approach is best.

Note: See TracTickets for help on using tickets.