Skip to content

Commit 5c600d3

Browse files
committed
docs(ci-testing): two patterns from a published-the-wrong-image incident
Both come from the same afternoon in netresearch/phpmyadmin-docker-compose-stack. Pattern 7 — a build step carried no `target:`, which had been harmless while the Dockerfile had one final stage. Appending an nginx stage after the runtime silently redirected that build to it, and php-fpm:latest was published as nginx. The smoke test that would have caught it lives in a separate job, and a pull-request build never leaves its runner, so it only runs on the default branch - after publishing. The pattern therefore pairs the explicit target with an identity assertion in the job that builds the image, and insists the assertion be run against both images so it is known to be able to fail. Pattern 8 — a gate compared scan results with `grep -Fxv -f`, on the reasoning that an empty pattern file matches nothing and every finding therefore counts as new. That holds for GNU grep on the host and is inverted in busybox, which the Alpine-based CI image ships: the gate silently passed instead. The empty baseline is the normal state whenever the reference scan is clean, so this was the likely case rather than an edge one. Pattern 7 opens by distinguishing itself from Pattern 5: both say "target" and mean different things - a bake target inheriting metadata there, the Dockerfile stage a build selects here. SKILL.md is untouched: it sits exactly on the 500-word cap. Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
1 parent 148629f commit 5c600d3

1 file changed

Lines changed: 121 additions & 0 deletions

File tree

skills/docker-development/references/ci-testing.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,124 @@ When smoke/boot-testing an image by hand (not in the CI matrix):
178178
- **Foreground apps that log to a file leave `docker logs` empty.** E.g. Tomcat started with `-fg` writes to `logs/catalina.out`, not stdout — an empty `docker logs` does *not* mean "nothing happened". Read the in-container log files (`docker exec <c> sh -c 'tail -n 80 .../catalina.out'`), and check the process and state (`docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' <c>`).
179179
- **Minimal/distroless images have no shell.** `docker exec … sh`/`tail`/`pgrep` won't exist on `scratch`/distroless runtimes — probe with host-side `curl` against a published port, `docker inspect` for state, or a debug sidecar (`docker run --rm --pid container:<c> busybox …`).
180180
- **Grep for real failure signals, not benign noise.** After a bundled-dependency swap, scan logs for `NoSuchMethodError|AbstractMethodError|LinkageError|IncompatibleClassChangeError` (binary incompatibility) — not bare `ClassNotFoundException`, which OSGi/plugin frameworks emit normally.
181+
182+
## Pattern 7: A build without `target:` builds whatever stage comes last
183+
184+
Not to be confused with Pattern 5: that `target` is a bake target inheriting
185+
metadata, this one is the Dockerfile stage a build selects. Same word, different
186+
thing, and reading one does not cover the other.
187+
188+
### Problem
189+
190+
A repository publishes one image from a multi-stage Dockerfile. The build step
191+
names no target, because there was only ever one final stage:
192+
193+
```yaml
194+
- uses: docker/build-push-action@…
195+
with:
196+
context: .
197+
tags: ${{ steps.meta.outputs.tags }} # no target:
198+
```
199+
200+
Later a second image is added — an nginx to front the php-fpm one — as a stage
201+
appended after the existing runtime. Nothing in the original build changed, yet
202+
it now publishes the *new* stage under the *old* name: Docker builds the last
203+
stage in the file when no target is given.
204+
205+
The failure is silent at build time and loud much later. `php-fpm:latest` was an
206+
nginx image for an hour; the first symptom was a sidecar dying on
207+
`exec: "/bin/bash": no such file or directory`.
208+
209+
### Fix
210+
211+
Name the target explicitly in every build, including the one that was there
212+
first:
213+
214+
```yaml
215+
- uses: docker/build-push-action@…
216+
with:
217+
context: .
218+
target: runtime # not "whatever is last"
219+
```
220+
221+
Then assert what was actually built, in the job that builds it. A smoke test in
222+
a separate job cannot help here: on a pull request the image never leaves the
223+
build runner, so that job only runs on the default branch — after publishing.
224+
225+
```yaml
226+
- name: The image is php-fpm, not the web stage
227+
if: github.event_name == 'pull_request'
228+
env:
229+
TAGS: ${{ steps.meta.outputs.tags }}
230+
run: |
231+
set -euo pipefail
232+
tag="$(printf '%s\n' "$TAGS" | head -n1)"
233+
docker run --rm --entrypoint sh "$tag" -c \
234+
'command -v php-fpm >/dev/null && ! command -v nginx >/dev/null'
235+
```
236+
237+
Prove the assertion by running it against *both* images: each must accept its
238+
own and reject the other. An assertion that only ever sees the correct image
239+
has not been tested.
240+
241+
### Also check `.dockerignore`
242+
243+
A stage that copies from the build context needs that path allowed. A
244+
deny-everything file is common and correct:
245+
246+
```
247+
*
248+
!rootfs/
249+
!config/nginx/ # the web stage copies this
250+
```
251+
252+
Adding a stage means revisiting it — otherwise the build fails on
253+
`failed to compute cache key: "/config/nginx": not found`, which reads like a
254+
missing file rather than an excluded one.
255+
256+
## Pattern 8: Verify shell semantics inside the target image, not on the host
257+
258+
### Problem
259+
260+
A CI script compares two scan results and blocks on what a build adds:
261+
262+
```sh
263+
grep -Fxv -f deployed.txt built.txt > added.txt || true
264+
if [ -s added.txt ]; then exit 1; fi
265+
```
266+
267+
The reasoning was: an empty `deployed.txt` matches nothing, so `-v` prints every
268+
line and all findings count as new — the safe direction. Verified on the
269+
developer machine, where it holds.
270+
271+
The image is Alpine-based and ships **busybox grep**, which does the opposite: an
272+
empty pattern file matches everything, `-v` prints nothing, `added.txt` comes out
273+
empty and the gate passes. And the empty baseline is not an edge case — it is the
274+
normal state whenever the reference scan is clean.
275+
276+
### Fix
277+
278+
Handle the case explicitly rather than relying on a semantic that differs
279+
between implementations:
280+
281+
```sh
282+
if [ ! -s deployed.txt ]; then
283+
cp built.txt added.txt
284+
else
285+
grep -Fxv -f deployed.txt built.txt > added.txt || true
286+
fi
287+
```
288+
289+
### The general rule
290+
291+
Any assumption about `grep`, `sed`, `awk`, `sort` or `printf` behaviour that a
292+
CI script depends on has to be checked in the image that will run it:
293+
294+
```sh
295+
docker run --rm --entrypoint sh <the-ci-image> -c '<the exact expression>'
296+
```
297+
298+
GNU coreutils on the host and busybox in an Alpine image disagree on more than
299+
this one case. A local check that passes proves the host's semantics, not the
300+
container's — and the difference surfaces as a gate that silently waves things
301+
through, which is the direction nobody notices.

0 commit comments

Comments
 (0)