Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ jobs:
- name: Verify build and feature selection contracts
run: python scripts/ci/test-build-routing.py

- name: Verify published-upgrade PHP container boundaries
run: python3 -I -S -B tests/ci/test_php_container_launcher.py

- name: Require APT retries off before networked APT (CVE-2026-109518)
run: python -I scripts/ci/test-apt-no-retries.py

Expand All @@ -91,7 +94,9 @@ jobs:
scripts/ci/laravel-embedded-upgrade-smoke.sh \
scripts/ci/check-packagist-release.sh \
scripts/ci/check-platform-conformance-mirror.sh \
scripts/ci/test-platform-conformance-resolver-audit.sh
scripts/ci/test-platform-conformance-resolver-audit.sh \
scripts/ci/setup-php-container.sh \
scripts/ci/run-php-container.sh
php -l scripts/ci/compare-platform-conformance-mirrors.php
php -l scripts/ci/check-v2-coverage.php
php -l scripts/ci/list-worker-protocol-resolvers.php
Expand Down
37 changes: 31 additions & 6 deletions .github/workflows/release-verification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ jobs:
- name: Verify published platform contract
env:
WORKFLOW_PLATFORM_CONFORMANCE_VERSION: ${{ steps.release.outputs.tag }}
WORKFLOW_TIMEOUT_REVISION: ${{ steps.product.outputs.revision }}
WORKFLOW_TIMEOUT_EXPECTED_SOURCE: ${{ github.workspace }}/.release-product
RUNNER_TEMP: ${{ env.TMPDIR }}
run: scripts/ci/check-platform-conformance-mirror.sh
- name: Revalidate PHP after product commands and clean owned state
Expand Down Expand Up @@ -257,8 +259,15 @@ jobs:
laravel-upgrade:
name: Laravel ${{ matrix.laravel }} / PHP ${{ matrix.php }} published upgrade
needs: published-package
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 25
env:
GITHUB_TOKEN: ''
GH_TOKEN: ''
GITHUB_ENTERPRISE_TOKEN: ''
GH_ENTERPRISE_TOKEN: ''
COMPOSER_AUTH: ''
WORKFLOW_COMPOSER_TRUST_VERIFIER: ${{ github.workspace }}/.qualification-control/.github/actions/verify-composer-trust/verify.php

strategy:
fail-fast: false
Expand All @@ -269,12 +278,28 @@ jobs:
with:
persist-credentials: false

- 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 fixed Bookworm APT guard
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
php-version: ${{ matrix.php }}
tools: composer
repository: durable-workflow/.github
ref: 18783be6bef737cc778e4c7282a8797e39daa61a
path: .central-apt-source
persist-credentials: false
- name: Check out the accepted Composer trust verifier
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
repository: durable-workflow/.github
ref: 242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5
path: .qualification-control
persist-credentials: false
- name: Select authenticated dependency trust
uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust
with:
clients: pip,python,composer,curl,git
- name: Build and verify the fixed PHP runtime
run: scripts/ci/setup-php-container.sh '${{ matrix.php }}'
- name: Verify Composer trust before upgrade requests
uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust

- name: Run the published-package upgrade journey
env:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ coverage.xml
/.regression-corpus-consumer-*/
/.claude/settings.local.json
**/.claude/settings.local.json

.central-apt-source/
.qualification-control/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

## 2.29.0 - 2026-11-13

Experimental embedded timeout cleanup is opt-in. Run the database migrations
before using it. The cleanup allowance stays within the original hard deadline;
the business outcome remains timed out. Poll/service admission remains refused.

- **Run migrations.** Opt-in deadline-triggered cleanup (#863, experimental,
embedded). A start can reserve the last part of its hard execution or run
timeout for cleanup with `StartOptions::withTimeoutCleanup($seconds)` (or
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"dev-main": "2.0.x-dev"
},
"durable-workflow": {
"product-train": "2.28.0",
"product-train": "2.29.0",
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
},
Expand Down
11 changes: 11 additions & 0 deletions docker/brotli/install-vendor.sh
Original file line number Diff line number Diff line change
@@ -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" "$@"
113 changes: 113 additions & 0 deletions docker/brotli/verify-runtime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

final class BrotliRuntimeRefused extends RuntimeException {}

function brotliCommand(array $command): string
{
$process = proc_open($command, [0 => ['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();
}
43 changes: 43 additions & 0 deletions docker/brotli/verify-system-brotli.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#define _GNU_SOURCE
#include <brotli/decode.h>
#include <brotli/encode.h>
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>

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;
}
1 change: 1 addition & 0 deletions docker/brotli/version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.0.9-2+deb12u1
24 changes: 24 additions & 0 deletions docker/php-ci/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions docker/php-ci/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# PHP runtime for published upgrade journeys

The published Laravel upgrade job builds one fixed runtime for each PHP minor
in the existing product contract. `scripts/ci/php-container-images.json` pins
the official PHP inputs. The Dockerfile, Composer, trust and Brotli inputs
reuse the reviewed SDK PHP source at
`e6494b86f930c0c196b36a5e94d3b7f5f46c437e`; Workflow owns its additional
PHP minor and the actual Laravel consumers. Qualification does not transfer
between callers.

The Docker context is an allowlist. The pinned central APT guard runs before
networked APT, extension compilation uses two jobs, and the build checks the
effective PHP/Composer CA bundles and Brotli runtime. The setup command checks
the real minor, required modules/functions, production INI, existing CI
defaults and disabled coverage, then runs the actual Composer TLS fixture.
Host Python uses isolated imports.

The `php` and `composer` launchers select the inspected immutable config ID,
run as the caller with two CPUs, 2 GiB and 512 PIDs, and remove their containers
on exit. Only source, private dependency state, selected read-only trust inputs
and the owned temporary SQLite directory are mounted. That directory preserves
the original shared-memory database and its sidecars across PHP invocations.
Host credentials are not forwarded. Composer trust is checked before each
networked upgrade operation using the accepted read-only verifier.

Run the offline launcher controls with:

```sh
python3 -I -S -B tests/ci/test_php_container_launcher.py
```

The hosted caller selects authenticated trust, checks out the exact fixed APT
source, and invokes `scripts/ci/setup-php-container.sh` for its matrix minor.
Local controls may reuse a previously built identical runtime config ID, but
must exercise Workflow's own module/INI, TLS and unchanged upgrade journey.
The source recipe, actual caller, closed central admission record and installed
results all require review before the release matrix is qualified. The initial
published timeout-cleanup probe retains its separately qualified PHP setup.
6 changes: 6 additions & 0 deletions docker/php-ci/ci-defaults.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
; Existing setup-php f3e473d1 defaults from src/configs/ini/{php,jit}.ini.
date.timezone=UTC
memory_limit=-1
opcache.enable=1
opcache.jit_buffer_size=256M
opcache.jit=1235
Loading
Loading