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); + } +}