Skip to content

Commit f9c4645

Browse files
committed
fix(release): stop rotating immutable per-version Docker dirs
Slot rotation was rewriting per-version build artifacts because the registry listed them as rotating files. Rotating current 8.0.0→8.1 rewrote the old 8.0.0 dir's rel-800→rel-810 pin and renamed the 8.0.0 dependabot entry to 8.1.0, colliding with the entry the next slot already owns. Version dirs are immutable historical artifacts (8.0.0/ builds rel-800 forever), exactly like the already-excluded 7.0.4 dir. This completes the #777 migration to symlink-based slot resolution: remove the per-version Dockerfiles/READMEs and dependabot.yml from files:, and add whole-dir excludes for docker/openemr/{8.0.0,8.1.0,8.1.1} plus dependabot.yml. SlotRotator.php is unchanged — the bug was the registry model, not the rotator. Rotation now flips only the slot symlink + registry; no per-version dir or the dependabot config is ever edited. Assisted-by: Claude Code
1 parent f2cd9c7 commit f9c4645

2 files changed

Lines changed: 125 additions & 76 deletions

File tree

tools/release/tests/SlotRotatorTest.php

Lines changed: 109 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,17 @@ public function testRotationAdvancesNextSlotAndRewritesPinFiles(): void
4949
]);
5050

5151
self::assertFalse($result->isNoOp(), 'Rotation should have changed files');
52-
self::assertContains('docker/openemr/8.1.0/Dockerfile', $result->changedFiles);
52+
// External slot-tracking files follow the next slot.
53+
self::assertContains('.github/workflows/test-bats.yml', $result->changedFiles);
54+
self::assertContains('utilities/container_benchmarking/benchmark.sh', $result->changedFiles);
5355
self::assertContains('docker/openemr/next', $result->changedFiles);
54-
self::assertContains('docker/openemr/OVERVIEW.md', $result->changedFiles);
5556
self::assertContains('tools/release/versions.yml', $result->changedFiles);
57+
// The per-version build dir is immutable: never rewritten by rotation.
58+
self::assertNotContains('docker/openemr/8.1.0/Dockerfile', $result->changedFiles);
5659

57-
$dockerfile = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile');
58-
self::assertStringContainsString('ARG OPENEMR_VERSION=rel-820', $dockerfile);
59-
self::assertStringNotContainsString('rel-810', $dockerfile);
60+
$bats = (string) file_get_contents($this->tmpDir . '/.github/workflows/test-bats.yml');
61+
self::assertStringContainsString('docker/openemr/8.2.0/**', $bats);
62+
self::assertStringNotContainsString('docker/openemr/8.1.0/**', $bats);
6063

6164
self::assertSame(
6265
'8.2.0',
@@ -69,6 +72,43 @@ public function testRotationAdvancesNextSlotAndRewritesPinFiles(): void
6972
self::assertStringContainsString('branch: "rel-820"', $registry);
7073
}
7174

75+
public function testCurrentRotationLeavesVersionDirAndDependabotUntouched(): void
76+
{
77+
$dockerfilePath = $this->tmpDir . '/docker/openemr/8.0.0/Dockerfile';
78+
$dependabotPath = $this->tmpDir . '/.github/dependabot.yml';
79+
$dockerfileBefore = (string) file_get_contents($dockerfilePath);
80+
$dependabotBefore = (string) file_get_contents($dependabotPath);
81+
82+
$rotator = new SlotRotator($this->tmpDir, $this->registryPath);
83+
84+
$result = $rotator->rotate([
85+
'current' => [
86+
'minor' => '8.1',
87+
'full' => '8.1.0',
88+
'branch' => 'rel-810',
89+
'docker_dir' => '8.1.0',
90+
],
91+
]);
92+
93+
// Rotating current flips only the symlink + registry.
94+
self::assertContains('docker/openemr/current', $result->changedFiles);
95+
self::assertContains('tools/release/versions.yml', $result->changedFiles);
96+
self::assertNotContains('docker/openemr/8.0.0/Dockerfile', $result->changedFiles);
97+
self::assertNotContains('.github/dependabot.yml', $result->changedFiles);
98+
99+
self::assertSame('8.1.0', readlink($this->tmpDir . '/docker/openemr/current'));
100+
self::assertSame(
101+
$dockerfileBefore,
102+
(string) file_get_contents($dockerfilePath),
103+
'immutable per-version Dockerfile must be byte-for-byte unchanged',
104+
);
105+
self::assertSame(
106+
$dependabotBefore,
107+
(string) file_get_contents($dependabotPath),
108+
'dependabot config must be byte-for-byte unchanged',
109+
);
110+
}
111+
72112
public function testRotationRepointsSlotSymlinkAndLeavesOtherSlotsAlone(): void
73113
{
74114
$rotator = new SlotRotator($this->tmpDir, $this->registryPath);
@@ -197,6 +237,8 @@ public function testForwardRotationDoesNotCorruptOtherSlots(): void
197237
],
198238
]);
199239

240+
// Excluded per-version dirs must never be touched, regardless of which
241+
// slot rotates.
200242
$currentPath = $this->tmpDir . '/docker/openemr/8.0.0/Dockerfile';
201243
$current = (string) file_get_contents($currentPath);
202244
self::assertStringContainsString('--branch rel-800', $current, 'current slot Dockerfile must not change');
@@ -209,7 +251,8 @@ public function testForwardRotationDoesNotCorruptOtherSlots(): void
209251
public function testDryRunReturnsDiffWithoutWritingFiles(): void
210252
{
211253
$rotator = new SlotRotator($this->tmpDir, $this->registryPath);
212-
$before = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile');
254+
$batsPath = $this->tmpDir . '/.github/workflows/test-bats.yml';
255+
$before = (string) file_get_contents($batsPath);
213256

214257
$result = $rotator->rotate(
215258
[
@@ -224,9 +267,9 @@ public function testDryRunReturnsDiffWithoutWritingFiles(): void
224267
);
225268

226269
self::assertFalse($result->isNoOp());
227-
$after = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile');
270+
$after = (string) file_get_contents($batsPath);
228271
self::assertSame($before, $after, 'dry-run must not touch the file');
229-
self::assertArrayHasKey('docker/openemr/8.1.0/Dockerfile', $result->snapshots);
272+
self::assertArrayHasKey('.github/workflows/test-bats.yml', $result->snapshots);
230273
}
231274

232275
public function testUnknownSlotThrows(): void
@@ -239,7 +282,7 @@ public function testUnknownSlotThrows(): void
239282

240283
public function testMissingPinFileThrows(): void
241284
{
242-
unlink($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile');
285+
unlink($this->tmpDir . '/.github/workflows/test-bats.yml');
243286
$rotator = new SlotRotator($this->tmpDir, $this->registryPath);
244287

245288
$this->expectException(\RuntimeException::class);
@@ -258,8 +301,8 @@ public function testMissingPinFileThrows(): void
258301
public function testReplacementAvoidsPartialVersionMatches(): void
259302
{
260303
file_put_contents(
261-
$this->tmpDir . '/docker/openemr/8.1.0/Dockerfile',
262-
"FROM alpine:3.21\nARG OPENEMR_VERSION=rel-810\n# version-like-but-not: 8.1.10 should stay\n",
304+
$this->tmpDir . '/utilities/container_benchmarking/benchmark.sh',
305+
"#!/bin/sh\nIMAGE=openemr/openemr:8.1.0\nBRANCH=rel-810\n# version-like-but-not: 8.1.10 should stay\n",
263306
);
264307
$rotator = new SlotRotator($this->tmpDir, $this->registryPath);
265308

@@ -272,7 +315,7 @@ public function testReplacementAvoidsPartialVersionMatches(): void
272315
],
273316
]);
274317

275-
$after = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.1.0/Dockerfile');
318+
$after = (string) file_get_contents($this->tmpDir . '/utilities/container_benchmarking/benchmark.sh');
276319
self::assertStringContainsString('8.1.10', $after, '8.1 inside 8.1.10 must NOT be rewritten');
277320
self::assertStringContainsString('rel-820', $after);
278321
}
@@ -282,15 +325,15 @@ public function testRotationLeavesScriptdirShellcheckDirectiveIntact(): void
282325
$rotator = new SlotRotator($this->tmpDir, $this->registryPath);
283326

284327
$rotator->rotate([
285-
'current' => [
328+
'next' => [
286329
'minor' => '8.2',
287330
'full' => '8.2.0',
288331
'branch' => 'rel-820',
289332
'docker_dir' => '8.2.0',
290333
],
291334
]);
292335

293-
$script = (string) file_get_contents($this->tmpDir . '/docker/openemr/8.0.0/openemr.sh');
336+
$script = (string) file_get_contents($this->tmpDir . '/utilities/container_benchmarking/benchmark.sh');
294337
self::assertStringContainsString(
295338
'# shellcheck source=SCRIPTDIR/env.stub',
296339
$script,
@@ -302,7 +345,7 @@ public function testRotationLeavesScriptdirShellcheckDirectiveIntact(): void
302345
'rotation must never inject a version path into a shellcheck source directive',
303346
);
304347
self::assertStringContainsString(
305-
"echo 'init for docker/openemr/8.2.0'",
348+
"echo 'benchmarking docker/openemr/8.2.0'",
306349
$script,
307350
'sanity: the genuine rotating docker_dir token should have been rewritten',
308351
);
@@ -330,48 +373,71 @@ private function seedFixtures(): void
330373
branch: "master"
331374
docker_dir: "8.1.1"
332375
376+
# Only genuinely external files that track a slot are rotated. The
377+
# per-version build dirs and dependabot config are immutable; rotation
378+
# flips the docker/openemr/{current,next,dev} symlink, not their contents.
333379
files:
334-
- path: docker/openemr/8.0.0/Dockerfile
335-
slot: current
336-
kinds: [docker_clone_branch]
337-
- path: docker/openemr/8.0.0/openemr.sh
338-
slot: current
339-
kinds: [docker_dir_ref]
340-
- path: docker/openemr/8.1.0/Dockerfile
380+
- path: .github/workflows/test-bats.yml
381+
slot: next
382+
kinds: [bats_test_paths]
383+
- path: utilities/container_benchmarking/benchmark.sh
341384
slot: next
342-
kinds: [docker_arg_branch]
343-
- path: docker/openemr/8.1.1/Dockerfile
344-
slot: dev
345-
kinds: [docker_arg_branch]
346-
- path: docker/openemr/OVERVIEW.md
347-
slot: all
348-
kinds: [overview_table]
349-
350-
excludes: []
385+
kinds: [benchmarking_default]
386+
387+
excludes:
388+
- path: docker/openemr/8.0.0
389+
reason: "immutable per-version build dir; never rotated"
390+
- path: docker/openemr/8.1.0
391+
reason: "immutable per-version build dir; never rotated"
392+
- path: docker/openemr/8.1.1
393+
reason: "immutable per-version build dir; never rotated"
394+
- path: .github/dependabot.yml
395+
reason: "one entry per build dir; never rotated"
351396
YAML);
352397

398+
// External slot-tracking files (in `files:`, next-slotted). These follow
399+
// the next slot when it rotates.
353400
$this->writeFile(
354-
'docker/openemr/8.0.0/Dockerfile',
355-
"FROM alpine:3.21\nRUN git clone https://github.com/openemr/openemr.git --branch rel-800 --depth 1\n",
401+
'.github/workflows/test-bats.yml',
402+
"on:\n pull_request:\n paths:\n - 'docker/openemr/8.1.0/**'\n",
356403
);
357-
$this->writeFile('docker/openemr/8.1.0/Dockerfile', "FROM alpine:3.21\nARG OPENEMR_VERSION=rel-810\n");
358-
$this->writeFile('docker/openemr/8.1.1/Dockerfile', "FROM alpine:3.21\nARG OPENEMR_VERSION=master\n");
359404

360-
// In-container init script for the current slot. It carries a rotating
361-
// docker_dir token (the path in the echo line) alongside a
362-
// self-referential `SCRIPTDIR` shellcheck directive that must never be
363-
// rewritten.
405+
// A benchmarking-style script carrying a rotating docker_dir token (the
406+
// echo path) and a self-referential SCRIPTDIR shellcheck directive that
407+
// must never be rewritten.
364408
$this->writeFile(
365-
'docker/openemr/8.0.0/openemr.sh',
409+
'utilities/container_benchmarking/benchmark.sh',
366410
"#!/bin/sh\nset -e\n"
367411
. "# shellcheck source=SCRIPTDIR/env.stub\n. /root/env.stub\n"
368-
. "echo 'init for docker/openemr/8.0.0'\n",
412+
. "echo 'benchmarking docker/openemr/8.1.0'\n"
413+
. "IMAGE=openemr/openemr:8.1.0\n",
369414
);
370415

416+
// Immutable per-version build dirs (in `excludes:`). Seeded so tests can
417+
// assert rotation leaves them byte-for-byte unchanged.
371418
$this->writeFile(
372-
'docker/openemr/OVERVIEW.md',
373-
"| 8.0.0 | latest |\n| 8.1.0 | next |\n| 8.1.1 | dev |\n",
419+
'docker/openemr/8.0.0/Dockerfile',
420+
"FROM alpine:3.21\nRUN git clone https://github.com/openemr/openemr.git --branch rel-800 --depth 1\n",
374421
);
422+
$this->writeFile('docker/openemr/8.1.0/Dockerfile', "FROM alpine:3.21\nARG OPENEMR_VERSION=rel-810\n");
423+
$this->writeFile('docker/openemr/8.1.1/Dockerfile', "FROM alpine:3.21\nARG OPENEMR_VERSION=master\n");
424+
425+
// Dependabot config (in `excludes:`). One entry per build dir, added at
426+
// dir-scaffold time, never renamed by rotation.
427+
$this->writeFile('.github/dependabot.yml', <<<'YAML'
428+
version: 2
429+
updates:
430+
# Docker - docker/openemr/8.0.0
431+
- package-ecosystem: "docker"
432+
directory: "/docker/openemr/8.0.0"
433+
schedule:
434+
interval: "daily"
435+
# Docker - docker/openemr/8.1.0
436+
- package-ecosystem: "docker"
437+
directory: "/docker/openemr/8.1.0"
438+
schedule:
439+
interval: "daily"
440+
YAML);
375441

376442
// Slot symlinks: the source of truth the consolidated build workflow
377443
// resolves each slot's version from. Rotation re-points these (see

tools/release/versions.yml

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -54,33 +54,6 @@ slots:
5454
# container_func_test_paths — workflow path filter + script arg
5555
# benchmarking_default — utilities/container_benchmarking default variant
5656
files:
57-
- path: docker/openemr/8.0.0/Dockerfile
58-
slot: current
59-
kinds: [docker_arg_branch]
60-
61-
- path: docker/openemr/8.1.0/Dockerfile
62-
slot: next
63-
kinds: [docker_arg_branch]
64-
65-
- path: docker/openemr/8.1.1/Dockerfile
66-
slot: dev
67-
kinds: [docker_arg_branch]
68-
69-
- path: docker/openemr/8.0.0/README.md
70-
slot: current
71-
kinds: [docker_image_tag]
72-
# FIXME: line 33 currently reads `image: openemr/openemr:7.0.5` — a stale
73-
# copy-paste from before 8.0.0 was tagged. Rotator should overwrite to
74-
# match slot.full on next run; flagged in PR #665 for human confirmation.
75-
76-
- path: docker/openemr/8.1.0/README.md
77-
slot: next
78-
kinds: [docker_image_tag]
79-
80-
- path: docker/openemr/8.1.1/README.md
81-
slot: dev
82-
kinds: [docker_image_tag]
83-
8457
- path: .github/workflows/test-bats.yml
8558
slot: next
8659
kinds: [bats_test_paths]
@@ -105,12 +78,6 @@ files:
10578
slot: next
10679
kinds: [benchmarking_default]
10780

108-
# Dependabot enumerates each rotating Docker dir explicitly; rotation needs
109-
# to add/remove entries when slot dirs change.
110-
- path: .github/dependabot.yml
111-
slot: all
112-
kinds: [dependabot_directory]
113-
11481
# Container-benchmarking docs reference the next-slot version as defaults.
11582
- path: utilities/container_benchmarking/README.md
11683
slot: next
@@ -126,6 +93,22 @@ excludes:
12693
- path: docker/openemr/7.0.4
12794
reason: "legacy 7.0.4 support track (entire dir)"
12895

96+
# Dependabot enumerates one entry per build dir; entries are added at
97+
# dir-scaffold time and never renamed by rotation (rotation flips the
98+
# docker/openemr/{current,next,dev} symlink, not the per-dir config).
99+
- path: .github/dependabot.yml
100+
reason: "one entry per build dir, added at dir-scaffold time; never rotated"
101+
102+
# Immutable per-version build dirs (entire dir). Each is pinned to its release
103+
# branch and never rotated — slot rotation flips the
104+
# docker/openemr/{current,next,dev} symlink instead.
105+
- path: docker/openemr/8.0.0
106+
reason: "immutable per-version build dir; pinned to its release branch, never rotated — slot rotation flips the docker/openemr/{current,next,dev} symlink instead"
107+
- path: docker/openemr/8.1.0
108+
reason: "immutable per-version build dir; pinned to its release branch, never rotated — slot rotation flips the docker/openemr/{current,next,dev} symlink instead"
109+
- path: docker/openemr/8.1.1
110+
reason: "immutable per-version build dir; pinned to its release branch, never rotated — slot rotation flips the docker/openemr/{current,next,dev} symlink instead"
111+
129112
# Frozen historical builds.
130113
- path: docker/openemr/obsolete
131114
reason: "frozen historical builds; never rotate"

0 commit comments

Comments
 (0)