Author: Mark Randall
Committer: Derick Rethans (derickr)
Date: 2026-06-29T17:55:43+01:00

Commit: 
https://github.com/php/web-php/commit/d353cf2b26424bf81ab96fcbfb71fab2fb002ba6
Raw diff: 
https://github.com/php/web-php/commit/d353cf2b26424bf81ab96fcbfb71fab2fb002ba6.diff

Add more reliable way of accessing branch / version / release data without 
having to rely on global-scoped variables.

Changed paths:
  A  include/branch-overrides.inc
  A  src/Releases/Branches.php
  A  tests/Unit/Releases/LegacyReleaseHelpersTest.php
  M  bin/bumpRelease
  M  include/branches.inc
  M  include/gpg-keys.inc
  M  include/releases.inc
  M  include/version.inc
  M  phpstan-baseline.neon
  M  phpstan.neon.dist
  M  public/eol.php
  M  public/images/supported-versions.php
  M  public/index.php
  M  public/releases/active.php
  M  public/releases/branches.php
  M  public/releases/feed.php
  M  public/releases/index.php
  M  public/releases/states.php
  M  public/supported-versions.php
  M  src/autoload.php
  M  tests/phpunit.xml


Diff:

diff --git a/bin/bumpRelease b/bin/bumpRelease
index f03c2c6da9..50ecf6bedc 100755
--- a/bin/bumpRelease
+++ b/bin/bumpRelease
@@ -1,21 +1,24 @@
 #!/usr/bin/env php
 <?php
+
+use phpweb\Releases\Branches;
+
 (PHP_SAPI === 'cli') or die("Please run this script using the cli sapi");
 
-require_once __DIR__ . "/../include/branches.inc";
-require_once __DIR__ . "/../include/version.inc";
-require_once __DIR__ . "/../include/releases.inc";
+require __DIR__ . '/../include/releases.inc';
+require __DIR__ . '/../src/autoload.php';
 
 if ($_SERVER['argc'] < 1) {
        fwrite(STDERR, "Usage: {$_SERVER['argv'][0]} major_version [ 
minor_version ]\n");
        exit(1);
 }
 
+$RELEASES = Branches::getReleaseData();
 $major = (int) $_SERVER['argv'][1];
 isset($RELEASES[$major]) or die("Unknown major version $major");
 $minor = isset($_SERVER['argv'][2]) ? (int) $_SERVER['argv'][2] : null;
 
-$version = get_current_release_for_branch($major, $minor);
+$version = Branches::getCurrentReleaseForBranch($major, $minor);
 $info = $RELEASES[$major][$version] ?? null;
 
 if ($info === null) {
@@ -34,7 +37,7 @@ $OLDRELEASES[$major] = array_merge(
 );
 
 file_put_contents(__DIR__ . "/../include/releases.inc", [
-       "<?php\n\$OLDRELEASES = ",
+       "<?php\nreturn \$OLDRELEASES = ",
        var_export($OLDRELEASES, true),
        ";\n",
 ]);
diff --git a/include/branch-overrides.inc b/include/branch-overrides.inc
new file mode 100644
index 0000000000..519facb955
--- /dev/null
+++ b/include/branch-overrides.inc
@@ -0,0 +1,33 @@
+<?php
+
+return [
+    /* 3.0 is here because version_compare() can't handle the only version in
+     * $OLDRELEASES, and it saves another special case in
+     * Branches::getBranchSecurityEOLDate(). */
+    '3.0' => [
+        'security' => '2000-10-20',
+    ],
+    '5.3' => [
+        'stable' => '2013-07-11',
+        'security' => '2014-08-14',
+    ],
+    '5.4' => [
+        'stable' => '2014-09-14',
+        'security' => '2015-09-03',
+    ],
+    '5.5' => [
+        'stable' => '2015-07-10',
+        'security' => '2016-07-21',
+    ],
+    '5.6' => [
+        'stable' => '2017-01-19',
+        'security' => '2018-12-31',
+    ],
+    '7.0' => [
+        'stable' => '2018-01-04',
+        'security' => '2019-01-10',
+    ],
+    '8.4' => [
+        'date' => '2024-11-21',
+    ],
+];
diff --git a/include/branches.inc b/include/branches.inc
index deb1f79841..2111319261 100644
--- a/include/branches.inc
+++ b/include/branches.inc
@@ -1,5 +1,7 @@
 <?php
 
+use phpweb\Releases\Branches;
+
 include_once __DIR__ . '/releases.inc';
 include_once __DIR__ . '/version.inc';
 
@@ -10,37 +12,7 @@ include_once __DIR__ . '/version.inc';
  *    - stable:   the end of active support (usually two years after release).
  *    - security: the end of security support (usually release + 3 years).
  */
-$BRANCHES = [
-    /* 3.0 is here because version_compare() can't handle the only version in
-     * $OLDRELEASES, and it saves another special case in
-     * get_branch_security_eol_date(). */
-    '3.0' => [
-        'security' => '2000-10-20',
-    ],
-    '5.3' => [
-        'stable' => '2013-07-11',
-        'security' => '2014-08-14',
-    ],
-    '5.4' => [
-        'stable' => '2014-09-14',
-        'security' => '2015-09-03',
-    ],
-    '5.5' => [
-        'stable' => '2015-07-10',
-        'security' => '2016-07-21',
-    ],
-    '5.6' => [
-        'stable' => '2017-01-19',
-        'security' => '2018-12-31',
-    ],
-    '7.0' => [
-        'stable' => '2018-01-04',
-        'security' => '2019-01-10',
-    ],
-    '8.4' => [
-        'date' => '2024-11-21',
-    ],
-];
+$BRANCHES = require __DIR__ . '/branch-overrides.inc';
 
 /* Time to keep EOLed branches in the array returned by get_active_branches(),
  * which is used on the front page download links and the supported versions
@@ -98,6 +70,7 @@ function version_number_to_branch(string $version): ?string {
     return null;
 }
 
+#[Deprecated('Use Branches::get_all_branches()')]
 function get_all_branches() {
     $branches = [];
 
@@ -135,7 +108,8 @@ function get_all_branches() {
     return $branches;
 }
 
-function get_active_branches($include_recent_eols = true) {
+#[Deprecated('Use Branches::active()')]
+function get_active_branches(bool $include_recent_eols = true) {
     $branches = [];
     $now = new DateTime();
 
@@ -170,6 +144,7 @@ function get_active_branches($include_recent_eols = true) {
 /* If you provide an array to $always_include, note that the version numbers
  * must be in $RELEASES _and_ must be the full version number, not the branch:
  * ie provide array('5.3.29'), not array('5.3'). */
+#[Deprecated('Use Branches::eol()')]
 function get_eol_branches($always_include = null) {
     $always_include = $always_include ?: [];
     $branches = [];
@@ -246,6 +221,7 @@ function get_eol_branches($always_include = null) {
  * MAJOR.MINOR.REVISION (the REVISION will be ignored if provided). This will
  * return either null (if no release exists on the given branch), or the usual
  * version metadata from $RELEASES for a single release. */
+#[Deprecated('Use Branches::getInitialRelease')]
 function get_initial_release($branch) {
     $branch = version_number_to_branch($branch);
     if (!$branch) {
@@ -274,6 +250,7 @@ function get_initial_release($branch) {
     return null;
 }
 
+#[Deprecated('Use Branches::getFinalRelease')]
 function get_final_release($branch) {
     $branch = version_number_to_branch($branch);
     if (!$branch) {
@@ -305,6 +282,7 @@ function get_final_release($branch) {
     return null;
 }
 
+#[Deprecated('Use Branches::getBranchBugsEOLDate')]
 function get_branch_bug_eol_date($branch): ?DateTime
 {
     if (isset($GLOBALS['BRANCHES'][$branch]['stable'])) {
@@ -324,6 +302,7 @@ function get_branch_bug_eol_date($branch): ?DateTime
     return $date?->setDate($date->format('Y'), 12, 31);
 }
 
+#[Deprecated('Use Branches::getBranchSecurityEOLDate')]
 function get_branch_security_eol_date($branch): ?DateTime
 {
     if (isset($GLOBALS['BRANCHES'][$branch]['security'])) {
@@ -351,6 +330,7 @@ function get_branch_security_eol_date($branch): ?DateTime
     return $date?->setDate($date->format('Y'), 12, 31);
 }
 
+#[Deprecated('Use Branches::getBranchReleaseDate')]
 function get_branch_release_date($branch): ?DateTime
 {
     $initial = get_initial_release($branch);
@@ -358,6 +338,7 @@ function get_branch_release_date($branch): ?DateTime
     return isset($initial['date']) ? new DateTime($initial['date']) : null;
 }
 
+#[Deprecated('Use Branches::getBranchSupportState')]
 function get_branch_support_state($branch) {
     $initial = get_branch_release_date($branch);
     $bug = get_branch_bug_eol_date($branch);
@@ -399,7 +380,7 @@ function compare_version(array $arrayA, string $versionB)
     return 0;
 }
 
-function version_array(string $version, ?int $length = null)
+function version_array(string $version, ?int $length = null): mixed
 {
     $versionArray = array_map(
         'intval',
@@ -419,6 +400,7 @@ function version_array(string $version, ?int $length = null)
     return $versionArray;
 }
 
+#[Deprecated('Use Branches::getCurrentReleaseForBranch')]
 function get_current_release_for_branch(int $major, ?int $minor): ?string {
     global $RELEASES, $OLDRELEASES;
 
@@ -441,3 +423,80 @@ function get_current_release_for_branch(int $major, ?int 
$minor): ?string {
 
     return null;
 }
+
+
+// Get latest release version and info.
+/**
+ * @return array{mixed,mixed}
+ */
+function release_get_latest(): array {
+    $RELEASES = Branches::getReleaseData();
+
+    $version = '0.0.0';
+    $current = null;
+    foreach ($RELEASES as $versions) {
+        foreach ($versions as $ver => $info) {
+            if (version_compare($ver, $version) > 0) {
+                $version = $ver;
+                $current = $info;
+            }
+        }
+    }
+
+    return [$version, $current];
+}
+
+function show_source_releases(): void
+{
+    $RELEASES = Branches::getReleaseData();
+
+    $SHOW_COUNT = 4;
+
+    $current_uri = htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 
'UTF-8');
+
+    $i = 0; foreach ($RELEASES as $MAJOR => $major_releases): /* major 
releases loop start */
+    $releases = array_slice($major_releases, 0, $SHOW_COUNT);
+    ?>
+    <a id="v<?php echo $MAJOR; ?>"></a>
+    <?php foreach ($releases as $v => $a): ?>
+    <?php $mver = substr($v, 0, strrpos($v, '.')); ?>
+    <?php $stable = $i++ === 0 ? "Current Stable" : "Old Stable"; ?>
+
+    <h3 id="v<?php echo $v; ?>" class="title">
+        <span class="release-state"><?php echo $stable; ?></span>
+        PHP <?php echo $v; ?>
+        (<a href="/ChangeLog-<?php echo $MAJOR; ?>.php#<?php echo 
urlencode($v); ?>" class="changelog">Changelog</a>)
+    </h3>
+    <div class="content-box">
+
+        <ul>
+            <?php foreach ($a['source'] as $rel): ?>
+                <li>
+                    <?php download_link($rel['filename'], $rel['filename']); ?>
+                    <span class="releasedate"><?php echo date('d M Y', 
strtotime($rel['date'])); ?></span>
+                    <?php
+                    if (isset($rel['md5']))    echo '<span class="md5sum">', 
$rel['md5'], '</span>';
+                    if (isset($rel['sha256'])) echo '<span class="sha256">', 
$rel['sha256'], '</span>';
+                    ?>
+                    <?php if (isset($rel['note']) && $rel['note']): ?>
+                        <p>
+                            <strong>Note:</strong>
+                            <?php echo $rel['note']; ?>
+                        </p>
+                    <?php endif; ?>
+                </li>
+            <?php endforeach; ?>
+            <li>
+                <a 
href="/downloads.php?os=windows&osvariant=windows-downloads&version=<?php echo 
urlencode($mver); ?>">
+                    Windows downloads
+                </a>
+            </li>
+        </ul>
+
+        <a href="<?php echo $current_uri; ?>#gpg-<?php echo $mver; ?>">GPG 
Keys for PHP <?php echo $mver; ?></a>
+    </div>
+<?php endforeach; ?>
+<?php endforeach; /* major releases loop end */ ?>
+    <?php
+}
+
diff --git a/include/gpg-keys.inc b/include/gpg-keys.inc
index 7a4bb6ad2c..d8661cfe9c 100644
--- a/include/gpg-keys.inc
+++ b/include/gpg-keys.inc
@@ -1,5 +1,6 @@
 <?php
-require __DIR__ . '/branches.inc';
+
+use phpweb\Releases\Branches;
 
 // GPG keys used for signing releases.
 
@@ -224,7 +225,7 @@ function gpg_key_get_branches(bool $activeOnly): array {
 
     if (!$activeOnly) { return $branches; }
 
-    $active = get_active_branches();
+    $active = Branches::active();
     return array_filter($branches, function ($branch) use ($active) {
         [$major] = explode('.', $branch, 2);
         return isset($active[$major][$branch]);
diff --git a/include/releases.inc b/include/releases.inc
index ea262decd9..829a818101 100644
--- a/include/releases.inc
+++ b/include/releases.inc
@@ -1,5 +1,5 @@
 <?php
-$OLDRELEASES = array (
+return $OLDRELEASES = array (
   8 => 
   array (
     '8.4.21' => 
diff --git a/include/version.inc b/include/version.inc
index 511f8e9536..aa210c0ac9 100644
--- a/include/version.inc
+++ b/include/version.inc
@@ -15,7 +15,7 @@
  *     ),
  * );
  */
-$RELEASES = (function () {
+return $RELEASES = (function () {
     $data = [];
 
     /* PHP 8.5 Release */
@@ -74,6 +74,7 @@ $RELEASES = (function () {
         [$major] = explode('.', $version, 2);
 
         $info = [
+            'version' => $version,
             'announcement' => $release['announcement'] ?? true,
             'tags' => $release['tags'],
             'date' => $release['date'],
@@ -91,75 +92,3 @@ $RELEASES = (function () {
     }
     return $ret;
 })();
-
-// Get latest release version and info.
-function release_get_latest() {
-    global $RELEASES;
-
-    $version = '0.0.0';
-    $current = null;
-    foreach ($RELEASES as $versions) {
-        foreach ($versions as $ver => $info) {
-            if (version_compare($ver, $version) > 0) {
-                $version = $ver;
-                $current = $info;
-            }
-        }
-    }
-
-    return [$version, $current];
-}
-
-function show_source_releases()
-{
-    global $RELEASES;
-
-    $SHOW_COUNT = 4;
-
-    $current_uri = htmlspecialchars($_SERVER['REQUEST_URI'], ENT_QUOTES, 
'UTF-8');
-
-    $i = 0; foreach ($RELEASES as $MAJOR => $major_releases): /* major 
releases loop start */
-        $releases = array_slice($major_releases, 0, $SHOW_COUNT);
-?>
-        <a id="v<?php echo $MAJOR; ?>"></a>
-        <?php foreach ($releases as $v => $a): ?>
-          <?php $mver = substr($v, 0, strrpos($v, '.')); ?>
-          <?php $stable = $i++ === 0 ? "Current Stable" : "Old Stable"; ?>
-
-          <h3 id="v<?php echo $v; ?>" class="title">
-            <span class="release-state"><?php echo $stable; ?></span>
-            PHP <?php echo $v; ?>
-            (<a href="/ChangeLog-<?php echo $MAJOR; ?>.php#<?php echo 
urlencode($v); ?>" class="changelog">Changelog</a>)
-          </h3>
-          <div class="content-box">
-
-            <ul>
-              <?php foreach ($a['source'] as $rel): ?>
-                <li>
-                  <?php download_link($rel['filename'], $rel['filename']); ?>
-                  <span class="releasedate"><?php echo date('d M Y', 
strtotime($rel['date'])); ?></span>
-                  <?php
-                    if (isset($rel['md5']))    echo '<span class="md5sum">', 
$rel['md5'], '</span>';
-                    if (isset($rel['sha256'])) echo '<span class="sha256">', 
$rel['sha256'], '</span>';
-                   ?>
-                  <?php if (isset($rel['note']) && $rel['note']): ?>
-                    <p>
-                      <strong>Note:</strong>
-                      <?php echo $rel['note']; ?>
-                    </p>
-                  <?php endif; ?>
-                </li>
-              <?php endforeach; ?>
-              <li>
-                <a 
href="/downloads.php?os=windows&osvariant=windows-downloads&version=<?php echo 
urlencode($mver); ?>">
-                  Windows downloads
-                </a>
-              </li>
-            </ul>
-
-            <a href="<?php echo $current_uri; ?>#gpg-<?php echo $mver; ?>">GPG 
Keys for PHP <?php echo $mver; ?></a>
-          </div>
-        <?php endforeach; ?>
-    <?php endforeach; /* major releases loop end */ ?>
-<?php
-}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index ed6401d99c..d59f38eda3 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -30,12 +30,6 @@ parameters:
                        count: 1
                        path: include/branches.inc
 
-               -
-                       message: '#^Function get_active_branches\(\) has 
parameter \$include_recent_eols with no type specified\.$#'
-                       identifier: missingType.parameter
-                       count: 1
-                       path: include/branches.inc
-
                -
                        message: '#^Function get_all_branches\(\) has no return 
type specified\.$#'
                        identifier: missingType.return
@@ -109,8 +103,8 @@ parameters:
                        path: include/branches.inc
 
                -
-                       message: '#^Function version_array\(\) has no return 
type specified\.$#'
-                       identifier: missingType.return
+                       message: '#^Offset ''note'' on array\{filename\: 
string, name\: string, md5\?\: string, sha256\?\: string, date\: string\} in 
isset\(\) does not exist\.$#'
+                       identifier: isset.offset
                        count: 1
                        path: include/branches.inc
 
@@ -120,12 +114,30 @@ parameters:
                        count: 2
                        path: include/branches.inc
 
+               -
+                       message: '#^Parameter \#2 \$timestamp of function date 
expects int\|null, int\|false given\.$#'
+                       identifier: argument.type
+                       count: 1
+                       path: include/branches.inc
+
                -
                        message: '#^Parameter \#3 \$length of function substr 
expects int\|null, int\<0, max\>\|false given\.$#'
                        identifier: argument.type
                        count: 2
                        path: include/branches.inc
 
+               -
+                       message: '#^Parameter \#3 \$length of function substr 
expects int\|null, int\|false given\.$#'
+                       identifier: argument.type
+                       count: 1
+                       path: include/branches.inc
+
+               -
+                       message: '#^Result of && is always false\.$#'
+                       identifier: booleanAnd.alwaysFalse
+                       count: 1
+                       path: include/branches.inc
+
                -
                        message: '#^Function bugfix\(\) has parameter \$number 
with no type specified\.$#'
                        identifier: missingType.parameter
@@ -1314,30 +1326,12 @@ parameters:
                        count: 1
                        path: include/site.inc
 
-               -
-                       message: '#^Function release_get_latest\(\) has no 
return type specified\.$#'
-                       identifier: missingType.return
-                       count: 1
-                       path: include/version.inc
-
-               -
-                       message: '#^Function show_source_releases\(\) has no 
return type specified\.$#'
-                       identifier: missingType.return
-                       count: 1
-                       path: include/version.inc
-
                -
                        message: '#^Offset ''announcement'' on array\{version\: 
''8\.2\.31'', date\: ''07 May 2026'', tags\: array\{''security''\}, sha256\: 
array\{''tar\.gz''\: ''083c2f61cc5f527eb29…'', ''tar\.bz2''\: 
''948183fa04cf261c9b9…'', ''tar\.xz''\: 
''95eae411d594fe6f6e5…''\}\}\|array\{version\: ''8\.3\.31'', date\: ''07 May 
2026'', tags\: array\{''security''\}, sha256\: array\{''tar\.gz''\: 
''4e7baaf0a690e954a20…'', ''tar\.bz2''\: ''e6986b1fd37eb254021…'', 
''tar\.xz''\: ''66410cee07f4b2baeb0…''\}\}\|array\{version\: ''8\.4\.22'', 
date\: ''04 Jun 2026'', tags\: array\{\}, sha256\: array\{''tar\.gz''\: 
''a012c2c9724baf214a7…'', ''tar\.bz2''\: ''4b16e7e2c384ce25e07…'', 
''tar\.xz''\: ''696c0f6ad92e94c5905…''\}\}\|array\{version\: ''8\.5\.7'', 
date\: ''04 Jun 2026'', tags\: array\{\}, sha256\: array\{''tar\.gz''\: 
''e5eba93fd6dd3241d0e…'', ''tar\.bz2''\: ''4ef9355f784d4b32015…'', 
''tar\.xz''\: ''01ba2ed1c2658dacf91…''\}\} on left side of \?\? does not 
exist\.$#'
                        identifier: nullCoalesce.offset
                        count: 1
                        path: include/version.inc
 
-               -
-                       message: '#^Parameter \#3 \$length of function substr 
expects int\|null, int\|false given\.$#'
-                       identifier: argument.type
-                       count: 1
-                       path: include/version.inc
-
                -
                        message: '#^Variable \$SIDEBAR_DATA might not be 
defined\.$#'
                        identifier: variable.undefined
@@ -2323,10 +2317,16 @@ parameters:
                        path: public/releases/feed.php
 
                -
-                       message: '#^Variable \$RELEASES might not be 
defined\.$#'
-                       identifier: variable.undefined
+                       message: '#^Cannot access offset ''date'' on 
array\{version\: string, announcement\: bool, tags\: list\<string\>, date\: 
string, source\: list\<array\{filename\: string, name\: string, md5\?\: string, 
sha256\?\: string, date\: string\}\>\}\|false\.$#'
+                       identifier: offsetAccess.nonOffsetAccessible
                        count: 1
-                       path: public/releases/feed.php
+                       path: public/releases/index.php
+
+               -
+                       message: '#^Cannot access offset ''supported_versions'' 
on array\{version\: string, announcement\: bool, tags\: list\<string\>, date\: 
string, source\: list\<array\{filename\: string, name\: string, md5\?\: string, 
sha256\?\: string, date\: string\}\>\}\|false\.$#'
+                       identifier: offsetAccess.nonOffsetAccessible
+                       count: 1
+                       path: public/releases/index.php
 
                -
                        message: '#^Function mk_rel\(\) has parameter 
\$announcement with no value type specified in iterable type array\.$#'
@@ -2347,21 +2347,27 @@ parameters:
                        path: public/releases/index.php
 
                -
-                       message: '#^Parameter \#1 \.\.\.\$arg1 of function max 
expects non\-empty\-array, list\<int\|string\> given\.$#'
-                       identifier: argument.type
+                       message: '#^Offset ''announcement'' on array\{version\: 
string, announcement\: bool, tags\: list\<string\>, date\: string, source\: 
list\<array\{filename\: string, name\: string, md5\?\: string, sha256\?\: 
string, date\: string\}\>\} on left side of \?\? always exists and is not 
nullable\.$#'
+                       identifier: nullCoalesce.offset
                        count: 1
                        path: public/releases/index.php
 
                -
-                       message: '#^Variable \$OLDRELEASES might not be 
defined\.$#'
-                       identifier: variable.undefined
-                       count: 3
+                       message: '#^Offset ''museum'' on array\{version\: 
string, announcement\: bool, tags\: list\<string\>, date\: string, source\: 
list\<array\{filename\: string, name\: string, md5\?\: string, sha256\?\: 
string, date\: string\}\>\} on left side of \?\? does not exist\.$#'
+                       identifier: nullCoalesce.offset
+                       count: 1
                        path: public/releases/index.php
 
                -
-                       message: '#^Variable \$RELEASES might not be 
defined\.$#'
-                       identifier: variable.undefined
-                       count: 2
+                       message: '#^Offset ''source'' on array\{version\: 
string, announcement\: bool, tags\: list\<string\>, date\: string, source\: 
list\<array\{filename\: string, name\: string, md5\?\: string, sha256\?\: 
string, date\: string\}\>\} on left side of \?\? always exists and is not 
nullable\.$#'
+                       identifier: nullCoalesce.offset
+                       count: 1
+                       path: public/releases/index.php
+
+               -
+                       message: '#^Offset ''windows'' on array\{version\: 
string, announcement\: bool, tags\: list\<string\>, date\: string, source\: 
list\<array\{filename\: string, name\: string, md5\?\: string, sha256\?\: 
string, date\: string\}\>\} on left side of \?\? does not exist\.$#'
+                       identifier: nullCoalesce.offset
+                       count: 1
                        path: public/releases/index.php
 
                -
@@ -2430,18 +2436,6 @@ parameters:
                        count: 1
                        path: public/submit-event.php
 
-               -
-                       message: '#^Parameter \#1 \$string of function 
htmlspecialchars expects string, float\|string given\.$#'
-                       identifier: argument.type
-                       count: 1
-                       path: public/supported-versions.php
-
-               -
-                       message: '#^Possibly invalid array key type 
float\|string\.$#'
-                       identifier: offsetAccess.invalidOffset
-                       count: 1
-                       path: public/supported-versions.php
-
                -
                        message: '#^Variable \$COUNTRIES might not be 
defined\.$#'
                        identifier: variable.undefined
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index ee0bff472b..8f6c30d214 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -19,3 +19,9 @@ parameters:
                        - include/releases.inc
                        - include/pregen-news.inc
                        - include/pregen-confs.inc
+
+       # Can do cleanup after, without the previous items themsleves being new 
errors
+       reportUnmatchedIgnoredErrors: false
+
+       editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%'
+       editorUrlTitle: '%%relFile%%:%%line%%'
diff --git a/public/eol.php b/public/eol.php
index 9a675c43aa..ec8f37207b 100644
--- a/public/eol.php
+++ b/public/eol.php
@@ -1,8 +1,10 @@
 <?php
+
+use phpweb\Releases\Branches;
+
 $_SERVER['BASE_PAGE'] = 'eol.php';
 
 require_once __DIR__ . '/../include/prepend.inc';
-require_once __DIR__ . '/../include/branches.inc';
 
 // Notes for specific branches can be added here, and will appear in the table.
 $BRANCH_NOTES = [
@@ -46,9 +48,9 @@
                </tr>
        </thead>
        <tbody>
-               <?php foreach (get_eol_branches() as $major => $branches): ?>
+               <?php foreach (Branches::eol() as $major => $branches): ?>
                        <?php foreach ($branches as $branch => $detail): ?>
-                               <?php $eolDate = 
get_branch_security_eol_date($branch) ?>
+                               <?php $eolDate = 
Branches::getBranchSecurityEOLDate($branch) ?>
                                <?php $eolPeriod = format_interval($eolDate, 
new DateTime('now')) ?>
                                        <tr>
                                                <td><?php echo 
htmlspecialchars($branch); ?></td>
diff --git a/public/images/supported-versions.php 
b/public/images/supported-versions.php
index 80434bbb8c..f2f695f3a5 100644
--- a/public/images/supported-versions.php
+++ b/public/images/supported-versions.php
@@ -1,6 +1,8 @@
 <?php
-require_once __DIR__ . '/../include/prepend.inc';
-require_once __DIR__ . '/../include/branches.inc';
+
+use phpweb\Releases\Branches;
+
+include_once __DIR__ . '/../include/prepend.inc';
 
 // Sizing constants.
 $margin_left = 80;
@@ -15,9 +17,9 @@ function branches_to_show() {
     $branches = [];
 
     // Flatten out the majors.
-    foreach (get_all_branches() as $major_branches) {
+    foreach (Branches::all() as $major_branches) {
         foreach ($major_branches as $branch => $version) {
-            if (version_compare($branch, '5.3', 'ge') && 
get_branch_security_eol_date($branch) > min_date()) {
+            if (version_compare($branch, '5.3', 'ge') && 
Branches::getBranchSecurityEOLDate($branch) > min_date()) {
                 $branches[$branch] = $version;
             }
         }
@@ -121,7 +123,7 @@ function date_horiz_coord(DateTime $date) {
        <!-- Branch labels -->
        <g class="branch-labels">
                <?php foreach ($branches as $branch => $version): ?>
-                       <g class="<?php echo get_branch_support_state($branch) 
?>">
+                       <g class="<?php echo 
Branches::getBranchSupportStatus($branch) ?>">
                                <rect x="0" y="<?php echo $version['top'] ?>" 
width="<?php echo 0.5 * $margin_left ?>" height="<?php echo $branch_height ?>" 
/>
                                <text x="<?php echo 0.25 * $margin_left ?>" 
y="<?php echo $version['top'] + (0.5 * $branch_height) ?>">
                                        <?php echo htmlspecialchars($branch) ?>
@@ -134,9 +136,9 @@ function date_horiz_coord(DateTime $date) {
        <g class="branches">
                <?php foreach ($branches as $branch => $version): ?>
                        <?php
-            $x_release = date_horiz_coord(get_branch_release_date($branch));
-            $x_bug = date_horiz_coord(get_branch_bug_eol_date($branch));
-            $x_eol = date_horiz_coord(get_branch_security_eol_date($branch));
+            $x_release = 
date_horiz_coord(Branches::getBranchReleaseDate($branch));
+            $x_bug = date_horiz_coord(Branches::getBranchBugsEOLDate($branch));
+            $x_eol = 
date_horiz_coord(Branches::getBranchSecurityEOLDate($branch));
             ?>
                        <rect class="stable" x="<?php echo $x_release ?>" 
y="<?php echo $version['top'] ?>" width="<?php echo $x_bug - $x_release ?>" 
height="<?php echo $branch_height ?>" />
                        <rect class="security" x="<?php echo $x_bug ?>" 
y="<?php echo $version['top'] ?>" width="<?php echo $x_eol - $x_bug ?>" 
height="<?php echo $branch_height ?>" />
diff --git a/public/index.php b/public/index.php
index 1694e3a246..b9063dc22d 100644
--- a/public/index.php
+++ b/public/index.php
@@ -1,6 +1,7 @@
 <?php
 
 use phpweb\News\NewsHandler;
+use phpweb\Releases\Branches;
 
 (function ($uri): void {
     // Special redirect cases not able to be captured in error.php
@@ -51,9 +52,7 @@
 
 $_SERVER['BASE_PAGE'] = 'index.php';
 require_once __DIR__ . '/../include/prepend.inc';
-require_once __DIR__ . '/../include/branches.inc';
 require_once __DIR__ . '/../include/pregen-confs.inc';
-require_once __DIR__ . '/../include/version.inc';
 
 mirror_setcookie("LAST_NEWS", $_SERVER["REQUEST_TIME"], 60 * 60 * 24 * 365);
 
@@ -92,7 +91,7 @@
 EOF;
 
 $intro .= "<ul class='hero__versions'>\n";
-$active_branches = get_active_branches();
+$active_branches = Branches::active();
 krsort($active_branches);
 foreach ($active_branches as $major => $releases) {
     krsort($releases);
diff --git a/public/releases/active.php b/public/releases/active.php
index d2cfca2862..1e3e77b59c 100644
--- a/public/releases/active.php
+++ b/public/releases/active.php
@@ -1,12 +1,11 @@
 <?php
 
-use phpweb\ProjectGlobals;
+use phpweb\Releases\Branches;
 
 $_SERVER['BASE_PAGE'] = 'releases/active.php';
 
 require_once __DIR__ . '/../../include/prepend.inc';
-require_once ProjectGlobals::getPublicRoot(). '/include/branches.inc';
 
 header('Content-Type: application/json; charset=UTF-8');
 
-echo json_encode(get_active_branches());
+echo json_encode(Branches::active());
diff --git a/public/releases/branches.php b/public/releases/branches.php
index da0f62f2ea..4b6cd78248 100644
--- a/public/releases/branches.php
+++ b/public/releases/branches.php
@@ -1,7 +1,8 @@
 <?php
 
-require_once __DIR__ . '/../../include/prepend.inc';
-require_once __DIR__ . '/../../include/branches.inc';
+use phpweb\Releases\Branches;
+
+include_once __DIR__ . '/../include/prepend.inc';
 
 header('Content-Type: application/json; charset=UTF-8');
 
@@ -10,15 +11,15 @@ function formatDate($date = null) {
 }
 
 $current = [];
-foreach (get_all_branches() as $major => $releases) {
+foreach (Branches::all() as $major => $releases) {
     foreach ($releases as $branch => $release) {
         $current[$branch] = [
             'branch' => $branch,
-            'latest' => ($release['version'] ?? null),
-            'state' => get_branch_support_state($branch),
-            'initial_release' => formatDate(get_branch_release_date($branch)),
-            'active_support_end' => 
formatDate(get_branch_bug_eol_date($branch)),
-            'security_support_end' => 
formatDate(get_branch_security_eol_date($branch)),
+            'latest' => $release['version'],
+            'state' => Branches::getBranchSupportStatus($branch),
+            'initial_release' => 
formatDate(Branches::getBranchReleaseDate($branch)),
+            'active_support_end' => 
formatDate(Branches::getBranchBugsEOLDate($branch)),
+            'security_support_end' => 
formatDate(Branches::getBranchSecurityEOLDate($branch)),
         ];
     }
 }
diff --git a/public/releases/feed.php b/public/releases/feed.php
index bc5eaf97de..2a1b391250 100644
--- a/public/releases/feed.php
+++ b/public/releases/feed.php
@@ -1,9 +1,13 @@
 <?php
 
+use phpweb\Releases\Branches;
+
 header("Content-Type: application/atom+xml");
 
 require __DIR__ . "/../../include/version.inc";
 
+$RELEASES = Branches::getReleaseData();
+
 echo <<<XML
 <?xml version="1.0" encoding="UTF-8"?>
 <feed xmlns="http://www.w3.org/2005/Atom"; 
xmlns:php="http://php.net/ns/releases";>
diff --git a/public/releases/index.php b/public/releases/index.php
index cd4d0073de..794c9a2e6b 100644
--- a/public/releases/index.php
+++ b/public/releases/index.php
@@ -1,8 +1,12 @@
 <?php
 
+use phpweb\Releases\Branches;
+
 $_SERVER['BASE_PAGE'] = 'releases/index.php';
 require_once __DIR__ . '/../../include/prepend.inc';
-require_once __DIR__ . "/../../include/branches.inc";
+
+$RELEASES = Branches::getReleaseData();
+$OLDRELEASES = Branches::getOldReleaseData();
 
 if (isset($_GET["serialize"]) || isset($_GET["json"])) {
     $RELEASES = $RELEASES + $OLDRELEASES;
@@ -10,7 +14,7 @@
     $machineReadable = [];
 
     $supportedVersions = [];
-    foreach (get_active_branches(false) as $major => $releases) {
+    foreach (Branches::active() as $major => $releases) {
         $supportedVersions[$major] = array_keys($releases);
     }
 
@@ -53,9 +57,7 @@
         }
     } else {
         foreach ($RELEASES as $major => $release) {
-            $version = key($release);
             $r = current($release);
-            $r["version"] = $version;
             $r['supported_versions'] = $supportedVersions[$major] ?? [];
             $machineReadable[$major] = $r;
         }
@@ -84,6 +86,8 @@
 </p>\n";
 
 $active_majors = array_keys($RELEASES);
+assert(!empty($active_majors));
+
 $latest = max($active_majors);
 foreach ($OLDRELEASES as $major => $a) {
     echo '<a id="v' . $major . '"></a>';
diff --git a/public/releases/states.php b/public/releases/states.php
index 429214563c..97a8d9660f 100644
--- a/public/releases/states.php
+++ b/public/releases/states.php
@@ -3,12 +3,11 @@
 # Please use /releases/branches.php instead.
 # This API *may* be removed at an indeterminate point in the future.
 
-use phpweb\ProjectGlobals;
+use phpweb\Releases\Branches;
 
 $_SERVER['BASE_PAGE'] = 'releases/active.php';
 
 require_once __DIR__ . '/../../include/prepend.inc';
-require_once ProjectGlobals::getPublicRoot() . '/include/branches.inc';
 
 header('Content-Type: application/json; charset=UTF-8');
 
@@ -18,14 +17,14 @@ function formatDate($date = null) {
     return $date !== null ? $date->format('c') : null;
 }
 
-foreach (get_all_branches() as $major => $releases) {
+foreach (Branches::all() as $major => $releases) {
     $states[$major] = [];
     foreach ($releases as $branch => $release) {
         $states[$major][$branch] = [
-            'state' => get_branch_support_state($branch),
-            'initial_release' => formatDate(get_branch_release_date($branch)),
-            'active_support_end' => 
formatDate(get_branch_bug_eol_date($branch)),
-            'security_support_end' => 
formatDate(get_branch_security_eol_date($branch)),
+            'state' => Branches::getBranchSupportStatus($branch),
+            'initial_release' => 
formatDate(Branches::getBranchReleaseDate($branch)),
+            'active_support_end' => 
formatDate(Branches::getBranchBugsEOLDate($branch)),
+            'security_support_end' => 
formatDate(Branches::getBranchSecurityEOLDate($branch)),
         ];
     }
     krsort($states[$major]);
diff --git a/public/supported-versions.php b/public/supported-versions.php
index c7d98b6097..78571eab77 100644
--- a/public/supported-versions.php
+++ b/public/supported-versions.php
@@ -1,8 +1,10 @@
 <?php
+
+use phpweb\Releases\Branches;
+
 $_SERVER['BASE_PAGE'] = 'supported-versions.php';
 
 require_once __DIR__ . '/../include/prepend.inc';
-require_once __DIR__ . '/../include/branches.inc';
 
 site_header('Supported Versions', ['css' => ['supported-versions.css']]);
 
@@ -51,14 +53,14 @@
                </tr>
        </thead>
        <tbody>
-               <?php foreach (get_active_branches(false) as $major => 
$releases): ?>
+               <?php foreach (Branches::active(false) as $major => $releases): 
?>
                        <?php ksort($releases) ?>
                        <?php foreach ($releases as $branch => $release): ?>
                                <?php
-                $state = get_branch_support_state($branch);
-                $initial = get_branch_release_date($branch);
-                $until = get_branch_bug_eol_date($branch);
-                $eol = get_branch_security_eol_date($branch);
+                $state = Branches::getBranchSupportStatus($branch);
+                $initial = Branches::getBranchReleaseDate($branch);
+                $until = Branches::getBranchBugsEOLDate($branch);
+                $eol = Branches::getBranchSecurityEOLDate($branch);
                 $now = new DateTime('now');
                 ?>
                                <tr class="<?php echo $state ?>">
diff --git a/src/Releases/Branches.php b/src/Releases/Branches.php
new file mode 100644
index 0000000000..fe80b6667e
--- /dev/null
+++ b/src/Releases/Branches.php
@@ -0,0 +1,434 @@
+<?php
+
+namespace phpweb\Releases;
+
+use DateInterval;
+use DateTime;
+use ValueError;
+use function count;
+use function explode;
+use function krsort;
+use function ksort;
+use function strlen;
+use function strncmp;
+use function version_compare;
+
+/**
+ * @phpstan-type NormalizedSourceStruct array{
+ *     filename: string,
+ *     name: string,
+ *     md5?: string,
+ *     sha256?: string,
+ *     date: string,
+ *  }
+ *
+ * @phpstan-type NormalizedReleaseStruct array{
+ *    version: string,
+ *    announcement: bool,
+ *    tags: list<string>,
+ *    date: string,
+ *    source: list<NormalizedSourceStruct>
+ * }
+ */
+class Branches
+{
+    /**
+     * @return array<int, array<string, NormalizedReleaseStruct>>
+     */
+    public static function getReleaseData(): array
+    {
+        static $cache = null;
+
+        /* there is no normalisation required here because it's all standard 
format */
+        return $cache ??= require __DIR__ . '/../../include/version.inc';
+    }
+
+    /**
+     * @return array<int, array<string, NormalizedReleaseStruct>>
+     */
+    public static function getOldReleaseData(): array
+    {
+        static $cache = null;
+
+        return $cache ??= (function () {
+            $original = require __DIR__ . '/../../include/releases.inc';
+
+            foreach ($original as &$releases) {
+                foreach ($releases as $releaseId => &$release) {
+                    /* always force the version to be copied into the array, 
some normalized steps do it anyway */
+                    $release['version'] = $releaseId;
+
+                    /* only care for true or false here */
+                    $announcement = $release['announcement'] ?? null;
+                    if (is_array($announcement)) {
+                        $release['announcement'] = !empty($announcement);
+                    }
+
+                    /* we have release announcements going back to 4.1.0 in 
releases/x_y_z.php */
+                    $release['announcement'] ??= 
version_compare($release['version'], '4.1.0', '>=');
+
+                    /* if any of the source files do not have a `filename` 
they are invalid */
+                    foreach ($release['source'] as $sIdx => $source) {
+                        if (!isset($source['filename'])) {
+                            unset($release['source'][$sIdx]);
+                        }
+                    }
+                }
+            }
+
+            return $original;
+        })();
+    }
+
+    /**
+     * @return array<string, array{security?:string, stable?:string, 
date?:string}>
+     */
+    public static function getBranchOverrides(): array
+    {
+        static $cache = null;
+        return $cache ??= require __DIR__ . 
'/../../include/branch-overrides.inc';
+    }
+
+    /**
+     * @return array<int, array<string, NormalizedReleaseStruct>>
+     */
+    public static function all(): array
+    {
+        static $cache = null;
+        return $cache ??= (function () {
+            $results = [];
+            foreach (self::getReleaseData() as $majorVersion => $releases) {
+                foreach ($releases as $releaseId => $release) {
+                    $results[$majorVersion][$releaseId] = $release;
+                }
+            }
+
+            foreach (self::getOldReleaseData() as $majorVersion => $releases) {
+                foreach ($releases as $releaseId => $release) {
+                    $results[$majorVersion][$releaseId] = $release;
+                }
+            }
+
+            return $results;
+        })();
+    }
+
+    /**
+     * Returns an associative array [$major][$major.$minor] = $release where 
$release is the
+     * standard data struct. In effect, finding the last version for each.
+     *
+     * Previously get_all_branches
+     *
+     * @return array<int, array<string, NormalizedReleaseStruct>>
+     */
+    public static function get_all_branches(): array
+    {
+        $GLOBAL_OLDRELEASES = self::getOldReleaseData();
+        $GLOBAL_RELEASES = self::getReleaseData();
+        $branches = [];
+
+        foreach ($GLOBAL_OLDRELEASES as $major => $releases) {
+            foreach ($releases as $version => $release) {
+                $branch = self::versionToBranch($version);
+
+                if (!isset($branches[$major][$branch])
+                    || version_compare($version, 
$branches[$major][$branch]['version'], 'gt')
+                ) {
+                    $branches[$major][$branch] = $release;
+                }
+            }
+        }
+
+        foreach ($GLOBAL_RELEASES as $major => $releases) {
+            foreach ($releases as $version => $release) {
+                $branch = self::versionToBranch($version);
+
+                if (!isset($branches[$major][$branch])
+                    || version_compare($version, 
$branches[$major][$branch]['version'], 'gt')
+                ) {
+                    $branches[$major][$branch] = $release;
+                }
+            }
+        }
+
+        krsort($branches);
+        foreach ($branches as &$branch) {
+            krsort($branch);
+        }
+
+        return $branches;
+    }
+
+    /**
+     * @param bool $include_recent_eols
+     * @return array<int, array<string, NormalizedReleaseStruct>>
+     */
+    public static function active(bool $include_recent_eols = true): array
+    {
+        $recentInterval = new DateInterval('P28D');
+
+        $GLOBAL_RELEASES = self::getReleaseData();
+        $branches = [];
+        $now = new DateTime();
+
+        foreach ($GLOBAL_RELEASES as $major => $releases) {
+            foreach ($releases as $releaseId => $release) {
+                $branch = self::versionToBranch($releaseId);
+
+                $threshold = self::getBranchSecurityEOLDate($branch);
+                if ($threshold === null) {
+                    // No EOL date available, assume it is ancient.
+                    continue;
+                }
+
+                if ($include_recent_eols) {
+                    $threshold->add($recentInterval);
+                }
+
+                if ($now < $threshold) {
+                    $branches[$major][$branch] = $release;
+                }
+            }
+
+            if (!empty($branches[$major])) {
+                ksort($branches[$major]);
+            }
+        }
+
+        ksort($branches);
+
+        return $branches;
+    }
+
+    /**
+     * @return array<int, array<string, array{
+     *     date: string,
+     *     link: string,
+     *     version: string,
+     * }>>
+     */
+    public static function eol(): array
+    {
+        $GLOBAL_OLDRELEASES = self::getOldReleaseData();
+        $GLOBAL_RELEASES = self::getReleaseData();
+
+        $branches = [];
+        $now = new DateTime();
+
+        // Gather the last release on each branch into a convenient array.
+        foreach ($GLOBAL_OLDRELEASES as $major => $releases) {
+            foreach ($releases as $version => $release) {
+                $branch = self::versionToBranch($version);
+
+                if (!isset($branches[$major][$branch])
+                    || version_compare($version, 
$branches[$major][$branch]['version'], 'gt')
+                ) {
+                    $branches[(string)$major][$branch] = [
+                        'date' => $release['date'],
+                        'link' => "/releases#$version",
+                        'version' => $version,
+                    ];
+                }
+            }
+        }
+
+        /* Exclude releases from active branches, where active is defined as 
"in
+         * the $RELEASES array and not explicitly marked as EOL there". */
+        foreach ($GLOBAL_RELEASES as $major => $releases) {
+            foreach ($releases as $version => $release) {
+                $branch = self::versionToBranch($version);
+
+                if ($now < self::getBranchSecurityEOLDate($branch)) {
+                    /* This branch isn't EOL: remove it from our array. */
+                    if (isset($branches[$major][$branch])) {
+                        unset($branches[$major][$branch]);
+                    }
+                }
+            }
+        }
+
+        krsort($branches);
+        foreach ($branches as &$branch) {
+            krsort($branch);
+        }
+
+        return $branches;
+    }
+
+    /**
+     * Finds the first release for a given branch
+     *
+     * @return NormalizedReleaseStruct|null
+     */
+    public static function getInitialReleaseForBranch(string $branch): ?array
+    {
+        $all = self::all();
+        $branch = self::versionToBranch($branch);
+        [$major] = explode('.', $branch);
+
+        /* it seems that 8.4.0 is completely missing from the data */
+        for ($patch = 0; $patch < 5; $patch++) {
+            $release = $all[$major][$branch . '.' . $patch] ?? null;
+            if ($release) {
+                return $release;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Finds the last release from a given branch
+     *
+     * @return NormalizedReleaseStruct|null
+     */
+    public static function getFinalReleaseForBranch(string $branch): ?array
+    {
+        $GLOBAL_OLDRELEASES = self::getOldReleaseData();
+        $GLOBAL_RELEASES = self::getReleaseData();
+        $branch = self::versionToBranch($branch);
+        [$major] = explode('.', $branch);
+
+        $last = "$branch.0";
+        foreach ($GLOBAL_OLDRELEASES[$major] as $version => $release) {
+            if (self::versionToBranch($version) == $branch && 
version_compare($version, $last, '>')) {
+                $last = $version;
+            }
+        }
+
+        if (isset($GLOBAL_OLDRELEASES[$major][$last])) {
+            return $GLOBAL_OLDRELEASES[$major][$last];
+        }
+
+        /* If there's only been one release on the branch, it won't be in
+         * $OLDRELEASES yet, so let's check $RELEASES. */
+        if (isset($GLOBAL_RELEASES[$major][$last])) {
+            // Fake a date like we have on the oldreleases array.
+            $release = $GLOBAL_RELEASES[$major][$last];
+            $release['date'] = $release['source'][0]['date'];
+
+            return $release;
+        }
+
+        // Shrug.
+        return null;
+    }
+
+    public static function getBranchBugsEOLDate(string $branch): ?DateTime
+    {
+        $GLOBAL_BRANCHES = self::getBranchOverrides();
+
+        if (isset($GLOBAL_BRANCHES[$branch]['stable'])) {
+            return new DateTime($GLOBAL_BRANCHES[$branch]['stable']);
+        }
+
+        $date = self::getBranchReleaseDate($branch);
+
+        $date = $date?->add(new DateInterval('P2Y'));
+
+        // Versions before 8.2 do not extend the release cycle to the end of 
the year
+        if (version_compare($branch, '8.2', '<')) {
+            return $date;
+        }
+
+        // Extend the release cycle to the end of the year
+        return $date?->setDate((int)$date->format('Y'), 12, 31);
+    }
+
+    public static function getBranchSecurityEOLDate(string $branch): ?DateTime
+    {
+        $GLOBAL_BRANCHES = self::getBranchOverrides();
+        if (isset($GLOBAL_BRANCHES[$branch]['security'])) {
+            return new DateTime($GLOBAL_BRANCHES[$branch]['security']);
+        }
+
+        /* Versions before 5.3 are based solely on the final release date in
+         * $OLDRELEASES. */
+        if (version_compare($branch, '5.3', '<')) {
+            $release = self::getFinalReleaseForBranch($branch);
+
+            return $release ? new DateTime($release['date']) : null;
+        }
+
+        $date = self::getBranchReleaseDate($branch);
+
+        // Versions before 8.1 have 3-year support since the initial release
+        if (version_compare($branch, '8.1', '<')) {
+            return $date?->add(new DateInterval('P3Y'));
+        }
+
+        $date = $date?->add(new DateInterval('P4Y'));
+
+        // Extend the release cycle to the end of the year
+        return $date?->setDate((int)$date->format('Y'), 12, 31);
+    }
+
+    public static function getBranchReleaseDate(string $branch): ?DateTime
+    {
+        $initial = self::getInitialReleaseForBranch($branch);
+
+        return isset($initial['date']) ? new DateTime($initial['date']) : null;
+    }
+
+    public static function getBranchSupportStatus(string $branch): ?string
+    {
+        $initial = self::getBranchReleaseDate($branch);
+        $bug = self::getBranchBugsEOLDate($branch);
+        $security = self::getBranchSecurityEOLDate($branch);
+
+        if ($initial && $bug && $security) {
+            $now = new DateTime();
+
+            if ($now >= $security) {
+                return 'eol';
+            }
+
+            if ($now >= $bug) {
+                return 'security';
+            }
+
+            if ($now >= $initial) {
+                return 'stable';
+            }
+
+            return 'future';
+        }
+
+        return null;
+    }
+
+    public static function getCurrentReleaseForBranch(int $major, ?int 
$minor): ?string
+    {
+        $GLOBAL_RELEASES = self::getReleaseData();
+        $GLOBAL_OLDRELEASES = self::getOldReleaseData();
+
+        $prefix = "{$major}.";
+        if ($minor !== null) {
+            $prefix .= "{$minor}.";
+        }
+
+        foreach (($GLOBAL_RELEASES[$major] ?? []) as $version => $_) {
+            if (!strncmp($prefix, $version, strlen($prefix))) {
+                return $version;
+            }
+        }
+
+        foreach (($GLOBAL_OLDRELEASES[$major] ?? []) as $version => $_) {
+            if (!strncmp($prefix, $version, strlen($prefix))) {
+                return $version;
+            }
+        }
+
+        return null;
+    }
+
+    private static function versionToBranch(string $version): string
+    {
+        $parts = explode('.', $version);
+        if (count($parts) > 1) {
+            return "$parts[0].$parts[1]";
+        }
+
+        throw new ValueError("Unexpected version '$version'");
+    }
+}
diff --git a/src/autoload.php b/src/autoload.php
index e69570a6b4..76c19ec9d5 100644
--- a/src/autoload.php
+++ b/src/autoload.php
@@ -1,5 +1,7 @@
 <?php
 
+require_once __DIR__ . '/../include/branches.inc';
+
 /**
  * @see 
https://github.com/php-fig/fig-standards/blob/a1a0674a742c9d07c5dd450209fb33b115ee7b40/accepted/PSR-4-autoloader-examples.md#closure-example
  */
diff --git a/tests/Unit/Releases/LegacyReleaseHelpersTest.php 
b/tests/Unit/Releases/LegacyReleaseHelpersTest.php
new file mode 100644
index 0000000000..1ada382d60
--- /dev/null
+++ b/tests/Unit/Releases/LegacyReleaseHelpersTest.php
@@ -0,0 +1,191 @@
+<?php
+
+namespace phpweb\Test\Unit\Releases;
+
+use Generator;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use phpweb\Releases\Branches;
+use function array_keys;
+
+require_once __DIR__ . '/../../../include/branches.inc';
+
+class LegacyReleaseHelpersTest extends TestCase
+{
+    public function testReleaseDataLoading(): void
+    {
+        self::assertNotEmpty(Branches::getReleaseData());
+        self::assertNotEmpty(Branches::getOldReleaseData());
+        self::assertNotEmpty(Branches::all());
+    }
+
+    public function testOldReleasesDoNotContainNewReleases(): void
+    {
+        $olderReleases = Branches::getOldReleaseData();
+        $recentReleases = Branches::getReleaseData();
+
+        foreach ($recentReleases as $majorId => $releases) {
+            foreach (array_keys($releases) as $releaseId) {
+                self::assertFalse(isset($olderReleases[$majorId][$releaseId]), 
"Duplicate data for '$releaseId'");
+            }
+        }
+    }
+
+    public function testAllReleasesContainsRecent(): void
+    {
+        $all = Branches::all();
+
+        foreach (Branches::getReleaseData() as $majorId => $releases) {
+            foreach (array_keys($releases) as $releaseId) {
+                self::assertTrue(
+                    isset($all[$majorId][$releaseId]),
+                    "Missing recent release data for '$releaseId' in all()",
+                );
+            }
+        }
+    }
+
+    public function testAllReleasesContainsOld(): void
+    {
+        $all = Branches::all();
+
+        foreach (Branches::getOldReleaseData() as $majorId => $releases) {
+            foreach (array_keys($releases) as $releaseId) {
+                self::assertTrue(
+                    isset($all[$majorId][$releaseId]),
+                    "Missing old release data for '$releaseId' in all()",
+                );
+            }
+        }
+    }
+
+    public function testNormalizationForAllData(): void
+    {
+        foreach (Branches::all() as $releases) {
+            foreach ($releases as $releaseId => $release) {
+                /* @phpstan-ignore-next-line */
+                self::assertTrue(
+                    /* @phpstan-ignore-next-line */
+                    isset($release['version']),
+                    "Release '$releaseId' does not have a version defined",
+                );
+
+                /* @phpstan-ignore-next-line */
+                self::assertTrue(
+                    /* @phpstan-ignore-next-line */
+                    isset($release['date']),
+                    "Release '$releaseId' does not have a date defined",
+                );
+
+                /* @phpstan-ignore-next-line */
+                self::assertTrue(
+                    /* @phpstan-ignore-next-line */
+                    isset($release['announcement']) && 
is_bool($release['announcement']),
+                    "Release '$releaseId' does not have an announcement 
defined",
+                );
+
+                /* @phpstan-ignore-next-line */
+                self::assertTrue(
+                    /* @phpstan-ignore-next-line */
+                    isset($release['source']) && is_array($release['source']),
+                    "Release '$releaseId' does not have a source list defined",
+                );
+
+                foreach ($release['source'] as $idx => $source) {
+                    /* @phpstan-ignore-next-line */
+                    self::assertTrue(
+                        /* @phpstan-ignore-next-line */
+                        isset($source['filename']),
+                        "Release '$releaseId' source $idx does not have a 
filename",
+                    );
+
+                    /* @phpstan-ignore-next-line */
+                    self::assertTrue(
+                        /* @phpstan-ignore-next-line */
+                        isset($source['name']),
+                        "Release '$releaseId' source $idx does not have a 
name",
+                    );
+                }
+            }
+        }
+    }
+
+    public function testActive(): void
+    {
+        self::assertNotEmpty(Branches::active());
+    }
+
+    public function testInitialReleases(): void
+    {
+        self::assertNotEmpty(
+            Branches::getInitialReleaseForBranch('8.5'),
+            'Unable to find initial branch for 8.5',
+        );
+
+        self::assertNotEmpty(
+            Branches::getInitialReleaseForBranch('8.4'),
+            'Unable to find initial branch for 8.4 (aborted release)',
+        );
+    }
+
+    /**
+     * These were dumped from the old functions and may break if additional
+     * information is added to the overrides at some point
+     */
+    public static function provideExpectedDates(): Generator
+    {
+        yield '8.5' => ['8.5', '2025-11-20', '2027-12-31', '2029-12-31'];
+        yield '8.4' => ['8.4', '2024-11-21', '2026-12-31', '2028-12-31'];
+        yield '8.3' => ['8.3', '2023-11-23', '2025-12-31', '2027-12-31'];
+        yield '8.2' => ['8.2', '2022-12-08', '2024-12-31', '2026-12-31'];
+        yield '8.1' => ['8.1', '2021-11-25', '2023-11-25', '2025-12-31'];
+        yield '8.0' => ['8.0', '2020-11-26', '2022-11-26', '2023-11-26'];
+        yield '7.4' => ['7.4', '2019-11-28', '2021-11-28', '2022-11-28'];
+        yield '7.3' => ['7.3', '2018-12-06', '2020-12-06', '2021-12-06'];
+        yield '7.2' => ['7.2', '2017-11-30', '2019-11-30', '2020-11-30'];
+        yield '7.1' => ['7.1', '2016-12-01', '2018-12-01', '2019-12-01'];
+        yield '7.0' => ['7.0', '2015-12-03', '2018-01-04', '2019-01-10'];
+        yield '5.6' => ['5.6', '2014-08-28', '2017-01-19', '2018-12-31'];
+        yield '5.5' => ['5.5', '2013-06-20', '2015-07-10', '2016-07-21'];
+        yield '5.4' => ['5.4', '2012-03-01', '2014-09-14', '2015-09-03'];
+        yield '5.3' => ['5.3', '2009-06-30', '2013-07-11', '2014-08-14'];
+        yield '5.2' => ['5.2', '2006-11-02', '2008-11-02', '2011-01-06'];
+        yield '5.1' => ['5.1', '2005-11-24', '2007-11-24', '2006-08-24'];
+        yield '5.0' => ['5.0', '2004-07-13', '2006-07-13', '2005-09-05'];
+        yield '4.4' => ['4.4', '2005-07-11', '2007-07-11', '2008-08-07'];
+        yield '4.3' => ['4.3', '2002-12-27', '2004-12-27', '2005-03-31'];
+        yield '4.2' => ['4.2', '2002-04-22', '2004-04-22', '2002-09-06'];
+        yield '4.1' => ['4.1', '2001-12-10', '2003-12-10', '2002-03-12'];
+        yield '4.0' => ['4.0', '2000-05-22', '2002-05-22', '2001-06-23'];
+
+        // 3.0 is not included as it's the only one which returns null
+    }
+
+    #[DataProvider('provideExpectedDates')]
+    public function testExpectedDates(string $branch, string $initialDate, 
string $bugfixDate, string $securityDate): void
+    {
+        self::assertEquals(
+            $initialDate,
+            Branches::getBranchReleaseDate($branch)?->format('Y-m-d'),
+        );
+
+        self::assertEquals(
+            $bugfixDate,
+            Branches::getBranchBugsEOLDate($branch)?->format('Y-m-d'),
+        );
+
+        self::assertEquals(
+            $securityDate,
+            Branches::getBranchSecurityEOLDate($branch)?->format('Y-m-d'),
+        );
+    }
+
+    public function testCurrentReleaseForBranch(): void
+    {
+        /* need something that won't change in response to new releases */
+        self::assertEquals(
+            '7.4.33',
+            Branches::getCurrentReleaseForBranch(7, 4),
+        );
+    }
+}
diff --git a/tests/phpunit.xml b/tests/phpunit.xml
index 2e1031e36b..43f088f434 100644
--- a/tests/phpunit.xml
+++ b/tests/phpunit.xml
@@ -3,7 +3,6 @@
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
     xsi:noNamespaceSchemaLocation="../vendor/phpunit/phpunit/phpunit.xsd"
     beStrictAboutChangesToGlobalState="true"
-    beStrictAboutCoverageMetadata="true"
     beStrictAboutOutputDuringTests="true"
     beStrictAboutTestsThatDoNotTestAnything="true"
     bootstrap="../src/autoload.php"
@@ -18,7 +17,6 @@
     displayDetailsOnTestsThatTriggerNotices="true"
     displayDetailsOnTestsThatTriggerWarnings="true"
     executionOrder="random"
-    requireCoverageMetadata="true"
     stopOnError="false"
     stopOnFailure="false"
     stopOnIncomplete="false"

Reply via email to