Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docker/apt/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Shared Bookworm parent and early APT preflight proposal

This source-only profile covers PHP **8.4.27 CLI / Debian 12 / amd64 and arm64**.
It does not qualify an installed image or select a build, pull, publisher, or
consumer adoption. The Ubuntu 26.04 / PHP 8.3 hosted setup profile is separate.

The official [Debian advisory](https://lists.debian.org/debian-lts-announce/2026/11/msg00004.html)
sets the Bookworm fixed floor to **2.6.2**. Both
[apt](https://packages.debian.org/bookworm/apt) and
[libapt-pkg6.0](https://packages.debian.org/bookworm/libapt-pkg6.0) must meet it.
`php-8.4.27-cli-bookworm.v1.json` records the exact official index, both native
child manifests, configs, attestation manifests and SPDX statements for the old
and proposed fixed parent. Descriptor bytes/sizes/SHA-256 and SPDX child subjects
were checked with bounded stock public Registry v2 metadata GETs. No rootfs layer
was fetched. The mutable tag was used only to discover the candidate; it is not
a permitted build selector.

| Parent | Index SHA-256 | Publisher APT/libapt records, both architectures |
| --- | --- | --- |
| Existing | `54b467ede1db96c66675e7b78457dd43729d0e7be31978d4a1e5c069ceabcf76` | 2.6.1 / 2.6.1 |
| Proposed | `b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe` | 2.6.2 / 2.6.2 |

The candidate config retains PHP_VERSION=8.4.27 on each native architecture.
These publisher records are source inventory. Actual fixed pair, architecture,
executable availability and affected consumer behavior still need a separately
selected original image qualification.

## Reuse before Python exists

`require-fixed-bookworm-apt.sh` is a single central read-only shell preflight.
It reads the effective Debian release as data, queries the installed native
APT/libapt pair and uses stock dpkg version comparison. It refuses other releases,
architectures, malformed/incomplete/duplicate rows, non-installed status and
either unfixed member. It accepts no overrides, acquires nothing and performs no
repair. It requires only `/bin/sh`, `/usr/bin/env`, `/usr/bin/dpkg-query` and
`/usr/bin/dpkg`; the package-query subprocesses receive an empty environment plus
fixed PATH/locale. It needs neither Python nor an APT operation to start.

The initial consumer source target is Sample App
`.devcontainer/docker/Dockerfile`, observed at commit
`42d9af5957832ed2b0413c7fea1853e4d47ed3f4`, blob
`11d349d36a7d26d6e8cd9abb99fd2a94c876aa73`. Its Brotli build and final stages both
use the old PHP index. Both run the APT-using Brotli helper before the later
obvious APT block; the final stage also runs Playwright with `--with-deps`.

The proposed narrow adoption, after central acceptance and owner selection, is:

1. Keep both existing PHP stage versions and both amd64/arm64 platforms; replace
only their index with the exact proposed fixed index above.
2. Check out an accepted immutable central source commit without persisted
credentials into `.central-apt-source` inside the disposable build context.
Verify actual HEAD equals that literal before the build. Use the same central
file directly; do not commit a repository-local guard implementation.
3. In each PHP stage, before its first RUN, copy the central file with ordinary
Docker `COPY` from
`.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh` and run it with
`/bin/sh` before the Brotli helper. A missing local context/file fails COPY;
this route has no external-image/named-context fallback.
4. Invoke that same guard immediately before the later direct APT block and
Playwright `--with-deps`, so earlier stage operations cannot silently replace
the accepted effective pair. Preserve all current product/helper pins, source
graphs, trust gates, test behavior and platform selection.

No action or caller currently invokes this profile. The exact accepted central
commit/file SHA and complete consumer diff are future immutable recipe inputs.
Docker context inclusion and both first-RUN orders must be checked on that
consumer diff before any selected build. Source tests use inert package/release
records and stock offline version comparison; they do not run a container or APT.
The existing Python host guard remains separate and unwired. No historical root
bootstrap or package-manager transaction is restored.
136 changes: 136 additions & 0 deletions docker/apt/php-8.4.27-cli-bookworm.v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
{
"schema": "durable-workflow.php-bookworm-apt-parent.v1",
"scope": "publisher-source-inventory-only",
"repository": "docker.io/library/php",
"discovery_tag": "8.4.27-cli-bookworm",
"php_version": "8.4.27",
"os": {
"id": "debian",
"version_id": "12"
},
"architectures": [
"amd64",
"arm64"
],
"fixed": {
"apt": "2.6.2",
"libapt-pkg6.0": "2.6.2"
},
"authority": [
"https://lists.debian.org/debian-lts-announce/2026/11/msg00004.html",
"https://packages.debian.org/bookworm/apt",
"https://packages.debian.org/bookworm/libapt-pkg6.0"
],
"inventory": {
"layer_fetch": false,
"image_execution": false,
"rootfs_inspection": false,
"installed_qualification": false
},
"parents": {
"predecessor": {
"index": {
"digest": "sha256:54b467ede1db96c66675e7b78457dd43729d0e7be31978d4a1e5c069ceabcf76",
"size": 6501
},
"platforms": {
"linux/amd64": {
"child": {
"digest": "sha256:dd0e2b902f1edfb6ca02fbcb391c28b1554c7d35a4e36e5925876b90aa804851",
"size": 2878
},
"config": {
"digest": "sha256:12f6d2ea136f247580f3dc42bda86b27af080fa8f04ea5ec9b581238702093ff",
"size": 9007
},
"attestation": {
"digest": "sha256:ab7746990035696c58dc15c84923a2164212106d49a72d38b4d887d2efcc88b9",
"size": 841
},
"spdx": {
"digest": "sha256:f56402e73d62fcdd665a7152bcd1aaa5e0da7e85b2ba7a42c9d306cd475a2672",
"size": 6407897
},
"packages": {
"apt": "2.6.1",
"libapt-pkg6.0": "2.6.1"
}
},
"linux/arm64": {
"child": {
"digest": "sha256:ec3adc33ae9801f3cb9291c3f343c12ae1e9f3879d866c1517cd32160a74fa23",
"size": 2879
},
"config": {
"digest": "sha256:ca2f912b5fc4cee612b0863e15e3a23a087f1446ec51136a64f3527809b7dd59",
"size": 9020
},
"attestation": {
"digest": "sha256:1f2b9e200944bd584ad1c39e036031fb81fa26fab8c7ad9f97f0ddfb600c24f9",
"size": 841
},
"spdx": {
"digest": "sha256:34eab70d705d37508cca2b6a93d1d448807c2ed4f966959f7755b77548aad6ee",
"size": 6436288
},
"packages": {
"apt": "2.6.1",
"libapt-pkg6.0": "2.6.1"
}
}
}
},
"candidate": {
"index": {
"digest": "sha256:b3e907a1108629c562c4bb313ce3449e34d49991f6bb2487913f6146f47200fe",
"size": 6501
},
"platforms": {
"linux/amd64": {
"child": {
"digest": "sha256:5d3eebbd478bc102494c539d43be98a885984b33f929c6081f1c59033a878d6a",
"size": 2878
},
"config": {
"digest": "sha256:3db89bf1e580bf2a2767ea5bdeb168f4f053bf275188a103744e97c47bf0edaa",
"size": 9007
},
"attestation": {
"digest": "sha256:bf3bf52ff7e0e21a244116b0d31eb2490b9b0a6fc5fb914d08e562d61c92a792",
"size": 841
},
"spdx": {
"digest": "sha256:169f54509152a0455c7c7ab21d3a90834b6208f298ea78e5392decc7028ea53b",
"size": 6407951
},
"packages": {
"apt": "2.6.2",
"libapt-pkg6.0": "2.6.2"
}
},
"linux/arm64": {
"child": {
"digest": "sha256:f4501edd9c94d75fe90c78a81e9db9e5147170398619258165dff01607cec592",
"size": 2879
},
"config": {
"digest": "sha256:2b040a3b56b4b844d9992dff098a3302811007cdb759fc1b8daf6c4f094a9d29",
"size": 9022
},
"attestation": {
"digest": "sha256:3adad572ed13aadfc7bfcd0d50c714c29ce52e4ac2ccd113ee415b57e0362b49",
"size": 841
},
"spdx": {
"digest": "sha256:939898a2aefda54caa35d0d25b27b8db2da9cbc314aad87e19de3dfde9d0bdb5",
"size": 6436342
},
"packages": {
"apt": "2.6.2",
"libapt-pkg6.0": "2.6.2"
}
}
}
}
}
}
65 changes: 65 additions & 0 deletions docker/apt/require-fixed-bookworm-apt.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/bin/sh
# Shared read-only image preflight. No Python, APT, download, or repair path.
set -eu
LC_ALL=C
export LC_ALL
RELEASE=/etc/os-release
QUERY=/usr/bin/dpkg-query
DPKG=/usr/bin/dpkg
readonly RELEASE QUERY DPKG

refuse() {
printf '%s\n' 'fixed_bookworm_apt=false: effective Debian 12 native APT pair did not prove the 2.6.2 floor' >&2
exit 1
}

[ "$#" -eq 0 ] || refuse
os=
release=
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
ID=*)
[ -z "$os" ] || refuse
case "$line" in ID=debian|ID=\"debian\") os=debian ;; *) refuse ;; esac
;;
VERSION_ID=*)
[ -z "$release" ] || refuse
case "$line" in VERSION_ID=12|VERSION_ID=\"12\") release=12 ;; *) refuse ;; esac
;;
*) : ;;
esac
done < "$RELEASE"
[ "$os:$release" = debian:12 ] || refuse

rows=$(/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C "$QUERY" --show \
'--showformat=${Package}|${Status}|${Version}|${Architecture}\n' apt libapt-pkg6.0) || refuse
apt=
library=
architecture=
while IFS= read -r line; do
case "$line" in *'|'*'|'*'|'*'|'*) refuse ;; *'|'*'|'*'|'*) ;; *) refuse ;; esac
IFS='|' read -r package status version arch <<EOF
$line
EOF
[ "$status" = 'install ok installed' ] || refuse
case "$version" in ''|[!0-9]*|*[!0-9A-Za-z.+:~\-]*) refuse ;; *) : ;; esac
[ "${#version}" -le 100 ] || refuse
case "$arch" in amd64|arm64) ;; *) refuse ;; esac
[ -z "$architecture" ] || [ "$architecture" = "$arch" ] || refuse
architecture=$arch
case "$package" in
apt) [ -z "$apt" ] || refuse; apt=$version ;;
libapt-pkg6.0) [ -z "$library" ] || refuse; library=$version ;;
*) refuse ;;
esac
done <<EOF
$rows
EOF
[ -n "$apt" ] && [ -n "$library" ] || refuse
native=$(/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C "$DPKG" --print-architecture) || refuse
[ "$native" = "$architecture" ] || refuse
for version in "$apt" "$library"; do
/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C "$DPKG" --compare-versions "$version" ge 2.6.2 || refuse
done
printf 'fixed_bookworm_apt=true os=debian release=12 architecture=%s apt=%s libapt-pkg6.0=%s minimum=2.6.2\n' \
"$architecture" "$apt" "$library"
112 changes: 112 additions & 0 deletions tests/test_bookworm_apt_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Run the shared shell with inert release/package records, never APT or an image."""
import json
from pathlib import Path
import subprocess
import tempfile
import unittest


ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "docker/apt/require-fixed-bookworm-apt.sh"
RECORD = ROOT / "docker/apt/php-8.4.27-cli-bookworm.v1.json"


class BookwormAptSourceTest(unittest.TestCase):
def run_guard(self, rows=None, release='ID=debian\nVERSION_ID="12"\n', native="amd64",
query_status=0, arguments=()):
if rows is None:
rows = ["apt|install ok installed|2.6.2|amd64",
"libapt-pkg6.0|install ok installed|2.6.2|amd64"]
with tempfile.TemporaryDirectory(prefix="cli-bookworm-inert-") as temporary:
directory = Path(temporary)
(directory / "release").write_text(release)
(directory / "rows").write_text("\n".join(rows) + "\n")
(directory / "native").write_text(native + "\n")
query = directory / "query"
query.write_text("#!/bin/sh\nset -eu\n"
"test -z \"${GITHUB_TOKEN+x}\"\n"
"test -z \"${LD_PRELOAD+x}\"\n"
"test \"$#\" -eq 4\n"
f"/bin/cat {directory}/rows\nexit {query_status}\n")
dpkg = directory / "dpkg"
dpkg.write_text("#!/bin/sh\nset -eu\n"
"test -z \"${GITHUB_TOKEN+x}\"\n"
"if [ \"$#\" -eq 1 ] && [ \"$1\" = --print-architecture ]; then\n"
f" /bin/cat {directory}/native\n"
"else\n exec /usr/bin/dpkg \"$@\"\nfi\n")
query.chmod(0o700)
dpkg.chmod(0o700)
source = SOURCE.read_text()
for key, value in {"RELEASE": directory / "release", "QUERY": query, "DPKG": dpkg}.items():
old = next(line for line in source.splitlines() if line.startswith(key + "="))
source = source.replace(old, key + "=" + str(value), 1)
program = directory / "guard.sh"
program.write_text(source)
return subprocess.run(["/bin/sh", str(program), *arguments],
env={"PATH": "/no-tools-or-python", "LC_ALL": "C", "GITHUB_TOKEN": "inert-fixture"},
capture_output=True, text=True, check=False, timeout=5)

def test_fixed_pair_passes_both_native_architectures_without_python_or_path_tools(self):
for arch in ("amd64", "arm64"):
with self.subTest(arch=arch):
rows = [f"{package}|install ok installed|2.6.2|{arch}"
for package in ("apt", "libapt-pkg6.0")]
result = self.run_guard(rows, native=arch)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("fixed_bookworm_apt=true", result.stdout)
self.assertIn("architecture=" + arch, result.stdout)

def test_original_parent_and_one_unfixed_member_refuse_before_any_network_operation(self):
for apt, library in (("2.6.1", "2.6.1"), ("2.6.2", "2.6.1"), ("2.6.1", "2.6.2")):
with self.subTest(apt=apt, library=library):
result = self.run_guard([f"apt|install ok installed|{apt}|amd64",
f"libapt-pkg6.0|install ok installed|{library}|amd64"])
self.assertNotEqual(0, result.returncode)
self.assertNotIn("=true", result.stdout)

def test_status_package_version_and_architecture_ambiguity_refuse(self):
good = ["apt|install ok installed|2.6.2|amd64", "libapt-pkg6.0|install ok installed|2.6.2|amd64"]
variants = [[], good[:1], good + good[:1], good + ["foreign|install ok installed|2.6.2|amd64"]]
for index in (0, 1):
for old, new in (("install ok installed", "deinstall ok config-files"),
("2.6.2", ""), ("2.6.2", "not-a-version"), ("2.6.2", "2.6.2;false"),
("2.6.2", "2" * 101), ("amd64", "arm64"), ("amd64", "all")):
changed = good.copy()
changed[index] = changed[index].replace(old, new)
variants.append(changed)
variants += [good[:1] + [""] + good[1:], [good[0] + "|extra", good[1]]]
for rows in variants:
with self.subTest(rows=rows):
self.assertNotEqual(0, self.run_guard(rows).returncode)
for native in ("arm64", "all", "amd64\narm64", ""):
with self.subTest(native=native):
self.assertNotEqual(0, self.run_guard(native=native).returncode)
self.assertNotEqual(0, self.run_guard(query_status=1).returncode)

def test_effective_release_is_read_as_data_with_no_overrides(self):
for release in ('ID=ubuntu\nVERSION_ID="12"\n', 'ID=debian\nVERSION_ID="13"\n',
'ID=debian\n', 'ID=debian\nID=debian\nVERSION_ID=12\n',
'ID=debian\nVERSION_ID=12\nVERSION_ID=12\n',
'ID=$(false)\nVERSION_ID=12\n'):
with self.subTest(release=release):
self.assertNotEqual(0, self.run_guard(release=release).returncode)
self.assertNotEqual(0, self.run_guard(arguments=("--version=2.6.2",)).returncode)

def test_metadata_records_old_and_fixed_pairs_for_same_php_and_both_platforms(self):
record = json.loads(RECORD.read_text())
self.assertEqual("publisher-source-inventory-only", record["scope"])
self.assertEqual("8.4.27", record["php_version"])
self.assertEqual({"id": "debian", "version_id": "12"}, record["os"])
self.assertFalse(any(record["inventory"].values()))
for name, version in (("predecessor", "2.6.1"), ("candidate", "2.6.2")):
parent = record["parents"][name]
self.assertEqual({"linux/amd64", "linux/arm64"}, set(parent["platforms"]))
for platform in parent["platforms"].values():
self.assertEqual({"apt": version, "libapt-pkg6.0": version}, platform["packages"])
for kind in ("child", "config", "attestation", "spdx"):
self.assertRegex(platform[kind]["digest"], r"^sha256:[0-9a-f]{64}$")
self.assertGreater(platform[kind]["size"], 0)


if __name__ == "__main__":
unittest.main()
Loading
Loading