diff --git a/tools/release/phpunit.xml.dist b/tools/release/phpunit.xml.dist index d10c471de14..e0f8f992291 100644 --- a/tools/release/phpunit.xml.dist +++ b/tools/release/phpunit.xml.dist @@ -19,6 +19,7 @@ tests/MilestoneUpdaterTest.php + tests/MilestoneSnapshotTest.php @@ -27,6 +28,7 @@ tests/RepoTaggerTest.php + tests/TaggerSnapshotTest.php diff --git a/tools/release/tests/MilestoneSnapshotTest.php b/tools/release/tests/MilestoneSnapshotTest.php new file mode 100644 index 00000000000..1f170d34a12 --- /dev/null +++ b/tools/release/tests/MilestoneSnapshotTest.php @@ -0,0 +1,110 @@ + + * 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)); + } +} diff --git a/tools/release/tests/Support/Journal.php b/tools/release/tests/Support/Journal.php new file mode 100644 index 00000000000..4770de138dc --- /dev/null +++ b/tools/release/tests/Support/Journal.php @@ -0,0 +1,31 @@ + -- ' + . 'create: title, due= | setdue: #ms, date | move: #issue, #ms | close: #ms'; + + public const TAG_LEGEND = + 'tags (tab): -- tag | retag adds force='; + + /** @param list $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}"; + } +} diff --git a/tools/release/tests/Support/MatchesSnapshots.php b/tools/release/tests/Support/MatchesSnapshots.php new file mode 100644 index 00000000000..22567523ac8 --- /dev/null +++ b/tools/release/tests/Support/MatchesSnapshots.php @@ -0,0 +1,38 @@ +.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.", + ); + } +} diff --git a/tools/release/tests/TaggerSnapshotTest.php b/tools/release/tests/TaggerSnapshotTest.php new file mode 100644 index 00000000000..9e9d932bd80 --- /dev/null +++ b/tools/release/tests/TaggerSnapshotTest.php @@ -0,0 +1,93 @@ + ', + '# ' . 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)); + } +} diff --git a/tools/release/tests/snapshots/milestones/due-dates.snap b/tools/release/tests/snapshots/milestones/due-dates.snap new file mode 100644 index 00000000000..cc9be4a82a0 --- /dev/null +++ b/tools/release/tests/snapshots/milestones/due-dates.snap @@ -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): -- create: title, due= | 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 diff --git a/tools/release/tests/snapshots/milestones/first-beta.snap b/tools/release/tests/snapshots/milestones/first-beta.snap new file mode 100644 index 00000000000..4db44a776ca --- /dev/null +++ b/tools/release/tests/snapshots/milestones/first-beta.snap @@ -0,0 +1,4 @@ +# First beta v35.0.0beta1: open the next major milestone Nextcloud 36 in each repo +# columns (tab): -- create: title, due= | setdue: #ms, date | move: #issue, #ms | close: #ms +create nextcloud/server Nextcloud 36 due=- +create nextcloud/activity Nextcloud 36 due=- diff --git a/tools/release/tests/snapshots/milestones/first-stable.snap b/tools/release/tests/snapshots/milestones/first-stable.snap new file mode 100644 index 00000000000..7e938682c64 --- /dev/null +++ b/tools/release/tests/snapshots/milestones/first-stable.snap @@ -0,0 +1,5 @@ +# First stable v34.0.0: close the Nextcloud 34 milestone, open 34.0.1 and 34.0.2 +# columns (tab): -- create: title, due= | 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=- diff --git a/tools/release/tests/snapshots/milestones/missing-next.snap b/tools/release/tests/snapshots/milestones/missing-next.snap new file mode 100644 index 00000000000..3cbfc1e9642 --- /dev/null +++ b/tools/release/tests/snapshots/milestones/missing-next.snap @@ -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): -- create: title, due= | 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=- diff --git a/tools/release/tests/snapshots/milestones/multi-repo.snap b/tools/release/tests/snapshots/milestones/multi-repo.snap new file mode 100644 index 00000000000..93f37134f4d --- /dev/null +++ b/tools/release/tests/snapshots/milestones/multi-repo.snap @@ -0,0 +1,8 @@ +# Patch v33.0.4 across two repos +# columns (tab): -- create: title, due= | 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=- diff --git a/tools/release/tests/snapshots/milestones/patch-release.snap b/tools/release/tests/snapshots/milestones/patch-release.snap new file mode 100644 index 00000000000..e7667157165 --- /dev/null +++ b/tools/release/tests/snapshots/milestones/patch-release.snap @@ -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): -- create: title, due= | 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=- diff --git a/tools/release/tests/snapshots/milestones/prerelease-noop.snap b/tools/release/tests/snapshots/milestones/prerelease-noop.snap new file mode 100644 index 00000000000..48fb0f2bd1c --- /dev/null +++ b/tools/release/tests/snapshots/milestones/prerelease-noop.snap @@ -0,0 +1,3 @@ +# Non-first-beta pre-release v33.0.2rc1: nothing happens +# columns (tab): -- create: title, due= | setdue: #ms, date | move: #issue, #ms | close: #ms +(no changes) diff --git a/tools/release/tests/snapshots/tagger/dry-run.snap b/tools/release/tests/snapshots/tagger/dry-run.snap new file mode 100644 index 00000000000..834c71c706a --- /dev/null +++ b/tools/release/tests/snapshots/tagger/dry-run.snap @@ -0,0 +1,6 @@ +# Tag v34.0.1 in dry-run: reports what it would do, writes nothing +# results (tab): +# tags (tab): -- tag | retag adds force= +nextcloud/activity OK stable34 would create v34.0.1 @ sha-activity + +(no tags written) diff --git a/tools/release/tests/snapshots/tagger/force.snap b/tools/release/tests/snapshots/tagger/force.snap new file mode 100644 index 00000000000..c96adf11cae --- /dev/null +++ b/tools/release/tests/snapshots/tagger/force.snap @@ -0,0 +1,7 @@ +# Tag v34.0.1 with --force: a normal repo is recreated, the server repo is still skipped +# results (tab): +# tags (tab): -- tag | retag adds force= +nextcloud/activity OK stable34 recreated v34.0.1 +nextcloud/server SKIPPED stable34 already tagged + +retag nextcloud/activity v34.0.1 sha-activity force=true diff --git a/tools/release/tests/snapshots/tagger/mixed.snap b/tools/release/tests/snapshots/tagger/mixed.snap new file mode 100644 index 00000000000..8ecda7c0511 --- /dev/null +++ b/tools/release/tests/snapshots/tagger/mixed.snap @@ -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): +# tags (tab): -- tag | retag adds force= +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