Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 17 additions & 19 deletions build/build-index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
137 changes: 56 additions & 81 deletions build/detect-versions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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<int, ?string> 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;
}

/**
Expand All @@ -96,66 +81,56 @@ function fetch_release_info(int $version): ?array {
* highest_stable: int|null,
* lowest_stable: int,
* dev_version: int,
* released: array<int, int>
* released: array<int, ?string>,
* 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 [
'highest_stable' => $highestStable,
'lowest_stable' => $lowestStable,
'dev_version' => $devVersion,
'released' => $released,
'supported' => $supportedVersions,
];
}

Expand Down
22 changes: 14 additions & 8 deletions build/server-block.php
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
<?php
// Role of a version section on the index page. Each role renders a note naming a single
// version, so each may be used at most once per page.
const SECTION_UPCOMING = 0;
const SECTION_LATEST_STABLE = 1;
const SECTION_LAST_SUPPORTED = 2;

/**
* Generate the HTML section for a given Nextcloud version,
* including links to manuals and notes about the version status.
* The $index parameter is used to determine if the version is latest, stable, previous stable, or last supported stable.
* If $index is null, it means it's a legacy version (released but outside support window).
*
* @param int|null $role One of the SECTION_* constants, or null for a version that gets
* no note: a legacy release, or a maintained one that is neither
* the newest nor the oldest.
*/
function generate_section(string $version, ?int $index = null): string {
function generate_section(string $version, ?int $role = null): string {
$note = '';
$label = $version;
if ($index === 0) {
if ($role === SECTION_UPCOMING) {
$label = 'latest';
$note = '<p>This documents the <em>upcoming</em> version of Nextcloud (not released).</p>';
} else if ($index === 1) {
} else if ($role === SECTION_LATEST_STABLE) {
$label = 'stable';
$note = '<p>This documents the <em>latest stable</em> version of Nextcloud.</p>';
} else if ($index === 2) {
$note = '<p>This documents the <em>previous stable</em> (still supported) version of Nextcloud.</p>';
} else if ($index === 3) {
} else if ($role === SECTION_LAST_SUPPORTED) {
$note = '<p>This documents the <em>last supported stable</em> version of Nextcloud.</p>';
}

Expand Down
23 changes: 23 additions & 0 deletions build/verify-index.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,29 @@
$version_count = preg_match_all('/<h2>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('<div class="section" id="nextcloud-older">', $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, "<em>$note</em>");
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, '<em>latest stable</em>') !== 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));
Expand Down
2 changes: 1 addition & 1 deletion conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading