From 4807b129e9b1e255c621a958590b1aa78c0f7d6d Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:57:49 +0000 Subject: [PATCH 1/2] Reuse the accepted central Bookworm APT preflight --- .devcontainer/docker/Dockerfile | 14 ++- .devcontainer/docker/README.md | 29 ++++++ .dockerignore | 1 + .github/workflows/devcontainer-image-pr.yml | 11 +++ .github/workflows/devcontainer-image.yml | 11 +++ .gitignore | 1 + tests/Unit/BrotliImageContractTest.php | 2 +- tests/Unit/DevcontainerImageContractTest.php | 25 ++++++ tests/ci/test_bookworm_consumer.py | 95 ++++++++++++++++++++ 9 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 .devcontainer/docker/README.md create mode 100644 tests/ci/test_bookworm_consumer.py diff --git a/.devcontainer/docker/Dockerfile b/.devcontainer/docker/Dockerfile index 11d349d3..ce8b1764 100644 --- a/.devcontainer/docker/Dockerfile +++ b/.devcontainer/docker/Dockerfile @@ -2,12 +2,14 @@ FROM node:22.23.4-bookworm-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e20 FROM composer:2.11.0@sha256:278dfc7bd35169965d03f87194816f43bf4e6b6ea61707c2114e52ad12fe24ce AS composer -FROM php:8.4.27-cli-bookworm@sha256:54b467ede1db96c66675e7b78457dd43729d0e7be31978d4a1e5c069ceabcf76 AS brotli-build +FROM php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe AS brotli-build +COPY .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh COPY --from=composer /usr/bin/composer /usr/local/bin/composer COPY .devcontainer/docker/verify-php-trust.php /usr/local/share/sample-app/verify-php-trust.php COPY docker/brotli/version docker/brotli/install-vendor.sh docker/brotli/verify-runtime.php docker/brotli/verify-system-brotli.c /usr/src/brotli-inputs/ -RUN php /usr/local/share/sample-app/verify-php-trust.php /usr/local/bin/composer \ +RUN /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh \ + && php /usr/local/share/sample-app/verify-php-trust.php /usr/local/bin/composer \ && sh /usr/src/brotli-inputs/install-vendor.sh /usr/src/brotli-inputs gcc libc6-dev \ && mkdir -p /out \ && cc -O2 /usr/src/brotli-inputs/verify-system-brotli.c -lbrotlidec -lbrotlienc -ldl -o /out/verify-system-brotli \ @@ -127,10 +129,12 @@ RUN set -eux; \ .; \ test -s /tmp/sample-app-mysql-seed.tar -FROM php:8.4.27-cli-bookworm@sha256:54b467ede1db96c66675e7b78457dd43729d0e7be31978d4a1e5c069ceabcf76 +FROM php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe +COPY .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh COPY --from=brotli-build /out /usr/local/share/sample-app/brotli -RUN sh /usr/local/share/sample-app/brotli/install-vendor.sh /usr/local/share/sample-app/brotli \ +RUN /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh \ + && sh /usr/local/share/sample-app/brotli/install-vendor.sh /usr/local/share/sample-app/brotli \ && php /usr/local/share/sample-app/brotli/verify-runtime.php \ && rm -rf /var/lib/apt/lists/* @@ -174,6 +178,7 @@ RUN set -eux; \ php /usr/local/share/sample-app/verify-php-trust.php "$(command -v composer)"; \ ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime; \ echo "${TZ}" > /etc/timezone; \ + /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh; \ apt-get update; \ apt-get upgrade -y --no-install-recommends; \ apt-get install -y --no-install-recommends \ @@ -357,6 +362,7 @@ RUN set -eux; \ playwright_version="$(node -e 'const lock = require("/tmp/sample-app-package-lock.json"); process.stdout.write(lock.packages["node_modules/playwright"].version)')"; \ test -n "$playwright_version"; \ npm install --global --no-audit --no-fund "playwright@${playwright_version}"; \ + /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh; \ PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT=120000 playwright install --with-deps --only-shell chromium; \ python3 /usr/local/share/sample-app/python/build-pip.py --clean-seeds; \ rm /usr/local/share/sample-app/python/build-pip.py /usr/local/share/sample-app/python/pip-urllib3.patch; \ diff --git a/.devcontainer/docker/README.md b/.devcontainer/docker/README.md new file mode 100644 index 00000000..483ad961 --- /dev/null +++ b/.devcontainer/docker/README.md @@ -0,0 +1,29 @@ +# Development image build inputs + +The development recipe uses the same pinned PHP 8.4.27 Bookworm index for its +Brotli builder and final stage, on both amd64 and arm64. Its shared APT preflight +comes from the accepted immutable source in +[central `docker/apt`](https://github.com/durable-workflow/.github/tree/18783be6bef737cc778e4c7282a8797e39daa61a/docker/apt). +That source records the exact parent descriptors and publisher SPDX package +inventory; installed-image acceptance is a separate result. + +Both image workflows check out that exact central commit without persisted +credentials into `.central-apt-source`, then verify its actual HEAD and the +preflight file's SHA-256 before preparing Buildx. The disposable checkout is +Git-ignored; its Git metadata is excluded from the Docker context. The Dockerfile +copies the central file directly from that local context into each PHP stage. +A missing checkout or file fails the build; there is no external-image fallback +or repository-local guard implementation. + +The preflight runs before each stage's first RUN reaches the Brotli APT helper, +then again before the final stage's direct APT block and Playwright dependency +installation. It uses stock shell/dpkg and needs no Python or package acquisition +to establish the installed native APT/libapt pair. + +The PR workflow builds untrusted native candidates with publication disabled. +The seven-job publisher remains a separate explicit main-only dispatch, including +its existing scan, anonymous/native qualification and promotion gates. Publisher +inventory does not establish installed-image acceptance. Publication and +production deployment retain their separate decisions. See +[#318](https://github.com/durable-workflow/sample-app/issues/318) for the current +adoption and effect decisions. diff --git a/.dockerignore b/.dockerignore index fb2834d3..531b35e0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ node_modules vendor .git +.central-apt-source/.git storage/logs/* storage/framework/cache/* storage/framework/sessions/* diff --git a/.github/workflows/devcontainer-image-pr.yml b/.github/workflows/devcontainer-image-pr.yml index 331f9f78..4a1fc157 100644 --- a/.github/workflows/devcontainer-image-pr.yml +++ b/.github/workflows/devcontainer-image-pr.yml @@ -47,6 +47,17 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check out the accepted central APT preflight + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false + - name: Verify the central APT source identity + run: | + test "$(git -C .central-apt-source rev-parse HEAD)" = 18783be6bef737cc778e4c7282a8797e39daa61a + echo '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576 .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh' | sha256sum --check --strict - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 - name: Build untrusted candidate without a shared cache uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 diff --git a/.github/workflows/devcontainer-image.yml b/.github/workflows/devcontainer-image.yml index 14280583..4ca3dd7b 100644 --- a/.github/workflows/devcontainer-image.yml +++ b/.github/workflows/devcontainer-image.yml @@ -43,6 +43,17 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check out the accepted central APT preflight + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false + - name: Verify the central APT source identity + run: | + test "$(git -C .central-apt-source rev-parse HEAD)" = 18783be6bef737cc778e4c7282a8797e39daa61a + echo '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576 .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh' | sha256sum --check --strict - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 - name: Sign in to GitHub Container Registry uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 diff --git a/.gitignore b/.gitignore index 78b6f88d..2a113424 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /storage/pail /vendor /.playground +/.central-apt-source/ /playground/php-runtime/vendor .env .env.backup diff --git a/tests/Unit/BrotliImageContractTest.php b/tests/Unit/BrotliImageContractTest.php index 4de6ec5e..5e9573ee 100644 --- a/tests/Unit/BrotliImageContractTest.php +++ b/tests/Unit/BrotliImageContractTest.php @@ -63,7 +63,7 @@ public function test_both_images_install_same_base_packages_before_network_clien $this->assertCount(2, $runtimes[1]); $this->assertSame($builder[1], $runtimes[1][1]); $runtime = substr($source, strrpos($source, "\nFROM php:")); - $install = strpos($runtime, 'RUN sh /usr/local/share/sample-app/brotli/install-vendor.sh '); + $install = strpos($runtime, 'sh /usr/local/share/sample-app/brotli/install-vendor.sh '); $check = strpos($runtime, 'php /usr/local/share/sample-app/brotli/verify-runtime.php'); $download = strpos($runtime, 'apt-get update'); foreach ([$install, $check, $download] as $position) { diff --git a/tests/Unit/DevcontainerImageContractTest.php b/tests/Unit/DevcontainerImageContractTest.php index c3372739..6fdf5432 100644 --- a/tests/Unit/DevcontainerImageContractTest.php +++ b/tests/Unit/DevcontainerImageContractTest.php @@ -145,6 +145,31 @@ public function test_composer_jobs_check_trust_before_dependency_resolution(): v } } + public function test_image_builds_bind_the_shared_preflight_before_buildx(): void + { + $commit = '18783be6bef737cc778e4c7282a8797e39daa61a'; + $hash = '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576'; + foreach (['devcontainer-image-pr.yml' => 'qualify', 'devcontainer-image.yml' => 'build'] as $file => $job) { + $steps = Yaml::parseFile($this->path('.github/workflows/'.$file))['jobs'][$job]['steps']; + $checkouts = array_filter($steps, static fn (array $step): bool => ($step['with']['repository'] ?? null) === 'durable-workflow/.github'); + $this->assertCount(1, $checkouts); + $index = array_key_first($checkouts); + $this->assertSame('actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', $steps[$index]['uses']); + $this->assertSame($commit, $steps[$index]['with']['ref']); + $this->assertSame('.central-apt-source', $steps[$index]['with']['path']); + $this->assertFalse($steps[$index]['with']['persist-credentials']); + $verification = $steps[$index + 1]['run']; + $this->assertStringContainsString('test "$(git -C .central-apt-source rev-parse HEAD)" = '.$commit, $verification); + $this->assertStringContainsString($hash.' .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh', $verification); + $this->assertStringContainsString('sha256sum --check --strict', $verification); + foreach ($steps as $position => $step) { + if (str_starts_with($step['uses'] ?? '', 'docker/setup-buildx-action@')) { + $this->assertLessThan($position, $index + 1); + } + } + } + } + public function test_main_publication_builds_both_architectures_before_promotion(): void { $workflow = Yaml::parseFile($this->path('.github/workflows/devcontainer-image.yml')); diff --git a/tests/ci/test_bookworm_consumer.py b/tests/ci/test_bookworm_consumer.py new file mode 100644 index 00000000..b38d8df9 --- /dev/null +++ b/tests/ci/test_bookworm_consumer.py @@ -0,0 +1,95 @@ +import fnmatch +import pathlib +import re +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +CONTEXT_SOURCE = ".central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" +GUARD_PATH = "/usr/local/share/sample-app/require-fixed-bookworm-apt.sh" +GUARD = "/bin/sh " + GUARD_PATH +PARENT = "php:8.4.27-cli-bookworm@sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe" + + +def instructions(source): + pending = "" + for line in source.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.endswith("\\"): + pending += line[:-1] + " " + continue + instruction = pending + line + pending = "" + operation, _, argument = instruction.partition(" ") + yield operation, argument.strip() + + +def php_stages(source): + stages = [] + current = None + for operation, argument in instructions(source): + if operation == "FROM": + current = [] if argument.startswith("php:") else None + if current is not None: + assert argument.split()[0] == PARENT, "Unexpected PHP parent" + stages.append(current) + elif current is not None: + current.append((operation, argument)) + assert len(stages) == 2, "Both PHP stages must use the accepted parent" + return stages + + +def protected_php_stages(source): + for stage in php_stages(source): + copy = stage.index(("COPY", CONTEXT_SOURCE + " " + GUARD_PATH)) + first_run = next(index for index, (operation, _) in enumerate(stage) if operation == "RUN") + assert copy < first_run, "The central local-context COPY must precede the first RUN" + assert stage[first_run][1].startswith(GUARD), "The first RUN must start with the central preflight" + for operation, argument in stage: + if operation != "RUN": + continue + for boundary in re.finditer(r"\bapt-get\s+(?:update|upgrade|install|purge)\b|\bplaywright\s+install\b[^;]*--with-deps", argument): + assert argument.rfind(GUARD, 0, boundary.start()) >= 0, "APT boundary has no preceding preflight in its RUN" + + +class BookwormConsumerTests(unittest.TestCase): + def setUp(self): + self.source = (ROOT / ".devcontainer/docker/Dockerfile").read_text() + + def test_both_php_stages_guard_their_apt_boundaries(self): + protected_php_stages(self.source) + + def test_an_early_brotli_install_cannot_precede_the_guard(self): + continuation = " " + chr(92) + chr(10) + " " + bad = self.source.replace("RUN " + GUARD + continuation + "&& ", "RUN ", 1) + self.assertTrue(self.source != bad, "The early-guard mutation must change the source") + with self.assertRaisesRegex(AssertionError, "first RUN"): + protected_php_stages(bad) + + def test_later_apt_and_playwright_guards_cannot_be_removed(self): + continuation = " " + chr(92) + chr(10) + " " + for following in ["apt-get update", "PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT=120000 playwright install"]: + with self.subTest(boundary=following): + bad = self.source.replace(GUARD + ";" + continuation + following, following, 1) + self.assertTrue(self.source != bad, "The later-guard mutation must change the source") + with self.assertRaisesRegex(AssertionError, "APT boundary"): + protected_php_stages(bad) + + def test_missing_central_context_has_no_external_copy_fallback(self): + bad = self.source.replace("COPY " + CONTEXT_SOURCE, "COPY --from=unreviewed-guard " + CONTEXT_SOURCE, 1) + with self.assertRaises(ValueError): + protected_php_stages(bad) + + def test_the_guard_is_in_the_build_context_but_git_metadata_is_not(self): + patterns = [line.strip().rstrip("/") for line in (ROOT / ".dockerignore").read_text().splitlines() if line.strip() and not line.startswith("#")] + ancestors = [str(path) for path in pathlib.PurePosixPath(CONTEXT_SOURCE).parents] + for pattern in patterns: + self.assertFalse(any(fnmatch.fnmatchcase(path, pattern) for path in [CONTEXT_SOURCE, *ancestors]), pattern) + self.assertIn(".central-apt-source/.git", patterns) + self.assertIn("/.central-apt-source/", (ROOT / ".gitignore").read_text().splitlines()) + + +if __name__ == "__main__": + unittest.main() From 7d9da8374bfae362c8913cf9d23d694e40c838a6 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:09:14 +0000 Subject: [PATCH 2/2] Read back the completed development image APT pair --- .devcontainer/docker/README.md | 2 ++ scripts/ci/qualify-devcontainer-image.sh | 1 + tests/ci/test_bookworm_consumer.py | 6 ++++++ 3 files changed, 9 insertions(+) diff --git a/.devcontainer/docker/README.md b/.devcontainer/docker/README.md index 483ad961..3b78f2d5 100644 --- a/.devcontainer/docker/README.md +++ b/.devcontainer/docker/README.md @@ -19,6 +19,8 @@ The preflight runs before each stage's first RUN reaches the Brotli APT helper, then again before the final stage's direct APT block and Playwright dependency installation. It uses stock shell/dpkg and needs no Python or package acquisition to establish the installed native APT/libapt pair. +The shared startup qualification invokes it again inside the completed image +after application preparation, retaining an explicit final installed-pair result. The PR workflow builds untrusted native candidates with publication disabled. The seven-job publisher remains a separate explicit main-only dispatch, including diff --git a/scripts/ci/qualify-devcontainer-image.sh b/scripts/ci/qualify-devcontainer-image.sh index 49dc7318..d27dc7e5 100755 --- a/scripts/ci/qualify-devcontainer-image.sh +++ b/scripts/ci/qualify-devcontainer-image.sh @@ -122,6 +122,7 @@ run_in_ready_devcontainer laravel .devcontainer/post-create.sh "${compose[@]}" up --detach --no-build --wait # The complete image inventory includes root-owned build sources and tools. +"${compose[@]}" exec -T --user root laravel /bin/sh /usr/local/share/sample-app/require-fixed-bookworm-apt.sh "${compose[@]}" exec -T --user root laravel php /usr/local/share/sample-app/brotli/verify-runtime.php run_in_ready_devcontainer laravel bash -euc ' diff --git a/tests/ci/test_bookworm_consumer.py b/tests/ci/test_bookworm_consumer.py index b38d8df9..adfa4698 100644 --- a/tests/ci/test_bookworm_consumer.py +++ b/tests/ci/test_bookworm_consumer.py @@ -90,6 +90,12 @@ def test_the_guard_is_in_the_build_context_but_git_metadata_is_not(self): self.assertIn(".central-apt-source/.git", patterns) self.assertIn("/.central-apt-source/", (ROOT / ".gitignore").read_text().splitlines()) + def test_completed_image_qualification_rechecks_the_installed_pair(self): + qualification = (ROOT / "scripts/ci/qualify-devcontainer-image.sh").read_text() + invocation = '"${compose[@]}" exec -T --user root laravel ' + GUARD + self.assertIn(invocation, qualification) + self.assertGreater(qualification.index(invocation), qualification.index("run_in_ready_devcontainer laravel .devcontainer/post-create.sh")) + if __name__ == "__main__": unittest.main()