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