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
2 changes: 1 addition & 1 deletion .github/workflows/test-release-tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
suite: [domain, milestones-update, milestones-audit, tagger]
suite: [domain, milestones-update, milestones-audit, tagger, updater]
name: test (${{ matrix.suite }})
defaults:
run:
Expand Down
4 changes: 4 additions & 0 deletions tools/release/phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
<testsuite name="tagger">
<file>tests/RepoTaggerTest.php</file>
</testsuite>
<!-- Updater-server release plan + config transforms. -->
<testsuite name="updater">
<directory>tests/Updater</directory>
</testsuite>
</testsuites>
<source>
<include>
Expand Down
35 changes: 35 additions & 0 deletions tools/release/src/Updater/MajorVersions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: MIT

declare(strict_types=1);

namespace Nextcloud\ReleaseTools\Updater;

/**
* Transform on config/major_versions.json: add a new major (with its minimum
* PHP) at the top, as update-updater-server.sh does for a first pre-release.
* Existing majors are left untouched.
*/
final class MajorVersions
{
/**
* @param array<string, mixed> $majors
* @return array<string, mixed>
*/
public static function ensureMajor(array $majors, int $major, string $minPhp): array
{
$key = (string) $major;
if (array_key_exists($key, $majors)) {
return $majors;
}
// Prepend, matching jq's `{($m): …} + .`.
return [$key => ['minPHP' => $minPhp]] + $majors;
}

public static function encode(array $data): string
{
return ReleasesJson::encode($data);
}
}
104 changes: 104 additions & 0 deletions tools/release/src/Updater/ReleasePlan.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: MIT

declare(strict_types=1);

namespace Nextcloud\ReleaseTools\Updater;

/**
* Everything the updater-server change derives from a release tag: the display
* version, the download URL pieces, channel/stability, the release type and the
* deploy percentage. Mirrors the tag parsing in update-updater-server.sh.
*
* Release type here is the *base* classification (first_stable / patch /
* prerelease). It only becomes "first_prerelease" once a lookup confirms there
* is no existing entry for the major (see ReleasesJson::findOldKey).
*/
final class ReleasePlan
{
public const TYPE_FIRST_STABLE = 'first_stable';
public const TYPE_PATCH = 'patch';
public const TYPE_PRERELEASE = 'prerelease';

public function __construct(
public readonly int $major,
public readonly int $minor,
public readonly int $patch,
public readonly string $versionString,
public readonly string $urlVersion,
public readonly string $urlDir,
public readonly string $stability,
public readonly string $channel,
public readonly string $releaseType,
public readonly int $deploy,
) {
}

public static function fromTag(string $tag, ?int $deployOverride = null): self
{
$version = preg_replace('/^v/', '', $tag);
$major = (int) (preg_match('/^(\d+)/', $version, $m) ? $m[1] : 0);
$parts = explode('.', $version);
$minor = (int) ($parts[1] ?? 0);
$patch = (int) (preg_match('/^(\d+)/', $parts[2] ?? '0', $m) ? $m[1] : 0);

$modifier = preg_match('/(rc|beta|alpha)(\d+)$/i', $version, $m) ? strtolower($m[1] . $m[2]) : '';
$modWord = $m[1] ?? '';
$modNum = $m[2] ?? '';

$base = "{$major}.{$minor}.{$patch}";

if ($modifier !== '') {
// RC -> "RC5" (uppercase, no space in the token); beta/alpha -> "beta 5".
$display = strtolower($modWord) === 'rc'
? strtoupper($modWord) . $modNum
: strtolower($modWord) . ' ' . $modNum;
$versionString = "{$base} {$display}";
$urlVersion = $base . $modifier;
$urlDir = 'prereleases';
$stability = 'beta';
$type = self::TYPE_PRERELEASE;
} elseif ($patch === 0 && $minor === 0) {
$versionString = $base;
$urlVersion = $base;
$urlDir = 'releases';
$stability = 'stable';
$type = self::TYPE_FIRST_STABLE;
} else {
$versionString = $base;
$urlVersion = $base;
$urlDir = 'releases';
$stability = 'stable';
$type = self::TYPE_PATCH;
}

$deploy = $deployOverride ?? self::autoDeploy($type, $stability, $minor, $patch);

return new self(
major: $major,
minor: $minor,
patch: $patch,
versionString: $versionString,
urlVersion: $urlVersion,
urlDir: $urlDir,
stability: $stability,
channel: $stability,
releaseType: $type,
deploy: $deploy,
);
}

/** X.0.0 -> 30%, X.0.1 -> 70%, everything else -> 100%. */
private static function autoDeploy(string $type, string $stability, int $minor, int $patch): int
{
if ($type === self::TYPE_FIRST_STABLE) {
return 30;
}
if ($stability === 'stable' && $minor === 0 && $patch === 1) {
return 70;
}
return 100;
}
}
89 changes: 89 additions & 0 deletions tools/release/src/Updater/ReleasesJson.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: MIT

declare(strict_types=1);

namespace Nextcloud\ReleaseTools\Updater;

/**
* Transforms on the updater server's config/releases.json. Pure: takes the
* decoded map (version string -> entry) and returns a new one. Mirrors the jq
* in update-updater-server.sh, including the tab-indented output the repo uses.
*/
final class ReleasesJson
{
/**
* The entry this release replaces: for a patch, the current stable entry of
* the major; for a (pre)release, the latest RC/beta/alpha entry. Null when
* there is none (a first pre-release of a new major).
*
* @param array<string, mixed> $releases
*/
public static function findOldKey(array $releases, int $major, string $type): ?string
{
$prefix = "{$major}.";
$found = null;
foreach (array_keys($releases) as $key) {
if (!str_starts_with($key, $prefix)) {
continue;
}
$isPre = preg_match('/[Rr][Cc]|[Bb]eta|[Aa]lpha/', $key) === 1;
if ($type === ReleasePlan::TYPE_PATCH) {
if (!$isPre && !str_contains($key, 'Enterprise')) {
$found = $key; // keep last match (insertion order)
}
} elseif ($isPre) {
$found = $key;
}
}
return $found;
}

/**
* The new entry. `deploy` is only written when it is not 100%.
*
* @return array<string, mixed>
*/
public static function newEntry(string $internalVersion, string $bz2Sig, string $zipSig, int $deploy): array
{
$entry = [
'internalVersion' => $internalVersion,
'signatures' => ['bz2' => $bz2Sig, 'zip' => $zipSig],
];
if ($deploy !== 100) {
$entry['deploy'] = $deploy;
}
return $entry;
}

/**
* Replace $oldKey (if given) with $newKey => $entry, else just append it.
*
* @param array<string, mixed> $releases
* @param array<string, mixed> $entry
* @return array<string, mixed>
*/
public static function apply(array $releases, ?string $oldKey, string $newKey, array $entry): array
{
if ($oldKey !== null) {
unset($releases[$oldKey]);
}
$releases[$newKey] = $entry;
return $releases;
}

/** Encode with tab indentation + trailing newline, matching the repo style. */
public static function encode(array $data): string
{
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// json_encode pretty-prints with 4 spaces; the repo uses tabs.
$json = preg_replace_callback(
'/^( +)/m',
static fn (array $m): string => str_repeat("\t", intdiv(strlen($m[1]), 4)),
$json,
);
return $json . "\n";
}
}
41 changes: 41 additions & 0 deletions tools/release/tests/Updater/MajorVersionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: MIT

declare(strict_types=1);

namespace Nextcloud\ReleaseTools\Tests\Updater;

use Nextcloud\ReleaseTools\Updater\MajorVersions;
use PHPUnit\Framework\TestCase;

/**
* What: adding a new major (with its minimum PHP) to major_versions.json.
*
* Why: a first pre-release of a new major must register it once, at the top,
* without disturbing existing majors; re-running must be a no-op.
*/
final class MajorVersionsTest extends TestCase
{
public function testAddsNewMajorAtTop(): void
{
$out = MajorVersions::ensureMajor(['34' => ['minPHP' => '8.1']], 35, '8.3');
// PHP casts numeric-string array keys to ints; JSON still encodes them as strings.
$this->assertSame([35, 34], array_keys($out));
$this->assertSame(['minPHP' => '8.3'], $out[35]);
}

public function testNoOpWhenMajorExists(): void
{
$in = ['35' => ['minPHP' => '8.0'], '34' => ['minPHP' => '8.1']];
$this->assertSame($in, MajorVersions::ensureMajor($in, 35, '8.3'));
}

public function testEncodeUsesTabs(): void
{
$json = MajorVersions::encode(['35' => ['minPHP' => '8.3']]);
$this->assertStringContainsString("\n\t\"35\"", $json);
$this->assertStringNotContainsString("\n \"", $json);
}
}
61 changes: 61 additions & 0 deletions tools/release/tests/Updater/ReleasePlanTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: MIT

declare(strict_types=1);

namespace Nextcloud\ReleaseTools\Tests\Updater;

use Nextcloud\ReleaseTools\Updater\ReleasePlan;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* What: how a tag becomes the updater-server release facts (display version,
* URL pieces, channel, release type, deploy %).
*
* Why: this is the fiddly bit of update-updater-server.sh - RC vs beta/alpha
* display formatting, the prereleases/releases split, and the X.0.0=30 /
* X.0.1=70 / else=100 deploy rule. Easy to get wrong in bash, pinned here.
*/
final class ReleasePlanTest extends TestCase
{
/** @return iterable<string, array{string, string, string, string, string, string, int}> */
public static function tags(): iterable
{
// tag => versionString, urlVersion, urlDir, stability, releaseType, deploy
yield 'patch' => ['v33.0.6', '33.0.6', '33.0.6', 'releases', 'stable', ReleasePlan::TYPE_PATCH, 100];
yield 'first stable' => ['v34.0.0', '34.0.0', '34.0.0', 'releases', 'stable', ReleasePlan::TYPE_FIRST_STABLE, 30];
yield 'x.0.1 -> 70' => ['v34.0.1', '34.0.1', '34.0.1', 'releases', 'stable', ReleasePlan::TYPE_PATCH, 70];
yield 'x.0.2 -> 100' => ['v34.0.2', '34.0.2', '34.0.2', 'releases', 'stable', ReleasePlan::TYPE_PATCH, 100];
yield 'minor bump' => ['v34.1.0', '34.1.0', '34.1.0', 'releases', 'stable', ReleasePlan::TYPE_PATCH, 100];
yield 'rc' => ['v34.0.0rc5', '34.0.0 RC5', '34.0.0rc5', 'prereleases', 'beta', ReleasePlan::TYPE_PRERELEASE, 100];
yield 'beta' => ['v35.0.0beta1', '35.0.0 beta 1', '35.0.0beta1', 'prereleases', 'beta', ReleasePlan::TYPE_PRERELEASE, 100];
yield 'alpha' => ['v35.0.0alpha2', '35.0.0 alpha 2', '35.0.0alpha2', 'prereleases', 'beta', ReleasePlan::TYPE_PRERELEASE, 100];
}

#[DataProvider('tags')]
public function testFromTag(string $tag, string $versionString, string $urlVersion, string $urlDir, string $stability, string $type, int $deploy): void
{
$p = ReleasePlan::fromTag($tag);
$this->assertSame($versionString, $p->versionString);
$this->assertSame($urlVersion, $p->urlVersion);
$this->assertSame($urlDir, $p->urlDir);
$this->assertSame($stability, $p->stability);
$this->assertSame($stability, $p->channel);
$this->assertSame($type, $p->releaseType);
$this->assertSame($deploy, $p->deploy);
}

public function testComponentsParsed(): void
{
$p = ReleasePlan::fromTag('v34.0.0rc5');
$this->assertSame([34, 0, 0], [$p->major, $p->minor, $p->patch]);
}

public function testDeployOverride(): void
{
$this->assertSame(50, ReleasePlan::fromTag('v34.0.0', 50)->deploy);
}
}
Loading
Loading