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.