diff --git a/.github/workflows/test-release-tools.yml b/.github/workflows/test-release-tools.yml
index 4f99e5e72db..1875240c14e 100644
--- a/.github/workflows/test-release-tools.yml
+++ b/.github/workflows/test-release-tools.yml
@@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- suite: [domain, milestones-update, milestones-audit, tagger]
+ suite: [domain, milestones-update, milestones-audit, tagger, updater]
name: test (${{ matrix.suite }})
defaults:
run:
diff --git a/tools/release/phpunit.xml.dist b/tools/release/phpunit.xml.dist
index 33046978295..d10c471de14 100644
--- a/tools/release/phpunit.xml.dist
+++ b/tools/release/phpunit.xml.dist
@@ -28,6 +28,10 @@
tests/RepoTaggerTest.php
+
+
+ tests/Updater
+
diff --git a/tools/release/src/Updater/MajorVersions.php b/tools/release/src/Updater/MajorVersions.php
new file mode 100644
index 00000000000..0082d8042e6
--- /dev/null
+++ b/tools/release/src/Updater/MajorVersions.php
@@ -0,0 +1,35 @@
+ $majors
+ * @return array
+ */
+ public static function ensureMajor(array $majors, int $major, string $minPhp): array
+ {
+ $key = (string) $major;
+ if (array_key_exists($key, $majors)) {
+ return $majors;
+ }
+ // Prepend, matching jq's `{($m): …} + .`.
+ return [$key => ['minPHP' => $minPhp]] + $majors;
+ }
+
+ public static function encode(array $data): string
+ {
+ return ReleasesJson::encode($data);
+ }
+}
diff --git a/tools/release/src/Updater/ReleasePlan.php b/tools/release/src/Updater/ReleasePlan.php
new file mode 100644
index 00000000000..989b5ca82a4
--- /dev/null
+++ b/tools/release/src/Updater/ReleasePlan.php
@@ -0,0 +1,104 @@
+ "RC5" (uppercase, no space in the token); beta/alpha -> "beta 5".
+ $display = strtolower($modWord) === 'rc'
+ ? strtoupper($modWord) . $modNum
+ : strtolower($modWord) . ' ' . $modNum;
+ $versionString = "{$base} {$display}";
+ $urlVersion = $base . $modifier;
+ $urlDir = 'prereleases';
+ $stability = 'beta';
+ $type = self::TYPE_PRERELEASE;
+ } elseif ($patch === 0 && $minor === 0) {
+ $versionString = $base;
+ $urlVersion = $base;
+ $urlDir = 'releases';
+ $stability = 'stable';
+ $type = self::TYPE_FIRST_STABLE;
+ } else {
+ $versionString = $base;
+ $urlVersion = $base;
+ $urlDir = 'releases';
+ $stability = 'stable';
+ $type = self::TYPE_PATCH;
+ }
+
+ $deploy = $deployOverride ?? self::autoDeploy($type, $stability, $minor, $patch);
+
+ return new self(
+ major: $major,
+ minor: $minor,
+ patch: $patch,
+ versionString: $versionString,
+ urlVersion: $urlVersion,
+ urlDir: $urlDir,
+ stability: $stability,
+ channel: $stability,
+ releaseType: $type,
+ deploy: $deploy,
+ );
+ }
+
+ /** X.0.0 -> 30%, X.0.1 -> 70%, everything else -> 100%. */
+ private static function autoDeploy(string $type, string $stability, int $minor, int $patch): int
+ {
+ if ($type === self::TYPE_FIRST_STABLE) {
+ return 30;
+ }
+ if ($stability === 'stable' && $minor === 0 && $patch === 1) {
+ return 70;
+ }
+ return 100;
+ }
+}
diff --git a/tools/release/src/Updater/ReleasesJson.php b/tools/release/src/Updater/ReleasesJson.php
new file mode 100644
index 00000000000..f08fca9e7ee
--- /dev/null
+++ b/tools/release/src/Updater/ReleasesJson.php
@@ -0,0 +1,89 @@
+ entry) and returns a new one. Mirrors the jq
+ * in update-updater-server.sh, including the tab-indented output the repo uses.
+ */
+final class ReleasesJson
+{
+ /**
+ * The entry this release replaces: for a patch, the current stable entry of
+ * the major; for a (pre)release, the latest RC/beta/alpha entry. Null when
+ * there is none (a first pre-release of a new major).
+ *
+ * @param array $releases
+ */
+ public static function findOldKey(array $releases, int $major, string $type): ?string
+ {
+ $prefix = "{$major}.";
+ $found = null;
+ foreach (array_keys($releases) as $key) {
+ if (!str_starts_with($key, $prefix)) {
+ continue;
+ }
+ $isPre = preg_match('/[Rr][Cc]|[Bb]eta|[Aa]lpha/', $key) === 1;
+ if ($type === ReleasePlan::TYPE_PATCH) {
+ if (!$isPre && !str_contains($key, 'Enterprise')) {
+ $found = $key; // keep last match (insertion order)
+ }
+ } elseif ($isPre) {
+ $found = $key;
+ }
+ }
+ return $found;
+ }
+
+ /**
+ * The new entry. `deploy` is only written when it is not 100%.
+ *
+ * @return array
+ */
+ public static function newEntry(string $internalVersion, string $bz2Sig, string $zipSig, int $deploy): array
+ {
+ $entry = [
+ 'internalVersion' => $internalVersion,
+ 'signatures' => ['bz2' => $bz2Sig, 'zip' => $zipSig],
+ ];
+ if ($deploy !== 100) {
+ $entry['deploy'] = $deploy;
+ }
+ return $entry;
+ }
+
+ /**
+ * Replace $oldKey (if given) with $newKey => $entry, else just append it.
+ *
+ * @param array $releases
+ * @param array $entry
+ * @return array
+ */
+ public static function apply(array $releases, ?string $oldKey, string $newKey, array $entry): array
+ {
+ if ($oldKey !== null) {
+ unset($releases[$oldKey]);
+ }
+ $releases[$newKey] = $entry;
+ return $releases;
+ }
+
+ /** Encode with tab indentation + trailing newline, matching the repo style. */
+ public static function encode(array $data): string
+ {
+ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ // json_encode pretty-prints with 4 spaces; the repo uses tabs.
+ $json = preg_replace_callback(
+ '/^( +)/m',
+ static fn (array $m): string => str_repeat("\t", intdiv(strlen($m[1]), 4)),
+ $json,
+ );
+ return $json . "\n";
+ }
+}
diff --git a/tools/release/tests/Updater/MajorVersionsTest.php b/tools/release/tests/Updater/MajorVersionsTest.php
new file mode 100644
index 00000000000..5aa271644b1
--- /dev/null
+++ b/tools/release/tests/Updater/MajorVersionsTest.php
@@ -0,0 +1,41 @@
+ ['minPHP' => '8.1']], 35, '8.3');
+ // PHP casts numeric-string array keys to ints; JSON still encodes them as strings.
+ $this->assertSame([35, 34], array_keys($out));
+ $this->assertSame(['minPHP' => '8.3'], $out[35]);
+ }
+
+ public function testNoOpWhenMajorExists(): void
+ {
+ $in = ['35' => ['minPHP' => '8.0'], '34' => ['minPHP' => '8.1']];
+ $this->assertSame($in, MajorVersions::ensureMajor($in, 35, '8.3'));
+ }
+
+ public function testEncodeUsesTabs(): void
+ {
+ $json = MajorVersions::encode(['35' => ['minPHP' => '8.3']]);
+ $this->assertStringContainsString("\n\t\"35\"", $json);
+ $this->assertStringNotContainsString("\n \"", $json);
+ }
+}
diff --git a/tools/release/tests/Updater/ReleasePlanTest.php b/tools/release/tests/Updater/ReleasePlanTest.php
new file mode 100644
index 00000000000..c5fff78adfd
--- /dev/null
+++ b/tools/release/tests/Updater/ReleasePlanTest.php
@@ -0,0 +1,61 @@
+ */
+ public static function tags(): iterable
+ {
+ // tag => versionString, urlVersion, urlDir, stability, releaseType, deploy
+ yield 'patch' => ['v33.0.6', '33.0.6', '33.0.6', 'releases', 'stable', ReleasePlan::TYPE_PATCH, 100];
+ yield 'first stable' => ['v34.0.0', '34.0.0', '34.0.0', 'releases', 'stable', ReleasePlan::TYPE_FIRST_STABLE, 30];
+ yield 'x.0.1 -> 70' => ['v34.0.1', '34.0.1', '34.0.1', 'releases', 'stable', ReleasePlan::TYPE_PATCH, 70];
+ yield 'x.0.2 -> 100' => ['v34.0.2', '34.0.2', '34.0.2', 'releases', 'stable', ReleasePlan::TYPE_PATCH, 100];
+ yield 'minor bump' => ['v34.1.0', '34.1.0', '34.1.0', 'releases', 'stable', ReleasePlan::TYPE_PATCH, 100];
+ yield 'rc' => ['v34.0.0rc5', '34.0.0 RC5', '34.0.0rc5', 'prereleases', 'beta', ReleasePlan::TYPE_PRERELEASE, 100];
+ yield 'beta' => ['v35.0.0beta1', '35.0.0 beta 1', '35.0.0beta1', 'prereleases', 'beta', ReleasePlan::TYPE_PRERELEASE, 100];
+ yield 'alpha' => ['v35.0.0alpha2', '35.0.0 alpha 2', '35.0.0alpha2', 'prereleases', 'beta', ReleasePlan::TYPE_PRERELEASE, 100];
+ }
+
+ #[DataProvider('tags')]
+ public function testFromTag(string $tag, string $versionString, string $urlVersion, string $urlDir, string $stability, string $type, int $deploy): void
+ {
+ $p = ReleasePlan::fromTag($tag);
+ $this->assertSame($versionString, $p->versionString);
+ $this->assertSame($urlVersion, $p->urlVersion);
+ $this->assertSame($urlDir, $p->urlDir);
+ $this->assertSame($stability, $p->stability);
+ $this->assertSame($stability, $p->channel);
+ $this->assertSame($type, $p->releaseType);
+ $this->assertSame($deploy, $p->deploy);
+ }
+
+ public function testComponentsParsed(): void
+ {
+ $p = ReleasePlan::fromTag('v34.0.0rc5');
+ $this->assertSame([34, 0, 0], [$p->major, $p->minor, $p->patch]);
+ }
+
+ public function testDeployOverride(): void
+ {
+ $this->assertSame(50, ReleasePlan::fromTag('v34.0.0', 50)->deploy);
+ }
+}
diff --git a/tools/release/tests/Updater/ReleasesJsonTest.php b/tools/release/tests/Updater/ReleasesJsonTest.php
new file mode 100644
index 00000000000..47b9766fe8c
--- /dev/null
+++ b/tools/release/tests/Updater/ReleasesJsonTest.php
@@ -0,0 +1,94 @@
+ ['internalVersion' => '33.0.4.1'],
+ '33.0.5' => ['internalVersion' => '33.0.5.1'],
+ '34.0.0 RC4' => ['internalVersion' => '34.0.0.6'],
+ '34.0.0 RC5' => ['internalVersion' => '34.0.0.7'],
+ ];
+ }
+
+ public function testFindOldKeyPatchPicksLatestStableOfMajor(): void
+ {
+ $this->assertSame('33.0.5', ReleasesJson::findOldKey($this->sample(), 33, ReleasePlan::TYPE_PATCH));
+ }
+
+ public function testFindOldKeyPrereleasePicksLatestRc(): void
+ {
+ $this->assertSame('34.0.0 RC5', ReleasesJson::findOldKey($this->sample(), 34, ReleasePlan::TYPE_PRERELEASE));
+ }
+
+ public function testFindOldKeyFirstStableReplacesLastRc(): void
+ {
+ $this->assertSame('34.0.0 RC5', ReleasesJson::findOldKey($this->sample(), 34, ReleasePlan::TYPE_FIRST_STABLE));
+ }
+
+ public function testFindOldKeyNoneForNewMajor(): void
+ {
+ $this->assertNull(ReleasesJson::findOldKey($this->sample(), 35, ReleasePlan::TYPE_PRERELEASE));
+ }
+
+ public function testFindOldKeyIgnoresEnterprise(): void
+ {
+ $releases = ['34.0.0' => [], '34.0.0 Enterprise' => []];
+ $this->assertSame('34.0.0', ReleasesJson::findOldKey($releases, 34, ReleasePlan::TYPE_PATCH));
+ }
+
+ public function testNewEntryOmitsDeployAt100(): void
+ {
+ $e = ReleasesJson::newEntry('33.0.6.1', 'BZ2', 'ZIP', 100);
+ $this->assertSame(['internalVersion' => '33.0.6.1', 'signatures' => ['bz2' => 'BZ2', 'zip' => 'ZIP']], $e);
+ }
+
+ public function testNewEntryIncludesDeployBelow100(): void
+ {
+ $e = ReleasesJson::newEntry('34.0.0.7', 'BZ2', 'ZIP', 30);
+ $this->assertSame(30, $e['deploy']);
+ }
+
+ public function testApplyReplacesOldKey(): void
+ {
+ $out = ReleasesJson::apply($this->sample(), '33.0.5', '33.0.6', ['internalVersion' => '33.0.6.1']);
+ $this->assertArrayNotHasKey('33.0.5', $out);
+ $this->assertArrayHasKey('33.0.6', $out);
+ }
+
+ public function testApplyAppendsWhenNoOldKey(): void
+ {
+ $out = ReleasesJson::apply($this->sample(), null, '35.0.0 beta 1', ['internalVersion' => '35.0.0.1']);
+ $this->assertArrayHasKey('35.0.0 beta 1', $out);
+ $this->assertCount(5, $out);
+ }
+
+ public function testEncodeUsesTabsAndTrailingNewline(): void
+ {
+ $json = ReleasesJson::encode(['33.0.6' => ['internalVersion' => '33.0.6.1']]);
+ $this->assertStringContainsString("\n\t\"33.0.6\"", $json, 'top-level keys indented with a tab');
+ $this->assertStringNotContainsString("\n \"", $json, 'no 4-space indentation');
+ $this->assertStringEndsWith("\n", $json);
+ }
+}