You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
base.alpine/files/init.sh performs every piece of runtime customisation the app image offers and it is the default entrypoint (base.alpine/Dockerfile:33), so it runs for every downstream image that keeps that entrypoint rather than overriding it or using /init-root.sh. Nothing verifies any of it. On a pull request the workflow builds the three images and stops there (.github/workflows/docker.yml:56-63: docker/build-push-action with push: false, and no later step consumes the result); on a push to master it builds and publishes app:master, and a tag publishes app:latest. A change that keeps the script syntactically valid but functionally wrong therefore reaches the registry with every check green.
That is not hypothetical. On current master (42d85b8) an invalid TIME_ZONE is announced as applied:
$ docker build -t baseimage-app base.alpine
$ docker run --rm -e TIME_ZONE=Nope/Nope baseimage-app id
init container
cp: can't stat '/usr/share/zoneinfo/Nope/Nope': No such file or directory
set timezone Nope/Nope (Thu Aug 20 02:10:16 CDT 2026)
custom APP_UID not defined, using default uid=1001
custom DOCKER_GID not defined, using default gid=999
execute id
uid=1001(app) gid=1001(app) groups=999(docker),1001(app)
cp fails at base.alpine/files/init.sh:9, the script has no set -e, and base.alpine/files/init.sh:11 reports the timezone as set while the container keeps the one it already had. #51 fixes that case; the gap that let it reach master unnoticed stays open, and the same gap covers APP_UID, DOCKER_GID, the two chowns and /srv/init.sh.
Impact
Every service built on the app image with the default entrypoint inherits this script. A silent regression changes the uid the container runs as, whether app ends up in a group carrying the mounted socket's GID (the docker group when the GID is free, the group already holding it otherwise, base.alpine/files/init.sh:22-48), or the ownership of /srv and /home/app. The first report then comes from a downstream project rather than from CI.
Proposed test
Save the script below as base.alpine/test.sh. It takes the image name as its argument and builds one throwaway derived image with root-owned files, so the chowns are checked by their effect rather than by the log:
#!/bin/bash# smoke test for the app image, pass the image name as the first argument
image="${1:-umputun/baseimage:app-latest}"
derived="baseimage-smoketest-$$"
failed=0
tmpdir=$(mktemp -d)trap'rm -rf "${tmpdir}"; docker rmi -f "${derived}" >/dev/null 2>&1' EXIT
expect_out() { # name, expected substring, actual outputif [[ "$3"==*"$2"* ]];thenecho"ok - $1";elseecho"FAIL - $1: '$2' not found in:"printf' %s\n'"$3"
failed=1
fi
}
expect_no_out() { # name, unexpected substring, actual outputif [[ "$3"!=*"$2"* ]];thenecho"ok - $1";elseecho"FAIL - $1: '$2' unexpectedly present in:"printf' %s\n'"$3"
failed=1
fi
}
expect_eq() { # name, expected output, actual outputif [[ "$3"=="$2" ]];thenecho"ok - $1";elseecho"FAIL - $1: expected '$2', got '$3'"
failed=1
fi
}
expect_code() { # name, expected code, actual codeif [[ "$3"=="$2" ]];thenecho"ok - $1";elseecho"FAIL - $1: expected exit $2, got $3"
failed=1
fi
}
# image with root-owned files in the directories init.sh is expected to chownprintf'FROM %s\nUSER root\nRUN touch /srv/root-file /home/app/root-file && chown root:root /srv/root-file /home/app/root-file\n'"${image}"|
docker build -q -t "${derived}" - >/dev/null || {
echo"FAIL - cannot build the helper image from ${image}"exit 1
}
expect_out "runs as app user""uid=1001(app) gid=1001(app)" \
"$(docker run --rm "${image}" id 2>&1)"
expect_eq "applies TIME_ZONE""JST" \
"$(docker run --rm -e INIT_QUIET=1 -e TIME_ZONE=Asia/Tokyo "${image}" date +%Z 2>&1)"
expect_eq "records TIME_ZONE in /etc/timezone""Asia/Tokyo" \
"$(docker run --rm -e INIT_QUIET=1 -e TIME_ZONE=Asia/Tokyo "${image}" cat /etc/timezone 2>&1)"
expect_out "applies APP_UID""uid=2000(app) gid=2000(app)" \
"$(docker run --rm -e APP_UID=2000 "${image}" id 2>&1)"
expect_out "applies DOCKER_GID""1500(docker)" \
"$(docker run --rm -e DOCKER_GID=1500 "${image}" id 2>&1)"
expect_out "joins the group already holding DOCKER_GID""998(ping)" \
"$(docker run --rm -e DOCKER_GID=998 "${image}" id 2>&1)"
expect_eq "chowns /srv to app""app:app" \
"$(docker run --rm -e INIT_QUIET=1 "${derived}" stat -c '%U:%G' /srv/root-file 2>&1)"
expect_eq "chowns /home/app to app""app:app" \
"$(docker run --rm -e INIT_QUIET=1 "${derived}" stat -c '%U:%G' /home/app/root-file 2>&1)"
expect_eq "SKIP_HOME_CHOWN leaves /home/app alone""root:root" \
"$(docker run --rm -e INIT_QUIET=1 -e SKIP_HOME_CHOWN=1 "${derived}" stat -c '%U:%G' /home/app/root-file 2>&1)"
expect_eq "SKIP_HOME_CHOWN still chowns /srv""app:app" \
"$(docker run --rm -e INIT_QUIET=1 -e SKIP_HOME_CHOWN=1 "${derived}" stat -c '%U:%G' /srv/root-file 2>&1)"printf'#!/bin/sh\necho "srv init ran"\n'>"${tmpdir}/init-ok.sh"
expect_out "runs /srv/init.sh""srv init ran" \
"$(docker run --rm -v "${tmpdir}/init-ok.sh:/srv/init.sh""${image}" id 2>&1)"printf'#!/bin/sh\nexit 3\n'>"${tmpdir}/init-fail.sh"
docker run --rm -v "${tmpdir}/init-fail.sh:/srv/init.sh""${image}" id >/dev/null 2>&1
expect_code "stops when /srv/init.sh fails""1""$?"
quiet_out=$(docker run --rm -e INIT_QUIET=1 "${image}" id 2>&1)
expect_no_out "INIT_QUIET suppresses informational output""init container""${quiet_out}"
expect_out "INIT_QUIET still runs the command""uid=1001(app)""${quiet_out}"
bad_tz=$(docker run --rm -e TIME_ZONE=Nope/Nope "${image}" id 2>&1)
expect_out "reports an invalid TIME_ZONE""error:""${bad_tz}"
expect_out "starts anyway with an invalid TIME_ZONE""uid=1001(app)""${bad_tz}"echoif [[ ${failed}-eq 0 ]];thenecho"all checks passed";elseecho"some checks failed";fiexit${failed}
master with the chown -R app:app /srv line and the SKIP_HOME_CHOWN block removed from init.sh: chowns /srv to app, chowns /home/app to app and SKIP_HOME_CHOWN still chowns /srv fail on top of the timezone one, so the suite does catch a silently dropped step
A full run takes about 40 seconds, most of it container startup and the one helper image build. An image built with docker buildx build --platform linux/arm64 --load behaves the same as one built by plain docker build, so the wiring below has the image available to the runner.
Wiring it into CI
Option 1, run it in the existing build job. Add load: true and a tag to the PR validation step, then run the script for the app image:
Spliced into the current workflow it adds no new actionlint findings. No new job, and it covers both amd64 and arm64 because each matrix leg builds for its own runner. The trade-off is that it only guards pull requests: a push to master builds with push: true and never loads the image, so the script would not run there.
Option 2, a separate test job. One job on ubuntu-latest that builds the app image for its own platform and runs the script, on pushes as well as pull requests. The build matrix stays untouched, at the cost of an extra job and an extra image build per run, and amd64-only coverage unless that job is itself a matrix.
Option 3, leave it as is and keep relying on downstream projects to notice.
I would take option 1: the images are already built there, 40 seconds is negligible next to the build itself, and pull requests are where changes to the script arrive. A test_app target in the Makefile would make the same script the local check.
base.alpine/files/init.shperforms every piece of runtime customisation theappimage offers and it is the default entrypoint (base.alpine/Dockerfile:33), so it runs for every downstream image that keeps that entrypoint rather than overriding it or using/init-root.sh. Nothing verifies any of it. On a pull request the workflow builds the three images and stops there (.github/workflows/docker.yml:56-63:docker/build-push-actionwithpush: false, and no later step consumes the result); on a push tomasterit builds and publishesapp:master, and a tag publishesapp:latest. A change that keeps the script syntactically valid but functionally wrong therefore reaches the registry with every check green.That is not hypothetical. On current master (42d85b8) an invalid
TIME_ZONEis announced as applied:cpfails atbase.alpine/files/init.sh:9, the script has noset -e, andbase.alpine/files/init.sh:11reports the timezone as set while the container keeps the one it already had. #51 fixes that case; the gap that let it reach master unnoticed stays open, and the same gap coversAPP_UID,DOCKER_GID, the two chowns and/srv/init.sh.Impact
Every service built on the
appimage with the default entrypoint inherits this script. A silent regression changes the uid the container runs as, whetherappends up in a group carrying the mounted socket's GID (thedockergroup when the GID is free, the group already holding it otherwise,base.alpine/files/init.sh:22-48), or the ownership of/srvand/home/app. The first report then comes from a downstream project rather than from CI.Proposed test
Save the script below as
base.alpine/test.sh. It takes the image name as its argument and builds one throwaway derived image with root-owned files, so the chowns are checked by their effect rather than by the log:Run it against a locally built image:
Results on my machine (Apple Silicon, Docker 29.7.2):
reports an invalid TIME_ZONEfails, which is the defect quoted abovechown -R app:app /srvline and theSKIP_HOME_CHOWNblock removed frominit.sh:chowns /srv to app,chowns /home/app to appandSKIP_HOME_CHOWN still chowns /srvfail on top of the timezone one, so the suite does catch a silently dropped stepA full run takes about 40 seconds, most of it container startup and the one helper image build. An image built with
docker buildx build --platform linux/arm64 --loadbehaves the same as one built by plaindocker build, so the wiring below has the image available to the runner.Wiring it into CI
Option 1, run it in the existing build job. Add
load: trueand a tag to the PR validation step, then run the script for theappimage:Spliced into the current workflow it adds no new
actionlintfindings. No new job, and it covers both amd64 and arm64 because each matrix leg builds for its own runner. The trade-off is that it only guards pull requests: a push tomasterbuilds withpush: trueand never loads the image, so the script would not run there.Option 2, a separate
testjob. One job onubuntu-latestthat builds the app image for its own platform and runs the script, on pushes as well as pull requests. The build matrix stays untouched, at the cost of an extra job and an extra image build per run, and amd64-only coverage unless that job is itself a matrix.Option 3, leave it as is and keep relying on downstream projects to notice.
I would take option 1: the images are already built there, 40 seconds is negligible next to the build itself, and pull requests are where changes to the script arrive. A
test_apptarget in the Makefile would make the same script the local check.