Skip to content

Commit 4905d52

Browse files
committed
Merge remote-tracking branch 'origin/master' into copilot/cleanup-tests-for-php-8-only
2 parents d9b47e8 + d786a2b commit 4905d52

13 files changed

Lines changed: 2565 additions & 31 deletions

File tree

‎.github/workflows/build.yaml‎

Lines changed: 2274 additions & 11 deletions
Large diffs are not rendered by default.

‎ci/src/Commands/GithubCommand.php‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,18 @@ protected function execute(InputInterface $input, OutputInterface $output)
5959
'schedule' => [
6060
['cron' => '0 0 * * *'], // every week on Tuesday
6161
],
62-
'push' => null,
62+
// Restricted to 'master' so a PR branch's commits are only
63+
// ever built once, via the 'pull_request' event below. A
64+
// broad `push: null` trigger (any branch) would otherwise
65+
// fire a second, fully duplicate run of the whole pipeline
66+
// (build, test, and the OCI artifact dance) for the exact
67+
// same commit as its corresponding pull_request run. Master
68+
// itself is never reached by 'pull_request' (it has no PR
69+
// targeting itself), so its push-triggered publish path is
70+
// unaffected.
71+
'push' => [
72+
'branches' => ['master'],
73+
],
6374
'pull_request' => [
6475
'branches' => ['master'],
6576
],
@@ -82,6 +93,14 @@ private function traverse(Node $node): void
8293
$line = 'Processing ' . $node->getName();
8394
$nodeAr = $node->toArray();
8495
$nodeAr['level'] = $node->getLevel();
96+
$nodeAr['hasChildren'] = $node->hasChildren();
97+
// BlueM\Tree\Node lowercases all property keys internally, so the
98+
// 'imageParent' key set by FileReader comes back as 'imageparent'.
99+
// Restore the expected casing here, once, for consumers like
100+
// GithubJobBuilder.
101+
$nodeAr['imageParent'] = $nodeAr['imageparent'] ?? 0;
102+
$nodeAr['imageParentRef'] = $nodeAr['imageparentref'] ?? 0;
103+
$nodeAr['imageDependencies'] = $nodeAr['imagedependencies'] ?? [];
85104
if ($node->getLevel() > $this->deepestLevel) {
86105
$this->deepestLevel = $node->getLevel();
87106
}

‎ci/src/FileReader.php‎

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ public function getInfo(string $dockerfilePath)
4242
'aliases' => [],
4343
'file' => $dockerfilePath,
4444
'parent' => 0,
45+
'imageParent' => 0,
46+
'imageParentRef' => null,
47+
'imageDependencies' => [],
4548
'serverspec' => [
4649
'DOCKER_IMAGE' => $id,
4750
'DOCKER_TAG' => $tagName,
@@ -57,16 +60,38 @@ public function getInfo(string $dockerfilePath)
5760
}
5861
}
5962
// Only internal images must be contained in build tree
60-
preg_match_all('/FROM (.*)/', $content, $fromMatches);
63+
preg_match_all('/^FROM\s+(\S+)(?:\s+AS\s+\S+)?\s*$/mi', $content, $fromMatches);
6164
$parentImage = array_pop($fromMatches[1]);
6265
if (strpos($parentImage, 'webdevops/') === 0) {
63-
if (str_ends_with($parentImage, ':latest')) {
64-
$parentImage = str_replace(':latest', ':' . $this->_settings['docker']['autoLatestTag'], $parentImage);
65-
}
66-
$node['parent'] = $parentImage;
66+
// Real Docker image inheritance: the Dockerfile's FROM references
67+
// another internal webdevops/* image. This is the only case where
68+
// 'imageParent' driven OCI artifact propagation (build-contexts
69+
// override) is valid. 'imageParentRef' keeps the *exact* literal
70+
// text used in the FROM statement (e.g. "webdevops/base:latest"),
71+
// because that is the key BuildKit matches against when a
72+
// `build-contexts` override is supplied; it must NOT be the
73+
// resolved/aliased image id, or the override silently fails to
74+
// apply and the build falls back to pulling the published image.
75+
$node['parent'] = $this->resolveInternalImageReference($parentImage);
76+
$node['imageParent'] = $node['parent'];
77+
$node['imageParentRef'] = $parentImage;
6778
} else if ($node['id'] !== 'webdevops/toolbox:latest') {
79+
// Synthetic scheduling dependency only (e.g. to serialize CI
80+
// against the Toolbox job). The Dockerfile does NOT actually
81+
// build FROM this image, so it must never drive OCI artifact
82+
// download/upload or build-contexts overrides.
6883
$node['parent'] = 'webdevops/toolbox:latest';
6984
}
85+
// Additional internal image dependencies referenced via
86+
// `COPY --from=webdevops/...` (not a FROM parent). BuildKit's named
87+
// build-context override mechanism applies identically to
88+
// `COPY --from=<name>` references, so these need the exact same OCI
89+
// artifact propagation as a real FROM parent, otherwise the copied
90+
// files still come from the published registry image.
91+
preg_match_all('/^COPY\s+--from=(webdevops\/\S+)/m', $content, $copyFromMatches);
92+
foreach (array_unique($copyFromMatches[1]) as $dependencyRef) {
93+
$node['imageDependencies'][$dependencyRef] = $this->resolveInternalImageReference($dependencyRef);
94+
}
7095
// Treat *-official images
7196
if (strpos($id, '-official:') !== false) {
7297
$node['aliases'][] = $id;
@@ -79,4 +104,41 @@ public function getInfo(string $dockerfilePath)
79104
return $node;
80105
}
81106

107+
/**
108+
* Resolve a literal internal `webdevops/<image>[:<tag>]` reference, as it
109+
* appears in a `FROM` or `COPY --from=` statement, to the exact image id
110+
* that is actually built by this repository's CI pipeline (i.e. the id
111+
* backed by a real `docker/<image>/<tag>/Dockerfile`).
112+
*
113+
* This is the single source of truth for resolving a `:latest` (or
114+
* untagged, which Docker treats identically) reference: most images do
115+
* not have a literal "latest" subdirectory, so `:latest` is only a
116+
* published alias for whichever folder `autoLatestTag` points to, and a
117+
* reference like "webdevops/base:latest" must resolve to
118+
* "webdevops/base:ubuntu-22.04" to match the job that actually builds
119+
* and exports it. A few images (e.g. toolbox, ssh, vsftp) *do* have a
120+
* literal "latest" subdirectory and must be left untouched. Checking the
121+
* filesystem directly, rather than assuming the "latest" alias
122+
* substitution always applies, keeps this correct for both cases.
123+
*/
124+
private function resolveInternalImageReference(string $reference): string
125+
{
126+
$imageAndTag = substr($reference, strlen('webdevops/'));
127+
if (strpos($imageAndTag, ':') !== false) {
128+
[$image, $tag] = explode(':', $imageAndTag, 2);
129+
} else {
130+
// `COPY --from=webdevops/toolbox` has no explicit tag; Docker
131+
// treats an untagged reference as `:latest`.
132+
$image = $imageAndTag;
133+
$tag = 'latest';
134+
}
135+
$dockerfileExists = fn (string $tag): bool => file_exists(
136+
__DIR__ . '/../../docker/' . $image . '/' . $tag . '/Dockerfile',
137+
);
138+
if ($tag === 'latest' && !$dockerfileExists($tag) && $dockerfileExists($this->_settings['docker']['autoLatestTag'])) {
139+
$tag = $this->_settings['docker']['autoLatestTag'];
140+
}
141+
return 'webdevops/' . $image . ':' . $tag;
142+
}
143+
82144
}

‎ci/src/GithubJobBuilder.php‎

Lines changed: 176 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010

1111
class GithubJobBuilder
1212
{
13+
/**
14+
* Tag/annotation used when exporting a parent image as an OCI layout so
15+
* that child jobs can deterministically reference the exported manifest
16+
* instead of relying on Buildx picking an arbitrary entry from index.json.
17+
*/
18+
private const PARENT_OCI_TAG = 'ci-parent-image';
19+
1320
/**
1421
* @return array<string, array<string, mixed>>
1522
*/
@@ -19,7 +26,12 @@ public function getJobsDescription(array $node): array
1926
$structuredTests = $this->structuredTests($node);
2027

2128
$jobId = GithubJobBuilder::toJobId($node['name']);
22-
$needs = ($node['parent'] ?? null) ? GithubJobBuilder::toJobId($node['parent']) . '_publish' : 'validate-automation';
29+
$hasParent = (bool)($node['parent'] ?? null);
30+
$imageDependencies = $this->imageDependencies($node);
31+
$hasImageDependencies = !empty($imageDependencies);
32+
$hasChildren = !empty($node['hasChildren']);
33+
$parentJobId = $hasParent ? GithubJobBuilder::toJobId($node['parent']) : null;
34+
$needs = $hasParent ? $parentJobId . '_publish' : 'validate-automation';
2335

2436
$pushTags = [];
2537
$pushTags[] = '-t "' . $node['id'] . '"';
@@ -58,21 +70,22 @@ public function getJobsDescription(array $node): array
5870
[
5971
['uses' => 'actions/checkout@v6'],
6072
['uses' => 'docker/setup-buildx-action@v3'],
61-
[
73+
...$this->downloadParentImageSteps($imageDependencies),
74+
array_filter([
6275
'name' => 'Build (load locally)',
76+
'if' => $hasImageDependencies ? '${{ github.ref == \'refs/heads/master\' }}' : null,
6377
'uses' => 'docker/build-push-action@v6',
64-
'with' => [
65-
'context' => dirname(str_replace(__DIR__ . '/../../', '', $node['file'])),
66-
'platforms' => '${{ matrix.platform }}',
67-
'load' => true,
68-
'tags' => 'ghcr.io/webdevops/' . $node['image'] . ':sha-${{ github.sha }}-${{ matrix.arch }}-' . $node['tag'],
69-
'cache-from' => 'type=gha',
70-
'cache-to' => 'type=gha,mode=max',
71-
'build-args' => implode("\n", [
72-
'TARGETARCH=${{ matrix.arch }}',
73-
]),
74-
],
75-
],
78+
'with' => $this->buildPushWith($node),
79+
], fn ($value): bool => $value !== null),
80+
$hasImageDependencies ? [
81+
'name' => 'Build (load locally, from parent artifact)',
82+
'if' => '${{ github.ref != \'refs/heads/master\' }}',
83+
'uses' => 'docker/build-push-action@v6',
84+
'with' => array_merge(
85+
$this->buildPushWith($node),
86+
['build-contexts' => $this->buildContexts($imageDependencies)],
87+
),
88+
] : null,
7689
$serverSpec ? [
7790
'name' => 'run serverspec',
7891
'run' => implode("\n", $serverSpec),
@@ -96,6 +109,41 @@ public function getJobsDescription(array $node): array
96109
'if' => '${{github.ref == \'refs/heads/master\'}}',
97110
'run' => 'docker push "ghcr.io/webdevops/' . $node['image'] . ':sha-${{ github.sha }}-${{ matrix.arch }}"-' . $node['tag'],
98111
],
112+
$hasChildren ? [
113+
'name' => 'Export image (OCI layout)',
114+
'if' => '${{ github.ref != \'refs/heads/master\' }}',
115+
'uses' => 'docker/build-push-action@v6',
116+
'with' => array_merge(
117+
[
118+
'context' => dirname(str_replace(__DIR__ . '/../../', '', $node['file'])),
119+
'platforms' => '${{ matrix.platform }}',
120+
'cache-from' => 'type=gha',
121+
'cache-to' => 'type=gha,mode=max',
122+
'build-args' => implode("\n", [
123+
'TARGETARCH=${{ matrix.arch }}',
124+
]),
125+
],
126+
// Must use the exact same build-contexts override as the
127+
// tested build above, otherwise this second Buildx invocation
128+
// silently re-resolves FROM/COPY --from references against the
129+
// published registry images and the exported artifact no
130+
// longer reflects the tested result.
131+
$hasImageDependencies ? ['build-contexts' => $this->buildContexts($imageDependencies)] : [],
132+
['outputs' => 'type=oci,tar=false,name=' . self::PARENT_OCI_TAG . ',dest=' . $this->getCiImagePath($node['id'])],
133+
),
134+
] : null,
135+
$hasChildren ? [
136+
'name' => 'Upload image (OCI layout)',
137+
'if' => '${{ github.ref != \'refs/heads/master\' }}',
138+
'uses' => 'actions/upload-artifact@v4',
139+
'with' => [
140+
'name' => $this->getCiImageArtifactName($node['id']),
141+
'path' => $this->getCiImagePath($node['id']),
142+
'retention-days' => 1,
143+
'compression-level' => 0,
144+
'if-no-files-found' => 'error',
145+
],
146+
] : null,
99147
],
100148
),
101149
),
@@ -156,6 +204,120 @@ public static function toJobId(string $name): string
156204
return $name;
157205
}
158206

207+
/**
208+
* Common `docker/build-push-action` inputs shared by the local build step
209+
* and the additional per-node variants (parent-context build, OCI export).
210+
*/
211+
private function buildPushWith(array $node): array
212+
{
213+
return [
214+
'context' => dirname(str_replace(__DIR__ . '/../../', '', $node['file'])),
215+
'platforms' => '${{ matrix.platform }}',
216+
'load' => true,
217+
'tags' => 'ghcr.io/webdevops/' . $node['image'] . ':sha-${{ github.sha }}-${{ matrix.arch }}-' . $node['tag'],
218+
'cache-from' => 'type=gha',
219+
'cache-to' => 'type=gha,mode=max',
220+
'build-args' => implode("\n", [
221+
'TARGETARCH=${{ matrix.arch }}',
222+
]),
223+
];
224+
}
225+
226+
/**
227+
* All internal webdevops/* images this node needs a same-run OCI
228+
* artifact override for: its real FROM parent (if any) plus any images
229+
* referenced via `COPY --from=webdevops/...`. Keyed by the *literal*
230+
* reference text as written in the Dockerfile (the key a Buildx
231+
* `build-contexts` override must match exactly), valued by the resolved
232+
* image id that identifies the job/artifact actually producing it.
233+
*
234+
* `node['parent']` is NOT used here on its own: it may be a purely
235+
* synthetic scheduling dependency (e.g. on the Toolbox job for images
236+
* whose Dockerfile does not actually FROM/COPY an internal image), which
237+
* must never drive OCI artifact propagation.
238+
*/
239+
private function imageDependencies(array $node): array
240+
{
241+
$dependencies = [];
242+
if (!empty($node['imageParent'])) {
243+
$literalRef = $node['imageParentRef'] ?: $node['imageParent'];
244+
$dependencies[$literalRef] = $node['imageParent'];
245+
}
246+
foreach ($node['imageDependencies'] ?? [] as $literalRef => $resolvedImage) {
247+
$dependencies[$literalRef] = $resolvedImage;
248+
}
249+
return $dependencies;
250+
}
251+
252+
/**
253+
* One "Download parent image (OCI layout)" step per distinct image id
254+
* referenced in $imageDependencies, deduplicated so the same artifact is
255+
* never downloaded twice (e.g. if an image happens to be both the FROM
256+
* parent and a COPY --from target).
257+
*
258+
* @return array<int, array<string, mixed>>
259+
*/
260+
private function downloadParentImageSteps(array $imageDependencies): array
261+
{
262+
$imageIds = array_unique(array_values($imageDependencies));
263+
return array_map(fn (string $imageId): array => [
264+
'name' => 'Download parent image (OCI layout): ' . $imageId,
265+
'if' => '${{ github.ref != \'refs/heads/master\' }}',
266+
'uses' => 'actions/download-artifact@v4.1.9',
267+
'with' => [
268+
'name' => $this->getCiImageArtifactName($imageId),
269+
'path' => $this->getCiImagePath($imageId),
270+
],
271+
], $imageIds);
272+
}
273+
274+
/**
275+
* Deterministic, filesystem/artifact-safe name for the image identified
276+
* by $imageId (e.g. "webdevops/php:8.4"), unique per architecture so
277+
* amd64/arm64 artifacts can never collide or be cross-consumed.
278+
*/
279+
private function getCiImageArtifactName(string $imageId): string
280+
{
281+
return 'docker-parent-' . GithubJobBuilder::toJobId($imageId) . '-${{ matrix.arch }}';
282+
}
283+
284+
/**
285+
* Predictable, collision-free extraction/export directory for the OCI
286+
* layout of the image identified by $imageId.
287+
*
288+
* Intentionally a path relative to the job's working directory (the
289+
* checked-out repository) rather than an absolute `${{ runner.temp }}`
290+
* path: every job in this workflow runs inside a `container:`, and
291+
* `${{ runner.temp }}` is evaluated by the Actions runner against the
292+
* *host* filesystem, which is only bind-mounted into the container under
293+
* `/__w/_temp`, not under the literal host path. A relative path is
294+
* resolved consistently by every step (checkout, Buildx, up-/download-artifact)
295+
* against the same container working directory, avoiding that mismatch.
296+
*/
297+
private function getCiImagePath(string $imageId): string
298+
{
299+
return '.ci-oci-image/' . GithubJobBuilder::toJobId($imageId);
300+
}
301+
302+
/**
303+
* Buildx `build-contexts` value mapping every literal FROM/COPY --from
304+
* reference in $imageDependencies to the OCI layout downloaded from the
305+
* corresponding parent job's artifact, so BuildKit never needs to pull
306+
* any of those images from Docker Hub during a non-master build. Must
307+
* be reused unchanged for every Buildx invocation of this node (tested
308+
* build and OCI export alike), otherwise a later invocation silently
309+
* resolves a dependency from the registry again.
310+
*/
311+
private function buildContexts(array $imageDependencies): string
312+
{
313+
$lines = [];
314+
foreach ($imageDependencies as $literalRef => $imageId) {
315+
$lines[] = $literalRef . '=oci-layout://' . $this->getCiImagePath($imageId) . ':' . self::PARENT_OCI_TAG;
316+
}
317+
return implode("\n", $lines);
318+
}
319+
320+
159321
private function serverSpec(array $node): array
160322
{
161323
$specFile = sprintf('spec/docker/%s_spec.rb', $node['image']);

‎docker/php-official/8.1/Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ RUN set -x \
197197
# Uninstall dev and header packages
198198
&& apt-get purge -y -f --force-yes \
199199
libc-client-dev \
200+
libc-client2007e-dev \
200201
libkrb5-dev \
201202
libbz2-dev \
202203
libavif-dev \

‎docker/php-official/8.2/Dockerfile‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ RUN set -x \
106106
libpng16-16 \
107107
libmcrypt4 \
108108
libzip4 \
109+
libc-client2007e \
109110
# Dev and headers
110111
libavif-dev \
111112
libjpeg62-turbo-dev \
@@ -199,6 +200,7 @@ RUN set -x \
199200
# Uninstall dev and header packages
200201
&& apt-get purge -y -f --force-yes \
201202
libc-client-dev \
203+
libc-client2007e-dev \
202204
libkrb5-dev \
203205
libbz2-dev \
204206
libavif-dev \

‎docker/php-official/8.3/Dockerfile‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ RUN set -x \
106106
libpng16-16 \
107107
libmcrypt4 \
108108
libzip4 \
109+
libc-client2007e \
109110
# Dev and headers
110111
libavif-dev \
111112
libjpeg62-turbo-dev \
@@ -199,6 +200,7 @@ RUN set -x \
199200
# Uninstall dev and header packages
200201
&& apt-get purge -y -f --force-yes \
201202
libc-client-dev \
203+
libc-client2007e-dev \
202204
libkrb5-dev \
203205
libbz2-dev \
204206
libavif-dev \

0 commit comments

Comments
 (0)