From 2eea1530f3a7d4a378edf7d8db3e5aefbabef327 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:36:24 +0000 Subject: [PATCH 01/13] Use bound stock PHP for cooperative qualification jobs --- .github/workflows/ci.yml | 38 ++++++++++++++----- .github/workflows/published-cancellation.yml | 36 +++++++++++++----- .../service-mode-published-smoke.yml | 38 ++++++++++++++----- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index beb83eda..481a9b6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -772,7 +772,7 @@ jobs: cooperative-integration: name: Connected cooperative cancellation if: ${{ github.event_name == 'workflow_dispatch' && inputs.cooperative_qualification }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 25 env: COMPOSE_PROJECT_NAME: php-cooperative-${{ github.run_id }}-${{ github.run_attempt }} @@ -784,22 +784,40 @@ jobs: with: path: sdk-php persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: '3.12' + - name: Select Ubuntu's Python 3.12 + run: | + /usr/bin/python3 -I -S -c 'import sys; assert sys.version_info[:2] == (3, 12)' + echo /usr/bin >> "$GITHUB_PATH" + - name: Create isolated Composer state + run: | + state="$(mktemp -d "$RUNNER_TEMP/php-check.XXXXXX")" + mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" + { + echo "COMPOSER_HOME=$state/home" + echo "COMPOSER_CACHE_DIR=$state/cache" + echo "TMPDIR=$state/tmp" + } >> "$GITHUB_ENV" - name: Select authenticated dependency trust id: trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust with: clients: pip,python,composer,curl,git,cargo - 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 + - uses: durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7 # bound-linux-php with: php-version: '8.3' - extensions: pcntl, posix - tools: composer:2.10.3 + ini-file: production + tools: composer:2.10.3,phpunit:11.5.57 coverage: none + - name: Require the stock cooperative worker capabilities + run: | + php -r 'foreach (["pcntl", "posix"] as $extension) { + if (!extension_loaded($extension)) { throw new RuntimeException("Missing ".$extension); } + } + foreach (["pcntl_fork", "pcntl_waitpid", "pcntl_signal", "pcntl_async_signals", "posix_kill"] as $function) { + if (!function_exists($function)) { throw new RuntimeException("Missing ".$function); } + }' - name: Check out the exact public Server source env: SERVER_COMMIT: ${{ inputs.server_commit }} @@ -836,7 +854,7 @@ jobs: '{qualification:"source",sdk_commit:$sdk,server_commit:$server,native_source_overlay:($native | if length > 0 then . else null end),python_sdk_commit:($python | if length > 0 then . else null end),rust_sdk_commit:($rust | if length > 0 then . else null end),cli_source_commit:($cli | if length > 0 then . else null end)}' \ > sdk-php/cooperative-source-provenance.json - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust - name: Bind exact CLI inspection source if: ${{ inputs.cli_commit != '' }} env: @@ -897,7 +915,7 @@ jobs: printf 'DURABLE_WORKFLOW_POLYGLOT_RUST_BINARY=%s\n' "$binary" >> "$GITHUB_ENV" printf 'DURABLE_WORKFLOW_POLYGLOT_ARTIFACT_DIRECTORY=%s/sdk-php/polyglot-evidence\n' "$GITHUB_WORKSPACE" >> "$GITHUB_ENV" - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust with: working-directory: sdk-php - run: composer install --no-interaction --prefer-dist --no-progress diff --git a/.github/workflows/published-cancellation.yml b/.github/workflows/published-cancellation.yml index a2151a78..d2bd4492 100644 --- a/.github/workflows/published-cancellation.yml +++ b/.github/workflows/published-cancellation.yml @@ -33,7 +33,7 @@ permissions: jobs: mixed-cascade: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 env: COMPOSE_PROJECT_NAME: published-cancellation-${{ github.run_id }}-${{ github.run_attempt }} @@ -60,26 +60,44 @@ jobs: for version in "$PHP_SDK_VERSION" "$PYTHON_SDK_VERSION" "$RUST_SDK_VERSION" "$CLI_VERSION"; do [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[1-9][0-9]*)?$ ]] done - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: '3.12' + - name: Select Ubuntu's Python 3.12 + run: | + /usr/bin/python3 -I -S -c 'import sys; assert sys.version_info[:2] == (3, 12)' + echo /usr/bin >> "$GITHUB_PATH" + - name: Create isolated Composer state + run: | + state="$(mktemp -d "$RUNNER_TEMP/php-check.XXXXXX")" + mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" + { + echo "COMPOSER_HOME=$state/home" + echo "COMPOSER_CACHE_DIR=$state/cache" + echo "TMPDIR=$state/tmp" + } >> "$GITHUB_ENV" - name: Bootstrap published-consumer dependency trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust with: clients: pip,python,composer,curl,git,cargo - 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 + - uses: durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7 # bound-linux-php with: php-version: '8.3' - extensions: pcntl, posix - tools: composer:2.10.3 + ini-file: production + tools: composer:2.10.3,phpunit:11.5.57 coverage: none + - name: Require the stock cooperative worker capabilities + run: | + php -r 'foreach (["pcntl", "posix"] as $extension) { + if (!extension_loaded($extension)) { throw new RuntimeException("Missing ".$extension); } + } + foreach (["pcntl_fork", "pcntl_waitpid", "pcntl_signal", "pcntl_async_signals", "posix_kill"] as $function) { + if (!function_exists($function)) { throw new RuntimeException("Missing ".$function); } + }' - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: toolchain: 1.86.0 - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust - name: Install the published SDKs and CLI run: | set -euo pipefail diff --git a/.github/workflows/service-mode-published-smoke.yml b/.github/workflows/service-mode-published-smoke.yml index 10462562..f2c6c945 100644 --- a/.github/workflows/service-mode-published-smoke.yml +++ b/.github/workflows/service-mode-published-smoke.yml @@ -28,7 +28,7 @@ permissions: jobs: source-free-service-mode: if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: DURABLE_WORKFLOW_SERVER_URL: http://127.0.0.1:8080 @@ -54,24 +54,42 @@ jobs: exit 1 fi - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: '3.12' + - name: Select Ubuntu's Python 3.12 + run: | + /usr/bin/python3 -I -S -c 'import sys; assert sys.version_info[:2] == (3, 12)' + echo /usr/bin >> "$GITHUB_PATH" + - name: Create isolated Composer state + run: | + state="$(mktemp -d "$RUNNER_TEMP/php-check.XXXXXX")" + mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" + { + echo "COMPOSER_HOME=$state/home" + echo "COMPOSER_CACHE_DIR=$state/cache" + echo "TMPDIR=$state/tmp" + } >> "$GITHUB_ENV" - name: Select authenticated dependency trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust with: 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 + - uses: durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7 # bound-linux-php with: php-version: '8.3' - extensions: pcntl, posix - tools: composer:2.10.3 + ini-file: production + tools: composer:2.10.3,phpunit:11.5.57 coverage: none + - name: Require the stock cooperative worker capabilities + run: | + php -r 'foreach (["pcntl", "posix"] as $extension) { + if (!extension_loaded($extension)) { throw new RuntimeException("Missing ".$extension); } + } + foreach (["pcntl_fork", "pcntl_waitpid", "pcntl_signal", "pcntl_async_signals", "posix_kill"] as $function) { + if (!function_exists($function)) { throw new RuntimeException("Missing ".$function); } + }' - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust - name: Resolve the immutable published SDK release env: SDK_VERSION: ${{ inputs.sdk_version }} @@ -110,7 +128,7 @@ jobs: PHP - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust - name: Install only the published package env: SDK_VERSION: ${{ inputs.sdk_version }} From ccae1cc21dd7fbcd5cc577fdb64d403233b1f4a7 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:09:50 +0000 Subject: [PATCH 02/13] Select the reviewed Server 3.8 container trust recipe --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 481a9b6b..4e2133fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -926,7 +926,7 @@ jobs: # and must stay immediately before the one registered build command. - name: Bind the Server build's container Composer trust id: container-trust - uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@699d09d602fc0353160bdecaa2b23f07ef4c7c21 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@d12a1e247ea28b89912cc87a8c0b596686d8c323 # clean-trust with: request: sdk-php-cooperative-server source-directory: server From 05027ae98fb5a9bf7d08e2d9a3d28dd9d26ada6c Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:14:42 +0000 Subject: [PATCH 03/13] Name the narrow container trust action role --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e2133fb..b6608e34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -926,7 +926,7 @@ jobs: # and must stay immediately before the one registered build command. - name: Bind the Server build's container Composer trust id: container-trust - uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@d12a1e247ea28b89912cc87a8c0b596686d8c323 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@d12a1e247ea28b89912cc87a8c0b596686d8c323 # composer-container-trust with: request: sdk-php-cooperative-server source-directory: server From ddd99dbc0ab1f0cde4f88db3bdef19d97a32d31b Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:46:07 +0000 Subject: [PATCH 04/13] Adopt the accepted staged-CA guard and isolated cleanup --- .github/workflows/ci.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93b3d9a0..4730da37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -928,7 +928,7 @@ jobs: # and must stay immediately before the one registered build command. - name: Bind the Server build's container Composer trust id: container-trust - uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@d12a1e247ea28b89912cc87a8c0b596686d8c323 # composer-container-trust + uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@06efa32de898cff5766ef9f3a9db6a368fced981 # composer-container-trust with: request: sdk-php-cooperative-server source-directory: server @@ -1003,6 +1003,16 @@ jobs: DW_SERVER_SOURCE_COMMIT: ${{ steps.container-trust.outputs.source-commit }} DW_SERVER_DOCKERFILE_SHA256: ${{ steps.container-trust.outputs.dockerfile-sha256 }} run: docker compose down -v --rmi local + - name: Remove only the verified staged public CA + if: ${{ always() && steps.container-trust.outcome == 'success' }} + working-directory: sdk-php + env: + VERIFIER_CONTEXT: ${{ steps.container-trust.outputs.context-directory }} + STAGED_CA: ${{ steps.container-trust.outputs.ca-file }} + CA_SHA256: ${{ steps.container-trust.outputs.ca-sha256 }} + run: >- + python3 -I -S -B "$VERIFIER_CONTEXT/../../../scripts/composer_container_trust.py" cleanup-ca + --ca-file "$STAGED_CA" --ca-sha256 "$CA_SHA256" --compose-directory . target-branch-qualification: name: Target branch qualification From 9367b5f6cd014f6d5b3f62b2c63e9fea88db0152 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:04:33 +0000 Subject: [PATCH 05/13] Prepare 3.18.1 and installed completion-limit qualification --- .../workflows/published-worker-metrics.yml | 18 +- CHANGELOG.md | 6 + composer.json | 2 +- docs/portal/build/messages.md | 6 +- docs/quickstart-contract.json | 4 +- tests/smoke/workflow-completion-limits.php | 166 ++++++++++++++++++ 6 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 tests/smoke/workflow-completion-limits.php diff --git a/.github/workflows/published-worker-metrics.yml b/.github/workflows/published-worker-metrics.yml index 086871d1..b797ebfc 100644 --- a/.github/workflows/published-worker-metrics.yml +++ b/.github/workflows/published-worker-metrics.yml @@ -2,16 +2,20 @@ name: Published worker metrics on: pull_request: - paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php'] + paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php'] push: branches: [main] - paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php'] + paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php'] workflow_dispatch: inputs: sdk_version: description: Exact published PHP SDK version type: string required: false + completion_limits: + description: Qualify completion limits on the immutable native 3.9.1 tuple + type: boolean + default: false permissions: contents: read @@ -22,8 +26,8 @@ jobs: timeout-minutes: 10 env: SDK_VERSION: ${{ inputs.sdk_version || '3.18.0' }} - SERVER_VERSION: '3.8.0' - SERVER_IMAGE: durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e + SERVER_VERSION: ${{ inputs.completion_limits && '3.9.1' || '3.8.0' }} + SERVER_IMAGE: ${{ inputs.completion_limits && 'durableworkflow/server@sha256:946c65eb70e8b4b7c4694be8a181c9669d46c3408b80d4e44d96f2030ea3cf51' || 'durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e' }} METRICS_RUNTIME_URL: http://127.0.0.1:8080 METRICS_TOKEN: published-metrics-test-token steps: @@ -80,6 +84,11 @@ jobs: run: | php -l tests/smoke/update-outcome.php timeout 60s php tests/smoke/update-outcome.php "$CONSUMER/vendor/autoload.php" | tee update-outcome.json + - name: Assert installed completion limits and same-worker recovery + if: inputs.completion_limits + run: | + php -l tests/smoke/workflow-completion-limits.php + timeout 240s php tests/smoke/workflow-completion-limits.php "$CONSUMER/vendor/autoload.php" | tee workflow-completion-limits.json - name: Retain the tuple, observations and installed dependency graph if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -88,6 +97,7 @@ jobs: path: | worker-metrics.json update-outcome.json + workflow-completion-limits.json ${{ env.CONSUMER }}/composer.lock retention-days: 14 - name: Show bounded Server diagnostics diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b44d6e1..f8e374e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [3.18.1] - 2026-11-13 + ### Fixed - Activity continuations honor the completion aggregate and command per-value @@ -21,6 +23,10 @@ project follows [Semantic Versioning](https://semver.org/). failures keep their existing behavior; older producers retain the conservative ordinary-request fallback. +- Pending update guidance distinguishes admission waits, which retain the original + request key, from GET-by-ID reads, where that key may be unavailable. Repeated + reads preserve the update ID and never admit a second update (.github#212). + ## [3.18.0] - 2026-11-13 ### Added diff --git a/composer.json b/composer.json index 5c6a02bb..94e97cbf 100644 --- a/composer.json +++ b/composer.json @@ -94,7 +94,7 @@ } }, "durable-workflow": { - "product-train": "3.18.0", + "product-train": "3.18.1", "supported-server-versions": "2.5.0", "supported-server-release-baselines": { "2": "2.5.0", diff --git a/docs/portal/build/messages.md b/docs/portal/build/messages.md index c035d1fa..4037cea0 100644 --- a/docs/portal/build/messages.md +++ b/docs/portal/build/messages.md @@ -91,10 +91,14 @@ $result = $handle->update( Reuse a stable request ID when a caller may retry the same update after losing its response. Use `waitFor: 'accepted'` when another process will observe completion later. -A request ID names one command. On a Server that advertises `command_request_keys` (native 3.9.0 and later), retrying a signal or update with the same request ID and the same input returns the original receipt, and reusing it with a different name, arguments or run target throws `SignalFailed` or `UpdateFailed` whose `isRequestIdConflict()` is true. `conflict()` names what differs and `retainedCommandId()` the original command. Nothing is written, and the SDK doesn't resend it. Use a new request ID for new input. +A request ID names one command. On a Server that advertises `command_request_keys` (native 3.9.1 and later), retrying a signal or update with the same request ID and the same input returns the original receipt, and reusing it with a different name, arguments or run target throws `SignalFailed` or `UpdateFailed` whose `isRequestIdConflict()` is true. `conflict()` names what differs and `retainedCommandId()` the original command. Nothing is written, and the SDK doesn't resend it. Use a new request ID for new input. If a `waitFor: 'completed'` wait ends before the update finishes, `update()` throws `UpdateStillPending` instead of returning a value. Call `update()` again with `$pending->requestId` to keep waiting. The Server returns the same update and doesn't apply it twice. +Use `Client::updateOutcome($workflowId, $pending->updateId)` to read the same update without another admission. This typed read defaults to `waitFor: 'completed'`: it throws `UpdateStillPending` while work is pending, then returns the result, including a completed `null`. + +A pending read preserves the stable update ID. The producer may omit the original request key on this GET path; `$pending->requestId` is then an empty string for an unknown key. Continue with `updateOutcome()` and that update ID. Never submit another update using an empty, inferred or internal hashed key. + ## Follow run transitions deliberately Ordinary handle methods target the current run. `signalSelectedRun()`, `querySelectedRun()`, and `updateSelectedRun()` preserve the selected run guard. Choose selected-run methods for administrative tools whose answer must refer to one immutable run. diff --git a/docs/quickstart-contract.json b/docs/quickstart-contract.json index 0743a632..fbd87c03 100644 --- a/docs/quickstart-contract.json +++ b/docs/quickstart-contract.json @@ -3,8 +3,8 @@ "schema_version": 2, "package": { "name": "durable-workflow/sdk", - "published_version": "3.18.0", - "composer_requirement": "3.18.0", + "published_version": "3.18.1", + "composer_requirement": "3.18.1", "onboarding_requirement": "^3.18" }, "runtime_targets": { diff --git a/tests/smoke/workflow-completion-limits.php b/tests/smoke/workflow-completion-limits.php new file mode 100644 index 00000000..33f8e1d2 --- /dev/null +++ b/tests/smoke/workflow-completion-limits.php @@ -0,0 +1,166 @@ +getFileName()); + requireCompletionAcceptance($file !== false && str_starts_with($file, $root.'/'), 'Source fallback is forbidden.'); +} +requireCompletionAcceptance(function_exists('pcntl_fork') && function_exists('posix_kill'), 'The acceptance profile requires process control.'); + +$client = new Client(getenv('METRICS_RUNTIME_URL'), token: getenv('METRICS_TOKEN')); +$info = $client->clusterInfo(); +$cap = $info->limits['max_workflow_task_completion_bytes'] ?? null; +$ordinary = $info->limits['max_payload_bytes'] ?? null; +$storage = $info->namespace['external_payload_storage']['status'] ?? 'unsupported'; +requireCompletionAcceptance($info->version === getenv('SERVER_VERSION') && ($info->raw['implementation'] ?? null) === 'rust' + && $cap === 33554432 && is_int($ordinary) && $ordinary > 0 + && in_array($storage, ['unconfigured', 'disabled', 'unsupported'], true), 'Unexpected published native limits or storage state.'); +$queue = 'completion-limits-'.bin2hex(random_bytes(6)); +$evidenceFile = tempnam(sys_get_temp_dir(), 'sdk-php-completion-'); +requireCompletionAcceptance(is_string($evidenceFile), 'Cannot create owned worker evidence.'); +$pid = pcntl_fork(); +requireCompletionAcceptance($pid !== -1, 'Cannot start the acceptance worker.'); +if ($pid === 0) { + $trace = new class implements MetricsRecorder { + public array $completions = []; + public array $diagnostics = []; + public array $activityCalls = []; + public int $uploads = 0; + public function increment(string $name, float $value = 1.0, array $tags = []): void + { + if ($name === 'durable_workflow_client_requests' && ($tags['method'] ?? null) === 'POST' + && str_contains($tags['route'] ?? '', '/workflow-tasks/') && str_ends_with($tags['route'], '/complete')) { + $index = array_key_last($this->completions); + if ($index !== null) { + $this->completions[$index]['status'] = (int) $tags['status_code']; + } + } + } + public function record(string $name, float $value, array $tags = []): void {} + }; + $exit = 0; + $errorClass = null; + try { + // Observe only actual wire size, command counts and status: no headers, + // credentials or payload contents enter the evidence. + $stack = HandlerStack::create(); + $stack->push(Middleware::tap(static function (RequestInterface $request) use ($trace): void { + $path = $request->getUri()->getPath(); + if ($request->getMethod() === 'POST' && str_contains($path, '/workflow-tasks/') && str_ends_with($path, '/complete')) { + $body = (string) $request->getBody(); + $trace->completions[] = ['bytes' => strlen($body), + 'schedules' => substr_count($body, '"type":"schedule_activity"')]; + } + if (in_array($request->getMethod(), ['POST', 'PUT'], true) && str_contains($path, '/external-payloads/')) { + ++$trace->uploads; + } + })); + $workerClient = new Client(getenv('METRICS_RUNTIME_URL'), token: getenv('METRICS_TOKEN'), + transport: new Psr18Transport(new HttpClient(['handler' => $stack])), metrics: $trace); + $worker = new Worker($workerClient, $queue, diagnosticListener: static function (string $event, array $details) use ($trace): void { + if ($event === 'worker.workflow_task_completion_too_large') { + $trace->diagnostics[] = array_intersect_key($details, array_flip(['limit_kind', 'limit_bytes', 'size_bytes'])); + } + }); + $worker->registerActivity('completion.length', static function (ActivityContext $context, string $value) use ($trace): int { + $bytes = strlen($value); + $trace->activityCalls[$bytes] = ($trace->activityCalls[$bytes] ?? 0) + 1; + return $bytes; + }); + $worker->registerWorkflow('completion.fanout', static function (WorkflowContext $context, int $count, int $bytes): array { + $operations = []; + $argument = str_repeat('x', $bytes); + for ($index = 0; $index < $count; ++$index) { + $operations[] = $context->deferActivity('completion.length', [$argument]); + } + $lengths = $context->all($operations); + return [count($lengths), array_sum($lengths)]; + }); + $worker->run(1); + } catch (Throwable $error) { + $exit = 1; + $errorClass = $error::class; + } finally { + file_put_contents($evidenceFile, json_encode(['completions' => $trace->completions, + 'diagnostics' => $trace->diagnostics, 'activity_calls' => $trace->activityCalls, + 'uploads' => $trace->uploads, 'worker_error_class' => $errorClass], JSON_THROW_ON_ERROR)); + } + exit($exit); +} + +try { + $start = static fn (int $count, int $bytes) => $client->startWorkflow('completion.fanout', + 'completion-'.bin2hex(random_bytes(8)), $queue, [$count, $bytes], executionTimeoutSeconds: 180, runTimeoutSeconds: 180); + $normal = $start(300, 10000); + requireCompletionAcceptance($normal->result(timeoutSeconds: 120) === [300, 3000000], 'The valid fan-out did not complete.'); + $over = $start(60, 450000); + try { + $over->result(timeoutSeconds: 40); + throw new RuntimeException('The oversized workflow unexpectedly completed.'); + } catch (WorkflowFailed) { + // The real Worker must settle the typed HTTP413 as one structural failure. + } + $followup = $start(1, 16); + requireCompletionAcceptance($followup->result(timeoutSeconds: 30) === [1, 16], 'The same Worker did not complete the follow-up.'); + $events = iterator_to_array($client->iterateWorkflowHistory($over->workflowId, $over->selectedRunId)); + $counts = array_count_values(array_column($events, 'event_type')); + $failures = array_values(array_filter($events, static fn (array $event): bool => $event['event_type'] === 'WorkflowFailed')); + requireCompletionAcceptance(($counts['ActivityScheduled'] ?? 0) === 0 && ($counts['WorkflowTaskFailed'] ?? 0) === 0 + && count($failures) === 1 && ($failures[0]['payload']['failure_category'] ?? null) === 'structural_limit', + 'The oversized aggregate admitted partial work or did not settle structurally.'); +} finally { + posix_kill($pid, SIGTERM); + pcntl_waitpid($pid, $workerStatus); +} + +try { + $evidence = json_decode((string) file_get_contents($evidenceFile), true, flags: JSON_THROW_ON_ERROR); + requireCompletionAcceptance(pcntl_wifexited($workerStatus) && pcntl_wexitstatus($workerStatus) === 0 + && $evidence['worker_error_class'] === null, 'The Worker did not shut down cleanly.'); + $large = array_values(array_filter($evidence['completions'], static fn (array $row): bool => $row['bytes'] > $cap)); + $valid = array_values(array_filter($evidence['completions'], static fn (array $row): bool => $row['schedules'] === 300)); + requireCompletionAcceptance(count($large) === 1 && $large[0]['status'] === 413 && $large[0]['schedules'] === 60 + && count($valid) === 1 && $valid[0]['status'] === 200 && $valid[0]['bytes'] > $ordinary + && count($evidence['diagnostics']) === 1 && $evidence['diagnostics'][0]['limit_bytes'] === $cap + && $evidence['diagnostics'][0]['size_bytes'] === $large[0]['bytes'] + && ($evidence['activity_calls'][10000] ?? 0) === 300 && ($evidence['activity_calls'][450000] ?? 0) === 0 + && ($evidence['activity_calls'][16] ?? 0) === 1 && $evidence['uploads'] === 0, + 'Wire, activity, no-upload or typed-refusal evidence differs from the contract.'); + echo json_encode(['outcome' => 'pass', 'sdk_version' => $version, 'sdk_reference' => $reference, + 'server_version' => $info->version, 'php_version' => PHP_VERSION, 'ordinary_limit_bytes' => $ordinary, + 'completion_limit_bytes' => $cap, 'storage_status' => $storage, 'normal' => $valid[0], 'over' => $large[0], + 'over_history_counts' => $counts, 'structural_diagnostic' => $evidence['diagnostics'][0], + 'activity_calls' => $evidence['activity_calls'], 'uploads' => 0, 'same_worker_followup' => true, + 'source_fallback' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL; +} finally { + unlink($evidenceFile); +} From d2160cfb8f5ce2aecdb9fe42762e448029c1bae1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:09:22 +0000 Subject: [PATCH 06/13] Provide explicit maintenance bootstrap for fixture observers --- tests/Integration/CooperativeCancellationTest.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/Integration/CooperativeCancellationTest.php b/tests/Integration/CooperativeCancellationTest.php index 962bbde5..1d8cf298 100644 --- a/tests/Integration/CooperativeCancellationTest.php +++ b/tests/Integration/CooperativeCancellationTest.php @@ -2008,8 +2008,21 @@ public function testBoundedAbandonKeepsRemoteCallbackIndependentAfterParentCance private function client(?Transport $transport = null, string $namespace = 'default'): Client { - return new Client($this->runtimeUrl, namespace: $namespace, token: $this->token, + $client = new Client($this->runtimeUrl, namespace: $namespace, token: $this->token, transport: $transport); + if ($transport !== null && method_exists($client, 'withActivityLeaseBootstrap')) { + // These owner-only observers wrap the default HTTP transport. The + // independent helper recreates that same URI/auth/TLS configuration, + // without inheriting the owner's fault injection or callback pipes. + $bootstrap = $this->directory.'/activity-lease-'.hash('sha256', $namespace).'.php'; + file_put_contents($bootstrap, 'runtimeUrl, true).', namespace: '.var_export($namespace, true).', ' + .'token: getenv("DURABLE_WORKFLOW_AUTH_TOKEN") ?: "test-token");'); + chmod($bootstrap, 0600); + $client = $client->withActivityLeaseBootstrap($bootstrap); + } + + return $client; } private function queue(string $kind): string From f82a672742f99c62cd998491f88347cb3aad0fc3 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:09:42 +0000 Subject: [PATCH 07/13] Recreate the observed client explicitly in lease maintenance --- tests/smoke/workflow-completion-limits.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/smoke/workflow-completion-limits.php b/tests/smoke/workflow-completion-limits.php index 33f8e1d2..d699fe1c 100644 --- a/tests/smoke/workflow-completion-limits.php +++ b/tests/smoke/workflow-completion-limits.php @@ -47,6 +47,12 @@ function requireCompletionAcceptance(bool $condition, string $message): void $queue = 'completion-limits-'.bin2hex(random_bytes(6)); $evidenceFile = tempnam(sys_get_temp_dir(), 'sdk-php-completion-'); requireCompletionAcceptance(is_string($evidenceFile), 'Cannot create owned worker evidence.'); +$bootstrapFile = tempnam(sys_get_temp_dir(), 'sdk-php-completion-lease-'); +requireCompletionAcceptance(is_string($bootstrapFile), 'Cannot create owned maintenance bootstrap.'); +// Authentication remains in the inherited fixture environment, never in this +// file or the evidence. Only the owner needs HTTP observation middleware. +file_put_contents($bootstrapFile, 'uploads; } })); - $workerClient = new Client(getenv('METRICS_RUNTIME_URL'), token: getenv('METRICS_TOKEN'), - transport: new Psr18Transport(new HttpClient(['handler' => $stack])), metrics: $trace); + $workerClient = (new Client(getenv('METRICS_RUNTIME_URL'), token: getenv('METRICS_TOKEN'), + transport: new Psr18Transport(new HttpClient(['handler' => $stack])), metrics: $trace)) + ->withActivityLeaseBootstrap($bootstrapFile); $worker = new Worker($workerClient, $queue, diagnosticListener: static function (string $event, array $details) use ($trace): void { if ($event === 'worker.workflow_task_completion_too_large') { $trace->diagnostics[] = array_intersect_key($details, array_flip(['limit_kind', 'limit_bytes', 'size_bytes'])); @@ -163,4 +170,5 @@ public function record(string $name, float $value, array $tags = []): void {} 'source_fallback' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL; } finally { unlink($evidenceFile); + unlink($bootstrapFile); } From 82a6afec83af7e4d9adbee51b79a2c6a47402de0 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:15:59 +0000 Subject: [PATCH 08/13] Retain bounded child observations when a completion case fails --- tests/smoke/workflow-completion-limits.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/smoke/workflow-completion-limits.php b/tests/smoke/workflow-completion-limits.php index d699fe1c..1c8f202f 100644 --- a/tests/smoke/workflow-completion-limits.php +++ b/tests/smoke/workflow-completion-limits.php @@ -124,6 +124,7 @@ public function record(string $name, float $value, array $tags = []): void {} exit($exit); } +$parentError = null; try { $start = static fn (int $count, int $bytes) => $client->startWorkflow('completion.fanout', 'completion-'.bin2hex(random_bytes(8)), $queue, [$count, $bytes], executionTimeoutSeconds: 180, runTimeoutSeconds: 180); @@ -144,6 +145,8 @@ public function record(string $name, float $value, array $tags = []): void {} requireCompletionAcceptance(($counts['ActivityScheduled'] ?? 0) === 0 && ($counts['WorkflowTaskFailed'] ?? 0) === 0 && count($failures) === 1 && ($failures[0]['payload']['failure_category'] ?? null) === 'structural_limit', 'The oversized aggregate admitted partial work or did not settle structurally.'); +} catch (Throwable $error) { + $parentError = $error; } finally { posix_kill($pid, SIGTERM); pcntl_waitpid($pid, $workerStatus); @@ -151,6 +154,10 @@ public function record(string $name, float $value, array $tags = []): void {} try { $evidence = json_decode((string) file_get_contents($evidenceFile), true, flags: JSON_THROW_ON_ERROR); + if ($parentError !== null) { + fwrite(STDERR, json_encode(['parent_error_class' => $parentError::class, 'worker' => $evidence], JSON_THROW_ON_ERROR).PHP_EOL); + throw $parentError; + } requireCompletionAcceptance(pcntl_wifexited($workerStatus) && pcntl_wexitstatus($workerStatus) === 0 && $evidence['worker_error_class'] === null, 'The Worker did not shut down cleanly.'); $large = array_values(array_filter($evidence['completions'], static fn (array $row): bool => $row['bytes'] > $cap)); From 9a2213e698f8ab52e1ec89e7944e9e64a0109c8e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:17:07 +0000 Subject: [PATCH 09/13] State the retained-outcome read capability in the portal guide --- docs/portal/build/messages.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/portal/build/messages.md b/docs/portal/build/messages.md index 4037cea0..5a876022 100644 --- a/docs/portal/build/messages.md +++ b/docs/portal/build/messages.md @@ -97,6 +97,8 @@ If a `waitFor: 'completed'` wait ends before the update finishes, `update()` thr Use `Client::updateOutcome($workflowId, $pending->updateId)` to read the same update without another admission. This typed read defaults to `waitFor: 'completed'`: it throws `UpdateStillPending` while work is pending, then returns the result, including a completed `null`. +This read requires the `update_outcome_reads` capability (native Server 3.6 and later). Without it, the SDK throws `LogicException` and does not resubmit the update. + A pending read preserves the stable update ID. The producer may omit the original request key on this GET path; `$pending->requestId` is then an empty string for an unknown key. Continue with `updateOutcome()` and that update ID. Never submit another update using an empty, inferred or internal hashed key. ## Follow run transitions deliberately From 985c20ada045177dfcbcfd9f2a514fe2762931fe Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:21:21 +0000 Subject: [PATCH 10/13] Use zero-time polling for the bounded fan-out smoke --- tests/smoke/workflow-completion-limits.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/smoke/workflow-completion-limits.php b/tests/smoke/workflow-completion-limits.php index 1c8f202f..63ac6bfb 100644 --- a/tests/smoke/workflow-completion-limits.php +++ b/tests/smoke/workflow-completion-limits.php @@ -112,7 +112,9 @@ public function record(string $name, float $value, array $tags = []): void {} $lengths = $context->all($operations); return [count($lengths), array_sum($lengths)]; }); - $worker->run(1); + // Match the other bounded installed smokes: avoid an idle long-poll + // delay before every activity in this serial functional fixture. + $worker->run(0); } catch (Throwable $error) { $exit = 1; $errorClass = $error::class; From 4bab37cc58d47f876976853e211f31dc8f80293a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:39:29 +0000 Subject: [PATCH 11/13] Exclude CI tooling and qualify candidate archives on the protected PHP path --- .gitattributes | 4 ++ .../workflows/published-worker-metrics.yml | 52 +++++++++++++++++++ CHANGELOG.md | 2 + composer.json | 3 ++ 4 files changed, 61 insertions(+) diff --git a/.gitattributes b/.gitattributes index 18e14aad..c8693b65 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ /tests export-ignore +/.github export-ignore +/docker export-ignore +/scripts/ci export-ignore +/.dockerignore export-ignore diff --git a/.github/workflows/published-worker-metrics.yml b/.github/workflows/published-worker-metrics.yml index b797ebfc..cf81f88b 100644 --- a/.github/workflows/published-worker-metrics.yml +++ b/.github/workflows/published-worker-metrics.yml @@ -16,6 +16,10 @@ on: description: Qualify completion limits on the immutable native 3.9.1 tuple type: boolean default: false + candidate_source: + description: Optional exact SDK commit for prepublication dist qualification only + type: string + required: false permissions: contents: read @@ -58,10 +62,56 @@ jobs: - name: Verify Composer trust before requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust - name: Install only the published SDK + if: inputs.candidate_source == '' run: | [[ "$SDK_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] composer init --working-dir="$CONSUMER" --name=durable-workflow/published-metrics --no-interaction composer require --working-dir="$CONSUMER" "durable-workflow/sdk:$SDK_VERSION" --no-scripts --no-interaction --prefer-dist --no-progress + - name: Validate an optional prepublication source + if: inputs.candidate_source != '' + env: + CANDIDATE_SOURCE: ${{ inputs.candidate_source }} + run: '[[ "$CANDIDATE_SOURCE" =~ ^[0-9a-f]{40}$ ]]' + - name: Check out only the selected public SDK candidate + if: inputs.candidate_source != '' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.candidate_source }} + path: .candidate-sdk + persist-credentials: false + - name: Prepare and inspect the exact candidate dist + if: inputs.candidate_source != '' + env: + CANDIDATE_SOURCE: ${{ inputs.candidate_source }} + run: | + python3 -I -S -B - <<'PY' + import hashlib,json,os,pathlib,subprocess,zipfile + source=pathlib.Path('.candidate-sdk') + commit=os.environ['CANDIDATE_SOURCE'] + assert subprocess.check_output(['git','-C',str(source),'rev-parse','HEAD'],text=True).strip()==commit + consumer=pathlib.Path(os.environ['CONSUMER']) + archive=consumer/'candidate.zip' + subprocess.run(['git','-C',str(source),'archive','--format=zip','--prefix=sdk/','--output',str(archive),commit],check=True) + with zipfile.ZipFile(archive) as package: + names={name.removeprefix('sdk/') for name in package.namelist()} + excluded=('.github/','docker/','scripts/ci/','tests/') + assert not any(name.startswith(excluded) or name=='.dockerignore' for name in names) + expected=subprocess.check_output(['git','-C',str(source),'ls-files','src','examples','resources','config','docs/quickstart-contract.json','docs/laravel-adoption-contract.json'],text=True).splitlines() + assert expected and set(expected)<=names + manifest=json.loads((source/'composer.json').read_text()) + assert manifest['name']=='durable-workflow/sdk' and manifest['type']=='library' + assert manifest['extra']['durable-workflow']['product-train']==os.environ['SDK_VERSION'] + manifest.update(version=os.environ['SDK_VERSION'],dist={'type':'zip','url':archive.as_uri(),'reference':commit}) + (consumer/'composer.json').write_text(json.dumps({'name':'durable-workflow/candidate-worker','repositories':[{'type':'package','package':manifest}],'require':{'durable-workflow/sdk':os.environ['SDK_VERSION']},'config':{'allow-plugins':{}}})+'\n') + pathlib.Path('candidate-package.json').write_text(json.dumps({'stage':'prepublication','source':commit,'archive_sha256':hashlib.sha256(archive.read_bytes()).hexdigest(),'retained_runtime_inputs':len(expected),'internal_ci_tooling':False})+'\n') + PY + - name: Verify Composer trust before candidate requests + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + working-directory: ${{ env.CONSUMER }} + - name: Install only the selected candidate dist + if: inputs.candidate_source != '' + run: composer install --working-dir="$CONSUMER" --no-scripts --no-interaction --prefer-dist --no-progress - name: Start the immutable published native Server run: | docker pull "$SERVER_IMAGE" @@ -98,6 +148,8 @@ jobs: worker-metrics.json update-outcome.json workflow-completion-limits.json + candidate-package.json + ${{ env.CONSUMER }}/candidate.zip ${{ env.CONSUMER }}/composer.lock retention-days: 14 - name: Show bounded Server diagnostics diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e374e6..d194f4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ project follows [Semantic Versioning](https://semver.org/). - Pending update guidance distinguishes admission waits, which retain the original request key, from GET-by-ID reads, where that key may be unavailable. Repeated reads preserve the update ID and never admit a second update (.github#212). +- Distribution archives exclude internal CI and container tooling. Runtime + classes, resources, examples and public contracts remain included. ## [3.18.0] - 2026-11-13 diff --git a/composer.json b/composer.json index 94e97cbf..a79b5172 100644 --- a/composer.json +++ b/composer.json @@ -84,6 +84,9 @@ }, "sort-packages": true }, + "archive": { + "exclude": ["/.github", "/docker", "/scripts/ci", "/tests", "/.dockerignore"] + }, "extra": { "laravel": { "providers": [ From d98382e687b9dae6af721ea48963cdd52b3bfc1e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:50:54 +0000 Subject: [PATCH 12/13] Keep the planner release separate from cooperative CI migration --- .github/workflows/ci.yml | 50 ++++--------------- .github/workflows/published-cancellation.yml | 36 ++++--------- .../service-mode-published-smoke.yml | 38 ++++---------- .../CooperativeCancellationTest.php | 15 +----- 4 files changed, 31 insertions(+), 108 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4730da37..ac1ae4a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -774,7 +774,7 @@ jobs: cooperative-integration: name: Connected cooperative cancellation if: ${{ github.event_name == 'workflow_dispatch' && inputs.cooperative_qualification }} - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest timeout-minutes: 25 env: COMPOSE_PROJECT_NAME: php-cooperative-${{ github.run_id }}-${{ github.run_attempt }} @@ -786,40 +786,22 @@ jobs: with: path: sdk-php persist-credentials: false - - name: Select Ubuntu's Python 3.12 - run: | - /usr/bin/python3 -I -S -c 'import sys; assert sys.version_info[:2] == (3, 12)' - echo /usr/bin >> "$GITHUB_PATH" - - name: Create isolated Composer state - run: | - state="$(mktemp -d "$RUNNER_TEMP/php-check.XXXXXX")" - mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" - { - echo "COMPOSER_HOME=$state/home" - echo "COMPOSER_CACHE_DIR=$state/cache" - echo "TMPDIR=$state/tmp" - } >> "$GITHUB_ENV" + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' - name: Select authenticated dependency trust id: trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: clients: pip,python,composer,curl,git,cargo - 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: durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7 # bound-linux-php + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.3' - ini-file: production - tools: composer:2.10.3,phpunit:11.5.57 + extensions: pcntl, posix + tools: composer:2.10.3 coverage: none - - name: Require the stock cooperative worker capabilities - run: | - php -r 'foreach (["pcntl", "posix"] as $extension) { - if (!extension_loaded($extension)) { throw new RuntimeException("Missing ".$extension); } - } - foreach (["pcntl_fork", "pcntl_waitpid", "pcntl_signal", "pcntl_async_signals", "posix_kill"] as $function) { - if (!function_exists($function)) { throw new RuntimeException("Missing ".$function); } - }' - name: Check out the exact public Server source env: SERVER_COMMIT: ${{ inputs.server_commit }} @@ -856,7 +838,7 @@ jobs: '{qualification:"source",sdk_commit:$sdk,server_commit:$server,native_source_overlay:($native | if length > 0 then . else null end),python_sdk_commit:($python | if length > 0 then . else null end),rust_sdk_commit:($rust | if length > 0 then . else null end),cli_source_commit:($cli | if length > 0 then . else null end)}' \ > sdk-php/cooperative-source-provenance.json - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - name: Bind exact CLI inspection source if: ${{ inputs.cli_commit != '' }} env: @@ -917,7 +899,7 @@ jobs: printf 'DURABLE_WORKFLOW_POLYGLOT_RUST_BINARY=%s\n' "$binary" >> "$GITHUB_ENV" printf 'DURABLE_WORKFLOW_POLYGLOT_ARTIFACT_DIRECTORY=%s/sdk-php/polyglot-evidence\n' "$GITHUB_WORKSPACE" >> "$GITHUB_ENV" - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: working-directory: sdk-php - run: composer install --no-interaction --prefer-dist --no-progress @@ -928,7 +910,7 @@ jobs: # and must stay immediately before the one registered build command. - name: Bind the Server build's container Composer trust id: container-trust - uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@06efa32de898cff5766ef9f3a9db6a368fced981 # composer-container-trust + uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@699d09d602fc0353160bdecaa2b23f07ef4c7c21 # clean-trust with: request: sdk-php-cooperative-server source-directory: server @@ -1003,16 +985,6 @@ jobs: DW_SERVER_SOURCE_COMMIT: ${{ steps.container-trust.outputs.source-commit }} DW_SERVER_DOCKERFILE_SHA256: ${{ steps.container-trust.outputs.dockerfile-sha256 }} run: docker compose down -v --rmi local - - name: Remove only the verified staged public CA - if: ${{ always() && steps.container-trust.outcome == 'success' }} - working-directory: sdk-php - env: - VERIFIER_CONTEXT: ${{ steps.container-trust.outputs.context-directory }} - STAGED_CA: ${{ steps.container-trust.outputs.ca-file }} - CA_SHA256: ${{ steps.container-trust.outputs.ca-sha256 }} - run: >- - python3 -I -S -B "$VERIFIER_CONTEXT/../../../scripts/composer_container_trust.py" cleanup-ca - --ca-file "$STAGED_CA" --ca-sha256 "$CA_SHA256" --compose-directory . target-branch-qualification: name: Target branch qualification diff --git a/.github/workflows/published-cancellation.yml b/.github/workflows/published-cancellation.yml index d2bd4492..a2151a78 100644 --- a/.github/workflows/published-cancellation.yml +++ b/.github/workflows/published-cancellation.yml @@ -33,7 +33,7 @@ permissions: jobs: mixed-cascade: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest timeout-minutes: 20 env: COMPOSE_PROJECT_NAME: published-cancellation-${{ github.run_id }}-${{ github.run_attempt }} @@ -60,44 +60,26 @@ jobs: for version in "$PHP_SDK_VERSION" "$PYTHON_SDK_VERSION" "$RUST_SDK_VERSION" "$CLI_VERSION"; do [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[1-9][0-9]*)?$ ]] done - - name: Select Ubuntu's Python 3.12 - run: | - /usr/bin/python3 -I -S -c 'import sys; assert sys.version_info[:2] == (3, 12)' - echo /usr/bin >> "$GITHUB_PATH" - - name: Create isolated Composer state - run: | - state="$(mktemp -d "$RUNNER_TEMP/php-check.XXXXXX")" - mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" - { - echo "COMPOSER_HOME=$state/home" - echo "COMPOSER_CACHE_DIR=$state/cache" - echo "TMPDIR=$state/tmp" - } >> "$GITHUB_ENV" + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' - name: Bootstrap published-consumer dependency trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: clients: pip,python,composer,curl,git,cargo - 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: durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7 # bound-linux-php + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.3' - ini-file: production - tools: composer:2.10.3,phpunit:11.5.57 + extensions: pcntl, posix + tools: composer:2.10.3 coverage: none - - name: Require the stock cooperative worker capabilities - run: | - php -r 'foreach (["pcntl", "posix"] as $extension) { - if (!extension_loaded($extension)) { throw new RuntimeException("Missing ".$extension); } - } - foreach (["pcntl_fork", "pcntl_waitpid", "pcntl_signal", "pcntl_async_signals", "posix_kill"] as $function) { - if (!function_exists($function)) { throw new RuntimeException("Missing ".$function); } - }' - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: toolchain: 1.86.0 - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - name: Install the published SDKs and CLI run: | set -euo pipefail diff --git a/.github/workflows/service-mode-published-smoke.yml b/.github/workflows/service-mode-published-smoke.yml index f2c6c945..10462562 100644 --- a/.github/workflows/service-mode-published-smoke.yml +++ b/.github/workflows/service-mode-published-smoke.yml @@ -28,7 +28,7 @@ permissions: jobs: source-free-service-mode: if: github.ref == 'refs/heads/main' - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest timeout-minutes: 10 env: DURABLE_WORKFLOW_SERVER_URL: http://127.0.0.1:8080 @@ -54,42 +54,24 @@ jobs: exit 1 fi - - name: Select Ubuntu's Python 3.12 - run: | - /usr/bin/python3 -I -S -c 'import sys; assert sys.version_info[:2] == (3, 12)' - echo /usr/bin >> "$GITHUB_PATH" - - name: Create isolated Composer state - run: | - state="$(mktemp -d "$RUNNER_TEMP/php-check.XXXXXX")" - mkdir -m 700 "$state/home" "$state/cache" "$state/tmp" - { - echo "COMPOSER_HOME=$state/home" - echo "COMPOSER_CACHE_DIR=$state/cache" - echo "TMPDIR=$state/tmp" - } >> "$GITHUB_ENV" + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.12' - name: Select authenticated dependency trust - uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: 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: durable-workflow/.github/.github/actions/setup-php-linux@411a14ac07d32073eed6ab4977f2825eba4739a7 # bound-linux-php + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: '8.3' - ini-file: production - tools: composer:2.10.3,phpunit:11.5.57 + extensions: pcntl, posix + tools: composer:2.10.3 coverage: none - - name: Require the stock cooperative worker capabilities - run: | - php -r 'foreach (["pcntl", "posix"] as $extension) { - if (!extension_loaded($extension)) { throw new RuntimeException("Missing ".$extension); } - } - foreach (["pcntl_fork", "pcntl_waitpid", "pcntl_signal", "pcntl_async_signals", "posix_kill"] as $function) { - if (!function_exists($function)) { throw new RuntimeException("Missing ".$function); } - }' - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - name: Resolve the immutable published SDK release env: SDK_VERSION: ${{ inputs.sdk_version }} @@ -128,7 +110,7 @@ jobs: PHP - name: Verify Composer trust before requests - uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - name: Install only the published package env: SDK_VERSION: ${{ inputs.sdk_version }} diff --git a/tests/Integration/CooperativeCancellationTest.php b/tests/Integration/CooperativeCancellationTest.php index 1d8cf298..962bbde5 100644 --- a/tests/Integration/CooperativeCancellationTest.php +++ b/tests/Integration/CooperativeCancellationTest.php @@ -2008,21 +2008,8 @@ public function testBoundedAbandonKeepsRemoteCallbackIndependentAfterParentCance private function client(?Transport $transport = null, string $namespace = 'default'): Client { - $client = new Client($this->runtimeUrl, namespace: $namespace, token: $this->token, + return new Client($this->runtimeUrl, namespace: $namespace, token: $this->token, transport: $transport); - if ($transport !== null && method_exists($client, 'withActivityLeaseBootstrap')) { - // These owner-only observers wrap the default HTTP transport. The - // independent helper recreates that same URI/auth/TLS configuration, - // without inheriting the owner's fault injection or callback pipes. - $bootstrap = $this->directory.'/activity-lease-'.hash('sha256', $namespace).'.php'; - file_put_contents($bootstrap, 'runtimeUrl, true).', namespace: '.var_export($namespace, true).', ' - .'token: getenv("DURABLE_WORKFLOW_AUTH_TOKEN") ?: "test-token");'); - chmod($bootstrap, 0600); - $client = $client->withActivityLeaseBootstrap($bootstrap); - } - - return $client; } private function queue(string $kind): string From 9df0bf427710a33579110e73b99295b6ef447902 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:57:52 +0000 Subject: [PATCH 13/13] Exclude Git metadata and verify actual Composer archives Keep only the accepted container verifier pin and exact CA cleanup prerequisite; cooperative profile and fixture migration remain separate. --- .github/workflows/ci.yml | 14 ++++++- composer.json | 2 +- tests/ci/test_composer_archive.py | 68 +++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tests/ci/test_composer_archive.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac1ae4a4..082f6299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -727,6 +727,8 @@ jobs: exit 1 fi grep -F 'Installed Composer product-train identifies' "$RUNNER_TEMP/stale-release-metadata.log" + - name: Reject Git metadata and CI tooling in real Composer archives + run: python3 -I -S -B tests/ci/test_composer_archive.py example-lock-audit: name: Laravel example lock audit @@ -910,7 +912,7 @@ jobs: # and must stay immediately before the one registered build command. - name: Bind the Server build's container Composer trust id: container-trust - uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@699d09d602fc0353160bdecaa2b23f07ef4c7c21 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@06efa32de898cff5766ef9f3a9db6a368fced981 # composer-container-trust with: request: sdk-php-cooperative-server source-directory: server @@ -985,6 +987,16 @@ jobs: DW_SERVER_SOURCE_COMMIT: ${{ steps.container-trust.outputs.source-commit }} DW_SERVER_DOCKERFILE_SHA256: ${{ steps.container-trust.outputs.dockerfile-sha256 }} run: docker compose down -v --rmi local + - name: Remove only the verified staged public CA + if: ${{ always() && steps.container-trust.outcome == 'success' }} + working-directory: sdk-php + env: + VERIFIER_CONTEXT: ${{ steps.container-trust.outputs.context-directory }} + STAGED_CA: ${{ steps.container-trust.outputs.ca-file }} + CA_SHA256: ${{ steps.container-trust.outputs.ca-sha256 }} + run: >- + python3 -I -S -B "$VERIFIER_CONTEXT/../../../scripts/composer_container_trust.py" cleanup-ca + --ca-file "$STAGED_CA" --ca-sha256 "$CA_SHA256" --compose-directory . target-branch-qualification: name: Target branch qualification diff --git a/composer.json b/composer.json index a79b5172..751b0777 100644 --- a/composer.json +++ b/composer.json @@ -85,7 +85,7 @@ "sort-packages": true }, "archive": { - "exclude": ["/.github", "/docker", "/scripts/ci", "/tests", "/.dockerignore"] + "exclude": ["/.git", "/.github", "/docker", "/scripts/ci", "/tests", "/.dockerignore"] }, "extra": { "laravel": { diff --git a/tests/ci/test_composer_archive.py b/tests/ci/test_composer_archive.py new file mode 100644 index 00000000..81f04e6b --- /dev/null +++ b/tests/ci/test_composer_archive.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Exercise Composer's actual archive filters for both Git checkout shapes.""" + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import zipfile + + +def run(*command, cwd=None): + return subprocess.check_output(command, cwd=cwd, text=True, stderr=subprocess.STDOUT) + + +def check(source): + required = run('git', 'ls-files', 'src', 'examples', 'resources', 'config', + 'docs/quickstart-contract.json', 'docs/laravel-adoption-contract.json', cwd=source).splitlines() + assert required + with tempfile.TemporaryDirectory(prefix='sdk-archive-private-marker-') as temporary: + state = Path(temporary) + ordinary = state / 'ordinary' + ordinary.mkdir() + # Materialize tracked bytes without exporting Git's filters first: the + # Composer invocation must prove its own CI/tooling exclusion policy. + for name in run('git', 'ls-files', cwd=source).splitlines(): + original = source / name + target = ordinary / name + target.parent.mkdir(parents=True, exist_ok=True) + if original.is_symlink(): + target.symlink_to(os.readlink(original)) + else: + shutil.copy2(original, target) + run('git', 'init', '-q', cwd=ordinary) + run('git', 'add', '-f', '.', cwd=ordinary) + run('git', '-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgSign=false', + '-c', 'user.name=Archive fixture', '-c', 'user.email=archive@example.invalid', + 'commit', '-qm', 'Archive fixture', cwd=ordinary) + linked = state / 'linked' + run('git', 'worktree', 'add', '--detach', str(linked), 'HEAD', cwd=ordinary) + assert (ordinary / '.git').is_dir() and (linked / '.git').is_file() + for checkout in (ordinary, linked): + output = state / (checkout.name + '-output') + output.mkdir() + run('composer', '--no-plugins', '--no-scripts', '--no-interaction', + 'archive', '--format=zip', '--dir=' + str(output), '--file=sdk', cwd=checkout) + with zipfile.ZipFile(output / 'sdk.zip') as archive: + names = {name.removeprefix('./') for name in archive.namelist()} + forbidden = ('.git/', '.github/', 'docker/', 'scripts/ci/', 'tests/') + assert not any(name in ('.git', '.dockerignore') or name.startswith(forbidden) + for name in names), 'Archive includes repository control or CI tooling' + assert set(required) <= names, 'Archive omits required package inputs' + for name in required: + assert archive.read(name) == (source / name).read_bytes(), name + for name in names: + if not name.endswith('/'): + content = archive.read(name) + assert str(state).encode() not in content, 'Archive leaks its private worktree path' + assert str(source).encode() not in content, 'Archive leaks the source checkout path' + print(checkout.name + ': real Composer ZIP excludes Git/CI/private paths and retains ' + + str(len(required)) + ' exact package inputs') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[2]) + check(parser.parse_args().source.resolve(strict=True))