From 183940d08c910307a07d9a297ec434b3447650b7 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Thu, 17 Sep 2026 14:49:35 +0200 Subject: [PATCH] fix(index): derive support state from updater eol The index claimed 32 was still supported, and labelled both 33 and 32 "last supported stable". Support state came from the release tag of vN.0.0 plus 365 days, which disagrees with the end of life dates the updater serves: 32 reached end of life on 2026-09-10, while the tag date put it at 2026-09-27. The published timestamp is not the announced release date either, and 365 days is not 12 calendar months once the window spans a leap February. detect-versions.php now reads major_versions.json from the updater config, the same source the release tooling derives maintenance from, which also drops one API call per version and the rate limit abort path that came with them. Section roles were derived from the loop index, which only defines 0..3, so a fourth maintained version collided with the "last supported stable" role. Twelve months of support over a four month cadence usually means three maintained majors, but the cadence is not a rule the index should encode. Only the newest and the oldest maintained version are labelled now, so any number of maintained versions in between reads correctly, and verify-index.php fails the build if a label is duplicated or missing. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- build/build-index.php | 36 +++++----- build/detect-versions.php | 137 ++++++++++++++++---------------------- build/server-block.php | 22 +++--- build/verify-index.php | 23 +++++++ conf.py | 2 +- 5 files changed, 111 insertions(+), 109 deletions(-) diff --git a/build/build-index.php b/build/build-index.php index 3a9c652a379..a43633c15ec 100644 --- a/build/build-index.php +++ b/build/build-index.php @@ -18,34 +18,32 @@ $versions = detect_versions($branches); $released_branches = $versions['released']; $devVersion = $versions['dev_version']; -$devStatus = isset($released_branches[$branches[0]]) ? 'development' : 'upcoming'; +$devStatus = array_key_exists($branches[0], $released_branches) ? 'development' : 'upcoming'; fwrite(STDERR, "➡️ Version $devVersion ($devStatus)\n"); -// Collect released stable versions within support window -$oneYearAgo = time() - (365 * 24 * 60 * 60); -$stableVersions = []; -foreach ($branches as $branch) { - if (isset($released_branches[$branch]) && $released_branches[$branch] >= $oneYearAgo) { - $stableVersions[] = $branch; - } elseif (isset($released_branches[$branch])) { - // Once we hit an unsupported version, stop - break; - } -} +// Maintained stable versions, newest first +$stableVersions = $versions['supported']; -// Generate sections with proper indices -$supported = [generate_section($devVersion, 0)]; +// Generate sections with their roles. Only the newest and the oldest maintained version +// are labelled; the ones between them carry no note. +$lastIdx = count($stableVersions) - 1; +$supported = [generate_section($devVersion, SECTION_UPCOMING)]; foreach ($stableVersions as $idx => $version) { - // Index 3 for the oldest supported version if there are multiple - $index = ($idx + 1 === count($stableVersions) && count($stableVersions) > 1) ? 3 : $idx + 1; - $supported[] = generate_section($version, $index); + if ($idx === 0) { + $role = SECTION_LATEST_STABLE; + } elseif ($idx === $lastIdx) { + $role = SECTION_LAST_SUPPORTED; + } else { + $role = null; + } + $supported[] = generate_section($version, $role); } -// Generate legacy sections (released but outside support window) +// Generate legacy sections (released but no longer maintained) $legacy = []; foreach ($branches as $branch) { - if (isset($released_branches[$branch]) && !in_array($branch, $stableVersions)) { + if (array_key_exists($branch, $released_branches) && !in_array($branch, $stableVersions)) { $legacy[] = generate_section($branch, null); } } diff --git a/build/detect-versions.php b/build/detect-versions.php index abda7814afc..4d5bb11ad14 100644 --- a/build/detect-versions.php +++ b/build/detect-versions.php @@ -13,6 +13,18 @@ * Example: php detect-versions.php 32 33 34 */ +/** + * The end of life dates the updater serves to every instance. A major is listed + * from its release, and carries an "eol" date once one is announced; the current + * major has none yet. + * + * This is the same source the release tooling derives maintenance from, so the + * index cannot disagree with the updater about what is still supported. + * + * @see https://github.com/nextcloud-releases/updater_server/blob/master/config/major_versions.json + */ +const MAJOR_VERSIONS_URL = 'https://raw.githubusercontent.com/nextcloud-releases/updater_server/master/config/major_versions.json'; + /** * Get the GitHub API headers with optional authentication. */ @@ -25,67 +37,40 @@ function get_github_headers(): string { } /** - * Get the repository name for a given version. - * Nextcloud moved to nextcloud-releases/server starting with version 32. - */ -function get_repo_for_version(int $version): string { - return $version >= 32 ? 'nextcloud-releases/server' : 'nextcloud/server'; -} - -/** - * Parse the HTTP status code from the response headers populated by file_get_contents. + * Fetch the released majors and their end of life dates. * - * @param array $headers The $http_response_header array - */ -function parse_http_status(array $headers): int { - preg_match('/HTTP\/[\d.]+ (\d+)/', $headers[0] ?? '', $matches); - return (int)($matches[1] ?? 0); -} - -/** - * Fetch release info for a given version from the GitHub API. + * Exits with code 1 when the file cannot be fetched or parsed, rather than + * silently generating an index that claims every version is out of support. * - * Returns an array ['date' => int] if the release exists (HTTP 200). - * Returns null if the release does not exist (HTTP 404). - * Exits with code 1 on any other HTTP status (rate limit, server error, etc.) - * to prevent silently generating empty or incorrect output. + * @return array major => end of life date (Y-m-d), null while none is announced */ -function fetch_release_info(int $version): ?array { - $repo = get_repo_for_version($version); - $url = sprintf('https://api.github.com/repos/%s/releases/tags/v%d.0.0', $repo, $version); - +function fetch_major_versions(): array { $context = stream_context_create([ 'http' => [ 'header' => get_github_headers(), 'timeout' => 10, - 'ignore_errors' => true ] ]); - $response = @file_get_contents($url, false, $context); - - // FIXME: function_exists conditional can be dropped once we don't need to support <8.4.0 - if (function_exists('http_get_last_response_headers')) { - /** @var array|null */ - $http_response_header = \http_get_last_response_headers(); + $response = @file_get_contents(MAJOR_VERSIONS_URL, false, $context); + if ($response === false) { + fwrite(STDERR, 'Error: could not fetch ' . MAJOR_VERSIONS_URL . " — aborting\n"); + exit(1); } - $status = isset($http_response_header) && is_array($http_response_header) - ? parse_http_status($http_response_header) - : 0; - - if ($status === 200) { - $data = json_decode($response, true); - $publishedAt = $data['published_at'] ?? $data['created_at'] ?? null; - return ['date' => $publishedAt ? strtotime($publishedAt) : time()]; + $data = json_decode($response, true); + if (!is_array($data) || empty($data)) { + fwrite(STDERR, 'Error: could not parse ' . MAJOR_VERSIONS_URL . " — aborting\n"); + exit(1); } - if ($status === 404) { - return null; + $majors = []; + foreach ($data as $major => $info) { + $majors[(int)$major] = $info['eol'] ?? null; } + krsort($majors, SORT_NUMERIC); - fwrite(STDERR, "GitHub API error (HTTP $status) checking v$version.0.0 — aborting\n"); - exit(1); + return $majors; } /** @@ -96,59 +81,48 @@ function fetch_release_info(int $version): ?array { * highest_stable: int|null, * lowest_stable: int, * dev_version: int, - * released: array + * released: array, + * supported: int[] * } */ function detect_versions(array $branches): array { rsort($branches, SORT_NUMERIC); - // Nextcloud's support policy: a release is supported for 1 year after its initial - // release. Versions released more than a year ago are considered out of support - // and count as $lowest_stable only if no newer in-support version exists. - $oneYearAgo = time() - (365 * 24 * 60 * 60); - $released = []; - $firstOutOfSupportTime = null; + $majors = fetch_major_versions(); + $today = gmdate('Y-m-d'); + // A branch with no entry has not been released yet. Dates are compared as + // Y-m-d strings, which orders correctly because the parts are zero-padded. + $released = []; + $supportedVersions = []; foreach ($branches as $branch) { - if ($firstOutOfSupportTime !== null) { - // Older than the first out-of-support version — skip API call, - // store with the same timestamp (also out of support). - fwrite(STDERR, "🛑 Version $branch is unsupported\n"); - $released[$branch] = $firstOutOfSupportTime; + if (!array_key_exists($branch, $majors)) { + fwrite(STDERR, "⏳ Version $branch is not released\n"); continue; } - $info = fetch_release_info($branch); - if ($info === null) { - fwrite(STDERR, "⏳ Version $branch is not released (tag v$branch.0.0 not found)\n"); - continue; - } + $eol = $majors[$branch]; + $released[$branch] = $eol; - $released[$branch] = $info['date']; - if ($info['date'] < $oneYearAgo) { - fwrite(STDERR, "🛑 Version $branch is unsupported (released on " . date('Y-m-d', $info['date']) . ")\n"); - $firstOutOfSupportTime = $info['date']; + if ($eol === null) { + fwrite(STDERR, "✅ Version $branch is maintained (no end of life announced)\n"); + $supportedVersions[] = $branch; + } elseif ($eol >= $today) { + fwrite(STDERR, "✅ Version $branch is maintained (end of life on $eol)\n"); + $supportedVersions[] = $branch; } else { - fwrite(STDERR, "✅ Version $branch is supported (released on " . date('Y-m-d', $info['date']) . ")\n"); + fwrite(STDERR, "🛑 Version $branch reached end of life on $eol\n"); } } - // highest_stable: highest branch with a confirmed release - $highestStable = null; - foreach ($branches as $b) { - if (isset($released[$b])) { - $highestStable = $b; - break; - } - } + // highest_stable: highest released branch + $highestStable = !empty($released) ? max(array_keys($released)) : null; - // dev_version: if the highest branch has a release, dev = highest + 1; + // dev_version: if the highest branch is released, dev = highest + 1; // otherwise the branch exists but isn't released yet (upcoming). - $devVersion = isset($released[$branches[0]]) ? $branches[0] + 1 : $branches[0]; + $devVersion = array_key_exists($branches[0], $released) ? $branches[0] + 1 : $branches[0]; - // lowest_stable: lowest version still within the support window. - // Using min($branches) would include ancient branches (e.g. stable10) that still - // exist on the remote but are long out of support. - $supportedVersions = array_keys(array_filter($released, fn($date) => $date >= $oneYearAgo)); + // lowest_stable: lowest maintained version. Using min($branches) would include ancient + // branches (e.g. stable10) that still exist on the remote but are long out of support. $lowestStable = !empty($supportedVersions) ? min($supportedVersions) : $highestStable; return [ @@ -156,6 +130,7 @@ function detect_versions(array $branches): array { 'lowest_stable' => $lowestStable, 'dev_version' => $devVersion, 'released' => $released, + 'supported' => $supportedVersions, ]; } diff --git a/build/server-block.php b/build/server-block.php index 5f3e99cfc26..5a357971f0c 100644 --- a/build/server-block.php +++ b/build/server-block.php @@ -1,22 +1,28 @@ This documents the upcoming version of Nextcloud (not released).

'; - } else if ($index === 1) { + } else if ($role === SECTION_LATEST_STABLE) { $label = 'stable'; $note = '

This documents the latest stable version of Nextcloud.

'; - } else if ($index === 2) { - $note = '

This documents the previous stable (still supported) version of Nextcloud.

'; - } else if ($index === 3) { + } else if ($role === SECTION_LAST_SUPPORTED) { $note = '

This documents the last supported stable version of Nextcloud.

'; } diff --git a/build/verify-index.php b/build/verify-index.php index 73fd9aec13d..98054d5f510 100644 --- a/build/verify-index.php +++ b/build/verify-index.php @@ -56,6 +56,29 @@ $version_count = preg_match_all('/

Nextcloud (\d+)/', $content, $versions); fprintf(STDERR, "✓ Found %d version sections: %s\n", $version_count, implode(', ', $versions[1])); +// Verify the status notes of the maintained versions. +// Everything before the "older releases" heading is the maintained block. +$maintained = explode('
', $content)[0]; + +// Each note names a single version, so none of them may appear twice. The versions +// between the newest and the oldest maintained one carry no note at all. +foreach (['upcoming', 'latest stable', 'last supported stable'] as $note) { + $occurrences = substr_count($maintained, "$note"); + if ($occurrences > 1) { + fprintf(STDERR, "❌ ERROR: note \"%s\" appears %d times, expected at most one!\n", $note, $occurrences); + exit(1); + } +} + +// The newest maintained version is always labelled, whatever the number of maintained +// versions. A missing note means a section was generated without a role. +if (substr_count($maintained, 'latest stable') !== 1) { + fwrite(STDERR, "❌ ERROR: no version is labelled as the latest stable!\n"); + exit(1); +} + +fprintf(STDERR, "✓ Maintained version notes are unambiguous\n"); + // Validate documentation links format (should be server/VERSION/) // Check both relative links (server/latest, server/stable, etc.) and external docs links $relative_docs_links = array_filter($relative_links, fn($l) => preg_match('~^server/(latest|stable|\d+)/~', $l)); diff --git a/conf.py b/conf.py index bb4d1857882..fe03644543d 100644 --- a/conf.py +++ b/conf.py @@ -60,7 +60,7 @@ # In CI: DOCS_VERSION_STABLE and DOCS_VERSION_START are injected by sphinxbuild.yml # via detect-versions.php so these values are always current without manual updates. # Fallbacks here are used for local builds only. -version_start = int(os.environ.get('DOCS_VERSION_START', 32)) +version_start = int(os.environ.get('DOCS_VERSION_START', 33)) version_stable = int(os.environ.get('DOCS_VERSION_STABLE', 35)) # CHANGING IT MUST RESULT IN A CHANGE OF THE SYMLINK ON THE LIVE SERVER # In CI: DOCS_DISPLAY_VERSION is injected by sphinxbuild.yml. # Fallback: PDF/ePub builds use release (DOCS_RELEASE); local master builds use version_stable+1.