1010
1111class 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 ' ]);
0 commit comments