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: 2 additions & 0 deletions tools/release/phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<!-- Milestone mover: close/create/move + due dates. -->
<testsuite name="milestones-update">
<file>tests/MilestoneUpdaterTest.php</file>
<file>tests/MilestoneSnapshotTest.php</file>
</testsuite>
<!-- Milestone consistency audit. -->
<testsuite name="milestones-audit">
Expand All @@ -27,6 +28,7 @@
<!-- Repository tagging. -->
<testsuite name="tagger">
<file>tests/RepoTaggerTest.php</file>
<file>tests/TaggerSnapshotTest.php</file>
</testsuite>
<!-- Updater-server release plan + config transforms. -->
<testsuite name="updater">
Expand Down
110 changes: 110 additions & 0 deletions tools/release/tests/MilestoneSnapshotTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

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

declare(strict_types=1);

namespace Nextcloud\ReleaseTools\Tests;

use Nextcloud\ReleaseTools\MilestoneUpdater;
use Nextcloud\ReleaseTools\Tests\Support\FakeGitHubApi;
use Nextcloud\ReleaseTools\Tests\Support\Journal;
use Nextcloud\ReleaseTools\Tests\Support\MatchesSnapshots;
use Nextcloud\ReleaseTools\Version;
use PHPUnit\Framework\TestCase;

/**
* What: golden-file snapshots of the full milestone-update journal per release
* scenario (the PHP successor to the old tests/milestone-scripts snapshot
* harness).
*
* Why: alongside the focused assertions in MilestoneUpdaterTest, these pin the
* entire mutation sequence as a reviewable artifact - the same "scenario ->
* expected" feel as the bash harness, so a behaviour change shows up as a
* readable snapshot diff. Update with UPDATE_SNAPSHOTS=1.
*/
final class MilestoneSnapshotTest extends TestCase
{
use MatchesSnapshots;

private const SERVER = 'nextcloud/server';
private const ACTIVITY = 'nextcloud/activity';

private function snapshot(string $description, FakeGitHubApi $api): string
{
return Journal::snapshot($description, Journal::MILESTONE_LEGEND, $api->journal);
}

public function testPatchRelease(): void
{
$api = new FakeGitHubApi();
$api->seedMilestone(self::SERVER, 10, 'Nextcloud 33.0.4', 'open', 2);
$api->seedMilestone(self::SERVER, 11, 'Nextcloud 33.0.5');
$api->seedIssue(self::SERVER, 100, 10);
$api->seedIssue(self::SERVER, 101, 10);

(new MilestoneUpdater($api))->run(Version::fromTag('v33.0.4'), [self::SERVER]);
$this->assertMatchesSnapshot('milestones/patch-release', $this->snapshot('Patch release v33.0.4: move open issues to 33.0.5, close 33.0.4, open 33.0.6', $api));
}

public function testFirstStable(): void
{
$api = new FakeGitHubApi();
$api->seedMilestone(self::SERVER, 20, 'Nextcloud 34');
(new MilestoneUpdater($api))->run(Version::fromTag('v34.0.0'), [self::SERVER]);
$this->assertMatchesSnapshot('milestones/first-stable', $this->snapshot('First stable v34.0.0: close the Nextcloud 34 milestone, open 34.0.1 and 34.0.2', $api));
}

public function testFirstBeta(): void
{
$api = new FakeGitHubApi();
(new MilestoneUpdater($api))->run(Version::fromTag('v35.0.0beta1'), [self::SERVER, self::ACTIVITY]);
$this->assertMatchesSnapshot('milestones/first-beta', $this->snapshot('First beta v35.0.0beta1: open the next major milestone Nextcloud 36 in each repo', $api));
}

public function testMissingNext(): void
{
$api = new FakeGitHubApi();
$api->seedMilestone(self::SERVER, 10, 'Nextcloud 33.0.4', 'open', 1);
$api->seedIssue(self::SERVER, 100, 10);
(new MilestoneUpdater($api))->run(Version::fromTag('v33.0.4'), [self::SERVER]);
$this->assertMatchesSnapshot('milestones/missing-next', $this->snapshot('Patch v33.0.4 when 33.0.5 is missing: create it first, then move/close/open 33.0.6', $api));
}

public function testDueDates(): void
{
$api = new FakeGitHubApi();
$api->seedMilestone(self::SERVER, 10, 'Nextcloud 33.0.4');
$api->seedMilestone(self::SERVER, 11, 'Nextcloud 33.0.5', 'open', 0, '2026-06-25T00:00:00Z');
$api->seedMilestone(self::SERVER, 12, 'Nextcloud 33.0.6');
(new MilestoneUpdater($api))->run(
Version::fromTag('v33.0.4'),
[self::SERVER],
'2026-07-02T00:00:00Z',
'2026-08-27T00:00:00Z',
);
$this->assertMatchesSnapshot('milestones/due-dates', $this->snapshot('Patch v33.0.4 with due dates: set 33.0.5 and 33.0.6 due dates, close 33.0.4', $api));
}

public function testMultiRepo(): void
{
$api = new FakeGitHubApi();
foreach ([self::SERVER, self::ACTIVITY] as $i => $repo) {
$base = ($i + 1) * 10;
$api->seedMilestone($repo, $base, 'Nextcloud 33.0.4', 'open', 1);
$api->seedMilestone($repo, $base + 1, 'Nextcloud 33.0.5');
$api->seedIssue($repo, 500 + $i, $base);
}
(new MilestoneUpdater($api))->run(Version::fromTag('v33.0.4'), [self::SERVER, self::ACTIVITY]);
$this->assertMatchesSnapshot('milestones/multi-repo', $this->snapshot('Patch v33.0.4 across two repos', $api));
}

public function testPrereleaseNoop(): void
{
$api = new FakeGitHubApi();
$api->seedMilestone(self::SERVER, 10, 'Nextcloud 33.0.2');
(new MilestoneUpdater($api))->run(Version::fromTag('v33.0.2rc1'), [self::SERVER]);
$this->assertMatchesSnapshot('milestones/prerelease-noop', $this->snapshot('Non-first-beta pre-release v33.0.2rc1: nothing happens', $api));
}
}
31 changes: 31 additions & 0 deletions tools/release/tests/Support/Journal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

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

declare(strict_types=1);

namespace Nextcloud\ReleaseTools\Tests\Support;

/**
* Builds the .snap body for the milestone/tagger snapshot tests: the raw
* tab-separated mutation journal (the standard, stable format the assertion
* tests also use), preceded by comment lines that say which scenario produced
* it and what the columns mean - so a .snap file is understandable on its own.
*/
final class Journal
{
public const MILESTONE_LEGEND =
'columns (tab): <action> <repo> <args> -- '
. 'create: title, due=<date|-> | setdue: #ms, date | move: #issue, #ms | close: #ms';

public const TAG_LEGEND =
'tags (tab): <action> <repo> <tag> <sha> -- tag | retag adds force=<bool>';

/** @param list<string> $lines raw journal lines */
public static function snapshot(string $description, string $legend, array $lines): string
{
$body = $lines === [] ? '(no changes)' : implode("\n", $lines);
return "# {$description}\n# {$legend}\n{$body}";
}
}
38 changes: 38 additions & 0 deletions tools/release/tests/Support/MatchesSnapshots.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

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

declare(strict_types=1);

namespace Nextcloud\ReleaseTools\Tests\Support;

/**
* Tiny golden-file snapshot helper. A test produces a string (e.g. the journal
* of GitHub mutations or a rendered file) and compares it to a committed
* `tests/snapshots/<name>.snap`. Missing snapshots are written on first run;
* re-run with `UPDATE_SNAPSHOTS=1` to regenerate after an intended change.
*/
trait MatchesSnapshots
{
protected function assertMatchesSnapshot(string $name, string $actual): void
{
$file = __DIR__ . '/../snapshots/' . $name . '.snap';
$actual = rtrim($actual, "\n") . "\n";

if (getenv('UPDATE_SNAPSHOTS') === '1' || !is_file($file)) {
if (!is_dir(dirname($file))) {
mkdir(dirname($file), 0o777, true);
}
file_put_contents($file, $actual);
$this->addToAssertionCount(1);
return;
}

$this->assertSame(
file_get_contents($file),
$actual,
"Snapshot '{$name}' differs. Re-run with UPDATE_SNAPSHOTS=1 if the change is intended.",
);
}
}
93 changes: 93 additions & 0 deletions tools/release/tests/TaggerSnapshotTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

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

declare(strict_types=1);

namespace Nextcloud\ReleaseTools\Tests;

use Nextcloud\ReleaseTools\RepoTagger;
use Nextcloud\ReleaseTools\Tests\Support\FakeGitHubApi;
use Nextcloud\ReleaseTools\Tests\Support\Journal;
use Nextcloud\ReleaseTools\Tests\Support\MatchesSnapshots;
use PHPUnit\Framework\TestCase;

/**
* What: golden-file snapshots of the tagger's per-repo decisions and the tags
* it writes, across a representative mix of repositories.
*
* Why: pins the whole tag run (status + chosen branch + the create/recreate/skip
* journal) as one reviewable artifact, covering create, skip-existing,
* force-recreate, server-repo immutability, default-branch fallback and
* no-branch failure together. Update with UPDATE_SNAPSHOTS=1.
*/
final class TaggerSnapshotTest extends TestCase
{
use MatchesSnapshots;

private function render(string $description, array $results, FakeGitHubApi $api): string
{
$lines = [
"# {$description}",
'# results (tab): <repo> <status> <branch> <detail>',
'# ' . Journal::TAG_LEGEND,
];
foreach ($results as $r) {
$lines[] = sprintf("%s\t%s\t%s\t%s", $r->repo, $r->status, $r->branch === '' ? '-' : $r->branch, $r->detail);
}
$lines[] = '';
$lines[] = $api->journal === [] ? '(no tags written)' : implode("\n", $api->journal);
return implode("\n", $lines);
}

public function testMixedRun(): void
{
$api = new FakeGitHubApi();
// a normal app on the release branch
$api->seedBranch('nextcloud/activity', 'stable34', 'sha-activity', true);
// an app where the tag already exists (will skip without force)
$api->seedBranch('nextcloud/notes', 'stable34', 'sha-notes', true);
$api->seedTag('nextcloud/notes', 'v34.0.1', 'old-notes');
// an app with no stable34, only a default branch (fallback)
$api->seedBranch('nextcloud/photos', 'main', 'sha-photos', true);
// the server repo, tag exists - must never be recreated even with force
$api->seedBranch('nextcloud/server', 'stable34', 'sha-server', true);
$api->seedTag('nextcloud/server', 'v34.0.1', 'old-server');
// a repo with no branch at all (fails)
// (nextcloud/ghost: nothing seeded)

$repos = ['nextcloud/activity', 'nextcloud/notes', 'nextcloud/photos', 'nextcloud/server', 'nextcloud/ghost'];
$tagger = new RepoTagger($api);
$results = array_map(
static fn (string $repo) => $tagger->tag($repo, 'stable34', 'v34.0.1', false),
$repos,
);

$this->assertMatchesSnapshot('tagger/mixed', $this->render('Tag v34.0.1 across a mixed set: new, already-tagged, default-branch fallback, server (immutable), and a repo with no branch', $results, $api));
}

public function testForceRun(): void
{
$api = new FakeGitHubApi();
$api->seedBranch('nextcloud/activity', 'stable34', 'sha-activity', true);
$api->seedTag('nextcloud/activity', 'v34.0.1', 'old');
$api->seedBranch('nextcloud/server', 'stable34', 'sha-server', true);
$api->seedTag('nextcloud/server', 'v34.0.1', 'old-server');

$tagger = new RepoTagger($api);
$results = [
$tagger->tag('nextcloud/activity', 'stable34', 'v34.0.1', true),
$tagger->tag('nextcloud/server', 'stable34', 'v34.0.1', true),
];
$this->assertMatchesSnapshot('tagger/force', $this->render('Tag v34.0.1 with --force: a normal repo is recreated, the server repo is still skipped', $results, $api));
}

public function testDryRun(): void
{
$api = new FakeGitHubApi();
$api->seedBranch('nextcloud/activity', 'stable34', 'sha-activity', true);
$results = [(new RepoTagger($api, dryRun: true))->tag('nextcloud/activity', 'stable34', 'v34.0.1', false)];
$this->assertMatchesSnapshot('tagger/dry-run', $this->render('Tag v34.0.1 in dry-run: reports what it would do, writes nothing', $results, $api));
}
}
5 changes: 5 additions & 0 deletions tools/release/tests/snapshots/milestones/due-dates.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Patch v33.0.4 with due dates: set 33.0.5 and 33.0.6 due dates, close 33.0.4
# columns (tab): <action> <repo> <args> -- create: title, due=<date|-> | setdue: #ms, date | move: #issue, #ms | close: #ms
setdue nextcloud/server 11 2026-07-02T00:00:00Z
close nextcloud/server 10
setdue nextcloud/server 12 2026-08-27T00:00:00Z
4 changes: 4 additions & 0 deletions tools/release/tests/snapshots/milestones/first-beta.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# First beta v35.0.0beta1: open the next major milestone Nextcloud 36 in each repo
# columns (tab): <action> <repo> <args> -- create: title, due=<date|-> | setdue: #ms, date | move: #issue, #ms | close: #ms
create nextcloud/server Nextcloud 36 due=-
create nextcloud/activity Nextcloud 36 due=-
5 changes: 5 additions & 0 deletions tools/release/tests/snapshots/milestones/first-stable.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# First stable v34.0.0: close the Nextcloud 34 milestone, open 34.0.1 and 34.0.2
# columns (tab): <action> <repo> <args> -- create: title, due=<date|-> | setdue: #ms, date | move: #issue, #ms | close: #ms
create nextcloud/server Nextcloud 34.0.1 due=-
close nextcloud/server 20
create nextcloud/server Nextcloud 34.0.2 due=-
6 changes: 6 additions & 0 deletions tools/release/tests/snapshots/milestones/missing-next.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Patch v33.0.4 when 33.0.5 is missing: create it first, then move/close/open 33.0.6
# columns (tab): <action> <repo> <args> -- create: title, due=<date|-> | setdue: #ms, date | move: #issue, #ms | close: #ms
create nextcloud/server Nextcloud 33.0.5 due=-
move nextcloud/server 100 11
close nextcloud/server 10
create nextcloud/server Nextcloud 33.0.6 due=-
8 changes: 8 additions & 0 deletions tools/release/tests/snapshots/milestones/multi-repo.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Patch v33.0.4 across two repos
# columns (tab): <action> <repo> <args> -- create: title, due=<date|-> | setdue: #ms, date | move: #issue, #ms | close: #ms
move nextcloud/server 500 11
close nextcloud/server 10
create nextcloud/server Nextcloud 33.0.6 due=-
move nextcloud/activity 501 21
close nextcloud/activity 20
create nextcloud/activity Nextcloud 33.0.6 due=-
6 changes: 6 additions & 0 deletions tools/release/tests/snapshots/milestones/patch-release.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Patch release v33.0.4: move open issues to 33.0.5, close 33.0.4, open 33.0.6
# columns (tab): <action> <repo> <args> -- create: title, due=<date|-> | setdue: #ms, date | move: #issue, #ms | close: #ms
move nextcloud/server 100 11
move nextcloud/server 101 11
close nextcloud/server 10
create nextcloud/server Nextcloud 33.0.6 due=-
3 changes: 3 additions & 0 deletions tools/release/tests/snapshots/milestones/prerelease-noop.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Non-first-beta pre-release v33.0.2rc1: nothing happens
# columns (tab): <action> <repo> <args> -- create: title, due=<date|-> | setdue: #ms, date | move: #issue, #ms | close: #ms
(no changes)
6 changes: 6 additions & 0 deletions tools/release/tests/snapshots/tagger/dry-run.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Tag v34.0.1 in dry-run: reports what it would do, writes nothing
# results (tab): <repo> <status> <branch> <detail>
# tags (tab): <action> <repo> <tag> <sha> -- tag | retag adds force=<bool>
nextcloud/activity OK stable34 would create v34.0.1 @ sha-activity

(no tags written)
7 changes: 7 additions & 0 deletions tools/release/tests/snapshots/tagger/force.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Tag v34.0.1 with --force: a normal repo is recreated, the server repo is still skipped
# results (tab): <repo> <status> <branch> <detail>
# tags (tab): <action> <repo> <tag> <sha> -- tag | retag adds force=<bool>
nextcloud/activity OK stable34 recreated v34.0.1
nextcloud/server SKIPPED stable34 already tagged

retag nextcloud/activity v34.0.1 sha-activity force=true
11 changes: 11 additions & 0 deletions tools/release/tests/snapshots/tagger/mixed.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Tag v34.0.1 across a mixed set: new, already-tagged, default-branch fallback, server (immutable), and a repo with no branch
# results (tab): <repo> <status> <branch> <detail>
# tags (tab): <action> <repo> <tag> <sha> -- tag | retag adds force=<bool>
nextcloud/activity OK stable34 created v34.0.1
nextcloud/notes SKIPPED stable34 already tagged
nextcloud/photos OK main created v34.0.1
nextcloud/server SKIPPED stable34 already tagged
nextcloud/ghost FAILED - no branch found

tag nextcloud/activity v34.0.1 sha-activity
tag nextcloud/photos v34.0.1 sha-photos
Loading