From ce4f05b692c783aa1580c378e88847bb3905be24 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:13:18 +0000 Subject: [PATCH 1/6] Prepare bounded PHP test container launcher and runtime checks --- .github/workflows/build.yml | 6 ++ scripts/ci/probe-php-test-runtime.php | 30 ++++++++ scripts/ci/run-php-container.sh | 66 +++++++++++++++++ scripts/ci/test-php-container-launcher.py | 86 +++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 scripts/ci/probe-php-test-runtime.php create mode 100755 scripts/ci/run-php-container.sh create mode 100644 scripts/ci/test-php-container-launcher.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8666e1e..80c0e28 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,6 +88,8 @@ jobs: node --check scripts/ci/verify-standalone-dependencies.js node --check scripts/ci/rust-dependency-inventory.js php -l scripts/ci/probe-standalone-runtime.php + php -l scripts/ci/probe-php-test-runtime.php + bash -n scripts/ci/run-php-container.sh bash -n scripts/ci/verify-release-tag-source.sh bash -n scripts/ci/establish-native-prerequisites.sh bash -n scripts/ci/reconcile-release-metadata.sh @@ -101,6 +103,7 @@ jobs: python scripts/ci/test-release-rust-workflow.py python scripts/ci/test-apt-no-retries.py python scripts/ci/test-build-workflow.py + python scripts/ci/test-php-container-launcher.py node --test scripts/ci/test-rust-dependency-inventory.js python scripts/ci/test-native-prerequisites.py node --test scripts/ci/test-release-workflow-metadata.js @@ -180,6 +183,8 @@ jobs: node --check scripts/ci/verify-standalone-dependencies.js node --check scripts/ci/rust-dependency-inventory.js php -l scripts/ci/probe-standalone-runtime.php + php -l scripts/ci/probe-php-test-runtime.php + bash -n scripts/ci/run-php-container.sh bash -n scripts/ci/verify-release-tag-source.sh bash -n scripts/ci/establish-native-prerequisites.sh bash -n scripts/ci/reconcile-release-metadata.sh @@ -193,6 +198,7 @@ jobs: python scripts/ci/test-release-rust-workflow.py python scripts/ci/test-apt-no-retries.py python scripts/ci/test-build-workflow.py + python scripts/ci/test-php-container-launcher.py node --test scripts/ci/test-rust-dependency-inventory.js python scripts/ci/test-native-prerequisites.py node --test scripts/ci/test-release-workflow-metadata.js diff --git a/scripts/ci/probe-php-test-runtime.php b/scripts/ci/probe-php-test-runtime.php new file mode 100644 index 0000000..9dfee70 --- /dev/null +++ b/scripts/ci/probe-php-test-runtime.php @@ -0,0 +1,30 @@ + PHP_VERSION, + 'architecture' => php_uname('m'), + 'extensions' => get_loaded_extensions(), + 'ini' => php_ini_loaded_file(), + 'coverage' => 'none', +], JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/ci/run-php-container.sh b/scripts/ci/run-php-container.sh new file mode 100755 index 0000000..191f3b8 --- /dev/null +++ b/scripts/ci/run-php-container.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +command="${0##*/}" +case "$command" in php|composer) ;; *) echo 'Invoke this launcher as php or composer.' >&2; exit 2 ;; esac +root="${CLI_PHP_CI_ROOT:?PHP CI repository root is required}" +state="${CLI_PHP_CI_STATE:?PHP CI state directory is required}" +image="${CLI_PHP_CI_IMAGE:?PHP CI image is required}" +if [[ ! "$image" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo 'PHP CI must use the inspected immutable image config ID.' >&2 + exit 1 +fi + +options=(--rm --init --interactive --name "cli-php-ci-${command}-${BASHPID}" + --cpus 2 --memory 2g --pids-limit 512 --network host + --cap-drop ALL --security-opt no-new-privileges + --user "$(id -u):$(id -g)" --workdir "$PWD" + --env "HOME=$state/home" + --mount "type=bind,source=$root,target=$root" + --mount "type=bind,source=$state,target=$state") + +# The Composer verifier is a composite action outside the checkout. +if [[ -n "${GITHUB_ACTION_PATH:-}" ]]; then + options+=(--mount "type=bind,source=$GITHUB_ACTION_PATH,target=$GITHUB_ACTION_PATH,readonly") +fi +git_directory="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir)" +if [[ "$git_directory" != "$root/"* ]]; then + options+=(--mount "type=bind,source=$git_directory,target=$git_directory,readonly") +fi + +declare -A mounted=() +for variable in SSL_CERT_FILE CURL_CA_BUNDLE GIT_SSL_CAINFO COMPOSER_CAFILE \ + REQUESTS_CA_BUNDLE PIP_CERT NPM_CONFIG_CAFILE CARGO_HTTP_CAINFO CARGO_HTTP_PROXY_CAINFO \ + SSL_CERT_DIR GIT_SSL_CAPATH COMPOSER_CAPATH; do + if [[ -n "${!variable:-}" ]]; then + selected="${!variable}" + if [[ -z "${mounted[$selected]:-}" ]]; then + options+=(--mount "type=bind,source=$selected,target=$selected,readonly") + mounted[$selected]=1 + fi + options+=(--env "$variable") + fi +done + +# Ordinary tests need no host credentials. The optional smoke fixture has +# explicit, separate role inputs; do not forward arbitrary DW_* or token env. +while IFS= read -r variable; do + case "$variable" in + COMPOSER_HOME|COMPOSER_CACHE_DIR|COMPOSER_MAX_PARALLEL_*|COMPOSER_IPRESOLVE|\ + DURABLE_WORKFLOW_CLI_SMOKE|DURABLE_WORKFLOW_CLI_SMOKE_SERVER_URL|\ + DURABLE_WORKFLOW_CLI_SMOKE_ADMIN_TOKEN|DURABLE_WORKFLOW_CLI_SMOKE_OPERATOR_TOKEN|\ + DURABLE_WORKFLOW_CLI_SMOKE_WORKER_TOKEN|\ + DW_CI_CA_SHA256|GITHUB_ACTIONS|CI|TMPDIR|TZ) + options+=(--env "$variable") ;; + esac +done < <(compgen -e) + +# The host verifier must inspect the actual container Composer PHAR. +arguments=() +for argument in "$@"; do + if [[ "$argument" == "$state/bin/composer" ]]; then + argument=/usr/local/bin/composer + fi + arguments+=("$argument") +done +exec docker run "${options[@]}" "$image" "$command" "${arguments[@]}" diff --git a/scripts/ci/test-php-container-launcher.py b/scripts/ci/test-php-container-launcher.py new file mode 100644 index 0000000..6696e25 --- /dev/null +++ b/scripts/ci/test-php-container-launcher.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Exercise the actual launcher without a Docker daemon or credentials.""" + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +LAUNCHER = ROOT / 'scripts/ci/run-php-container.sh' + + +class PhpContainerLauncherTest(unittest.TestCase): + def invoke(self, command='php', arguments=(), **overrides): + with tempfile.TemporaryDirectory() as directory: + state = Path(directory) + (state / 'bin').mkdir() + (state / 'bin' / command).symlink_to(LAUNCHER) + docker = state / 'bin/docker' + docker.write_text('#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n') + docker.chmod(0o755) + bundle = state / 'ca.pem' + bundle.write_text('synthetic mount only') + environment = { + 'PATH': str(state / 'bin') + os.pathsep + os.defpath, + 'CLI_PHP_CI_ROOT': str(ROOT), + 'CLI_PHP_CI_STATE': str(state), + 'CLI_PHP_CI_IMAGE': 'sha256:' + 'f' * 64, + 'SSL_CERT_FILE': str(bundle), + 'COMPOSER_CAFILE': str(bundle), + 'COMPOSER_HOME': str(state / 'composer-home'), + 'DW_CI_CA_SHA256': '1' * 64, + 'GITHUB_TOKEN': 'synthetic-must-not-forward', + 'COMPOSER_AUTH': 'synthetic-must-not-forward', + 'DW_TOKEN': 'synthetic-must-not-forward', + 'PHP_INI_SCAN_DIR': '/synthetic-must-not-forward', + 'DURABLE_WORKFLOW_CLI_SMOKE_ADMIN_TOKEN': 'synthetic-smoke', + **overrides, + } + arguments = [str(state / 'bin/composer') if x == '{composer}' else x for x in arguments] + result = subprocess.run([str(state / 'bin' / command), *arguments], cwd=ROOT, + env=environment, capture_output=True, text=True) + return result, str(bundle), str(state) + + def test_credentials_and_php_overrides_do_not_enter_runtime(self): + result, bundle, state = self.invoke(arguments=('-r', 'echo PHP_VERSION;')) + self.assertEqual(0, result.returncode, result.stderr) + invocation = json.loads(result.stdout) + passed = [invocation[i + 1] for i, part in enumerate(invocation[:-1]) if part == '--env'] + for name in ['COMPOSER_HOME', 'DW_CI_CA_SHA256', 'DURABLE_WORKFLOW_CLI_SMOKE_ADMIN_TOKEN']: + self.assertIn(name, passed) + for name in ['GITHUB_TOKEN', 'COMPOSER_AUTH', 'DW_TOKEN', 'PHP_INI_SCAN_DIR']: + self.assertNotIn(name, passed) + self.assertIn('HOME=' + state + '/home', passed) + mounts = [invocation[i + 1] for i, part in enumerate(invocation[:-1]) if part == '--mount'] + self.assertEqual(1, mounts.count(f'type=bind,source={bundle},target={bundle},readonly')) + self.assertFalse(any('docker.sock' in mount for mount in mounts)) + self.assertIn('--cap-drop', invocation) + self.assertIn('no-new-privileges', invocation) + self.assertEqual(['php', '-r', 'echo PHP_VERSION;'], invocation[-3:]) + + def test_composer_verifier_reads_container_phar(self): + result, _, _ = self.invoke(arguments=('/guard/verify.php', '{composer}', str(ROOT)), + GITHUB_ACTION_PATH='/guard') + self.assertEqual(0, result.returncode, result.stderr) + invocation = json.loads(result.stdout) + self.assertEqual(['php', '/guard/verify.php', '/usr/local/bin/composer', str(ROOT)], invocation[-4:]) + self.assertIn('type=bind,source=/guard,target=/guard,readonly', invocation) + + def test_composer_uses_container_executable(self): + result, _, _ = self.invoke(command='composer', arguments=('test',)) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(['composer', 'test'], json.loads(result.stdout)[-2:]) + + def test_mutable_image_refuses_before_docker(self): + result, _, _ = self.invoke(CLI_PHP_CI_IMAGE='cli-php-ci:mutable') + self.assertNotEqual(0, result.returncode) + self.assertIn('inspected immutable image config ID', result.stderr) + self.assertEqual('', result.stdout) + + +if __name__ == '__main__': + unittest.main() From 6e78a0099165687b5d2fb56d2137358e32e7220a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:19:19 +0000 Subject: [PATCH 2/6] Reuse the shared PHP test base and add guarded CLI setup --- .dockerignore | 11 +++ .gitignore | 2 + docker/brotli/install-vendor.sh | 11 +++ docker/brotli/verify-runtime.php | 113 ++++++++++++++++++++++++++ docker/brotli/verify-system-brotli.c | 43 ++++++++++ docker/brotli/version | 1 + docker/php-ci/Dockerfile | 24 ++++++ docker/php-ci/README.md | 23 ++++++ docker/php-ci/verify-php-trust.php | 36 ++++++++ scripts/ci/php-container-images.json | 5 ++ scripts/ci/probe-php-test-runtime.php | 12 +-- scripts/ci/run-php-container.sh | 1 + scripts/ci/setup-php-container.sh | 43 ++++++++++ scripts/ci/test-php-container-tls.py | 97 ++++++++++++++++++++++ 14 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 docker/brotli/install-vendor.sh create mode 100644 docker/brotli/verify-runtime.php create mode 100644 docker/brotli/verify-system-brotli.c create mode 100644 docker/brotli/version create mode 100644 docker/php-ci/Dockerfile create mode 100644 docker/php-ci/README.md create mode 100644 docker/php-ci/verify-php-trust.php create mode 100644 scripts/ci/php-container-images.json create mode 100755 scripts/ci/setup-php-container.sh create mode 100644 scripts/ci/test-php-container-tls.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aa21957 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +** +!docker/ +!docker/php-ci/ +!docker/php-ci/Dockerfile +!docker/php-ci/verify-php-trust.php +!docker/brotli/ +!docker/brotli/* +!.central-apt-source/ +!.central-apt-source/docker/ +!.central-apt-source/docker/apt/ +!.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh diff --git a/.gitignore b/.gitignore index b71e3c7..db8ab90 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ /src/GeneratedBuildInfo.php .claude/settings.local.json **/.claude/settings.local.json + +/.central-apt-source diff --git a/docker/brotli/install-vendor.sh b/docker/brotli/install-vendor.sh new file mode 100644 index 0000000..a60803e --- /dev/null +++ b/docker/brotli/install-vendor.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +set -eu + +inputs="${1:?Brotli verification input directory is required.}" +shift +version="$(cat "$inputs/version")" +test -n "$version" + +apt-get update +apt-get install -y --no-install-recommends \ + "libbrotli1=$version" "libbrotli-dev=$version" "$@" diff --git a/docker/brotli/verify-runtime.php b/docker/brotli/verify-runtime.php new file mode 100644 index 0000000..b708ab0 --- /dev/null +++ b/docker/brotli/verify-runtime.php @@ -0,0 +1,113 @@ + ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new BrotliRuntimeRefused('Cannot inspect the Brotli runtime.'); + } + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) { + throw new BrotliRuntimeRefused(implode(' ', $command).' failed: '.trim($error)); + } + + return $output; +} + +function brotliElfMentionsDecoder(string $path): bool +{ + $input = fopen($path, 'rb'); + if ($input === false) { + throw new BrotliRuntimeRefused('Cannot inspect '.$path); + } + try { + if (fread($input, 4) !== "\x7fELF") { + return false; + } + $tail = ''; + while (!feof($input)) { + $chunk = $tail.fread($input, 1024 * 1024); + if (str_contains($chunk, "libbrotlidec.so.1\0")) { + return true; + } + $tail = substr($chunk, -32); + } + + return false; + } finally { + fclose($input); + } +} + +function brotliInspectFile(SplFileInfo $file, array $owned): int +{ + $path = $file->getPathname(); + if (str_starts_with($file->getFilename(), 'libbrotlidec.so') && !$file->isDir() + && !isset($owned[realpath($path) ?: $path])) { + throw new BrotliRuntimeRefused('A decoder is outside the patched package: '.$path); + } + if (!$file->isFile() || $file->isLink() || !brotliElfMentionsDecoder($path)) { + return 0; + } + $links = brotliCommand(['ldd', $path]); + // A library also contains its own SONAME. Only ldd's dependency mapping + // identifies a consumer; an unresolved mapping still fails. + if (!preg_match('~libbrotlidec\.so\.1\s+=>\s+(\S+)~', $links, $match)) { + return 0; + } + if (!isset($owned[realpath($match[1]) ?: $match[1]])) { + throw new BrotliRuntimeRefused('An ELF consumer does not load the patched decoder: '.$path); + } + + return 1; +} + +function verifyBrotliRuntime(): void +{ + $expected = trim((string) file_get_contents(__DIR__.'/version')); + $actual = trim(brotliCommand(['dpkg-query', '-W', '-f=${Version}', 'libbrotli1'])); + if ($actual !== $expected) { + throw new BrotliRuntimeRefused('libbrotli1 is '.$actual.'; expected the reviewed vendor package '.$expected.'.'); + } + $owned = []; + foreach (explode("\n", brotliCommand(['dpkg-query', '-L', 'libbrotli1'])) as $path) { + if (str_contains(basename($path), 'libbrotlidec.so')) { + $resolved = realpath($path); + if ($resolved !== false) { + $owned[$resolved] = true; + } + } + } + if ($owned === []) { + throw new BrotliRuntimeRefused('The patched package owns no decoder.'); + } + + $consumers = 0; + // These are the image's tool, dependency, application and browser roots. + // Symlinked directories are not followed; Debian /lib and /bin resolve into /usr. + foreach (['/usr', '/opt', '/app', '/var/www', '/ms-playwright'] as $root) { + if (!is_dir($root)) { + continue; + } + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $consumers += brotliInspectFile($file, $owned); + } + } + if ($consumers === 0) { + throw new BrotliRuntimeRefused('No system Brotli consumer was inspected.'); + } + echo brotliCommand([__DIR__.'/verify-system-brotli']); + echo json_encode(['package' => $actual, 'elf_consumers' => $consumers], JSON_THROW_ON_ERROR).PHP_EOL; +} + +if (realpath($argv[0] ?? '') === __FILE__) { + verifyBrotliRuntime(); +} diff --git a/docker/brotli/verify-system-brotli.c b/docker/brotli/verify-system-brotli.c new file mode 100644 index 0000000..5935604 --- /dev/null +++ b/docker/brotli/verify-system-brotli.c @@ -0,0 +1,43 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +int main(void) { + const char *marker = BrotliDecoderErrorString((BrotliDecoderErrorCode)-17); + Dl_info origin = {0}; + const void *symbol = dlsym(RTLD_DEFAULT, "BrotliDecoderErrorString"); + unsigned version = BrotliDecoderVersion(); + if (!symbol || !dladdr(symbol, &origin) || !origin.dli_fname) { + fputs("Cannot resolve the loaded Brotli decoder.\n", stderr); + return 2; + } + printf("decoder=%u.%u.%u marker=%s library=%s\n", + version >> 24, (version >> 12) & 4095, version & 4095, + marker ? marker : "missing", origin.dli_fname); + /* Brotli 1.0 reports the error name without the newer branch's prefix. */ + if (!marker || strcmp(marker, "BLOCK_SWITCH") != 0) { + fputs("The loaded decoder lacks the CVE-2026-111497 fix.\n", stderr); + return 1; + } + + /* Only valid bytes produced here are decoded; no malformed corpus is used. */ + const unsigned char input[] = "Durable Workflow ordinary Brotli response"; + unsigned char encoded[1024]; + unsigned char decoded[sizeof(input)]; + size_t encoded_size = sizeof(encoded); + size_t decoded_size = sizeof(decoded); + if (!BrotliEncoderCompress(4, 22, BROTLI_MODE_GENERIC, sizeof(input) - 1, + input, &encoded_size, encoded) || + BrotliDecoderDecompress(encoded_size, encoded, &decoded_size, decoded) != + BROTLI_DECODER_RESULT_SUCCESS || + decoded_size != sizeof(input) - 1 || + memcmp(decoded, input, sizeof(input) - 1) != 0) { + fputs("Ordinary Brotli data did not round trip.\n", stderr); + return 2; + } + puts("Valid Brotli data round trips through the corrected system decoder."); + return 0; +} diff --git a/docker/brotli/version b/docker/brotli/version new file mode 100644 index 0000000..92b4227 --- /dev/null +++ b/docker/brotli/version @@ -0,0 +1 @@ +1.0.9-2+deb12u1 diff --git a/docker/php-ci/Dockerfile b/docker/php-ci/Dockerfile new file mode 100644 index 0000000..8e69dba --- /dev/null +++ b/docker/php-ci/Dockerfile @@ -0,0 +1,24 @@ +ARG PHP_IMAGE +FROM composer@sha256:2cf275428757e5435a667bfc0d038b14520ca02be2b1b70d44a05c8c19e2de48 AS composer + +FROM ${PHP_IMAGE} + +COPY .central-apt-source/docker/apt/require-fixed-bookworm-apt.sh /usr/local/share/durable-workflow/php-ci/require-fixed-bookworm-apt.sh +COPY --from=composer /usr/bin/composer /usr/local/bin/composer +COPY docker/php-ci/verify-php-trust.php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php +COPY docker/brotli /usr/local/share/durable-workflow/php-ci/brotli + +RUN /bin/sh /usr/local/share/durable-workflow/php-ci/require-fixed-bookworm-apt.sh \ + && printf 'Acquire::Retries "0";\n' > /etc/apt/apt.conf.d/99-no-retries \ + && php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php /usr/local/bin/composer \ + && sh /usr/local/share/durable-workflow/php-ci/brotli/install-vendor.sh /usr/local/share/durable-workflow/php-ci/brotli \ + git unzip python3 gcc libc6-dev libicu-dev libonig-dev libzip-dev \ + && docker-php-ext-install -j2 bcmath intl mbstring pcntl sockets zip \ + && cc -O2 /usr/local/share/durable-workflow/php-ci/brotli/verify-system-brotli.c -lbrotlidec -lbrotlienc -ldl \ + -o /usr/local/share/durable-workflow/php-ci/brotli/verify-system-brotli \ + && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \ + && php /usr/local/share/durable-workflow/php-ci/verify-php-trust.php /usr/local/bin/composer \ + && php /usr/local/share/durable-workflow/php-ci/brotli/verify-runtime.php \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app diff --git a/docker/php-ci/README.md b/docker/php-ci/README.md new file mode 100644 index 0000000..89fc0a1 --- /dev/null +++ b/docker/php-ci/README.md @@ -0,0 +1,23 @@ +# PHP test runtime + +The no-database Dockerfile, Composer CA verifier and Brotli inputs are reused +byte-for-byte from SDK PHP source +[`4d37a42d`](https://github.com/durable-workflow/sdk-php/tree/4d37a42d1c95020fa2c75dcd0cc7585dd788f71c/docker/php-ci), itself adapted +from the accepted Sample App container pattern. Keep these common build inputs +aligned before starting another base build. CLI owns its minor selection, +runtime probe, restricted launcher environment and actual test results. + +`scripts/ci/php-container-images.json` selects immutable official PHP images +for 8.2, 8.4 and 8.5. Composer is pinned to the reviewed 2.10.3 image index. +The build requires the APT helper from central source +`18783be6bef737cc778e4c7282a8797e39daa61a`; setup checks its commit and file +digest before building. The probe requires production INI, no coverage, +PHPUnit's modules and the process-control functions exercised by CLI tests. + +The setup script runs with authenticated CA environment from the dependency +trust bootstrap and private runner state. It inspects the built image ID, +runs the actual Composer TLS control, then installs `php` and `composer` +launchers. Those launchers forward the selected CA files as read-only mounts +and keep unrelated host credentials outside the runtime. The caller's normal +Composer verifier still checks the actual container PHAR before dependency +requests and again before project scripts. diff --git a/docker/php-ci/verify-php-trust.php b/docker/php-ci/verify-php-trust.php new file mode 100644 index 0000000..79f2e8c --- /dev/null +++ b/docker/php-ci/verify-php-trust.php @@ -0,0 +1,36 @@ + PHP_VERSION, diff --git a/scripts/ci/run-php-container.sh b/scripts/ci/run-php-container.sh index 191f3b8..f09cc5b 100755 --- a/scripts/ci/run-php-container.sh +++ b/scripts/ci/run-php-container.sh @@ -52,6 +52,7 @@ while IFS= read -r variable; do DURABLE_WORKFLOW_CLI_SMOKE_WORKER_TOKEN|\ DW_CI_CA_SHA256|GITHUB_ACTIONS|CI|TMPDIR|TZ) options+=(--env "$variable") ;; + *) ;; # Host credentials and unrelated configuration stay outside. esac done < <(compgen -e) diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh new file mode 100755 index 0000000..edbd169 --- /dev/null +++ b/scripts/ci/setup-php-container.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +minor="${1:?Usage: setup-php-container.sh 8.2|8.4|8.5}" +image="$(python3 - "$root/scripts/ci/php-container-images.json" "$minor" <<'PY' +import json,re,sys +images=json.load(open(sys.argv[1])) +image=images[sys.argv[2]] +assert re.fullmatch(r'php:8\.[245]\.\d+-cli-bookworm@sha256:[0-9a-f]{64}', image) +print(image) +PY +)" +[[ -f "$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" ]] +[[ "$(git -C "$root/.central-apt-source" rev-parse HEAD)" = 18783be6bef737cc778e4c7282a8797e39daa61a ]] +echo '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576 '"$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" | sha256sum --check --strict + +runtime="cli-php-ci:${minor}-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" +docker build --file "$root/docker/php-ci/Dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$runtime" "$root" +runtime_id="$(docker image inspect "$runtime" --format '{{.Id}}')" +docker run --rm --init --name "cli-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local}" \ + --cpus 1 --memory 512m --pids-limit 64 --network none \ + --user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=bind,source=$root/scripts/ci/probe-php-test-runtime.php,target=/probe.php,readonly" \ + "$runtime_id" php /probe.php "$minor" +printf 'php_ci_image=%s\n' "$runtime_id" + +state="$(mktemp -d "${RUNNER_TEMP:?RUNNER_TEMP is required}/cli-php-ci.XXXXXX")" +mkdir -p "$state/bin" "$state/home" "$state/tmp" "$state/composer-home/cache" +ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/php" +ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/composer" +CLI_PHP_CI_TEST_IMAGE="$runtime_id" CLI_PHP_CI_ROOT="$root" CLI_PHP_CI_STATE="$state" \ + python3 "$root/scripts/ci/test-php-container-tls.py" -v +{ + echo "CLI_PHP_CI_IMAGE=$runtime_id" + echo "CLI_PHP_CI_ROOT=$root" + echo "CLI_PHP_CI_STATE=$state" + echo "COMPOSER_HOME=$state/composer-home" + echo "COMPOSER_CACHE_DIR=$state/composer-home/cache" + echo "TMPDIR=$state/tmp" +} >> "${GITHUB_ENV:?GITHUB_ENV is required}" +echo "$state/bin" >> "${GITHUB_PATH:?GITHUB_PATH is required}" diff --git a/scripts/ci/test-php-container-tls.py b/scripts/ci/test-php-container-tls.py new file mode 100644 index 0000000..7095592 --- /dev/null +++ b/scripts/ci/test-php-container-tls.py @@ -0,0 +1,97 @@ +import http.server +import json +import os +import pathlib +import ssl +import subprocess +import tempfile +import threading +import unittest + + +@unittest.skipUnless(os.environ.get("CLI_PHP_CI_TEST_IMAGE"), "requires the built PHP CI image") +class PhpContainerTlsTests(unittest.TestCase): + def test_selected_clean_ca_overrides_ambient_trust_before_http(self): + state = pathlib.Path(os.environ["CLI_PHP_CI_STATE"]) + clean_bundle = pathlib.Path(os.environ["SSL_CERT_FILE"]) + requests = [] + + class Repository(http.server.BaseHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + payload = json.dumps({"packages": {"cli/tls-control": {"1.0.0": { + "name": "cli/tls-control", "version": "1.0.0", "type": "metapackage", + }}}}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *_arguments): + pass + + with tempfile.TemporaryDirectory(dir=state) as directory: + project = pathlib.Path(directory) + + def openssl(*arguments): + subprocess.run(["openssl", *arguments], cwd=project, check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=30) + + openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=AAA Certificate Services", "-keyout", "root.key", "-out", "root.pem") + openssl("req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=127.0.0.1", + "-keyout", "server.key", "-out", "server.csr") + (project / "extensions.cnf").write_text("subjectAltName=IP:127.0.0.1\n") + openssl("x509", "-req", "-in", "server.csr", "-CA", "root.pem", "-CAkey", "root.key", + "-CAcreateserial", "-days", "1", "-extfile", "extensions.cnf", "-out", "server.pem") + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(project / "server.pem", project / "server.key") + # The Docker source/state mounts must never expose the synthetic private keys. + (project / "root.key").unlink() + (project / "server.key").unlink() + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Repository) + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + (project / "composer.json").write_text(json.dumps({ + "name": "cli/tls-project", + "repositories": [{"type": "composer", "url": f"https://127.0.0.1:{server.server_port}"}, + {"packagist.org": False}], + })) + home = project / "home" + home.mkdir() + (home / "config.json").write_text(json.dumps({"config": {"cafile": str(project / "root.pem")}})) + environment = os.environ.copy() + for name in list(environment): + if name.endswith(("_CA_BUNDLE", "_CAINFO", "_CAPATH")) or name in { + "SSL_CERT_FILE", "SSL_CERT_DIR", "COMPOSER_CAFILE", "COMPOSER_CAPATH", + "PIP_CERT", "CARGO_HTTP_PROXY_CAINFO", "DW_CI_CA_SHA256", "COMPOSER", + }: + environment.pop(name) + environment.update({"CLI_PHP_CI_IMAGE": os.environ["CLI_PHP_CI_TEST_IMAGE"], + "COMPOSER_HOME": str(home), "COMPOSER_CACHE_DIR": str(home / "cache")}) + command = [str(state / "bin/composer"), "--no-plugins", "--no-scripts", "--no-cache", + "show", "--all", "cli/tls-control"] + ambient = subprocess.run(command, cwd=project, env=environment, text=True, + capture_output=True, timeout=45) + self.assertEqual(ambient.returncode, 0, ambient.stderr) + self.assertGreater(len(requests), 0, "Ambient trust must reach the HTTPS repository") + requests.clear() + environment.update({"SSL_CERT_FILE": str(clean_bundle), "CURL_CA_BUNDLE": str(clean_bundle), + "GIT_SSL_CAINFO": str(clean_bundle), "COMPOSER_CAFILE": str(clean_bundle)}) + selected = subprocess.run(command, cwd=project, env=environment, text=True, + capture_output=True, timeout=45) + self.assertNotEqual(selected.returncode, 0) + self.assertIn("curl error 60", selected.stderr, selected.stderr) + self.assertEqual(requests, [], "Selected clean trust must refuse before any HTTP request") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +if __name__ == "__main__": + unittest.main() From 17897aa81b30f86fafcbcf0d193cd42d98126f47 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:28:01 +0000 Subject: [PATCH 3/6] Add CLI Node and process tools over the shared PHP base --- docker/php-ci/README.md | 2 ++ docker/php-ci/cli.Dockerfile | 11 +++++++++++ scripts/ci/probe-php-test-runtime.php | 14 ++++++++++++++ scripts/ci/setup-php-container.sh | 13 ++++++++++++- 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 docker/php-ci/cli.Dockerfile diff --git a/docker/php-ci/README.md b/docker/php-ci/README.md index 89fc0a1..7530d3e 100644 --- a/docker/php-ci/README.md +++ b/docker/php-ci/README.md @@ -6,6 +6,8 @@ byte-for-byte from SDK PHP source from the accepted Sample App container pattern. Keep these common build inputs aligned before starting another base build. CLI owns its minor selection, runtime probe, restricted launcher environment and actual test results. +`cli.Dockerfile` extends that base with the pinned Node binary and procps, +which the existing release-contract and installer-shell fixtures require. `scripts/ci/php-container-images.json` selects immutable official PHP images for 8.2, 8.4 and 8.5. Composer is pinned to the reviewed 2.10.3 image index. diff --git a/docker/php-ci/cli.Dockerfile b/docker/php-ci/cli.Dockerfile new file mode 100644 index 0000000..3940e13 --- /dev/null +++ b/docker/php-ci/cli.Dockerfile @@ -0,0 +1,11 @@ +ARG PHP_RUNTIME_IMAGE +FROM node@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da AS node + +FROM ${PHP_RUNTIME_IMAGE} + +COPY --from=node /usr/local/bin/node /usr/local/bin/node + +RUN /bin/sh /usr/local/share/durable-workflow/php-ci/require-fixed-bookworm-apt.sh \ + && apt-get update \ + && apt-get install -y --no-install-recommends procps \ + && rm -rf /var/lib/apt/lists/* diff --git a/scripts/ci/probe-php-test-runtime.php b/scripts/ci/probe-php-test-runtime.php index fade97d..902b7aa 100644 --- a/scripts/ci/probe-php-test-runtime.php +++ b/scripts/ci/probe-php-test-runtime.php @@ -23,10 +23,24 @@ final class PhpTestRuntimeRefused extends RuntimeException {} if (php_ini_loaded_file() !== '/usr/local/etc/php/php.ini') { throw new PhpTestRuntimeRefused('The container production INI must be loaded.'); } +if (! is_executable('/usr/bin/ps')) { + throw new PhpTestRuntimeRefused('Installer shell-detection tests require procps.'); +} +$process = proc_open(['node', '--version'], [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); +if (! is_resource($process)) { + throw new PhpTestRuntimeRefused('Release-contract tests require Node.'); +} +$nodeVersion = trim(stream_get_contents($pipes[1])); +fclose($pipes[1]); +fclose($pipes[2]); +if (proc_close($process) !== 0 || $nodeVersion !== 'v22.23.4') { + throw new PhpTestRuntimeRefused('Release-contract tests require the pinned Node runtime.'); +} echo json_encode([ 'php' => PHP_VERSION, 'architecture' => php_uname('m'), 'extensions' => get_loaded_extensions(), 'ini' => php_ini_loaded_file(), 'coverage' => 'none', + 'node' => $nodeVersion, ], JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh index edbd169..298be4a 100755 --- a/scripts/ci/setup-php-container.sh +++ b/scripts/ci/setup-php-container.sh @@ -17,8 +17,19 @@ PY echo '2a36a2417e8947357ea6f6cbc2f6c53a834f3c069f8013a34d6d861b7123d576 '"$root/.central-apt-source/docker/apt/require-fixed-bookworm-apt.sh" | sha256sum --check --strict runtime="cli-php-ci:${minor}-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" -docker build --file "$root/docker/php-ci/Dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$runtime" "$root" +base="$runtime-base" +docker build --file "$root/docker/php-ci/Dockerfile" --build-arg "PHP_IMAGE=$image" --tag "$base" "$root" +base_id="$(docker image inspect "$base" --format '{{.Id}}')" +docker build --file "$root/docker/php-ci/cli.Dockerfile" --build-arg "PHP_RUNTIME_IMAGE=$base" --tag "$runtime" "$root" +[[ "$(docker image inspect "$base" --format '{{.Id}}')" = "$base_id" ]] runtime_id="$(docker image inspect "$runtime" --format '{{.Id}}')" +docker image inspect "$base_id" "$runtime_id" | python3 -c ' +import json,sys +base,runtime=json.load(sys.stdin) +layers=base["RootFS"]["Layers"] +assert runtime["RootFS"]["Layers"][:len(layers)] == layers, "CLI overlay must extend the inspected shared base" +print("php_ci_base="+base["Id"]) +' docker run --rm --init --name "cli-php-ci-probe-${minor}-${GITHUB_RUN_ID:-local}" \ --cpus 1 --memory 512m --pids-limit 64 --network none \ --user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges \ From 684e0253bf5eef750a7404b9a3462a1074c5eaca Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:35:32 +0000 Subject: [PATCH 4/6] Use executable launcher controls without redundant shell lint --- .github/workflows/build.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80c0e28..197d37a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,7 +89,6 @@ jobs: node --check scripts/ci/rust-dependency-inventory.js php -l scripts/ci/probe-standalone-runtime.php php -l scripts/ci/probe-php-test-runtime.php - bash -n scripts/ci/run-php-container.sh bash -n scripts/ci/verify-release-tag-source.sh bash -n scripts/ci/establish-native-prerequisites.sh bash -n scripts/ci/reconcile-release-metadata.sh @@ -184,7 +183,6 @@ jobs: node --check scripts/ci/rust-dependency-inventory.js php -l scripts/ci/probe-standalone-runtime.php php -l scripts/ci/probe-php-test-runtime.php - bash -n scripts/ci/run-php-container.sh bash -n scripts/ci/verify-release-tag-source.sh bash -n scripts/ci/establish-native-prerequisites.sh bash -n scripts/ci/reconcile-release-metadata.sh From ed5908f30320aebed6af160aa73d2526d3e36111 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:54:23 +0000 Subject: [PATCH 5/6] Isolate PHP setup Python from workspace imports --- .github/workflows/build.yml | 4 +-- scripts/ci/setup-php-container.sh | 6 ++-- scripts/ci/test-php-container-launcher.py | 42 +++++++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 197d37a..49ab760 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -102,7 +102,7 @@ jobs: python scripts/ci/test-release-rust-workflow.py python scripts/ci/test-apt-no-retries.py python scripts/ci/test-build-workflow.py - python scripts/ci/test-php-container-launcher.py + python -I -S -B scripts/ci/test-php-container-launcher.py node --test scripts/ci/test-rust-dependency-inventory.js python scripts/ci/test-native-prerequisites.py node --test scripts/ci/test-release-workflow-metadata.js @@ -196,7 +196,7 @@ jobs: python scripts/ci/test-release-rust-workflow.py python scripts/ci/test-apt-no-retries.py python scripts/ci/test-build-workflow.py - python scripts/ci/test-php-container-launcher.py + python -I -S -B scripts/ci/test-php-container-launcher.py node --test scripts/ci/test-rust-dependency-inventory.js python scripts/ci/test-native-prerequisites.py node --test scripts/ci/test-release-workflow-metadata.js diff --git a/scripts/ci/setup-php-container.sh b/scripts/ci/setup-php-container.sh index 298be4a..38fb99c 100755 --- a/scripts/ci/setup-php-container.sh +++ b/scripts/ci/setup-php-container.sh @@ -4,7 +4,7 @@ umask 077 root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" minor="${1:?Usage: setup-php-container.sh 8.2|8.4|8.5}" -image="$(python3 - "$root/scripts/ci/php-container-images.json" "$minor" <<'PY' +image="$(python3 -I -S -B - "$root/scripts/ci/php-container-images.json" "$minor" <<'PY' import json,re,sys images=json.load(open(sys.argv[1])) image=images[sys.argv[2]] @@ -23,7 +23,7 @@ base_id="$(docker image inspect "$base" --format '{{.Id}}')" docker build --file "$root/docker/php-ci/cli.Dockerfile" --build-arg "PHP_RUNTIME_IMAGE=$base" --tag "$runtime" "$root" [[ "$(docker image inspect "$base" --format '{{.Id}}')" = "$base_id" ]] runtime_id="$(docker image inspect "$runtime" --format '{{.Id}}')" -docker image inspect "$base_id" "$runtime_id" | python3 -c ' +docker image inspect "$base_id" "$runtime_id" | python3 -I -S -B -c ' import json,sys base,runtime=json.load(sys.stdin) layers=base["RootFS"]["Layers"] @@ -42,7 +42,7 @@ mkdir -p "$state/bin" "$state/home" "$state/tmp" "$state/composer-home/cache" ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/php" ln -sf "$root/scripts/ci/run-php-container.sh" "$state/bin/composer" CLI_PHP_CI_TEST_IMAGE="$runtime_id" CLI_PHP_CI_ROOT="$root" CLI_PHP_CI_STATE="$state" \ - python3 "$root/scripts/ci/test-php-container-tls.py" -v + python3 -I -S -B "$root/scripts/ci/test-php-container-tls.py" -v { echo "CLI_PHP_CI_IMAGE=$runtime_id" echo "CLI_PHP_CI_ROOT=$root" diff --git a/scripts/ci/test-php-container-launcher.py b/scripts/ci/test-php-container-launcher.py index 6696e25..5a7b62c 100644 --- a/scripts/ci/test-php-container-launcher.py +++ b/scripts/ci/test-php-container-launcher.py @@ -5,12 +5,54 @@ import os from pathlib import Path import subprocess +import sys import tempfile import unittest ROOT = Path(__file__).resolve().parents[2] LAUNCHER = ROOT / 'scripts/ci/run-php-container.sh' +SETUP = ROOT / 'scripts/ci/setup-php-container.sh' + + +class PhpContainerSetupIsolationTest(unittest.TestCase): + def test_workspace_python_imports_do_not_run_before_apt_source_guard(self): + for origin in ['working-directory', 'PYTHONPATH']: + with self.subTest(origin=origin), tempfile.TemporaryDirectory() as directory: + temporary = Path(directory) + project = temporary / 'project' + scripts = project / 'scripts/ci' + scripts.mkdir(parents=True) + setup = scripts / SETUP.name + setup.write_bytes(SETUP.read_bytes()) + (scripts / 'php-container-images.json').write_bytes( + (ROOT / 'scripts/ci/php-container-images.json').read_bytes()) + marker = temporary / 'workspace-imported' + poison = project if origin == 'working-directory' else temporary / 'python-path' + poison.mkdir(exist_ok=True) + (poison / 'json.py').write_text( + 'from pathlib import Path\n' + f'Path({str(marker)!r}).write_text("executed")\n' + 'raise RuntimeError("Workspace Python import executed")\n') + binaries = temporary / 'bin' + binaries.mkdir() + (binaries / 'python3').symlink_to(sys.executable) + docker = binaries / 'docker' + docker.write_text('#!/bin/sh\nexit 99\n') + docker.chmod(0o755) + environment = {'PATH': str(binaries) + os.pathsep + os.defpath} + if origin == 'PYTHONPATH': + environment['PYTHONPATH'] = str(poison) + positive = subprocess.run([sys.executable, '-c', 'import json'], cwd=project, + env=environment, capture_output=True) + self.assertNotEqual(0, positive.returncode) + self.assertTrue(marker.exists(), 'The unisolated positive control must execute the marker') + marker.unlink() + + result = subprocess.run(['bash', str(setup), '8.2'], cwd=project, + env=environment, capture_output=True) + self.assertEqual(1, result.returncode, 'The missing APT source must stop setup before Docker') + self.assertFalse(marker.exists(), 'The real setup must not execute workspace/PYTHONPATH code') class PhpContainerLauncherTest(unittest.TestCase): From 5a2818cadec5d96e854cfd948fd826b3708704c7 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:35:46 +0000 Subject: [PATCH 6/6] Use admitted pinned runtimes for the remaining PHP test cells --- .github/workflows/build.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49ab760..4e6f678 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,7 +47,7 @@ jobs: test: name: Test (PHP ${{ matrix.php }}) if: ${{ github.server_url == 'https://github.com' }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 strategy: fail-fast: false @@ -67,12 +67,15 @@ jobs: clients: pip,python,composer,curl,git - name: Disable APT download retries (CVE-2026-109518) run: echo 'Acquire::Retries "0";' | sudo tee /etc/apt/apt.conf.d/99-no-retries >/dev/null - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + - name: Check out the accepted PHP container build source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: - php-version: ${{ matrix.php }} - extensions: pcntl, posix - tools: composer:2.10.3 - coverage: none + repository: durable-workflow/.github + ref: 18783be6bef737cc778e4c7282a8797e39daa61a + path: .central-apt-source + persist-credentials: false + - name: Build the pinned PHP runtime and install launchers + run: bash "$GITHUB_WORKSPACE/scripts/ci/setup-php-container.sh" "${{ matrix.php }}" - name: Verify actual Composer dependency trust uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - run: composer install --prefer-dist --no-interaction