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.