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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
/tests export-ignore
/.github export-ignore
/docker export-ignore
/scripts/ci export-ignore
/.dockerignore export-ignore
14 changes: 13 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
70 changes: 66 additions & 4 deletions .github/workflows/published-worker-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,24 @@ 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
candidate_source:
description: Optional exact SDK commit for prepublication dist qualification only
type: string
required: false

permissions:
contents: read
Expand All @@ -22,8 +30,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:
Expand Down Expand Up @@ -54,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"
Expand All @@ -80,6 +134,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
Expand All @@ -88,6 +147,9 @@ jobs:
path: |
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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +23,12 @@ 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).
- Distribution archives exclude internal CI and container tooling. Runtime
classes, resources, examples and public contracts remain included.

## [3.18.0] - 2026-11-13

### Added
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@
},
"sort-packages": true
},
"archive": {
"exclude": ["/.git", "/.github", "/docker", "/scripts/ci", "/tests", "/.dockerignore"]
},
"extra": {
"laravel": {
"providers": [
Expand All @@ -94,7 +97,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",
Expand Down
8 changes: 7 additions & 1 deletion docs/portal/build/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,16 @@ $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`.

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

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.
Expand Down
4 changes: 2 additions & 2 deletions docs/quickstart-contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
68 changes: 68 additions & 0 deletions tests/ci/test_composer_archive.py
Original file line number Diff line number Diff line change
@@ -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()

Check warning on line 42 in tests/ci/test_composer_archive.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this composite assertion into separate assertions.

See more on https://sonarcloud.io/project/issues?id=durable-workflow_sdk-php&issues=AaHVNo9DH6_68wC1tSfj&open=AaHVNo9DH6_68wC1tSfj&pullRequest=411
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))
Loading
Loading