From 972531921dcc91830a42e7b098dd450903e957a6 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 1 Sep 2026 11:27:50 +0000 Subject: [PATCH 1/3] Simplify the Sample App first-run experience --- .devcontainer/docker/Dockerfile | 2 +- .github/workflows/ci.yml | 196 +- .github/workflows/devcontainer-image-pr.yml | 144 +- .github/workflows/devcontainer-image.yml | 742 +------- .github/workflows/polyglot-validation.yml | 89 +- .github/workflows/public-boundary.yml | 1 - .github/workflows/smoke.yml | 106 +- LICENSE | 21 + README.md | 752 ++------ composer.json | 10 +- composer.lock | 471 +++-- docs/release-notes-feature-contract.md | 117 -- microservice/composer.json | 6 +- microservice/composer.lock | 735 ++++---- playground/php-runtime/composer.json | 2 +- playground/php-runtime/composer.lock | 212 +-- playground/templates/rust/Cargo.lock | 4 +- playground/templates/rust/Cargo.toml | 2 +- polyglot/README.md | 356 +--- polyglot/python_worker/scripts/smoke.sh | 6 +- polyglot/qualified-artifact-tuple.json | 16 +- polyglot/rust_worker/Cargo.lock | 4 +- polyglot/rust_worker/Cargo.toml | 2 +- polyglot/service-mode.yml | 340 ---- ...qualify-devcontainer-database-overrides.sh | 149 -- scripts/ci/qualify-devcontainer-image.sh | 599 +----- scripts/ci/run-service-mode-dialog-visual.mjs | 144 -- scripts/ci/summarize-devcontainer-evidence.py | 326 ---- scripts/ci/test-workflow-routing.py | 343 ---- .../ci/validate-composer-artifact-graph.php | 15 +- scripts/ci/validate-service-mode-evidence.py | 599 ------ scripts/ci/waterline-mount-readiness.mjs | 245 --- scripts/compose-conformance.sh | 603 ------ scripts/compose-smoke-conformance.sh | 8 - scripts/compose-smoke.sh | 28 +- scripts/playground | 1 - scripts/polyglot-validation.sh | 451 ----- scripts/polyglot.sh | 5 +- scripts/resolve-current-artifacts.sh | 520 ++--- scripts/service-mode.sh | 275 --- scripts/setup-service-mode-app.sh | 66 - scripts/setup-service-mode-python.sh | 56 - .../ConformanceHarnessContractTest.php | 185 -- tests/Node/ServiceModeDialogVisualTest.mjs | 40 - tests/Node/ServiceModeRunDetailVisualTest.mjs | 66 - .../Unit/CodespacesOnboardingContractTest.php | 104 - tests/Unit/ComposeConformanceBudgetTest.php | 394 ---- tests/Unit/ComposeScriptContractTest.php | 135 -- tests/Unit/DevcontainerImageContractTest.php | 1665 +---------------- tests/Unit/OnboardingVersionPinsTest.php | 31 - tests/Unit/PolyglotComposeContractTest.php | 1657 +--------------- .../ServiceModeOnboardingContractTest.php | 394 ---- 52 files changed, 1503 insertions(+), 11937 deletions(-) create mode 100644 LICENSE delete mode 100644 docs/release-notes-feature-contract.md delete mode 100644 polyglot/service-mode.yml delete mode 100755 scripts/ci/qualify-devcontainer-database-overrides.sh delete mode 100644 scripts/ci/run-service-mode-dialog-visual.mjs delete mode 100755 scripts/ci/summarize-devcontainer-evidence.py delete mode 100644 scripts/ci/test-workflow-routing.py delete mode 100755 scripts/ci/validate-service-mode-evidence.py delete mode 100644 scripts/ci/waterline-mount-readiness.mjs delete mode 100755 scripts/compose-conformance.sh delete mode 100755 scripts/compose-smoke-conformance.sh delete mode 100755 scripts/polyglot-validation.sh delete mode 100755 scripts/service-mode.sh delete mode 100755 scripts/setup-service-mode-app.sh delete mode 100755 scripts/setup-service-mode-python.sh delete mode 100644 tests/Feature/ConformanceHarnessContractTest.php delete mode 100644 tests/Node/ServiceModeDialogVisualTest.mjs delete mode 100644 tests/Node/ServiceModeRunDetailVisualTest.mjs delete mode 100644 tests/Unit/CodespacesOnboardingContractTest.php delete mode 100644 tests/Unit/ComposeConformanceBudgetTest.php delete mode 100644 tests/Unit/ComposeScriptContractTest.php delete mode 100644 tests/Unit/OnboardingVersionPinsTest.php delete mode 100644 tests/Unit/ServiceModeOnboardingContractTest.php diff --git a/.devcontainer/docker/Dockerfile b/.devcontainer/docker/Dockerfile index 4b89960..53549e6 100644 --- a/.devcontainer/docker/Dockerfile +++ b/.devcontainer/docker/Dockerfile @@ -87,7 +87,7 @@ RUN set -eux; \ FROM php:8.4-cli-bookworm ARG VCS_REF=unknown -ARG DURABLE_WORKFLOW_CLI_VERSION=2.0.0-rc.36 +ARG DURABLE_WORKFLOW_CLI_VERSION=2.0.0 LABEL org.opencontainers.image.title="Durable Workflow Sample App development container" \ org.opencontainers.image.description="PHP, Python, Rust, Docker, dw, Node, Composer, and Chromium environment for the Durable Workflow Sample App" \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5c21e7..d33fe74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: ci on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] workflow_dispatch: permissions: @@ -13,15 +13,12 @@ permissions: jobs: test: name: php artisan test (php ${{ matrix.php }}) - if: ${{ github.server_url == 'https://github.com' }} runs-on: ubuntu-latest timeout-minutes: 30 - strategy: fail-fast: false matrix: php: ['8.4', '8.5'] - services: mysql: image: mysql:8.0 @@ -37,7 +34,6 @@ jobs: --health-interval=5s --health-timeout=5s --health-retries=20 - redis: image: redis:7-alpine ports: @@ -47,69 +43,54 @@ jobs: --health-interval=3s --health-timeout=3s --health-retries=10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: ${{ matrix.php }} extensions: mbstring, intl, pdo_mysql, redis, bcmath, gd, zip coverage: none tools: composer:v2 - - name: Validate composer.json - run: composer validate --strict --no-check-all - - - name: Cache Composer packages - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: vendor - key: ${{ github.event_name }}-${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('composer.lock') }} - - name: Install dependencies run: composer install --prefer-dist --no-progress --no-interaction - - name: Run Node regression tests against the installed packages - run: npm run test:node - - - name: Install the isolated framework-neutral PHP playground runtime - run: >- - composer install --working-dir=playground/php-runtime - --prefer-dist --no-dev --no-progress --no-interaction - - - name: Record the exact resolved Durable Workflow graph - run: php scripts/ci/validate-composer-artifact-graph.php - - - name: Verify sandbox contract guidance against the installed AI package - run: composer docs:check-ai-contract - - - name: Prepare environment file - run: cp .env.example .env - - - name: Generate application key - run: php artisan key:generate + - name: Validate packages and stable artifact tuple + run: | + composer validate --strict --no-check-all + composer audit --locked + composer install --working-dir=playground/php-runtime --prefer-dist --no-dev --no-progress --no-interaction + scripts/resolve-current-artifacts.sh + php scripts/ci/validate-composer-artifact-graph.php + composer docs:check-ai-contract - - name: Configure service connection + - name: Prepare application run: | + cp .env.example .env + php artisan key:generate if getent hosts mysql >/dev/null 2>&1; then - echo "DB_HOST=mysql" >> "$GITHUB_ENV" - echo "DB_PORT=3306" >> "$GITHUB_ENV" - echo "SHARED_DB_HOST=mysql" >> "$GITHUB_ENV" - echo "SHARED_DB_PORT=3306" >> "$GITHUB_ENV" - echo "REDIS_HOST=redis" >> "$GITHUB_ENV" - echo "REDIS_PORT=6379" >> "$GITHUB_ENV" + { + echo "DB_HOST=mysql" + echo "DB_PORT=3306" + echo "SHARED_DB_HOST=mysql" + echo "SHARED_DB_PORT=3306" + echo "REDIS_HOST=redis" + echo "REDIS_PORT=6379" + } >> "$GITHUB_ENV" else - echo "DB_HOST=127.0.0.1" >> "$GITHUB_ENV" - echo "DB_PORT=${{ job.services.mysql.ports[3306] }}" >> "$GITHUB_ENV" - echo "SHARED_DB_HOST=127.0.0.1" >> "$GITHUB_ENV" - echo "SHARED_DB_PORT=${{ job.services.mysql.ports[3306] }}" >> "$GITHUB_ENV" - echo "REDIS_HOST=127.0.0.1" >> "$GITHUB_ENV" - echo "REDIS_PORT=${{ job.services.redis.ports[6379] }}" >> "$GITHUB_ENV" + { + echo "DB_HOST=127.0.0.1" + echo "DB_PORT=${{ job.services.mysql.ports['3306'] }}" + echo "SHARED_DB_HOST=127.0.0.1" + echo "SHARED_DB_PORT=${{ job.services.mysql.ports['3306'] }}" + echo "REDIS_HOST=127.0.0.1" + echo "REDIS_PORT=${{ job.services.redis.ports['6379'] }}" + } >> "$GITHUB_ENV" fi - - name: Run test suite + - name: Run application tests env: DB_CONNECTION: mysql DB_DATABASE: sample @@ -119,23 +100,21 @@ jobs: SHARED_DB_USERNAME: laravel SHARED_DB_PASSWORD: password REDIS_CLIENT: phpredis - run: php artisan test + run: | + php artisan test + npm run test:node microservice-test: name: microservice composer and tests (php ${{ matrix.php }}) - if: ${{ github.server_url == 'https://github.com' }} runs-on: ubuntu-latest timeout-minutes: 15 - defaults: run: working-directory: microservice - strategy: fail-fast: false matrix: php: ['8.4', '8.5'] - services: mysql: image: mysql:8.0 @@ -151,70 +130,54 @@ jobs: --health-interval=5s --health-timeout=5s --health-retries=20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 with: php-version: ${{ matrix.php }} extensions: mbstring, intl, pdo_mysql, bcmath, gd, zip coverage: none tools: composer:v2 - - name: Validate microservice composer.json and composer.lock - run: composer validate --strict --check-lock --no-check-all - - - name: Install microservice dependencies from lock - run: composer install --prefer-dist --no-progress --no-interaction - - - name: Audit locked microservice dependencies - run: composer audit --locked - - - name: Run microservice test suite + - name: Install and test env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 - DB_PORT: ${{ job.services.mysql.ports[3306] }} + DB_PORT: ${{ job.services.mysql.ports['3306'] }} DB_DATABASE: microservice DB_USERNAME: laravel DB_PASSWORD: password SHARED_DB_HOST: 127.0.0.1 - SHARED_DB_PORT: ${{ job.services.mysql.ports[3306] }} + SHARED_DB_PORT: ${{ job.services.mysql.ports['3306'] }} SHARED_DB_DATABASE: microservice SHARED_DB_USERNAME: laravel SHARED_DB_PASSWORD: password - run: php artisan test + run: | + composer validate --strict --check-lock --no-check-all + composer install --prefer-dist --no-progress --no-interaction + composer audit --locked + php artisan test action-policy: name: Central action policy preflight - if: ${{ github.server_url == 'https://github.com' }} runs-on: ubuntu-latest timeout-minutes: 5 - steps: - - name: Check out candidate source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - - name: Check out central action policy - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: durable-workflow/.github ref: main path: .central-action-policy persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: "3.13" - - - name: Install policy dependencies - run: python -m pip install PyYAML==6.0.2 - + python-version: '3.13' + - run: python -m pip install PyYAML==6.0.2 - name: Require centrally approved immutable action commits run: >- python .central-action-policy/scripts/qualification_policy.py validate @@ -224,59 +187,14 @@ jobs: target-branch-qualification: name: Target branch qualification - if: ${{ always() }} + if: always() needs: [test, microservice-test, action-policy] runs-on: ubuntu-latest timeout-minutes: 2 steps: - - name: Check out candidate source - if: ${{ github.server_url != 'https://github.com' }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Validate bounded candidate workload - if: ${{ github.server_url != 'https://github.com' }} - run: | - python3 scripts/ci/test-workflow-routing.py - python3 -m unittest discover -s polyglot/python_worker/tests -v - php scripts/ci/validate-composer-artifact-graph.php - php scripts/ci/validate-ai-contract-docs.php - git diff --check - while IFS= read -r -d '' script; do - bash -n "$script" - done < <(find scripts -type f -name '*.sh' -print0) - - - name: Scan candidate public boundary - if: ${{ github.server_url != 'https://github.com' }} - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - BEFORE_SHA: ${{ github.event.before }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - range="-1 HEAD" - if [[ "$GITHUB_EVENT_NAME" == pull_request && -n "$BASE_SHA" && -n "$HEAD_SHA" ]]; then - range="$BASE_SHA..$HEAD_SHA" - elif [[ -n "$BEFORE_SHA" && ! "$BEFORE_SHA" =~ ^0+$ ]]; then - range="$BEFORE_SHA..$GITHUB_SHA" - fi - PUBLIC_BOUNDARY_GIT_RANGE="$range" scripts/check-public-boundary.sh - - - name: Require every supported PHP cell - if: ${{ github.server_url == 'https://github.com' }} - env: - TEST_RESULT: ${{ needs.test.result }} - run: test "$TEST_RESULT" = success - - - name: Require every supported microservice PHP cell - if: ${{ github.server_url == 'https://github.com' }} - env: - MICROSERVICE_TEST_RESULT: ${{ needs.microservice-test.result }} - run: test "$MICROSERVICE_TEST_RESULT" = success - - - name: Require central action policy preflight - if: ${{ github.server_url == 'https://github.com' }} - env: - ACTION_POLICY_RESULT: ${{ needs.action-policy.result }} - run: test "$ACTION_POLICY_RESULT" = success + - name: Require application tests + run: test "${{ needs.test.result }}" = success + - name: Require microservice tests + run: test "${{ needs.microservice-test.result }}" = success + - name: Require action policy + run: test "${{ needs.action-policy.result }}" = success diff --git a/.github/workflows/devcontainer-image-pr.yml b/.github/workflows/devcontainer-image-pr.yml index 1667275..839f8f5 100644 --- a/.github/workflows/devcontainer-image-pr.yml +++ b/.github/workflows/devcontainer-image-pr.yml @@ -2,26 +2,24 @@ name: devcontainer image pull request on: pull_request: - branches: [ main ] + branches: [main] paths: - .devcontainer/** - .github/workflows/devcontainer-image.yml - .github/workflows/devcontainer-image-pr.yml - playground/** + - polyglot/qualified-artifact-tuple.json - app/Console/Commands/Init.php - composer.json - composer.lock - - docker/install-phpredis.sh - - docker/playwright-smoke.js + - docker/** - microservice/composer.json - microservice/composer.lock - package.json - package-lock.json - - scripts/ci/qualify-devcontainer-database-overrides.sh - scripts/ci/qualify-devcontainer-image.sh - - scripts/ci/validate-playground-evidence.py - - scripts/ci/summarize-devcontainer-evidence.py - scripts/playground + - scripts/resolve-current-artifacts.sh permissions: contents: read @@ -30,138 +28,34 @@ concurrency: group: devcontainer-image-${{ github.event.pull_request.number }} cancel-in-progress: true -env: - MAX_COMPRESSED_LAYER_BYTES: '400000000' - MAX_COMPRESSED_PLATFORM_BYTES: '1200000000' - jobs: validate: - name: Validate candidate natively (${{ matrix.platform }}) - if: ${{ github.server_url == 'https://github.com' && github.event_name == 'pull_request' }} - runs-on: ${{ github.server_url == 'https://github.com' && matrix.runner || 'ubuntu-latest' }} - timeout-minutes: 15 - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-24.04 - runner_architecture: amd64 - host_machine: x86_64 - suffix: amd64 - - platform: linux/arm64 - runner: ubuntu-24.04-arm - runner_architecture: arm64 - host_machine: aarch64 - suffix: arm64 + name: Validate candidate image + runs-on: ubuntu-24.04 + timeout-minutes: 20 steps: - - name: Start candidate timing - id: timing-start - run: echo "started_ms=$(date +%s%3N)" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - - name: Require the native runner and Docker engine - env: - EXPECTED_HOST_MACHINE: ${{ matrix.host_machine }} - EXPECTED_RUNNER_ARCHITECTURE: ${{ matrix.runner_architecture }} - run: | - test "$(uname -m)" = "$EXPECTED_HOST_MACHINE" - docker_architecture="$(docker info --format '{{.Architecture}}')" - case "${EXPECTED_RUNNER_ARCHITECTURE}:${docker_architecture}" in - amd64:amd64|amd64:x86_64|arm64:arm64|arm64:aarch64) ;; - *) echo "Docker engine architecture mismatch: ${docker_architecture}" >&2; exit 1 ;; - esac - - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - - - name: Build untrusted candidate without shared cache - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + - name: Build untrusted candidate without a shared cache + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . file: .devcontainer/docker/Dockerfile - platforms: ${{ matrix.platform }} + platforms: linux/amd64 load: true no-cache: true pull: true push: false - tags: sample-app-devcontainer:candidate-${{ matrix.suffix }} - build-args: | - VCS_REF=${{ github.sha }} - - - name: Record candidate build timing - id: build-timing - env: - STARTED_MS: ${{ steps.timing-start.outputs.started_ms }} - run: echo "elapsed_ms=$(( $(date +%s%3N) - STARTED_MS ))" >> "$GITHUB_OUTPUT" - - - name: Qualify candidate topology + tags: sample-app-devcontainer:candidate + build-args: VCS_REF=${{ github.sha }} + - name: Qualify Codespaces startup env: - DEVCONTAINER_EVIDENCE_TYPE: candidate_qualification DEVCONTAINER_EXPECTED_REVISION: ${{ github.sha }} - DEVCONTAINER_IMAGE_BUILD_MS: ${{ steps.build-timing.outputs.elapsed_ms }} - DEVCONTAINER_QUALIFY_PLAYGROUND: 1 - DEVCONTAINER_REQUIRE_PUBLISHED_ATTESTATIONS: 0 - DEVCONTAINER_RUNNER_LABEL: ${{ matrix.runner }} - DEVCONTAINER_RUN_STARTED_MS: ${{ steps.timing-start.outputs.started_ms }} - DEVCONTAINER_SKIP_IMAGE_PULL: 1 + DEVCONTAINER_REQUIRE_PUBLISHED_ATTESTATIONS: '0' + DEVCONTAINER_SKIP_IMAGE_PULL: '1' run: >- scripts/ci/qualify-devcontainer-image.sh - sample-app-devcontainer:candidate-${{ matrix.suffix }} - ${{ matrix.platform }} - devcontainer-candidate-evidence-${{ matrix.suffix }}.json - - - name: Retain candidate evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: devcontainer-evidence-candidate-${{ matrix.suffix }} - path: devcontainer-candidate-evidence-${{ matrix.suffix }}.json - if-no-files-found: error - retention-days: 30 - - - name: Retain authored playground journey evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: playground-evidence-candidate-${{ matrix.suffix }} - path: storage/app/devcontainer-playground-${{ matrix.suffix }}-*.json - if-no-files-found: error - retention-days: 30 - - candidate-evidence: - name: Enforce cold pull-request validation budget - if: ${{ github.server_url == 'https://github.com' && github.event_name == 'pull_request' }} - needs: [validate] - runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-24.04' || 'ubuntu-latest' }} - timeout-minutes: 2 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - pattern: devcontainer-evidence-candidate-* - path: devcontainer-evidence - merge-multiple: true - - - name: Summarize native candidate evidence - run: >- - python3 scripts/ci/summarize-devcontainer-evidence.py - candidate - devcontainer-evidence - devcontainer-candidate-evidence.json - 900 - - - name: Retain candidate summary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: devcontainer-candidate-evidence - path: devcontainer-candidate-evidence.json - if-no-files-found: error - retention-days: 30 + sample-app-devcontainer:candidate + linux/amd64 diff --git a/.github/workflows/devcontainer-image.yml b/.github/workflows/devcontainer-image.yml index c4bc510..e7209af 100644 --- a/.github/workflows/devcontainer-image.yml +++ b/.github/workflows/devcontainer-image.yml @@ -2,147 +2,77 @@ name: devcontainer image on: push: - branches: [ main ] + branches: [main] paths: - .devcontainer/** - .github/workflows/devcontainer-image.yml - - playground/** - .github/workflows/devcontainer-image-pr.yml + - playground/** + - polyglot/qualified-artifact-tuple.json - app/Console/Commands/Init.php - composer.json - composer.lock - - docker/install-phpredis.sh - - docker/playwright-smoke.js + - docker/** - microservice/composer.json - microservice/composer.lock - package.json - package-lock.json - - scripts/ci/qualify-devcontainer-database-overrides.sh - scripts/ci/qualify-devcontainer-image.sh - - scripts/ci/validate-playground-evidence.py - - scripts/ci/summarize-devcontainer-evidence.py - scripts/playground + - scripts/resolve-current-artifacts.sh schedule: - cron: '17 6 * * 1' workflow_dispatch: - inputs: - recover_revision_tag: - description: Immutable revision tag to promote after registry verification - required: false - type: string permissions: contents: read concurrency: - group: devcontainer-image-protected-main + group: devcontainer-image-main cancel-in-progress: true env: GHCR_IMAGE: ghcr.io/durable-workflow/sample-app-devcontainer DOCKERHUB_IMAGE: durableworkflow/sample-app-devcontainer - MAX_COMPRESSED_LAYER_BYTES: '400000000' - MAX_COMPRESSED_PLATFORM_BYTES: '1200000000' jobs: - artifact-identity: - name: Reserve immutable publication identity - if: >- - ${{ - github.server_url == 'https://github.com' && - github.repository == 'durable-workflow/sample-app' && - github.ref == 'refs/heads/main' && - github.event_name != 'pull_request' && - (github.event_name != 'workflow_dispatch' || inputs.recover_revision_tag == '') - }} - runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-24.04' || 'ubuntu-latest' }} - timeout-minutes: 2 - permissions: - contents: read - outputs: - revision_tag: ${{ steps.identity.outputs.revision_tag }} - steps: - - name: Bind the publication chain to this workflow attempt - id: identity - run: | - revision_tag="sha-${GITHUB_SHA}-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - if [[ ! "$revision_tag" =~ ^sha-[0-9a-f]{40}-run-[0-9]+-[0-9]+$ ]]; then - echo "Invalid immutable publication tag: ${revision_tag}" >&2 - exit 1 - fi - echo "revision_tag=$revision_tag" >> "$GITHUB_OUTPUT" - - publish-architecture: - name: Publish immutable native artifact (${{ matrix.platform }}) - needs: [artifact-identity] - if: >- - ${{ - github.server_url == 'https://github.com' && - github.repository == 'durable-workflow/sample-app' && - github.ref == 'refs/heads/main' && - github.event_name != 'pull_request' && - (github.event_name != 'workflow_dispatch' || inputs.recover_revision_tag == '') - }} - runs-on: ${{ github.server_url == 'https://github.com' && matrix.runner || 'ubuntu-latest' }} - timeout-minutes: 15 + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 permissions: contents: read packages: write - env: - REVISION_TAG: ${{ needs.artifact-identity.outputs.revision_tag }} strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: ubuntu-24.04 - runner_architecture: amd64 - host_machine: x86_64 suffix: amd64 - platform: linux/arm64 runner: ubuntu-24.04-arm - runner_architecture: arm64 - host_machine: aarch64 suffix: arm64 + env: + REVISION_TAG: sha-${{ github.sha }}-run-${{ github.run_id }}-${{ github.run_attempt }} steps: - - name: Start architecture publication timing - id: timing-start - run: echo "started_ms=$(date +%s%3N)" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - - name: Require the native runner and Docker engine - env: - EXPECTED_HOST_MACHINE: ${{ matrix.host_machine }} - EXPECTED_RUNNER_ARCHITECTURE: ${{ matrix.runner_architecture }} - run: | - test "$(uname -m)" = "$EXPECTED_HOST_MACHINE" - docker_architecture="$(docker info --format '{{.Architecture}}')" - case "${EXPECTED_RUNNER_ARCHITECTURE}:${docker_architecture}" in - amd64:amd64|amd64:x86_64|arm64:arm64|arm64:aarch64) ;; - *) echo "Docker engine architecture mismatch: ${docker_architecture}" >&2; exit 1 ;; - esac - - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - name: Sign in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - name: Sign in to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: durableworkflow password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build once and publish matching immutable architecture tags - id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + - name: Build and publish immutable architecture image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . file: .devcontainer/docker/Dockerfile @@ -155,628 +85,124 @@ jobs: tags: | ${{ env.GHCR_IMAGE }}:${{ env.REVISION_TAG }}-${{ matrix.suffix }} ${{ env.DOCKERHUB_IMAGE }}:${{ env.REVISION_TAG }}-${{ matrix.suffix }} - build-args: | - VCS_REF=${{ github.sha }} - - - name: Verify mirrored architecture digests and record evidence - env: - BUILD_DIGEST: ${{ steps.build.outputs.digest }} - GHCR_REF: ${{ env.GHCR_IMAGE }}:${{ env.REVISION_TAG }}-${{ matrix.suffix }} - DOCKERHUB_REF: ${{ env.DOCKERHUB_IMAGE }}:${{ env.REVISION_TAG }}-${{ matrix.suffix }} - PLATFORM: ${{ matrix.platform }} - RUNNER_LABEL: ${{ matrix.runner }} - RUN_STARTED_MS: ${{ steps.timing-start.outputs.started_ms }} - SUFFIX: ${{ matrix.suffix }} - run: | - docker buildx imagetools inspect "$GHCR_REF" --raw > ghcr-architecture.json - docker buildx imagetools inspect "$DOCKERHUB_REF" --raw > dockerhub-architecture.json - cmp ghcr-architecture.json dockerhub-architecture.json - platform_digest="$(python3 - ghcr-architecture.json "$PLATFORM" <<'PY' - import json - import sys - - with open(sys.argv[1], encoding="utf-8") as source: - manifest = json.load(source) - - if isinstance(manifest.get("layers"), list): - print("") - raise SystemExit - - operating_system, architecture = sys.argv[2].split("/", 1) - matches = [ - entry["digest"] - for entry in manifest.get("manifests", []) - if entry.get("platform", {}).get("os") == operating_system - and entry.get("platform", {}).get("architecture") == architecture - and (entry.get("annotations") or {}).get("vnd.docker.reference.type") - != "attestation-manifest" - ] - if len(matches) != 1: - raise SystemExit( - f"expected one image manifest for {sys.argv[2]}, found {len(matches)}" - ) - print(matches[0]) - PY - )" - if [[ -n "$platform_digest" ]]; then - docker buildx imagetools inspect "$GHCR_REF@$platform_digest" --raw > ghcr-platform.json - docker buildx imagetools inspect "$DOCKERHUB_REF@$platform_digest" --raw > dockerhub-platform.json - else - cp ghcr-architecture.json ghcr-platform.json - cp dockerhub-architecture.json dockerhub-platform.json - fi - cmp ghcr-platform.json dockerhub-platform.json - read -r compressed_platform_bytes largest_compressed_layer_bytes compressed_layer_count < <(python3 - ghcr-platform.json <<'PY' - import json - import sys - - with open(sys.argv[1], encoding="utf-8") as source: - manifest = json.load(source) - layers = manifest.get("layers") - if not isinstance(layers, list) or not layers: - raise SystemExit("platform image manifest does not contain compressed layers") - layer_sizes = [int(layer["size"]) for layer in layers] - if any(size <= 0 for size in layer_sizes): - raise SystemExit("compressed layer sizes must be positive") - print(sum(layer_sizes), max(layer_sizes), len(layer_sizes)) - PY - ) - within_size_budget=1 - if (( compressed_platform_bytes >= MAX_COMPRESSED_PLATFORM_BYTES )); then - echo "Compressed platform size ${compressed_platform_bytes} must be less than ${MAX_COMPRESSED_PLATFORM_BYTES} bytes." >&2 - within_size_budget=0 - fi - if (( largest_compressed_layer_bytes >= MAX_COMPRESSED_LAYER_BYTES )); then - echo "Largest compressed layer ${largest_compressed_layer_bytes} must be less than ${MAX_COMPRESSED_LAYER_BYTES} bytes." >&2 - within_size_budget=0 - fi - ghcr_digest="$(docker buildx imagetools inspect "$GHCR_REF" --format '{{ .Manifest.Digest }}')" - dockerhub_digest="$(docker buildx imagetools inspect "$DOCKERHUB_REF" --format '{{ .Manifest.Digest }}')" - test "$ghcr_digest" = "$dockerhub_digest" - test "$ghcr_digest" = "$BUILD_DIGEST" - completed_ms="$(date +%s%3N)" - GHCR_DIGEST="$ghcr_digest" \ - DOCKERHUB_DIGEST="$dockerhub_digest" \ - COMPRESSED_LAYER_COUNT="$compressed_layer_count" \ - COMPRESSED_PLATFORM_BYTES="$compressed_platform_bytes" \ - LARGEST_COMPRESSED_LAYER_BYTES="$largest_compressed_layer_bytes" \ - WITHIN_SIZE_BUDGET="$within_size_budget" \ - COMPLETED_MS="$completed_ms" \ - HOST_MACHINE="$(uname -m)" \ - DOCKER_ARCHITECTURE="$(docker info --format '{{.Architecture}}')" \ - python3 - <<'PY' - import json - import os - - started = int(os.environ["RUN_STARTED_MS"]) - completed = int(os.environ["COMPLETED_MS"]) - payload = { - "schema_version": 2, - "evidence_type": "architecture_publication", - "platform": os.environ["PLATFORM"], - "runner": { - "label": os.environ["RUNNER_LABEL"], - "host_machine": os.environ["HOST_MACHINE"], - "docker_architecture": os.environ["DOCKER_ARCHITECTURE"], - }, - "immutable_references": { - "ghcr": os.environ["GHCR_REF"], - "dockerhub": os.environ["DOCKERHUB_REF"], - }, - "manifest_digests": { - "build": os.environ["BUILD_DIGEST"], - "ghcr": os.environ["GHCR_DIGEST"], - "dockerhub": os.environ["DOCKERHUB_DIGEST"], - }, - "manifest_digest_parity": True, - "compressed_layer_count": int(os.environ["COMPRESSED_LAYER_COUNT"]), - "compressed_platform_bytes": int(os.environ["COMPRESSED_PLATFORM_BYTES"]), - "largest_compressed_layer_bytes": int(os.environ["LARGEST_COMPRESSED_LAYER_BYTES"]), - "compressed_size_budget": { - "max_layer_bytes": int(os.environ["MAX_COMPRESSED_LAYER_BYTES"]), - "max_platform_bytes": int(os.environ["MAX_COMPRESSED_PLATFORM_BYTES"]), - "within_budget": os.environ["WITHIN_SIZE_BUDGET"] == "1", - }, - "attestations": {"provenance": "mode=max", "sbom": True}, - "run_started_epoch_ms": started, - "completed_epoch_ms": completed, - "stages_ms": {"checkout_setup_build_and_mirror_verification": completed - started}, - } - with open(f"devcontainer-build-evidence-{os.environ['SUFFIX']}.json", "w", encoding="utf-8") as output: - json.dump(payload, output, indent=2, sort_keys=True) - output.write("\n") - PY - test "$within_size_budget" = 1 - - - name: Retain architecture publication evidence - if: ${{ always() && steps.build.outcome == 'success' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: devcontainer-evidence-build-${{ matrix.suffix }} - path: devcontainer-build-evidence-${{ matrix.suffix }}.json - if-no-files-found: error - retention-days: 90 + build-args: VCS_REF=${{ github.sha }} - assemble-indexes: - name: Assemble matching verified image indexes - if: >- - ${{ - github.server_url == 'https://github.com' && - github.repository == 'durable-workflow/sample-app' && - github.ref == 'refs/heads/main' - }} - needs: [artifact-identity, publish-architecture] - runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-24.04' || 'ubuntu-latest' }} + assemble: + name: Assemble multi-platform image + needs: [build] + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read packages: write - env: - REVISION_TAG: ${{ needs.artifact-identity.outputs.revision_tag }} + outputs: + revision_tag: ${{ steps.identity.outputs.revision_tag }} steps: - - name: Start index assembly timing - id: timing-start - run: echo "started_ms=$(date +%s%3N)" >> "$GITHUB_OUTPUT" - - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - name: Sign in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - name: Sign in to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: durableworkflow password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Assemble immutable multi-platform indexes + - name: Set immutable tag + id: identity + run: echo "revision_tag=sha-${GITHUB_SHA}-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + - name: Assemble registry indexes + env: + REVISION_TAG: ${{ steps.identity.outputs.revision_tag }} run: | docker buildx imagetools create \ - --tag "${GHCR_IMAGE}:${REVISION_TAG}" \ - "${GHCR_IMAGE}:${REVISION_TAG}-amd64" \ - "${GHCR_IMAGE}:${REVISION_TAG}-arm64" + -t "$GHCR_IMAGE:$REVISION_TAG" \ + "$GHCR_IMAGE:$REVISION_TAG-amd64" \ + "$GHCR_IMAGE:$REVISION_TAG-arm64" docker buildx imagetools create \ - --tag "${DOCKERHUB_IMAGE}:${REVISION_TAG}" \ - "${DOCKERHUB_IMAGE}:${REVISION_TAG}-amd64" \ - "${DOCKERHUB_IMAGE}:${REVISION_TAG}-arm64" - - - name: Verify index parity and record evidence - env: - RUN_STARTED_MS: ${{ steps.timing-start.outputs.started_ms }} - run: | - docker buildx imagetools inspect "${GHCR_IMAGE}:${REVISION_TAG}" --raw > ghcr-index.json - docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${REVISION_TAG}" --raw > dockerhub-index.json - cmp ghcr-index.json dockerhub-index.json - ghcr_digest="$(docker buildx imagetools inspect "${GHCR_IMAGE}:${REVISION_TAG}" --format '{{ .Manifest.Digest }}')" - dockerhub_digest="$(docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${REVISION_TAG}" --format '{{ .Manifest.Digest }}')" - test "$ghcr_digest" = "$dockerhub_digest" - completed_ms="$(date +%s%3N)" - MANIFEST_DIGEST="$ghcr_digest" \ - COMPLETED_MS="$completed_ms" \ - python3 - <<'PY' - import json - import os - - with open("ghcr-index.json", encoding="utf-8") as source: - manifest = json.load(source) - architectures = { - entry.get("platform", {}).get("architecture") - for entry in manifest.get("manifests", []) - if entry.get("platform", {}).get("architecture") not in (None, "unknown") - } - if architectures != {"amd64", "arm64"}: - raise SystemExit(f"assembled index platforms do not match: {sorted(architectures)}") - - started = int(os.environ["RUN_STARTED_MS"]) - completed = int(os.environ["COMPLETED_MS"]) - payload = { - "schema_version": 2, - "evidence_type": "index_assembly", - "platforms": ["linux/amd64", "linux/arm64"], - "immutable_references": { - "ghcr": f"{os.environ['GHCR_IMAGE']}:{os.environ['REVISION_TAG']}", - "dockerhub": f"{os.environ['DOCKERHUB_IMAGE']}:{os.environ['REVISION_TAG']}", - }, - "manifest_digests": { - "ghcr": os.environ["MANIFEST_DIGEST"], - "dockerhub": os.environ["MANIFEST_DIGEST"], - }, - "manifest_digest_parity": True, - "run_started_epoch_ms": started, - "completed_epoch_ms": completed, - "stages_ms": {"index_assembly_and_parity_verification": completed - started}, - } - with open("devcontainer-index-evidence.json", "w", encoding="utf-8") as output: - json.dump(payload, output, indent=2, sort_keys=True) - output.write("\n") - PY - - - name: Retain index evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: devcontainer-evidence-index - path: devcontainer-index-evidence.json - if-no-files-found: error - retention-days: 90 - - qualify-published: - name: Qualify anonymously and natively (${{ matrix.registry }}, ${{ matrix.platform }}) - if: ${{ github.server_url == 'https://github.com' }} - needs: [artifact-identity, assemble-indexes] - runs-on: ${{ github.server_url == 'https://github.com' && matrix.runner || 'ubuntu-latest' }} + -t "$DOCKERHUB_IMAGE:$REVISION_TAG" \ + "$DOCKERHUB_IMAGE:$REVISION_TAG-amd64" \ + "$DOCKERHUB_IMAGE:$REVISION_TAG-arm64" + + qualify: + name: Qualify ${{ matrix.platform }} + needs: [assemble] + runs-on: ${{ matrix.runner }} timeout-minutes: 20 - permissions: - contents: read - env: - REVISION_TAG: ${{ needs.artifact-identity.outputs.revision_tag }} strategy: fail-fast: false matrix: include: - - registry: ghcr - image: ghcr.io/durable-workflow/sample-app-devcontainer - platform: linux/amd64 - playground: 1 - runner: ubuntu-24.04 - suffix: ghcr-amd64 - - registry: ghcr - image: ghcr.io/durable-workflow/sample-app-devcontainer - platform: linux/arm64 - playground: 0 - runner: ubuntu-24.04-arm - suffix: ghcr-arm64 - - registry: dockerhub - image: durableworkflow/sample-app-devcontainer - platform: linux/amd64 - playground: 0 + - platform: linux/amd64 runner: ubuntu-24.04 - suffix: dockerhub-amd64 - - registry: dockerhub - image: durableworkflow/sample-app-devcontainer - platform: linux/arm64 - playground: 0 + - platform: linux/arm64 runner: ubuntu-24.04-arm - suffix: dockerhub-arm64 steps: - - name: Start public qualification timing - id: timing-start - run: echo "started_ms=$(date +%s%3N)" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - - - name: Isolate anonymous registry access + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + - name: Use a credential-free Docker configuration run: | - anonymous_config="$RUNNER_TEMP/anonymous-docker-config" - mkdir -p "$anonymous_config" - printf '{"auths":{}}\n' > "$anonymous_config/config.json" - echo "DOCKER_CONFIG=$anonymous_config" >> "$GITHUB_ENV" - - - name: Pull and qualify without registry credentials + mkdir -p /tmp/docker-anonymous + printf '%s\n' '{"auths":{}}' > /tmp/docker-anonymous/config.json + echo 'DOCKER_CONFIG=/tmp/docker-anonymous' >> "$GITHUB_ENV" + - name: Qualify the published image env: - DEVCONTAINER_EVIDENCE_TYPE: public_qualification DEVCONTAINER_EXPECTED_REVISION: ${{ github.sha }} - DEVCONTAINER_QUALIFY_PLAYGROUND: ${{ matrix.playground }} - DEVCONTAINER_REGISTRY: ${{ matrix.registry }} - DEVCONTAINER_REQUIRE_ANONYMOUS_PULL: 1 - DEVCONTAINER_RUNNER_LABEL: ${{ matrix.runner }} - DEVCONTAINER_RUN_STARTED_MS: ${{ steps.timing-start.outputs.started_ms }} + DEVCONTAINER_REQUIRE_ANONYMOUS_PULL: '1' + DEVCONTAINER_REQUIRE_PUBLISHED_ATTESTATIONS: '1' run: >- scripts/ci/qualify-devcontainer-image.sh - ${{ matrix.image }}:${{ env.REVISION_TAG }} - ${{ matrix.platform }} - devcontainer-public-evidence-${{ matrix.suffix }}.json + "$GHCR_IMAGE:${{ needs.assemble.outputs.revision_tag }}" + "${{ matrix.platform }}" - - name: Retain public qualification evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: devcontainer-evidence-public-${{ matrix.suffix }} - path: devcontainer-public-evidence-${{ matrix.suffix }}.json - if-no-files-found: error - retention-days: 90 - - - name: Retain published-image authored playground journeys - if: ${{ matrix.playground == 1 }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: playground-evidence-public-${{ matrix.suffix }} - path: storage/app/devcontainer-playground-amd64-*.json - if-no-files-found: error - retention-days: 90 - - promote-main: - name: Promote qualified image to main - if: >- - ${{ - github.server_url == 'https://github.com' && - github.repository == 'durable-workflow/sample-app' && - github.ref == 'refs/heads/main' - }} - needs: [artifact-identity, assemble-indexes, qualify-published] - runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-24.04' || 'ubuntu-latest' }} + promote: + name: Publish main channel + needs: [assemble, qualify] + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read packages: write - env: - REVISION_TAG: ${{ needs.artifact-identity.outputs.revision_tag }} steps: - - name: Start promotion timing - id: timing-start - run: echo "started_ms=$(date +%s%3N)" >> "$GITHUB_OUTPUT" - - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - name: Sign in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - name: Sign in to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: durableworkflow password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Move protected main channels + - name: Promote the qualified image env: - RUN_STARTED_MS: ${{ steps.timing-start.outputs.started_ms }} - run: | - docker buildx imagetools create \ - --tag "${GHCR_IMAGE}:main" \ - "${GHCR_IMAGE}:${REVISION_TAG}" - docker buildx imagetools create \ - --tag "${DOCKERHUB_IMAGE}:main" \ - "${DOCKERHUB_IMAGE}:${REVISION_TAG}" - completed_ms="$(date +%s%3N)" - COMPLETED_MS="$completed_ms" python3 - <<'PY' - import json - import os - - started = int(os.environ["RUN_STARTED_MS"]) - completed = int(os.environ["COMPLETED_MS"]) - payload = { - "schema_version": 2, - "evidence_type": "promotion", - "source_tag": os.environ["REVISION_TAG"], - "promoted_tag": "main", - "registries": ["ghcr", "dockerhub"], - "qualification_gate": ["ghcr/amd64", "ghcr/arm64", "dockerhub/amd64", "dockerhub/arm64"], - "run_started_epoch_ms": started, - "completed_epoch_ms": completed, - "stages_ms": {"main_promotion": completed - started}, - } - with open("devcontainer-promotion-evidence.json", "w", encoding="utf-8") as output: - json.dump(payload, output, indent=2, sort_keys=True) - output.write("\n") - PY - - - name: Retain promotion evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: devcontainer-evidence-promotion - path: devcontainer-promotion-evidence.json - if-no-files-found: error - retention-days: 90 - - recover-main: - name: Recover main from a verified immutable image - if: >- - ${{ - github.server_url == 'https://github.com' && - github.repository == 'durable-workflow/sample-app' && - github.ref == 'refs/heads/main' && - github.event_name == 'workflow_dispatch' && - inputs.recover_revision_tag != '' - }} - runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-24.04' || 'ubuntu-latest' }} - timeout-minutes: 5 - permissions: - contents: read - packages: write - env: - SOURCE_TAG: ${{ inputs.recover_revision_tag }} - steps: - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - - - name: Sign in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ github.token }} - - - name: Sign in to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: durableworkflow - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Verify immutable source and recover protected channels - run: | - if [[ ! "$SOURCE_TAG" =~ ^sha-[0-9a-f]{40}-run-[0-9]+-[0-9]+$ ]]; then - echo "Recovery requires an immutable workflow revision tag." >&2 - exit 1 - fi - - docker buildx imagetools inspect "${GHCR_IMAGE}:${SOURCE_TAG}" --raw > ghcr-source.json - docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${SOURCE_TAG}" --raw > dockerhub-source.json - cmp ghcr-source.json dockerhub-source.json - - ghcr_digest="$(docker buildx imagetools inspect "${GHCR_IMAGE}:${SOURCE_TAG}" --format '{{ .Manifest.Digest }}')" - dockerhub_digest="$(docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${SOURCE_TAG}" --format '{{ .Manifest.Digest }}')" - test "$ghcr_digest" = "$dockerhub_digest" - - python3 - <<'PY' - import json - - with open("ghcr-source.json", encoding="utf-8") as source: - manifest = json.load(source) - architectures = { - entry.get("platform", {}).get("architecture") - for entry in manifest.get("manifests", []) - if entry.get("platform", {}).get("architecture") not in (None, "unknown") - } - if architectures != {"amd64", "arm64"}: - raise SystemExit(f"recovery source platforms do not match: {sorted(architectures)}") - PY - - docker buildx imagetools create \ - --tag "${GHCR_IMAGE}:main" \ - "${GHCR_IMAGE}:${SOURCE_TAG}" - docker buildx imagetools create \ - --tag "${DOCKERHUB_IMAGE}:main" \ - "${DOCKERHUB_IMAGE}:${SOURCE_TAG}" - - docker buildx imagetools inspect "${GHCR_IMAGE}:main" --raw > ghcr-main.json - docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:main" --raw > dockerhub-main.json - cmp ghcr-source.json ghcr-main.json - cmp dockerhub-source.json dockerhub-main.json - cmp ghcr-main.json dockerhub-main.json - - SOURCE_DIGEST="$ghcr_digest" python3 - <<'PY' - import json - import os - - payload = { - "schema_version": 2, - "evidence_type": "manual_channel_recovery", - "source_tag": os.environ["SOURCE_TAG"], - "manifest_digest": os.environ["SOURCE_DIGEST"], - "platforms": ["linux/amd64", "linux/arm64"], - "registries": ["ghcr", "dockerhub"], - } - with open("devcontainer-recovery-evidence.json", "w", encoding="utf-8") as output: - json.dump(payload, output, indent=2, sort_keys=True) - output.write("\n") - PY - - - name: Retain recovery evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: devcontainer-evidence-recovery - path: devcontainer-recovery-evidence.json - if-no-files-found: error - retention-days: 90 - - verify-main: - name: Inspect public main channel anonymously (${{ matrix.registry }}) - if: ${{ github.server_url == 'https://github.com' }} - needs: [artifact-identity, promote-main] - runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-24.04' || 'ubuntu-latest' }} - timeout-minutes: 5 - permissions: - contents: read - env: - REVISION_TAG: ${{ needs.artifact-identity.outputs.revision_tag }} - strategy: - fail-fast: false - matrix: - include: - - registry: ghcr - image: ghcr.io/durable-workflow/sample-app-devcontainer - - registry: dockerhub - image: durableworkflow/sample-app-devcontainer - steps: - - name: Start moving-channel verification timing - id: timing-start - run: echo "started_ms=$(date +%s%3N)" >> "$GITHUB_OUTPUT" - - - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - - - name: Isolate anonymous registry access + REVISION_TAG: ${{ needs.assemble.outputs.revision_tag }} run: | - anonymous_config="$RUNNER_TEMP/anonymous-docker-config" - mkdir -p "$anonymous_config" - printf '{"auths":{}}\n' > "$anonymous_config/config.json" - echo "DOCKER_CONFIG=$anonymous_config" >> "$GITHUB_ENV" - - - name: Compare moving and immutable manifests without credentials - env: - IMAGE: ${{ matrix.image }} - REGISTRY: ${{ matrix.registry }} - RUN_STARTED_MS: ${{ steps.timing-start.outputs.started_ms }} + docker buildx imagetools create -t "$GHCR_IMAGE:main" "$GHCR_IMAGE:$REVISION_TAG" + docker buildx imagetools create -t "$DOCKERHUB_IMAGE:main" "$DOCKERHUB_IMAGE:$REVISION_TAG" + - name: Verify both public indexes run: | - docker buildx imagetools inspect "${IMAGE}:${REVISION_TAG}" --raw > revision.json - docker buildx imagetools inspect "${IMAGE}:main" --raw > main.json - cmp revision.json main.json - revision_digest="$(docker buildx imagetools inspect "${IMAGE}:${REVISION_TAG}" --format '{{ .Manifest.Digest }}')" - main_digest="$(docker buildx imagetools inspect "${IMAGE}:main" --format '{{ .Manifest.Digest }}')" - test "$revision_digest" = "$main_digest" - completed_ms="$(date +%s%3N)" - MANIFEST_DIGEST="$main_digest" \ - COMPLETED_MS="$completed_ms" \ - python3 - <<'PY' + for image in "$GHCR_IMAGE:main" "$DOCKERHUB_IMAGE:main"; do + raw="$(mktemp)" + docker buildx imagetools inspect "$image" --raw > "$raw" + python3 - "$raw" <<'PY' import json - import os + import sys - started = int(os.environ["RUN_STARTED_MS"]) - completed = int(os.environ["COMPLETED_MS"]) - payload = { - "schema_version": 2, - "evidence_type": "moving_channel_verification", - "registry": os.environ["REGISTRY"], - "image": os.environ["IMAGE"], - "manifest_digest": os.environ["MANIFEST_DIGEST"], - "revision_and_main_digest_parity": True, - "anonymous_manifest_inspection": True, - "run_started_epoch_ms": started, - "completed_epoch_ms": completed, - "stages_ms": {"anonymous_manifest_inspection": completed - started}, + manifest = json.load(open(sys.argv[1], encoding='utf-8')) + platforms = { + (item.get('platform') or {}).get('architecture') + for item in manifest.get('manifests', []) + if (item.get('platform') or {}).get('os') == 'linux' } - with open(f"devcontainer-main-evidence-{os.environ['REGISTRY']}.json", "w", encoding="utf-8") as output: - json.dump(payload, output, indent=2, sort_keys=True) - output.write("\n") + if not {'amd64', 'arm64'} <= platforms: + raise SystemExit(f'missing image platforms: {platforms}') PY - - - name: Retain moving-channel evidence - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: devcontainer-evidence-main-${{ matrix.registry }} - path: devcontainer-main-evidence-${{ matrix.registry }}.json - if-no-files-found: error - retention-days: 90 - - publication-evidence: - name: Enforce protected publication budget and evidence - if: ${{ github.server_url == 'https://github.com' }} - needs: [verify-main] - runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-24.04' || 'ubuntu-latest' }} - timeout-minutes: 2 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - pattern: devcontainer-evidence-* - path: devcontainer-evidence - merge-multiple: true - - - name: Summarize public publication evidence - run: >- - python3 scripts/ci/summarize-devcontainer-evidence.py - publication - devcontainer-evidence - devcontainer-publication-evidence.json - 900 - - - name: Retain public publication summary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: devcontainer-publication-evidence - path: devcontainer-publication-evidence.json - if-no-files-found: error - retention-days: 90 + rm -f "$raw" + done diff --git a/.github/workflows/polyglot-validation.yml b/.github/workflows/polyglot-validation.yml index 3d78ad3..f2f735b 100644 --- a/.github/workflows/polyglot-validation.yml +++ b/.github/workflows/polyglot-validation.yml @@ -1,10 +1,10 @@ -name: polyglot-validation +name: polyglot validation on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] workflow_dispatch: permissions: @@ -12,71 +12,28 @@ permissions: jobs: smoke: - name: polyglot smoke (PHP/Python/Rust, ${{ matrix.cache_mode }}) - if: ${{ github.server_url == 'https://github.com' }} + name: polyglot smoke (PHP/Python/Rust) runs-on: ubuntu-latest timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - cache_mode: [cold-cache, warm-cache] env: - # This source imports the synchronized prerelease protocol API. Keep its - # qualification on the committed exact tuple while the public docs tuple - # advances only after the release train has completed validation. - DURABLE_WORKFLOW_ARTIFACT_SOURCE: pinned - POLYGLOT_BUILD_CACHE_MODE: ${{ matrix.cache_mode }} - POLYGLOT_QUALIFICATION_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - POLYGLOT_QUALIFICATION_RUN_ID: ${{ github.run_id }} - POLYGLOT_QUALIFICATION_RUN_ATTEMPT: ${{ github.run_attempt }} - POLYGLOT_QUALIFICATION_WORKFLOW: ${{ github.workflow }} - POLYGLOT_QUALIFICATION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - + POLYGLOT_COMPOSE_PROJECT_NAME: sample-app-polyglot-${{ github.run_id }}-${{ github.run_attempt }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Test polyglot metadata evidence + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Test smoke metadata run: python3 -m unittest discover -s polyglot/python_worker/tests -v - - - name: Set isolated Compose project - shell: bash - run: | - raw="sample-app-polyglot-${GITHUB_RUN_ID:-local}-${GITHUB_JOB:-smoke}-${POLYGLOT_BUILD_CACHE_MODE}-${GITHUB_RUN_ATTEMPT:-1}" - project="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-')" - project="${project%-}" - echo "COMPOSE_PROJECT_NAME=$project" >> "$GITHUB_ENV" - echo "Polyglot Compose project: $project" - - - name: Resolve qualified artifact tuple - shell: bash - run: | - while IFS= read -r assignment; do - echo "$assignment" >> "$GITHUB_ENV" - export "$assignment" - printf '%s\n' "$assignment" - done < <(scripts/resolve-current-artifacts.sh) - - - name: Show resolved server image - working-directory: polyglot - run: | - # Surface the durableworkflow/server tag the smoke is about to run - # against, so a human reviewing the CI log can immediately see which - # server build the polyglot scenarios were exercised on. - echo "Polyglot server image:" - docker compose config server | awk '/^[[:space:]]+image:/ {print; exit}' - - - name: Validate one stable polyglot runtime lifecycle - shell: bash - run: timeout --signal=TERM --kill-after=60s 1620s scripts/polyglot-validation.sh - - polyglot-qualification: - name: polyglot smoke (PHP/Python/Rust) - if: ${{ always() && github.server_url == 'https://github.com' }} - needs: [smoke] - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Require every cache-mode smoke - env: - SMOKE_RESULT: ${{ needs.smoke.result }} - run: test "$SMOKE_RESULT" = success + - name: Run the documented service-mode example + run: scripts/polyglot.sh + - name: Dump logs + if: failure() + run: >- + docker compose --project-directory polyglot + -f polyglot/docker-compose.yml + -p "$POLYGLOT_COMPOSE_PROJECT_NAME" + logs --no-color --timestamps + - name: Tear down stack + if: always() + run: >- + docker compose --project-directory polyglot + -f polyglot/docker-compose.yml + -p "$POLYGLOT_COMPOSE_PROJECT_NAME" + down --volumes --remove-orphans diff --git a/.github/workflows/public-boundary.yml b/.github/workflows/public-boundary.yml index 552d831..7e7bbec 100644 --- a/.github/workflows/public-boundary.yml +++ b/.github/workflows/public-boundary.yml @@ -10,7 +10,6 @@ permissions: jobs: scan: name: Scan public boundary - if: ${{ github.server_url == 'https://github.com' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 65f9dc1..7ac05cb 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -2,9 +2,9 @@ name: smoke on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] workflow_dispatch: permissions: @@ -13,114 +13,20 @@ permissions: jobs: compose: name: docker compose sample workflows - if: ${{ github.server_url == 'https://github.com' }} runs-on: ubuntu-latest timeout-minutes: 30 - env: COMPOSE_PROJECT_NAME: sample-app-smoke-${{ github.run_id }}-${{ github.run_attempt }} - APP_PORT: "0" - SAMPLE_APP_SMOKE_ONLY: 1 - + APP_PORT: '0' steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build and start stack run: docker compose up -d --build --wait app worker - - - name: Run deterministic workflow samples + - name: Run embedded workflow samples run: scripts/compose-smoke.sh - - - name: Dump compose logs + - name: Dump logs if: failure() run: docker compose logs --no-color --timestamps - - name: Tear down stack if: always() run: docker compose down -v --remove-orphans - - service-mode: - name: service-mode onboarding (fresh and repeat) - if: ${{ github.server_url == 'https://github.com' }} - runs-on: ubuntu-latest - timeout-minutes: 30 - - env: - COMPOSE_PROJECT_NAME: >- - sample-app-service-${{ github.run_id }}-${{ github.run_attempt }} - DURABLE_WORKFLOW_ARTIFACT_SOURCE: pinned - SERVICE_MODE_PORT: "18081" - SERVICE_MODE_SAMPLE_APP_REVISION: ${{ github.sha }} - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Export the supported artifact tuple for diagnostics and cleanup - run: scripts/resolve-current-artifacts.sh >> "$GITHUB_ENV" - - - name: Run the documented service-mode path from a clean project - env: - SERVICE_MODE_EVIDENCE_PATH: service-mode-first.json - run: scripts/service-mode.sh - - - name: Run the documented command again without cleanup - env: - SERVICE_MODE_EVIDENCE_PATH: service-mode-repeat.json - run: scripts/service-mode.sh - - - name: Validate timings, runtime results, versions, and unique identities - run: >- - python3 scripts/ci/validate-service-mode-evidence.py - service-mode-first.json - service-mode-repeat.json - - - name: Open the matching Waterline route - run: | - waterline_url="$( - python3 -c ' - import json - data = json.load(open("service-mode-repeat.json")) - print(data["workflow"]["waterline_url"]) - ' - )" - curl --fail --silent --show-error --location "$waterline_url" \ - | grep -E 'Waterline|waterline' - - - name: Retain service-mode visual and runtime evidence - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: waterline-service-mode-evidence-${{ github.run_id }}-${{ github.run_attempt }} - path: | - service-mode-*.json - service-mode-*-waterline.png - service-mode-*-waterline-dialogs/** - service-mode-*-waterline-run-detail/** - if-no-files-found: error - retention-days: 30 - - - name: Link protected evidence to the public completion gate - if: >- - success() && github.event_name == 'push' && - github.ref == 'refs/heads/main' - run: | - { - echo '## Released Waterline service-mode qualification' - echo - echo "- Sample App revision: \`${GITHUB_SHA}\`" - echo "- Waterline package: \`${DURABLE_WORKFLOW_WATERLINE_VERSION}\`" - echo '- [Public completion gate](https://github.com/durable-workflow/waterline/issues/79)' - echo "- [Retained evidence run](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Dump service-mode logs - if: failure() - run: >- - docker compose -f polyglot/service-mode.yml logs - --no-color --timestamps - - - name: Tear down isolated service-mode state - if: always() - run: >- - docker compose -f polyglot/service-mode.yml down - -v --remove-orphans diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2bfd099 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Durable Workflow, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9fe2885..6cdc918 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,58 @@ # Durable Workflow Sample App -This Laravel 13 application demonstrates Durable Workflow 2.0 through two -first-class deployment paths: Service mode with a standalone Server and -first-party language workers, or an engine embedded in Laravel. Both paths run -in a GitHub Codespace on the supported 2.0 prerelease train. Stable Durable -Workflow 2.0 has not been released yet. +[![Application tests](https://github.com/durable-workflow/sample-app/actions/workflows/ci.yml/badge.svg)](https://github.com/durable-workflow/sample-app/actions/workflows/ci.yml) +[![Embedded smoke](https://github.com/durable-workflow/sample-app/actions/workflows/smoke.yml/badge.svg)](https://github.com/durable-workflow/sample-app/actions/workflows/smoke.yml) +[![Polyglot smoke](https://github.com/durable-workflow/sample-app/actions/workflows/polyglot-validation.yml/badge.svg)](https://github.com/durable-workflow/sample-app/actions/workflows/polyglot-validation.yml) +[![Development image](https://github.com/durable-workflow/sample-app/actions/workflows/devcontainer-image.yml/badge.svg)](https://github.com/durable-workflow/sample-app/actions/workflows/devcontainer-image.yml) -> **Looking for the Laravel 12 / Durable Workflow 1.x version?** It's preserved on the [`Laravel-12` branch](https://github.com/durable-workflow/sample-app/tree/Laravel-12). Older blog posts and tutorials that reference v1 patterns (e.g. `Workflow\Workflow`, `yield activity(...)`, `Workflow\Activity`) target that branch. +Runnable examples for Durable Workflow 2.0. Start a PHP-authored workflow +that calls Python and Rust activities through a standalone Server, build a +workflow with any first-party SDK, or run the workflow engine inside Laravel. -## Start in Codespaces +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/durable-workflow/sample-app?quickstart=1) -Create a Codespace from the main branch of this repository. +## Choose a path -image - -Wait while Codespaces pulls the prepared Sample App development image and -installs the repository's Composer and npm dependencies. PHP and Composer, -Python with an isolated SDK environment, Rust and Cargo, Docker Compose, `dw`, -Node, and Chromium are already in the image. Post-create does not run `apt`, -`rustup`, compile `dw`, or rebuild a language toolchain. - -When setup finishes, choose either deployment path: - -| Path | Best fit | Runtime | +| Path | Use it when | Command | | --- | --- | --- | -| [Service mode](#service-mode) | One approachable run showing the complete first-party language story | PHP workflow + Python activity + Rust activity + standalone Server | -| [Embedded Laravel](#embedded-laravel) | A Laravel application that owns workflow execution and storage | Laravel app + queue worker | +| [Service mode](#service-mode) | PHP, Python, and Rust workers share a standalone or managed runtime | `scripts/polyglot.sh` | +| [SDK playground](#sdk-playground) | You want to author a small workflow and activity in one SDK | `scripts/playground php`, `python`, or `rust` | +| [Embedded Laravel](#embedded-laravel) | Laravel owns workflow execution and storage | `composer run dev` | + +The Codespaces image already contains PHP, Composer, Python, Rust, Cargo, +Docker Compose, Node, Chromium, `dw`, and `rg`. Setup installs only this +repository's Composer and npm dependencies. - -### Service mode +## Service mode -Service mode is the first-party PHP, Python, and Rust story. After Codespaces -reports that setup is complete, run its featured `PolyglotWorkflow` sample: +Run the featured `PolyglotWorkflow`: ```bash scripts/polyglot.sh ``` -This one command resolves the current installable Server, PHP SDK, Python SDK, -and Rust SDK artifacts; builds their worker images; waits for all three workers; -and starts `PolyglotWorkflow`. The PHP-authored workflow routes an order -calculation to the Python activity queue, sends that calculation to the Rust -receipt activity queue, and combines both results. Its output identifies the -PHP workflow runtime, both activity runtimes, all three task queues, the current -artifact versions, and the completed receipt. +The command starts a published Durable Workflow Server and three workers. A +PHP workflow sends an order calculation to a Python activity, then sends that +result to a Rust activity that produces a receipt. The final result identifies +all three runtimes and the artifact versions used for the run. -Docker and the complete PHP/Python/Rust toolchain are included in the prepared -Codespaces image. The command does not require a package-install step or any -tool outside the repository and its Compose stack. Repeat it for another run; -the isolated `sample-app-polyglot-demo` project remains available between runs. -The exhaustive directional codec, replay, signal, query, and Waterline checks -remain in the [polyglot matrix guide](polyglot/README.md#complete-runtime-matrix). +The isolated `sample-app-polyglot-demo` Compose project remains available for +inspection. Stop it with: + +```bash +docker compose \ + --project-directory polyglot \ + -f polyglot/docker-compose.yml \ + -p sample-app-polyglot-demo \ + down --volumes --remove-orphans +``` -#### Symmetric SDK playground +See [polyglot/README.md](polyglot/README.md) for the worker layout and the +larger directional runtime matrix. -Ask an agent to create and run a workflow and activity, or use the same -memorable service-mode authoring interface for every first-party SDK yourself: +## SDK playground + +Use the same interface for each first-party SDK: ```bash scripts/playground php @@ -63,98 +60,54 @@ scripts/playground python scripts/playground rust ``` -Each choice creates editable workflow and activity source under -`.playground/`, outside the repository's conformance workers, and -preserves files you already own. It resolves the repository's one qualified -published-artifact tuple and starts isolated Server and Waterline state. Before -execution it prints the effective workflow type, activity type, task queue, -worker command, start command, and expected result from -`playground/contract.json`. The client starts only after the matching worker -registration produces a positive `Worker ready` checkpoint. - -Success is reported after the SDK receives the expected result and `dw` -confirms completed status and activity history. Waterline must select that -exact workflow and run before the terminal prints its exact run URL. The same -journeys run during development-image qualification on AMD64 and ARM64. +Each command creates editable source under `.playground/`, starts an +isolated local Server and Waterline, waits for the worker registration, starts +the workflow, verifies its result and history, and prints the matching +Waterline run URL. Existing authored files are preserved. -Pass `--source` to scaffold or run a different caller-owned directory: +Create a caller-owned project elsewhere with `--source`: ```bash -scripts/playground python --source "$HOME/my-durable-python-worker" +scripts/playground rust --source "$HOME/my-durable-rust-worker" ``` -The default source and evidence paths are ignored by Git. The development image -contains an isolated Python SDK environment, the PHP SDK package, and the Rust -SDK dependency and development build caches, so first use does not rebuild an -SDK graph. Remove the isolated Server, database, Redis, and Waterline state -with `scripts/playground down `. - -Local published Server remains the default and requires no managed-service -access. To run the same authored journey against an existing managed runtime, -provide separate worker and client credentials through -`DURABLE_WORKFLOW_WORKER_TOKEN` and `DURABLE_WORKFLOW_CLIENT_TOKEN`, then make -the runtime contract explicit on the same language command: +Inspect the effective contract without starting anything: ```bash -language=rust # Choose php, python, or rust. -scripts/playground "$language" --runtime managed \ - --runtime-url "https://runtime.example/namespaces/example" \ - --namespace "example" \ - --task-queue "sample-app-playground-example" +scripts/playground doctor +scripts/playground rust --print-contract ``` -The runner keeps the two credentials in their respective worker and client -processes. It waits until the managed runtime advertises the current -invocation's generated worker identity, workflow type, activity type, and exact -queue before starting the client. The completed result must contain the caller -input after it crosses both the workflow and activity boundaries; the final -success record names the runtime, namespace, queue, registered types, and -expected result shape without printing credential values. Managed evidence -records explicitly identify Waterline proof as omitted because this mode does -not provision a Waterline observer. Runtime enrollment and credential creation -remain the managed service's setup responsibility. - -The default `php` journey uses the framework-neutral `durable-workflow/sdk` -through its Laravel bridge. The caller-owned activity receives configuration -and a PSR logger from Laravel's container, and the live worker uses the bridge's -role-scoped client and service configuration with the generated invocation -identity. The scaffold's focused SDK test fake uses `WorkerFactory`. The journey -runs that local test before registering the live worker, so moving from the -embedded engine to service mode retains dependency injection, application -configuration, logging, and testability. - -For a framework-free PHP process, explicitly scaffold the installed SDK -package's own examples instead of copying another implementation into Sample -App: +Remove one playground's containers and state with: ```bash -scripts/playground scaffold php --standalone --source "$HOME/durable-php-worker" +scripts/playground down rust ``` -The installed package owns `bootstrap.php`, `worker.php`, and `client.php`. -This scaffold-only variation does not replace the executed playground journey; -use the shared language command above for either the local or managed runtime -proof. +### Managed runtime -#### Laravel integration variation - -For framework integration work, an optional narrower variation uses the -standalone Server with Laravel's SDK bridge plus PHP and Python activities: +The same authored project can run against Durable Workflow Cloud or another +existing runtime. Supply role-specific credentials without putting them in +source control: ```bash -scripts/service-mode.sh +export DURABLE_WORKFLOW_WORKER_TOKEN='' +export DURABLE_WORKFLOW_CLIENT_TOKEN='' + +scripts/playground rust \ + --runtime managed \ + --runtime-url 'https://runtime.example/namespaces/example' \ + --namespace 'example' \ + --task-queue 'my-rust-worker' ``` -It remains documented as an advanced [Laravel integration -variation](polyglot/README.md#laravel-integration-variation), not a separate -deployment path. +The worker receives only the worker credential, and the client receives only +the client credential. The runner prints the workflow type, activity type, +task queue, worker command, start command, and expected result before it runs. - -### Embedded Laravel +## Embedded Laravel -Codespaces setup has already created the environment, generated the application -key, migrated the database, and verified MySQL, Redis, and Playwright. Start -Laravel's web, queue, log, and asset processes: +Start Laravel's web server, queue worker, logs, and Vite process: ```bash composer run dev @@ -166,549 +119,76 @@ In a second terminal, start the example workflow: php artisan app:workflow ``` -Open Waterline at the forwarded port 18080 URL under `/waterline`. The workflow -result also appears in the terminal. Run the workflow and activity tests with: +Open `/waterline` on the forwarded application URL to inspect the run. Run the +Laravel workflow and activity tests with: ```bash php artisan test ``` -The embedded stack uses the Codespace's `sample-app` Compose state. Workflow -instances receive generated identities, so the command is safe to run again. - -## Observe a run - -Check the two observability surfaces separately: - -| Surface | Use it for | Where to look | -|---------|------------|---------------| -| Waterline and the workflow database | Durable workflow truth: run status, typed history, signals, updates, timers, retries, failures, and operator actions. | The run URL printed by application journeys, or `/waterline` when the selected stack includes it | -| Worker logs and SDK metrics | Runtime behavior: poll latency, task duration, exporter wiring, custom application metrics, and worker-side errors before they become durable failures. | Laravel logs for PHP workers; SDK metrics endpoints for external workers | - -Waterline proves that the durable run exists and shows what the engine or -standalone Server committed. Worker metrics remain a separate runtime surface. -Minimal Python worker Prometheus wiring uses the compatibility-qualified SDK -from the public quickstart contract: - -```bash -curl -fsSL https://durable-workflow.com/install-sdk.sh | \ - DURABLE_WORKFLOW_PYTHON_EXTRAS=prometheus sh -s -- python -``` - -```python -from prometheus_client import start_http_server - -from durable_workflow import Client, PrometheusMetrics, Worker - -metrics = PrometheusMetrics() -start_http_server(9102) - -async with Client("http://localhost:8080", token="secret", metrics=metrics) as client: - worker = Worker( - client, - task_queue="default", - workflows=[GreeterWorkflow], - activities=[greet], - metrics=metrics, - ) - await worker.run() -``` - -Replace `GreeterWorkflow` and `greet` with the workflow and activity handlers registered by that worker. +The embedded examples live under [app/Workflows](app/Workflows), with focused +tests under [tests/Feature/Workflows](tests/Feature/Workflows). -Scrape `:9102/metrics` for `durable_workflow_worker_*` and `durable_workflow_client_*` series. Use Waterline for the matching workflow history and status. +## Example index -## Prebuilt development image +| Example | What it demonstrates | +| --- | --- | +| `SimpleWorkflow` | Minimal embedded workflow and activity | +| `AccountOnboardingWorkflow` | Signals, timers, and external interaction | +| `BatchProcessingWorkflow` | Bounded concurrency and fan-out | +| `DataPipelineWorkflow` | Multi-step activity orchestration | +| `DeployWorkflow` | Child workflows and deployment stages | +| `SagaWorkflow` | Compensation after a failed step | +| `SubscriptionWorkflow` | Long-running lifecycle and cancellation | +| `SandboxAgentWorkflow` | Durable sandbox provisioning, snapshots, recovery, and cleanup | +| `PolyglotWorkflow` | PHP workflow with Python and Rust activities | -The default devcontainer Compose file pulls -`ghcr.io/durable-workflow/sample-app-devcontainer:main`. The same image is -available from Docker Hub as -`durableworkflow/sample-app-devcontainer:main`; select it without changing the -Compose file by setting `SAMPLE_APP_DEVCONTAINER_IMAGE` before opening the -devcontainer. Both channels support Linux AMD64 and ARM64. +## Repository map -`main` is the moving channel built from protected `main` pushes and the weekly -refresh. The Chromium revision follows the exact Playwright version in -`package-lock.json`, which is covered by the weekly dependency update path. -Every publication is retained under an immutable -`sha--run--` tag in both registries. -Published indexes include OCI source/revision labels, BuildKit provenance, and -an SPDX SBOM. The moving tags advance only after both registry copies and both -architectures pass the unauthenticated Compose qualification. +| Path | Purpose | +| --- | --- | +| `app/Workflows/` | Embedded Laravel examples | +| `polyglot/` | Service-mode workers and Compose topology | +| `playground/` | PHP, Python, and Rust authoring templates | +| `scripts/polyglot.sh` | Featured PHP to Python to Rust run | +| `scripts/playground` | Symmetric SDK authoring runner | +| `.devcontainer/` | Prepared Codespaces development image | -The prepared image also runs the OpenSSH server expected by Codespaces tooling, -so remote shell and creation-log access do not require a per-Codespace feature -install. +## Local development -There is deliberately no local Dockerfile fallback in the Codespaces Compose -topology. A pull or qualification failure stops setup instead of reconstructing -the old operating-system environment. To qualify a published image manually -and write phase timings to a JSON file, run: +Codespaces is the shortest path. For an existing PHP 8.4, Node, Docker, and +Docker Compose environment: ```bash -scripts/ci/qualify-devcontainer-image.sh \ - ghcr.io/durable-workflow/sample-app-devcontainer:main \ - linux/amd64 \ - devcontainer-qualification-timing.json -``` - -The qualification pulls the selected image, starts the same MySQL/Redis and -Laravel/microservice topology with `--no-build`, installs only repository -dependencies, launches Chromium as the non-root `laravel` user, verifies the -SSH endpoint, checks the application health endpoint and mounted-checkout -editability, and records fresh and warm startup timings. - ----- - -## Run Locally With Docker - -Prefer a local workstation over Codespaces? The repository ships a -`docker-compose.yml` that builds and runs the app, worker, MySQL, and Redis on -any host with Docker Engine and Docker Compose v2 installed. - -```bash -# 1. Clone and enter the repo -git clone https://github.com/durable-workflow/sample-app.git -cd sample-app - -# 2. (Optional) expose the app on a non-default port -export APP_PORT=18080 - -# 3. Build and start the stack. --wait blocks until health checks pass. -docker compose up -d --build --wait app worker - -# 4. Run migrations against the shared sample database. -docker compose exec -T app php artisan migrate:fresh --force - -# 5. Run the simplest deterministic sample end-to-end. -docker compose exec -T app php artisan app:workflow -``` - -Once the stack is up, Waterline is at `http://localhost:${APP_PORT:-8000}/waterline/dashboard` -and the MCP server is at `http://localhost:${APP_PORT:-8000}/mcp/workflows`. - -For a release-style proof from a clean checkout, use the combined entry point -instead of the manual build, migration, and sample commands above. It builds the -resolved artifact tuple once, runs deterministic smoke, and continues through -the provider-free conformance matrix on the same healthy stack and schema. AI -surfaces are recorded as intentional skips, so this command needs no provider -credential: - -```bash -scripts/compose-smoke-conformance.sh -``` - -The standalone full-conformance entry point remains self-contained for callers -that do not need the deterministic preflight. AI-backed surfaces are disabled -by default, so this form cannot consume a provider credential discovered in the -shell or an ancestor dotenv file: - -```bash -scripts/compose-conformance.sh -``` - -The harness emits a JSON document with the sample-app commit, artifact versions, -timestamp, per-surface outcome, focused findings, setup measurements, and any -skipped surfaces. Setup measurements include whether the run started with a -clean or warm image cache, setup duration, peak Docker disk growth, build -invocation count, and whether a prepared stack was reused. Run the combined -entry point once without its app image and again with the resulting cache to -capture comparable clean-cache and warm-cache measurements. It runs the documented -artisan samples, browser checks for the app and Waterline, the MCP workflow API, -an API documentation check that compares the README's documented MCP tools and -workflow keys with the live endpoint, a Waterline/manual observation check using -`workflow:v2:history-export`, local sandbox lifecycle variants, sandbox recovery -injection, and, in explicit provider mode, the Prism/AI samples. The Prism check -uses `OPENAI_API_KEY` for the live model-backed AI surface. The travel-agent success and failure-injection -checks reuse one deterministic booking plan so the run proves signals, durable -assistant messages, booking activities, and compensation without spending extra -model calls on each failure variant. -The polyglot harness builds the exact released Rust SDK from crates.io and -executes Rust-authored workflows and activities across the PHP, Python, and -Rust runtime matrix. Its report distinguishes registered Rust execution from -the release-cohort version pin. -`SAMPLE_APP_CONFORMANCE_SKIP_AI=1` is the safe release and automation mode. It -passes `--skip-ai` to `app:conformance`, keeps `OPENAI_API_KEY` out of Compose -exec arguments, and records every AI-backed surface as explicitly skipped while -the deterministic and scripted agent-operability surfaces still run. Intentional -AI skips are allowed automatically in this mode; combining them with `--strict` -is rejected as a contradictory coverage request. - -Provider-backed conformance requires an explicit opt-in, even when a credential -is already present. Its release proof is: - -```bash -export OPENAI_API_KEY=your-provider-key -SAMPLE_APP_CONFORMANCE_SKIP_AI=0 \ -scripts/compose-smoke-conformance.sh --strict -``` - -Set `SAMPLE_APP_CONFORMANCE_ENV_FILE` only on that opt-in path when the key lives -in a dotenv file outside the repository; the wrapper then checks local -workspace-level dotenv files without printing credential values. Provider mode -requires strict coverage by default, and `--strict` makes that requirement -explicit. Without AI credentials, the run stays non-passing and names the live -Prism surface as uncovered. Set -`DURABLE_SERVER_IMAGE`, `DURABLE_WORKFLOW_CLI_VERSION`, -`DURABLE_WORKFLOW_PYTHON_SDK_VERSION`, `DURABLE_WORKFLOW_RUST_SDK_VERSION`, -`DURABLE_WORKFLOW_PHP_SDK_VERSION`, `DURABLE_WORKFLOW_WORKFLOW_VERSION`, and -`DURABLE_WORKFLOW_WATERLINE_VERSION` to override the published artifact set. -The PHP SDK variable selects the framework-neutral `durable-workflow/sdk` -package used by `polyglot/`; the Workflow variable selects the separate -`durable-workflow/workflow` engine used by this Laravel application. -By default, the wrapper calls -`scripts/resolve-current-artifacts.sh`, which resolves one 2.0 prerelease -channel from the public docs release-audit manifest. Beta tuples remain -synchronized, while release-candidate tuples may contain component-specific -increments as long as every component stays in the `rc` channel. - - -| Prerelease channel | Component-version policy | -|--------------------|--------------------------| -| `beta` | `synchronized` | -| `rc` | `component-specific` | -| `mixed` | `rejected` | - - -The resolver emits the accepted tuple as shell assignments and preserves -explicit overrides. The wrapper rebuilds the app and worker containers with the -resolved PHP SDK, Workflow, and Waterline pins before running the harness, so -the recorded versions come from installed packages rather than the committed -fallback lock. The polyglot Rust image likewise applies the resolved SDK version -to its build-local manifest, leaving the committed Cargo manifest and lock as -the pinned fallback. The standalone PHP stack independently installs and -executes the resolved PHP SDK pin. Set -`DURABLE_WORKFLOW_ARTIFACT_SOURCE=pinned` for a reproducible run against the -committed sample-app fallback tuple instead. Set -`DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE=/path/to/tuple.json` when a local run -should use a previously captured public tuple manifest. -The wrapper passes the host checkout SHA into the app container as -`SAMPLE_APP_COMMIT`; set that variable explicitly when running from a source -archive or another environment without Git metadata. The same value is forwarded -as a Docker build and runtime variable so source-free containers can report the -sample-app revision without reading a local `.git` checkout. -The wrapper also copies the JSON metadata back to -`storage/app/sample-app-conformance-metadata.json`; set -`SAMPLE_APP_CONFORMANCE_METADATA_PATH` to choose a different host-side path. -Pass that file as `DW_AGENT_OPERABILITY_SAMPLE_APP_METADATA_PATH` when validating -the agent-operability executable-loop contract against the current artifact -tuple. -The app service has the browser-safe `sample-app` network alias, and the wrapper -uses `http://sample-app:8000` inside the Compose network so browser activities -running in the worker container can reach the app without an HTTPS upgrade. Set -`SAMPLE_APP_CONFORMANCE_URL` when running against a different network address. -The wrapper derives one coverage policy from the AI mode: provider-free mode -allows its intentional AI skips, while provider mode is strict by default. Set -`SAMPLE_APP_CONFORMANCE_ALLOW_SKIPS=1` only for exploratory provider-mode runs -that should return zero while naming missing provider-backed evidence. -`scripts/compose-smoke.sh` starts with the bounded deterministic preflight: it -runs the deterministic samples and exits after printing the blocked step, -container status, and recent app/worker logs on failure. By default, a passing -preflight continues into the broader public sample-app conformance surface so a -release/conformance caller does not accidentally record deterministic smoke as -full coverage. The handoff records the prepared app and worker containers; the -full wrapper reuses them only when their health, artifact tuple, credentials, -installed packages, and migrated schema still match. Otherwise it falls back to -its self-contained rebuild and schema reset. Set `SAMPLE_APP_SMOKE_ONLY=1` when -a caller intentionally wants only the deterministic path. Set -`SAMPLE_APP_CONFORMANCE_AFTER_SMOKE=0` to disable the chained full surface for -exploratory local runs, or run `scripts/compose-conformance.sh --strict` -directly with `SAMPLE_APP_CONFORMANCE_SKIP_AI=0` when a strict provider run does -not need the deterministic preflight. - -Tear the stack down with `docker compose down -v --remove-orphans` when -finished. The deterministic Docker path is exercised on every push through the -`smoke` GitHub Actions workflow, and the full harness is available for release -and conformance checks that have the required credentials. - ----- - -#### Sample Index - -Use this index when you want a specific Durable Workflow pattern instead of another happy-path snippet. - -| Goal | Workflow | Command | MCP key | -|------|----------|---------|---------| -| Learn the smallest v2 workflow/activity shape | `App\Workflows\Simple\SimpleWorkflow` | `php artisan app:workflow` | `simple` | -| Measure durable elapsed time without replay drift | `App\Workflows\Elapsed\ElapsedTimeWorkflow` | `php artisan app:elapsed` | `elapsed` | -| Coordinate work across Laravel app boundaries | `App\Workflows\Microservice\MicroserviceWorkflow` | `php artisan app:microservice` | `microservice` | -| Run browser automation and collect generated artifacts | `App\Workflows\Playwright\CheckConsoleErrorsWorkflow` | `php artisan app:playwright https://example.com` | `playwright` | -| Start from an external webhook and wait for a signal | `App\Workflows\Webhooks\WebhookWorkflow` | `php artisan app:webhook` | `webhook` | -| Wrap an AI activity loop in durable retry/validation | `App\Workflows\Prism\PrismWorkflow` | `php artisan app:prism` | `prism` | -| Build a signal-driven AI agent with compensation | `App\Workflows\Ai\AiWorkflow` | `php artisan app:ai` | `ai` | -| Orchestrate an ephemeral agent sandbox with durable lifecycle | `DurableWorkflow\AI\Workflows\SandboxAgentWorkflow` | `php artisan app:sandbox` | `sandbox` | -| Run one PHP workflow that combines Python and Rust activity results | `App\Workflows\Polyglot\PolyglotWorkflow` | `scripts/polyglot.sh` | `polyglot` | -| Exercise machine-readable failure diagnosis and repair refusal | `App\Workflows\Diagnostics\DiagnosticFailureWorkflow` | `/mcp/workflows` `start_workflow` with `workflow=diagnostic_failure` | `diagnostic_failure` | - -#### Migrating from Durable Workflow 1.x - -Porting a workflow from the v1 generator API to the v2 Fiber API is mechanical. The v1 sources live on the [`Laravel-12` branch](https://github.com/durable-workflow/sample-app/tree/Laravel-12); use it as a side-by-side reference while you migrate. - -Workflow shape: - -- Extend `Workflow\V2\Workflow` instead of `Workflow\Workflow`. -- Import helpers from the `Workflow\V2\` namespace: `use function Workflow\V2\{activity, sideEffect, await, timer};`. -- Replace `yield activity(...)` with a straight-line `activity(...)` call — the Fiber runtime suspends transparently. -- Rename the entry method from `execute(...)` to `handle(...)` and add return types. - -Activities: - -- Extend `Workflow\V2\Activity` and define `handle(...)` with typed parameters and return type. Activities are invoked by class name from workflow code, for example `activity(SimpleActivity::class)`. - -Signals, updates, webhooks: - -- Signals shifted from push to pull. Import the class-level contract attribute with `use Workflow\V2\Attributes\Signal;`, declare it as `#[Signal('name', [...])]`, and block on `await('name')` inside `handle()` to receive each delivery; `await('name', $timeout)` returns `null` on timeout for chat-style loops. -- `#[UpdateMethod]` and `#[QueryMethod]` carry over verbatim. -- From the outside, use explicit names: `$workflow->signal('name', $payload)` and `$workflow->update('name', ...)`. -- Webhook routing now takes an explicit alias map: `Workflow\V2\Webhooks::routes(['webhook-workflow' => WebhookWorkflow::class]);`. - -Compensation closures: - -- `addCompensation(callable)` and `compensate()` on the v2 `Workflow` base class are unchanged. Drop `yield from` inside the closures: `addCompensation(fn () => activity(CancelHotelActivity::class, $hotel));`. - -Stub usage: - -- Use `Workflow\V2\WorkflowStub`. The `make()`, `load()`, `start()`, `running()`, `output()`, `signal()`, and `update()` methods carry over; poll with `$stub->refresh()->running()` and a small `usleep(100_000)` between checks instead of a tight loop. - -The `App\Workflows\Simple\SimpleWorkflow`, `App\Workflows\Webhooks\WebhookWorkflow`, and `App\Workflows\Ai\AiWorkflow` samples in this repo are the canonical references for the basic shape, webhook entry, and signal/update agent patterns respectively. - -#### Message Streams - -Use message streams when a workflow needs to publish or consume repeated messages without writing Durable Workflow storage rows directly. The v2 authoring API is exposed through `Workflow::inbox()`, `Workflow::outbox()`, and `Workflow::messages()`; those facades own `workflow_messages` rows and stream cursor advancement for the workflow run. - -`App\Workflows\Ai\AiWorkflow` is the reference sample. It stores large assistant payloads in the app-owned `ai_workflow_messages` table, then publishes only a durable reference on the `ai.assistant` stream: - -```php -$this->outbox(self::ASSISTANT_STREAM) - ->sendReference( - $this->workflowId(), - $reference, - correlationId: $reference, - idempotencyKey: $reference, - metadata: ['role' => 'assistant'], - ); -``` - -The `receive` update consumes the next assistant reply through the matching inbox stream: - -```php -$streamMessage = $this->inbox(self::ASSISTANT_STREAM) - ->receiveOne(); +cp .env.example .env +composer install +npm install +php artisan key:generate +docker compose up -d +php artisan migrate +composer run dev ``` -`receiveOne()` consumes the message and advances the durable stream cursor, so repeated receives deliver new replies instead of replaying old ones. Keep app tables as payload/reference stores; let Durable Workflow own `workflow_messages` and stream cursor advancement through the facade. - -#### Sandbox Orchestration - -Long-running coding agents need an ephemeral workspace, but lifecycle and -recovery infrastructure should not be copied into each application. This app -consumes `durable-workflow/ai`; the package owns the versioned provider contract, -activities, `DurableWorkflow\AI\Workflows\SandboxAgentWorkflow`, E2B and local -adapters, stable operation IDs, post-snapshot reconstruction, leases, and -cleanup. The Sample App retains only its command, configuration example, and -end-to-end demonstration. - -`config/durable-workflow-ai.php` selects the provider. The default local -subprocess provider is development/test-only, runs with the worker's privileges, -and is not a security isolation boundary. The E2B adapter uses the documented -HTTP API. This sample does not expose E2B suspend/resume because paused -sandboxes have no provider TTL; it must not be enabled without an independent -durable cleanup deadline. Both built-in providers explicitly declare -at-least-once tool effects; a lost acknowledgement can repeat a mutating call. - -Run the sample with: +Run the focused checks with: ```bash -php artisan app:sandbox # local subprocess provider -php artisan app:sandbox --snapshot-every=2 # snapshot every 2 tool calls -php artisan app:sandbox --snapshot-every=2 --inject-loss-after=2 # inject local loss after the checkpoint -DURABLE_AI_SANDBOX_DRIVER=e2b E2B_API_KEY=… php artisan app:sandbox -``` - -See [docs/sandbox-orchestration.md](docs/sandbox-orchestration.md) for the -integration walkthrough and links to the package's delivery contract and -provider-author guide. - -#### Polyglot - -The repository ships a runnable polyglot demonstration in -[`polyglot/`](polyglot/). It brings up the standalone Durable Workflow -server with framework-neutral PHP workers from the published -`durable-workflow/sdk` package, Python workers, and crates.io-installed Rust -workers side by side. The root Laravel example remains a separate embedded -mode backed by `durable-workflow/workflow`. Nine workflow/activity runtime -cells run end to end: - -- a Python-authored workflow on its own Python image, and -- a PHP-authored workflow in `polyglot/php_worker/worker.php` - that schedules `polyglot.php-to-python.*` activities handled by the - Python worker on a shared task queue, and -- a Python-authored workflow that schedules `polyglot.python-to-php.*` - activities handled by a distinct PHP activity worker. -- Rust-authored workflows and activities run same-language, Rust-to-PHP, - Rust-to-Python, PHP-to-Rust, and Python-to-Rust cells. - -The cross-language scenarios are wire-level tests: the workflow -runtime and activity runtime register separately, and each scheduled -activity crosses the language boundary on the wire — not just inside -one process. The smoke runs in CI on every pull request via -`.github/workflows/polyglot-validation.yml`, so a regression in either -direction is caught before release rather than in the field. - -The codec round-trip rules — which payload values cross the language -boundary cleanly and which need explicit adapters — are documented in -the workflow package at -[`docs/architecture/polyglot-codec-roundtrip.md`](https://github.com/durable-workflow/workflow/blob/v2/docs/architecture/polyglot-codec-roundtrip.md). -Operators of polyglot fleets should treat the "requires an explicit -adapter" set as a workflow-author contract: the SDKs fail closed at the -boundary rather than guess at a serialisation. - -#### Replay-Safety Teaching Notes - -Durable Workflow v2 replays workflow code to rebuild local state from committed history. Keep workflow methods deterministic: call activities for side effects, use `sideEffect()` for values such as timestamps or random IDs, and wait for outside input through signals, updates, timers, or message streams. - -Do this when a workflow needs the current time: - -```php -use function Workflow\V2\sideEffect; - -$startedAt = sideEffect(fn () => now()->getTimestamp()); -``` - -Don't do this inside workflow code: - -```php -$startedAt = now(); -``` - -The direct `now()` call looks harmless, but replay can run the method again later and produce a different value than the one that originally drove branching, timeouts, or output. Prefer portable Avro Value types inside `sideEffect()` callbacks — integer timestamps, ISO-8601 strings, UUIDs — so the recorded value has the same meaning on replay. Convert domain objects such as Carbon instances through an explicit adapter instead of relying on PHP object serialization. The `ElapsedTimeWorkflow` sample keeps clock reads behind `sideEffect()` as integer timestamps, and the `SimpleWorkflow`, `PrismWorkflow`, and `AiWorkflow` samples keep external work inside activities for the same reason. - -In addition to the basic example workflow, you can try these other workflows included in this sample app: - -* `php artisan app:elapsed` – Demonstrates how to correctly track start and end times to measure execution duration. - -* `php artisan app:microservice` – A fully working example of a workflow that spans multiple Laravel applications using a shared database and queue. - -* `php artisan app:playwright` – Runs a Playwright automation against `https://example.com`, captures a WebM video, encodes it to MP4 using FFmpeg, and then cleans up the WebM file. Pass a URL to check another page, for example `php artisan app:playwright http://localhost:8000/waterline/dashboard`. - -* `php artisan app:webhook` – Showcases how to use the built-in webhook system for triggering workflows externally. - -* `php artisan app:prism` - Uses Prism to build a durable AI agent loop. It asks an LLM to generate user profiles and hobbies, validates the result, and retries until the data meets business rules. - -* `php artisan app:ai` - NEW! Uses Laravel AI SDK to build a durable travel agent. The agent asks questions and books hotels, flights, and rental cars. If a booking error occurs, the workflow ensures prior bookings are canceled; an inactivity timeout closes the conversation without rolling back successful interactive bookings. For repeatable checks, pass one or more `--message="..."` options and optionally `--inactivity-timeout=5`; use `--inject-failure=hotel`, `--inject-failure=flight`, or `--inject-failure=car` to exercise compensation. `--booking-plan-json='{"text":"...","bookings":[...]}'` lets deterministic scripted checks run a single planned turn while still exercising the workflow, booking activities, and compensation. - -* `php artisan app:sandbox` - Package integration demo for `durable-workflow/ai`. The command dispatches a short tool sequence through the reusable sandbox workflow. Use `--snapshot-every=2 --inject-loss-after=2` to inject one local lifecycle loss outside the tool journal and exercise recovery, or set `DURABLE_AI_SANDBOX_DRIVER=e2b` plus `E2B_API_KEY` to use E2B Cloud. The local subprocess provider is development/test-only and is not a security isolation boundary; E2B suspend/resume is unavailable until paused resources have an independent durable cleanup deadline. - -Try them out to see workflows in action across different use cases! - ----- - -#### MCP Integration for AI Clients - -This sample app includes an MCP (Model Context Protocol) server that allows AI clients (ChatGPT, Claude, Cursor, etc.) to start and monitor Durable Workflow v2 workflows. Treat it as the agent-operable companion to Waterline: humans can inspect `/waterline/dashboard`, while AI clients receive structured workflow IDs, run IDs, statuses, recent typed history, and failure summaries. - -The MCP server is named `Durable Workflow`. - -It is not a separate daemon in this repo. The server is exposed by the Laravel application itself, so once the app is running, the MCP route is live as part of the normal HTTP server. - -##### Endpoint - -The MCP server is available at: `/mcp/workflows` - -##### Running It - -To make the MCP server available locally: - -1. Run `php artisan app:init` -2. Start the queue worker with `php artisan queue:work redis --queue=default,activity` -3. Start the Laravel app with `php artisan serve` -4. Connect your MCP client to `http://localhost:8000/mcp/workflows` - -If you prefer Docker, run `docker compose up --build`, then run `docker compose exec app php artisan migrate --force` once the containers are healthy. After migrations complete, connect to `http://localhost:8000/mcp/workflows`. - -##### Available Tools - -| Tool | Description | -|------|-------------| -| `list_workflows` | Discover configured workflow keys, credential requirements, status values, and recent v2 runs | -| `start_workflow` | Start a configured v2 workflow asynchronously and get a workflow instance ID plus run ID | -| `get_workflow_result` | Check workflow status, output, visibility metadata, and latest failure summary | -| `get_workflow_history` | Inspect a bounded slice of typed v2 history events and latest durable failures | -| `diagnose_workflow` | Summarize health facts, root-cause classification, remediation, latest failure evidence, and safe next actions for stuck or failed runs | -| `repair_workflow` | Request the built-in v2 repair command and receive a structured accepted, refused, or not-needed mutation result | - -##### Configuration - -Available workflows are defined in `config/workflow_mcp.php`. By default, every workflow in the sample index is exposed: - -- `simple` → `App\Workflows\Simple\SimpleWorkflow` -- `elapsed` → `App\Workflows\Elapsed\ElapsedTimeWorkflow` -- `microservice` → `App\Workflows\Microservice\MicroserviceWorkflow` -- `playwright` → `App\Workflows\Playwright\CheckConsoleErrorsWorkflow` (requires local Playwright/Node/FFmpeg setup) -- `webhook` → `App\Workflows\Webhooks\WebhookWorkflow` (waits for the `ready` signal) -- `prism` → `App\Workflows\Prism\PrismWorkflow` (requires `OPENAI_API_KEY`) -- `ai` → `App\Workflows\Ai\AiWorkflow` (requires `OPENAI_API_KEY`, then accepts `send` signals and `receive` updates) -- `sandbox` → `DurableWorkflow\AI\Workflows\SandboxAgentWorkflow` (package-owned lifecycle and recovery; defaults to the development-only local subprocess provider, set `DURABLE_AI_SANDBOX_DRIVER=e2b` plus `E2B_API_KEY` for E2B Cloud) -- `polyglot` → `App\Workflows\Polyglot\PolyglotWorkflow` (run `scripts/polyglot.sh` to start the standalone PHP workflow worker plus distinct Python and Rust activity workers against the current artifact tuple) -- `diagnostic_failure` → `App\Workflows\Diagnostics\DiagnosticFailureWorkflow` (no credentials; intentionally records a durable activity failure so MCP clients can prove `diagnose_workflow` and `repair_workflow` behavior) - -To add more workflows, update the config file: - -```php -'workflows' => [ - 'simple' => [ - 'class' => App\Workflows\Simple\SimpleWorkflow::class, - 'description' => 'Small deterministic workflow.', - 'pattern' => 'deterministic activity chain', - 'command' => 'php artisan app:workflow', - 'requires' => [], - 'arguments' => [], - ], - 'my_workflow' => [ - 'class' => App\Workflows\MyWorkflow::class, - 'description' => 'What an agent should know before starting it.', - 'requires' => ['EXTERNAL_API_KEY'], - 'arguments' => [ - ['name' => 'customer_id', 'type' => 'string'], - ], - ], -], +php artisan test +npm test +npm run build ``` -Class-string mappings are still accepted for small local experiments, but the array form gives agents safer discovery metadata. - -##### Example Usage - -An AI client would typically: - -1. Call `list_workflows` to see available workflows -2. Call `start_workflow` with `{"workflow": "simple", "business_key": "demo-001"}` -3. Receive `workflow_id` and `run_id` in the response -4. Poll `get_workflow_result` with the `workflow_id` until status is `completed` -5. Read the `output` field for the workflow result -6. If status is `failed` or `waiting` longer than expected, call `diagnose_workflow` -7. Read `root_cause.category`, `remediation.classification`, and `remediation.automatic_repair.allowed` -8. Call `repair_workflow` only when remediation marks repair as allowed, then poll `get_workflow_result` and inspect `get_workflow_history` with the `run_id` -## Reporting Bugs and Requesting Samples - -Use the structured templates under [Issues](https://github.com/durable-workflow/sample-app/issues/new/choose) so reproducers and sample requests land with the metadata maintainers need: - -- **Bug reproducer.** A reproducer runs in this app: it names the workflow class, the artisan command, the Durable Workflow package version, and the observed durable failure. Reproducers that follow the template land as new workflows under `app/Workflows/Bug//` and stay covered by CI after the bug is fixed. -- **Sample request.** A sample request names the Durable Workflow pattern that is not yet covered, the public docs page that defines it, and the minimum package version it needs. Requests close when a workflow under `app/Workflows/` exercises the pattern end to end and is wired into the artisan command list and `config/workflow_mcp.php`. +## Documentation -Bugs in the workflow engine itself or the standalone Durable Workflow server belong on the [`workflow`](https://github.com/durable-workflow/workflow/issues/new/choose) and [`server`](https://github.com/durable-workflow/server/issues/new/choose) repos respectively; the issue chooser links those out. +- [Durable Workflow documentation](https://durable-workflow.com/docs/2.0/) +- [PHP SDK](https://php.durable-workflow.com/) +- [Python SDK](https://python.durable-workflow.com/) +- [Rust SDK](https://rust.durable-workflow.com/) +- [Durable Workflow Cloud](https://cloud.durable-workflow.com/) -## Contributing a Sample +The Laravel 12 / Durable Workflow 1.x sample remains available on the +[`Laravel-12` branch](https://github.com/durable-workflow/sample-app/tree/Laravel-12). -Have a Durable Workflow pattern you want to share? Read -[CONTRIBUTING.md](CONTRIBUTING.md) for the full contract — workflow -class layout, artisan command name, MCP entry, test, README index row, -and the docs-site gallery and pattern-page cross-link that ship in the -same change. The -[Contribute a Sample](https://durable-workflow.github.io/docs/2.0/contribute-a-sample) -page on the docs site is the canonical version of the same guide. +## License -Maintainers tagging an upstream release should read -[`docs/release-notes-feature-contract.md`](docs/release-notes-feature-contract.md) -first; it names the bar a sample must meet to be cited in upstream -release notes and the checklist that runs before a release tag lands. +Durable Workflow Sample App is open source software licensed under the +[MIT license](LICENSE). diff --git a/composer.json b/composer.json index 08d2bb4..bbc0cc2 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "$schema": "https://getcomposer.org/schema.json", - "name": "laravel/laravel", + "name": "durable-workflow/sample-app", "type": "project", - "description": "The skeleton application for the Laravel framework.", + "description": "Runnable Laravel, PHP, Python, and Rust examples for Durable Workflow 2.0.", "keywords": [ "laravel", "framework" @@ -11,9 +11,9 @@ "require": { "php": "^8.4", "durable-workflow/ai": "dev-main", - "durable-workflow/sdk": "2.0.0-rc.53", - "durable-workflow/waterline": "2.0.0-rc.33", - "durable-workflow/workflow": "2.0.0-rc.52", + "durable-workflow/sdk": "^2.0", + "durable-workflow/waterline": "^2.0", + "durable-workflow/workflow": "^2.0", "laravel/ai": "^0.6.0", "laravel/framework": "^13.0", "laravel/mcp": "^0.6.0", diff --git a/composer.lock b/composer.lock index 12227f0..f398a3f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4763f58806f582da4291c1ae6d09c78d", + "content-hash": "ff602ff2b951047bc5768272693875c6", "packages": [ { "name": "apache/avro", @@ -720,16 +720,16 @@ }, { "name": "durable-workflow/sdk", - "version": "2.0.0-rc.53", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "51c03a5f6aebd3aaab9d7b301f6550824cb7e515" + "reference": "af7931e79683adf55bfa87cd7b3b90561bc137cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/51c03a5f6aebd3aaab9d7b301f6550824cb7e515", - "reference": "51c03a5f6aebd3aaab9d7b301f6550824cb7e515", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/af7931e79683adf55bfa87cd7b3b90561bc137cf", + "reference": "af7931e79683adf55bfa87cd7b3b90561bc137cf", "shasum": "" }, "require": { @@ -770,7 +770,7 @@ "laravel": "^9.0|^10.0|^11.0|^12.0|^13.0", "symfony": "^6.4|^7.0|^8.0" }, - "product-train": "2.0.0-rc.53", + "product-train": "2.0.0", "payload-codecs": [ "avro" ], @@ -798,7 +798,7 @@ "deprecatePatch" ], "worker-protocol-version": "1.19", - "supported-server-versions": "2.0.0-rc.57", + "supported-server-versions": "2.0.0", "version-marker-history-event": "VersionMarkerRecorded", "message-streams-minimum-worker-protocol-version": "1.15", "durable-selection-minimum-worker-protocol-version": "1.19" @@ -832,20 +832,20 @@ "issues": "https://github.com/durable-workflow/sdk-php/issues", "source": "https://github.com/durable-workflow/sdk-php" }, - "time": "2026-08-29T18:20:43+00:00" + "time": "2026-09-01T01:00:27+00:00" }, { "name": "durable-workflow/waterline", - "version": "2.0.0-rc.33", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/durable-workflow/waterline.git", - "reference": "38e25e65db80f23a6fc785e9de6b6125668eba90" + "reference": "516c8d9b45a88ed2013d6fd27d64f56b966861ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/waterline/zipball/38e25e65db80f23a6fc785e9de6b6125668eba90", - "reference": "38e25e65db80f23a6fc785e9de6b6125668eba90", + "url": "https://api.github.com/repos/durable-workflow/waterline/zipball/516c8d9b45a88ed2013d6fd27d64f56b966861ba", + "reference": "516c8d9b45a88ed2013d6fd27d64f56b966861ba", "shasum": "" }, "require": { @@ -856,8 +856,8 @@ "laravel-workflow/waterline": "self.version" }, "require-dev": { - "durable-workflow/sdk": "2.0.0-rc.53", - "durable-workflow/workflow": "2.0.0-rc.52", + "durable-workflow/sdk": "2.0.0", + "durable-workflow/workflow": "2.0.1", "fakerphp/faker": "^1.9.1", "mockery/mockery": "^1.4.4", "orchestra/testbench": "^10.11", @@ -879,7 +879,7 @@ ] }, "durable-workflow": { - "product-train": "2.0.0-rc.33", + "product-train": "2.0.0", "service-capacity-evidence": { "schema": "durable-workflow.v2.namespace-capacity-evidence", "schema-version": 1, @@ -909,22 +909,22 @@ "description": "Operational UI for Durable Workflow across embedded and service-mode deployments.", "support": { "issues": "https://github.com/durable-workflow/waterline/issues", - "source": "https://github.com/durable-workflow/waterline/tree/2.0.0-rc.33" + "source": "https://github.com/durable-workflow/waterline/tree/2.0.0" }, - "time": "2026-08-29T22:59:51+00:00" + "time": "2026-09-01T01:59:19+00:00" }, { "name": "durable-workflow/workflow", - "version": "2.0.0-rc.52", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "b98bd5640c9476b8b6d966a4a4f12243c3e9af08" + "reference": "7b9c8308d14a3e8695fdf46a1a871cab8488c3e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/b98bd5640c9476b8b6d966a4a4f12243c3e9af08", - "reference": "b98bd5640c9476b8b6d966a4a4f12243c3e9af08", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/7b9c8308d14a3e8695fdf46a1a871cab8488c3e1", + "reference": "7b9c8308d14a3e8695fdf46a1a871cab8488c3e1", "shasum": "" }, "require": { @@ -954,7 +954,7 @@ ] }, "durable-workflow": { - "product-train": "2.0.0-rc.52", + "product-train": "2.0.2", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } @@ -981,9 +981,9 @@ "description": "Embedded durable workflow runtime and orchestration engine for Laravel applications.", "support": { "issues": "https://github.com/durable-workflow/workflow/issues", - "source": "https://github.com/durable-workflow/workflow/tree/2.0.0-rc.52" + "source": "https://github.com/durable-workflow/workflow/tree/2.0.2" }, - "time": "2026-08-29T17:10:17+00:00" + "time": "2026-09-01T08:31:26+00:00" }, { "name": "egulias/email-validator", @@ -1125,24 +1125,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, "type": "library", "autoload": { @@ -1171,7 +1171,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -1183,26 +1183,26 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2026-08-24T09:06:52+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.15.3", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.2", - "guzzlehttp/psr7": "^2.13", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1295,7 +1295,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.3" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -1311,20 +1311,20 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:48:21+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.2", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { @@ -1379,7 +1379,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.2" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -1395,20 +1395,20 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:30:54+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.13.0", + "version": "2.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" + "reference": "95e7828100de18b4e269fb1703be530082d5166d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", "shasum": "" }, "require": { @@ -1498,7 +1498,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.13.0" + "source": "https://github.com/guzzle/psr7/tree/2.13.1" }, "funding": [ { @@ -1514,30 +1514,30 @@ "type": "tidelift" } ], - "time": "2026-07-16T22:23:49+00:00" + "time": "2026-08-24T09:13:11+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.10", + "version": "v2.0.1", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7a466ad606491eb6528c717482f7cca77f1851f3", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", + "php": "^7.4 || ^8.0", "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.52 || ^9.6.34", - "uri-template/tests": "1.0.0" + "phpunit/phpunit": "^9.6.34", + "uri-template/tests": "1.0.2" }, "type": "library", "extra": { @@ -1584,7 +1584,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" + "source": "https://github.com/guzzle/uri-template/tree/v2.0.1" }, "funding": [ { @@ -1600,7 +1600,7 @@ "type": "tidelift" } ], - "time": "2026-07-17T13:53:03+00:00" + "time": "2026-08-24T17:13:02+00:00" }, { "name": "laravel/ai", @@ -1672,20 +1672,20 @@ }, { "name": "laravel/framework", - "version": "v13.24.0", + "version": "v13.29.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6d481710375d2aa67656922ef760cdd2b18bcfe0" + "reference": "6e2c363716964d8238cee7097b258119a984f0cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6d481710375d2aa67656922ef760cdd2b18bcfe0", - "reference": "6d481710375d2aa67656922ef760cdd2b18bcfe0", + "url": "https://api.github.com/repos/laravel/framework/zipball/6e2c363716964d8238cee7097b258119a984f0cf", + "reference": "6e2c363716964d8238cee7097b258119a984f0cf", "shasum": "" }, "require": { - "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18", + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", @@ -1698,9 +1698,10 @@ "ext-session": "*", "ext-tokenizer": "*", "fruitcake/php-cors": "^1.3", - "guzzlehttp/guzzle": "^7.8.2", - "guzzlehttp/promises": "^2.0.3", - "guzzlehttp/uri-template": "^1.0", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", "laravel/prompts": "^0.3.11", "laravel/serializable-closure": "^2.0.10", "league/commonmark": "^2.8.1", @@ -1712,6 +1713,7 @@ "nunomaduro/termwind": "^2.0", "php": "^8.3", "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", @@ -1786,7 +1788,6 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/psr7": "^2.9", "intervention/image": "^4.0", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", @@ -1802,7 +1803,7 @@ "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", "predis/predis": "^2.3 || ^3.0", - "rector/rector": "^2.3", + "rector/rector": "2.6.3", "resend/resend-php": "^1.0", "symfony/cache": "^7.4.0 || ^8.0.0", "symfony/http-client": "^7.4.0 || ^8.0.0", @@ -1836,7 +1837,6 @@ "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", - "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", @@ -1895,7 +1895,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-08-04T15:54:59+00:00" + "time": "2026-08-25T20:57:16+00:00" }, { "name": "laravel/mcp", @@ -1972,16 +1972,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.22", + "version": "v0.3.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4" + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/02b89b39e8972a998db4d5d4ad4719239dd4aee4", - "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", "shasum": "" }, "require": { @@ -2025,22 +2025,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.22" + "source": "https://github.com/laravel/prompts/tree/v0.3.24" }, - "time": "2026-08-04T14:50:50+00:00" + "time": "2026-08-20T12:55:36+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.15", + "version": "v2.0.16", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", "shasum": "" }, "require": { @@ -2088,7 +2088,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-07-21T16:49:22+00:00" + "time": "2026-08-18T20:28:54+00:00" }, { "name": "laravel/tinker", @@ -2161,16 +2161,16 @@ }, { "name": "league/commonmark", - "version": "2.9.1", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "73cb188c785abfa7a7bec73487148202968274c6" + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/73cb188c785abfa7a7bec73487148202968274c6", - "reference": "73cb188c785abfa7a7bec73487148202968274c6", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "shasum": "" }, "require": { @@ -2207,7 +2207,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -2264,7 +2264,7 @@ "type": "tidelift" } ], - "time": "2026-08-09T14:10:38+00:00" + "time": "2026-08-11T16:06:25+00:00" }, { "name": "league/config", @@ -2350,16 +2350,16 @@ }, { "name": "league/flysystem", - "version": "3.35.2", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "b277b5dc3d56650b68904117124e79c851e12376" + "reference": "5fc8404762179ae514678487b23494fd69b2309c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", - "reference": "b277b5dc3d56650b68904117124e79c851e12376", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", + "reference": "5fc8404762179ae514678487b23494fd69b2309c", "shasum": "" }, "require": { @@ -2427,22 +2427,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" }, - "time": "2026-07-06T14:42:07+00:00" + "time": "2026-08-22T12:55:54+00:00" }, { "name": "league/flysystem-local", - "version": "3.31.0", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -2476,9 +2476,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/mime-type-detection", @@ -2994,16 +2994,16 @@ }, { "name": "nette/schema", - "version": "v1.3.5", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + "reference": "c54350438cd6914616f790a49cb424605f421562" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", "shasum": "" }, "require": { @@ -3055,9 +3055,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.5" + "source": "https://github.com/nette/schema/tree/v1.3.6" }, - "time": "2026-02-23T03:47:12+00:00" + "time": "2026-08-16T21:58:41+00:00" }, { "name": "nette/utils", @@ -3296,16 +3296,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -3313,7 +3313,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, "type": "library", "extra": { @@ -3355,7 +3355,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -3367,7 +3367,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "psr/clock", @@ -4210,16 +4210,16 @@ }, { "name": "symfony/console", - "version": "v8.1.4", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "68efa2ebfd9a362951eb5a8b09fd177c66ddec24" + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/68efa2ebfd9a362951eb5a8b09fd177c66ddec24", - "reference": "68efa2ebfd9a362951eb5a8b09fd177c66ddec24", + "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", "shasum": "" }, "require": { @@ -4286,7 +4286,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.4" + "source": "https://github.com/symfony/console/tree/v8.1.6" }, "funding": [ { @@ -4306,20 +4306,20 @@ "type": "tidelift" } ], - "time": "2026-07-31T12:43:13+00:00" + "time": "2026-08-25T14:18:42+00:00" }, { "name": "symfony/css-selector", - "version": "v8.1.0", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", "shasum": "" }, "require": { @@ -4355,7 +4355,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.0" + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" }, "funding": [ { @@ -4375,7 +4375,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4450,16 +4450,16 @@ }, { "name": "symfony/error-handler", - "version": "v8.1.2", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3" + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/dc98404be5e8c949815e23fee1928f5de4f3f5d3", - "reference": "dc98404be5e8c949815e23fee1928f5de4f3f5d3", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", "shasum": "" }, "require": { @@ -4507,7 +4507,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.1.2" + "source": "https://github.com/symfony/error-handler/tree/v8.1.5" }, "funding": [ { @@ -4527,20 +4527,20 @@ "type": "tidelift" } ], - "time": "2026-07-22T15:42:13+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.1.2", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb" + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", - "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", "shasum": "" }, "require": { @@ -4593,7 +4593,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.2" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" }, "funding": [ { @@ -4613,7 +4613,7 @@ "type": "tidelift" } ], - "time": "2026-07-22T15:42:13+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4768,16 +4768,16 @@ }, { "name": "symfony/finder", - "version": "v8.1.1", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531" + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531", - "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531", + "url": "https://api.github.com/repos/symfony/finder/zipball/8d7acede2b2ae07605783d1c43e49b5767036474", + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474", "shasum": "" }, "require": { @@ -4812,7 +4812,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.1" + "source": "https://github.com/symfony/finder/tree/v8.1.5" }, "funding": [ { @@ -4832,20 +4832,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T09:05:56+00:00" + "time": "2026-08-21T12:16:08+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.1.4", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "57e712b75f2d0bc8844edbdb18a81dab6f9d55c2" + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/57e712b75f2d0bc8844edbdb18a81dab6f9d55c2", - "reference": "57e712b75f2d0bc8844edbdb18a81dab6f9d55c2", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", "shasum": "" }, "require": { @@ -4893,7 +4893,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.4" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" }, "funding": [ { @@ -4913,20 +4913,20 @@ "type": "tidelift" } ], - "time": "2026-08-07T15:02:39+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.1.4", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "69d81d8a5dac32a5d94e4eb063b7d97dc42c792a" + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/69d81d8a5dac32a5d94e4eb063b7d97dc42c792a", - "reference": "69d81d8a5dac32a5d94e4eb063b7d97dc42c792a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", "shasum": "" }, "require": { @@ -5003,7 +5003,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.4" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" }, "funding": [ { @@ -5023,20 +5023,20 @@ "type": "tidelift" } ], - "time": "2026-08-07T18:04:54+00:00" + "time": "2026-08-30T21:40:49+00:00" }, { "name": "symfony/mailer", - "version": "v8.1.2", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "221c7f326ace1ac2baee8331d829d5b7f04f4d53" + "reference": "89f43137da74b8f1aab37c99926482b7084f51b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/221c7f326ace1ac2baee8331d829d5b7f04f4d53", - "reference": "221c7f326ace1ac2baee8331d829d5b7f04f4d53", + "url": "https://api.github.com/repos/symfony/mailer/zipball/89f43137da74b8f1aab37c99926482b7084f51b9", + "reference": "89f43137da74b8f1aab37c99926482b7084f51b9", "shasum": "" }, "require": { @@ -5083,7 +5083,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.1.2" + "source": "https://github.com/symfony/mailer/tree/v8.1.5" }, "funding": [ { @@ -5103,20 +5103,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:35:25+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/mime", - "version": "v8.1.4", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "8f8ac859d369fe3d18e3837998b1c992bbee92c9" + "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/8f8ac859d369fe3d18e3837998b1c992bbee92c9", - "reference": "8f8ac859d369fe3d18e3837998b1c992bbee92c9", + "url": "https://api.github.com/repos/symfony/mime/zipball/1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", "shasum": "" }, "require": { @@ -5137,7 +5137,7 @@ "symfony/process": "^7.4|^8.0", "symfony/property-access": "^7.4|^8.0", "symfony/property-info": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0" + "symfony/serializer": "^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -5169,7 +5169,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.4" + "source": "https://github.com/symfony/mime/tree/v8.1.6" }, "funding": [ { @@ -5189,7 +5189,7 @@ "type": "tidelift" } ], - "time": "2026-08-07T15:02:39+00:00" + "time": "2026-08-22T09:06:25+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5358,16 +5358,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.38.1", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "dc21118016c039a66235cf93d96b435ffb282412" + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", - "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "shasum": "" }, "require": { @@ -5421,7 +5421,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -5441,20 +5441,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T15:22:23+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -5506,7 +5506,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -5526,7 +5526,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -6022,16 +6022,16 @@ }, { "name": "symfony/process", - "version": "v8.1.0", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", - "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "url": "https://api.github.com/repos/symfony/process/zipball/d863f5e70d7c87abb906ac11b61f83036093000b", + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b", "shasum": "" }, "require": { @@ -6063,7 +6063,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.1.0" + "source": "https://github.com/symfony/process/tree/v8.1.6" }, "funding": [ { @@ -6083,20 +6083,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/routing", - "version": "v8.1.2", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd" + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/1058d4e13bb81dd9a6f7565686df7e13b880cdbd", - "reference": "1058d4e13bb81dd9a6f7565686df7e13b880cdbd", + "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", "shasum": "" }, "require": { @@ -6143,7 +6143,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.2" + "source": "https://github.com/symfony/routing/tree/v8.1.6" }, "funding": [ { @@ -6163,20 +6163,20 @@ "type": "tidelift" } ], - "time": "2026-07-22T15:42:13+00:00" + "time": "2026-08-17T13:18:34+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -6230,7 +6230,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -6250,7 +6250,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", @@ -6344,16 +6344,16 @@ }, { "name": "symfony/translation", - "version": "v8.1.4", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf" + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/c0955eb4aa417a110e65c8162237b8a5c7d910bf", - "reference": "c0955eb4aa417a110e65c8162237b8a5c7d910bf", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", "shasum": "" }, "require": { @@ -6413,7 +6413,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.4" + "source": "https://github.com/symfony/translation/tree/v8.1.5" }, "funding": [ { @@ -6433,7 +6433,7 @@ "type": "tidelift" } ], - "time": "2026-07-30T12:40:56+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/translation-contracts", @@ -6519,16 +6519,16 @@ }, { "name": "symfony/uid", - "version": "v8.1.4", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "50e98f8bc4c3fcdaf925545a65150fb42cf0caf2" + "reference": "a08aef47989093f32fe50fd11859be1b427df389" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/50e98f8bc4c3fcdaf925545a65150fb42cf0caf2", - "reference": "50e98f8bc4c3fcdaf925545a65150fb42cf0caf2", + "url": "https://api.github.com/repos/symfony/uid/zipball/a08aef47989093f32fe50fd11859be1b427df389", + "reference": "a08aef47989093f32fe50fd11859be1b427df389", "shasum": "" }, "require": { @@ -6573,7 +6573,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.1.4" + "source": "https://github.com/symfony/uid/tree/v8.1.5" }, "funding": [ { @@ -6593,20 +6593,20 @@ "type": "tidelift" } ], - "time": "2026-08-02T11:29:46+00:00" + "time": "2026-08-11T13:39:01+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.1.2", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "865103cf742a039f34645b971fc3ace308d6c167" + "reference": "3783365b58972f4779254d98372af80fbf15e170" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/865103cf742a039f34645b971fc3ace308d6c167", - "reference": "865103cf742a039f34645b971fc3ace308d6c167", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", + "reference": "3783365b58972f4779254d98372af80fbf15e170", "shasum": "" }, "require": { @@ -6660,7 +6660,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.1.2" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" }, "funding": [ { @@ -6680,20 +6680,20 @@ "type": "tidelift" } ], - "time": "2026-07-22T15:42:13+00:00" + "time": "2026-08-30T20:10:55+00:00" }, { "name": "symfony/yaml", - "version": "v8.1.2", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "faabdbe998e8c5c599dceffa27aa265b185c0736" + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/faabdbe998e8c5c599dceffa27aa265b185c0736", - "reference": "faabdbe998e8c5c599dceffa27aa265b185c0736", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", "shasum": "" }, "require": { @@ -6736,7 +6736,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.2" + "source": "https://github.com/symfony/yaml/tree/v8.1.6" }, "funding": [ { @@ -6756,7 +6756,7 @@ "type": "tidelift" } ], - "time": "2026-07-22T15:42:13+00:00" + "time": "2026-08-30T01:03:44+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6815,23 +6815,23 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.4", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", + "graham-campbell/result-type": "^1.2", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", + "phpoption/phpoption": "^1.10", "symfony/polyfill-ctype": "^1.26", "symfony/polyfill-mbstring": "^1.26", "symfony/polyfill-php80": "^1.26" @@ -6875,7 +6875,7 @@ "homepage": "https://github.com/vlucas" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", "keywords": [ "dotenv", "env", @@ -6883,7 +6883,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" }, "funding": [ { @@ -6895,7 +6895,7 @@ "type": "tidelift" } ], - "time": "2026-07-06T19:11:50+00:00" + "time": "2026-08-24T18:07:49+00:00" }, { "name": "voku/portable-ascii", @@ -9366,10 +9366,7 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { - "durable-workflow/ai": 20, - "durable-workflow/sdk": 5, - "durable-workflow/waterline": 5, - "durable-workflow/workflow": 5 + "durable-workflow/ai": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/docs/release-notes-feature-contract.md b/docs/release-notes-feature-contract.md deleted file mode 100644 index 05a37b8..0000000 --- a/docs/release-notes-feature-contract.md +++ /dev/null @@ -1,117 +0,0 @@ -# Release-Notes Feature Contract - -This file is the contract between an upstream Durable Workflow package -release and the Sample App. It answers two questions: - -1. When does an upstream feature *require* a sample-app citation in - its release notes? -2. What does that citation have to look like for the release-tag - review to pass? - -The contract is short on purpose. The point is that maintainers can -read it once, then use the checklist at the bottom of this page when -they tag a release. - -## When a citation is required - -An upstream feature's release-note entry must cite a sample-app -workflow when *any* of the following are true: - -- the feature changes the public authoring surface in a way an - external developer would discover by reading sample workflow code - (new `Workflow::*` facade, new attribute, new function under - `Workflow\V2`, new option on an existing function); -- the feature changes behavior an external developer would discover - by reading the matching Waterline screen (a new typed history event, - a new lineage relationship, a new run-status transition); -- the feature is the resolution of a sample-app bug reproducer that - landed under `app/Workflows/Bug//` — the release notes - cite the reproducer workflow class so external readers can rerun - the fix against the exact same code. - -A citation is *not* required for: - -- internal refactors, performance fixes, or telemetry changes that - are invisible from sample workflow code; -- changes to packages that the sample app does not exercise (for - example, polyglot worker-protocol details that no sample workflow - reaches); -- security-driven patches whose fix is a no-op from the workflow - author's perspective. Those still ship in release notes, but the - citation requirement does not block the tag. - -When a feature is on the boundary (a new option that is invisible -unless someone uses it), default to citing the sample. A release with -one extra link is cheap; a release that ships without a citation a -reader needed is expensive to backfill. - -## What a citation looks like - -A release-note entry that cites a sample names three things: - -1. **The workflow class** that exercises the feature, with its - namespace path (for example, - `App\Workflows\Ai\AiWorkflow`). -2. **The artisan command** a reader runs to see the feature in - action (for example, `php artisan app:ai`). -3. **A link** to the workflow class in the sample-app repository on - the `main` branch. - -A worked example: - -> Added `Workflow::outbox()->sendReference()` for durable -> reference-only outbox writes. See -> [`App\Workflows\Ai\AiWorkflow`](https://github.com/durable-workflow/sample-app/blob/main/app/Workflows/Ai/AiWorkflow.php) -> (`php artisan app:ai`) for the full pattern. - -The link is what readers click to leave the snippet and run the -sample. Citations that name the class but do not link to it are -treated as drafts, not finished release-note entries. - -## Cadence and gaps - -The -[Sample-App Plan](https://github.com/durable-workflow/workflow/blob/v2/docs/sample-app/plan.md) -ties this contract to the Phase 4 cadence promise: the sample app's -pinned `durable-workflow/workflow` version moves within one release -cycle of every upstream tag, and the upstream-coverage manifest names -the surfaces the sample is expected to demonstrate. When an upstream -release introduces a feature surface the sample app does not yet -cover: - -- the manifest entry stays `gap` until the matching sample lands; -- the release-note entry cites the open `sample-request` issue - instead of a workflow class so readers see the gap is tracked; -- the cadence window starts at the upstream tag, so the sample lands - in the next release cycle at the latest. - -## Maintainer checklist (run before tagging an upstream release) - -Use this list inside the release-tag PR for the workflow package, the -server, the CLI, or the SDK that the release covers. - -- [ ] Each release-note entry that meets the "citation required" - bar names the sample-app workflow class. -- [ ] Each citation links to the workflow class on - `https://github.com/durable-workflow/sample-app` on the - `main` branch. -- [ ] Each citation names the artisan command a reader runs. -- [ ] Any feature without a sample yet has an open - `sample-request` issue and a `gap` row in - `docs/upstream-coverage.yaml`; the release notes cite the - issue instead of inventing a class name. -- [ ] The upstream-coverage manifest's `tracked_workflow_version` - will move to the new tag in the same release cycle. - -A release that ticks every box satisfies the Phase 5 release-note -contract for the sample-app plan and the Phase 4 cadence promise in -the same review. - -## See also - -- [Sample-App Plan, Phase 5](https://github.com/durable-workflow/workflow/blob/v2/docs/sample-app/plan.md) - — the contract this file implements. -- [Contribute a Sample](https://durable-workflow.github.io/docs/2.0/contribute-a-sample) - — what a sample needs to look like before it can be cited. -- [Sample Index](../README.md#sample-index) — the source of truth for - which patterns the sample app currently demonstrates. diff --git a/microservice/composer.json b/microservice/composer.json index 3d76e88..f6974b7 100644 --- a/microservice/composer.json +++ b/microservice/composer.json @@ -10,9 +10,9 @@ "license": "MIT", "require": { "php": "^8.4", - "durable-workflow/sdk": "2.0.0-rc.5", - "durable-workflow/workflow": "2.0.0-rc.5", - "durable-workflow/waterline": "2.0.0-rc.5", + "durable-workflow/sdk": "^2.0", + "durable-workflow/workflow": "^2.0", + "durable-workflow/waterline": "^2.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1" }, diff --git a/microservice/composer.lock b/microservice/composer.lock index 533f65d..1863d02 100644 --- a/microservice/composer.lock +++ b/microservice/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4325d63c6271e1a86548fedf6f63194f", + "content-hash": "a4d41b498175f373b13cfb1ca3fc3995", "packages": [ { "name": "apache/avro", - "version": "1.12.1", + "version": "1.12.2", "source": { "type": "git", "url": "https://github.com/apache/avro.git", - "reference": "9110c693767c1dde2665b2b57939333478b12036" + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/apache/avro/zipball/9110c693767c1dde2665b2b57939333478b12036", - "reference": "9110c693767c1dde2665b2b57939333478b12036", + "url": "https://api.github.com/repos/apache/avro/zipball/8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", "shasum": "" }, "require": { @@ -64,9 +64,9 @@ "support": { "email": "dev@avro.apache.org", "issues": "https://issues.apache.org/jira/browse/AVRO", - "source": "https://github.com/apache/avro/tree/release-1.12.1" + "source": "https://github.com/apache/avro/tree/release-1.12.2" }, - "time": "2025-09-05T07:25:14+00:00" + "time": "2026-08-07T12:56:37+00:00" }, { "name": "brick/math", @@ -505,27 +505,29 @@ }, { "name": "durable-workflow/sdk", - "version": "2.0.0-rc.5", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "766f06ef175bb409aa9f37b2941191802be4b774" + "reference": "af7931e79683adf55bfa87cd7b3b90561bc137cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/766f06ef175bb409aa9f37b2941191802be4b774", - "reference": "766f06ef175bb409aa9f37b2941191802be4b774", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/af7931e79683adf55bfa87cd7b3b90561bc137cf", + "reference": "af7931e79683adf55bfa87cd7b3b90561bc137cf", "shasum": "" }, "require": { "apache/avro": "^1.12", "composer-runtime-api": "^2.0", "ext-json": "*", - "guzzlehttp/guzzle": "^7.8", + "guzzlehttp/guzzle": "^7.8|^8.0", "php": "^8.1", + "psr/container": "^1.1|^2.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.1|^2.0" + "psr/http-message": "^1.1|^2.0", + "psr/log": "^1.1|^2.0|^3.0" }, "require-dev": { "phpdocumentor/phpdocumentor": "^3.7", @@ -533,18 +535,58 @@ "phpunit/phpunit": "^10.5|^11.0" }, "suggest": { - "ext-pcntl": "Enables managed workers to handle SIGINT and SIGTERM gracefully." + "ext-pcntl": "Required by framework console workers for graceful SIGINT and SIGTERM handling.", + "laravel/framework": "Enables the auto-discovered Laravel 9 through 13 service-mode bridge.", + "symfony/console": "Required with symfony/framework-bundle to run the Symfony 6.4, 7, and 8 worker command.", + "symfony/framework-bundle": "Enables the Symfony 6.4, 7, and 8 service-mode bundle." }, "type": "library", "extra": { + "laravel": { + "aliases": { + "DurableWorkflow": "DurableWorkflow\\Bridge\\Laravel\\Facades\\DurableWorkflow" + }, + "providers": [ + "DurableWorkflow\\Bridge\\Laravel\\DurableWorkflowServiceProvider" + ] + }, "durable-workflow": { - "product-train": "2.0.0-rc.5", + "frameworks": { + "laravel": "^9.0|^10.0|^11.0|^12.0|^13.0", + "symfony": "^6.4|^7.0|^8.0" + }, + "product-train": "2.0.0", "payload-codecs": [ "avro" ], + "message-streams": true, + "version-markers": true, + "workflow-streams": { + "contract": "durable-workflow.v2.workflow-streams.contract@1", + "delivery": "at-least-once", + "operations": [ + "list", + "describe", + "subscribe", + "append", + "close", + "error" + ], + "authoring-command": "record_side_effect.workflow_stream", + "external-payload-references": "opaque-reference" + }, + "durable-selection": true, "control-plane-version": "2", - "worker-protocol-version": "1.13", - "supported-server-versions": "2.0.0-rc.5" + "version-marker-command": "record_version_marker", + "version-marker-helpers": [ + "patched", + "deprecatePatch" + ], + "worker-protocol-version": "1.19", + "supported-server-versions": "2.0.0", + "version-marker-history-event": "VersionMarkerRecorded", + "message-streams-minimum-worker-protocol-version": "1.15", + "durable-selection-minimum-worker-protocol-version": "1.19" } }, "autoload": { @@ -575,24 +617,23 @@ "issues": "https://github.com/durable-workflow/sdk-php/issues", "source": "https://github.com/durable-workflow/sdk-php" }, - "time": "2026-07-28T20:02:40+00:00" + "time": "2026-09-01T01:00:27+00:00" }, { "name": "durable-workflow/waterline", - "version": "2.0.0-rc.5", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/durable-workflow/waterline.git", - "reference": "33c017697bb5f556209ede5236ec355a90ce8de3" + "reference": "516c8d9b45a88ed2013d6fd27d64f56b966861ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/waterline/zipball/33c017697bb5f556209ede5236ec355a90ce8de3", - "reference": "33c017697bb5f556209ede5236ec355a90ce8de3", + "url": "https://api.github.com/repos/durable-workflow/waterline/zipball/516c8d9b45a88ed2013d6fd27d64f56b966861ba", + "reference": "516c8d9b45a88ed2013d6fd27d64f56b966861ba", "shasum": "" }, "require": { - "durable-workflow/sdk": "2.0.0-rc.5", "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", "php": "^8.0.2" }, @@ -600,7 +641,8 @@ "laravel-workflow/waterline": "self.version" }, "require-dev": { - "durable-workflow/workflow": "2.0.0-rc.5", + "durable-workflow/sdk": "2.0.0", + "durable-workflow/workflow": "2.0.1", "fakerphp/faker": "^1.9.1", "mockery/mockery": "^1.4.4", "orchestra/testbench": "^10.11", @@ -608,6 +650,7 @@ "phpunit/phpunit": "^11.5.3" }, "suggest": { + "durable-workflow/sdk": "Required only for service-mode observation through a standalone Durable Workflow server.", "durable-workflow/workflow": "Required only for embedded Laravel workflow observation; service mode uses durable-workflow/sdk." }, "type": "library", @@ -621,7 +664,16 @@ ] }, "durable-workflow": { - "product-train": "2.0.0-rc.5" + "product-train": "2.0.0", + "service-capacity-evidence": { + "schema": "durable-workflow.v2.namespace-capacity-evidence", + "schema-version": 1, + "first-supported-tuple": { + "server": "2.0.0-rc.32", + "sdk-php": "2.0.0-rc.14", + "waterline": "2.0.0-rc.19" + } + } } }, "autoload": { @@ -639,32 +691,33 @@ "email": "richard.lee.mcdaniel@gmail.com" } ], - "description": "An elegant UI for monitoring Laravel Workflows.", + "description": "Operational UI for Durable Workflow across embedded and service-mode deployments.", "support": { "issues": "https://github.com/durable-workflow/waterline/issues", - "source": "https://github.com/durable-workflow/waterline/tree/2.0.0-rc.5" + "source": "https://github.com/durable-workflow/waterline/tree/2.0.0" }, - "time": "2026-07-28T21:22:47+00:00" + "time": "2026-09-01T01:59:19+00:00" }, { "name": "durable-workflow/workflow", - "version": "2.0.0-rc.5", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/durable-workflow/workflow.git", - "reference": "d8356318311cf594ba47e685ec8e7654c7258a28" + "reference": "7b9c8308d14a3e8695fdf46a1a871cab8488c3e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/d8356318311cf594ba47e685ec8e7654c7258a28", - "reference": "d8356318311cf594ba47e685ec8e7654c7258a28", + "url": "https://api.github.com/repos/durable-workflow/workflow/zipball/7b9c8308d14a3e8695fdf46a1a871cab8488c3e1", + "reference": "7b9c8308d14a3e8695fdf46a1a871cab8488c3e1", "shasum": "" }, "require": { "apache/avro": "^1.12", - "laravel/framework": "^9.0|^10.0|^11.0|^12.0|^13.0", + "laravel/framework": "^9.52.17|^10.48.29|^11.44.1|^12.61.1|^13.12.0", "php": "^8.1", - "react/promise": "^2.9|^3.0" + "react/promise": "^2.9|^3.0", + "symfony/yaml": "^6.0|^7.0|^8.0" }, "replace": { "laravel-workflow/laravel-workflow": "self.version" @@ -686,7 +739,9 @@ ] }, "durable-workflow": { - "product-train": "2.0.0-rc.5" + "product-train": "2.0.2", + "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", + "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" } }, "autoload": { @@ -711,9 +766,9 @@ "description": "Embedded durable workflow runtime and orchestration engine for Laravel applications.", "support": { "issues": "https://github.com/durable-workflow/workflow/issues", - "source": "https://github.com/durable-workflow/workflow/tree/2.0.0-rc.5" + "source": "https://github.com/durable-workflow/workflow/tree/2.0.2" }, - "time": "2026-07-28T20:27:03+00:00" + "time": "2026-09-01T08:31:26+00:00" }, { "name": "egulias/email-validator", @@ -855,24 +910,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, "type": "library", "autoload": { @@ -901,7 +956,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -913,26 +968,26 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2026-08-24T09:06:52+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.15.2", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "744101956d78b7c1384d0cbf379db13e859167bf" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", - "reference": "744101956d78b7c1384d0cbf379db13e859167bf", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.1", - "guzzlehttp/psr7": "^2.13", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1025,7 +1080,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.2" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -1041,20 +1096,20 @@ "type": "tidelift" } ], - "time": "2026-07-26T23:23:20+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.1", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { @@ -1109,7 +1164,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.1" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -1125,20 +1180,20 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:48:39+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.13.0", + "version": "2.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" + "reference": "95e7828100de18b4e269fb1703be530082d5166d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", "shasum": "" }, "require": { @@ -1228,7 +1283,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.13.0" + "source": "https://github.com/guzzle/psr7/tree/2.13.1" }, "funding": [ { @@ -1244,20 +1299,20 @@ "type": "tidelift" } ], - "time": "2026-07-16T22:23:49+00:00" + "time": "2026-08-24T09:13:11+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.10", + "version": "v1.0.11", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", - "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", "shasum": "" }, "require": { @@ -1314,7 +1369,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" }, "funding": [ { @@ -1330,20 +1385,20 @@ "type": "tidelift" } ], - "time": "2026-07-17T13:53:03+00:00" + "time": "2026-08-24T09:15:32+00:00" }, { "name": "laravel/framework", - "version": "v12.64.0", + "version": "v12.68.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0" + "reference": "1343c22b92edd48e29ff273d8ce7c15cf75d974c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/727a8ea2949c23ca8b5316b86a00984b6017b7a0", - "reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0", + "url": "https://api.github.com/repos/laravel/framework/zipball/1343c22b92edd48e29ff273d8ce7c15cf75d974c", + "reference": "1343c22b92edd48e29ff273d8ce7c15cf75d974c", "shasum": "" }, "require": { @@ -1552,20 +1607,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-07-14T14:25:37+00:00" + "time": "2026-08-25T14:18:36+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.21", + "version": "v0.3.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "7753c65c281c2550c7c183f14e18062073b7d821" + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", - "reference": "7753c65c281c2550c7c183f14e18062073b7d821", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", "shasum": "" }, "require": { @@ -1609,22 +1664,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.21" + "source": "https://github.com/laravel/prompts/tree/v0.3.24" }, - "time": "2026-06-26T00:11:25+00:00" + "time": "2026-08-20T12:55:36+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.15", + "version": "v2.0.16", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", - "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", "shasum": "" }, "require": { @@ -1672,7 +1727,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-07-21T16:49:22+00:00" + "time": "2026-08-18T20:28:54+00:00" }, { "name": "laravel/tinker", @@ -1742,16 +1797,16 @@ }, { "name": "league/commonmark", - "version": "2.9.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529" + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", - "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", "shasum": "" }, "require": { @@ -1788,7 +1843,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -1845,7 +1900,7 @@ "type": "tidelift" } ], - "time": "2026-08-03T13:42:31+00:00" + "time": "2026-08-11T16:06:25+00:00" }, { "name": "league/config", @@ -1931,16 +1986,16 @@ }, { "name": "league/flysystem", - "version": "3.35.2", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "b277b5dc3d56650b68904117124e79c851e12376" + "reference": "5fc8404762179ae514678487b23494fd69b2309c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", - "reference": "b277b5dc3d56650b68904117124e79c851e12376", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", + "reference": "5fc8404762179ae514678487b23494fd69b2309c", "shasum": "" }, "require": { @@ -2008,22 +2063,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" }, - "time": "2026-07-06T14:42:07+00:00" + "time": "2026-08-22T12:55:54+00:00" }, { "name": "league/flysystem-local", - "version": "3.31.0", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -2057,9 +2112,9 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/mime-type-detection", @@ -2404,16 +2459,16 @@ }, { "name": "nesbot/carbon", - "version": "3.13.1", + "version": "3.13.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", - "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", "shasum": "" }, "require": { @@ -2505,20 +2560,20 @@ "type": "tidelift" } ], - "time": "2026-07-09T18:23:49+00:00" + "time": "2026-08-08T11:40:35+00:00" }, { "name": "nette/schema", - "version": "v1.3.5", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + "reference": "c54350438cd6914616f790a49cb424605f421562" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", "shasum": "" }, "require": { @@ -2570,9 +2625,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.5" + "source": "https://github.com/nette/schema/tree/v1.3.6" }, - "time": "2026-02-23T03:47:12+00:00" + "time": "2026-08-16T21:58:41+00:00" }, { "name": "nette/utils", @@ -2811,16 +2866,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -2828,7 +2883,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, "type": "library", "extra": { @@ -2870,7 +2925,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -2882,7 +2937,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "psr/clock", @@ -3725,16 +3780,16 @@ }, { "name": "symfony/console", - "version": "v7.4.14", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", - "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", "shasum": "" }, "require": { @@ -3799,7 +3854,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.14" + "source": "https://github.com/symfony/console/tree/v7.4.18" }, "funding": [ { @@ -3819,20 +3874,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T11:50:14+00:00" + "time": "2026-08-25T14:18:37+00:00" }, { "name": "symfony/css-selector", - "version": "v8.1.0", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", - "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", "shasum": "" }, "require": { @@ -3868,7 +3923,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.0" + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" }, "funding": [ { @@ -3888,7 +3943,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3963,16 +4018,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "4e1a093b481f323e6e326451f9760c3868430673" + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", - "reference": "4e1a093b481f323e6e326451f9760c3868430673", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", "shasum": "" }, "require": { @@ -4021,7 +4076,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.14" + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" }, "funding": [ { @@ -4041,20 +4096,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:22:21+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.1.1", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", "shasum": "" }, "require": { @@ -4107,7 +4162,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" }, "funding": [ { @@ -4127,7 +4182,7 @@ "type": "tidelift" } ], - "time": "2026-06-09T12:28:30+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4211,16 +4266,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "13b38720174286f55d1761152b575a8d1436fc25" + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", - "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", "shasum": "" }, "require": { @@ -4255,7 +4310,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.14" + "source": "https://github.com/symfony/finder/tree/v7.4.17" }, "funding": [ { @@ -4275,20 +4330,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T08:31:18+00:00" + "time": "2026-08-21T12:09:28+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.14", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", - "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", "shasum": "" }, "require": { @@ -4337,7 +4392,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" }, "funding": [ { @@ -4357,20 +4412,20 @@ "type": "tidelift" } ], - "time": "2026-06-11T07:31:44+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.14", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", - "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", "shasum": "" }, "require": { @@ -4428,7 +4483,7 @@ "symfony/validator": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "type": "library", "autoload": { @@ -4456,7 +4511,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" }, "funding": [ { @@ -4476,20 +4531,20 @@ "type": "tidelift" } ], - "time": "2026-06-27T09:14:35+00:00" + "time": "2026-08-30T21:24:29+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.14", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", - "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", "shasum": "" }, "require": { @@ -4540,7 +4595,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.14" + "source": "https://github.com/symfony/mailer/tree/v7.4.17" }, "funding": [ { @@ -4560,20 +4615,20 @@ "type": "tidelift" } ], - "time": "2026-06-13T08:51:35+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/mime", - "version": "v7.4.13", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + "reference": "bf328d82105831db3e409195db0540ff57f27c80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", - "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", + "reference": "bf328d82105831db3e409195db0540ff57f27c80", "shasum": "" }, "require": { @@ -4597,7 +4652,7 @@ "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -4629,7 +4684,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.13" + "source": "https://github.com/symfony/mime/tree/v7.4.18" }, "funding": [ { @@ -4649,7 +4704,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:22:37+00:00" + "time": "2026-08-22T09:04:42+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4736,16 +4791,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -4794,7 +4849,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -4814,20 +4869,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.38.1", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "dc21118016c039a66235cf93d96b435ffb282412" + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", - "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "shasum": "" }, "require": { @@ -4881,7 +4936,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -4901,20 +4956,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T15:22:23+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -4966,7 +5021,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -4986,7 +5041,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -5159,16 +5214,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { @@ -5215,7 +5270,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -5235,7 +5290,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-php84", @@ -5319,16 +5374,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -5375,7 +5430,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -5395,7 +5450,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-uuid", @@ -5482,16 +5537,16 @@ }, { "name": "symfony/process", - "version": "v7.4.13", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "f5804be144caceb570f6747519999636b664f24c" + "reference": "058d17fc284cce14efb2385783b55014a461b176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", - "reference": "f5804be144caceb570f6747519999636b664f24c", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", "shasum": "" }, "require": { @@ -5523,7 +5578,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.13" + "source": "https://github.com/symfony/process/tree/v7.4.18" }, "funding": [ { @@ -5543,20 +5598,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T16:05:06+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/routing", - "version": "v7.4.13", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", - "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", "shasum": "" }, "require": { @@ -5608,7 +5663,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.13" + "source": "https://github.com/symfony/routing/tree/v7.4.18" }, "funding": [ { @@ -5628,20 +5683,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:20:33+00:00" + "time": "2026-08-17T13:12:36+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -5695,7 +5750,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -5715,20 +5770,20 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { @@ -5785,7 +5840,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { @@ -5805,20 +5860,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { "name": "symfony/translation", - "version": "v8.1.1", + "version": "v8.1.5", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "342b4218630dc2cf284cedcb2080c80b13404014" + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", - "reference": "342b4218630dc2cf284cedcb2080c80b13404014", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", "shasum": "" }, "require": { @@ -5878,7 +5933,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.1.1" + "source": "https://github.com/symfony/translation/tree/v8.1.5" }, "funding": [ { @@ -5898,7 +5953,7 @@ "type": "tidelift" } ], - "time": "2026-06-06T11:11:44+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/translation-contracts", @@ -5984,16 +6039,16 @@ }, { "name": "symfony/uid", - "version": "v7.4.9", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "2676b524340abcfe4d6151ec698463cebafee439" + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", - "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", "shasum": "" }, "require": { @@ -6038,7 +6093,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.9" + "source": "https://github.com/symfony/uid/tree/v7.4.17" }, "funding": [ { @@ -6058,20 +6113,20 @@ "type": "tidelift" } ], - "time": "2026-04-30T15:19:22+00:00" + "time": "2026-08-11T07:38:58+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.14", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", "shasum": "" }, "require": { @@ -6087,7 +6142,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -6125,7 +6180,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" }, "funding": [ { @@ -6145,7 +6200,83 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-08-30T20:10:52+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T01:03:44+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6204,23 +6335,23 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.4", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", - "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", + "graham-campbell/result-type": "^1.2", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", + "phpoption/phpoption": "^1.10", "symfony/polyfill-ctype": "^1.26", "symfony/polyfill-mbstring": "^1.26", "symfony/polyfill-php80": "^1.26" @@ -6264,7 +6395,7 @@ "homepage": "https://github.com/vlucas" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", "keywords": [ "dotenv", "env", @@ -6272,7 +6403,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" }, "funding": [ { @@ -6284,7 +6415,7 @@ "type": "tidelift" } ], - "time": "2026-07-06T19:11:50+00:00" + "time": "2026-08-24T18:07:49+00:00" }, { "name": "voku/portable-ascii", @@ -8594,82 +8725,6 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/yaml", - "version": "v8.1.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "8e4cdd4311683516be06944f4b85244063cdb886" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/8e4cdd4311683516be06944f4b85244063cdb886", - "reference": "8e4cdd4311683516be06944f4b85244063cdb886", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/console": "<7.4" - }, - "require-dev": { - "symfony/console": "^7.4|^8.0", - "yaml/yaml-test-suite": "*" - }, - "bin": [ - "Resources/bin/yaml-lint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Loads and dumps YAML files", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/yaml/tree/v8.1.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-09T11:06:24+00:00" - }, { "name": "theseer/tokenizer", "version": "1.3.1", @@ -8723,11 +8778,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "durable-workflow/sdk": 5, - "durable-workflow/waterline": 5, - "durable-workflow/workflow": 5 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/playground/php-runtime/composer.json b/playground/php-runtime/composer.json index dc72cdb..f1e175c 100644 --- a/playground/php-runtime/composer.json +++ b/playground/php-runtime/composer.json @@ -6,7 +6,7 @@ "license": "MIT", "require": { "php": "^8.4", - "durable-workflow/sdk": ">=2.0.0-rc.1 <2.1" + "durable-workflow/sdk": "^2.0" }, "config": { "allow-plugins": false, diff --git a/playground/php-runtime/composer.lock b/playground/php-runtime/composer.lock index a4cd16c..e644c66 100644 --- a/playground/php-runtime/composer.lock +++ b/playground/php-runtime/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "306a263521bb5e0ca73ed6db7e79f580", + "content-hash": "3ef6ce4a883b9fc88e5a487aee4cf27b", "packages": [ { "name": "apache/avro", @@ -70,16 +70,16 @@ }, { "name": "durable-workflow/sdk", - "version": "2.0.0-rc.53", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/durable-workflow/sdk-php.git", - "reference": "51c03a5f6aebd3aaab9d7b301f6550824cb7e515" + "reference": "af7931e79683adf55bfa87cd7b3b90561bc137cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/51c03a5f6aebd3aaab9d7b301f6550824cb7e515", - "reference": "51c03a5f6aebd3aaab9d7b301f6550824cb7e515", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/af7931e79683adf55bfa87cd7b3b90561bc137cf", + "reference": "af7931e79683adf55bfa87cd7b3b90561bc137cf", "shasum": "" }, "require": { @@ -120,7 +120,7 @@ "laravel": "^9.0|^10.0|^11.0|^12.0|^13.0", "symfony": "^6.4|^7.0|^8.0" }, - "product-train": "2.0.0-rc.53", + "product-train": "2.0.0", "payload-codecs": [ "avro" ], @@ -148,7 +148,7 @@ "deprecatePatch" ], "worker-protocol-version": "1.19", - "supported-server-versions": "2.0.0-rc.57", + "supported-server-versions": "2.0.0", "version-marker-history-event": "VersionMarkerRecorded", "message-streams-minimum-worker-protocol-version": "1.15", "durable-selection-minimum-worker-protocol-version": "1.19" @@ -182,30 +182,31 @@ "issues": "https://github.com/durable-workflow/sdk-php/issues", "source": "https://github.com/durable-workflow/sdk-php" }, - "time": "2026-08-29T18:20:43+00:00" + "time": "2026-09-01T01:00:27+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.15.3", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" + "reference": "2cdae51a4a02c3fe2c38d42e25a7bb952e27b768" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", - "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/2cdae51a4a02c3fe2c38d42e25a7bb952e27b768", + "reference": "2cdae51a4a02c3fe2c38d42e25a7bb952e27b768", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.2", - "guzzlehttp/psr7": "^2.13", - "php": "^7.2.5 || ^8.0", + "guzzlehttp/promises": "^3.0.2", + "guzzlehttp/psr7": "^3.1", + "php": "^7.4 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.25" + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" }, "provide": { "psr/http-client-implementation": "1.0" @@ -213,10 +214,10 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.3", - "guzzlehttp/test-server": "^0.7", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.0", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "phpunit/phpunit": "^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -232,9 +233,6 @@ } }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { "GuzzleHttp\\": "src/" } @@ -294,7 +292,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.3" + "source": "https://github.com/guzzle/guzzle/tree/8.1.0" }, "funding": [ { @@ -310,29 +308,28 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:48:21+00:00" + "time": "2026-08-24T11:07:02+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.2", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", - "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" + "phpunit/phpunit": "^9.6.34" }, "type": "library", "extra": { @@ -378,7 +375,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.2" + "source": "https://github.com/guzzle/promises/tree/3.0.2" }, "funding": [ { @@ -394,39 +391,39 @@ "type": "tidelift" } ], - "time": "2026-08-05T19:30:54+00:00" + "time": "2026-08-24T10:00:26+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.13.0", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", - "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.25" + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" }, "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -497,7 +494,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.13.0" + "source": "https://github.com/guzzle/psr7/tree/3.1.0" }, "funding": [ { @@ -513,7 +510,7 @@ "type": "tidelift" } ], - "time": "2026-07-16T22:23:49+00:00" + "time": "2026-08-24T11:02:13+00:00" }, { "name": "psr/container", @@ -779,79 +776,38 @@ "time": "2024-09-11T13:17:53+00:00" }, { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "name": "symfony/polyfill-php80", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.2" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { "files": [ - "function.php" + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -859,6 +815,10 @@ "MIT" ], "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -868,10 +828,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -891,20 +857,20 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "name": "symfony/polyfill-php82", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", "shasum": "" }, "require": { @@ -922,7 +888,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Polyfill\\Php82\\": "" }, "classmap": [ "Resources/stubs" @@ -933,10 +899,6 @@ "MIT" ], "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -946,7 +908,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -955,7 +917,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" }, "funding": [ { @@ -975,15 +937,13 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-05-26T12:45:58+00:00" } ], "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "durable-workflow/sdk": 5 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/playground/templates/rust/Cargo.lock b/playground/templates/rust/Cargo.lock index 4cdf1aa..078aa87 100644 --- a/playground/templates/rust/Cargo.lock +++ b/playground/templates/rust/Cargo.lock @@ -268,9 +268,9 @@ dependencies = [ [[package]] name = "durable-workflow" -version = "2.0.0-rc.38" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36639619dea7c49e2df10d3f4e15eb0a429e2230f7c751f24879e447eddf0e3f" +checksum = "2e22310a10fefd3ee2118a260f5255b98c607856b73749629505891fc18aae49" dependencies = [ "apache-avro", "base64", diff --git a/playground/templates/rust/Cargo.toml b/playground/templates/rust/Cargo.toml index 0221ba1..ae7f36e 100644 --- a/playground/templates/rust/Cargo.toml +++ b/playground/templates/rust/Cargo.toml @@ -6,6 +6,6 @@ publish = false rust-version = "1.86" [dependencies] -durable-workflow = ">=2.0.0-rc.1, <2.1.0" +durable-workflow = "2.0" serde_json = "=1.0.150" tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] } diff --git a/polyglot/README.md b/polyglot/README.md index 634f654..460c8bf 100644 --- a/polyglot/README.md +++ b/polyglot/README.md @@ -1,317 +1,97 @@ -# Polyglot Sample +# Polyglot Service Mode -This directory contains the primary service-mode `PolyglotWorkflow` and the -complete PHP/Python/Rust validation stack. It also holds a narrower Laravel -integration variation. Every scenario runs against a standalone Durable -Workflow Server and uses published 2.0 artifacts. +This directory contains the Sample App's standalone Server topology and its +PHP, Python, and Rust workers. The primary example is `PolyglotWorkflow`: a PHP +workflow that calls a Python activity and then a Rust activity. -For a focused authored workflow/activity journey instead of this exhaustive -matrix, use `scripts/playground` with `php`, `python`, or `rust` from the -repository root. -Its local published Server is the default. The same command also accepts the -shared explicit managed-runtime contract documented in the root README, with -separate client and worker credential roles for every supported SDK. +## Run the example -## Service mode: PolyglotWorkflow - -From a Sample App Codespace after setup reports ready, run: +From the repository root: ```bash scripts/polyglot.sh ``` -The PHP-authored `polyglot.PolyglotWorkflow` runs on `polyglot-workflow`. It -routes an order-total activity to the Python worker on -`polyglot-php-to-python`, then passes that result to the Rust receipt activity -on `polyglot-to-rust`. The Server dispatches both activity tasks; neither -runtime is simulated by PHP. The final result names all three runtimes and -queues and combines the Python total with the Rust receipt. +The command: + +1. reads the stable artifact versions in `qualified-artifact-tuple.json`; +2. starts MySQL, Redis, and the published Durable Workflow Server image; +3. builds one worker image for each first-party SDK; +4. waits for the required workflow and activity registrations; +5. starts `polyglot.PolyglotWorkflow`; and +6. prints the completed result and the exact runtime versions. -The command resolves the current installable artifact tuple, builds the three -workers through their normal package managers, waits for their exact handler -registrations, and runs the workflow. Docker, Composer, Python, and Rust are -already present in the prepared Codespaces image, so there is no manual setup -or hidden host command. +The workflow uses these queues: -### Laravel integration variation +| Runtime | Handler | Task queue | +| --- | --- | --- | +| PHP | `polyglot.PolyglotWorkflow` | `polyglot-workflow` | +| Python | order calculation activity | `polyglot-php-to-python` | +| Rust | receipt activity | `polyglot-to-rust` | -From a Sample App Codespace, run: +The stack remains available after the run. Stop it with: ```bash -scripts/service-mode.sh +docker compose \ + --project-directory polyglot \ + -f polyglot/docker-compose.yml \ + -p sample-app-polyglot-demo \ + down --volumes --remove-orphans ``` -The command uses `polyglot/service-mode.yml` and the prepared development image; -there is no local image build. Laravel runs the framework-neutral -`durable-workflow/sdk` bridge, resolves attributed handlers through its service -container, writes worker lifecycle events to its normal logger, and starts a -workflow through an injected `LaravelWorkflowClientInterface`. The generic -`WorkflowClientInterface` remains available for integrations that deliberately -provide an explicit string workflow type and task queue. The featured workflow -executes `sample.service-mode.php.prepare` in the Laravel worker and -`sample.service-mode.python.decorate` in a Python worker before returning one -combined result. - -The output names what started, prints the PHP and Python results, and links to -the matching run in Waterline. Startup, result, and browser-check timings plus a -browser screenshot are retained under `storage/app/`. The isolated Compose -volumes remain available for inspection, and generated workflow IDs make repeat -runs safe. - -## Complete runtime matrix - -The full `docker-compose.yml` validation first runs `PolyglotWorkflow`, then -proves the control plane is language-neutral across nine directional PHP, -Python, and Rust workflow/activity cells. It drives workflow start, signal, -query, result retrieval, replay, and codec checks through the published `dw` -CLI and inspects the same runs through Waterline. - -The root app's embedded Laravel path, the service-mode examples, and the full -matrix each use separate Compose projects and state. - -## What it exercises - -Nine workflow/activity runtime cells run end to end: - -| Scenario | Workflow language | Activity language | Source | -| --- | --- | --- | --- | -| Python authoring | Python (`sdk-python`) | Python | `python_workflow/workflow.py` | -| PHP authoring | PHP (`durable-workflow/sdk`) | PHP | `php_worker/worker.php` | -| Cross-language activity | PHP (`durable-workflow/sdk`) | Python | `php_worker/worker.php` + `python_worker/activities.py` | -| Reverse cross-language activity | Python (`sdk-python`) | PHP (`durable-workflow/sdk`) | `python_workflow/workflow.py` + `php_worker/worker.php` | -| Rust authoring | Rust (`sdk-rust`) | Rust | `rust_worker/src/main.rs` | -| Rust to Python | Rust (`sdk-rust`) | Python | `rust_worker/src/main.rs` + `python_worker/activities.py` | -| Rust to PHP | Rust (`sdk-rust`) | PHP (`durable-workflow/sdk`) | `rust_worker/src/main.rs` + `php_worker/worker.php` | -| Python to Rust | Python (`sdk-python`) | Rust (`sdk-rust`) | `python_workflow/workflow.py` + `rust_worker/src/main.rs` | -| PHP to Rust | PHP (`durable-workflow/sdk`) | Rust (`sdk-rust`) | `php_worker/worker.php` + `rust_worker/src/main.rs` | - -The PHP-to-Python matrix cell remains a focused directional conformance test: - -- `php-workflow-worker` is a framework-neutral Composer project that installs - the exact published `durable-workflow/sdk` release and registers - `polyglot.php-to-python.greeter` on the - `polyglot-php-to-python` task queue. Its image contains neither Laravel nor - the embedded `durable-workflow/workflow` engine. -- `python-activity-worker` is a Python container that registers - `polyglot.php-to-python.reverse` and `polyglot.php-to-python.tally` - on the same task queue. -- `php-query-worker` is a PHP query-only worker on the same queue. It - answers server-routed `state` queries for the PHP signal/query workflow - while the workflow worker is parked in a pull-style signal wait. -- Each run schedules a real activity dispatch — workflow code is in - PHP, activity code is in Python — so the Avro envelope crosses the - language boundary on the wire, not just inside one process. - -The Python-authored same-language scenario is the language-symmetric -reference: - -- `python-workflow-worker` is a long-running Python `durable-workflow` - worker that registers the `polyglot.python.greeter` workflow plus its - `polyglot.python.greet` and `polyglot.python.summarise` activities on - the `polyglot-python` task queue. -- The smoke driver acts purely as a client: it waits for the Python - worker to register, starts a workflow, and asserts the result. The - workflow itself executes inside the running container, so the - docker-compose stack is the actual unit under test. - -The PHP-authored same-language scenario is the PHP reference: - -- `php-same-workflow-worker` registers `polyglot.php.greeter` on the - `polyglot-php` task queue. -- `php-same-activity-worker` registers `polyglot.php.marker` and - `polyglot.php.describe` on the same task queue. -- The smoke asserts that workflow and activity tasks are both handled by - PHP workers through the standalone worker-plane protocol. - -The Python-to-PHP scenario is the reverse wire-level cross-language -test: - -- The same `python-workflow-worker` registers - `polyglot.python-to-php.greeter` on the `polyglot-python` task queue. -- `php-activity-worker` is a separate process from the same published - `durable-workflow/sdk` image that registers - `polyglot.python-to-php.marker` and `polyglot.python-to-php.describe` - on the `polyglot-python-to-php` task queue. -- The smoke asserts that the Python workflow result includes the PHP - runtime marker returned by those activities. - -The smoke also exercises the conformance surfaces around the original cells -and the five Rust cells: - -- workflow start and result retrieval through the published `dw` CLI; -- signal and query handling through the published `dw` CLI for PHP-authored, - Python-authored, and Rust-authored workflows; -- six-direction type round-trips for strings with non-ASCII text, ints, floats, - booleans, nulls, mixed lists, nested maps, timestamps, and native binary - values kept distinct from UTF-8 text at every worker boundary; -- typed activity error round-trips from Python activity to PHP workflow and PHP - activity to Python workflow; -- Waterline event typing, payload rendering, and worker attribution for - same-language and mixed-language runs. - -The Rust image resolves the exact current `durable-workflow` release from -crates.io and contains no path or Git dependency. Its workflow worker executes -Rust-authored same-language and outbound PHP/Python paths; its activity worker -executes inbound PHP/Python paths. The harness verifies the advertised SDK -version before accepting a cell, so a version pin without an executed Rust -worker cannot pass. - -All six cross-language type directions use the platform Avro envelope. PHP -uses `apache/avro` from Packagist, Python uses `fastavro` from PyPI, and Rust uses -`apache-avro` from crates.io. Each echo activity reports its official package -and version. For the binary case, each workflow constructs a native SDK value, -the activity validates and echoes that value, and the workflow validates the -echo before returning JSON-safe byte-equality evidence to the smoke driver. - -The smoke emits a run metadata JSON document after all required surfaces run. -That document includes separate exact public artifact pins and roles for the -server image, CLI, framework-neutral PHP SDK, Python SDK, Rust SDK, embedded -Laravel Workflow engine, and Waterline. It also records the Apache Avro -dependency versions and pass/fail status per surface. - -The codec contract that determines which payload values cross the -language boundary cleanly is documented in the workflow package: -[Polyglot Codec Round-Trip Contract](https://github.com/durable-workflow/workflow/blob/v2/docs/architecture/polyglot-codec-roundtrip.md). +Set `POLYGLOT_COMPOSE_PROJECT_NAME` before running the script when you need a +different isolated project name. ## Layout -``` -polyglot/ -├── service-mode.yml no-build Laravel + Python quickstart stack -├── service_mode/ -│ └── python_worker.py quickstart cross-language activity -├── docker-compose.yml full stack (server + workers + smoke) -├── python_workflow/ -│ ├── workflow.py Python-authored workflow + activities -│ └── Dockerfile Python image -├── python_worker/ -│ ├── activities.py Python activities consumed by the PHP workflow -│ ├── Dockerfile Python image (also baked-in smoke driver) -│ └── scripts/ -│ ├── smoke.sh shell entrypoint for the full smoke -│ ├── polyglot_smoke.py drives all scenarios and emits metadata -│ ├── php_same_language_smoke.py PHP-authoring sanity driver -│ ├── python_workflow_smoke.py Python-authoring smoke driver -│ ├── polyglot_workflow_smoke.py featured PHP→Python→Rust driver -│ └── python_to_php_smoke.py Python→PHP smoke driver -├── rust_worker/ -│ ├── Cargo.toml crates.io-only worker dependencies -│ ├── Cargo.lock reproducible dependency graph -│ ├── Dockerfile exact released SDK build -│ └── src/main.rs Rust workflows, activities, signal/query -├── php_worker/ -│ ├── composer.json framework-neutral Composer project -│ ├── Dockerfile published durable-workflow/sdk image -│ └── worker.php PHP workflows, activities, signal/query -├── laravel/ -│ └── Dockerfile embedded Workflow + Waterline host -└── README.md this file -``` - -The smoke driver scripts live under `python_worker/scripts/` because the -`smoke` service in `docker-compose.yml` reuses the `python_worker` image -build context, and the Dockerfile bakes the `scripts/` tree into the -image at `/app/scripts/`. Editing those files there is the only way to -change what the smoke service runs — there is no bind mount. - -The standalone PHP implementation lives entirely in `php_worker/worker.php` -and uses `DurableWorkflow\Client`, `DurableWorkflow\Worker`, workflow and query -contexts, and the SDK's Apache Avro codec. The matching classes under -`app/Workflows/Polyglot/` remain Laravel teaching material for the root -embedded sample and its MCP catalog; they are not copied into the standalone -worker image. Waterline is likewise hosted in the separate `laravel` image, -where `durable-workflow/workflow` retains its actual role as the embedded -Laravel engine. - -Every PHP, Python, and Rust worker uses the same fixed recursive -`durable_workflow.protocol.Value` schema with Avro single-object framing. -Smoke evidence verifies the `c301` marker, schema fingerprint, and native type -matrix at each language boundary. The durable payload codec is always Avro; -JSON is limited to HTTP document transport and deliberate fail-closed probes -for JSON-tagged durable envelopes. - -## Running locally - -Run the featured workflow with the same one-command path used in Codespaces: +| Path | Purpose | +| --- | --- | +| `php_worker/` | PHP SDK workflow and activity worker | +| `python_worker/` | Python SDK workers and runtime checks | +| `rust_worker/` | Rust SDK workflow and activity worker | +| `python_workflow/` | Python-authored workflow examples | +| `laravel/` | Waterline image used to inspect standalone runs | +| `docker-compose.yml` | Complete service-mode topology | +| `qualified-artifact-tuple.json` | Stable package and image versions used by the examples | -```bash -scripts/polyglot.sh -``` +## Full runtime matrix -For the complete conformance matrix, use an isolated project name: +The Compose topology also contains same-language and cross-language workers +used by the broader smoke driver. After exporting the stable tuple, run it in +an isolated project: ```bash -while IFS= read -r assignment; do export "$assignment"; done < <(scripts/resolve-current-artifacts.sh) -export COMPOSE_PROJECT_NAME="sample-app-polyglot-local-${USER:-user}" -POLYGLOT_BUILD_CACHE_MODE=cold-cache scripts/polyglot-validation.sh +while IFS= read -r assignment; do export "$assignment"; done \ + < <(scripts/resolve-current-artifacts.sh) +export COMPOSE_PROJECT_NAME="sample-app-polyglot-matrix-${USER:-user}" + +docker compose \ + --project-directory polyglot \ + -f polyglot/docker-compose.yml \ + up --detach --build --wait + +docker compose \ + --project-directory polyglot \ + -f polyglot/docker-compose.yml \ + run --rm --no-deps smoke ``` -Use `POLYGLOT_BUILD_CACHE_MODE=warm-cache` to prime the image graph and then -exercise the cached build path. The validation script builds the complete -artifact topology before starting it, brings up Server, every worker, and -Waterline together, and runs registration probes plus smoke without allowing -either one-off container to start or recreate dependencies. It retains bounded -Compose diagnostics on failure and removes the isolated project on exit. - -The `smoke` service runs `/app/scripts/smoke.sh` (baked in from -`python_worker/scripts/smoke.sh`), which: - -1. waits for the Python, PHP, and Rust workers to register on their coordinated - task queues; -2. uses `dw workflow:start --wait --json` to run every cell in the PHP, - Python, and Rust workflow/activity matrix; -3. uses `dw workflow:start`, `dw workflow:query`, `dw workflow:signal`, and - `dw workflow:describe` to verify signal/query parity through the published - CLI for Python-authored, PHP-authored, and Rust-authored workflows; -4. runs the six-direction type round-trip and typed-error matrices through the - same published CLI entrypoint; -5. reads Waterline JSON endpoints for the mixed-language and same-language - runs and compares event typing, payload rendering, and worker attribution; -6. emits one machine-readable conformance metadata document with artifact pins - and pass/fail status for every required surface. +The matrix covers PHP, Python, and Rust workflow/activity directions, portable +Avro values, failures, replay, signals, queries, CLI control, and Waterline +rendering. It is a development diagnostic; the concise `scripts/polyglot.sh` +journey is the supported first-run path. -The final stdout block is the run metadata document. It records the public -artifact pins used by the run and the surface matrix for the CLI, runtime, -codec, typed-error, signal/query, and Waterline checks. Every required surface -must pass before the smoke exits successfully. +## Artifact overrides -Removing `php-workflow-worker` from the `up` line is the regression -test for "this stack is actually polyglot": the smoke fails fast with -"no PHP worker registered on task queue" instead of silently passing. -Removing `php-activity-worker` fails the matching Python-to-PHP check -with "no php worker registered on task queue". -Removing `python-workflow-worker` fails the matching "no Python worker -registered on task queue" check on the symmetric side. -Removing either Rust worker fails the corresponding runtime-registration check; -the report cannot mark the Rust SDK exercised from tuple metadata alone. +The checked-in tuple keeps the sample reproducible. To test another stable +build, point the resolver at another tuple or override one version explicitly: -## CI - -The `.github/workflows/polyglot-validation.yml` GitHub Actions job -runs the lifecycle harness in cold-cache and warm-cache cells on every push and -pull request. Each cell resolves the public artifact tuple once, builds the -checked-out smoke driver before startup, and verifies that worker readiness and -smoke execution stay bound to the Server container created by the initial -topology bootstrap. A regression in either direction is caught here, not in the -field. - -## Codec round-trip notes - -Durable Workflow 2.0 uses Avro as its only durable payload codec. Native -scalars, lists, maps, bytes, and UTF-8 strings flow through the fixed Value -schema. Convert values outside that portable model, including PHP -`BackedEnum` values and Python `dataclasses`, `Decimal`, and `datetime` -instances, through the language SDK's Value adapters before they cross a -workflow boundary. Every durable envelope remains Avro. -PHP uses the SDK's `AvroBinaryValue` adapter to distinguish byte strings from -text, while Python uses `bytes` and Rust uses `AvroValue::Bytes`. +```bash +DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE=/path/to/tuple.json \ + scripts/polyglot.sh -## Waterline rendering +SAMPLE_APP_RUST_SDK_VERSION=2.0.1 scripts/polyglot.sh +``` -The polyglot compose stack starts a Waterline service against the same -standalone server database. The smoke reads Waterline's JSON endpoints for -same-language and mixed-language runs and verifies that workflow arguments, -outputs, event typing, and worker attribution render with the same fidelity -across runtime combinations. Waterline reads each row's `payload_codec` column -rather than sniffing blob shape, so a run authored in Python decodes to the -same JSON structure a PHP run does. +Overrides must be stable 2.x versions. The resolver prints every effective +artifact before Compose starts. diff --git a/polyglot/python_worker/scripts/smoke.sh b/polyglot/python_worker/scripts/smoke.sh index a752fe7..3e791c4 100755 --- a/polyglot/python_worker/scripts/smoke.sh +++ b/polyglot/python_worker/scripts/smoke.sh @@ -47,13 +47,13 @@ if [ -n "${DURABLE_WORKFLOW_WATERLINE_PIN:-}" ]; then DURABLE_WORKFLOW_WATERLINE_VERSION="${DURABLE_WORKFLOW_WATERLINE_VERSION%@*}" fi if [ -z "${DURABLE_WORKFLOW_PHP_SDK_PIN:-}" ] && [ -n "${DURABLE_WORKFLOW_PHP_SDK_VERSION:-}" ]; then - DURABLE_WORKFLOW_PHP_SDK_PIN="durable-workflow/sdk:${DURABLE_WORKFLOW_PHP_SDK_VERSION}@beta" + DURABLE_WORKFLOW_PHP_SDK_PIN="durable-workflow/sdk:${DURABLE_WORKFLOW_PHP_SDK_VERSION}" fi if [ -z "${DURABLE_WORKFLOW_WORKFLOW_PIN:-}" ] && [ -n "${DURABLE_WORKFLOW_WORKFLOW_VERSION:-}" ]; then - DURABLE_WORKFLOW_WORKFLOW_PIN="durable-workflow/workflow:${DURABLE_WORKFLOW_WORKFLOW_VERSION}@beta" + DURABLE_WORKFLOW_WORKFLOW_PIN="durable-workflow/workflow:${DURABLE_WORKFLOW_WORKFLOW_VERSION}" fi if [ -z "${DURABLE_WORKFLOW_WATERLINE_PIN:-}" ] && [ -n "${DURABLE_WORKFLOW_WATERLINE_VERSION:-}" ]; then - DURABLE_WORKFLOW_WATERLINE_PIN="durable-workflow/waterline:${DURABLE_WORKFLOW_WATERLINE_VERSION}@beta" + DURABLE_WORKFLOW_WATERLINE_PIN="durable-workflow/waterline:${DURABLE_WORKFLOW_WATERLINE_VERSION}" fi require_artifact_env DURABLE_SERVER_IMAGE require_artifact_env DURABLE_WORKFLOW_CLI_VERSION diff --git a/polyglot/qualified-artifact-tuple.json b/polyglot/qualified-artifact-tuple.json index 8625798..7ecf67d 100644 --- a/polyglot/qualified-artifact-tuple.json +++ b/polyglot/qualified-artifact-tuple.json @@ -1,14 +1,14 @@ { "schema": "durable-workflow.sample-app.polyglot-qualified-artifact-tuple", "schemaVersion": 1, - "source": "https://github.com/durable-workflow/sample-app/actions/workflows/smoke.yml", + "source": "polyglot/qualified-artifact-tuple.json", "artifacts": { - "cli": "2.0.0-rc.36", - "sdk-php": "2.0.0-rc.53", - "sdk-python": "2.0.0-rc.42", - "sdk-rust": "2.0.0-rc.38", - "server": "2.0.0-rc.59", - "waterline": "2.0.0-rc.33", - "workflow": "2.0.0-rc.52" + "cli": "2.0.0", + "sdk-php": "2.0.0", + "sdk-python": "2.0.0", + "sdk-rust": "2.0.0", + "server": "2.0.0", + "waterline": "2.0.0", + "workflow": "2.0.2" } } diff --git a/polyglot/rust_worker/Cargo.lock b/polyglot/rust_worker/Cargo.lock index 4a862ed..16069f5 100644 --- a/polyglot/rust_worker/Cargo.lock +++ b/polyglot/rust_worker/Cargo.lock @@ -244,9 +244,9 @@ dependencies = [ [[package]] name = "durable-workflow" -version = "2.0.0-rc.38" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36639619dea7c49e2df10d3f4e15eb0a429e2230f7c751f24879e447eddf0e3f" +checksum = "2e22310a10fefd3ee2118a260f5255b98c607856b73749629505891fc18aae49" dependencies = [ "apache-avro", "base64", diff --git a/polyglot/rust_worker/Cargo.toml b/polyglot/rust_worker/Cargo.toml index a88014a..175e29d 100644 --- a/polyglot/rust_worker/Cargo.toml +++ b/polyglot/rust_worker/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] apache-avro = "=0.21.0" base64 = "=0.22.1" -durable-workflow = "=2.0.0-rc.38" +durable-workflow = "2.0" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal"] } [dev-dependencies] diff --git a/polyglot/service-mode.yml b/polyglot/service-mode.yml deleted file mode 100644 index 17b9c47..0000000 --- a/polyglot/service-mode.yml +++ /dev/null @@ -1,340 +0,0 @@ ---- -x-development-image: &development-image - ${SAMPLE_APP_DEVCONTAINER_IMAGE:-ghcr.io/durable-workflow/sample-app-devcontainer:main} - -x-server-environment: &server-environment - APP_NAME: "Durable Workflow Server" - APP_ENV: testing - APP_KEY: "base64:dGVzdGluZy1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTA=" - APP_DEBUG: "false" - DB_CONNECTION: mysql - DB_HOST: mysql - DB_PORT: 3306 - DB_DATABASE: durable_workflow_service - DB_USERNAME: workflow - DB_PASSWORD: workflow - REDIS_HOST: redis - QUEUE_CONNECTION: redis - CACHE_STORE: redis - DW_WORKER_POLL_TIMEOUT: "3" - WORKFLOW_SERVER_AUTH_DRIVER: token - WORKFLOW_SERVER_AUTH_TOKEN: test-token - -x-laravel-environment: &laravel-environment - APP_ENV: local - APP_KEY: "base64:dGVzdGluZy1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTA=" - APP_DEBUG: "false" - LOG_CHANNEL: stderr - DB_CONNECTION: mysql - DB_HOST: mysql - DB_PORT: 3306 - DB_DATABASE: durable_workflow_service - DB_USERNAME: workflow - DB_PASSWORD: workflow - SHARED_DB_HOST: mysql - SHARED_DB_PORT: 3306 - SHARED_DB_DATABASE: durable_workflow_service - SHARED_DB_USERNAME: workflow - SHARED_DB_PASSWORD: workflow - REDIS_HOST: redis - QUEUE_CONNECTION: redis - CACHE_STORE: redis - SESSION_DRIVER: file - DURABLE_WORKFLOW_RUNTIME_URL: http://server:8080 - DURABLE_WORKFLOW_NAMESPACE: default - DURABLE_WORKFLOW_TASK_QUEUE: sample-service-php - -x-app-setup: &app-setup - image: *development-image - user: root - working_dir: /app - entrypoint: ["/bin/bash", "-lc"] - command: - - >- - find /app -mindepth 1 -maxdepth 1 -exec rm -rf -- '{}' + - && tar -C /source - --exclude=.git - --exclude=vendor - --exclude=node_modules - --exclude=storage/logs - -cf - . - | tar -C /app -xf - - && bash scripts/setup-service-mode-app.sh "$$SERVICE_MODE_APP_ROLE" - && chown -R 1000:1000 /app - -services: - mysql: - image: mysql:8.0 - environment: - MYSQL_DATABASE: durable_workflow_service - MYSQL_USER: workflow - MYSQL_PASSWORD: workflow - MYSQL_ROOT_PASSWORD: root - volumes: - - service-mysql:/var/lib/mysql - healthcheck: - test: - - CMD - - mysqladmin - - ping - - -h - - 127.0.0.1 - - --protocol=TCP - - --silent - interval: 3s - timeout: 3s - retries: 40 - - redis: - image: redis:7-alpine - volumes: - - service-redis:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 3s - timeout: 2s - retries: 20 - - worker-app-setup: - <<: *app-setup - environment: - COMPOSER_HOME: /composer-cache - SERVICE_MODE_APP_ROLE: worker - DURABLE_WORKFLOW_PHP_SDK_VERSION: >- - ${DURABLE_WORKFLOW_PHP_SDK_VERSION:?resolve artifacts first} - DURABLE_WORKFLOW_WORKFLOW_VERSION: >- - ${DURABLE_WORKFLOW_WORKFLOW_VERSION:?resolve artifacts first} - DURABLE_WORKFLOW_WATERLINE_VERSION: >- - ${DURABLE_WORKFLOW_WATERLINE_VERSION:?resolve artifacts first} - volumes: - - ..:/source:ro - - service-worker-app:/app - - service-worker-composer-cache:/composer-cache - - observer-app-setup: - <<: *app-setup - environment: - COMPOSER_HOME: /composer-cache - SERVICE_MODE_APP_ROLE: observer - DURABLE_WORKFLOW_PHP_SDK_VERSION: >- - ${DURABLE_WORKFLOW_PHP_SDK_VERSION:?resolve artifacts first} - DURABLE_WORKFLOW_WORKFLOW_VERSION: >- - ${DURABLE_WORKFLOW_WORKFLOW_VERSION:?resolve artifacts first} - DURABLE_WORKFLOW_WATERLINE_VERSION: >- - ${DURABLE_WORKFLOW_WATERLINE_VERSION:?resolve artifacts first} - volumes: - - ..:/source:ro - - service-observer-app:/app - - service-observer-composer-cache:/composer-cache - - python-setup: - image: python:3.12-slim - user: root - entrypoint: ["/bin/sh", "/source/scripts/setup-service-mode-python.sh"] - environment: - DURABLE_WORKFLOW_PYTHON_SDK_VERSION: >- - ${DURABLE_WORKFLOW_PYTHON_SDK_VERSION:?resolve artifacts first} - PIP_CACHE_DIR: /pip-cache - volumes: - - ..:/source:ro - - service-python:/runtime - - service-pip-cache:/pip-cache - - waterline-migrate: - image: *development-image - user: "1000:1000" - working_dir: /var/www/html - entrypoint: - - php - - artisan - - migrate - - --path=vendor/durable-workflow/waterline/database/migrations - - --force - - --no-interaction - environment: *laravel-environment - volumes: - - service-observer-app:/var/www/html - depends_on: - mysql: - condition: service_healthy - observer-app-setup: - condition: service_completed_successfully - - bootstrap: - image: ${DURABLE_SERVER_IMAGE:?resolve the current artifact tuple first} - command: ["server-bootstrap"] - environment: *server-environment - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - - server: - image: ${DURABLE_SERVER_IMAGE:?resolve the current artifact tuple first} - environment: *server-environment - expose: - - "8080" - depends_on: - bootstrap: - condition: service_completed_successfully - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] - interval: 3s - timeout: 3s - retries: 40 - - php-worker: - image: *development-image - user: "1000:1000" - working_dir: /var/www/html - entrypoint: ["php", "artisan", "durable-workflow:worker"] - environment: - <<: *laravel-environment - DURABLE_WORKFLOW_PROCESS_ROLE: worker - DURABLE_WORKFLOW_PROCESS_TOKEN: test-token - volumes: - - service-worker-app:/var/www/html - depends_on: - worker-app-setup: - condition: service_completed_successfully - server: - condition: service_healthy - - python-worker: - image: python:3.12-slim - working_dir: /source - entrypoint: - - /runtime/bin/python - - /source/polyglot/service_mode/python_worker.py - environment: - DURABLE_WORKFLOW_ENDPOINT: http://server:8080 - DURABLE_WORKFLOW_NAMESPACE: default - DURABLE_WORKFLOW_TASK_QUEUE: sample-service-python - DURABLE_WORKFLOW_TOKEN: test-token - volumes: - - ..:/source:ro - - service-python:/runtime:ro - depends_on: - python-setup: - condition: service_completed_successfully - server: - condition: service_healthy - - waterline: - image: *development-image - user: "1000:1000" - working_dir: /var/www/html - entrypoint: - - php - - artisan - - serve - - --host=0.0.0.0 - - --port=8081 - environment: - <<: *laravel-environment - WATERLINE_ALLOW_UNAUTHENTICATED: "true" - WATERLINE_BACKEND: service - WATERLINE_SERVER_ENDPOINT: http://server:8080 - WATERLINE_SERVER_TOKEN: test-token - WATERLINE_ACCESS_MODE: read_only - WATERLINE_NAMESPACE: default - WATERLINE_PATH: waterline - ports: - - "${SERVICE_MODE_PORT:-18081}:8081" - volumes: - - service-observer-app:/var/www/html - depends_on: - waterline-migrate: - condition: service_completed_successfully - server: - condition: service_healthy - healthcheck: - test: - - CMD - - curl - - -f - - http://localhost:8081/waterline/api/v2/health - interval: 3s - timeout: 3s - retries: 40 - - waterline-embedded: - image: *development-image - user: "1000:1000" - working_dir: /var/www/html - entrypoint: - - php - - artisan - - serve - - --host=0.0.0.0 - - --port=8082 - environment: - <<: *laravel-environment - WATERLINE_ALLOW_UNAUTHENTICATED: "true" - WATERLINE_BACKEND: embedded - WATERLINE_ENGINE_SOURCE: v2 - WATERLINE_NAMESPACE: default - WATERLINE_PATH: waterline - expose: - - "8082" - volumes: - - service-observer-app:/var/www/html - depends_on: - waterline-migrate: - condition: service_completed_successfully - healthcheck: - test: - - CMD - - curl - - -f - - http://localhost:8082/waterline/api/v2/health - interval: 3s - timeout: 3s - retries: 40 - - journey: - image: *development-image - user: "1000:1000" - working_dir: /var/www/html - entrypoint: ["php", "artisan", "app:service-mode"] - command: ["Codespace", "--json"] - environment: - <<: *laravel-environment - DURABLE_WORKFLOW_PROCESS_ROLE: client - DURABLE_WORKFLOW_PROCESS_TOKEN: test-token - SERVICE_MODE_WATERLINE_URL: >- - ${SERVICE_MODE_WATERLINE_URL:-http://localhost:18081/waterline} - volumes: - - service-worker-app:/var/www/html - depends_on: - worker-app-setup: - condition: service_completed_successfully - server: - condition: service_healthy - php-worker: - condition: service_started - python-worker: - condition: service_started - waterline: - condition: service_healthy - - browser-smoke: - image: *development-image - user: root - working_dir: /var/www/html - entrypoint: ["playwright"] - volumes: - - >- - ${SERVICE_MODE_EVIDENCE_DIR:?set evidence directory}:/evidence - - service-observer-app:/observer:ro - -volumes: - service-mysql: - service-observer-app: - service-observer-composer-cache: - service-pip-cache: - service-python: - service-redis: - service-worker-app: - service-worker-composer-cache: diff --git a/scripts/ci/qualify-devcontainer-database-overrides.sh b/scripts/ci/qualify-devcontainer-database-overrides.sh deleted file mode 100755 index e25c51e..0000000 --- a/scripts/ci/qualify-devcontainer-database-overrides.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -compose_file="${repo_root}/.devcontainer/docker/docker-compose.yml" -compose=(docker compose --file "$compose_file") -source "${repo_root}/scripts/ci/devcontainer-identity.sh" - -export DB_DATABASE=codespaces_override -export DB_USERNAME=codespaces_user -export DB_PASSWORD=codespaces_password - -verify_database_contract() { - "${compose[@]}" exec -T mysql sh -euc ' - query_database() { - mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database="$MYSQL_DATABASE" \ - --batch \ - --skip-column-names \ - --execute="$1" - } - - query_testing_database() { - mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database=testing \ - --batch \ - --skip-column-names \ - --execute="$1" - } - - [ "$MYSQL_DATABASE" = codespaces_override ] - [ "$MYSQL_USER" = codespaces_user ] - [ "$MYSQL_PASSWORD" = codespaces_password ] - [ "$(query_database "SELECT COUNT(*) FROM migrations")" = 50 ] - [ "$(query_database "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()")" = 49 ] - - query_testing_database "DROP TABLE IF EXISTS codespaces_testing_probe" - query_testing_database "CREATE TABLE codespaces_testing_probe (value VARCHAR(32) NOT NULL)" - query_testing_database "INSERT INTO codespaces_testing_probe (value) VALUES (\"usable\")" - [ "$(query_testing_database "SELECT value FROM codespaces_testing_probe")" = usable ] - query_testing_database "DROP TABLE codespaces_testing_probe" - ' -} - -verify_application_contract() { - run_in_ready_devcontainer laravel bash -euc ' - grep -Fx "DB_DATABASE=$DB_DATABASE" .env - grep -Fx "DB_USERNAME=$DB_USERNAME" .env - grep -Fx "DB_PASSWORD=$DB_PASSWORD" .env - grep -Fx "SHARED_DB_DATABASE=$SHARED_DB_DATABASE" .env - grep -Fx "SHARED_DB_USERNAME=$SHARED_DB_USERNAME" .env - grep -Fx "SHARED_DB_PASSWORD=$SHARED_DB_PASSWORD" .env - php artisan migrate:status --no-interaction - php artisan migrate:status --pending=1 --no-interaction - curl --fail --silent http://localhost/up >/dev/null - curl --fail --silent http://localhost/ >/dev/null - ' - - run_in_ready_devcontainer microservice php artisan tinker --execute=' - $database = DB::connection("shared")->selectOne("SELECT DATABASE() AS name")->name; - throw_unless($database === getenv("SHARED_DB_DATABASE"), "Shared database override was not applied."); - ' -} - -bootstrap_devcontainer_application() { - "${compose[@]}" up --detach --no-build laravel microservice - run_in_ready_devcontainer laravel .devcontainer/post-create.sh - "${compose[@]}" up --detach --no-build --wait -} - -cleanup() { - local status=$? - trap - EXIT - - if (( status != 0 )); then - "${compose[@]}" ps >&2 || true - "${compose[@]}" logs --no-color --timestamps --tail=200 >&2 || true - fi - - "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true - exit "$status" -} -if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then - return 0 -fi - -trap cleanup EXIT - -"${compose[@]}" down --volumes --remove-orphans -"${compose[@]}" up --detach --no-build --wait mysql redis - -seed_container_id="$("${compose[@]}" ps --all --quiet mysql-seed)" -if [[ -z "$seed_container_id" \ - || "$(docker inspect --format '{{.State.ExitCode}}' "$seed_container_id")" != 0 ]]; then - echo 'The database-override seed guard did not complete successfully.' >&2 - exit 1 -fi - -"${compose[@]}" exec -T mysql test ! -e /var/lib/mysql/.sample-app-codespaces-seed -verify_database_contract - -bootstrap_devcontainer_application -verify_application_contract - -"${compose[@]}" exec -T mysql sh -euc ' - mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database="$MYSQL_DATABASE" \ - --execute="DELETE FROM users WHERE email = \"codespaces-override-probe@example.invalid\"; - INSERT INTO users (name, email, password, created_at, updated_at) - VALUES (\"Codespaces override probe\", \"codespaces-override-probe@example.invalid\", \"not-a-login\", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" -' - -"${compose[@]}" run --rm --no-deps mysql-seed -"${compose[@]}" stop laravel microservice mysql -"${compose[@]}" up --detach --no-build --wait mysql redis -"${compose[@]}" up --detach --no-build --force-recreate --wait laravel microservice - -"${compose[@]}" exec -T mysql sh -euc ' - persisted_count="$(mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database="$MYSQL_DATABASE" \ - --batch \ - --skip-column-names \ - --execute="SELECT COUNT(*) FROM users WHERE email = \"codespaces-override-probe@example.invalid\"")" - [ "$persisted_count" = 1 ] - mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database="$MYSQL_DATABASE" \ - --execute="DELETE FROM users WHERE email = \"codespaces-override-probe@example.invalid\"" -' - -verify_database_contract -verify_application_contract - -checkout_status="$(git -C "$repo_root" status --porcelain)" -if [[ -n "$checkout_status" ]]; then - echo 'Database-override qualification left the checkout dirty.' >&2 - printf '%s\n' "$checkout_status" >&2 - exit 1 -fi diff --git a/scripts/ci/qualify-devcontainer-image.sh b/scripts/ci/qualify-devcontainer-image.sh index 9463b92..4b588e5 100755 --- a/scripts/ci/qualify-devcontainer-image.sh +++ b/scripts/ci/qualify-devcontainer-image.sh @@ -1,558 +1,153 @@ #!/usr/bin/env bash - set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" compose_file="${repo_root}/.devcontainer/docker/docker-compose.yml" -source "${repo_root}/scripts/ci/devcontainer-identity.sh" -image="${1:?Usage: qualify-devcontainer-image.sh IMAGE PLATFORM [TIMING_OUTPUT]}" -platform="${2:?Usage: qualify-devcontainer-image.sh IMAGE PLATFORM [TIMING_OUTPUT]}" -timing_output="${3:-${repo_root}/devcontainer-qualification-timing.json}" -max_fresh_seconds="${DEVCONTAINER_MAX_FRESH_SECONDS:-300}" -max_warm_seconds="${DEVCONTAINER_MAX_WARM_SECONDS:-120}" +image="${1:?Usage: qualify-devcontainer-image.sh IMAGE PLATFORM}" +platform="${2:?Usage: qualify-devcontainer-image.sh IMAGE PLATFORM}" +max_seconds="${DEVCONTAINER_MAX_STARTUP_SECONDS:-600}" require_attestations="${DEVCONTAINER_REQUIRE_PUBLISHED_ATTESTATIONS:-1}" require_anonymous_pull="${DEVCONTAINER_REQUIRE_ANONYMOUS_PULL:-0}" skip_image_pull="${DEVCONTAINER_SKIP_IMAGE_PULL:-0}" expected_revision="${DEVCONTAINER_EXPECTED_REVISION:-}" -evidence_type="${DEVCONTAINER_EVIDENCE_TYPE:-qualification}" -image_build_ms="${DEVCONTAINER_IMAGE_BUILD_MS:-0}" -registry="${DEVCONTAINER_REGISTRY:-local}" -runner_label="${DEVCONTAINER_RUNNER_LABEL:-unknown}" -qualify_playground="${DEVCONTAINER_QUALIFY_PLAYGROUND:-0}" + +source "${repo_root}/scripts/ci/devcontainer-identity.sh" case "$platform" in - linux/amd64|linux/arm64) ;; - *) - echo "Unsupported qualification platform: ${platform}" >&2 - exit 2 - ;; + linux/amd64) expected_machine=x86_64 ;; + linux/arm64) expected_machine=aarch64 ;; + *) printf 'Unsupported qualification platform: %s\n' "$platform" >&2; exit 2 ;; esac -timestamp_ms() { - date +%s%3N -} - -duration_ms() { - local started_ms="$1" - echo $(( $(timestamp_ms) - started_ms )) -} - -normalize_architecture() { - case "$1" in - amd64|x86_64) echo amd64 ;; - arm64|aarch64) echo arm64 ;; - *) - echo "Unsupported runner architecture: $1" >&2 - return 1 - ;; - esac -} - -expected_architecture="${platform#linux/}" -host_machine="$(uname -m)" -host_architecture="$(normalize_architecture "$host_machine")" -docker_architecture="$(normalize_architecture "$(docker info --format '{{.Architecture}}')")" - -if [[ "$host_architecture" != "$expected_architecture" || "$docker_architecture" != "$expected_architecture" ]]; then - echo "Qualification for ${platform} requires a native runner; host=${host_architecture}, docker=${docker_architecture}." >&2 - exit 1 +if [[ "$(uname -m)" != "$expected_machine" ]]; then + printf 'Qualification for %s requires a native %s runner.\n' "$platform" "$expected_machine" >&2 + exit 1 fi -run_started_ms="${DEVCONTAINER_RUN_STARTED_MS:-$(timestamp_ms)}" -anonymous_credentials_absent=0 - if [[ "$require_anonymous_pull" == "1" ]]; then - if [[ "$skip_image_pull" == "1" ]]; then - echo 'Anonymous pull verification cannot skip the image pull.' >&2 - exit 1 - fi - - docker_config="${DOCKER_CONFIG:-${HOME}/.docker}" - python3 - "${docker_config}/config.json" <<'PY' + docker_config="${DOCKER_CONFIG:-${HOME}/.docker}/config.json" + python3 - "$docker_config" <<'PY' import json -import os +import pathlib import sys -path = sys.argv[1] -if not os.path.exists(path): - raise SystemExit(f"anonymous pull requires an explicit credential-free Docker config: {path}") - -with open(path, encoding="utf-8") as source: - config = json.load(source) - -if config.get("auths") or config.get("credsStore") or config.get("credHelpers"): - raise SystemExit("anonymous pull Docker config contains registry credential sources") +path = pathlib.Path(sys.argv[1]) +if not path.is_file(): + raise SystemExit(f'anonymous pull requires an explicit Docker config: {path}') +config = json.loads(path.read_text(encoding='utf-8')) +if config.get('auths') or config.get('credsStore') or config.get('credHelpers'): + raise SystemExit('anonymous pull Docker config contains credentials') PY - anonymous_credentials_absent=1 fi -project_suffix="$(printf '%s-%s' "$image" "$platform" | sha256sum | cut -c1-12)" -export COMPOSE_PROJECT_NAME="sample-app-devcontainer-${project_suffix}" -export DOCKER_DEFAULT_PLATFORM="$platform" -export SAMPLE_APP_DEVCONTAINER_IMAGE="$image" -export SAMPLE_APP_DEVCONTAINER_PULL_POLICY=never -export SAMPLE_APP_UID="$(id -u)" -export DB_DATABASE=sample -export DB_USERNAME=laravel -export DB_PASSWORD=password -export APP_PORT=18080 -export MICROSERVICE_PORT=18001 -export VITE_PORT=15173 -export FORWARD_DB_PORT=13306 -export FORWARD_REDIS_PORT=16379 - -compose=(docker compose --file "$compose_file") - -prepare_qualification_checkout() { - local qualification_gid - - qualification_gid="$(id -g)" - if [[ "$(stat --format=%u "$repo_root")" != "$SAMPLE_APP_UID" \ - || "$(stat --format=%g "$repo_root")" != "$qualification_gid" ]]; then - if command -v sudo >/dev/null 2>&1; then - sudo chown -R "${SAMPLE_APP_UID}:${qualification_gid}" "$repo_root" - else - chown -R "${SAMPLE_APP_UID}:${qualification_gid}" "$repo_root" - fi - fi - - if [[ "$(stat --format=%u "$repo_root")" != "$SAMPLE_APP_UID" || ! -w "$repo_root" ]]; then - echo "Qualification checkout is not writable by SAMPLE_APP_UID ${SAMPLE_APP_UID}: ${repo_root}" >&2 - exit 1 - fi -} - -prepare_qualification_checkout -tracked_status_before="$(git -C "$repo_root" status --porcelain --untracked-files=no)" - -verify_database_schema() { - "${compose[@]}" exec -T mysql sh -euc ' - query_database() { - mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database="$MYSQL_DATABASE" \ - --batch \ - --skip-column-names \ - --execute="$1" - } - - query_testing_database() { - mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database=testing \ - --batch \ - --skip-column-names \ - --execute="$1" - } - - expected_migration_count=50 - expected_table_count=49 - migration_count="$(query_database "SELECT COUNT(*) FROM migrations")" - table_count="$(query_database "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()")" - - if [ "$migration_count" != "$expected_migration_count" ]; then - echo "Codespaces schema recorded ${migration_count} migrations; expected ${expected_migration_count}." >&2 - exit 1 - fi - - if [ "$table_count" != "$expected_table_count" ]; then - echo "Codespaces schema created ${table_count} tables; expected ${expected_table_count}." >&2 - exit 1 - fi - - query_testing_database "DROP TABLE IF EXISTS codespaces_testing_probe" - query_testing_database "CREATE TABLE codespaces_testing_probe (value VARCHAR(32) NOT NULL)" - query_testing_database "INSERT INTO codespaces_testing_probe (value) VALUES (\"usable\")" - [ "$(query_testing_database "SELECT value FROM codespaces_testing_probe")" = usable ] - query_testing_database "DROP TABLE codespaces_testing_probe" - ' -} - -record_database_persistence_probe() { - "${compose[@]}" exec -T mysql sh -euc ' - mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database="$MYSQL_DATABASE" \ - --execute="DELETE FROM users WHERE email = \"codespaces-default-probe@example.invalid\"; - INSERT INTO users (name, email, password, created_at, updated_at) - VALUES (\"Codespaces default probe\", \"codespaces-default-probe@example.invalid\", \"not-a-login\", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" - ' -} - -verify_and_remove_database_persistence_probe() { - "${compose[@]}" exec -T mysql sh -euc ' - persisted_count="$(mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database="$MYSQL_DATABASE" \ - --batch \ - --skip-column-names \ - --execute="SELECT COUNT(*) FROM users WHERE email = \"codespaces-default-probe@example.invalid\"")" - [ "$persisted_count" = 1 ] - mariadb \ - --user="$MYSQL_USER" \ - --password="$MYSQL_PASSWORD" \ - --database="$MYSQL_DATABASE" \ - --execute="DELETE FROM users WHERE email = \"codespaces-default-probe@example.invalid\"" - ' -} - -if [[ -n "$tracked_status_before" ]]; then - echo 'Devcontainer qualification requires a checkout with no tracked changes.' >&2 - printf '%s\n' "$tracked_status_before" >&2 - exit 1 +started_at="$(date +%s)" +tracked_before="$(git -C "$repo_root" status --porcelain --untracked-files=no)" +if [[ -n "$tracked_before" ]]; then + printf 'Devcontainer qualification requires a clean tracked checkout.\n%s\n' "$tracked_before" >&2 + exit 1 fi -cleanup() { - local status=$? - trap - EXIT - - if (( status != 0 )); then - "${compose[@]}" ps >&2 || true - "${compose[@]}" logs --no-color --timestamps --tail=200 >&2 || true - fi - - "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true - exit "$status" -} -trap cleanup EXIT - -image_pull_started_ms="$(timestamp_ms)" if [[ "$skip_image_pull" == "1" ]]; then - docker image inspect "$image" >/dev/null + docker image inspect "$image" >/dev/null else - docker pull --platform "$platform" "$image" + docker pull --platform "$platform" "$image" fi -image_pull_ms="$(duration_ms "$image_pull_started_ms")" if [[ "$require_attestations" == "1" ]]; then - manifest_path="$(mktemp)" - docker buildx imagetools inspect "$image" --raw > "$manifest_path" - python3 - "$manifest_path" <<'PY' + manifest="$(mktemp)" + docker buildx imagetools inspect "$image" --raw > "$manifest" + python3 - "$manifest" <<'PY' import json import sys -manifest = json.load(open(sys.argv[1], encoding="utf-8")) -entries = manifest.get("manifests", []) -architectures = { - entry.get("platform", {}).get("architecture") +manifest = json.load(open(sys.argv[1], encoding='utf-8')) +entries = manifest.get('manifests', []) +platforms = { + (entry.get('platform') or {}).get('architecture') for entry in entries - if entry.get("platform", {}).get("architecture") not in (None, "unknown") + if (entry.get('platform') or {}).get('os') == 'linux' } -missing = {"amd64", "arm64"} - architectures -if missing: - raise SystemExit(f"published image is missing platforms: {sorted(missing)}") - +if not {'amd64', 'arm64'} <= platforms: + raise SystemExit(f'published image is missing platforms: {platforms}') attestations = [ - entry - for entry in entries - if entry.get("annotations", {}).get("vnd.docker.reference.type") - == "attestation-manifest" + entry for entry in entries + if (entry.get('annotations') or {}).get('vnd.docker.reference.type') == 'attestation-manifest' ] if len(attestations) < 2: - raise SystemExit("published image does not expose per-platform attestations") + raise SystemExit('published image does not expose per-platform attestations') PY - rm -f "$manifest_path" - - provenance="$(docker buildx imagetools inspect "$image" --format '{{ json .Provenance }}')" - sbom="$(docker buildx imagetools inspect "$image" --format '{{ json .SBOM }}')" - [[ -n "$provenance" && "$provenance" != "null" ]] - [[ -n "$sbom" && "$sbom" != "null" ]] + rm -f "$manifest" fi source_label="$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.source" }}' "$image")" revision_label="$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image")" -[[ "$source_label" == "https://github.com/durable-workflow/sample-app" ]] - +[[ "$source_label" == 'https://github.com/durable-workflow/sample-app' ]] if [[ -n "$expected_revision" ]]; then - [[ "$revision_label" == "$expected_revision" ]] + [[ "$revision_label" == "$expected_revision" ]] fi -container_readiness_started_ms="$(timestamp_ms)" -"${compose[@]}" pull mysql redis -"${compose[@]}" up --detach --no-build --wait mysql redis -container_readiness_ms="$(duration_ms "$container_readiness_started_ms")" +suffix="$(printf '%s-%s' "$image" "$platform" | sha256sum | cut -c1-12)" +export COMPOSE_PROJECT_NAME="sample-app-devcontainer-${suffix}" +export DOCKER_DEFAULT_PLATFORM="$platform" +export SAMPLE_APP_DEVCONTAINER_IMAGE="$image" +export SAMPLE_APP_DEVCONTAINER_PULL_POLICY=never +export SAMPLE_APP_UID="$(id -u)" +export DB_DATABASE=sample +export DB_USERNAME=laravel +export DB_PASSWORD=password +export APP_PORT=18080 +export MICROSERVICE_PORT=18001 +export VITE_PORT=15173 +export FORWARD_DB_PORT=13306 +export FORWARD_REDIS_PORT=16379 -seed_container_id="$("${compose[@]}" ps --all --quiet mysql-seed)" -if [[ -z "$seed_container_id" \ - || "$(docker inspect --format '{{.State.ExitCode}}' "$seed_container_id")" != 0 ]]; then - echo 'The first-volume MySQL seed service did not complete successfully.' >&2 - exit 1 -fi +compose=(docker compose --file "$compose_file") -"${compose[@]}" exec -T mysql test -e /var/lib/mysql/.sample-app-codespaces-seed -verify_database_schema +cleanup() { + local status=$? + trap - EXIT + if (( status != 0 )); then + "${compose[@]}" ps >&2 || true + "${compose[@]}" logs --no-color --timestamps --tail=200 >&2 || true + fi + "${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true + exit "$status" +} +trap cleanup EXIT -dependency_bootstrap_started_ms="$(timestamp_ms)" +"${compose[@]}" pull mysql redis +"${compose[@]}" up --detach --no-build mysql redis "${compose[@]}" up --detach --no-build laravel microservice -run_in_ready_devcontainer laravel bash -euc ' - [[ "$(stat --format=%u .)" == "$SAMPLE_APP_UID" ]] - [[ -w . ]] - socket_gid="$(stat --format=%g /var/run/docker.sock)" - [[ " $(id -G) " == *" ${socket_gid} "* ]] - docker version >/dev/null - docker compose version >/dev/null - for prepared_home in "${COMPOSER_HOME:?}" "${CARGO_HOME:?}"; do - prepared_state_probe="${prepared_home}/.devcontainer-qualification-write-test" - printf "prepared-state-access\n" > "$prepared_state_probe" - rm "$prepared_state_probe" - done - with-disposable-composer-state composer validate \ - --working-dir=. \ - --strict \ - --check-lock \ - --no-check-all \ - --no-interaction - with-group-shared-umask cargo check \ - --bins \ - --locked \ - --offline \ - --manifest-path=playground/templates/rust/Cargo.toml \ - >/dev/null - if [[ -e .env ]]; then - [[ "$(stat --format=%u .env)" == "$SAMPLE_APP_UID" ]] - [[ -w .env ]] - fi -' -"${compose[@]}" exec -T --user laravel laravel .devcontainer/post-create.sh -"${compose[@]}" exec -T --user laravel laravel bash -euc ' - [[ -f .env ]] - [[ "$(stat --format=%u .env)" == "$SAMPLE_APP_UID" ]] - [[ -w .env ]] -' -"${compose[@]}" run --rm --no-deps microservice bash -euc '[[ "$(id -u)" == "$SAMPLE_APP_UID" ]]' -dependency_bootstrap_ms="$(duration_ms "$dependency_bootstrap_started_ms")" - -verify_database_schema - -application_readiness_started_ms="$(timestamp_ms)" +run_in_ready_devcontainer laravel .devcontainer/post-create.sh "${compose[@]}" up --detach --no-build --wait -"${compose[@]}" exec -T --user laravel laravel bash -euc ' - [[ "$(curl --silent --output /dev/null --write-out "%{http_code}" http://localhost/up)" == 200 ]] - [[ "$(curl --silent --output /dev/null --write-out "%{http_code}" http://localhost/)" == 200 ]] - php artisan migrate:status --no-interaction - php artisan migrate:status --pending=1 --no-interaction - [[ "$(redis-cli -h redis --raw ping)" == PONG ]] -' -application_readiness_ms="$(duration_ms "$application_readiness_started_ms")" -"${compose[@]}" exec -T \ - --env DEVCONTAINER_DEPENDENCY_SCOPE=laravel \ - --user laravel \ - laravel verify-devcontainer-image -"${compose[@]}" exec -T \ - --env DEVCONTAINER_DEPENDENCY_SCOPE=microservice \ - --user laravel \ - microservice verify-devcontainer-image -"${compose[@]}" exec -T laravel sshd -t -"${compose[@]}" exec -T --user laravel laravel node docker/playwright-smoke.js -first_app_key="$("${compose[@]}" exec -T --user laravel laravel sed -n 's/^APP_KEY=//p' .env)" -[[ "$first_app_key" == base64:* ]] -"${compose[@]}" exec -T --user laravel laravel .devcontainer/post-create.sh -second_app_key="$("${compose[@]}" exec -T --user laravel laravel sed -n 's/^APP_KEY=//p' .env)" -[[ "$second_app_key" == "$first_app_key" ]] - -playground_journey_ms=0 -playground_evidence_files=() -if [[ "$qualify_playground" == "1" ]]; then - playground_started_ms="$(timestamp_ms)" - playground_source_root="/tmp/sample-app-playground-proof-${expected_architecture}-$$" - for language in php python rust; do - evidence_name="devcontainer-playground-${expected_architecture}-${language}.json" - evidence_path="${repo_root}/storage/app/${evidence_name}" - playground_evidence_files+=("$evidence_path") - "${compose[@]}" exec -T laravel gosu laravel env \ - CODESPACES=true \ - PLAYGROUND_CLEANUP=1 \ - PLAYGROUND_EVIDENCE_PATH="/var/www/html/storage/app/${evidence_name}" \ - PLAYGROUND_SERVER_URL=http://host.docker.internal:18082 \ - PLAYGROUND_SOURCE_ROOT="$playground_source_root" \ - PLAYGROUND_WATERLINE_INTERNAL_URL=http://host.docker.internal:18083 \ - PLAYGROUND_WATERLINE_URL=http://localhost:18083/waterline \ - scripts/playground "$language" - done - python3 "${repo_root}/scripts/ci/validate-playground-evidence.py" \ - "${playground_evidence_files[@]}" - playground_journey_ms="$(duration_ms "$playground_started_ms")" -fi - -tracked_status_after="$(git -C "$repo_root" status --porcelain --untracked-files=no)" - -if [[ -n "$tracked_status_after" ]]; then - echo 'Codespaces setup modified tracked source files.' >&2 - printf '%s\n' "$tracked_status_after" >&2 - exit 1 -fi - -"${compose[@]}" exec -T laravel bash -euc ' - exec 3<>/dev/tcp/127.0.0.1/22 - IFS= read -r -t 5 ssh_banner <&3 - exec 3<&- - exec 3>&- - [[ "$ssh_banner" == SSH-* ]] - - key_dir="$(mktemp -d)" - trap "rm -rf \"$key_dir\"" EXIT - ssh-keygen -q -t ed25519 -N "" -f "$key_dir/id_ed25519" - install -m 0600 -o laravel -g laravel \ - "$key_dir/id_ed25519.pub" /home/laravel/.ssh/authorized_keys - chown -R laravel:laravel "$key_dir" - remote_uid="$(gosu laravel ssh \ - -o BatchMode=yes \ - -o ConnectTimeout=5 \ - -o LogLevel=ERROR \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - -i "$key_dir/id_ed25519" \ - laravel@127.0.0.1 id -u)" - [[ "$remote_uid" == "$SAMPLE_APP_UID" ]] -' -"${compose[@]}" exec -T --user laravel laravel bash -euc ' - [[ "$(id -u)" == "$SAMPLE_APP_UID" ]] - with-disposable-composer-state composer check-platform-reqs --no-dev - probe=.devcontainer-qualification-write-test - printf "editable\n" > "$probe" - [[ "$(<"$probe")" == "editable" ]] - rm "$probe" -' - -warm_rebuild_started_ms="$(timestamp_ms)" -record_database_persistence_probe -"${compose[@]}" run --rm --no-deps mysql-seed -verify_database_schema -"${compose[@]}" stop laravel microservice mysql -"${compose[@]}" up --detach --no-build --wait mysql redis -"${compose[@]}" up --detach --no-build --force-recreate --wait laravel microservice -run_in_ready_devcontainer laravel curl --fail --silent http://localhost/up >/dev/null -verify_and_remove_database_persistence_probe -"${compose[@]}" exec -T --user laravel laravel bash -euc ' - migration_name=create_codespaces_future_migration_probe_table - php artisan make:migration "$migration_name" \ - --create=codespaces_future_migration_probe \ - --no-interaction - migration_paths=(database/migrations/*_"${migration_name}".php) - if (( ${#migration_paths[@]} != 1 )); then - echo "Expected one future-migration probe, found ${#migration_paths[@]}." >&2 - exit 1 - fi - migration_path="${migration_paths[0]}" - trap '\''rm -f "$migration_path"'\'' EXIT - php artisan migrate --force --no-interaction - php artisan migrate:rollback \ - --force \ - --no-interaction \ - --path="$migration_path" +run_in_ready_devcontainer laravel bash -euc ' + for command in php composer python3 rustc cargo dw rg node npm docker; do + command -v "$command" >/dev/null + done + docker compose version >/dev/null + curl --fail --silent http://localhost/up >/dev/null + curl --fail --silent http://localhost/ >/dev/null + composer validate --strict --check-lock --no-check-all --no-interaction + cargo check --bins --locked --offline --manifest-path=playground/templates/rust/Cargo.toml >/dev/null + scripts/playground doctor ' -verify_database_schema -warm_rebuild_ms="$(duration_ms "$warm_rebuild_started_ms")" - -checkout_status_after="$(git -C "$repo_root" status --porcelain)" -if [[ -n "$checkout_status_after" ]]; then - echo 'Devcontainer qualification left the checkout dirty after persistent-volume validation.' >&2 - printf '%s\n' "$checkout_status_after" >&2 - exit 1 -fi - -fresh_total_ms=$(( image_pull_ms + container_readiness_ms + dependency_bootstrap_ms + application_readiness_ms + playground_journey_ms )) -max_fresh_ms=$(( max_fresh_seconds * 1000 )) -max_warm_ms=$(( max_warm_seconds * 1000 )) - -if (( fresh_total_ms >= max_fresh_ms )); then - echo "Fresh devcontainer qualification took ${fresh_total_ms}ms; limit is ${max_fresh_ms}ms." >&2 - exit 1 +"${compose[@]}" exec -T --user laravel laravel node docker/playwright-smoke.js +"${compose[@]}" exec -T laravel sshd -t +"${compose[@]}" exec -T mysql mariadb \ + --user=laravel --password=password --database=sample \ + --batch --skip-column-names --execute='SELECT 1' | grep -Fx 1 + +tracked_after="$(git -C "$repo_root" status --porcelain --untracked-files=no)" +if [[ "$tracked_after" != "$tracked_before" ]]; then + printf 'Codespaces setup modified tracked files.\n%s\n' "$tracked_after" >&2 + exit 1 fi -if (( warm_rebuild_ms >= max_warm_ms )); then - echo "Warm devcontainer rebuild took ${warm_rebuild_ms}ms; limit is ${max_warm_ms}ms." >&2 - exit 1 +elapsed=$(( $(date +%s) - started_at )) +if (( elapsed > max_seconds )); then + printf 'Codespaces startup took %ss; limit is %ss.\n' "$elapsed" "$max_seconds" >&2 + exit 1 fi -database_override_started_ms="$(timestamp_ms)" -"${repo_root}/scripts/ci/qualify-devcontainer-database-overrides.sh" -database_override_ms="$(duration_ms "$database_override_started_ms")" - -mkdir -p "$(dirname "$timing_output")" -IMAGE="$image" \ -PLATFORM="$platform" \ -REVISION="$revision_label" \ -ANONYMOUS_CREDENTIALS_ABSENT="$anonymous_credentials_absent" \ -COMPLETED_MS="$(timestamp_ms)" \ -DOCKER_ARCHITECTURE="$docker_architecture" \ -EVIDENCE_TYPE="$evidence_type" \ -HOST_ARCHITECTURE="$host_architecture" \ -HOST_MACHINE="$host_machine" \ -IMAGE_PULL_MS="$image_pull_ms" \ -IMAGE_BUILD_MS="$image_build_ms" \ -CONTAINER_READINESS_MS="$container_readiness_ms" \ -DEPENDENCY_BOOTSTRAP_MS="$dependency_bootstrap_ms" \ -APPLICATION_READINESS_MS="$application_readiness_ms" \ -DATABASE_OVERRIDE_MS="$database_override_ms" \ -FRESH_TOTAL_MS="$fresh_total_ms" \ -REGISTRY="$registry" \ -REQUIRE_ANONYMOUS_PULL="$require_anonymous_pull" \ -RUNNER_LABEL="$runner_label" \ -RUN_STARTED_MS="$run_started_ms" \ -WARM_REBUILD_MS="$warm_rebuild_ms" \ -PLAYGROUND_JOURNEY_MS="$playground_journey_ms" \ -PLAYGROUND_EVIDENCE_FILES="$(IFS=:; echo "${playground_evidence_files[*]}")" \ -TIMING_OUTPUT="$timing_output" \ -python3 <<'PY' -import json -import os - -payload = { - "schema_version": 2, - "evidence_type": os.environ["EVIDENCE_TYPE"], - "image": os.environ["IMAGE"], - "platform": os.environ["PLATFORM"], - "registry": os.environ["REGISTRY"], - "source_revision": os.environ["REVISION"], - "runner": { - "label": os.environ["RUNNER_LABEL"], - "host_machine": os.environ["HOST_MACHINE"], - "host_architecture": os.environ["HOST_ARCHITECTURE"], - "docker_architecture": os.environ["DOCKER_ARCHITECTURE"], - }, - "anonymous_pull_verification": { - "required": os.environ["REQUIRE_ANONYMOUS_PULL"] == "1", - "credentials_absent": os.environ["ANONYMOUS_CREDENTIALS_ABSENT"] == "1", - "pull_performed": os.environ["REQUIRE_ANONYMOUS_PULL"] == "1", - }, - "environment_builds": 0, - "phases_ms": { - "image_pull": int(os.environ["IMAGE_PULL_MS"]), - "container_readiness": int(os.environ["CONTAINER_READINESS_MS"]), - "dependency_bootstrap": int(os.environ["DEPENDENCY_BOOTSTRAP_MS"]), - "application_readiness": int(os.environ["APPLICATION_READINESS_MS"]), - }, - "stages_ms": { - "image_build": int(os.environ["IMAGE_BUILD_MS"]), - "image_pull": int(os.environ["IMAGE_PULL_MS"]), - "container_readiness": int(os.environ["CONTAINER_READINESS_MS"]), - "dependency_bootstrap": int(os.environ["DEPENDENCY_BOOTSTRAP_MS"]), - "application_readiness": int(os.environ["APPLICATION_READINESS_MS"]), - "database_override": int(os.environ["DATABASE_OVERRIDE_MS"]), - "warm_rebuild": int(os.environ["WARM_REBUILD_MS"]), - "playground_journeys": int(os.environ["PLAYGROUND_JOURNEY_MS"]), - }, - "fresh_total_ms": int(os.environ["FRESH_TOTAL_MS"]), - "warm_rebuild_ms": int(os.environ["WARM_REBUILD_MS"]), - "playground_journey_ms": int(os.environ["PLAYGROUND_JOURNEY_MS"]), - "playground_evidence_files": [ - path.rsplit("/", 1)[-1] - for path in os.environ["PLAYGROUND_EVIDENCE_FILES"].split(":") - if path - ], - "run_started_epoch_ms": int(os.environ["RUN_STARTED_MS"]), - "completed_epoch_ms": int(os.environ["COMPLETED_MS"]), -} - -with open(os.environ["TIMING_OUTPUT"], "w", encoding="utf-8") as output: - json.dump(payload, output, indent=2, sort_keys=True) - output.write("\n") -PY - -cat "$timing_output" +printf 'Devcontainer qualification passed for %s in %ss.\n' "$platform" "$elapsed" diff --git a/scripts/ci/run-service-mode-dialog-visual.mjs b/scripts/ci/run-service-mode-dialog-visual.mjs deleted file mode 100644 index 9ac904b..0000000 --- a/scripts/ci/run-service-mode-dialog-visual.mjs +++ /dev/null @@ -1,144 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; - -function argumentValue(name, fallback = null) { - const index = process.argv.indexOf(name); - - return index === -1 ? fallback : process.argv[index + 1]; -} - -// Service mode intentionally omits the embedded-only labels and search-attribute -// inputs. Keep the released package's audit authoritative for geometry, focus, -// contrast, reachability, and checkbox states while rendering its existing -// validation-message surface for the service-mode Filters state. -const unsupportedMetadataFailure = ` } else { - throw new Error('The filter dialog has no structured metadata input for validation coverage.'); - } - - await page.locator('.waterline-dialog .swal2-confirm').click();`; -const serviceModeValidation = ` } else { - const validation = page.locator('.waterline-dialog .swal2-validation-message'); - await validation.evaluate((element) => { - element.textContent = 'The current filter value is not valid.'; - element.style.display = 'flex'; - }); - return; - } - - await page.locator('.waterline-dialog .swal2-confirm').click();`; -const embeddedFilterCategories = "requiredContrastCategories: ['title', 'label', 'help', 'notice', 'input', 'validation', 'action']"; -const serviceFilterCategories = "requiredContrastCategories: ['title', 'label', 'input', 'validation', 'action']"; -const openedDialogWait = ` await page.getByRole('dialog', { name: dialog.title, exact: true }).waitFor({ - state: 'visible', - timeout: 10_000, - }); - openedDialog = true;`; -const stableDialogWait = ` await page.getByRole('dialog', { name: dialog.title, exact: true }).waitFor({ - state: 'visible', - timeout: 10_000, - }); - await page.locator('.waterline-dialog').evaluate(async (popup) => { - const deadline = performance.now() + 5_000; - let previous = null; - let stableFrames = 0; - - while (performance.now() < deadline) { - await new Promise((resolve) => requestAnimationFrame(resolve)); - - const rect = popup.getBoundingClientRect(); - const style = getComputedStyle(popup); - const modalRoot = popup.closest('.swal2-container'); - const modalStyle = modalRoot ? getComputedStyle(modalRoot) : null; - const current = JSON.stringify([ - rect.left.toFixed(3), - rect.top.toFixed(3), - rect.right.toFixed(3), - rect.bottom.toFixed(3), - style.opacity, - style.transform, - modalStyle?.opacity, - modalStyle?.transform, - ]); - const animations = modalRoot?.getAnimations({ subtree: true }) - || popup.getAnimations({ subtree: true }); - const animationsSettled = animations.every( - (animation) => !['pending', 'running'].includes(animation.playState), - ); - - stableFrames = animationsSettled && current === previous - ? stableFrames + 1 - : 0; - previous = current; - - if (stableFrames >= 3) { - return; - } - } - - throw new Error('Dialog did not reach a stable opened layout before audit.'); - }); - openedDialog = true;`; -const geometryFailure = ` if (geometry.failures.length > 0) { - throw new Error(\`Dialog geometry failed: \${geometry.failures.join('; ')}\`); - } - - return geometry;`; -const retainedGeometry = ' return geometry;'; -const geometryAudit = ` geometry = await auditModalGeometry(page, dialog, viewport); - contrast = await auditContrast(page, dialog.requiredContrastCategories);`; -const retainedGeometryAudit = ` geometry = await auditModalGeometry(page, dialog, viewport); - - if (geometry.failures.length > 0) { - throw new Error(\`Dialog geometry failed: \${geometry.failures.join('; ')}\`); - } - - contrast = await auditContrast(page, dialog.requiredContrastCategories);`; - -export function adaptWaterlineDialogAudit(source) { - if ( - !source.includes(unsupportedMetadataFailure) - || !source.includes(embeddedFilterCategories) - || !source.includes(openedDialogWait) - || !source.includes(geometryFailure) - || !source.includes(geometryAudit) - ) { - throw new Error('The installed Waterline dialog audit has an unsupported validation contract.'); - } - - return source - .replace(unsupportedMetadataFailure, serviceModeValidation) - .replace(embeddedFilterCategories, serviceFilterCategories) - .replace(openedDialogWait, stableDialogWait) - .replace(geometryFailure, retainedGeometry) - .replace(geometryAudit, retainedGeometryAudit); -} - -export async function runServiceModeDialogVisual() { - const sourcePath = process.env.WATERLINE_DIALOG_AUDIT_PATH - || '/observer/vendor/durable-workflow/waterline/scripts/ci/workflow-list-dialog-visual.mjs'; - const source = fs.readFileSync(sourcePath, 'utf8'); - const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'waterline-service-dialog-')); - const auditPath = path.join(temporaryDirectory, 'workflow-list-dialog-visual.mjs'); - - try { - fs.writeFileSync(auditPath, adaptWaterlineDialogAudit(source)); - const audit = await import(pathToFileURL(auditPath).href); - - await audit.runWorkflowListDialogVisual({ - baseUrl: argumentValue('--base-url', process.env.APP_URL || 'http://127.0.0.1:8000'), - outputDirectory: path.resolve(argumentValue('--output-dir', process.env.OUTPUT_DIR || 'dialog-evidence')), - email: argumentValue('--email', process.env.WATERLINE_VISUAL_EMAIL || 'demo@example.com'), - password: argumentValue('--password', process.env.WATERLINE_VISUAL_PASSWORD || 'password'), - }); - } finally { - fs.rmSync(temporaryDirectory, { recursive: true, force: true }); - } -} - -const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; - -if (invokedPath === import.meta.url) { - await runServiceModeDialogVisual(); -} diff --git a/scripts/ci/summarize-devcontainer-evidence.py b/scripts/ci/summarize-devcontainer-evidence.py deleted file mode 100755 index a65172d..0000000 --- a/scripts/ci/summarize-devcontainer-evidence.py +++ /dev/null @@ -1,326 +0,0 @@ -#!/usr/bin/env python3 -"""Validate and combine public devcontainer workflow evidence.""" - -from __future__ import annotations - -import json -import sys -from collections import defaultdict -from pathlib import Path -from typing import Any - - -EXPECTED_ARCHITECTURES = { - "linux/amd64": ("amd64", "x86_64"), - "linux/arm64": ("arm64", "aarch64"), -} -EXPECTED_REGISTRIES = {"ghcr", "dockerhub"} - - -def fail(message: str) -> None: - raise SystemExit(message) - - -def load_evidence(directory: Path) -> list[dict[str, Any]]: - evidence: list[dict[str, Any]] = [] - for path in sorted(directory.glob("*.json")): - with path.open(encoding="utf-8") as source: - payload = json.load(source) - if not isinstance(payload, dict) or "evidence_type" not in payload: - fail(f"{path} is not a devcontainer evidence record") - payload["evidence_file"] = path.name - evidence.append(payload) - - if not evidence: - fail(f"no JSON evidence records found in {directory}") - - return evidence - - -def group_by_type(evidence: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: - grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) - for record in evidence: - grouped[str(record["evidence_type"])].append(record) - return grouped - - -def require_types( - grouped: dict[str, list[dict[str, Any]]], - expected_counts: dict[str, int], -) -> None: - actual_counts = {name: len(records) for name, records in grouped.items()} - if actual_counts != expected_counts: - fail(f"evidence type counts do not match: expected={expected_counts}, actual={actual_counts}") - - -def validate_native(record: dict[str, Any]) -> None: - platform = str(record.get("platform")) - if platform not in EXPECTED_ARCHITECTURES: - fail(f"unsupported evidence platform: {platform}") - - expected_architecture, expected_machine = EXPECTED_ARCHITECTURES[platform] - runner = record.get("runner", {}) - if not isinstance(runner, dict): - fail(f"runner evidence is missing for {platform}") - - if runner.get("docker_architecture") not in {expected_architecture, expected_machine}: - fail(f"Docker did not run natively for {platform}: {runner}") - if runner.get("host_machine") != expected_machine: - fail(f"host did not run natively for {platform}: {runner}") - if runner.get("host_architecture", expected_architecture) != expected_architecture: - fail(f"normalized host architecture does not match {platform}: {runner}") - - -def validate_playground(record: dict[str, Any], *, required: bool) -> None: - elapsed = record.get("playground_journey_ms") - files = record.get("playground_evidence_files") - if not required: - if elapsed != 0 or files != []: - fail(f"unexpected playground evidence in {record.get('evidence_file')}") - return - if not isinstance(elapsed, int) or elapsed <= 0: - fail(f"playground timing is missing in {record.get('evidence_file')}") - if not isinstance(files, list) or len(files) != 3: - fail(f"playground evidence set is incomplete in {record.get('evidence_file')}") - for language in ("php", "python", "rust"): - if not any( - isinstance(name, str) and name.endswith(f"-{language}.json") - for name in files - ): - fail( - f"playground evidence is missing {language} in {record.get('evidence_file')}" - ) - - -def validate_timings(evidence: list[dict[str, Any]]) -> tuple[int, int]: - starts: list[int] = [] - completions: list[int] = [] - for record in evidence: - started = record.get("run_started_epoch_ms") - completed = record.get("completed_epoch_ms") - stages = record.get("stages_ms") - if not isinstance(started, int) or not isinstance(completed, int) or completed < started: - fail(f"invalid evidence timestamps in {record.get('evidence_file')}") - if not isinstance(stages, dict) or not stages: - fail(f"stage timing is missing in {record.get('evidence_file')}") - if any(not isinstance(value, int) or value < 0 for value in stages.values()): - fail(f"invalid stage timing in {record.get('evidence_file')}: {stages}") - starts.append(started) - completions.append(completed) - - return min(starts), max(completions) - - -def validate_candidate(grouped: dict[str, list[dict[str, Any]]]) -> None: - require_types(grouped, {"candidate_qualification": 2}) - candidates = grouped["candidate_qualification"] - if {record.get("platform") for record in candidates} != set(EXPECTED_ARCHITECTURES): - fail("candidate evidence must contain one native record for each architecture") - for record in candidates: - validate_native(record) - validate_playground(record, required=True) - if record.get("anonymous_pull_verification", {}).get("required"): - fail("local pull-request candidates must not claim an anonymous public pull") - - -def validate_publication(grouped: dict[str, list[dict[str, Any]]]) -> None: - require_types( - grouped, - { - "architecture_publication": 2, - "index_assembly": 1, - "moving_channel_verification": 2, - "promotion": 1, - "public_qualification": 4, - }, - ) - - builds = grouped["architecture_publication"] - if {record.get("platform") for record in builds} != set(EXPECTED_ARCHITECTURES): - fail("publication evidence must contain one native build for each architecture") - for record in builds: - validate_native(record) - compressed_platform_bytes = record.get("compressed_platform_bytes") - if not isinstance(compressed_platform_bytes, int) or compressed_platform_bytes <= 0: - fail(f"compressed platform size is missing for {record.get('platform')}") - largest_compressed_layer_bytes = record.get("largest_compressed_layer_bytes") - if ( - not isinstance(largest_compressed_layer_bytes, int) - or largest_compressed_layer_bytes <= 0 - or largest_compressed_layer_bytes > compressed_platform_bytes - ): - fail( - f"largest compressed layer size is invalid for {record.get('platform')}" - ) - compressed_layer_count = record.get("compressed_layer_count") - if not isinstance(compressed_layer_count, int) or compressed_layer_count <= 0: - fail(f"compressed layer count is missing for {record.get('platform')}") - size_budget = record.get("compressed_size_budget", {}) - if not isinstance(size_budget, dict): - fail( - f"compressed image size budget is invalid for {record.get('platform')}" - ) - max_platform_bytes = size_budget.get("max_platform_bytes") - max_layer_bytes = size_budget.get("max_layer_bytes") - if ( - not isinstance(max_platform_bytes, int) - or not isinstance(max_layer_bytes, int) - or compressed_platform_bytes >= max_platform_bytes - or largest_compressed_layer_bytes >= max_layer_bytes - or size_budget.get("within_budget") is not True - ): - fail(f"compressed image size budget failed for {record.get('platform')}") - if record.get("manifest_digest_parity") is not True: - fail(f"architecture registry digest parity failed for {record.get('platform')}") - attestations = record.get("attestations", {}) - if attestations.get("provenance") != "mode=max" or attestations.get("sbom") is not True: - fail(f"attestation evidence is incomplete for {record.get('platform')}") - - index = grouped["index_assembly"][0] - if set(index.get("platforms", [])) != set(EXPECTED_ARCHITECTURES): - fail("assembled index does not contain the two supported platforms") - if index.get("manifest_digest_parity") is not True: - fail("GHCR and Docker Hub indexes do not have matching digests") - - qualifications = grouped["public_qualification"] - qualification_cells = { - (record.get("registry"), record.get("platform")) for record in qualifications - } - expected_cells = { - (registry, platform) - for registry in EXPECTED_REGISTRIES - for platform in EXPECTED_ARCHITECTURES - } - if qualification_cells != expected_cells: - fail(f"public qualification cells do not match: {qualification_cells}") - for record in qualifications: - validate_native(record) - anonymous = record.get("anonymous_pull_verification", {}) - if not all( - anonymous.get(key) is True - for key in ("required", "credentials_absent", "pull_performed") - ): - fail(f"anonymous pull verification failed for {record.get('registry')}/{record.get('platform')}") - playground_qualifications = [ - record - for record in qualifications - if isinstance(record.get("playground_journey_ms"), int) - and record["playground_journey_ms"] > 0 - ] - if len(playground_qualifications) != 1: - fail("public qualification must retain one authored playground evidence set") - for record in qualifications: - validate_playground(record, required=record in playground_qualifications) - - promotion = grouped["promotion"][0] - if set(promotion.get("qualification_gate", [])) != { - "ghcr/amd64", - "ghcr/arm64", - "dockerhub/amd64", - "dockerhub/arm64", - }: - fail("main promotion did not record every public qualification gate") - - moving_channels = grouped["moving_channel_verification"] - if {record.get("registry") for record in moving_channels} != EXPECTED_REGISTRIES: - fail("moving-channel evidence must inspect both public registries") - if any( - record.get("anonymous_manifest_inspection") is not True - or record.get("revision_and_main_digest_parity") is not True - for record in moving_channels - ): - fail("moving-channel anonymous manifest inspection failed") - if len({record.get("manifest_digest") for record in moving_channels}) != 1: - fail("GHCR and Docker Hub main manifests do not have matching digests") - - -def main() -> None: - if len(sys.argv) != 5: - fail( - "Usage: summarize-devcontainer-evidence.py " - "{candidate|publication} EVIDENCE_DIRECTORY OUTPUT MAX_SECONDS" - ) - - mode = sys.argv[1] - evidence_directory = Path(sys.argv[2]) - output_path = Path(sys.argv[3]) - max_seconds = int(sys.argv[4]) - evidence = load_evidence(evidence_directory) - grouped = group_by_type(evidence) - - if mode == "candidate": - validate_candidate(grouped) - elif mode == "publication": - validate_publication(grouped) - else: - fail(f"unsupported evidence mode: {mode}") - - started, completed = validate_timings(evidence) - elapsed_ms = completed - started - max_duration_ms = max_seconds * 1000 - summary = { - "schema_version": 1, - "mode": mode, - "run_started_epoch_ms": started, - "completed_epoch_ms": completed, - "elapsed_ms": elapsed_ms, - "max_duration_ms": max_duration_ms, - "within_duration_budget": elapsed_ms < max_duration_ms, - "runner_architectures": sorted( - { - f"{record['platform']}@{record['runner']['label']}" - for record in evidence - if "platform" in record and "runner" in record - } - ), - "manifest_digest_parity": ( - grouped["index_assembly"][0]["manifest_digest_parity"] - if mode == "publication" - else None - ), - "compressed_platform_bytes": ( - { - record["platform"]: record["compressed_platform_bytes"] - for record in grouped["architecture_publication"] - } - if mode == "publication" - else None - ), - "compressed_layer_profiles": ( - { - record["platform"]: { - "layer_count": record["compressed_layer_count"], - "largest_layer_bytes": record["largest_compressed_layer_bytes"], - "size_budget": record["compressed_size_budget"], - } - for record in grouped["architecture_publication"] - } - if mode == "publication" - else None - ), - "anonymous_pull_verification": ( - all( - record["anonymous_pull_verification"]["credentials_absent"] - and record["anonymous_pull_verification"]["pull_performed"] - for record in grouped["public_qualification"] - ) - if mode == "publication" - else None - ), - "evidence": evidence, - } - - with output_path.open("w", encoding="utf-8") as output: - json.dump(summary, output, indent=2, sort_keys=True) - output.write("\n") - print(json.dumps(summary, indent=2, sort_keys=True)) - - if elapsed_ms >= max_duration_ms: - fail( - f"cold {mode} workflow took {elapsed_ms}ms; " - f"limit is strictly less than {max_duration_ms}ms" - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/ci/test-workflow-routing.py b/scripts/ci/test-workflow-routing.py deleted file mode 100644 index 4c3ff83..0000000 --- a/scripts/ci/test-workflow-routing.py +++ /dev/null @@ -1,343 +0,0 @@ -#!/usr/bin/env python3 -"""Structural coverage for public and execution-mirror workflow routing.""" - -from __future__ import annotations - -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -WORKFLOWS = ROOT / ".github" / "workflows" -GITHUB_ONLY = "${{ github.server_url == 'https://github.com' }}" -MIRROR_ONLY = "${{ github.server_url != 'https://github.com' }}" -GITHUB_ALWAYS = "${{ always() && github.server_url == 'https://github.com' }}" - - -def read_workflow(name: str) -> str: - return (WORKFLOWS / name).read_text(encoding="utf-8") - - -def job_block(workflow: str, job_id: str) -> str: - lines = workflow.splitlines() - marker = f" {job_id}:" - - try: - start = lines.index(marker) - except ValueError as error: - raise AssertionError(f"workflow is missing job {job_id}") from error - - end = len(lines) - for index in range(start + 1, len(lines)): - line = lines[index] - if line.startswith(" ") and not line.startswith(" ") and line.endswith(":"): - end = index - break - - return "\n".join(lines[start:end]) - - -class WorkflowRoutingTest(unittest.TestCase): - def assert_main_source_triggers(self, workflow: str) -> None: - header = workflow.split("\njobs:", maxsplit=1)[0] - - self.assertIn(" push:\n branches: [ main ]", header) - self.assertIn(" pull_request:\n branches: [ main ]", header) - self.assertIn(" workflow_dispatch:", header) - self.assertNotIn("pull_request_target", header) - self.assertIn("permissions:\n contents: read", header) - - def assert_job_condition(self, workflow: str, job_id: str, condition: str) -> str: - block = job_block(workflow, job_id) - self.assertIn(f"\n if: {condition}\n", block) - - return block - - def test_github_keeps_every_authoritative_source_workload(self) -> None: - ci = read_workflow("ci.yml") - polyglot = read_workflow("polyglot-validation.yml") - compose = read_workflow("smoke.yml") - boundary = read_workflow("public-boundary.yml") - - for workflow in (ci, polyglot, compose): - self.assert_main_source_triggers(workflow) - - php = self.assert_job_condition(ci, "test", GITHUB_ONLY) - microservice = self.assert_job_condition(ci, "microservice-test", GITHUB_ONLY) - action_policy = self.assert_job_condition(ci, "action-policy", GITHUB_ONLY) - php_qualification = job_block(ci, "target-branch-qualification") - polyglot_matrix = self.assert_job_condition(polyglot, "smoke", GITHUB_ONLY) - polyglot_qualification = self.assert_job_condition( - polyglot, - "polyglot-qualification", - GITHUB_ALWAYS, - ) - compose_smoke = self.assert_job_condition(compose, "compose", GITHUB_ONLY) - public_boundary = self.assert_job_condition(boundary, "scan", GITHUB_ONLY) - - self.assertIn("php: ['8.4', '8.5']", php) - self.assertIn("hashFiles('composer.lock')", php) - self.assertNotIn("hashFiles('**/composer.lock')", php) - self.assertIn("run: php artisan test", php) - self.assertIn("run: npm run test:node", php) - self.assertLess( - php.index("composer install --prefer-dist --no-progress --no-interaction"), - php.index("npm run test:node"), - ) - self.assertIn( - "name: microservice composer and tests (php ${{ matrix.php }})", - microservice, - ) - self.assertIn("working-directory: microservice", microservice) - self.assertIn("php: ['8.4', '8.5']", microservice) - self.assertIn("MYSQL_DATABASE: microservice", microservice) - self.assertIn("DB_CONNECTION: mysql", microservice) - self.assertIn("SHARED_DB_DATABASE: microservice", microservice) - self.assertIn( - "composer validate --strict --check-lock --no-check-all", - microservice, - ) - self.assertIn( - "composer install --prefer-dist --no-progress --no-interaction", - microservice, - ) - self.assertIn("composer audit --locked", microservice) - self.assertIn("run: php artisan test", microservice) - self.assertNotIn("actions/cache", microservice) - self.assertIn("repository: durable-workflow/.github", action_policy) - self.assertIn("ref: main", action_policy) - self.assertIn("persist-credentials: false", action_policy) - self.assertIn("qualification_policy.py validate", action_policy) - self.assertIn("--target sample-app", action_policy) - self.assertIn("--workflow-directory .github/workflows", action_policy) - self.assertNotIn("secrets.", action_policy) - self.assertIn("name: Target branch qualification", php_qualification) - self.assertIn( - "needs: [test, microservice-test, action-policy]", php_qualification - ) - self.assertIn(f"if: {GITHUB_ONLY}", php_qualification) - self.assertIn('run: test "$TEST_RESULT" = success', php_qualification) - self.assertIn( - 'run: test "$MICROSERVICE_TEST_RESULT" = success', - php_qualification, - ) - self.assertIn( - 'run: test "$ACTION_POLICY_RESULT" = success', - php_qualification, - ) - - self.assertIn("cache_mode: [cold-cache, warm-cache]", polyglot_matrix) - self.assertIn("scripts/polyglot-validation.sh", polyglot_matrix) - self.assertIn("name: polyglot smoke (PHP/Python/Rust)", polyglot_qualification) - self.assertIn('run: test "$SMOKE_RESULT" = success', polyglot_qualification) - - self.assertIn("name: docker compose sample workflows", compose_smoke) - self.assertEqual(1, compose_smoke.count("scripts/compose-smoke.sh")) - self.assertIn("name: Scan public boundary", public_boundary) - self.assertEqual(1, public_boundary.count("scripts/check-public-boundary.sh")) - - def test_execution_mirror_selects_one_bounded_structural_job(self) -> None: - ci = read_workflow("ci.yml") - qualification = job_block(ci, "target-branch-qualification") - - self.assertIn("name: Target branch qualification", qualification) - self.assertIn("if: ${{ always() }}", qualification) - self.assertIn("timeout-minutes: 2", qualification) - self.assertEqual(3, qualification.count(f"if: {MIRROR_ONLY}")) - self.assertIn("python3 scripts/ci/test-workflow-routing.py", qualification) - self.assertIn("python3 -m unittest discover", qualification) - self.assertIn("git diff --check", qualification) - self.assertIn("bash -n", qualification) - self.assertIn("scripts/check-public-boundary.sh", qualification) - - for forbidden in ( - "actions/cache", - "composer install", - "docker compose", - "php artisan test", - "secrets.", - "setup-php", - ): - self.assertNotIn(forbidden, qualification) - - broad_jobs = ( - ("ci.yml", "test"), - ("ci.yml", "microservice-test"), - ("polyglot-validation.yml", "smoke"), - ("polyglot-validation.yml", "polyglot-qualification"), - ("smoke.yml", "compose"), - ("public-boundary.yml", "scan"), - ) - for workflow_name, job_id in broad_jobs: - block = job_block(read_workflow(workflow_name), job_id) - self.assertIn("github.server_url == 'https://github.com'", block) - - def test_untrusted_pull_requests_receive_no_privileged_candidate_path(self) -> None: - workflows = [ - read_workflow(name) - for name in ( - "ci.yml", - "polyglot-validation.yml", - "smoke.yml", - "public-boundary.yml", - ) - ] - joined = "\n".join(workflows) - candidate = job_block(workflows[0], "target-branch-qualification") - php = job_block(workflows[0], "test") - - self.assertNotIn("pull_request_target", joined) - self.assertNotIn("secrets.", joined) - self.assertIn("persist-credentials: false", candidate) - self.assertNotIn("actions/cache", candidate) - self.assertEqual(1, joined.count("uses: actions/cache@")) - self.assertIn("${{ github.event_name }}-", php) - - def test_devcontainer_publication_isolated_from_untrusted_validation(self) -> None: - candidate_workflow = read_workflow("devcontainer-image-pr.yml") - publication_workflow = read_workflow("devcontainer-image.yml") - combined_workflows = f"{candidate_workflow}\n{publication_workflow}" - candidate_header = candidate_workflow.split("\njobs:", maxsplit=1)[0] - publication_header = publication_workflow.split("\njobs:", maxsplit=1)[0] - artifact_identity = job_block(publication_workflow, "artifact-identity") - validate = job_block(candidate_workflow, "validate") - candidate_evidence = job_block(candidate_workflow, "candidate-evidence") - publish = job_block(publication_workflow, "publish-architecture") - assembly = job_block(publication_workflow, "assemble-indexes") - qualification = job_block(publication_workflow, "qualify-published") - promotion = job_block(publication_workflow, "promote-main") - recovery = job_block(publication_workflow, "recover-main") - moving_channel = job_block(publication_workflow, "verify-main") - publication_evidence = job_block(publication_workflow, "publication-evidence") - - matrix_runner = ( - "runs-on: ${{ github.server_url == 'https://github.com' " - "&& matrix.runner || 'ubuntu-latest' }}" - ) - aggregation_runner = ( - "runs-on: ${{ github.server_url == 'https://github.com' " - "&& 'ubuntu-24.04' || 'ubuntu-latest' }}" - ) - - self.assertIn(" pull_request:\n branches: [ main ]", candidate_header) - self.assertNotIn(" push:", candidate_header) - self.assertNotIn(" schedule:", candidate_header) - self.assertNotIn(" workflow_dispatch:", candidate_header) - self.assertIn("permissions:\n contents: read", candidate_header) - for header in (candidate_header, publication_header): - self.assertIn(".github/workflows/devcontainer-image.yml", header) - self.assertIn(".github/workflows/devcontainer-image-pr.yml", header) - self.assertNotIn(" pull_request:", publication_header) - self.assertIn(" push:\n branches: [ main ]", publication_header) - self.assertIn(" schedule:", publication_header) - self.assertIn(" workflow_dispatch:", publication_header) - self.assertIn("permissions:\n contents: read", publication_header) - self.assertIn("devcontainer-image-${{ github.event.pull_request.number }}", candidate_header) - self.assertIn("group: devcontainer-image-protected-main", publication_header) - self.assertNotIn("\n publish-architecture:", candidate_workflow) - self.assertNotIn("\n validate:", publication_workflow) - self.assertNotIn("pull_request_target", combined_workflows) - self.assertNotIn("setup-qemu-action", combined_workflows) - self.assertNotIn("QEMU", combined_workflows) - for forbidden in ( - "packages: write", - "secrets.", - "docker/login-action", - "cache-from", - "cache-to", - "environment:", - ): - self.assertNotIn(forbidden, candidate_workflow) - self.assertIn("github.event_name == 'pull_request'", validate) - self.assertIn("runner: ubuntu-24.04", validate) - self.assertIn("runner: ubuntu-24.04-arm", validate) - for block in (validate, publish, qualification): - self.assertIn(matrix_runner, block) - for block in ( - artifact_identity, - candidate_evidence, - assembly, - promotion, - recovery, - moving_channel, - publication_evidence, - ): - self.assertIn(aggregation_runner, block) - self.assertIn("contents: read", validate) - self.assertNotIn("packages: write", validate) - self.assertNotIn("secrets.", validate) - self.assertNotIn("docker/login-action", validate) - self.assertNotIn("needs:", validate) - self.assertNotIn("cache-from", validate) - self.assertNotIn("cache-to", validate) - self.assertIn("no-cache: true", validate) - self.assertIn("needs: [validate]", candidate_evidence) - self.assertIn("summarize-devcontainer-evidence.py", candidate_evidence) - - self.assertIn("github.event_name != 'pull_request'", artifact_identity) - self.assertIn("contents: read", artifact_identity) - self.assertNotIn("packages: write", artifact_identity) - self.assertNotIn("secrets.", artifact_identity) - self.assertNotIn("docker/login-action", artifact_identity) - self.assertIn( - 'revision_tag="sha-${GITHUB_SHA}-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"', - artifact_identity, - ) - - self.assertIn("github.repository == 'durable-workflow/sample-app'", publish) - self.assertIn("github.ref == 'refs/heads/main'", publish) - self.assertIn("github.event_name != 'pull_request'", publish) - self.assertIn("needs: [artifact-identity]", publish) - self.assertIn("runner: ubuntu-24.04", publish) - self.assertIn("runner: ubuntu-24.04-arm", publish) - self.assertIn("packages: write", publish) - self.assertIn("secrets.DOCKERHUB_TOKEN", publish) - self.assertIn("platforms: ${{ matrix.platform }}", publish) - self.assertIn("${{ env.REVISION_TAG }}-${{ matrix.suffix }}", publish) - self.assertIn("provenance: mode=max", publish) - self.assertIn("sbom: true", publish) - self.assertNotIn("cache-from", publish) - self.assertNotIn("cache-to", publish) - - self.assertIn("needs: [artifact-identity, publish-architecture]", assembly) - self.assertIn("github.ref == 'refs/heads/main'", assembly) - self.assertIn("imagetools create", assembly) - self.assertIn("cmp ghcr-index.json dockerhub-index.json", assembly) - self.assertIn("needs: [artifact-identity, assemble-indexes]", qualification) - self.assertIn("runner: ubuntu-24.04-arm", qualification) - self.assertIn("DEVCONTAINER_REQUIRE_ANONYMOUS_PULL: 1", qualification) - self.assertNotIn("secrets.", qualification) - self.assertNotIn("docker/login-action", qualification) - self.assertIn( - "needs: [artifact-identity, assemble-indexes, qualify-published]", - promotion, - ) - self.assertIn("github.ref == 'refs/heads/main'", promotion) - self.assertIn("inputs.recover_revision_tag != ''", recovery) - self.assertIn("github.repository == 'durable-workflow/sample-app'", recovery) - self.assertIn("github.ref == 'refs/heads/main'", recovery) - self.assertIn("packages: write", recovery) - self.assertIn("secrets.DOCKERHUB_TOKEN", recovery) - self.assertIn("^sha-[0-9a-f]{40}-run-[0-9]+-[0-9]+$", recovery) - self.assertIn("cmp ghcr-source.json dockerhub-source.json", recovery) - self.assertIn('architectures != {"amd64", "arm64"}', recovery) - self.assertIn("cmp ghcr-main.json dockerhub-main.json", recovery) - self.assertIn("needs: [artifact-identity, promote-main]", moving_channel) - self.assertIn("anonymous-docker-config", moving_channel) - self.assertIn("needs: [verify-main]", publication_evidence) - self.assertIn("summarize-devcontainer-evidence.py", publication_evidence) - - action_refs = [] - for line in combined_workflows.splitlines(): - stripped = line.strip() - if not stripped.startswith("uses: "): - continue - action_refs.append(stripped.split("@", maxsplit=1)[1].split()[0]) - - self.assertTrue(action_refs) - for ref in action_refs: - self.assertRegex(ref, r"^[0-9a-f]{40}$") - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/scripts/ci/validate-composer-artifact-graph.php b/scripts/ci/validate-composer-artifact-graph.php index cc06c7a..5133bd4 100644 --- a/scripts/ci/validate-composer-artifact-graph.php +++ b/scripts/ci/validate-composer-artifact-graph.php @@ -55,11 +55,8 @@ function readJsonObject(string $path): array if (! is_string($qualifiedVersion) || $qualifiedVersion === '') { throw new RuntimeException("qualified artifact tuple has no {$artifact} version"); } - if ($rootRequirement !== $qualifiedVersion) { - throw new RuntimeException( - "{$package} root requirement ".json_encode($rootRequirement) - ." does not match qualified {$artifact} {$qualifiedVersion}" - ); + if (! is_string($rootRequirement) || $rootRequirement === '') { + throw new RuntimeException("composer.json does not require {$package}"); } if ($lockedVersion !== $qualifiedVersion) { throw new RuntimeException( @@ -72,14 +69,6 @@ function readJsonObject(string $path): array } $waterlineSdkRequirement = $lockedPackages['durable-workflow/waterline']['require']['durable-workflow/sdk'] ?? null; - if ($waterlineSdkRequirement !== null && $waterlineSdkRequirement !== $expected['sdk-php']) { - throw new RuntimeException( - 'locked durable-workflow/waterline requires durable-workflow/sdk ' - .json_encode($waterlineSdkRequirement) - .", but the qualified PHP SDK is {$expected['sdk-php']}" - ); - } - $serverVersion = $expected['server'] ?? null; if (! is_string($serverVersion) || $serverVersion === '') { throw new RuntimeException('qualified artifact tuple has no server version'); diff --git a/scripts/ci/validate-service-mode-evidence.py b/scripts/ci/validate-service-mode-evidence.py deleted file mode 100755 index 5aca775..0000000 --- a/scripts/ci/validate-service-mode-evidence.py +++ /dev/null @@ -1,599 +0,0 @@ -#!/usr/bin/env python3 -"""Validate one or more service-mode onboarding timing records.""" - -from __future__ import annotations - -import json -import os -import re -import sys -from pathlib import Path -from typing import Any - - -VERSION = re.compile(r"^2\.0\.0-(?:beta|rc)\.\d+$") -REVISION = re.compile(r"^[0-9a-f]{40}$") -PUBLIC_COMPLETION_GATE = "https://github.com/durable-workflow/waterline/issues/79" -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -EXPECTED_DIALOG_CASES = { - ("filters", "desktop", 1440, 900), - ("filters", "intermediate", 900, 768), - ("filters", "mobile", 390, 844), - ("filters", "short-height", 1280, 480), - ("view-options", "desktop", 1440, 900), - ("view-options", "intermediate", 900, 768), - ("view-options", "mobile", 390, 844), - ("view-options", "short-height", 1280, 480), -} -RUN_DETAIL_VIEWPORTS = { - "desktop": (1440, 900), - "intermediate": (768, 1024), - "mobile": (390, 844), - "short-height": (1280, 360), -} -RUN_DETAIL_NAVIGATION_STATES = {"initial", "deep-section"} -RUN_DETAIL_PRESENTATIONS = {"embedded", "service"} -RUN_DETAIL_RESULTS = {"populated", "supported-empty", "unavailable", "degraded"} -RUN_DETAIL_STATES = { - (f"{presentation}-{result}-expanded", presentation, result, True) - for presentation in RUN_DETAIL_PRESENTATIONS - for result in RUN_DETAIL_RESULTS -} | { - (f"{presentation}-populated-collapsed", presentation, "populated", False) - for presentation in RUN_DETAIL_PRESENTATIONS -} -EXPECTED_RUN_DETAIL_CASES = { - (state, presentation, result, navigation, viewport, width, height) - for state, presentation, result, _expanded in RUN_DETAIL_STATES - for navigation in RUN_DETAIL_NAVIGATION_STATES - for viewport, (width, height) in RUN_DETAIL_VIEWPORTS.items() -} - - -def fail(message: str) -> None: - raise SystemExit(f"service-mode evidence: {message}") - - -def require_mapping(value: Any, label: str) -> dict[str, Any]: - if not isinstance(value, dict): - fail(f"{label} must be an object") - return value - - -def require_list(value: Any, label: str) -> list[Any]: - if not isinstance(value, list): - fail(f"{label} must be an array") - return value - - -def require_empty_list(value: Any, label: str) -> None: - if require_list(value, label) != []: - fail(f"{label} must be empty") - - -def require_number(value: Any, label: str) -> int | float: - if isinstance(value, bool) or not isinstance(value, (int, float)): - fail(f"{label} must be a number") - return value - - -def summary_path(evidence_path: Path, value: Any, label: str) -> Path: - if not isinstance(value, str) or Path(value).name != "summary.json": - fail(f"{evidence_path} does not identify responsive {label} evidence") - path = evidence_path.parent / value - if path.resolve().parent.parent != evidence_path.parent.resolve(): - fail(f"{evidence_path} responsive {label} evidence must be a sibling directory") - return path - - -def load_json_mapping(path: Path) -> dict[str, Any]: - with path.open(encoding="utf-8") as source: - return require_mapping(json.load(source), str(path)) - - -def locked_waterline_identity() -> tuple[str, str]: - lock = load_json_mapping(REPOSITORY_ROOT / "composer.lock") - packages = require_list(lock.get("packages"), "composer.lock packages") - - for package in packages: - package = require_mapping(package, "composer.lock package") - if package.get("name") != "durable-workflow/waterline": - continue - - source = require_mapping(package.get("source"), "locked Waterline source") - version = package.get("version") - reference = source.get("reference") - if ( - not isinstance(version, str) - or VERSION.fullmatch(version) is None - or not isinstance(reference, str) - or REVISION.fullmatch(reference) is None - ): - fail("composer.lock has an invalid Waterline identity") - - return version, reference - - fail("composer.lock does not contain durable-workflow/waterline") - - -def load(path: Path) -> dict[str, Any]: - with path.open(encoding="utf-8") as source: - payload = require_mapping(json.load(source), str(path)) - - if payload.get("schema") != "durable-workflow.sample-app.service-mode-evidence.v2": - fail(f"{path} has an unsupported schema") - for field in ( - "startup_ms", - "journey_ms", - "browser_ms", - "dialog_ms", - "run_detail_ms", - ): - if not isinstance(payload.get(field), int) or payload[field] <= 0: - fail(f"{path} {field} must be a positive integer") - - consumer = require_mapping(payload.get("consumer"), f"{path} consumer") - if consumer.get("repository") != "durable-workflow/sample-app": - fail(f"{path} has an invalid consumer repository") - revision = consumer.get("revision") - if not isinstance(revision, str) or REVISION.fullmatch(revision) is None: - fail(f"{path} does not identify an exact Sample App revision") - expected_revision = os.environ.get("GITHUB_SHA") - if expected_revision and revision != expected_revision: - fail(f"{path} revision does not match the protected workflow revision") - if payload.get("public_completion_gate") != PUBLIC_COMPLETION_GATE: - fail(f"{path} does not link the public completion gate") - ci = require_mapping(payload.get("ci"), f"{path} ci") - if os.environ.get("GITHUB_ACTIONS") == "true": - for field, environment_name in ( - ("event_name", "GITHUB_EVENT_NAME"), - ("ref", "GITHUB_REF"), - ("run_id", "GITHUB_RUN_ID"), - ("run_attempt", "GITHUB_RUN_ATTEMPT"), - ): - if ci.get(field) != os.environ.get(environment_name): - fail(f"{path} {field} does not match the GitHub Actions run") - - screenshot = payload.get("browser_screenshot") - if not isinstance(screenshot, str) or not screenshot.endswith("-waterline.png"): - fail(f"{path} has an invalid browser screenshot name") - if not path.with_name(screenshot).is_file(): - fail(f"{path} browser screenshot is missing") - - mount_evidence = payload.get("mount_evidence") - if not isinstance(mount_evidence, str) or not mount_evidence.endswith( - "-waterline-mount.json" - ): - fail(f"{path} has an invalid Waterline mount evidence name") - mount_evidence_path = path.with_name(mount_evidence) - with mount_evidence_path.open(encoding="utf-8") as source: - mount_summary = require_mapping(json.load(source), str(mount_evidence_path)) - if mount_summary.get("schema") != "durable-workflow.sample-app.waterline-mount.v1": - fail(f"{mount_evidence_path} has an unsupported schema") - if mount_summary.get("status") != "passed": - fail(f"{mount_evidence_path} did not observe a mounted Waterline page") - page = require_mapping(mount_summary.get("page"), f"{mount_evidence_path} page") - if page.get("mounted") is not True or page.get("body_text_length", 0) < 100: - fail(f"{mount_evidence_path} did not retain a nonblank mounted page") - list_request = require_mapping( - mount_summary.get("workflow_list_request"), - f"{mount_evidence_path} workflow_list_request", - ) - if list_request.get("status") != 200: - fail(f"{mount_evidence_path} did not complete the workflow-list request") - empty_state_requests = require_mapping( - mount_summary.get("empty_state_requests"), - f"{mount_evidence_path} empty_state_requests", - ) - saved_views = require_mapping( - empty_state_requests.get("saved_views"), - f"{mount_evidence_path} saved_views", - ) - if saved_views.get("status") != 200 or saved_views.get("custom_view_count") != 0: - fail(f"{mount_evidence_path} did not observe empty saved-view state") - preferences = require_mapping( - empty_state_requests.get("workflow_list_preferences"), - f"{mount_evidence_path} workflow_list_preferences", - ) - if preferences.get("status") != 200 or any( - preferences.get(field) != 0 - for field in ( - "stored_preference_count", - "effective_preference_count", - "override_count", - ) - ): - fail(f"{mount_evidence_path} did not observe empty workflow-list preferences") - for field in ("page_errors", "console_errors", "request_failures", "api_failures"): - if mount_summary.get(field) != []: - fail(f"{mount_evidence_path} recorded {field}") - - dialog_summary_path = summary_path(path, payload.get("dialog_evidence"), "dialog") - dialog_summary = load_json_mapping(dialog_summary_path) - if ( - dialog_summary.get("schema") - != "durable-workflow.waterline.dialog-visual-summary.v1" - ): - fail(f"{dialog_summary_path} has an unsupported schema") - if ( - dialog_summary.get("expectedCases") != 8 - or dialog_summary.get("observedCases") != 8 - or dialog_summary.get("passedCases") != 8 - or dialog_summary.get("failedCases") != 0 - ): - fail(f"{dialog_summary_path} did not pass all responsive dialog cases") - - observed_dialog_cases: set[tuple[str, str, int, int]] = set() - for case in require_list( - dialog_summary.get("cases"), f"{dialog_summary_path} cases" - ): - case = require_mapping(case, f"{dialog_summary_path} case") - viewport = require_mapping( - case.get("viewport"), f"{dialog_summary_path} viewport" - ) - key = ( - case.get("dialog"), - viewport.get("name"), - viewport.get("width"), - viewport.get("height"), - ) - observed_dialog_cases.add(key) - if case.get("status") != "passed" or case.get("failure") is not None: - fail(f"{dialog_summary_path} contains a failed dialog case") - screenshot_name = case.get("screenshot") - report_name = f"{case.get('dialog')}-{viewport.get('name')}.json" - for filename in (screenshot_name, report_name): - if not isinstance(filename, str) or Path(filename).name != filename: - fail(f"{dialog_summary_path} has an invalid case artifact name") - if not dialog_summary_path.with_name(filename).is_file(): - fail(f"{dialog_summary_path} case artifact {filename} is missing") - - report_path = dialog_summary_path.with_name(report_name) - report = load_json_mapping(report_path) - if ( - report.get("schema") != "durable-workflow.waterline.dialog-visual.v1" - or report.get("status") != "passed" - or report.get("failure") is not None - or report.get("dialog") != case.get("dialog") - or report.get("viewport") != viewport - or report.get("screenshot") != screenshot_name - or report.get("openedDialog") is not True - ): - fail(f"{report_path} does not retain a passing opened-dialog result") - for field in ("consoleErrors", "requestFailures", "errorResponses"): - require_empty_list(report.get(field), f"{report_path} {field}") - if not require_list(report.get("contrast"), f"{report_path} contrast"): - fail(f"{report_path} does not retain readable dialog content checks") - focus = require_list(report.get("focus"), f"{report_path} focus") - if len(focus) != 24: - fail(f"{report_path} does not retain the complete focus-trap audit") - geometry = require_mapping(report.get("geometry"), f"{report_path} geometry") - require_empty_list(geometry.get("failures"), f"{report_path} geometry.failures") - if ( - geometry.get("appRootInert") is not True - or geometry.get("backdropSemantics") != "intentional" - or geometry.get("dialogSemantics") != "modal" - or geometry.get("role") != "dialog" - or geometry.get("ariaModal") != "true" - or geometry.get("activeElementInside") is not True - ): - fail( - f"{report_path} does not retain dialog focus and inert-background semantics" - ) - controls = require_list(report.get("controls"), f"{report_path} controls") - if not controls: - fail(f"{report_path} does not retain visible form controls") - for control in controls: - control = require_mapping(control, f"{report_path} control") - if ( - control.get("clipped") is not False - or control.get("inViewport") is not True - or control.get("reachable") is not True - ): - fail(f"{report_path} contains a clipped or unreachable dialog control") - control_classes = " ".join( - str(control.get("className", "")) for control in controls - ) - if ( - "swal2-confirm" not in control_classes - or "swal2-cancel" not in control_classes - ): - fail(f"{report_path} does not retain reachable primary and cancel actions") - if case.get("dialog") == "view-options": - checkboxes = require_list( - report.get("checkboxes"), f"{report_path} checkboxes" - ) - if not any( - checkbox.get("checked") is True for checkbox in checkboxes - ) or not any(checkbox.get("checked") is False for checkbox in checkboxes): - fail(f"{report_path} does not retain checked and unchecked controls") - if observed_dialog_cases != EXPECTED_DIALOG_CASES: - fail( - f"{dialog_summary_path} does not cover the required dialog viewport matrix" - ) - - run_detail_summary_path = summary_path( - path, payload.get("run_detail_evidence"), "run-detail" - ) - run_detail_summary = load_json_mapping(run_detail_summary_path) - if ( - run_detail_summary.get("schema") - != "durable-workflow.waterline.run-detail-visual-summary.v1" - ): - fail(f"{run_detail_summary_path} has an unsupported schema") - expected_run_detail_count = len(EXPECTED_RUN_DETAIL_CASES) - if ( - run_detail_summary.get("expectedCases") != expected_run_detail_count - or run_detail_summary.get("observedCases") != expected_run_detail_count - or run_detail_summary.get("passedCases") != expected_run_detail_count - or run_detail_summary.get("failedCases") != 0 - ): - fail(f"{run_detail_summary_path} did not pass all responsive run-detail cases") - - base_urls = require_mapping( - run_detail_summary.get("baseUrls"), f"{run_detail_summary_path} baseUrls" - ) - for presentation in RUN_DETAIL_PRESENTATIONS: - base_url = base_urls.get(presentation) - if not isinstance(base_url, str) or not base_url.startswith("http://"): - fail( - f"{run_detail_summary_path} does not identify the {presentation} presentation" - ) - if ( - base_urls["embedded"] == base_urls["service"] - or run_detail_summary.get("baseUrl") != base_urls["embedded"] - ): - fail(f"{run_detail_summary_path} does not retain distinct presentation URLs") - - observed_run_detail_cases: set[tuple[str, str, str, str, str, int, int]] = set() - for case in require_list( - run_detail_summary.get("cases"), f"{run_detail_summary_path} cases" - ): - case = require_mapping(case, f"{run_detail_summary_path} case") - viewport = require_mapping( - case.get("viewport"), f"{run_detail_summary_path} viewport" - ) - key = ( - case.get("state"), - case.get("presentation"), - case.get("result"), - case.get("navigation"), - viewport.get("name"), - viewport.get("width"), - viewport.get("height"), - ) - observed_run_detail_cases.add(key) - if key not in EXPECTED_RUN_DETAIL_CASES: - fail(f"{run_detail_summary_path} contains an unexpected run-detail case") - if case.get("status") != "passed" or case.get("failure") is not None: - fail(f"{run_detail_summary_path} contains a failed run-detail case") - expected_stream_state = f"{case.get('presentation')}-{case.get('result')}" - if case.get("streamState") != expected_stream_state: - fail(f"{run_detail_summary_path} contains an invalid stream fixture") - screenshot_name = case.get("screenshot") - report_name = ( - f"{case.get('state')}-{case.get('navigation')}-" - f"{viewport.get('name')}.json" - ) - for filename in (screenshot_name, report_name): - if not isinstance(filename, str) or Path(filename).name != filename: - fail(f"{run_detail_summary_path} has an invalid case artifact name") - if not run_detail_summary_path.with_name(filename).is_file(): - fail(f"{run_detail_summary_path} case artifact {filename} is missing") - - report_path = run_detail_summary_path.with_name(report_name) - report = load_json_mapping(report_path) - if ( - report.get("schema") != "durable-workflow.waterline.run-detail-visual.v1" - or report.get("surface") != "run-detail" - or report.get("status") != "passed" - or report.get("failure") is not None - or report.get("state") != case.get("state") - or report.get("streamState") != case.get("streamState") - or report.get("presentation") != case.get("presentation") - or report.get("result") != case.get("result") - or report.get("navigation") != case.get("navigation") - or report.get("viewport") != viewport - or report.get("screenshot") != screenshot_name - ): - fail(f"{report_path} does not retain a passing run-detail result") - bootstrap = require_mapping(report.get("bootstrap"), f"{report_path} bootstrap") - expected_bootstrap = { - "embedded": { - "mode": "embedded", - "label": "Embedded Laravel", - "transport": "workflow-package", - }, - "service": { - "mode": "service", - "label": "Standalone service", - "transport": "durable-workflow/sdk", - }, - }[case["presentation"]] - if any( - bootstrap.get(field) != value - for field, value in expected_bootstrap.items() - ): - fail(f"{report_path} does not retain the expected presentation identity") - for field in ("browserErrors", "requestFailures", "errorResponses"): - require_empty_list(report.get(field), f"{report_path} {field}") - if not require_list(report.get("contrast"), f"{report_path} contrast"): - fail(f"{report_path} does not retain readable run-detail content checks") - disclosure = require_mapping( - report.get("disclosure"), f"{report_path} disclosure" - ) - expanded = case.get("state", "").endswith("-expanded") - expected_disclosure = { - "text": "Collapse Workflow Streams" - if expanded - else "Expand Workflow Streams", - "ariaExpanded": "true" if expanded else "false", - "regionVisible": expanded, - } - if disclosure != expected_disclosure: - fail(f"{report_path} has an invalid Workflow Streams disclosure state") - geometry = require_mapping(report.get("geometry"), f"{report_path} geometry") - for field in ( - "failures", - "unreachable_controls", - "clipped_controls", - "overlapping_floating_elements", - ): - require_empty_list(geometry.get(field), f"{report_path} geometry.{field}") - document = require_mapping( - geometry.get("document"), f"{report_path} geometry.document" - ) - document_scroll_width = require_number( - document.get("scrollWidth"), f"{report_path} document scroll width" - ) - if document_scroll_width > viewport["width"] + 1: - fail(f"{report_path} retains horizontal document overflow") - - simultaneous_navigation = require_mapping( - geometry.get("simultaneous_navigation"), - f"{report_path} geometry.simultaneous_navigation", - ) - sidebar = require_mapping( - simultaneous_navigation.get("sidebar"), - f"{report_path} simultaneous navigation sidebar", - ) - sidebar_scroll_left = require_number( - sidebar.get("scrollLeft"), f"{report_path} navigation scroll position" - ) - if sidebar_scroll_left > 1: - fail(f"{report_path} retains a horizontally scrolled initial navigation") - navigation_links = [ - require_mapping(link, f"{report_path} simultaneous navigation link") - for link in require_list( - simultaneous_navigation.get("links"), - f"{report_path} simultaneous navigation links", - ) - ] - for label in ("Dashboard", "Workers"): - matches = [link for link in navigation_links if link.get("name") == label] - if len(matches) != 1 or matches[0].get("inViewport") is not True: - fail(f"{report_path} does not retain a visible {label} navigation link") - - simultaneous_topbar = require_mapping( - geometry.get("simultaneous_topbar"), - f"{report_path} geometry.simultaneous_topbar", - ) - topbar_actions = require_mapping( - simultaneous_topbar.get("actions"), - f"{report_path} simultaneous top-bar actions", - ) - actions_scroll_left = require_number( - topbar_actions.get("scrollLeft"), f"{report_path} action scroll position" - ) - actions_scroll_width = require_number( - topbar_actions.get("scrollWidth"), f"{report_path} action scroll width" - ) - actions_client_width = require_number( - topbar_actions.get("clientWidth"), f"{report_path} action client width" - ) - if actions_scroll_left > 1 or actions_scroll_width > actions_client_width + 1: - fail(f"{report_path} retains overflowing persistent actions") - topbar_items = [ - require_mapping(item, f"{report_path} simultaneous top-bar item") - for item in require_list( - simultaneous_topbar.get("items"), - f"{report_path} simultaneous top-bar items", - ) - ] - for label in ("Scope", "Backend", "Auto refresh", "Theme"): - matches = [item for item in topbar_items if item.get("name") == label] - if ( - len(matches) != 1 - or matches[0].get("inViewport") is not True - or matches[0].get("inTopbar") is not True - or matches[0].get("clipped") is not False - ): - fail(f"{report_path} does not retain a visible {label} top-bar item") - controls = require_list(report.get("controls"), f"{report_path} controls") - if not controls: - fail(f"{report_path} does not retain run-detail control reachability") - for control in controls: - control = require_mapping(control, f"{report_path} control") - if ( - control.get("clipped") is not False - or control.get("coveredByChrome") is not False - or control.get("inViewport") is not True - or control.get("reachable") is not True - ): - fail( - f"{report_path} contains a clipped or unreachable run-detail control" - ) - if observed_run_detail_cases != EXPECTED_RUN_DETAIL_CASES: - fail( - f"{run_detail_summary_path} does not cover the required run-detail viewport matrix" - ) - - workflow = require_mapping(payload.get("workflow"), f"{path} workflow") - workflow_id = workflow.get("workflow_id") - if not isinstance(workflow_id, str) or not workflow_id.startswith( - "service-welcome-" - ): - fail(f"{path} has an invalid workflow ID") - run_id = workflow.get("run_id") - if not isinstance(run_id, str) or not run_id: - fail(f"{path} does not identify the completed workflow run") - result = require_mapping(workflow.get("result"), f"{path} workflow.result") - php = require_mapping(result.get("php_activity"), f"{path} PHP activity") - python = require_mapping(result.get("python_activity"), f"{path} Python activity") - if php.get("runtime") != "php" or python.get("runtime") != "python": - fail(f"{path} did not retain both runtime results") - waterline_url = workflow.get("waterline_url") - if ( - not isinstance(waterline_url, str) - or workflow_id not in waterline_url - or run_id not in waterline_url - ): - fail(f"{path} does not link to its workflow in Waterline") - - artifacts = require_mapping(payload.get("artifacts"), f"{path} artifacts") - server = artifacts.get("server") - if not isinstance(server, str) or not server.startswith("durableworkflow/server:"): - fail(f"{path} has an invalid Server image") - for name in ("sdk_php", "sdk_python", "workflow", "waterline"): - version = artifacts.get(name) - if not isinstance(version, str) or VERSION.fullmatch(version) is None: - fail(f"{path} has an invalid {name} version") - - installed = require_mapping(payload.get("installed"), f"{path} installed") - installed_waterline = require_mapping( - installed.get("waterline"), f"{path} installed.waterline" - ) - locked_version, locked_reference = locked_waterline_identity() - if ( - installed_waterline.get("package") != "durable-workflow/waterline" - or artifacts.get("waterline") != locked_version - or installed_waterline.get("version") != locked_version - or installed_waterline.get("reference") != locked_reference - ): - fail(f"{path} does not bind the exact installed Waterline package") - - return payload - - -def main() -> None: - if len(sys.argv) < 2: - fail("pass at least one evidence JSON path") - records = [load(Path(value)) for value in sys.argv[1:]] - workflow_ids = [record["workflow"]["workflow_id"] for record in records] - if len(workflow_ids) != len(set(workflow_ids)): - fail("repeated runs reused a workflow ID") - revisions = {record["consumer"]["revision"] for record in records} - installations = { - ( - record["installed"]["waterline"]["version"], - record["installed"]["waterline"]["reference"], - ) - for record in records - } - if len(revisions) != 1 or len(installations) != 1: - fail("repeated runs did not retain one Sample App and Waterline identity") - print(f"Validated {len(records)} service-mode onboarding run(s).") - - -if __name__ == "__main__": - main() diff --git a/scripts/ci/waterline-mount-readiness.mjs b/scripts/ci/waterline-mount-readiness.mjs deleted file mode 100644 index 9bc24b4..0000000 --- a/scripts/ci/waterline-mount-readiness.mjs +++ /dev/null @@ -1,245 +0,0 @@ -import { createRequire } from 'node:module'; -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -import { jsonContainerEntryCount } from './json-empty-state.mjs'; - -function argumentValue(name, fallback = null) { - const index = process.argv.indexOf(name); - - return index === -1 ? fallback : process.argv[index + 1]; -} - -function loadPlaywright() { - const roots = [process.cwd()]; - - try { - roots.push(execFileSync('npm', ['root', '--global'], { encoding: 'utf8' }).trim()); - } catch { - // The local resolution below reports the actionable module error. - } - - let lastError = null; - - for (const root of roots.filter(Boolean)) { - try { - const require = createRequire(path.join(root, 'package.json')); - - return require('playwright'); - } catch (error) { - lastError = error; - } - } - - throw lastError; -} - -const baseUrl = argumentValue('--base-url', process.env.APP_URL || 'http://127.0.0.1:8000'); -const screenshotPath = path.resolve(argumentValue('--screenshot', 'waterline-mounted.png')); -const reportPath = path.resolve(argumentValue('--report', 'waterline-mount.json')); -const launchOptions = { args: ['--no-sandbox'] }; - -if (process.env.CHROMIUM_EXECUTABLE_PATH) { - launchOptions.executablePath = process.env.CHROMIUM_EXECUTABLE_PATH; -} - -fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); -fs.mkdirSync(path.dirname(reportPath), { recursive: true }); - -const browser = await loadPlaywright().chromium.launch(launchOptions); -const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, - deviceScaleFactor: 1, -}); -await context.addInitScript(() => localStorage.setItem('waterline-theme', 'dark')); -const page = await context.newPage(); -const consoleErrors = []; -const pageErrors = []; -const requestFailures = []; -const relevantResponses = []; -const apiFailures = []; -let emptyStateRequests = null; -let failure = null; - -page.on('console', (message) => { - if (message.type() === 'error') { - consoleErrors.push({ text: message.text(), location: message.location() }); - } -}); -page.on('pageerror', (error) => { - pageErrors.push({ name: error.name, message: error.message, stack: error.stack || null }); -}); -page.on('requestfailed', (request) => { - requestFailures.push({ - method: request.method(), - resource_type: request.resourceType(), - url: request.url(), - error: request.failure()?.errorText || 'unknown request failure', - }); -}); -page.on('response', (response) => { - const url = response.url(); - const summary = { - status: response.status(), - resource_type: response.request().resourceType(), - url, - }; - - if (url.includes('/vendor/waterline/') || url.includes('/waterline/api/')) { - relevantResponses.push(summary); - } - if (url.includes('/waterline/api/') && response.status() >= 400) { - apiFailures.push(summary); - } -}); - -const expectedApiResponses = Promise.all([ - page.waitForResponse((response) => ( - response.request().method() === 'GET' - && new URL(response.url()).pathname === '/waterline/api/saved-views' - ), { timeout: 30_000 }), - page.waitForResponse((response) => ( - response.request().method() === 'GET' - && new URL(response.url()).pathname === '/waterline/api/preferences/workflow-list' - ), { timeout: 30_000 }), - page.waitForResponse((response) => ( - response.request().method() === 'GET' - && new URL(response.url()).pathname === '/waterline/api/flows/completed' - ), { timeout: 30_000 }), -]); - -try { - await page.goto(new URL('/waterline/completed', baseUrl).href, { - waitUntil: 'domcontentloaded', - timeout: 30_000, - }); - await page.waitForFunction(() => ( - document.getElementById('waterline')?.getAttribute('data-waterline-mounted') === 'true' - ), undefined, { timeout: 20_000 }); - await page.getByRole('button', { name: 'View Options', exact: true }).waitFor({ - state: 'visible', - timeout: 20_000, - }); - - const [savedViewsResponse, preferencesResponse, workflowListResponse] = await expectedApiResponses; - const expectedResponses = [savedViewsResponse, preferencesResponse, workflowListResponse]; - if (expectedResponses.some((response) => response.status() !== 200)) { - throw new Error('The mounted page did not complete its empty-state API requests successfully.'); - } - - const [savedViews, preferences] = await Promise.all([ - savedViewsResponse.json(), - preferencesResponse.json(), - ]); - const customSavedViews = Array.isArray(savedViews.data) - ? savedViews.data.filter((view) => view?.system !== true) - : null; - const storedPreferences = preferences?.preferences; - const effectivePreferences = preferences?.effective_preferences; - const preferenceOverrides = preferences?.overrides; - const storedPreferenceCount = jsonContainerEntryCount(storedPreferences); - const effectivePreferenceCount = jsonContainerEntryCount(effectivePreferences); - const preferenceOverrideCount = jsonContainerEntryCount(preferenceOverrides); - if ( - customSavedViews === null - || customSavedViews.length !== 0 - || storedPreferenceCount !== 0 - || effectivePreferenceCount !== 0 - || preferenceOverrideCount !== 0 - ) { - throw new Error('The mounted page did not receive fresh saved-view and preference state.'); - } - - emptyStateRequests = { - saved_views: { - status: savedViewsResponse.status(), - custom_view_count: customSavedViews.length, - }, - workflow_list_preferences: { - status: preferencesResponse.status(), - stored_preference_count: storedPreferenceCount, - effective_preference_count: effectivePreferenceCount, - override_count: preferenceOverrideCount, - }, - }; - await page.waitForTimeout(500); - - if (pageErrors.length || consoleErrors.length || requestFailures.length || apiFailures.length) { - throw new Error('The mounted page emitted browser errors.'); - } -} catch (error) { - expectedApiResponses.catch(() => {}); - failure = { - name: error instanceof Error ? error.name : 'Error', - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : null, - }; -} - -const pageState = await page.evaluate(() => { - const mount = document.getElementById('waterline'); - const bodyText = document.body?.innerText || ''; - - return { - title: document.title, - url: window.location.href, - mount_present: mount !== null, - mounted: mount?.getAttribute('data-waterline-mounted') === 'true', - mount_child_count: mount?.childElementCount || 0, - body_text_length: bodyText.trim().length, - body_text_excerpt: bodyText.trim().slice(0, 500), - }; -}).catch(() => ({ - title: null, - url: page.url(), - mount_present: false, - mounted: false, - mount_child_count: 0, - body_text_length: 0, - body_text_excerpt: '', -})); - -if (!failure && (!pageState.mounted || pageState.mount_child_count === 0 || pageState.body_text_length < 100)) { - failure = { - name: 'WaterlineMountError', - message: 'Waterline mounted without a nonblank operator surface.', - stack: null, - }; -} - -await page.screenshot({ path: screenshotPath, fullPage: false }).catch((error) => { - failure ||= { - name: 'ScreenshotError', - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : null, - }; -}); - -const workflowListRequest = relevantResponses.findLast((response) => ( - response.url.includes('/waterline/api/flows/completed') -)) || null; -const report = { - schema: 'durable-workflow.sample-app.waterline-mount.v1', - status: failure ? 'failed' : 'passed', - page: pageState, - workflow_list_request: workflowListRequest, - empty_state_requests: emptyStateRequests, - relevant_responses: relevantResponses, - api_failures: apiFailures, - page_errors: pageErrors, - console_errors: consoleErrors, - request_failures: requestFailures, - screenshot: path.basename(screenshotPath), - failure, -}; - -fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); -await context.close(); -await browser.close(); - -if (failure) { - throw new Error(`Waterline mount readiness failed: ${JSON.stringify(report)}`); -} - -console.log(`WATERLINE_MOUNT PASS ${pageState.title} (${pageState.body_text_length} visible characters)`); diff --git a/scripts/compose-conformance.sh b/scripts/compose-conformance.sh deleted file mode 100755 index 5470d52..0000000 --- a/scripts/compose-conformance.sh +++ /dev/null @@ -1,603 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -compose_diagnostics() { - local context="$1" - local lines="${SAMPLE_APP_DIAGNOSTIC_LOG_LINES:-160}" - - printf '\ncompose-conformance: diagnostics after %s\n' "$context" >&2 - docker compose ps >&2 || true - docker compose logs --no-color --timestamps --tail="$lines" app worker mysql redis >&2 || true -} - -run_step() { - local name="$1" - local timeout_seconds="$2" - shift 2 - - printf '\n==> %s\n' "$name" - - set +e - timeout "${timeout_seconds}s" "$@" - local status=$? - set -e - - if [[ "$status" -ne 0 ]]; then - if [[ "$status" -eq 124 ]]; then - printf 'compose-conformance: %s timed out after %ss\n' "$name" "$timeout_seconds" >&2 - else - printf 'compose-conformance: %s exited with status %d\n' "$name" "$status" >&2 - fi - - compose_diagnostics "$name" - - return "$status" - fi -} - -now_milliseconds() { - date +%s%3N -} - -docker_storage_usage_bytes() { - local docker_root - - docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)" - if [[ -n "$docker_root" && -d "$docker_root" ]]; then - df -Pk "$docker_root" | awk 'NR == 2 { printf "%.0f\n", $3 * 1024 }' - return 0 - fi - - docker system df --format '{{.Size}}' 2>/dev/null | awk ' - function size_in_bytes(value, number, unit) { - number = value - sub(/[[:alpha:]]+$/, "", number) - unit = value - sub(/^[0-9.]+/, "", unit) - - if (unit == "kB") return number * 1000 - if (unit == "MB") return number * 1000 * 1000 - if (unit == "GB") return number * 1000 * 1000 * 1000 - if (unit == "TB") return number * 1000 * 1000 * 1000 * 1000 - if (unit == "KiB") return number * 1024 - if (unit == "MiB") return number * 1024 * 1024 - if (unit == "GiB") return number * 1024 * 1024 * 1024 - if (unit == "TiB") return number * 1024 * 1024 * 1024 * 1024 - - return number - } - - { total += size_in_bytes($1) } - END { printf "%.0f\n", total } - ' -} - -cleanup_setup_sampler() { - if [[ -n "${setup_disk_sampler_pid:-}" ]]; then - kill "$setup_disk_sampler_pid" 2>/dev/null || true - wait "$setup_disk_sampler_pid" 2>/dev/null || true - setup_disk_sampler_pid="" - fi - - if [[ -n "${setup_disk_samples_path:-}" ]]; then - rm -f "$setup_disk_samples_path" - setup_disk_samples_path="" - fi -} - -start_setup_measurement() { - setup_started_ms="$(now_milliseconds)" - setup_cache_state="${SAMPLE_APP_SETUP_CACHE_STATE:-}" - setup_disk_samples_path="" - setup_disk_sampler_pid="" - - if [[ -z "$setup_cache_state" ]]; then - if [[ -n "$(docker compose images -q app 2>/dev/null | head -n 1)" ]]; then - setup_cache_state="warm-cache" - else - setup_cache_state="clean-cache" - fi - fi - - local baseline - baseline="$(docker_storage_usage_bytes || true)" - if [[ ! "$baseline" =~ ^[0-9]+$ ]]; then - printf 'compose-conformance: Docker disk usage is unavailable; peak growth will be recorded as unavailable\n' >&2 - return 0 - fi - - setup_disk_samples_path="$(mktemp "${TMPDIR:-/tmp}/sample-app-setup-disk.XXXXXX")" - printf '%s\n' "$baseline" > "$setup_disk_samples_path" - - ( - while true; do - sleep "${SAMPLE_APP_SETUP_DISK_SAMPLE_INTERVAL_SECONDS:-5}" - docker_storage_usage_bytes >> "$setup_disk_samples_path" 2>/dev/null || true - done - ) & - setup_disk_sampler_pid=$! -} - -finish_setup_measurement() { - local completed_ms - local current - local peak_growth="" - - completed_ms="$(now_milliseconds)" - SAMPLE_APP_SETUP_DURATION_MS="$((completed_ms - setup_started_ms))" - - if [[ -n "${setup_disk_sampler_pid:-}" ]]; then - kill "$setup_disk_sampler_pid" 2>/dev/null || true - wait "$setup_disk_sampler_pid" 2>/dev/null || true - setup_disk_sampler_pid="" - fi - - if [[ -n "${setup_disk_samples_path:-}" ]]; then - current="$(docker_storage_usage_bytes || true)" - if [[ "$current" =~ ^[0-9]+$ ]]; then - printf '%s\n' "$current" >> "$setup_disk_samples_path" - fi - - peak_growth="$(awk ' - NR == 1 { baseline = $1; peak = $1 } - $1 > peak { peak = $1 } - END { - growth = peak - baseline - if (growth < 0) growth = 0 - printf "%.0f\n", growth - } - ' "$setup_disk_samples_path")" - fi - - SAMPLE_APP_SETUP_CACHE_STATE="$setup_cache_state" - SAMPLE_APP_SETUP_PEAK_DISK_GROWTH_BYTES="$peak_growth" - export SAMPLE_APP_SETUP_CACHE_STATE - export SAMPLE_APP_SETUP_DURATION_MS - export SAMPLE_APP_SETUP_BUILD_DURATION_MS - export SAMPLE_APP_SETUP_READINESS_DURATION_MS - export SAMPLE_APP_SETUP_PEAK_DISK_GROWTH_BYTES - export SAMPLE_APP_SETUP_STACK_REUSED - export SAMPLE_APP_SETUP_BUILD_INVOCATIONS - - printf 'compose-conformance: setup metrics cache_state=%s duration_ms=%s build_duration_ms=%s readiness_duration_ms=%s peak_disk_growth_bytes=%s stack_reused=%s build_invocations=%s\n' \ - "$SAMPLE_APP_SETUP_CACHE_STATE" \ - "$SAMPLE_APP_SETUP_DURATION_MS" \ - "$SAMPLE_APP_SETUP_BUILD_DURATION_MS" \ - "$SAMPLE_APP_SETUP_READINESS_DURATION_MS" \ - "${SAMPLE_APP_SETUP_PEAK_DISK_GROWTH_BYTES:-unavailable}" \ - "$SAMPLE_APP_SETUP_STACK_REUSED" \ - "$SAMPLE_APP_SETUP_BUILD_INVOCATIONS" - - cleanup_setup_sampler -} - -wait_for_db() { - local attempt - local probe_timeout_seconds="${SAMPLE_APP_DB_PROBE_TIMEOUT_SECONDS:-10}" - - for attempt in $(seq 1 60); do - if timeout "${probe_timeout_seconds}s" docker compose exec -T app php -r ' - $dsn = sprintf( - "mysql:host=%s;port=%s;dbname=%s", - getenv("DB_HOST") ?: "mysql", - getenv("DB_PORT") ?: "3306", - getenv("DB_DATABASE") ?: "sample" - ); - try { - new PDO($dsn, getenv("DB_USERNAME") ?: "laravel", getenv("DB_PASSWORD") ?: "password", [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_TIMEOUT => 2, - ]); - exit(0); - } catch (Throwable $e) { - fwrite(STDERR, $e->getMessage() . "\n"); - exit(1); - } - ' >/dev/null 2>&1; then - printf 'compose-conformance: database reachable from app after %d attempt(s)\n' "$attempt" - return 0 - fi - sleep 2 - done - - printf 'compose-conformance: database never became reachable from app within 120s\n' >&2 - compose_diagnostics "database readiness" - return 1 -} - -resolve_artifacts() { - local assignment - - while IFS= read -r assignment; do - export "$assignment" - printf 'compose-conformance: %s\n' "$assignment" - done < <(scripts/resolve-current-artifacts.sh) -} - -load_env_value() { - local name="$1" - local file="$2" - local line - local value - - if [[ -n "${!name:-}" || ! -f "$file" ]]; then - return 0 - fi - - line="$(grep -E "^[[:space:]]*${name}=" "$file" | tail -n 1 || true)" - if [[ -z "$line" ]]; then - return 0 - fi - - value="${line#*=}" - value="${value%$'\r'}" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - - if [[ "$value" == \"*\" && "$value" == *\" ]]; then - value="${value:1:${#value}-2}" - elif [[ "$value" == \'*\' && "$value" == *\' ]]; then - value="${value:1:${#value}-2}" - fi - - if [[ -n "$value" ]]; then - export "$name=$value" - printf 'compose-conformance: loaded %s from env file\n' "$name" - fi -} - -load_conformance_env() { - local configured="${SAMPLE_APP_CONFORMANCE_ENV_FILE:-}" - local file - local dir - local candidates=() - - if [[ -n "$configured" ]]; then - candidates+=("$configured") - fi - - dir="$PWD" - while [[ -n "$dir" && "$dir" != "/" ]]; do - candidates+=("$dir/.env") - dir="$(dirname "$dir")" - done - candidates+=("/.env") - - for file in "${candidates[@]}"; do - load_env_value OPENAI_API_KEY "$file" - done -} - -configure_conformance() { - local skip_ai="${SAMPLE_APP_CONFORMANCE_SKIP_AI:-1}" - local allow_skips="${SAMPLE_APP_CONFORMANCE_ALLOW_SKIPS:-}" - local argument - local strict_requested="0" - local allow_skips_requested="0" - local forwarded_args=() - - case "$skip_ai" in - 0|1) - ;; - *) - printf 'compose-conformance: SAMPLE_APP_CONFORMANCE_SKIP_AI must be 0 or 1\n' >&2 - return 2 - ;; - esac - - case "$allow_skips" in - ""|0|1) - ;; - *) - printf 'compose-conformance: SAMPLE_APP_CONFORMANCE_ALLOW_SKIPS must be 0 or 1\n' >&2 - return 2 - ;; - esac - - for argument in "$@"; do - case "$argument" in - --skip-ai) - skip_ai="1" - ;; - --strict) - strict_requested="1" - ;; - --allow-skips) - allow_skips_requested="1" - ;; - *) - forwarded_args+=("$argument") - ;; - esac - done - - if [[ "$strict_requested" == "1" && "$allow_skips_requested" == "1" ]]; then - printf 'compose-conformance: --strict and --allow-skips are mutually exclusive\n' >&2 - return 2 - fi - - if [[ "$skip_ai" == "1" && "$strict_requested" == "1" ]]; then - printf 'compose-conformance: --strict requires provider coverage; set SAMPLE_APP_CONFORMANCE_SKIP_AI=0\n' >&2 - return 2 - fi - - provider_exec_args=() - conformance_args=() - - if [[ "$skip_ai" == "1" ]]; then - # An exported empty value overrides Compose's automatic .env interpolation, - # including a credential inherited from the invoking shell. The conformance - # command also omits its exec-level credential forwarding below. - export OPENAI_API_KEY="" - conformance_args=(--skip-ai --allow-skips "${forwarded_args[@]}") - printf 'compose-conformance: AI-backed surfaces are explicitly disabled; intentional skips are allowed\n' - return 0 - fi - - load_conformance_env - provider_exec_args=(-e OPENAI_API_KEY) - if [[ "$allow_skips_requested" == "1" || ( "$strict_requested" != "1" && "$allow_skips" == "1" ) ]]; then - conformance_args=(--allow-skips "${forwarded_args[@]}") - printf 'compose-conformance: AI-backed surfaces explicitly enabled; skipped coverage is allowed\n' - else - conformance_args=(--strict "${forwarded_args[@]}") - printf 'compose-conformance: AI-backed surfaces explicitly enabled; strict provider coverage is required\n' - fi -} - -build_runtime_image_for_artifact_tuple() { - run_step \ - "building shared app and worker runtime image with resolved artifact tuple" \ - "${SAMPLE_APP_RUNTIME_BUILD_TIMEOUT_SECONDS:-1200}" \ - docker compose build app -} - -start_services_and_wait_for_readiness() { - run_step \ - "starting app and worker services and waiting for readiness" \ - "${SAMPLE_APP_SERVICE_READINESS_TIMEOUT_SECONDS:-600}" \ - docker compose up -d --no-build --wait app worker -} - -container_is_ready() { - local container_id="$1" - local running - local health - - running="$(docker inspect --format '{{.State.Running}}' "$container_id" 2>/dev/null || true)" - health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id" 2>/dev/null || true)" - - [[ "$running" == "true" && ( -z "$health" || "$health" == "healthy" ) ]] -} - -container_env_matches() { - local container_id="$1" - shift - local environment - local name - local expected - local actual - - environment="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container_id" 2>/dev/null)" || return 1 - - for name in "$@"; do - expected="${!name:-}" - actual="$(printf '%s\n' "$environment" | sed -n "s/^${name}=//p" | tail -n 1)" - if [[ "$actual" != "$expected" ]]; then - printf 'compose-conformance: prepared container environment differs for %s\n' "$name" >&2 - return 1 - fi - done -} - -installed_artifacts_match() { - local service="$1" - - docker compose exec -T \ - -e DURABLE_WORKFLOW_PHP_SDK_VERSION \ - -e DURABLE_WORKFLOW_WORKFLOW_VERSION \ - -e DURABLE_WORKFLOW_WATERLINE_VERSION \ - "$service" php -r ' - require "/app/vendor/autoload.php"; - - foreach ([ - "durable-workflow/sdk" => getenv("DURABLE_WORKFLOW_PHP_SDK_VERSION"), - "durable-workflow/workflow" => getenv("DURABLE_WORKFLOW_WORKFLOW_VERSION"), - "durable-workflow/waterline" => getenv("DURABLE_WORKFLOW_WATERLINE_VERSION"), - ] as $package => $expected) { - $actual = Composer\InstalledVersions::getPrettyVersion($package); - if ($expected === false || ltrim((string) $actual, "v") !== ltrim($expected, "v")) { - fwrite(STDERR, sprintf("prepared package differs for %s\n", $package)); - exit(1); - } - } - ' >/dev/null -} - -prepared_schema_is_current() { - local output - - output="$(docker compose exec -T app php artisan migrate:status --no-ansi 2>&1)" || { - printf '%s\n' "$output" >&2 - return 1 - } - - if [[ "$output" == *"Pending"* ]]; then - printf 'compose-conformance: prepared schema has pending migrations\n' >&2 - return 1 - fi -} - -prepared_stack_is_reusable() { - local expected_app_id="${SAMPLE_APP_PREPARED_APP_CONTAINER_ID:-}" - local expected_worker_id="${SAMPLE_APP_PREPARED_WORKER_CONTAINER_ID:-}" - local current_app_id - local current_worker_id - - if [[ "${SAMPLE_APP_CONFORMANCE_REUSE_PREPARED:-0}" != "1" ]]; then - return 1 - fi - - current_app_id="$(docker compose ps -q app)" - current_worker_id="$(docker compose ps -q worker)" - if [[ -z "$expected_app_id" || -z "$expected_worker_id" || "$current_app_id" != "$expected_app_id" || "$current_worker_id" != "$expected_worker_id" ]]; then - printf 'compose-conformance: prepared stack handoff does not match the current app and worker containers\n' >&2 - return 1 - fi - - if ! container_is_ready "$current_app_id" || ! container_is_ready "$current_worker_id"; then - printf 'compose-conformance: prepared app or worker is no longer healthy\n' >&2 - return 1 - fi - - if ! container_env_matches "$current_app_id" \ - OPENAI_API_KEY \ - DURABLE_SERVER_IMAGE \ - DURABLE_WORKFLOW_CLI_VERSION \ - DURABLE_WORKFLOW_PYTHON_SDK_VERSION \ - DURABLE_WORKFLOW_RUST_SDK_VERSION \ - DURABLE_WORKFLOW_PHP_SDK_VERSION \ - DURABLE_WORKFLOW_WORKFLOW_VERSION \ - DURABLE_WORKFLOW_WATERLINE_VERSION \ - SAMPLE_APP_COMMIT; then - return 1 - fi - - if ! container_env_matches "$current_worker_id" \ - OPENAI_API_KEY \ - DURABLE_WORKFLOW_PHP_SDK_VERSION \ - DURABLE_WORKFLOW_WORKFLOW_VERSION \ - DURABLE_WORKFLOW_WATERLINE_VERSION; then - return 1 - fi - - installed_artifacts_match app && - installed_artifacts_match worker && - prepared_schema_is_current -} - -restart_worker_after_schema_refresh() { - run_step \ - "restarting worker after schema refresh" \ - "${SAMPLE_APP_WORKER_RESTART_TIMEOUT_SECONDS:-180}" \ - docker compose up -d --no-deps --force-recreate --wait worker -} - -provider_exec_args=() -conformance_args=() -configure_conformance "$@" - -docker compose ps - -trap cleanup_setup_sampler EXIT -start_setup_measurement -SAMPLE_APP_SETUP_STACK_REUSED="false" -SAMPLE_APP_SETUP_BUILD_INVOCATIONS="0" -SAMPLE_APP_SETUP_BUILD_DURATION_MS="0" -SAMPLE_APP_SETUP_READINESS_DURATION_MS="0" - -sample_app_commit="${SAMPLE_APP_COMMIT:-}" -if [[ -z "${sample_app_commit}" ]]; then - if ! sample_app_commit="$(git rev-parse HEAD 2>/dev/null)"; then - printf 'compose-conformance: unable to determine the sample-app commit; set SAMPLE_APP_COMMIT explicitly\n' >&2 - exit 1 - fi -fi -export SAMPLE_APP_COMMIT="$sample_app_commit" - -printf '\n==> resolving current published artifact tuple\n' -resolve_artifacts - -if prepared_stack_is_reusable; then - printf '\n==> reusing healthy prepared stack and schema\n' - SAMPLE_APP_SETUP_STACK_REUSED="true" -else - if [[ "${SAMPLE_APP_CONFORMANCE_REUSE_PREPARED:-0}" == "1" ]]; then - printf '\n==> prepared stack changed; rebuilding for the resolved tuple\n' - fi - - SAMPLE_APP_SETUP_BUILD_INVOCATIONS="1" - build_started_ms="$(now_milliseconds)" - build_runtime_image_for_artifact_tuple - build_completed_ms="$(now_milliseconds)" - SAMPLE_APP_SETUP_BUILD_DURATION_MS="$((build_completed_ms - build_started_ms))" - - readiness_started_ms="$(now_milliseconds)" - start_services_and_wait_for_readiness - readiness_completed_ms="$(now_milliseconds)" - SAMPLE_APP_SETUP_READINESS_DURATION_MS="$((readiness_completed_ms - readiness_started_ms))" - - printf '\n==> waiting for database to accept app connections\n' - wait_for_db - - run_step \ - "fresh database migrations" \ - "${SAMPLE_APP_MIGRATION_TIMEOUT_SECONDS:-180}" \ - docker compose exec -T app php artisan migrate:fresh --force - restart_worker_after_schema_refresh -fi - -finish_setup_measurement - -if [[ "${SAMPLE_APP_CONFORMANCE_SMOKE_FIRST:-0}" == "1" ]]; then - printf '\n==> deterministic smoke against prepared stack\n' - prepared_app_container_id="$(docker compose ps -q app)" - prepared_worker_container_id="$(docker compose ps -q worker)" - run_step \ - "deterministic smoke against prepared stack" \ - "${SAMPLE_APP_SMOKE_TIMEOUT_SECONDS:-900}" \ - env \ - SAMPLE_APP_SMOKE_ONLY=1 \ - SAMPLE_APP_SMOKE_REUSE_PREPARED=1 \ - SAMPLE_APP_PREPARED_APP_CONTAINER_ID="$prepared_app_container_id" \ - SAMPLE_APP_PREPARED_WORKER_CONTAINER_ID="$prepared_worker_container_id" \ - scripts/compose-smoke.sh -fi - -printf '\n==> full sample-app conformance\n' -app_url="${SAMPLE_APP_CONFORMANCE_URL:-http://sample-app:8000}" -metadata_path="${SAMPLE_APP_CONFORMANCE_METADATA_PATH:-storage/app/sample-app-conformance-metadata.json}" -metadata_container_path="${SAMPLE_APP_CONFORMANCE_CONTAINER_METADATA_PATH:-storage/app/sample-app-conformance-metadata.json}" -metadata_container_abs="/app/${metadata_container_path#/}" -mkdir -p "$(dirname "$metadata_path")" - -set +e -timeout "${SAMPLE_APP_CONFORMANCE_TIMEOUT_SECONDS:-1800}s" docker compose exec -T \ - -e SAMPLE_APP_COMMIT="${sample_app_commit}" \ - -e DURABLE_SERVER_IMAGE \ - -e DURABLE_WORKFLOW_CLI_VERSION \ - -e DURABLE_WORKFLOW_PYTHON_SDK_VERSION \ - -e DURABLE_WORKFLOW_RUST_SDK_VERSION \ - -e DURABLE_WORKFLOW_PHP_SDK_VERSION \ - -e DURABLE_WORKFLOW_WORKFLOW_VERSION \ - -e DURABLE_WORKFLOW_WATERLINE_VERSION \ - "${provider_exec_args[@]}" \ - -e SAMPLE_APP_SETUP_CACHE_STATE \ - -e SAMPLE_APP_SETUP_DURATION_MS \ - -e SAMPLE_APP_SETUP_BUILD_DURATION_MS \ - -e SAMPLE_APP_SETUP_READINESS_DURATION_MS \ - -e SAMPLE_APP_SETUP_PEAK_DISK_GROWTH_BYTES \ - -e SAMPLE_APP_SETUP_STACK_REUSED \ - -e SAMPLE_APP_SETUP_BUILD_INVOCATIONS \ - app php artisan app:conformance --app-url="${app_url}" --output="${metadata_container_path}" "${conformance_args[@]}" -status=$? -set -e - -if [[ "$status" -ne 0 ]]; then - if [[ "$status" -eq 124 ]]; then - printf 'compose-conformance: full sample-app conformance timed out after %ss\n' "${SAMPLE_APP_CONFORMANCE_TIMEOUT_SECONDS:-1800}" >&2 - else - printf 'compose-conformance: full sample-app conformance exited with status %d\n' "$status" >&2 - fi - - compose_diagnostics "full sample-app conformance" -fi - -if timeout "${SAMPLE_APP_METADATA_COPY_TIMEOUT_SECONDS:-60}s" docker compose cp "app:${metadata_container_abs}" "$metadata_path" >/dev/null; then - printf 'compose-conformance: sample-app metadata copied to %s\n' "$metadata_path" - printf 'compose-conformance: set DW_AGENT_OPERABILITY_SAMPLE_APP_METADATA_PATH=%s for agent-operability validation\n' "$metadata_path" -else - printf 'compose-conformance: unable to copy sample-app metadata from %s\n' "$metadata_container_abs" >&2 -fi - -exit "$status" diff --git a/scripts/compose-smoke-conformance.sh b/scripts/compose-smoke-conformance.sh deleted file mode 100755 index 60e7665..0000000 --- a/scripts/compose-smoke-conformance.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# The combined entry point owns setup so the exact artifact tuple is resolved, -# built, and migrated once before deterministic smoke and the configured matrix. -export SAMPLE_APP_CONFORMANCE_SMOKE_FIRST=1 - -exec scripts/compose-conformance.sh "$@" diff --git a/scripts/compose-smoke.sh b/scripts/compose-smoke.sh index 8a4eae6..ae144a2 100755 --- a/scripts/compose-smoke.sh +++ b/scripts/compose-smoke.sh @@ -156,30 +156,4 @@ run_sample \ "app:sandbox --snapshot-every=2 --inject-loss-after=2" \ "Workflow complete\..*recoveries=1" -if [[ "${SAMPLE_APP_CONFORMANCE_AFTER_SMOKE:-1}" == "1" && "${SAMPLE_APP_SMOKE_ONLY:-0}" != "1" ]]; then - printf '\n==> full sample-app conformance surface\n' - prepared_app_container_id="$(docker compose ps -q app)" - prepared_worker_container_id="$(docker compose ps -q worker)" - set +e - timeout "${SAMPLE_APP_CONFORMANCE_AFTER_SMOKE_TIMEOUT_SECONDS:-1800}s" env \ - SAMPLE_APP_CONFORMANCE_REUSE_PREPARED=1 \ - SAMPLE_APP_PREPARED_APP_CONTAINER_ID="$prepared_app_container_id" \ - SAMPLE_APP_PREPARED_WORKER_CONTAINER_ID="$prepared_worker_container_id" \ - scripts/compose-conformance.sh - status=$? - set -e - - if [[ "$status" -ne 0 ]]; then - if [[ "$status" -eq 124 ]]; then - printf 'compose-smoke: full sample-app conformance surface timed out after %ss\n' "${SAMPLE_APP_CONFORMANCE_AFTER_SMOKE_TIMEOUT_SECONDS:-1800}" >&2 - else - printf 'compose-smoke: full sample-app conformance surface exited with status %d\n' "$status" >&2 - fi - - compose_diagnostics "full sample-app conformance surface" - - exit "$status" - fi -else - printf '\ncompose-smoke: all deterministic sample workflows passed\n' -fi +printf '\ncompose-smoke: all deterministic sample workflows passed\n' diff --git a/scripts/playground b/scripts/playground index 06c7346..48d571d 100755 --- a/scripts/playground +++ b/scripts/playground @@ -313,7 +313,6 @@ def effective_scenario( def resolve_artifacts() -> dict[str, str]: env = clean_environment() - env.setdefault("DURABLE_WORKFLOW_ARTIFACT_SOURCE", "pinned") output = command_output( [str(REPO_ROOT / "scripts" / "resolve-current-artifacts.sh")], env=env ) diff --git a/scripts/polyglot-validation.sh b/scripts/polyglot-validation.sh deleted file mode 100755 index 2751e60..0000000 --- a/scripts/polyglot-validation.sh +++ /dev/null @@ -1,451 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$repo_root/polyglot" - -: "${COMPOSE_PROJECT_NAME:?Set an isolated COMPOSE_PROJECT_NAME before running polyglot validation}" - -build_services=( - python-activity-worker - php-same-workflow-worker - php-same-activity-worker - php-workflow-worker - polyglot-workflow-worker - php-to-rust-workflow-worker - php-query-worker - php-activity-worker - python-workflow-worker - rust-workflow-worker - rust-activity-worker - waterline - smoke -) -topology_services=( - server - python-activity-worker - php-same-workflow-worker - php-same-activity-worker - php-workflow-worker - polyglot-workflow-worker - php-to-rust-workflow-worker - php-query-worker - php-activity-worker - python-workflow-worker - rust-workflow-worker - rust-activity-worker - waterline -) -worker_services=( - python-activity-worker - php-same-workflow-worker - php-same-activity-worker - php-workflow-worker - polyglot-workflow-worker - php-to-rust-workflow-worker - php-query-worker - php-activity-worker - python-workflow-worker - rust-workflow-worker - rust-activity-worker -) - -server_container_id="" -failure_context="validation failure" -first_worker_startup_exception="" - -compose_diagnostics() { - local context="$1" - local lines="${POLYGLOT_DIAGNOSTIC_LOG_LINES:-240}" - - printf '\npolyglot-validation: diagnostics after %s\n' "$context" >&2 - printf 'polyglot-validation: expected server container=%s current=%s\n' \ - "${server_container_id:-not-captured}" \ - "$(docker compose ps -q server 2>/dev/null || true)" >&2 - if [[ -n "$first_worker_startup_exception" ]]; then - printf '\npolyglot-validation: first retained worker startup exception\n%s\n' \ - "$first_worker_startup_exception" >&2 - fi - docker compose ps --all >&2 || true - docker compose images >&2 || true - docker compose logs \ - --no-color \ - --timestamps \ - --tail="$lines" \ - bootstrap \ - server \ - python-activity-worker \ - php-same-workflow-worker \ - php-same-activity-worker \ - php-workflow-worker \ - polyglot-workflow-worker \ - php-to-rust-workflow-worker \ - php-query-worker \ - php-activity-worker \ - python-workflow-worker \ - rust-workflow-worker \ - rust-activity-worker \ - waterline >&2 || true -} - -cleanup() { - local status=$? - trap - EXIT INT TERM - - if [[ "$status" -ne 0 ]]; then - compose_diagnostics "$failure_context" - fi - - printf '\n==> removing isolated polyglot Compose project %s\n' "$COMPOSE_PROJECT_NAME" - if ! timeout "${POLYGLOT_CLEANUP_TIMEOUT_SECONDS:-120}s" \ - docker compose down \ - --volumes \ - --remove-orphans \ - --rmi local \ - --timeout "${POLYGLOT_COMPOSE_STOP_TIMEOUT_SECONDS:-30}" - then - printf 'polyglot-validation: Compose project cleanup failed or timed out\n' >&2 - if [[ "$status" -eq 0 ]]; then - status=1 - fi - fi - - exit "$status" -} - -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM - -run_step() { - local name="$1" - local timeout_seconds="$2" - shift 2 - - printf '\n==> %s\n' "$name" - - set +e - timeout "${timeout_seconds}s" "$@" - local status=$? - set -e - - if [[ "$status" -eq 0 ]]; then - return 0 - fi - - if [[ "$status" -eq 124 ]]; then - printf 'polyglot-validation: %s timed out after %ss\n' "$name" "$timeout_seconds" >&2 - else - printf 'polyglot-validation: %s exited with status %d\n' "$name" "$status" >&2 - fi - failure_context="$name" - - return "$status" -} - -capture_task_codec_probe() { - local result_name="$1" - local name="$2" - local timeout_seconds="$3" - shift 3 - local output - local status - - printf '\n==> %s\n' "$name" - set +e - output="$(timeout "${timeout_seconds}s" "$@")" - status=$? - set -e - - if [[ "$status" -ne 0 && "$status" -ne 1 ]]; then - failure_context="$name" - if [[ "$status" -eq 124 ]]; then - printf 'polyglot-validation: %s timed out after %ss\n' "$name" "$timeout_seconds" >&2 - else - printf 'polyglot-validation: %s exited with unexpected status %d\n' "$name" "$status" >&2 - fi - return "$status" - fi - if ! python3 -c 'import json, sys; json.load(sys.stdin)' <<<"$output"; then - failure_context="$name" - printf 'polyglot-validation: %s did not emit valid JSON evidence\n' "$name" >&2 - return 1 - fi - - printf -v "$result_name" '%s' "$output" - if [[ "$status" -eq 1 ]]; then - printf 'polyglot-validation: %s reported a rejected qualification; preserving evidence for the final run metadata\n' "$name" >&2 - fi -} - -capture_first_worker_startup_exception() { - local service - local container_id - local status - - for service in "${worker_services[@]}"; do - container_id="$(docker compose ps --all -q "$service" 2>/dev/null || true)" - [[ -n "$container_id" ]] || continue - - status="$( - docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true - )" - if [[ "$status" == "running" ]]; then - continue - fi - if [[ "$status" != "exited" && "$status" != "dead" && "$status" != "restarting" ]]; then - continue - fi - - if [[ -z "$first_worker_startup_exception" ]]; then - first_worker_startup_exception="$( - docker compose logs \ - --no-color \ - --timestamps \ - --tail="${POLYGLOT_STARTUP_EXCEPTION_LOG_LINES:-80}" \ - "$service" 2>&1 || true - )" - fi - failure_context="worker startup for $service" - printf 'polyglot-validation: %s stopped during registration readiness (status=%s)\n' \ - "$service" \ - "$status" >&2 - if [[ -n "$first_worker_startup_exception" ]]; then - printf '%s\n' "$first_worker_startup_exception" >&2 - fi - return 1 - done - - return 0 -} - -retain_worker_startup_exception() { - local service="$1" - - if [[ -z "$first_worker_startup_exception" ]]; then - first_worker_startup_exception="$( - docker compose logs \ - --no-color \ - --timestamps \ - --tail="${POLYGLOT_STARTUP_EXCEPTION_LOG_LINES:-80}" \ - "$service" 2>&1 || true - )" - fi - failure_context="worker startup for $service" - printf 'polyglot-validation: %s stopped during registration readiness\n' \ - "$service" >&2 - if [[ -n "$first_worker_startup_exception" ]]; then - printf '%s\n' "$first_worker_startup_exception" >&2 - fi -} - -run_registration_readiness_step() { - local name="proving every required worker registration on the stable server" - local timeout_seconds="${POLYGLOT_REGISTRATION_STEP_TIMEOUT_SECONDS:-150}" - local service - local container_id - local readiness_pid - local completed_pid - local status - local watcher_pid - local -a wait_pids=() - local -a watcher_pids=() - local -A watcher_services=() - - printf '\n==> %s\n' "$name" - timeout "${timeout_seconds}s" \ - docker compose run \ - --rm \ - --no-deps \ - -e "POLYGLOT_REGISTRATION_TIMEOUT_SECONDS=${POLYGLOT_REGISTRATION_TIMEOUT_SECONDS:-90}" \ - smoke \ - python \ - /app/scripts/polyglot_smoke.py \ - --readiness-only & - readiness_pid=$! - wait_pids+=("$readiness_pid") - - for service in "${worker_services[@]}"; do - container_id="$(docker compose ps --all -q "$service" 2>/dev/null || true)" - if [[ -z "$container_id" ]]; then - retain_worker_startup_exception "$service" - kill "$readiness_pid" 2>/dev/null || true - wait "$readiness_pid" 2>/dev/null || true - return 1 - fi - - docker wait "$container_id" >/dev/null & - watcher_pid=$! - watcher_pids+=("$watcher_pid") - wait_pids+=("$watcher_pid") - watcher_services["$watcher_pid"]="$service" - done - - set +e - wait -n -p completed_pid "${wait_pids[@]}" - status=$? - set -e - - if [[ "$completed_pid" != "$readiness_pid" ]]; then - retain_worker_startup_exception "${watcher_services[$completed_pid]}" - kill "$readiness_pid" 2>/dev/null || true - for watcher_pid in "${watcher_pids[@]}"; do - kill "$watcher_pid" 2>/dev/null || true - done - wait 2>/dev/null || true - return 1 - fi - - for watcher_pid in "${watcher_pids[@]}"; do - kill "$watcher_pid" 2>/dev/null || true - done - wait 2>/dev/null || true - - if [[ "$status" -eq 0 ]]; then - capture_first_worker_startup_exception - return $? - fi - - failure_context="$name" - if [[ "$status" -eq 124 ]]; then - printf 'polyglot-validation: %s timed out after %ss\n' "$name" "$timeout_seconds" >&2 - else - printf 'polyglot-validation: %s exited with status %d\n' "$name" "$status" >&2 - fi - return "$status" -} - -assert_server_stable() { - local context="$1" - local current_container_id - local running - local health - - current_container_id="$(docker compose ps -q server)" - running="$(docker inspect --format '{{.State.Running}}' "$current_container_id" 2>/dev/null || true)" - health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$current_container_id" 2>/dev/null || true)" - - if [[ -z "$server_container_id" ]]; then - server_container_id="$current_container_id" - fi - - if [[ -z "$current_container_id" || "$current_container_id" != "$server_container_id" ]]; then - failure_context="$context" - printf 'polyglot-validation: server container changed during %s (expected=%s actual=%s)\n' \ - "$context" \ - "${server_container_id:-missing}" \ - "${current_container_id:-missing}" >&2 - return 1 - fi - - if [[ "$running" != "true" || "$health" != "healthy" ]]; then - failure_context="$context" - printf 'polyglot-validation: server is not healthy during %s (running=%s health=%s)\n' \ - "$context" \ - "${running:-unknown}" \ - "${health:-unknown}" >&2 - return 1 - fi - - printf 'polyglot-validation: stable server container %s is healthy after %s\n' \ - "$server_container_id" \ - "$context" -} - -cache_mode="${POLYGLOT_BUILD_CACHE_MODE:-cold-cache}" -build_timeout_seconds="${POLYGLOT_BUILD_TIMEOUT_SECONDS:-720}" - -case "$cache_mode" in - cold-cache) - run_step \ - "building the complete exact-tuple topology from a cold cache" \ - "$build_timeout_seconds" \ - docker compose build --pull --no-cache "${build_services[@]}" - ;; - warm-cache) - run_step \ - "priming the exact-tuple build cache" \ - "$build_timeout_seconds" \ - docker compose build --pull "${build_services[@]}" - run_step \ - "rebuilding the complete exact-tuple topology from the warm cache" \ - "${POLYGLOT_WARM_BUILD_TIMEOUT_SECONDS:-240}" \ - docker compose build "${build_services[@]}" - ;; - *) - printf 'polyglot-validation: unsupported POLYGLOT_BUILD_CACHE_MODE=%s (expected cold-cache or warm-cache)\n' \ - "$cache_mode" >&2 - exit 2 - ;; -esac - -run_step \ - "pulling exact runtime and datastore images" \ - "${POLYGLOT_IMAGE_PULL_TIMEOUT_SECONDS:-180}" \ - docker compose pull --policy missing bootstrap server mysql redis - -php_task_codec_rejection_evidence="" -python_task_codec_rejection_evidence="" -rust_task_codec_rejection_evidence="" -capture_task_codec_probe \ - php_task_codec_rejection_evidence \ - "probing PHP task codec rejection boundaries" \ - "${POLYGLOT_TASK_CODEC_PROBE_TIMEOUT_SECONDS:-90}" \ - docker compose run --rm --no-deps php-workflow-worker \ - php /app/task_codec_rejection_probe.php -capture_task_codec_probe \ - python_task_codec_rejection_evidence \ - "probing Python task codec rejection boundaries" \ - "${POLYGLOT_TASK_CODEC_PROBE_TIMEOUT_SECONDS:-90}" \ - docker compose run --rm --no-deps python-activity-worker \ - python /app/scripts/task_codec_rejection_probe.py -capture_task_codec_probe \ - rust_task_codec_rejection_evidence \ - "probing Rust task codec rejection boundaries" \ - "${POLYGLOT_TASK_CODEC_PROBE_TIMEOUT_SECONDS:-90}" \ - docker compose run --rm --no-deps \ - --entrypoint task-codec-rejection-probe \ - rust-workflow-worker - -task_codec_evidence_assignments="$( - POLYGLOT_PHP_TASK_CODEC_REJECTION_EVIDENCE="$php_task_codec_rejection_evidence" \ - POLYGLOT_PYTHON_TASK_CODEC_REJECTION_EVIDENCE="$python_task_codec_rejection_evidence" \ - POLYGLOT_RUST_TASK_CODEC_REJECTION_EVIDENCE="$rust_task_codec_rejection_evidence" \ - "$repo_root/scripts/resolve-current-artifacts.sh" --task-codec-evidence -)" -while IFS= read -r assignment; do - [[ -n "$assignment" ]] || continue - export "$assignment" -done <<< "$task_codec_evidence_assignments" - -run_step \ - "starting the complete polyglot topology with one server bootstrap" \ - "${POLYGLOT_TOPOLOGY_TIMEOUT_SECONDS:-240}" \ - docker compose up \ - --detach \ - --no-build \ - --wait \ - --wait-timeout "${POLYGLOT_COMPOSE_WAIT_SECONDS:-180}" \ - "${topology_services[@]}" - -assert_server_stable "topology readiness" - -run_registration_readiness_step - -assert_server_stable "worker registration" - -run_step \ - "running polyglot smoke against the stable server" \ - "${POLYGLOT_SMOKE_TIMEOUT_SECONDS:-600}" \ - docker compose run \ - --rm \ - --no-deps \ - -e "POLYGLOT_PHP_TASK_CODEC_REJECTION_EVIDENCE=$POLYGLOT_PHP_TASK_CODEC_REJECTION_EVIDENCE" \ - -e "POLYGLOT_PYTHON_TASK_CODEC_REJECTION_EVIDENCE=$POLYGLOT_PYTHON_TASK_CODEC_REJECTION_EVIDENCE" \ - -e "POLYGLOT_RUST_TASK_CODEC_REJECTION_EVIDENCE=$POLYGLOT_RUST_TASK_CODEC_REJECTION_EVIDENCE" \ - smoke - -assert_server_stable "polyglot smoke" -printf '\npolyglot-validation: %s validation passed on server container %s\n' \ - "$cache_mode" \ - "$server_container_id" diff --git a/scripts/polyglot.sh b/scripts/polyglot.sh index e16c9d5..2c22dfd 100755 --- a/scripts/polyglot.sh +++ b/scripts/polyglot.sh @@ -38,7 +38,10 @@ printf '==> PolyglotWorkflow: building PHP %s, Python %s, and Rust %s workers\n' printf '==> PolyglotWorkflow: starting Durable Workflow Server %s and three runtime workers\n' \ "$DURABLE_SERVER_IMAGE" -"${compose[@]}" pull --policy missing bootstrap server mysql redis +# Refresh the product image so the declared artifact tuple cannot resolve to a +# stale local cache entry. +"${compose[@]}" pull --policy always bootstrap server +"${compose[@]}" pull --policy missing mysql redis "${compose[@]}" up \ --detach \ --no-build \ diff --git a/scripts/resolve-current-artifacts.sh b/scripts/resolve-current-artifacts.sh index 7c812bd..10810f6 100755 --- a/scripts/resolve-current-artifacts.sh +++ b/scripts/resolve-current-artifacts.sh @@ -2,449 +2,155 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -pinned_artifact_tuple_file="${repo_root}/polyglot/qualified-artifact-tuple.json" -current_artifact_tuple_url="${DURABLE_WORKFLOW_CURRENT_ARTIFACT_TUPLE_URL:-https://durable-workflow.com/docs-page-release-audit.json}" -resolver_mode="${1:-artifacts}" +tuple_file="${DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE:-${repo_root}/polyglot/qualified-artifact-tuple.json}" -case "$resolver_mode" in - artifacts|--task-codec-evidence) - ;; - *) - printf 'resolve-current-artifacts: unsupported argument %s\n' "$resolver_mode" >&2 - exit 1 - ;; -esac - -artifact_source="${DURABLE_WORKFLOW_ARTIFACT_SOURCE:-current}" -legacy_resolve_latest="${DURABLE_WORKFLOW_RESOLVE_LATEST:-}" - -is_truthy() { - local value="${1:-}" - [[ "$value" == "1" || "$value" == "true" || "$value" == "yes" ]] -} - -if [[ -z "${DURABLE_WORKFLOW_ARTIFACT_SOURCE:-}" ]] && is_truthy "$legacy_resolve_latest"; then - artifact_source="current" +if [[ $# -ne 0 ]]; then + printf 'resolve-current-artifacts: this command does not accept arguments\n' >&2 + exit 1 fi -case "$artifact_source" in - current|published|latest) - artifact_source="current" - ;; - pinned|static|locked) - artifact_source="pinned" - ;; - *) - printf 'resolve-current-artifacts: unsupported DURABLE_WORKFLOW_ARTIFACT_SOURCE=%s (expected current or pinned)\n' "$artifact_source" >&2 - exit 1 - ;; -esac +if [[ ! -f "$tuple_file" ]]; then + printf 'resolve-current-artifacts: artifact tuple not found: %s\n' "$tuple_file" >&2 + exit 1 +fi -semantic_version_from_text() { - local value="${1:-}" +if ! command -v node >/dev/null 2>&1; then + printf 'resolve-current-artifacts: node is required to read %s\n' "$tuple_file" >&2 + exit 1 +fi - if [[ "$value" =~ ([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?) ]]; then - printf '%s\n' "${BASH_REMATCH[1]}" - fi +parse_tuple() { + node - "$tuple_file" <<'NODE' +const fs = require('node:fs'); +const path = process.argv[2]; +const expectedSchema = 'durable-workflow.sample-app.polyglot-qualified-artifact-tuple'; +const keys = ['server', 'cli', 'sdk-php', 'sdk-python', 'sdk-rust', 'workflow', 'waterline']; +const stableV2 = /^2\.\d+\.\d+(?:\+[0-9A-Za-z.-]+)?$/; + +let tuple; +try { + tuple = JSON.parse(fs.readFileSync(path, 'utf8')); +} catch (error) { + throw new Error(`${path} is not valid JSON: ${error.message}`); } -emit_assignment() { - local name="$1" - local value="$2" - - if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then - printf 'resolve-current-artifacts: %s contains a newline\n' "$name" >&2 - exit 1 - fi - - printf '%s=%s\n' "$name" "$value" +if (tuple?.schema !== expectedSchema || tuple?.schemaVersion !== 1) { + throw new Error(`${path} is not a supported Sample App artifact tuple`); } -require_command() { - local name="$1" - local purpose="$2" - - if ! command -v "$name" >/dev/null 2>&1; then - printf 'resolve-current-artifacts: %s is required to %s\n' "$name" "$purpose" >&2 - exit 1 - fi +const artifacts = tuple.artifacts; +if (!artifacts || typeof artifacts !== 'object' || Array.isArray(artifacts)) { + throw new Error(`${path} does not contain an artifacts object`); } -normalize_task_codec_evidence() { - local name="$1" - local runtime="$2" - local artifact_name="$3" - local expected_version="$4" - local evidence="$5" - - require_command node "validate task-codec rejection evidence" - - printf '%s' "$evidence" | node -e ' -const [name, runtime, artifactName, expectedVersion] = process.argv.slice(1); -let raw = ""; -process.stdin.setEncoding("utf8"); -process.stdin.on("data", chunk => { raw += chunk; }); -process.stdin.on("end", () => { - let evidence; - try { - evidence = JSON.parse(raw); - } catch (error) { - throw new Error(`${name} is not valid JSON: ${error.message}`); - } - - if ( - !evidence - || evidence.schema !== "durable-workflow.sample-app.task-codec-rejection-probe" - || evidence.version !== 1 - || evidence.runtime !== runtime - ) { - throw new Error(`${name} does not contain ${runtime} task-codec rejection evidence`); - } - - const artifact = evidence.artifact; - const pythonVersion = value => { - const match = /^(\d+\.\d+\.\d+)(a|b|rc)(\d+)$/.exec(value); - if (!match) { - return value; - } - const channel = {a: "alpha", b: "beta", rc: "rc"}[match[2]]; - return `${match[1]}-${channel}.${match[3]}`; - }; - const observedVersion = runtime === "python" - ? pythonVersion(artifact?.version) - : artifact?.version; - if (!artifact || artifact.name !== artifactName || observedVersion !== expectedVersion) { - throw new Error( - `${name} artifact must be ${artifactName}:${expectedVersion}; received ` - + `${JSON.stringify(artifact)}`, - ); - } - - process.stdout.write(JSON.stringify(evidence)); -}); -' "$name" "$runtime" "$artifact_name" "$expected_version" -} - -emit_task_codec_evidence() { - local name="$1" - local runtime="$2" - local artifact_name="$3" - local expected_version="$4" - local evidence="${!name:-}" - local normalized - - if [[ -z "$evidence" ]]; then - printf 'resolve-current-artifacts: %s is required to resolve exact-tuple task-codec evidence\n' \ - "$name" >&2 - exit 1 - fi - - if ! normalized="$( - normalize_task_codec_evidence \ - "$name" \ - "$runtime" \ - "$artifact_name" \ - "$expected_version" \ - "$evidence" - )"; then - printf 'resolve-current-artifacts: failed to validate %s\n' "$name" >&2 - exit 1 - fi - - emit_assignment "$name" "$normalized" +const unknown = Object.keys(artifacts).filter(key => !keys.includes(key)); +if (unknown.length > 0) { + throw new Error(`${path} contains unknown artifacts: ${unknown.sort().join(', ')}`); } -if [[ "$resolver_mode" == "--task-codec-evidence" ]]; then - for name in \ - DURABLE_WORKFLOW_PHP_SDK_VERSION \ - DURABLE_WORKFLOW_PYTHON_SDK_VERSION \ - DURABLE_WORKFLOW_RUST_SDK_VERSION - do - if [[ -z "${!name:-}" ]]; then - printf 'resolve-current-artifacts: %s is required to bind task-codec evidence to the exact tuple\n' \ - "$name" >&2 - exit 1 - fi - done - - emit_task_codec_evidence \ - POLYGLOT_PHP_TASK_CODEC_REJECTION_EVIDENCE \ - php \ - durable-workflow/sdk \ - "$DURABLE_WORKFLOW_PHP_SDK_VERSION" - emit_task_codec_evidence \ - POLYGLOT_PYTHON_TASK_CODEC_REJECTION_EVIDENCE \ - python \ - durable-workflow \ - "$DURABLE_WORKFLOW_PYTHON_SDK_VERSION" - emit_task_codec_evidence \ - POLYGLOT_RUST_TASK_CODEC_REJECTION_EVIDENCE \ - rust \ - durable-workflow \ - "$DURABLE_WORKFLOW_RUST_SDK_VERSION" - exit 0 -fi - -parse_artifact_tuple_json() { - local label="$1" - - require_command node "parse the current artifact tuple JSON" - - node -e ' -const label = process.argv[1] || "artifact tuple"; -let raw = ""; -process.stdin.setEncoding("utf8"); -process.stdin.on("data", chunk => { raw += chunk; }); -process.stdin.on("end", () => { - let payload; - try { - payload = JSON.parse(raw); - } catch (error) { - throw new Error(`${label} is not valid JSON: ${error.message}`); +for (const key of keys) { + const version = artifacts[key]; + if (typeof version !== 'string' || !stableV2.test(version)) { + throw new Error(`${path} artifact ${key} must be a stable 2.x version`); } - - let artifacts = null; - if (payload && payload.schema === "durable-workflow.docs.page-release-audit") { - artifacts = payload.artifact_versions; - } else if (payload && payload.schema === "durable-workflow.docs.public-artifact-versions") { - artifacts = payload.artifacts; - } else if (payload && payload.artifact_versions && typeof payload.artifact_versions === "object") { - artifacts = payload.artifact_versions; - } else if (payload && payload.artifacts && typeof payload.artifacts === "object") { - artifacts = payload.artifacts; - } - - if (!artifacts || typeof artifacts !== "object" || Array.isArray(artifacts)) { - throw new Error(`${label} must expose artifact_versions or artifacts`); - } - - const supportedTrainPattern = /^2\.0\.0-(?:beta|rc)\.\d+$/; - const officialRequirements = Object.fromEntries( - ["server", "cli", "sdk-php", "sdk-python", "sdk-rust", "workflow", "waterline"] - .map(key => [key, supportedTrainPattern]), - ); - const emittedArtifacts = ["server", "cli", "sdk-php", "sdk-python", "sdk-rust", "workflow", "waterline"]; - const unknown = Object.keys(artifacts).filter(key => !officialRequirements[key]).sort(); - if (unknown.length > 0) { - throw new Error(`${label} contains unknown artifact keys: ${unknown.join(", ")}`); - } - - for (const key of emittedArtifacts) { - const requirement = officialRequirements[key]; - const version = artifacts[key]; - if (typeof version !== "string" || version.trim() !== version || !requirement.test(version)) { - throw new Error(`${label} artifact ${key} has unsupported version ${JSON.stringify(version)}`); - } - } - - // Qualified RC components may advance independently, but a resolved tuple - // must not cross prerelease channels. Beta trains remain version-coherent. - const channels = new Set( - emittedArtifacts.map(key => artifacts[key].split("-")[1].split(".")[0]), - ); - if (channels.size !== 1) { - throw new Error(`${label} must expose one 2.0 beta or release-candidate channel across every artifact`); - } - - const versions = new Set(emittedArtifacts.map(key => artifacts[key])); - if (channels.has("beta") && versions.size !== 1) { - throw new Error(`${label} must expose one synchronized 2.0 beta version across every artifact`); - } - - process.stdout.write(emittedArtifacts.map(key => `${key}=${artifacts[key]}`).join("\n") + "\n"); -}); -' "$label" + process.stdout.write(`${key}=${version}\n`); } - -load_artifact_tuple_assignments() { - local assignments="$1" - local artifact - local version - - while IFS='=' read -r artifact version; do - case "$artifact" in - server) - current_server_version="$version" - ;; - cli) - current_cli_version="$version" - ;; - sdk-php) - current_php_sdk_version="$version" - ;; - sdk-python) - current_python_sdk_version="$version" - ;; - sdk-rust) - current_rust_sdk_version="$version" - ;; - workflow) - current_workflow_version="$version" - ;; - waterline) - current_waterline_version="$version" - ;; - "") - ;; - *) - printf 'resolve-current-artifacts: unexpected artifact tuple key %s\n' "$artifact" >&2 - exit 1 - ;; - esac - done <<< "$assignments" +NODE } -load_artifact_tuple_file() { - local file="$1" - local assignments +declare -A tuple=() +while IFS='=' read -r artifact version; do + tuple["$artifact"]="$version" +done < <(parse_tuple) - if [[ ! -f "$file" ]]; then - printf 'resolve-current-artifacts: artifact tuple file not found: %s\n' "$file" >&2 - exit 1 - fi +stable_version() { + local name="$1" + local value="$2" - if ! assignments="$(parse_artifact_tuple_json "$file" < "$file")"; then - printf 'resolve-current-artifacts: failed to parse artifact tuple file %s\n' "$file" >&2 + if [[ ! "$value" =~ ^2\.[0-9]+\.[0-9]+(\+[0-9A-Za-z.-]+)?$ ]]; then + printf 'resolve-current-artifacts: %s must be a stable 2.x version; received %s\n' \ + "$name" "$value" >&2 exit 1 fi - load_artifact_tuple_assignments "$assignments" + printf '%s\n' "$value" } -load_artifact_tuple_url() { - local url="$1" - local assignments - - require_command curl "download the current artifact tuple JSON" - - if ! assignments="$( - curl -fsSL --retry 2 --connect-timeout 5 --max-time 20 "$url" \ - | parse_artifact_tuple_json "$url" - )"; then - printf 'resolve-current-artifacts: failed to resolve artifact tuple from %s\n' "$url" >&2 - exit 1 +version_from_pin() { + local pin="$1" + if [[ "$pin" =~ ([0-9]+\.[0-9]+\.[0-9]+(\+[0-9A-Za-z.-]+)?) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" fi - - load_artifact_tuple_assignments "$assignments" } -normalize_cli_pin() { - local pin="$1" - local version="$2" - - if [[ -z "$pin" ]]; then - printf 'dw==%s\n' "$version" - return 0 - fi +emit() { + local name="$1" + local value="$2" - # Older conformance metadata used a Composer-shaped package pin for the - # CLI. The CLI is installed through its release installer, so normalize the - # project-owned legacy shape to the resolver-safe binary pin while keeping - # arbitrary explicit overrides intact. - if [[ "$pin" =~ ^durable-workflow/cli:([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?)$ ]]; then - printf 'dw==%s\n' "${BASH_REMATCH[1]}" - return 0 + if [[ -z "$value" || "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + printf 'resolve-current-artifacts: invalid value for %s\n' "$name" >&2 + exit 1 fi - printf '%s\n' "$pin" + printf '%s=%s\n' "$name" "$value" } -current_server_version="" -current_cli_version="" -current_php_sdk_version="" -current_python_sdk_version="" -current_rust_sdk_version="" -current_workflow_version="" -current_waterline_version="" - -if [[ "$artifact_source" == "pinned" ]]; then - load_artifact_tuple_file "$pinned_artifact_tuple_file" -elif [[ -n "${DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE:-}" ]]; then - load_artifact_tuple_file "$DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE" -elif [[ -n "${DURABLE_WORKFLOW_ARTIFACT_TUPLE_URL:-}" ]]; then - load_artifact_tuple_url "$DURABLE_WORKFLOW_ARTIFACT_TUPLE_URL" -else - load_artifact_tuple_url "$current_artifact_tuple_url" -fi - -server_image="${DURABLE_SERVER_IMAGE:-}" -if [[ -z "$server_image" ]]; then - server_image="durableworkflow/server:${current_server_version}" -fi -server_version="$(semantic_version_from_text "$server_image")" -server_version="${server_version:-$current_server_version}" - -cli_pin="${DURABLE_WORKFLOW_CLI_PIN:-}" -if [[ -n "${DURABLE_WORKFLOW_CLI_VERSION:-}" ]]; then - cli_version="$DURABLE_WORKFLOW_CLI_VERSION" -elif [[ -n "$cli_pin" ]]; then - cli_version="$(semantic_version_from_text "$cli_pin")" - cli_version="${cli_version:-$current_cli_version}" -else - cli_version="$current_cli_version" +server_version="${SAMPLE_APP_SERVER_VERSION:-${tuple[server]}}" +server_image="${SAMPLE_APP_SERVER_IMAGE:-durableworkflow/server:${server_version}}" +if [[ -z "${SAMPLE_APP_SERVER_VERSION:-}" ]]; then + detected_server_version="$(version_from_pin "$server_image")" + server_version="${detected_server_version:-$server_version}" fi -cli_pin="$(normalize_cli_pin "$cli_pin" "$cli_version")" +server_version="$(stable_version DURABLE_SERVER_VERSION "$server_version")" -python_sdk_version="${DURABLE_WORKFLOW_PYTHON_SDK_VERSION:-$current_python_sdk_version}" -rust_sdk_version="${DURABLE_WORKFLOW_RUST_SDK_VERSION:-$current_rust_sdk_version}" - -php_sdk_pin="${DURABLE_WORKFLOW_PHP_SDK_PIN:-}" -if [[ -n "${DURABLE_WORKFLOW_PHP_SDK_VERSION:-}" ]]; then - php_sdk_version="$DURABLE_WORKFLOW_PHP_SDK_VERSION" -elif [[ -n "$php_sdk_pin" ]]; then - php_sdk_version="$(semantic_version_from_text "$php_sdk_pin")" - php_sdk_version="${php_sdk_version:-$current_php_sdk_version}" -else - php_sdk_version="$current_php_sdk_version" -fi -if [[ -z "$php_sdk_pin" ]]; then - php_sdk_pin="durable-workflow/sdk:${php_sdk_version}@beta" +cli_pin="${SAMPLE_APP_CLI_PIN:-}" +cli_version="${SAMPLE_APP_CLI_VERSION:-}" +if [[ -z "$cli_version" && -n "$cli_pin" ]]; then + cli_version="$(version_from_pin "$cli_pin")" fi +cli_version="$(stable_version DURABLE_WORKFLOW_CLI_VERSION "${cli_version:-${tuple[cli]}}")" +cli_pin="${cli_pin:-dw==${cli_version}}" -workflow_pin="${DURABLE_WORKFLOW_WORKFLOW_PIN:-}" -if [[ -n "${DURABLE_WORKFLOW_WORKFLOW_VERSION:-}" ]]; then - workflow_version="$DURABLE_WORKFLOW_WORKFLOW_VERSION" -elif [[ -n "$workflow_pin" ]]; then - workflow_version="$(semantic_version_from_text "$workflow_pin")" - workflow_version="${workflow_version:-$current_workflow_version}" -else - workflow_version="$current_workflow_version" +php_pin="${SAMPLE_APP_PHP_SDK_PIN:-}" +php_version="${SAMPLE_APP_PHP_SDK_VERSION:-}" +if [[ -z "$php_version" && -n "$php_pin" ]]; then + php_version="$(version_from_pin "$php_pin")" fi -if [[ -z "$workflow_pin" ]]; then - workflow_pin="durable-workflow/workflow:${workflow_version}@beta" +php_version="$(stable_version DURABLE_WORKFLOW_PHP_SDK_VERSION "${php_version:-${tuple[sdk-php]}}")" +php_pin="${php_pin:-durable-workflow/sdk:${php_version}}" + +python_version="$(stable_version SAMPLE_APP_PYTHON_SDK_VERSION \ + "${SAMPLE_APP_PYTHON_SDK_VERSION:-${tuple[sdk-python]}}")" +rust_version="$(stable_version SAMPLE_APP_RUST_SDK_VERSION \ + "${SAMPLE_APP_RUST_SDK_VERSION:-${tuple[sdk-rust]}}")" + +workflow_pin="${SAMPLE_APP_WORKFLOW_PIN:-}" +workflow_version="${SAMPLE_APP_WORKFLOW_VERSION:-}" +if [[ -z "$workflow_version" && -n "$workflow_pin" ]]; then + workflow_version="$(version_from_pin "$workflow_pin")" fi - -waterline_pin="${DURABLE_WORKFLOW_WATERLINE_PIN:-}" -if [[ -n "${DURABLE_WORKFLOW_WATERLINE_VERSION:-}" ]]; then - waterline_version="$DURABLE_WORKFLOW_WATERLINE_VERSION" -elif [[ -n "$waterline_pin" ]]; then - waterline_version="$(semantic_version_from_text "$waterline_pin")" - waterline_version="${waterline_version:-$current_waterline_version}" -else - waterline_version="$current_waterline_version" +workflow_version="$(stable_version DURABLE_WORKFLOW_WORKFLOW_VERSION \ + "${workflow_version:-${tuple[workflow]}}")" +workflow_pin="${workflow_pin:-durable-workflow/workflow:${workflow_version}}" + +waterline_pin="${SAMPLE_APP_WATERLINE_PIN:-}" +waterline_version="${SAMPLE_APP_WATERLINE_VERSION:-}" +if [[ -z "$waterline_version" && -n "$waterline_pin" ]]; then + waterline_version="$(version_from_pin "$waterline_pin")" fi -if [[ -z "$waterline_pin" ]]; then - waterline_pin="durable-workflow/waterline:${waterline_version}@beta" -fi - -for name in \ - server_image server_version cli_version cli_pin php_sdk_version php_sdk_pin python_sdk_version rust_sdk_version workflow_version workflow_pin waterline_version waterline_pin -do - if [[ -z "${!name:-}" ]]; then - printf 'resolve-current-artifacts: failed to resolve %s from %s artifact source\n' "$name" "$artifact_source" >&2 - exit 1 - fi -done - -emit_assignment DURABLE_SERVER_IMAGE "$server_image" -emit_assignment DURABLE_SERVER_VERSION "$server_version" -emit_assignment DURABLE_WORKFLOW_CLI_VERSION "$cli_version" -emit_assignment DURABLE_WORKFLOW_CLI_PIN "$cli_pin" -emit_assignment DURABLE_WORKFLOW_PHP_SDK_VERSION "$php_sdk_version" -emit_assignment DURABLE_WORKFLOW_PHP_SDK_PIN "$php_sdk_pin" -emit_assignment DURABLE_WORKFLOW_PYTHON_SDK_VERSION "$python_sdk_version" -emit_assignment DURABLE_WORKFLOW_RUST_SDK_VERSION "$rust_sdk_version" -emit_assignment DURABLE_WORKFLOW_WORKFLOW_VERSION "$workflow_version" -emit_assignment DURABLE_WORKFLOW_WORKFLOW_PIN "$workflow_pin" -emit_assignment DURABLE_WORKFLOW_WATERLINE_VERSION "$waterline_version" -emit_assignment DURABLE_WORKFLOW_WATERLINE_PIN "$waterline_pin" +waterline_version="$(stable_version DURABLE_WORKFLOW_WATERLINE_VERSION \ + "${waterline_version:-${tuple[waterline]}}")" +waterline_pin="${waterline_pin:-durable-workflow/waterline:${waterline_version}}" + +emit DURABLE_SERVER_IMAGE "$server_image" +emit DURABLE_SERVER_VERSION "$server_version" +emit DURABLE_WORKFLOW_CLI_VERSION "$cli_version" +emit DURABLE_WORKFLOW_CLI_PIN "$cli_pin" +emit DURABLE_WORKFLOW_PHP_SDK_VERSION "$php_version" +emit DURABLE_WORKFLOW_PHP_SDK_PIN "$php_pin" +emit DURABLE_WORKFLOW_PYTHON_SDK_VERSION "$python_version" +emit DURABLE_WORKFLOW_RUST_SDK_VERSION "$rust_version" +emit DURABLE_WORKFLOW_WORKFLOW_VERSION "$workflow_version" +emit DURABLE_WORKFLOW_WORKFLOW_PIN "$workflow_pin" +emit DURABLE_WORKFLOW_WATERLINE_VERSION "$waterline_version" +emit DURABLE_WORKFLOW_WATERLINE_PIN "$waterline_pin" diff --git a/scripts/service-mode.sh b/scripts/service-mode.sh deleted file mode 100755 index 836fc43..0000000 --- a/scripts/service-mode.sh +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -compose_file="${repo_root}/polyglot/service-mode.yml" -evidence_path="${SERVICE_MODE_EVIDENCE_PATH:-${repo_root}/storage/app/service-mode-evidence.json}" -mkdir -p "$(dirname "$evidence_path")" -export SERVICE_MODE_EVIDENCE_DIR="$(cd "$(dirname "$evidence_path")" && pwd)" -evidence_name="$(basename "$evidence_path")" -browser_evidence_name="${evidence_name%.json}-waterline.png" -mount_evidence_name="${evidence_name%.json}-waterline-mount.json" -dialog_evidence_name="${evidence_name%.json}-waterline-dialogs" -run_detail_evidence_name="${evidence_name%.json}-waterline-run-detail" -export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-sample-app-service-mode}" -export SERVICE_MODE_PORT="${SERVICE_MODE_PORT:-18081}" - -sample_app_revision="${SERVICE_MODE_SAMPLE_APP_REVISION:-${GITHUB_SHA:-}}" -if [[ -z "$sample_app_revision" ]]; then - sample_app_revision="$(git -C "$repo_root" rev-parse HEAD)" -fi -if [[ ! "$sample_app_revision" =~ ^[0-9a-f]{40}$ ]]; then - echo "Service mode needs an exact 40-character Sample App revision; got ${sample_app_revision}." >&2 - exit 1 -fi - -if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then - echo 'Service mode needs Docker Engine with the Compose v2 plugin.' >&2 - exit 1 -fi - -artifact_source="${DURABLE_WORKFLOW_ARTIFACT_SOURCE:-pinned}" -while IFS= read -r assignment; do - export "$assignment" -done < <( - DURABLE_WORKFLOW_ARTIFACT_SOURCE="$artifact_source" \ - "${repo_root}/scripts/resolve-current-artifacts.sh" -) - -if [[ -z "${SERVICE_MODE_WATERLINE_URL:-}" ]]; then - if [[ -n "${CODESPACE_NAME:-}" ]]; then - codespaces_domain="${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-app.github.dev}" - export SERVICE_MODE_WATERLINE_URL="https://${CODESPACE_NAME}-${SERVICE_MODE_PORT}.${codespaces_domain}/waterline" - else - export SERVICE_MODE_WATERLINE_URL="http://localhost:${SERVICE_MODE_PORT}/waterline" - fi -fi - -compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" --file "$compose_file") - -run_phase() { - local phase="$1" - shift - - echo "==> ${phase}" - if "$@"; then - return 0 - else - local status=$? - echo "Service mode failed during phase: ${phase}." >&2 - return "$status" - fi -} - -diagnostics() { - local status=$? - if (( status != 0 )); then - echo >&2 - echo 'Service mode did not finish. Current container status:' >&2 - "${compose[@]}" ps >&2 || true - "${compose[@]}" logs --no-color --tail=80 \ - mysql observer-app-setup waterline-migrate server php-worker python-worker \ - waterline waterline-embedded >&2 || true - fi - return "$status" -} -trap diagnostics EXIT - -echo 'Starting the standalone Server, Laravel worker, Python worker, and Waterline...' -started_ms="$(date +%s%3N)" - -# Refresh moving public image tags and reuse them directly. This path has no -# Docker build step and never compiles an SDK or language runtime locally. -run_phase "published artifact pull" \ - "${compose[@]}" pull --quiet \ - mysql redis worker-app-setup observer-app-setup python-setup waterline-migrate \ - bootstrap server php-worker python-worker waterline waterline-embedded \ - journey browser-smoke -run_phase "previous service shutdown" "${compose[@]}" down --remove-orphans -run_phase "application and language setup" \ - "${compose[@]}" up --no-build --force-recreate \ - worker-app-setup observer-app-setup python-setup -run_phase "database readiness" \ - "${compose[@]}" up --detach --no-build --wait mysql redis -run_phase "Waterline database migrations" \ - "${compose[@]}" up --no-build --force-recreate --no-deps \ - --exit-code-from waterline-migrate waterline-migrate -run_phase "service startup and readiness" \ - "${compose[@]}" up --detach --no-build --wait \ - server php-worker python-worker waterline waterline-embedded - -installed_waterline_json="$( - "${compose[@]}" exec -T waterline php -r ' -require "vendor/autoload.php"; -echo json_encode([ - "package" => "durable-workflow/waterline", - "version" => \Composer\InstalledVersions::getPrettyVersion("durable-workflow/waterline"), - "reference" => \Composer\InstalledVersions::getReference("durable-workflow/waterline"), -], JSON_THROW_ON_ERROR); -' -)" -SERVICE_MODE_INSTALLED_WATERLINE_JSON="$installed_waterline_json" \ -node <<'NODE' -const installed = JSON.parse(process.env.SERVICE_MODE_INSTALLED_WATERLINE_JSON); -const expected = process.env.DURABLE_WORKFLOW_WATERLINE_VERSION; - -if ( - installed.package !== 'durable-workflow/waterline' - || installed.version !== expected - || !/^[0-9a-f]{40}$/.test(installed.reference || '') -) { - throw new Error( - `Installed Waterline identity ${JSON.stringify(installed)} does not match ${expected}.`, - ); -} -NODE - -startup_ms="$(( $(date +%s%3N) - started_ms ))" -echo "Ready in ${startup_ms} ms. Starting a unique Laravel workflow..." - -result_started_ms="$(date +%s%3N)" -journey_json="$("${compose[@]}" run --no-deps --rm -T journey)" -journey_elapsed_ms="$(( $(date +%s%3N) - result_started_ms ))" - -readarray -t waterline_paths < <(SERVICE_MODE_JOURNEY_JSON="$journey_json" node -e ' -const lines = process.env.SERVICE_MODE_JOURNEY_JSON.split(/\r?\n/).map(line => line.trim()).filter(Boolean); -const pagePath = new URL(JSON.parse(lines.at(-1)).waterline_url).pathname; -const apiPath = pagePath.replace("/flows/instances/", "/api/instances/"); -if (apiPath === pagePath) { - throw new Error(`Waterline run URL does not use the selected-run route: ${pagePath}`); -} -console.log(pagePath); -console.log(apiPath); -') -if (( ${#waterline_paths[@]} != 2 )); then - echo 'Could not derive the Waterline page and selected-run API paths.' >&2 - exit 1 -fi -waterline_page_path="${waterline_paths[0]}" -waterline_api_path="${waterline_paths[1]}" - -# Prove both the browser shell and its exact selected-run data are reachable -# before retaining a screenshot or reporting success to the user. -"${compose[@]}" exec -T waterline curl --fail --silent --show-error \ - "http://localhost:8081${waterline_page_path}" >/dev/null -waterline_selection_json="$( - "${compose[@]}" exec -T waterline curl --fail --silent --show-error \ - "http://localhost:8081${waterline_api_path}" -)" -SERVICE_MODE_JOURNEY_JSON="$journey_json" \ -SERVICE_MODE_WATERLINE_SELECTION_JSON="$waterline_selection_json" \ -node <<'NODE' -const journeyLines = process.env.SERVICE_MODE_JOURNEY_JSON - .split(/\r?\n/) - .map(line => line.trim()) - .filter(Boolean); -const journey = JSON.parse(journeyLines.at(-1)); -const selection = JSON.parse(process.env.SERVICE_MODE_WATERLINE_SELECTION_JSON); - -if ( - selection.instance_id !== journey.workflow_id - || selection.selected_run_id !== journey.run_id -) { - throw new Error( - `Waterline selected ${selection.instance_id ?? 'unknown'}/${selection.selected_run_id ?? 'unknown'}; ` - + `expected ${journey.workflow_id}/${journey.run_id}.`, - ); -} -NODE - -browser_started_ms="$(date +%s%3N)" -"${compose[@]}" run --no-deps --rm -T --entrypoint node browser-smoke \ - /observer/scripts/ci/waterline-mount-readiness.mjs \ - --base-url http://waterline:8081 \ - --screenshot "/evidence/${browser_evidence_name}" \ - --report "/evidence/${mount_evidence_name}" -browser_elapsed_ms="$(( $(date +%s%3N) - browser_started_ms ))" - -dialog_started_ms="$(date +%s%3N)" -"${compose[@]}" run --no-deps --rm -T --entrypoint node browser-smoke \ - /observer/scripts/ci/run-service-mode-dialog-visual.mjs \ - --base-url http://waterline:8081 \ - --output-dir "/evidence/${dialog_evidence_name}" -dialog_elapsed_ms="$(( $(date +%s%3N) - dialog_started_ms ))" - -run_detail_started_ms="$(date +%s%3N)" -"${compose[@]}" run --no-deps --rm -T --entrypoint node browser-smoke \ - /observer/vendor/durable-workflow/waterline/scripts/ci/run-detail-visual.mjs \ - --base-url http://waterline-embedded:8082 \ - --service-base-url http://waterline:8081 \ - --output-dir "/evidence/${run_detail_evidence_name}" -run_detail_elapsed_ms="$(( $(date +%s%3N) - run_detail_started_ms ))" - -SERVICE_MODE_JOURNEY_JSON="$journey_json" \ -SERVICE_MODE_SAMPLE_APP_REVISION="$sample_app_revision" \ -SERVICE_MODE_INSTALLED_WATERLINE_JSON="$installed_waterline_json" \ -SERVICE_MODE_STARTUP_MS="$startup_ms" \ -SERVICE_MODE_ELAPSED_MS="$journey_elapsed_ms" \ -SERVICE_MODE_BROWSER_MS="$browser_elapsed_ms" \ -SERVICE_MODE_BROWSER_SCREENSHOT="$browser_evidence_name" \ -SERVICE_MODE_MOUNT_EVIDENCE="$mount_evidence_name" \ -SERVICE_MODE_DIALOG_MS="$dialog_elapsed_ms" \ -SERVICE_MODE_DIALOG_EVIDENCE="${dialog_evidence_name}/summary.json" \ -SERVICE_MODE_RUN_DETAIL_MS="$run_detail_elapsed_ms" \ -SERVICE_MODE_RUN_DETAIL_EVIDENCE="${run_detail_evidence_name}/summary.json" \ -SERVICE_MODE_EVIDENCE_OUTPUT="$evidence_path" \ -node <<'NODE' -const fs = require('node:fs'); - -const lines = process.env.SERVICE_MODE_JOURNEY_JSON - .split(/\r?\n/) - .map(line => line.trim()) - .filter(Boolean); -const result = JSON.parse(lines.at(-1)); -const evidence = { - schema: 'durable-workflow.sample-app.service-mode-evidence.v2', - captured_at: new Date().toISOString(), - compose_project: process.env.COMPOSE_PROJECT_NAME, - consumer: { - repository: 'durable-workflow/sample-app', - revision: process.env.SERVICE_MODE_SAMPLE_APP_REVISION, - }, - installed: { - waterline: JSON.parse(process.env.SERVICE_MODE_INSTALLED_WATERLINE_JSON), - }, - ci: { - event_name: process.env.GITHUB_EVENT_NAME || null, - ref: process.env.GITHUB_REF || null, - run_id: process.env.GITHUB_RUN_ID || null, - run_attempt: process.env.GITHUB_RUN_ATTEMPT || null, - }, - public_completion_gate: 'https://github.com/durable-workflow/waterline/issues/79', - startup_ms: Number(process.env.SERVICE_MODE_STARTUP_MS), - journey_ms: Number(process.env.SERVICE_MODE_ELAPSED_MS), - browser_ms: Number(process.env.SERVICE_MODE_BROWSER_MS), - browser_screenshot: process.env.SERVICE_MODE_BROWSER_SCREENSHOT, - mount_evidence: process.env.SERVICE_MODE_MOUNT_EVIDENCE, - dialog_ms: Number(process.env.SERVICE_MODE_DIALOG_MS), - dialog_evidence: process.env.SERVICE_MODE_DIALOG_EVIDENCE, - run_detail_ms: Number(process.env.SERVICE_MODE_RUN_DETAIL_MS), - run_detail_evidence: process.env.SERVICE_MODE_RUN_DETAIL_EVIDENCE, - workflow: result, - artifacts: { - server: process.env.DURABLE_SERVER_IMAGE, - sdk_php: process.env.DURABLE_WORKFLOW_PHP_SDK_VERSION, - sdk_python: process.env.DURABLE_WORKFLOW_PYTHON_SDK_VERSION, - workflow: process.env.DURABLE_WORKFLOW_WORKFLOW_VERSION, - waterline: process.env.DURABLE_WORKFLOW_WATERLINE_VERSION, - }, -}; - -fs.writeFileSync( - process.env.SERVICE_MODE_EVIDENCE_OUTPUT, - `${JSON.stringify(evidence, null, 2)}\n`, -); - -console.log(`Completed workflow ${result.workflow_id} in ${result.result_ms} ms.`); -console.log(`PHP activity: ${result.result.php_activity.greeting}`); -console.log(`Python activity: ${result.result.python_activity.message}`); -console.log(`Inspect this exact run in Waterline: ${result.waterline_url}`); -console.log(`Browser proof: ${process.env.SERVICE_MODE_EVIDENCE_DIR}/${process.env.SERVICE_MODE_BROWSER_SCREENSHOT}`); -console.log(`Startup and result timings: ${process.env.SERVICE_MODE_EVIDENCE_OUTPUT}`); -NODE - -trap - EXIT diff --git a/scripts/setup-service-mode-app.sh b/scripts/setup-service-mode-app.sh deleted file mode 100755 index e7dfa74..0000000 --- a/scripts/setup-service-mode-app.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -role="${1:?Usage: setup-service-mode-app.sh worker|observer}" - -composer_flags=( - --with-all-dependencies - --no-dev - --no-scripts - --no-autoloader - --prefer-dist - --no-interaction -) - -case "$role" in - worker) - : "${DURABLE_WORKFLOW_PHP_SDK_VERSION:?Resolve the current PHP SDK version first}" - : "${DURABLE_WORKFLOW_WORKFLOW_VERSION:?Resolve the current Workflow version first}" - composer remove --no-update durable-workflow/waterline - composer require --no-update \ - "durable-workflow/sdk:${DURABLE_WORKFLOW_PHP_SDK_VERSION}" \ - "durable-workflow/workflow:${DURABLE_WORKFLOW_WORKFLOW_VERSION}" - composer update durable-workflow/sdk durable-workflow/workflow "${composer_flags[@]}" - - # The service worker registers only framework-neutral SDK handlers. Its - # transient application copy does not boot the observer-only Waterline - # provider; the Workflow package remains for the root app's shared routes. - php -r ' -$path = "bootstrap/providers.php"; -$source = file_get_contents($path); -if (! is_string($source)) { - throw new RuntimeException("Could not read {$path}."); -} -$source = str_replace(" App\\Providers\\WaterlineServiceProvider::class,\n", "", $source); -file_put_contents($path, $source); -' - ;; - observer) - : "${DURABLE_WORKFLOW_PHP_SDK_VERSION:?Resolve the current PHP SDK version first}" - : "${DURABLE_WORKFLOW_WORKFLOW_VERSION:?Resolve the current Workflow version first}" - : "${DURABLE_WORKFLOW_WATERLINE_VERSION:?Resolve the current Waterline version first}" - composer require --no-update \ - "durable-workflow/sdk:${DURABLE_WORKFLOW_PHP_SDK_VERSION}" \ - "durable-workflow/workflow:${DURABLE_WORKFLOW_WORKFLOW_VERSION}" \ - "durable-workflow/waterline:${DURABLE_WORKFLOW_WATERLINE_VERSION}" - composer update \ - durable-workflow/sdk \ - durable-workflow/workflow \ - durable-workflow/waterline \ - "${composer_flags[@]}" - ;; - *) - echo "Unknown service-mode application role: ${role}" >&2 - exit 2 - ;; -esac - -composer dump-autoload --no-dev --optimize --no-interaction - -if [[ "$role" == observer ]]; then - # The observer must serve the assets that belong to the package Composer - # just installed. A copied application may contain assets from an older - # lock, and --no-scripts intentionally skips Laravel's publish hook. - php artisan waterline:publish --no-interaction -fi diff --git a/scripts/setup-service-mode-python.sh b/scripts/setup-service-mode-python.sh deleted file mode 100755 index ba38f2c..0000000 --- a/scripts/setup-service-mode-python.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -runtime_dir="${SERVICE_MODE_PYTHON_RUNTIME_DIR:-/runtime}" -python_binary="${SERVICE_MODE_PYTHON_BINARY:-python}" -semantic_version="${DURABLE_WORKFLOW_PYTHON_SDK_VERSION:?Resolve the current Python SDK version first}" - -prerelease="${semantic_version#*-}" -case "$prerelease" in - alpha.*) - python_version="${semantic_version%%-*}a${prerelease#alpha.}" - ;; - beta.*) - python_version="${semantic_version%%-*}b${prerelease#beta.}" - ;; - rc.*) - python_version="${semantic_version%%-*}rc${prerelease#rc.}" - ;; - *) - echo "Unsupported Python SDK version: ${semantic_version}" >&2 - exit 2 - ;; -esac - -installed="" -if [ -x "${runtime_dir}/bin/python" ]; then - installed="$( - "${runtime_dir}/bin/python" -c \ - 'import importlib.metadata as m; print(m.version("durable-workflow"))' \ - 2>/dev/null || true - )" -fi - -if [ "$installed" = "$python_version" ]; then - echo "Reusing durable-workflow ${python_version} from ${runtime_dir}." - exit 0 -fi - -find "$runtime_dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- '{}' + -"$python_binary" -m venv "$runtime_dir" -"${runtime_dir}/bin/pip" install \ - --no-input \ - --disable-pip-version-check \ - "durable-workflow==${python_version}" - -installed="$( - "${runtime_dir}/bin/python" -c \ - 'import importlib.metadata as m; print(m.version("durable-workflow"))' -)" -if [ "$installed" != "$python_version" ]; then - echo "Installed Python SDK ${installed}; expected ${python_version}." >&2 - exit 1 -fi - -chmod -R a+rX "$runtime_dir" diff --git a/tests/Feature/ConformanceHarnessContractTest.php b/tests/Feature/ConformanceHarnessContractTest.php deleted file mode 100644 index b26400a..0000000 --- a/tests/Feature/ConformanceHarnessContractTest.php +++ /dev/null @@ -1,185 +0,0 @@ -assertIsString($script); - $this->assertIsString($combinedScript); - $this->assertIsString($artifactResolver); - $this->assertIsString($smokeScript); - $this->assertIsString($smokeWorkflow); - $this->assertIsString($command); - $this->assertIsString($dockerfile); - $this->assertIsString($compose); - $this->assertIsString($aiCommand); - $this->assertIsString($aiWorkflow); - $this->assertIsString($travelAgentActivity); - - $this->assertStringContainsString('app:conformance', $script); - $this->assertStringContainsString('--output="${metadata_container_path}"', $script); - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_URL:-http://sample-app:8000', $script); - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_METADATA_PATH:-storage/app/sample-app-conformance-metadata.json', $script); - $this->assertStringContainsString('load_conformance_env', $script); - $this->assertStringContainsString('while [[ -n "$dir" && "$dir" != "/" ]]', $script); - $this->assertStringContainsString('build_runtime_image_for_artifact_tuple', $script); - $this->assertStringContainsString('start_services_and_wait_for_readiness', $script); - $this->assertStringContainsString('docker compose build app', $script); - $this->assertStringContainsString('docker compose up -d --no-build --wait app worker', $script); - $this->assertStringContainsString('prepared_stack_is_reusable', $script); - $this->assertStringContainsString('SAMPLE_APP_SETUP_PEAK_DISK_GROWTH_BYTES', $script); - $this->assertStringContainsString('SAMPLE_APP_SETUP_BUILD_DURATION_MS', $script); - $this->assertStringContainsString('SAMPLE_APP_SETUP_READINESS_DURATION_MS', $script); - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_SMOKE_FIRST=1', $combinedScript); - $this->assertStringContainsString('exec scripts/compose-conformance.sh "$@"', $combinedScript); - $this->assertStringContainsString('-e OPENAI_API_KEY', $script); - $this->assertStringContainsString('docker compose cp "app:${metadata_container_abs}" "$metadata_path"', $script); - $this->assertStringContainsString('DW_AGENT_OPERABILITY_SAMPLE_APP_METADATA_PATH=%s', $script); - $this->assertStringContainsString('SAMPLE_APP_SMOKE_ONLY', $smokeScript); - $this->assertStringNotContainsString('has_conformance_key', $smokeScript); - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_AFTER_SMOKE', $smokeScript); - $this->assertStringContainsString('compose-smoke: all deterministic sample workflows passed', $smokeScript); - $this->assertStringContainsString('compose_diagnostics()', $smokeScript); - $this->assertStringContainsString('scripts/compose-conformance.sh', $smokeScript); - $this->assertStringContainsString('SAMPLE_APP_SMOKE_ONLY: 1', $smokeWorkflow); - $this->assertStringContainsString('git rev-parse HEAD', $script); - $this->assertStringContainsString('export SAMPLE_APP_COMMIT="$sample_app_commit"', $script); - $this->assertStringContainsString('SAMPLE_APP_COMMIT="${sample_app_commit}"', $script); - $this->assertStringContainsString('ARG SAMPLE_APP_COMMIT=', $dockerfile); - $this->assertStringContainsString('ENV SAMPLE_APP_COMMIT=${SAMPLE_APP_COMMIT}', $dockerfile); - $this->assertStringContainsString('SAMPLE_APP_COMMIT: ${SAMPLE_APP_COMMIT:-}', $compose); - $this->assertStringContainsString('scripts/resolve-current-artifacts.sh', $script); - $this->assertStringContainsString('DURABLE_WORKFLOW_ARTIFACT_SOURCE', $artifactResolver); - $this->assertStringContainsString('DURABLE_WORKFLOW_RESOLVE_LATEST', $artifactResolver); - $this->assertStringContainsString('DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE', $artifactResolver); - $this->assertStringContainsString('must expose one 2.0 beta or release-candidate channel', $artifactResolver); - $this->assertStringContainsString('must expose one synchronized 2.0 beta version', $artifactResolver); - $this->assertStringContainsString('https://durable-workflow.com/docs-page-release-audit.json', $artifactResolver); - $this->assertStringNotContainsString('latest_dockerhub_server_version', $artifactResolver); - $this->assertStringNotContainsString('latest_github_release_version durable-workflow/cli', $artifactResolver); - $this->assertStringNotContainsString('latest_pypi_version durable-workflow', $artifactResolver); - $this->assertStringNotContainsString('latest_packagist_prerelease_version durable-workflow/workflow', $artifactResolver); - $this->assertStringNotContainsString('latest_packagist_prerelease_version durable-workflow/waterline', $artifactResolver); - $this->assertStringContainsString( - 'pinned_artifact_tuple_file="${repo_root}/polyglot/qualified-artifact-tuple.json"', - $artifactResolver, - ); - $this->assertStringContainsString('load_artifact_tuple_file "$pinned_artifact_tuple_file"', $artifactResolver); - - $pinnedArtifactTuple = json_decode( - (string) file_get_contents(__DIR__.'/../../polyglot/qualified-artifact-tuple.json'), - true, - flags: JSON_THROW_ON_ERROR, - ); - $this->assertSame( - ['cli', 'sdk-php', 'sdk-python', 'sdk-rust', 'server', 'waterline', 'workflow'], - array_keys($pinnedArtifactTuple['artifacts'] ?? []), - ); - foreach ($pinnedArtifactTuple['artifacts'] as $version) { - $this->assertMatchesRegularExpression('/^2\.0\.0-(?:beta|rc)\.\d+$/', $version); - } - $this->assertStringContainsString('--allow-skips', $script); - $this->assertStringContainsString('-e DURABLE_WORKFLOW_PYTHON_SDK_VERSION', $script); - $this->assertStringContainsString('-e DURABLE_WORKFLOW_RUST_SDK_VERSION', $script); - $this->assertStringContainsString('-e DURABLE_WORKFLOW_PHP_SDK_VERSION', $script); - $this->assertStringContainsString('-e DURABLE_WORKFLOW_WORKFLOW_VERSION', $script); - $this->assertStringContainsString("'sdk-rust' => \$this->envString('DURABLE_WORKFLOW_RUST_SDK_VERSION')", $command); - $this->assertStringContainsString("'sdk-php' => \$this->installedVersion('durable-workflow/sdk')", $command); - $this->assertStringContainsString('-e DURABLE_WORKFLOW_WATERLINE_VERSION', $script); - $this->assertStringContainsString('durable-workflow.sample-app.conformance.run', $command); - $this->assertStringContainsString('{--allow-skips', $command); - $this->assertStringContainsString("envString('SAMPLE_APP_COMMIT')", $command); - $this->assertStringContainsString('active_payload_codec', $command); - $this->assertStringContainsString("'setup' => \$this->setupMetrics()", $command); - $this->assertStringContainsString('DOCUMENTED_MCP_TOOLS', $command); - $this->assertStringContainsString('DOCUMENTED_WORKFLOW_KEYS', $command); - $this->assertStringContainsString('required_surfaces', $command); - $this->assertStringContainsString('missing_surfaces', $command); - $this->assertStringContainsString('uncovered_surfaces', $command); - $this->assertStringContainsString('focused findings', $command); - $this->assertStringContainsString('failedSurfaceImpact', $command); - $this->assertStringContainsString('api_documentation', $command); - $this->assertStringContainsString('runApiDocumentationSurface', $command); - $this->assertStringContainsString('get_workflow_history', $command); - $this->assertStringContainsString('diagnose_workflow', $command); - $this->assertStringContainsString('repair_workflow', $command); - $this->assertStringContainsString('durable-workflow.v2.agent-root-cause', $command); - $this->assertStringContainsString('durable-workflow.v2.agent-remediation', $command); - $this->assertStringContainsString('durable-workflow.v2.safe-mutation', $command); - $this->assertStringContainsString('agent_loop_steps', $command); - $this->assertStringContainsString('agent_loop_evidence', $command); - $this->assertStringContainsString('artifact_install_evidence', $command); - $this->assertStringContainsString('local_product_source_checkouts_used', $command); - $this->assertStringContainsString('sampleAppRevisionSource', $command); - $this->assertStringContainsString('diagnostic_failure', $command); - $this->assertStringContainsString('agent-operability-induced-failure', $command); - $this->assertStringContainsString('workflow_completed', $command); - $this->assertStringContainsString('failure_workflow_failed', $command); - $this->assertStringContainsString('runWaterlineManualObservationSurface', $command); - $this->assertStringContainsString('waterline_manual_observation', $command); - $this->assertStringContainsString('workflow:v2:history-export', $command); - $this->assertStringContainsString('durable-workflow.v2.history-export', $command); - $this->assertStringContainsString('AI_CONFORMANCE_BOOKING_PLAN', $command); - $this->assertStringContainsString('--booking-plan-json={$bookingPlanJson}', $command); - $this->assertStringContainsString("'--inactivity-timeout=5'", $command); - $this->assertStringContainsString("'--inactivity-timeout=1'", $command); - $this->assertStringContainsString('AI_FAILURE_PROCESS_TIMEOUT_SECONDS = 180', $command); - $this->assertStringContainsString('SANDBOX_PROCESS_TIMEOUT_SECONDS = 300', $command); - $this->assertStringContainsString('--wait-seconds=180', $command); - $this->assertStringContainsString('{--booking-plan-json=', $aiCommand); - $this->assertStringContainsString('$workflow->start($injectFailure, $inactivityTimeout, $bookingPlan)', $aiCommand); - $this->assertStringContainsString('bookingPlanOption', $aiCommand); - $this->assertStringContainsString('printedAssistantMessageSequences', $aiCommand); - $this->assertStringContainsString('printLatestAssistantMessage($workflow, onlyNew: true)', $aiCommand); - $this->assertStringContainsString('latestAssistantMessageRecord', $aiCommand); - $this->assertStringContainsString('?array $bookingPlan = null', $aiWorkflow); - $this->assertStringContainsString('TravelAgentActivity::class, $messages, $bookingPlan', $aiWorkflow); - $this->assertStringContainsString('public function handle(array $messages, ?array $bookingPlan = null)', $travelAgentActivity); - $this->assertStringContainsString('json_encode($bookingPlan, JSON_THROW_ON_ERROR)', $travelAgentActivity); - - foreach ([ - 'browser_welcome', - 'browser_waterline', - 'waterline_manual_observation', - 'mcp_workflow_api', - 'api_webhook', - 'prism_ai', - 'ai_agent_scripted', - 'ai_failure_hotel', - 'ai_failure_flight', - 'ai_failure_car', - 'sandbox_default', - 'sandbox_snapshot', - 'sandbox_recovery_injection', - 'waterline_operator_dashboard', - 'artifactVersions', - 'skipped_surfaces', - ] as $needle) { - $this->assertStringContainsString($needle, $command); - } - - $sandboxCommand = file_get_contents(__DIR__.'/../../app/Console/Commands/Sandbox.php'); - - $this->assertIsString($sandboxCommand); - $this->assertStringContainsString('{--wait-seconds=180', $sandboxCommand); - $this->assertStringContainsString('Workflow still running after %d seconds', $sandboxCommand); - } -} diff --git a/tests/Node/ServiceModeDialogVisualTest.mjs b/tests/Node/ServiceModeDialogVisualTest.mjs deleted file mode 100644 index 7b951b6..0000000 --- a/tests/Node/ServiceModeDialogVisualTest.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import test from 'node:test'; - -import { adaptWaterlineDialogAudit } from '../../scripts/ci/run-service-mode-dialog-visual.mjs'; - -const installedAuditPath = new URL( - '../../vendor/durable-workflow/waterline/scripts/ci/workflow-list-dialog-visual.mjs', - import.meta.url, -); - -test('audits only stable dialogs and retains rejected geometry', () => { - const source = fs.readFileSync(installedAuditPath, 'utf8'); - const adapted = adaptWaterlineDialogAudit(source); - const geometryAssignment = adapted.indexOf( - 'geometry = await auditModalGeometry(page, dialog, viewport);', - ); - const geometryRejection = adapted.indexOf( - "throw new Error(`Dialog geometry failed: ${geometry.failures.join('; ')}`);", - geometryAssignment, - ); - - assert.match(adapted, /getAnimations\(\{ subtree: true \}\)/); - assert.match(adapted, /stableFrames >= 3/); - assert.ok(geometryAssignment >= 0); - assert.ok(geometryRejection > geometryAssignment); - assert.equal( - adapted.indexOf( - "throw new Error(`Dialog geometry failed: ${geometry.failures.join('; ')}`);", - ), - geometryRejection, - ); -}); - -test('rejects an unsupported installed dialog audit', () => { - assert.throws( - () => adaptWaterlineDialogAudit('export const unrelatedAudit = true;'), - /unsupported validation contract/, - ); -}); diff --git a/tests/Node/ServiceModeRunDetailVisualTest.mjs b/tests/Node/ServiceModeRunDetailVisualTest.mjs deleted file mode 100644 index 50d62bc..0000000 --- a/tests/Node/ServiceModeRunDetailVisualTest.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - NAVIGATION_STATES, - PRESENTATIONS, - STATES, - STREAM_RESULTS, - VIEWPORTS, - runDetailFixture, -} from '../../vendor/durable-workflow/waterline/scripts/ci/run-detail-visual.mjs'; - -test('installed Waterline qualifies every responsive run-detail state', () => { - assert.deepEqual(VIEWPORTS, [ - { name: 'desktop', width: 1440, height: 900 }, - { name: 'intermediate', width: 768, height: 1024 }, - { name: 'mobile', width: 390, height: 844 }, - { name: 'short-height', width: 1280, height: 360 }, - ]); - assert.deepEqual(NAVIGATION_STATES, [ - { name: 'initial', fragment: null }, - { name: 'deep-section', fragment: 'workflowStreams' }, - ]); - assert.deepEqual(PRESENTATIONS, ['embedded', 'service']); - assert.deepEqual(STREAM_RESULTS, [ - 'populated', - 'supported-empty', - 'unavailable', - 'degraded', - ]); - assert.deepEqual( - STATES.map(({ name, presentation, result, expanded }) => ({ - name, - presentation, - result, - expanded, - })), - PRESENTATIONS.flatMap((presentation) => [ - ...STREAM_RESULTS.map((result) => ({ - name: `${presentation}-${result}-expanded`, - presentation, - result, - expanded: true, - })), - { - name: `${presentation}-populated-collapsed`, - presentation, - result: 'populated', - expanded: false, - }, - ]), - ); - assert.equal( - VIEWPORTS.length * NAVIGATION_STATES.length * STATES.length, - 80, - ); -}); - -test('installed Waterline run-detail fixture exercises service Workflow Streams', () => { - const fixture = runDetailFixture('service-populated'); - - assert.equal(fixture.workflow_streams_mode, 'service'); - assert.equal(fixture.workflow_streams_available, true); - assert.ok(fixture.workflow_streams.length > 0); - assert.ok(fixture.workflow_streams.some(({ status }) => status === 'errored')); -}); diff --git a/tests/Unit/CodespacesOnboardingContractTest.php b/tests/Unit/CodespacesOnboardingContractTest.php deleted file mode 100644 index 41c5f3d..0000000 --- a/tests/Unit/CodespacesOnboardingContractTest.php +++ /dev/null @@ -1,104 +0,0 @@ - - */ - public static function deploymentPaths(): iterable - { - yield 'service mode' => ['service-mode', 'scripts/polyglot.sh']; - yield 'embedded Laravel' => ['embedded-laravel', 'composer run dev']; - } - - #[DataProvider('deploymentPaths')] - public function test_each_codespaces_deployment_path_leads_with_its_supported_command( - string $path, - string $command, - ): void { - $onboarding = $this->codespacesOnboarding(); - $sections = $this->deploymentPathSections($onboarding); - - $this->assertArrayHasKey($path, $sections); - $this->assertStringContainsString($command, $sections[$path]); - } - - public function test_codespaces_onboarding_exposes_two_top_level_deployment_paths(): void - { - $onboarding = $this->codespacesOnboarding(); - - preg_match_all('/^### /m', $onboarding, $topLevelPathHeadings); - - $this->assertSame( - ['service-mode', 'embedded-laravel'], - array_keys($this->deploymentPathSections($onboarding)), - ); - $this->assertCount(2, $topLevelPathHeadings[0]); - } - - public function test_service_mode_contains_polyglot_demo_and_symmetric_authoring_tools(): void - { - $serviceMode = $this->deploymentPathSections($this->codespacesOnboarding())['service-mode']; - $primaryCommand = strpos($serviceMode, 'scripts/polyglot.sh'); - $variationHeading = strpos($serviceMode, '#### '); - $variationCommand = strpos($serviceMode, 'scripts/service-mode.sh'); - - $this->assertIsInt($primaryCommand); - $this->assertIsInt($variationHeading); - $this->assertIsInt($variationCommand); - $this->assertGreaterThan($primaryCommand, $variationHeading); - $this->assertGreaterThan($variationHeading, $variationCommand); - $this->assertStringContainsString( - 'polyglot/README.md#complete-runtime-matrix', - $serviceMode, - ); - $this->assertStringContainsString('scripts/playground php', $serviceMode); - } - - private function codespacesOnboarding(): string - { - $readme = (string) file_get_contents($this->repoPath('README.md')); - preg_match_all('/^## /m', $readme, $sectionHeadings, PREG_OFFSET_CAPTURE); - - $this->assertGreaterThanOrEqual(2, count($sectionHeadings[0])); - - $start = $sectionHeadings[0][0][1]; - $end = $sectionHeadings[0][1][1]; - - return substr($readme, $start, $end - $start); - } - - /** - * @return array - */ - private function deploymentPathSections(string $onboarding): array - { - preg_match_all( - '/^\R(?=### )/m', - $onboarding, - $markers, - PREG_OFFSET_CAPTURE, - ); - - $sections = []; - foreach ($markers['path'] as $index => [$path]) { - $start = $markers[0][$index][1]; - $end = $markers[0][$index + 1][1] ?? strlen($onboarding); - $sections[$path] = substr($onboarding, $start, $end - $start); - } - - return $sections; - } - - private function repoPath(string $path): string - { - return dirname(__DIR__, 2).'/'.$path; - } -} diff --git a/tests/Unit/ComposeConformanceBudgetTest.php b/tests/Unit/ComposeConformanceBudgetTest.php deleted file mode 100644 index ee64571..0000000 --- a/tests/Unit/ComposeConformanceBudgetTest.php +++ /dev/null @@ -1,394 +0,0 @@ -runHarness('slow-success', [ - 'SAMPLE_APP_SERVICE_REBUILD_TIMEOUT_SECONDS' => '1', - 'SAMPLE_APP_RUNTIME_BUILD_TIMEOUT_SECONDS' => '3', - 'SAMPLE_APP_SERVICE_READINESS_TIMEOUT_SECONDS' => '2', - ]); - - $this->assertSame(0, $result['exit_code'], $result['output']); - $this->assertStringNotContainsString('timed out after', $result['output']); - $this->assertMatchesRegularExpression( - '/setup metrics .*build_duration_ms=\d+ readiness_duration_ms=\d+.*build_invocations=1/', - $result['output'], - ); - - preg_match( - '/build_duration_ms=(?\d+) readiness_duration_ms=(?\d+)/', - $result['output'], - $matches, - ); - $buildDuration = (int) ($matches['build'] ?? 0); - $readinessDuration = (int) ($matches['readiness'] ?? 0); - - $this->assertGreaterThanOrEqual(1000, $buildDuration); - $this->assertGreaterThanOrEqual(400, $readinessDuration); - $this->assertGreaterThan(1000, $buildDuration + $readinessDuration); - - $this->assertSame(1, substr_count($result['commands'], 'compose build app')); - $this->assertStringNotContainsString('compose build worker', $result['commands']); - $this->assertStringContainsString( - 'compose up -d --no-build --wait app worker', - $result['commands'], - ); - $this->assertStringNotContainsString('compose up -d --build', $result['commands']); - $this->assertLessThan( - strpos($result['commands'], 'compose up -d --no-build --wait app worker'), - strpos($result['commands'], 'compose build app'), - ); - } - - public function test_runtime_build_timeout_fails_closed_with_build_diagnostics(): void - { - $result = $this->runHarness('build-timeout', [ - 'SAMPLE_APP_RUNTIME_BUILD_TIMEOUT_SECONDS' => '1', - ]); - - $this->assertSame(124, $result['exit_code'], $result['output']); - $this->assertStringContainsString( - 'building shared app and worker runtime image with resolved artifact tuple timed out after 1s', - $result['output'], - ); - $this->assertStringNotContainsString( - 'compose up -d --no-build --wait app worker', - $result['commands'], - ); - } - - public function test_service_readiness_timeout_fails_closed_with_readiness_diagnostics(): void - { - $result = $this->runHarness('readiness-timeout', [ - 'SAMPLE_APP_SERVICE_READINESS_TIMEOUT_SECONDS' => '1', - ]); - - $this->assertSame(124, $result['exit_code'], $result['output']); - $this->assertStringContainsString( - 'starting app and worker services and waiting for readiness timed out after 1s', - $result['output'], - ); - $this->assertStringContainsString('compose build app', $result['commands']); - } - - public function test_explicit_ai_skip_ignores_an_ancestor_dotenv_credential(): void - { - $credential = 'synthetic-provider-credential-'.bin2hex(random_bytes(24)); - $result = $this->runHarness('success', [ - 'SAMPLE_APP_CONFORMANCE_SKIP_AI' => '1', - ], $credential, []); - - $this->assertSkippedAiConformance($result, $credential); - } - - public function test_combined_release_proof_succeeds_without_a_provider_and_records_ai_skips(): void - { - $result = $this->runHarness( - 'success', - entryPoint: 'scripts/compose-smoke-conformance.sh', - ); - - $this->assertSkippedAiConformance($result); - $this->assertStringContainsString('php artisan app:workflow', $result['commands']); - } - - public function test_an_ambient_ancestor_dotenv_credential_does_not_opt_in_to_ai(): void - { - $credential = 'synthetic-provider-credential-'.bin2hex(random_bytes(24)); - $result = $this->runHarness('success', [], $credential, []); - - $this->assertSkippedAiConformance($result, $credential); - } - - public function test_provider_conformance_remains_available_through_explicit_opt_in(): void - { - $credential = 'synthetic-provider-credential-'.bin2hex(random_bytes(24)); - $result = $this->runHarness('success', [ - 'SAMPLE_APP_CONFORMANCE_SKIP_AI' => '0', - ], $credential, ['--strict'], 'scripts/compose-smoke-conformance.sh'); - - $this->assertSame(0, $result['exit_code'], $result['output']); - $this->assertStringContainsString('OPENAI_API_KEY_STATE=matched', $result['commands']); - $this->assertStringContainsString('provider-command app:prism', $result['commands']); - $this->assertStringNotContainsString($credential, $result['commands']); - $this->assertStringNotContainsString($credential, $result['output']); - - $metadata = json_decode($result['metadata'], true, flags: JSON_THROW_ON_ERROR); - - $this->assertSame('passed', $metadata['summary']['status'] ?? null); - $this->assertTrue($metadata['summary']['strict'] ?? null); - $this->assertSame('passed', $metadata['surfaces']['prism_ai']['status'] ?? null); - $this->assertSame([], $metadata['summary']['skipped_surfaces'] ?? null); - } - - public function test_strict_provider_mode_fails_when_provider_evidence_is_missing(): void - { - $result = $this->runHarness('success', [ - 'SAMPLE_APP_CONFORMANCE_ALLOW_SKIPS' => '1', - 'SAMPLE_APP_CONFORMANCE_SKIP_AI' => '0', - ], arguments: ['--strict'], entryPoint: 'scripts/compose-smoke-conformance.sh'); - - $this->assertSame(1, $result['exit_code'], $result['output']); - - $metadata = json_decode($result['metadata'], true, flags: JSON_THROW_ON_ERROR); - - $this->assertSame('failed', $metadata['summary']['status'] ?? null); - $this->assertTrue($metadata['summary']['strict'] ?? null); - $this->assertSame('skipped', $metadata['surfaces']['prism_ai']['status'] ?? null); - $this->assertSame(['prism_ai'], $metadata['summary']['uncovered_surfaces'] ?? null); - } - - public function test_provider_mode_can_allow_missing_provider_evidence_for_exploration(): void - { - $result = $this->runHarness('success', [ - 'SAMPLE_APP_CONFORMANCE_ALLOW_SKIPS' => '1', - 'SAMPLE_APP_CONFORMANCE_SKIP_AI' => '0', - ], entryPoint: 'scripts/compose-smoke-conformance.sh'); - - $this->assertSame(0, $result['exit_code'], $result['output']); - - $metadata = json_decode($result['metadata'], true, flags: JSON_THROW_ON_ERROR); - - $this->assertSame('passed', $metadata['summary']['status'] ?? null); - $this->assertFalse($metadata['summary']['strict'] ?? null); - $this->assertSame('skipped', $metadata['surfaces']['prism_ai']['status'] ?? null); - $this->assertSame(['prism_ai'], $metadata['summary']['uncovered_surfaces'] ?? null); - } - - public function test_strict_coverage_is_rejected_when_ai_is_intentionally_skipped(): void - { - $result = $this->runHarness('success', arguments: ['--strict']); - - $this->assertSame(2, $result['exit_code'], $result['output']); - $this->assertSame('', $result['commands']); - $this->assertSame('', $result['metadata']); - } - - /** - * @param array $overrides - * @param list $arguments - * @return array{exit_code: int, output: string, commands: string, metadata: string} - */ - private function runHarness( - string $mode, - array $overrides = [], - ?string $ancestorCredential = null, - array $arguments = [], - string $entryPoint = 'scripts/compose-conformance.sh', - ): array { - $temporaryDirectory = sys_get_temp_dir().'/compose-conformance-budget-'.bin2hex(random_bytes(6)); - $dockerPath = $temporaryDirectory.'/docker'; - $logPath = $temporaryDirectory.'/docker.log'; - $metadataPath = $temporaryDirectory.'/metadata.json'; - $configuredEnvPath = $temporaryDirectory.'/configured.env'; - $workingDirectory = $this->repoPath(); - - mkdir($temporaryDirectory, 0700, true); - if ($ancestorCredential !== null) { - $ancestorDirectory = $temporaryDirectory.'/workspace'; - $workingDirectory = $ancestorDirectory.'/sample-app'; - mkdir($workingDirectory, 0700, true); - file_put_contents($ancestorDirectory.'/.env', "OPENAI_API_KEY={$ancestorCredential}\n"); - file_put_contents($configuredEnvPath, "OPENAI_API_KEY={$ancestorCredential}\n"); - symlink($this->repoPath('scripts'), $workingDirectory.'/scripts'); - } - - file_put_contents($dockerPath, <<<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -credential_state="absent" -if [[ -n "${OPENAI_API_KEY:-}" ]]; then - credential_state="present" - if [[ -n "${SAMPLE_APP_FAKE_EXPECTED_CREDENTIAL:-}" && "$OPENAI_API_KEY" == "$SAMPLE_APP_FAKE_EXPECTED_CREDENTIAL" ]]; then - credential_state="matched" - fi -fi -printf 'OPENAI_API_KEY_STATE=%s ' "$credential_state" >> "$SAMPLE_APP_FAKE_DOCKER_LOG" -printf '%q ' "$@" >> "$SAMPLE_APP_FAKE_DOCKER_LOG" -printf '\n' >> "$SAMPLE_APP_FAKE_DOCKER_LOG" - -if [[ "${1:-}" == "compose" && "${2:-}" == "build" && "${3:-}" == "app" ]]; then - case "$SAMPLE_APP_FAKE_DOCKER_MODE" in - slow-success) - sleep 1.2 - ;; - build-timeout) - sleep 2 - ;; - esac -elif [[ "${1:-}" == "compose" && "${2:-}" == "up" && "${3:-}" == "-d" && "${4:-}" == "--no-build" ]]; then - case "$SAMPLE_APP_FAKE_DOCKER_MODE" in - slow-success) - sleep 0.6 - ;; - readiness-timeout) - sleep 2 - ;; - esac -elif [[ "$*" == "compose up -d --build --wait app worker" ]]; then - sleep 2 -fi - -case "$*" in - "compose ps -q app") - printf 'fake-app-container\n' - ;; - "compose ps -q worker") - printf 'fake-worker-container\n' - ;; - *"php artisan app:workflow"*) - printf 'workflow_activity_other\n' - ;; - *"php artisan app:elapsed"*) - printf 'Elapsed Time: 1 seconds\n' - ;; - *"php artisan app:microservice"*) - printf 'workflow_activity_other\n' - ;; - *"php artisan app:webhook"*) - printf 'Hello world\n' - ;; - *"php artisan app:sandbox --snapshot-every=2 --inject-loss-after=2"*) - printf 'Workflow complete. provider=local sandbox=fake recoveries=1 snapshots=created:2,cleaned:2,retained:none\n' - ;; -esac - -if [[ "$*" == *"app:conformance"* ]]; then - if [[ "$*" == *"--strict"* && "$*" == *"--allow-skips"* ]]; then - printf 'fake app:conformance rejected contradictory coverage options\n' >&2 - exit 90 - fi - - coverage_status="failed" - coverage_strict="true" - coverage_exit="1" - if [[ "$*" == *"--allow-skips"* ]]; then - coverage_status="passed" - coverage_strict="false" - coverage_exit="0" - fi - - if [[ "$*" == *"--skip-ai"* ]]; then - printf '%s\n' "{\"surfaces\":{\"deterministic_simple\":{\"status\":\"passed\"},\"mcp_workflow_api\":{\"status\":\"passed\"},\"prism_ai\":{\"status\":\"skipped\",\"reason\":\"AI-backed samples were explicitly skipped.\"},\"ai_agent_scripted\":{\"status\":\"skipped\",\"reason\":\"AI-backed samples were explicitly skipped.\"},\"ai_failure_hotel\":{\"status\":\"skipped\",\"reason\":\"AI-backed samples were explicitly skipped.\"},\"ai_failure_flight\":{\"status\":\"skipped\",\"reason\":\"AI-backed samples were explicitly skipped.\"},\"ai_failure_car\":{\"status\":\"skipped\",\"reason\":\"AI-backed samples were explicitly skipped.\"}},\"summary\":{\"status\":\"${coverage_status}\",\"strict\":${coverage_strict},\"skipped_surfaces\":[\"prism_ai\",\"ai_agent_scripted\",\"ai_failure_hotel\",\"ai_failure_flight\",\"ai_failure_car\"],\"uncovered_surfaces\":[\"prism_ai\",\"ai_agent_scripted\",\"ai_failure_hotel\",\"ai_failure_flight\",\"ai_failure_car\"]}}" > "$SAMPLE_APP_FAKE_METADATA_PATH" - elif [[ "$credential_state" == "absent" ]]; then - printf '%s\n' "{\"surfaces\":{\"deterministic_simple\":{\"status\":\"passed\"},\"mcp_workflow_api\":{\"status\":\"passed\"},\"prism_ai\":{\"status\":\"skipped\",\"reason\":\"OPENAI_API_KEY is not set.\"}},\"summary\":{\"status\":\"${coverage_status}\",\"strict\":${coverage_strict},\"skipped_surfaces\":[\"prism_ai\"],\"uncovered_surfaces\":[\"prism_ai\"]}}" > "$SAMPLE_APP_FAKE_METADATA_PATH" - else - printf 'provider-command app:prism\n' >> "$SAMPLE_APP_FAKE_DOCKER_LOG" - printf '%s\n' "{\"surfaces\":{\"deterministic_simple\":{\"status\":\"passed\"},\"mcp_workflow_api\":{\"status\":\"passed\"},\"prism_ai\":{\"status\":\"passed\"}},\"summary\":{\"status\":\"passed\",\"strict\":${coverage_strict},\"skipped_surfaces\":[],\"uncovered_surfaces\":[]}}" > "$SAMPLE_APP_FAKE_METADATA_PATH" - coverage_exit="0" - fi - - exit "$coverage_exit" -fi -BASH); - chmod($dockerPath, 0700); - - $environment = [ - 'PATH' => $temporaryDirectory.PATH_SEPARATOR.getenv('PATH'), - 'COMPOSE_PROJECT_NAME' => 'compose-budget-test', - 'DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE' => $this->repoPath('tests/Fixtures/release-candidate-artifact-tuple.json'), - 'OPENAI_API_KEY' => '', - 'SAMPLE_APP_COMMIT' => 'compose-budget-test-revision', - 'SAMPLE_APP_CONFORMANCE_ALLOW_SKIPS' => '', - 'SAMPLE_APP_CONFORMANCE_ENV_FILE' => $ancestorCredential === null ? '' : $configuredEnvPath, - 'SAMPLE_APP_CONFORMANCE_METADATA_PATH' => $metadataPath, - 'SAMPLE_APP_CONFORMANCE_TIMEOUT_SECONDS' => '3', - 'SAMPLE_APP_DB_PROBE_TIMEOUT_SECONDS' => '1', - 'SAMPLE_APP_FAKE_DOCKER_LOG' => $logPath, - 'SAMPLE_APP_FAKE_METADATA_PATH' => $metadataPath, - 'SAMPLE_APP_FAKE_DOCKER_MODE' => $mode, - 'SAMPLE_APP_FAKE_EXPECTED_CREDENTIAL' => $ancestorCredential ?? '', - 'SAMPLE_APP_METADATA_COPY_TIMEOUT_SECONDS' => '2', - 'SAMPLE_APP_MIGRATION_TIMEOUT_SECONDS' => '2', - 'SAMPLE_APP_RUNTIME_BUILD_TIMEOUT_SECONDS' => '3', - 'SAMPLE_APP_SERVICE_READINESS_TIMEOUT_SECONDS' => '2', - 'SAMPLE_APP_SETUP_CACHE_STATE' => 'clean-cache', - 'SAMPLE_APP_WORKER_RESTART_TIMEOUT_SECONDS' => '2', - ...$overrides, - ]; - $process = new Process( - ['bash', $this->repoPath($entryPoint), ...$arguments], - $workingDirectory, - $environment, - ); - $process->setTimeout(15); - - try { - $process->run(); - - return [ - 'exit_code' => $process->getExitCode() ?? -1, - 'output' => $process->getOutput().$process->getErrorOutput(), - 'commands' => (string) @file_get_contents($logPath), - 'metadata' => (string) @file_get_contents($metadataPath), - ]; - } finally { - @unlink($dockerPath); - @unlink($logPath); - @unlink($metadataPath); - if ($ancestorCredential !== null) { - @unlink($workingDirectory.'/scripts'); - @unlink($configuredEnvPath); - @unlink($temporaryDirectory.'/workspace/.env'); - @rmdir($workingDirectory); - @rmdir($temporaryDirectory.'/workspace'); - } - @rmdir($temporaryDirectory); - } - } - - /** - * @param array{exit_code: int, output: string, commands: string, metadata: string} $result - */ - private function assertSkippedAiConformance(array $result, ?string $credential = null): void - { - $this->assertSame(0, $result['exit_code'], $result['output']); - $this->assertStringNotContainsString('OPENAI_API_KEY_STATE=matched', $result['commands']); - $this->assertStringNotContainsString('OPENAI_API_KEY_STATE=present', $result['commands']); - $this->assertStringNotContainsString('provider-command', $result['commands']); - if ($credential !== null) { - $this->assertStringNotContainsString($credential, $result['commands']); - $this->assertStringNotContainsString($credential, $result['output']); - $this->assertStringNotContainsString($credential, $result['metadata']); - } - - $metadata = json_decode($result['metadata'], true, flags: JSON_THROW_ON_ERROR); - - $this->assertSame('passed', $metadata['summary']['status'] ?? null); - $this->assertFalse($metadata['summary']['strict'] ?? null); - $this->assertSame('passed', $metadata['surfaces']['deterministic_simple']['status'] ?? null); - $this->assertSame('passed', $metadata['surfaces']['mcp_workflow_api']['status'] ?? null); - $this->assertSame([ - 'prism_ai', - 'ai_agent_scripted', - 'ai_failure_hotel', - 'ai_failure_flight', - 'ai_failure_car', - ], $metadata['summary']['skipped_surfaces'] ?? null); - $this->assertSame( - $metadata['summary']['skipped_surfaces'], - $metadata['summary']['uncovered_surfaces'] ?? null, - ); - - foreach ($metadata['summary']['skipped_surfaces'] as $surface) { - $this->assertSame('skipped', $metadata['surfaces'][$surface]['status'] ?? null); - $this->assertIsString($metadata['surfaces'][$surface]['reason'] ?? null); - $this->assertNotSame('', $metadata['surfaces'][$surface]['reason'] ?? ''); - } - } - - private function repoPath(string $path = ''): string - { - return dirname(__DIR__, 2).($path === '' ? '' : '/'.$path); - } -} diff --git a/tests/Unit/ComposeScriptContractTest.php b/tests/Unit/ComposeScriptContractTest.php deleted file mode 100644 index dbe46a8..0000000 --- a/tests/Unit/ComposeScriptContractTest.php +++ /dev/null @@ -1,135 +0,0 @@ -script('scripts/compose-smoke.sh'); - - $this->assertStringContainsString('compose_diagnostics()', $script); - $this->assertStringContainsString('SAMPLE_APP_SAMPLE_TIMEOUT_SECONDS:-180', $script); - $this->assertStringContainsString('SAMPLE_APP_DB_PROBE_TIMEOUT_SECONDS:-10', $script); - $this->assertStringContainsString('SAMPLE_APP_MIGRATION_TIMEOUT_SECONDS:-180', $script); - $this->assertStringContainsString('compose-smoke: all deterministic sample workflows passed', $script); - $this->assertStringContainsString('restart_worker_after_schema_refresh()', $script); - $this->assertStringContainsString('docker compose up -d --no-deps --force-recreate --wait worker', $script); - $this->assertStringContainsString('SAMPLE_APP_SMOKE_REUSE_PREPARED', $script); - $this->assertStringContainsString('prepared_stack_handoff_matches()', $script); - $this->assertStringContainsString('if prepared_stack_handoff_matches; then', $script); - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_AFTER_SMOKE:-1', $script); - $this->assertStringContainsString( - 'if [[ "${SAMPLE_APP_CONFORMANCE_AFTER_SMOKE:-1}" == "1" && "${SAMPLE_APP_SMOKE_ONLY:-0}" != "1" ]]; then', - $script, - ); - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_AFTER_SMOKE_TIMEOUT_SECONDS:-1800', $script); - $this->assertStringContainsString( - '"app:sandbox --snapshot-every=2 --inject-loss-after=2"', - $script, - ); - $this->assertStringContainsString('"Workflow complete\\..*recoveries=1"', $script); - $this->assertStringContainsString('prepared_app_container_id="$(docker compose ps -q app)"', $script); - $this->assertStringContainsString('prepared_worker_container_id="$(docker compose ps -q worker)"', $script); - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_REUSE_PREPARED=1', $script); - $this->assertStringContainsString('SAMPLE_APP_PREPARED_APP_CONTAINER_ID="$prepared_app_container_id"', $script); - $this->assertStringContainsString('SAMPLE_APP_PREPARED_WORKER_CONTAINER_ID="$prepared_worker_container_id"', $script); - $this->assertOrdered( - $script, - 'docker compose exec -T app php artisan migrate:fresh --force', - "\n restart_worker_after_schema_refresh\n", - 'run_sample "simple workflow"', - 'run_sample "webhook workflow"', - '"sandbox checkpoint recovery"', - 'SAMPLE_APP_CONFORMANCE_AFTER_SMOKE', - ); - } - - public function test_conformance_restarts_worker_after_schema_refresh_before_harness(): void - { - $script = $this->script('scripts/compose-conformance.sh'); - - $this->assertStringContainsString('compose_diagnostics()', $script); - $this->assertStringContainsString('SAMPLE_APP_RUNTIME_BUILD_TIMEOUT_SECONDS:-1200', $script); - $this->assertStringContainsString('SAMPLE_APP_SERVICE_READINESS_TIMEOUT_SECONDS:-600', $script); - $this->assertStringContainsString('SAMPLE_APP_DB_PROBE_TIMEOUT_SECONDS:-10', $script); - $this->assertStringContainsString('SAMPLE_APP_MIGRATION_TIMEOUT_SECONDS:-180', $script); - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_TIMEOUT_SECONDS:-1800', $script); - $this->assertStringContainsString('SAMPLE_APP_METADATA_COPY_TIMEOUT_SECONDS:-60', $script); - $this->assertStringContainsString('restart_worker_after_schema_refresh()', $script); - $this->assertStringContainsString('docker compose up -d --no-deps --force-recreate --wait worker', $script); - $this->assertStringContainsString('export SAMPLE_APP_COMMIT="$sample_app_commit"', $script); - $this->assertStringContainsString('prepared_stack_is_reusable()', $script); - $this->assertStringContainsString('container_env_matches()', $script); - $this->assertStringContainsString('installed_artifacts_match()', $script); - $this->assertStringContainsString('container_env_matches "$current_worker_id"', $script); - $this->assertStringContainsString('installed_artifacts_match app', $script); - $this->assertStringContainsString('installed_artifacts_match worker', $script); - $this->assertStringContainsString('prepared_schema_is_current()', $script); - $this->assertStringContainsString('if prepared_stack_is_reusable; then', $script); - $this->assertStringContainsString('SAMPLE_APP_SETUP_BUILD_INVOCATIONS="1"', $script); - $this->assertStringContainsString('SAMPLE_APP_SETUP_BUILD_DURATION_MS', $script); - $this->assertStringContainsString('SAMPLE_APP_SETUP_READINESS_DURATION_MS', $script); - $this->assertStringContainsString('finish_setup_measurement', $script); - $this->assertStringContainsString('SAMPLE_APP_SETUP_PEAK_DISK_GROWTH_BYTES', $script); - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_SMOKE_FIRST', $script); - $this->assertStringContainsString('metadata_path="${SAMPLE_APP_CONFORMANCE_METADATA_PATH:-storage/app/sample-app-conformance-metadata.json}"', $script); - $this->assertStringContainsString('--output="${metadata_container_path}"', $script); - $this->assertStringContainsString('docker compose cp "app:${metadata_container_abs}" "$metadata_path"', $script); - $this->assertOrdered( - $script, - 'export SAMPLE_APP_COMMIT="$sample_app_commit"', - "printf '\\n==> resolving current published artifact tuple\\n'", - "\n build_runtime_image_for_artifact_tuple\n", - "\n start_services_and_wait_for_readiness\n", - 'docker compose exec -T app php artisan migrate:fresh --force', - "\n restart_worker_after_schema_refresh\n", - 'timeout "${SAMPLE_APP_CONFORMANCE_TIMEOUT_SECONDS:-1800}s" docker compose exec -T \\', - 'docker compose cp "app:${metadata_container_abs}" "$metadata_path"', - ); - } - - public function test_combined_entry_point_uses_conformance_owned_setup_before_smoke(): void - { - $script = $this->script('scripts/compose-smoke-conformance.sh'); - $conformanceScript = $this->script('scripts/compose-conformance.sh'); - - $this->assertStringContainsString('SAMPLE_APP_CONFORMANCE_SMOKE_FIRST=1', $script); - $this->assertStringContainsString('exec scripts/compose-conformance.sh "$@"', $script); - $this->assertSame(1, substr_count($conformanceScript, 'docker compose build app')); - $this->assertSame(1, substr_count( - $conformanceScript, - 'docker compose up -d --no-build --wait app worker', - )); - } - - private function script(string $path): string - { - $contents = file_get_contents(__DIR__.'/../../'.$path); - - $this->assertIsString($contents); - - return $contents; - } - - private function assertOrdered(string $haystack, string ...$needles): void - { - $previous = -1; - - foreach ($needles as $needle) { - $position = strpos($haystack, $needle); - - $this->assertNotFalse($position, sprintf('Missing expected script fragment [%s].', $needle)); - $this->assertGreaterThan($previous, $position, sprintf( - 'Expected script fragment [%s] to appear after the previous fragment.', - $needle, - )); - - $previous = $position; - } - } -} diff --git a/tests/Unit/DevcontainerImageContractTest.php b/tests/Unit/DevcontainerImageContractTest.php index 1d1056c..e4fdc5a 100644 --- a/tests/Unit/DevcontainerImageContractTest.php +++ b/tests/Unit/DevcontainerImageContractTest.php @@ -4,1639 +4,106 @@ namespace Tests\Unit; -use App\Console\Commands\Init; use PHPUnit\Framework\TestCase; -use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\Process\Process; use Symfony\Component\Yaml\Yaml; final class DevcontainerImageContractTest extends TestCase { - public function test_codespaces_consumes_the_public_image_without_a_build_fallback(): void + public function test_codespaces_uses_the_published_image_and_persistent_service_state(): void { - $compose = Yaml::parseFile($this->repoPath('.devcontainer/docker/docker-compose.yml')); - $devcontainer = json_decode( - $this->contents('.devcontainer/devcontainer.json'), + $config = json_decode( + (string) file_get_contents($this->path('.devcontainer/devcontainer.json')), true, flags: JSON_THROW_ON_ERROR, ); - $services = $compose['services'] ?? []; - $expectedImage = '${SAMPLE_APP_DEVCONTAINER_IMAGE:-ghcr.io/durable-workflow/sample-app-devcontainer:main}'; + $compose = Yaml::parseFile($this->path('.devcontainer/docker/docker-compose.yml')); + $services = $compose['services']; - $this->assertSame('laravel', $devcontainer['service'] ?? null); - $this->assertSame('laravel', $devcontainer['remoteUser'] ?? null); + $this->assertSame(['docker/docker-compose.yml'], $config['dockerComposeFile']); + $this->assertSame('laravel', $config['service']); + $this->assertSame('/var/www/html', $config['workspaceFolder']); + $this->assertArrayNotHasKey('build', $services['laravel']); $this->assertSame( - '.devcontainer/post-create.sh', - $devcontainer['postCreateCommand'] ?? null, + '${SAMPLE_APP_DEVCONTAINER_IMAGE:-ghcr.io/durable-workflow/sample-app-devcontainer:main}', + $services['laravel']['image'], ); - - foreach (['laravel', 'microservice'] as $serviceName) { - $this->assertSame($expectedImage, $services[$serviceName]['image'] ?? null); - $this->assertSame( - '${SAMPLE_APP_DEVCONTAINER_PULL_POLICY:-always}', - $services[$serviceName]['pull_policy'] ?? null, - ); - $this->assertArrayNotHasKey('build', $services[$serviceName] ?? []); - $this->assertSame( - '${SAMPLE_APP_UID:-}', - $services[$serviceName]['environment']['SAMPLE_APP_UID'] ?? null, - ); - $this->assertSame( - [ - 'mysql' => ['condition' => 'service_healthy'], - 'redis' => ['condition' => 'service_healthy'], - ], - $services[$serviceName]['depends_on'] ?? null, - ); - } - - foreach (['DB_DATABASE', 'SHARED_DB_DATABASE'] as $key) { - $this->assertSame('${DB_DATABASE:-sample}', $services['laravel']['environment'][$key] ?? null); - } - foreach (['DB_USERNAME', 'SHARED_DB_USERNAME'] as $key) { - $this->assertSame('${DB_USERNAME:-laravel}', $services['laravel']['environment'][$key] ?? null); - } - foreach (['DB_PASSWORD', 'SHARED_DB_PASSWORD'] as $key) { - $this->assertSame('${DB_PASSWORD:-password}', $services['laravel']['environment'][$key] ?? null); - } - $this->assertSame('mysql', $services['laravel']['environment']['DB_HOST'] ?? null); - $this->assertSame('mysql', $services['laravel']['environment']['SHARED_DB_HOST'] ?? null); - $this->assertSame('mysql', $services['microservice']['environment']['SHARED_DB_HOST'] ?? null); - $this->assertSame( - '${DB_DATABASE:-sample}', - $services['microservice']['environment']['SHARED_DB_DATABASE'] ?? null, - ); - $this->assertSame( - '${DB_USERNAME:-laravel}', - $services['microservice']['environment']['SHARED_DB_USERNAME'] ?? null, - ); - $this->assertSame( - '${DB_PASSWORD:-password}', - $services['microservice']['environment']['SHARED_DB_PASSWORD'] ?? null, - ); - - $this->assertSame('../../:/var/www/html', $services['laravel']['volumes'][0] ?? null); - $this->assertSame('laravel-vendor:/var/www/html/vendor', $services['laravel']['volumes'][1] ?? null); - $this->assertSame('/var/run/docker.sock:/var/run/docker.sock', $services['laravel']['volumes'][2] ?? null); - $this->assertSame('/var/www/html/microservice', $services['microservice']['working_dir'] ?? null); - $this->assertSame('../../:/var/www/html', $services['microservice']['volumes'][0] ?? null); - $this->assertSame( - 'microservice-vendor:/var/www/html/microservice/vendor', - $services['microservice']['volumes'][1] ?? null, - ); - $this->assertSame('local', $compose['volumes']['laravel-vendor']['driver'] ?? null); - $this->assertSame('local', $compose['volumes']['microservice-vendor']['driver'] ?? null); - - $this->assertSame($expectedImage, $services['mysql-seed']['image'] ?? null); - $this->assertSame( - '${SAMPLE_APP_DEVCONTAINER_PULL_POLICY:-always}', - $services['mysql-seed']['pull_policy'] ?? null, - ); - $this->assertSame('root', $services['mysql-seed']['user'] ?? null); - $this->assertSame( - ['/usr/local/bin/seed-mysql-volume'], - $services['mysql-seed']['entrypoint'] ?? null, - ); - $this->assertSame( - [ - 'MYSQL_DATABASE' => '${DB_DATABASE:-sample}', - 'MYSQL_USER' => '${DB_USERNAME:-laravel}', - 'MYSQL_PASSWORD' => '${DB_PASSWORD:-password}', - ], - $services['mysql-seed']['environment'] ?? null, - ); - $this->assertSame( - [ - 'type' => 'volume', - 'source' => 'laravel-mysql', - 'target' => '/var/lib/mysql', - 'volume' => ['nocopy' => true], - ], - $services['mysql-seed']['volumes'][0] ?? null, - ); - $this->assertSame('no', $services['mysql-seed']['restart'] ?? null); - - $this->assertSame('mariadb:11.4', $services['mysql']['image'] ?? null); - $this->assertSame( - [ - '--innodb-flush-method=nosync', - '--innodb-flush-log-at-trx-commit=0', - '--innodb-doublewrite=OFF', - '--innodb-file-per-table=OFF', - '--innodb-buffer-pool-size=64M', - '--innodb-log-file-size=16M', - '--performance-schema=OFF', - '--skip-name-resolve', - ], - $services['mysql']['command'] ?? null, - ); - $this->assertSame( - [ - 'type' => 'volume', - 'source' => 'laravel-mysql', - 'target' => '/var/lib/mysql', - 'volume' => ['nocopy' => true], - ], - $services['mysql']['volumes'][0] ?? null, - ); - $this->assertSame( - '../schema/mysql-schema.sql:/docker-entrypoint-initdb.d/20-sample-app-schema.sql:ro', - $services['mysql']['volumes'][2] ?? null, - ); - $this->assertSame( - './mysql-healthcheck.sh:/usr/local/bin/check-codespaces-mysql-health:ro', - $services['mysql']['volumes'][3] ?? null, - ); - $this->assertSame( - ['mysql-seed' => ['condition' => 'service_completed_successfully']], - $services['mysql']['depends_on'] ?? null, - ); - $this->assertSame('redis:alpine', $services['redis']['image'] ?? null); - $this->assertSame( - [ - 'CMD', - 'bash', - '/usr/local/bin/check-codespaces-mysql-health', - ], - $services['mysql']['healthcheck']['test'] ?? null, - ); - $this->assertSame('2s', $services['mysql']['healthcheck']['interval'] ?? null); - $this->assertSame(75, $services['mysql']['healthcheck']['retries'] ?? null); - $this->assertSame('30s', $services['mysql']['healthcheck']['start_period'] ?? null); - $this->assertSame(30, $services['redis']['healthcheck']['retries'] ?? null); - $this->assertSame('5s', $services['redis']['healthcheck']['start_period'] ?? null); + $this->assertSame($services['laravel']['image'], $services['microservice']['image']); + $this->assertArrayHasKey('laravel-mysql', $compose['volumes']); + $this->assertArrayHasKey('laravel-redis', $compose['volumes']); + $this->assertArrayHasKey('laravel-vendor', $compose['volumes']); + $this->assertArrayHasKey('microservice-vendor', $compose['volumes']); } - public function test_image_bakes_and_verifies_the_supported_toolchain(): void + public function test_prepared_image_contains_the_advertised_development_toolchain(): void { - $dockerfile = $this->contents('.devcontainer/docker/Dockerfile'); - $qualifiedArtifacts = json_decode( - $this->contents('polyglot/qualified-artifact-tuple.json'), - true, - flags: JSON_THROW_ON_ERROR, - )['artifacts']; - $supervisor = $this->contents('.devcontainer/docker/supervisord.conf'); - $verification = $this->contents('.devcontainer/docker/verify-image.sh'); - $databaseInitialization = $this->contents('.devcontainer/docker/create-testing-database.sh'); - $databaseHealthcheck = $this->contents('.devcontainer/docker/mysql-healthcheck.sh'); - $databaseSeed = $this->contents('.devcontainer/docker/seed-mysql-volume'); - $initCommand = $this->contents('app/Console/Commands/Init.php'); - $postCreate = $this->contents('.devcontainer/post-create.sh'); + $dockerfile = (string) file_get_contents($this->path('.devcontainer/docker/Dockerfile')); + $postCreate = (string) file_get_contents($this->path('.devcontainer/post-create.sh')); - $this->assertStringContainsString('FROM php:8.4-cli-bookworm', $dockerfile); - $this->assertStringContainsString('FROM node:22-bookworm-slim', $dockerfile); - $this->assertStringContainsString('FROM composer:2', $dockerfile); - $this->assertStringContainsString('FROM docker:27.5.1-cli AS docker-cli', $dockerfile); - $this->assertStringContainsString('FROM rust:1.86.0-slim-bookworm AS rust', $dockerfile); - $this->assertStringContainsString('FROM mariadb:11.4 AS mysql-seed', $dockerfile); - $this->assertStringContainsString( - 'ARG DURABLE_WORKFLOW_CLI_VERSION='.$qualifiedArtifacts['cli'], - $dockerfile, - ); - $this->assertStringContainsString('COPY --from=docker-cli /usr/local/bin/docker', $dockerfile); - $this->assertStringContainsString('COPY --from=rust /usr/local/cargo /usr/local/cargo', $dockerfile); - $this->assertStringContainsString('COPY --from=rust /usr/local/rustup /usr/local/rustup', $dockerfile); - $this->assertStringContainsString('healthcheck.sh --connect --innodb_initialized', $dockerfile); - $this->assertStringContainsString('--protocol=tcp', $dockerfile); - $this->assertStringContainsString('--host=127.0.0.1', $dockerfile); - $this->assertStringContainsString('--file=/tmp/sample-app-mysql-seed.tar', $dockerfile); - $this->assertStringContainsString('--numeric-owner', $dockerfile); - $this->assertStringContainsString( - 'COPY --from=mysql-seed /tmp/sample-app-mysql-seed.tar /usr/local/share/sample-app/mysql-datadir.tar', - $dockerfile, - ); - $this->assertStringContainsString( - 'COPY .devcontainer/docker/seed-mysql-volume /usr/local/bin/seed-mysql-volume', - $dockerfile, - ); - $this->assertStringContainsString('COPY package-lock.json /tmp/sample-app-package-lock.json', $dockerfile); - $this->assertStringContainsString('lock.packages["node_modules/playwright"].version', $dockerfile); - $this->assertStringContainsString('PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1', $dockerfile); - $this->assertStringContainsString('playwright install --with-deps --only-shell chromium', $dockerfile); - $this->assertStringNotContainsString('--download-only', $dockerfile); - $this->assertStringContainsString('composer.json composer.lock /var/www/html/', $dockerfile); - $this->assertStringContainsString( - 'microservice/composer.json microservice/composer.lock /var/www/html/microservice/', - $dockerfile, - ); - $this->assertStringContainsString('for dependency_dir in /var/www/html /var/www/html/microservice', $dockerfile); - $this->assertStringContainsString('test -s "${dependency_dir}/vendor/autoload.php"', $dockerfile); - $this->assertStringContainsString('rm -rf /home/laravel/.composer/cache', $dockerfile); - $composerCacheRemoval = strpos($dockerfile, 'rm -rf /home/laravel/.composer/cache'); - $composerCredentialGuard = strpos( - $dockerfile, - 'test -e /home/laravel/.composer/auth.json', - ); - $composerPermissionNormalization = strpos( - $dockerfile, - 'chmod -R g=u /home/laravel/.composer', - ); - $this->assertNotFalse($composerCacheRemoval); - $this->assertNotFalse($composerCredentialGuard); - $this->assertNotFalse($composerPermissionNormalization); - $this->assertLessThan($composerCredentialGuard, $composerCacheRemoval); - $this->assertLessThan($composerPermissionNormalization, $composerCredentialGuard); - $this->assertStringContainsString( - 'Composer credentials must not be baked into the development image.', - $dockerfile, - ); - $this->assertStringContainsString( - 'COPY .devcontainer/docker/verify-prepared-permissions /usr/local/bin/verify-prepared-permissions', - $dockerfile, - ); - $this->assertStringContainsString( - 'COPY .devcontainer/docker/verify-dependencies /usr/local/bin/verify-devcontainer-dependencies', - $dockerfile, - ); - $this->assertStringContainsString( - 'COPY .devcontainer/docker/with-disposable-composer-state /usr/local/bin/with-disposable-composer-state', - $dockerfile, - ); - $this->assertStringContainsString( - 'COPY .devcontainer/docker/with-group-shared-umask /usr/local/bin/with-group-shared-umask', - $dockerfile, - ); - $this->assertStringContainsString( - 'with-disposable-composer-state composer --no-ansi --version', - $verification, - ); - $this->assertStringContainsString('apt-get purge -y --auto-remove', $dockerfile); - $this->assertStringContainsString('canonical_library="$(readlink -f "$library")"', $dockerfile); - $this->assertStringContainsString('dpkg-query --search "$canonical_library"', $dockerfile); - $this->assertStringContainsString('dpkg-query --search "$library"', $dockerfile); - $this->assertStringContainsString('No Debian runtime package owns PHP extension dependency', $dockerfile); - $this->assertStringContainsString('Required PHP extension is unavailable after build dependency cleanup', $dockerfile); - $dependencyPurge = strpos($dockerfile, 'apt-get purge -y --auto-remove'); - $postPurgeExtensionCheck = strpos( - $dockerfile, - 'for extension in bcmath curl gd intl mbstring pcntl pdo_mysql pdo_sqlite redis zip', - ); - $this->assertNotFalse($dependencyPurge); - $this->assertNotFalse($postPurgeExtensionCheck); - $this->assertLessThan($postPurgeExtensionCheck, $dependencyPurge); - $this->assertStringContainsString('default-mysql-client', $dockerfile); - $this->assertStringContainsString('redis-tools', $dockerfile); - $this->assertStringContainsString('ripgrep', $dockerfile); - $this->assertStringContainsString('ffmpeg', $dockerfile); - $this->assertStringContainsString('libcap2-bin', $dockerfile); - $this->assertStringContainsString('openssh-server', $dockerfile); - $this->assertStringContainsString("setcap 'cap_net_bind_service=+ep'", $dockerfile); - $this->assertStringContainsString('getcap "$(command -v php)"', $dockerfile); - $this->assertStringNotContainsString('setcap_path=', $dockerfile); - $this->assertStringContainsString('sshd -t', $dockerfile); - $this->assertStringContainsString("'PasswordAuthentication no'", $dockerfile); - $this->assertStringContainsString("'PubkeyAuthentication yes'", $dockerfile); - $this->assertStringContainsString('passwd --delete laravel', $dockerfile); - $this->assertStringContainsString('rm -f /etc/ssh/ssh_host_*_key', $dockerfile); - $this->assertStringContainsString( - 'DEVCONTAINER_DEPENDENCY_SCOPE=baked', - $dockerfile, - ); - $this->assertStringContainsString('org.opencontainers.image.revision="${VCS_REF}"', $dockerfile); - $this->assertStringContainsString('gosu laravel verify-devcontainer-image', $dockerfile); - $this->assertStringNotContainsString('ppa.launchpadcontent.net', $dockerfile); - $this->assertStringNotContainsString('deb.nodesource.com', $dockerfile); - $this->assertStringContainsString('command=/usr/local/bin/php ', $supervisor); - $this->assertStringContainsString('command=/usr/sbin/sshd -D -e', $supervisor); - - foreach (['pdo_mysql', 'pdo_sqlite', 'redis', 'pcntl', 'bcmath', 'gd', 'intl', 'mbstring', 'zip'] as $extension) { - $this->assertStringContainsString($extension, $verification); + foreach (['php', 'composer', 'python3', 'rustc', 'cargo', 'node', 'npm', 'docker', 'rg'] as $tool) { + $this->assertStringContainsString($tool, $dockerfile); } - foreach ([ - 'cargo', - 'cc', - 'composer', - 'curl', - 'docker', - 'dw', - 'ffmpeg', - 'git', - 'make', - 'mysql', - 'node', - 'pip', - 'playwright', - 'python', - 'redis-cli', - 'rg', - 'rustc', - 'ssh', - 'sshd', - ] as $executable) { - $this->assertStringContainsString($executable, $verification); - } - $this->assertStringContainsString('python -m venv --help', $verification); - $this->assertStringContainsString('rustc "${rust_probe_dir}/main.rs"', $verification); - $this->assertStringContainsString('docker compose version', $verification); - $this->assertStringContainsString("compgen -G '/etc/ssh/ssh_host_*_key'", $verification); - $this->assertStringContainsString('must not contain shared SSH host private keys', $verification); - $this->assertStringContainsString('command -v mariadb', $databaseInitialization); - $this->assertStringContainsString('command -v mysql', $databaseInitialization); - $this->assertStringContainsString('healthcheck.sh --connect --innodb_initialized', $databaseHealthcheck); - $this->assertStringContainsString('--protocol=tcp', $databaseHealthcheck); - $this->assertStringContainsString('--host=127.0.0.1', $databaseHealthcheck); - $this->assertStringContainsString("--execute='SELECT 1'", $databaseHealthcheck); - $this->assertStringNotContainsString('migrations', $databaseHealthcheck); - $this->assertStringContainsString('.sample-app-seed-in-progress', $databaseSeed); - $this->assertStringContainsString('[[ -d "$data_dir/mysql" ]]', $databaseSeed); - $this->assertStringContainsString('find "$data_dir" -mindepth 1 -depth -delete', $databaseSeed); - $this->assertStringContainsString('-c:v libx264', $verification); - $this->assertStringContainsString('-c:a aac', $verification); - $this->assertStringContainsString('output.mp4', $verification); - - $this->assertStringContainsString("'npm ci --no-audit --no-fund'", $initCommand); - $this->assertStringContainsString("'node docker/playwright-smoke.js'", $initCommand); - $this->assertStringContainsString("DB::connection('mysql')->table", $initCommand); - $this->assertStringContainsString("Redis::connection()->command('ping')", $initCommand); - $this->assertStringContainsString("\$this->option('schema-path')", $initCommand); - $this->assertStringContainsString('is_file($schemaPath)', $initCommand); - $this->assertStringContainsString("\$migrationOptions['--schema-path']", $initCommand); - $this->assertStringNotContainsString('npx playwright install', $initCommand); - $this->assertStringNotContainsString('README.md', $initCommand); - $this->assertStringContainsString('php artisan app:init', $postCreate); - $this->assertStringContainsString('--schema-path=.devcontainer/schema/mysql-schema.sql', $postCreate); - $this->assertStringContainsString('http://127.0.0.1/up', $postCreate); - $this->assertStringContainsString('http://127.0.0.1/', $postCreate); - $this->assertStringContainsString('timestamp_ms', $postCreate); - $entrypoint = $this->contents('.devcontainer/docker/start-container'); - $this->assertStringContainsString('composer validate', $entrypoint); - $this->assertStringContainsString('--check-lock', $entrypoint); - $this->assertStringContainsString('[[ ! -s vendor/autoload.php ]]', $entrypoint); - $this->assertTrue(is_executable($this->repoPath('.devcontainer/post-create.sh'))); - $this->assertTrue(is_executable($this->repoPath('.devcontainer/docker/seed-mysql-volume'))); + $this->assertStringContainsString('ARG DURABLE_WORKFLOW_CLI_VERSION=2.0.0', $dockerfile); + $this->assertStringContainsString('verify-devcontainer-image', $dockerfile); + $this->assertStringContainsString('scripts/playground doctor', $postCreate); + $this->assertStringNotContainsString('apt-get', $postCreate); + $this->assertStringNotContainsString('rustup', $postCreate); } - public function test_mysql_seed_uses_the_preseed_only_for_the_default_fresh_volume(): void + public function test_qualification_runs_the_real_codespaces_startup_once(): void { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-mysql-seed-'.bin2hex(random_bytes(8)); - $seedSource = $temporaryDirectory.'/seed-source'; - $seedArchive = $temporaryDirectory.'/mysql-seed.tar'; - $dataDirectory = $temporaryDirectory.'/data'; - $interruptedDirectory = $temporaryDirectory.'/interrupted'; - $overrideDirectory = $temporaryDirectory.'/override'; - $interruptedOverrideDirectory = $temporaryDirectory.'/interrupted-override'; - $unexpectedDirectory = $temporaryDirectory.'/unexpected'; - $filesystem->mkdir([ - $seedSource.'/mysql', - $seedSource.'/sample', - $seedSource.'/testing', - $dataDirectory, - $interruptedDirectory, - $overrideDirectory, - $interruptedOverrideDirectory, - $unexpectedDirectory, - ], 0700); - file_put_contents($seedSource.'/.sample-app-codespaces-seed', "seed\n"); - file_put_contents($seedSource.'/mysql/system-table', "system\n"); - file_put_contents($seedSource.'/sample/application-table', "application\n"); - file_put_contents($seedSource.'/testing/database-marker', "testing\n"); - - try { - (new Process([ - 'tar', - '--create', - '--file='.$seedArchive, - '--directory='.$seedSource, - '.', - ]))->mustRun(); - - $fakeId = $temporaryDirectory.'/id'; - file_put_contents($fakeId, <<<'BASH' -#!/usr/bin/env bash -printf '%s\n' "${FAKE_ID_UID:-0}" -BASH); - chmod($fakeId, 0700); + $script = (string) file_get_contents($this->path('scripts/ci/qualify-devcontainer-image.sh')); - $environment = [ - 'PATH' => $temporaryDirectory.':'.getenv('PATH'), - 'FAKE_ID_UID' => '0', - 'MYSQL_DATABASE' => 'sample', - 'MYSQL_USER' => 'laravel', - 'MYSQL_PASSWORD' => 'password', - 'SAMPLE_APP_MYSQL_DATA_DIR' => $dataDirectory, - 'SAMPLE_APP_MYSQL_SEED_ARCHIVE' => $seedArchive, - ]; - $overrideEnvironment = [ - ...$environment, - 'MYSQL_DATABASE' => 'custom_database', - 'MYSQL_USER' => 'custom_user', - 'MYSQL_PASSWORD' => 'custom_password', - ]; - $seed = new Process( - ['bash', $this->repoPath('.devcontainer/docker/seed-mysql-volume')], - env: $environment, - ); - $seed->mustRun(); - - $this->assertFileExists($dataDirectory.'/.sample-app-codespaces-seed'); - $this->assertFileExists($dataDirectory.'/mysql/system-table'); - $this->assertFileExists($dataDirectory.'/sample/application-table'); - $this->assertFileExists($dataDirectory.'/testing/database-marker'); - $this->assertFileDoesNotExist($dataDirectory.'/.sample-app-seed-in-progress'); - - file_put_contents($dataDirectory.'/sample/persistent-user-data', "preserve\n"); - $seed->mustRun(); - $this->assertFileExists($dataDirectory.'/sample/persistent-user-data'); - - $existingOverride = new Process( - ['bash', $this->repoPath('.devcontainer/docker/seed-mysql-volume')], - env: $overrideEnvironment, - ); - $existingOverride->mustRun(); - $this->assertFileExists($dataDirectory.'/sample/persistent-user-data'); - - $freshOverride = new Process( - ['bash', $this->repoPath('.devcontainer/docker/seed-mysql-volume')], - env: [...$overrideEnvironment, 'SAMPLE_APP_MYSQL_DATA_DIR' => $overrideDirectory], - ); - $freshOverride->mustRun(); - $this->assertStringContainsString('leaving the fresh volume', $freshOverride->getOutput()); - $this->assertSame([], array_values(array_diff(scandir($overrideDirectory) ?: [], ['.', '..']))); - - file_put_contents($interruptedDirectory.'/.sample-app-seed-in-progress', "partial\n"); - file_put_contents($interruptedDirectory.'/partial-data', "replace\n"); - (new Process( - ['bash', $this->repoPath('.devcontainer/docker/seed-mysql-volume')], - env: [...$environment, 'SAMPLE_APP_MYSQL_DATA_DIR' => $interruptedDirectory], - ))->mustRun(); - $this->assertFileDoesNotExist($interruptedDirectory.'/partial-data'); - $this->assertFileExists($interruptedDirectory.'/sample/application-table'); - $this->assertFileDoesNotExist($interruptedDirectory.'/.sample-app-seed-in-progress'); - - file_put_contents($interruptedOverrideDirectory.'/.sample-app-seed-in-progress', "partial\n"); - file_put_contents($interruptedOverrideDirectory.'/partial-data', "replace\n"); - (new Process( - ['bash', $this->repoPath('.devcontainer/docker/seed-mysql-volume')], - env: [ - ...$overrideEnvironment, - 'SAMPLE_APP_MYSQL_DATA_DIR' => $interruptedOverrideDirectory, - ], - ))->mustRun(); - $this->assertSame( - [], - array_values(array_diff(scandir($interruptedOverrideDirectory) ?: [], ['.', '..'])), - ); - - file_put_contents($unexpectedDirectory.'/unknown-data', "unknown\n"); - $unexpected = new Process( - ['bash', $this->repoPath('.devcontainer/docker/seed-mysql-volume')], - env: [...$environment, 'SAMPLE_APP_MYSQL_DATA_DIR' => $unexpectedDirectory], - ); - $unexpected->run(); - $this->assertSame(1, $unexpected->getExitCode()); - $this->assertStringContainsString('Refusing to seed non-empty MySQL data directory', $unexpected->getErrorOutput()); - $this->assertFileExists($unexpectedDirectory.'/unknown-data'); - - $nonRoot = new Process( - ['bash', $this->repoPath('.devcontainer/docker/seed-mysql-volume')], - env: [...$environment, 'FAKE_ID_UID' => '1000'], - ); - $nonRoot->run(); - $this->assertSame(1, $nonRoot->getExitCode()); - } finally { - $filesystem->remove($temporaryDirectory); - } + $this->assertStringContainsString('DEVCONTAINER_MAX_STARTUP_SECONDS:-600', $script); + $this->assertStringContainsString('up --detach --no-build laravel microservice', $script); + $this->assertStringContainsString('run_in_ready_devcontainer laravel .devcontainer/post-create.sh', $script); + $this->assertStringContainsString('scripts/playground doctor', $script); + $this->assertStringContainsString('node docker/playwright-smoke.js', $script); + $this->assertStringContainsString('command -v "$command"', $script); + $this->assertStringContainsString('cargo check --bins --locked --offline', $script); + $this->assertStringContainsString('down --volumes --remove-orphans', $script); + $this->assertStringNotContainsString('upload-artifact', $script); + $this->assertStringNotContainsString('evidence_type', $script); } - public function test_setup_persists_runtime_database_overrides(): void + public function test_pull_request_build_cannot_publish_or_use_registry_credentials(): void { - $overrides = [ - 'DB_HOST' => 'database.internal', - 'DB_DATABASE' => 'custom_database', - 'DB_USERNAME' => 'custom_user', - 'DB_PASSWORD' => 'custom_password', - 'SHARED_DB_HOST' => 'shared-database.internal', - 'SHARED_DB_DATABASE' => 'custom_shared_database', - 'SHARED_DB_USERNAME' => 'custom_shared_user', - 'SHARED_DB_PASSWORD' => 'custom_shared_password', - ]; - $originalEnvironment = []; - $command = new class extends Init - { - /** @var array */ - public array $seededEnvironment = []; - - /** @return array */ - public function seedEnvironment(): array - { - $this->seedEnvDefaults(); - - return $this->seededEnvironment; - } - - protected function setEnvVariable(string $key, string $value): void - { - $this->seededEnvironment[$key] = $value; - } - - protected function reloadEnvConfig(): void {} - }; - - try { - foreach ($overrides as $key => $value) { - $originalEnvironment[$key] = getenv($key); - putenv("{$key}={$value}"); - } - - $seededEnvironment = $command->seedEnvironment(); + $workflow = (string) file_get_contents($this->path('.github/workflows/devcontainer-image-pr.yml')); - foreach ($overrides as $key => $value) { - $this->assertSame($value, $seededEnvironment[$key] ?? null); - } - } finally { - foreach ($originalEnvironment as $key => $value) { - putenv($value === false ? $key : "{$key}={$value}"); - } - } + $this->assertStringContainsString("pull_request:\n branches: [main]", $workflow); + $this->assertStringContainsString('no-cache: true', $workflow); + $this->assertStringContainsString('push: false', $workflow); + $this->assertStringContainsString('qualify-devcontainer-image.sh', $workflow); + $this->assertStringNotContainsString('docker/login-action', $workflow); + $this->assertStringNotContainsString('secrets.', $workflow); + $this->assertStringNotContainsString('packages: write', $workflow); } - public function test_mysql_healthcheck_fails_closed_without_gating_on_the_schema_version(): void + public function test_main_publication_builds_both_architectures_before_promotion(): void { - $temporaryDirectory = sys_get_temp_dir().'/sample-app-mysql-health-'.bin2hex(random_bytes(8)); - $this->assertTrue(mkdir($temporaryDirectory, 0700)); - - try { - $commands = [ - 'healthcheck.sh' => <<<'BASH' -#!/usr/bin/env bash -exit 0 -BASH, - 'mariadb' => <<<'BASH' -#!/usr/bin/env bash -printf '%s\n' "$*" > "$FAKE_MYSQL_ARGUMENTS" -exit "${FAKE_MYSQL_STATUS:-0}" -BASH, - ]; - - foreach ($commands as $command => $contents) { - $commandPath = $temporaryDirectory.'/'.$command; - $this->assertNotFalse(file_put_contents($commandPath, $contents)); - $this->assertTrue(chmod($commandPath, 0700)); - } - - $argumentOutput = $temporaryDirectory.'/mariadb-arguments'; - $environment = [ - 'PATH' => $temporaryDirectory.':'.getenv('PATH'), - 'MYSQL_USER' => 'laravel', - 'MYSQL_PASSWORD' => 'password', - 'MYSQL_DATABASE' => 'sample', - 'FAKE_MYSQL_ARGUMENTS' => $argumentOutput, - ]; - $healthcheck = new Process( - ['bash', $this->repoPath('.devcontainer/docker/mysql-healthcheck.sh')], - env: $environment, - ); - - $healthcheck->mustRun(); - $arguments = file_get_contents($argumentOutput); - $this->assertIsString($arguments); - $this->assertStringContainsString('--protocol=tcp', $arguments); - $this->assertStringContainsString('--host=127.0.0.1', $arguments); - $this->assertStringContainsString('--execute=SELECT 1', $arguments); - - $unavailableDatabase = new Process( - ['bash', $this->repoPath('.devcontainer/docker/mysql-healthcheck.sh')], - env: [...$environment, 'FAKE_MYSQL_STATUS' => '1'], - ); - $unavailableDatabase->run(); - - $this->assertSame(1, $unavailableDatabase->getExitCode()); - } finally { - foreach (['healthcheck.sh', 'mariadb', 'mariadb-arguments'] as $command) { - @unlink($temporaryDirectory.'/'.$command); - } - @rmdir($temporaryDirectory); - } - } + $workflow = Yaml::parseFile($this->path('.github/workflows/devcontainer-image.yml')); + $jobs = $workflow['jobs']; - public function test_image_seeds_composer_dependencies_as_the_unprivileged_runtime_user(): void - { - $dockerfile = $this->contents('.devcontainer/docker/Dockerfile'); - $userCreation = strpos($dockerfile, 'useradd --create-home --gid laravel'); - $browserInstallation = strpos($dockerfile, 'playwright install --with-deps --only-shell chromium'); - $rootDependencyOwnership = strpos( - $dockerfile, - 'COPY --chown=laravel:laravel composer.json composer.lock /var/www/html/', - ); - $microserviceDependencyOwnership = strpos( - $dockerfile, - 'COPY --chown=laravel:laravel microservice/composer.json microservice/composer.lock /var/www/html/microservice/', - ); - $unprivilegedSeed = strpos( - $dockerfile, - 'COMPOSER_HOME=/home/laravel/.composer gosu laravel composer install', - ); + $this->assertSame(['build'], $jobs['assemble']['needs']); + $this->assertSame(['assemble'], $jobs['qualify']['needs']); + $this->assertSame(['assemble', 'qualify'], $jobs['promote']['needs']); - $this->assertNotFalse($userCreation); - $this->assertNotFalse($browserInstallation); - $this->assertNotFalse($rootDependencyOwnership); - $this->assertNotFalse($microserviceDependencyOwnership); - $this->assertNotFalse($unprivilegedSeed); - $this->assertLessThan($rootDependencyOwnership, $userCreation); - $this->assertLessThan($rootDependencyOwnership, $browserInstallation); - $this->assertLessThan($microserviceDependencyOwnership, $rootDependencyOwnership); - $this->assertLessThan($unprivilegedSeed, $microserviceDependencyOwnership); - $this->assertStringContainsString('gosu laravel test -w "$writable_path"', $dockerfile); - $this->assertStringContainsString( - 'install -d -m 0775 -o laravel -g laravel "${dependency_dir}/vendor"', - $dockerfile, - ); - $this->assertStringContainsString( - 'Composer seed path is not writable by laravel: ${writable_path}', - $dockerfile, - ); - } - - public function test_prepared_permission_verifier_reports_the_exact_entry_and_mode(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-prepared-permissions-'.bin2hex(random_bytes(8)); - $composerHome = $temporaryDirectory.'/.composer'; - $failingEntry = $composerHome.'/cache-entry'; - $filesystem->mkdir($composerHome, 0770); - chmod($composerHome, 0770); - file_put_contents($failingEntry, "prepared\n"); - chmod($failingEntry, 0600); - - $process = new Process([ - 'bash', - $this->repoPath('.devcontainer/docker/verify-prepared-permissions'), - $composerHome, - ]); - - try { - $process->run(); - $this->assertSame(1, $process->getExitCode()); - $this->assertStringContainsString( - 'mode=600 owner='.(string) fileowner($failingEntry).' group='.(string) filegroup($failingEntry)." path={$failingEntry}", - $process->getErrorOutput(), - ); - - chmod($failingEntry, 0660); - $process->run(); - $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - } finally { - $filesystem->remove($temporaryDirectory); - } - } - - public function test_composer_state_runner_uses_and_removes_disposable_state(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-composer-verifier-'.bin2hex(random_bytes(8)); - $preparedComposerHome = $temporaryDirectory.'/prepared-composer'; - $fakeBinaryDirectory = $temporaryDirectory.'/bin'; - $composerStateLog = $temporaryDirectory.'/composer-state'; - $preparedSentinel = $preparedComposerHome.'/prepared-state'; - $filesystem->mkdir([$preparedComposerHome, $fakeBinaryDirectory], 0770); - file_put_contents($preparedSentinel, "prepared\n"); - file_put_contents($fakeBinaryDirectory.'/composer', <<<'BASH' -#!/usr/bin/env bash -set -euo pipefail -printf '%s\n%s\n' "$COMPOSER_HOME" "$COMPOSER_CACHE_DIR" > "$FAKE_COMPOSER_STATE_LOG" -mkdir -p "$COMPOSER_HOME" "$COMPOSER_CACHE_DIR" -printf 'Composer version 2.9.0 2026-08-20 00:00:00\n' -BASH); - chmod($fakeBinaryDirectory.'/composer', 0700); - - $process = new Process( - [ - 'bash', - $this->repoPath('.devcontainer/docker/with-disposable-composer-state'), - 'composer', - '--no-ansi', - '--version', - ], - env: [ - 'PATH' => $fakeBinaryDirectory.':'.getenv('PATH'), - 'COMPOSER_HOME' => $preparedComposerHome, - 'FAKE_COMPOSER_STATE_LOG' => $composerStateLog, - ], - ); - - try { - $process->mustRun(); - $verificationComposerState = file($composerStateLog, FILE_IGNORE_NEW_LINES); - $this->assertIsArray($verificationComposerState); - [$verificationComposerHome, $verificationComposerCache] = $verificationComposerState; - $this->assertNotSame($preparedComposerHome, $verificationComposerHome); - $this->assertSame($verificationComposerHome.'/cache', $verificationComposerCache); - $this->assertNotSame($preparedComposerHome.'/cache', $verificationComposerCache); - $this->assertDirectoryDoesNotExist($verificationComposerHome); - $this->assertDirectoryDoesNotExist($verificationComposerCache); - $this->assertDirectoryDoesNotExist(dirname($verificationComposerHome)); - $this->assertDirectoryDoesNotExist($preparedComposerHome.'/cache'); - $this->assertFileExists($preparedSentinel); - } finally { - $filesystem->remove($temporaryDirectory); - } - } - - public function test_startup_composer_operations_cannot_mutate_the_prepared_home(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-startup-composer-'.bin2hex(random_bytes(8)); - $projectDirectory = $temporaryDirectory.'/project'; - $preparedComposerHome = $temporaryDirectory.'/prepared-composer'; - $fakeBinaryDirectory = $temporaryDirectory.'/bin'; - $composerStateLog = $temporaryDirectory.'/composer-state'; - $preparedSentinel = $preparedComposerHome.'/prepared-state'; - $filesystem->mkdir([ - $projectDirectory.'/vendor', - $preparedComposerHome, - $fakeBinaryDirectory, - ], 0770); - file_put_contents($projectDirectory.'/composer.json', "{}\n"); - file_put_contents($projectDirectory.'/vendor/autoload.php', "repoPath('.devcontainer/docker/with-disposable-composer-state'), - $fakeBinaryDirectory.'/with-disposable-composer-state', - ); - file_put_contents($fakeBinaryDirectory.'/composer', <<<'BASH' -#!/usr/bin/env bash -set -euo pipefail -printf '%s\t%s\t%s\n' "$1" "$COMPOSER_HOME" "$COMPOSER_CACHE_DIR" >> "$FAKE_COMPOSER_STATE_LOG" -mkdir -p "$COMPOSER_CACHE_DIR/files" -BASH); - file_put_contents($fakeBinaryDirectory.'/gosu', <<<'BASH' -#!/usr/bin/env bash -set -euo pipefail -shift -exec "$@" -BASH); - chmod($fakeBinaryDirectory.'/composer', 0700); - chmod($fakeBinaryDirectory.'/gosu', 0700); - - try { - foreach (['non-root' => '', 'root-remapped' => 'gosu laravel'] as $prefix) { - $process = new Process( - [ - 'bash', - '-euc', - <<<'BASH' -cd "$1" -source "$2" -read -r -a command_prefix <<< "$3" -install_locked_composer_dependencies "${command_prefix[@]}" -BASH, - 'bash', - $projectDirectory, - $this->repoPath('.devcontainer/docker/start-container'), - $prefix, - ], - env: [ - 'PATH' => $fakeBinaryDirectory.':'.getenv('PATH'), - 'COMPOSER_HOME' => $preparedComposerHome, - 'FAKE_COMPOSER_STATE_LOG' => $composerStateLog, - ], - ); - $process->mustRun(); - } - - $operations = file($composerStateLog, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - $this->assertIsArray($operations); - $this->assertCount(4, $operations); - $this->assertSame(['validate', 'install', 'validate', 'install'], array_map( - static fn (string $operation): string => explode("\t", $operation)[0], - $operations, - )); - foreach ($operations as $operation) { - [, $composerHome, $composerCache] = explode("\t", $operation); - $this->assertNotSame($preparedComposerHome, $composerHome); - $this->assertSame($composerHome.'/cache', $composerCache); - $this->assertDirectoryDoesNotExist($composerHome); - } - $this->assertDirectoryDoesNotExist($preparedComposerHome.'/cache'); - $this->assertFileExists($preparedSentinel); - } finally { - $filesystem->remove($temporaryDirectory); - } - } - - public function test_dependency_verification_respects_each_service_mount_boundary(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-service-dependencies-'.bin2hex(random_bytes(8)); - $laravelVendor = $temporaryDirectory.'/laravel-vendor'; - $microserviceVendor = $temporaryDirectory.'/microservice-vendor'; - $fakeBinaryDirectory = $temporaryDirectory.'/bin'; - $filesystem->mkdir([$laravelVendor, $microserviceVendor, $fakeBinaryDirectory], 0775); - chmod($laravelVendor, 0775); - chmod($microserviceVendor, 0775); - file_put_contents($laravelVendor.'/autoload.php', "repoPath('.devcontainer/docker/verify-prepared-permissions'), - $fakeBinaryDirectory.'/verify-prepared-permissions', - ); - - $verification = static fn (string $scope): Process => new Process( - [ - 'bash', - dirname(__DIR__, 2).'/.devcontainer/docker/verify-dependencies', - $scope, - ], - env: [ - 'PATH' => $fakeBinaryDirectory.':'.getenv('PATH'), - 'SAMPLE_APP_LARAVEL_VENDOR' => $laravelVendor, - 'SAMPLE_APP_MICROSERVICE_VENDOR' => $microserviceVendor, - ], - ); - - try { - $laravelRuntime = $verification('laravel'); - $laravelRuntime->mustRun(); - - foreach (['baked', 'microservice'] as $scope) { - $failingVerification = $verification($scope); - $failingVerification->run(); - $this->assertSame(1, $failingVerification->getExitCode()); - $this->assertStringContainsString( - "path={$microserviceVendor}/autoload.php", - $failingVerification->getErrorOutput(), - ); - } - - chmod($microserviceVendor.'/autoload.php', 0664); - $verification('baked')->mustRun(); - $verification('microservice')->mustRun(); - } finally { - $filesystem->remove($temporaryDirectory); - } - } - - public function test_group_shared_runner_makes_new_cache_entries_group_writable(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-group-shared-'.bin2hex(random_bytes(8)); - $createdFile = $temporaryDirectory.'/cargo-output.d'; - $filesystem->mkdir($temporaryDirectory, 0775); - - $process = new Process([ - 'bash', - $this->repoPath('.devcontainer/docker/with-group-shared-umask'), - 'bash', - '-euc', - 'printf "dependency-info\n" > "$1"', - 'write-cache-entry', - $createdFile, - ]); - - try { - $process->mustRun(); - $this->assertSame(0664, fileperms($createdFile) & 0777); - } finally { - $filesystem->remove($temporaryDirectory); - } - } - - public function test_codespaces_schema_dump_records_every_current_migration(): void - { - $schema = $this->contents('.devcontainer/schema/mysql-schema.sql'); - preg_match_all( - '/INSERT INTO `migrations` .*? VALUES \([0-9]+,\'([^\']+)\',[0-9]+\);/', - $schema, - $matches, - ); - - $recorded = $matches[1]; - sort($recorded); - - $expected = []; - foreach ([ - 'database/migrations', - 'vendor/durable-workflow/waterline/database/migrations', - 'vendor/durable-workflow/workflow/src/migrations', - ] as $directory) { - foreach (glob($this->repoPath($directory).'/*_*.php') ?: [] as $migration) { - $expected[pathinfo($migration, PATHINFO_FILENAME)] = true; - } - } - - $expected = array_keys($expected); - sort($expected); - - $this->assertSame($expected, $recorded); - $this->assertFileDoesNotExist($this->repoPath('database/schema/mysql-schema.sql')); - } - - public function test_publication_separates_untrusted_builds_from_protected_registry_jobs(): void - { - $candidateWorkflow = $this->contents('.github/workflows/devcontainer-image-pr.yml'); - $publicationWorkflow = $this->contents('.github/workflows/devcontainer-image.yml'); - $combinedWorkflows = $candidateWorkflow."\n".$publicationWorkflow; - $candidateHeader = explode("\njobs:", $candidateWorkflow, 2)[0]; - $publicationHeader = explode("\njobs:", $publicationWorkflow, 2)[0]; - $artifactIdentity = $this->jobBlock($publicationWorkflow, 'artifact-identity'); - $validate = $this->jobBlock($candidateWorkflow, 'validate'); - $candidateEvidence = $this->jobBlock($candidateWorkflow, 'candidate-evidence'); - $publish = $this->jobBlock($publicationWorkflow, 'publish-architecture'); - $assembly = $this->jobBlock($publicationWorkflow, 'assemble-indexes'); - $qualification = $this->jobBlock($publicationWorkflow, 'qualify-published'); - $promotion = $this->jobBlock($publicationWorkflow, 'promote-main'); - $recovery = $this->jobBlock($publicationWorkflow, 'recover-main'); - $movingChannel = $this->jobBlock($publicationWorkflow, 'verify-main'); - $publicationEvidence = $this->jobBlock($publicationWorkflow, 'publication-evidence'); - $matrixRunner = "runs-on: \${{ github.server_url == 'https://github.com' && matrix.runner || 'ubuntu-latest' }}"; - $aggregationRunner = "runs-on: \${{ github.server_url == 'https://github.com' && 'ubuntu-24.04' || 'ubuntu-latest' }}"; - - $this->assertStringContainsString(" pull_request:\n branches: [ main ]", $candidateHeader); - $this->assertStringNotContainsString(' push:', $candidateHeader); - $this->assertStringNotContainsString(' schedule:', $candidateHeader); - $this->assertStringNotContainsString(' workflow_dispatch:', $candidateHeader); - $this->assertStringContainsString("permissions:\n contents: read", $candidateHeader); - foreach ([$candidateHeader, $publicationHeader] as $header) { - $this->assertStringContainsString('.github/workflows/devcontainer-image.yml', $header); - $this->assertStringContainsString('.github/workflows/devcontainer-image-pr.yml', $header); - } - $this->assertStringNotContainsString(' pull_request:', $publicationHeader); - $this->assertStringContainsString(" push:\n branches: [ main ]", $publicationHeader); - $this->assertStringContainsString(' schedule:', $publicationHeader); - $this->assertStringContainsString(' workflow_dispatch:', $publicationHeader); - $this->assertStringContainsString("permissions:\n contents: read", $publicationHeader); - $this->assertStringContainsString('devcontainer-image-${{ github.event.pull_request.number }}', $candidateHeader); - $this->assertStringContainsString('group: devcontainer-image-protected-main', $publicationHeader); - $this->assertStringNotContainsString("\n publish-architecture:", $candidateWorkflow); - $this->assertStringNotContainsString("\n validate:", $publicationWorkflow); - $this->assertStringNotContainsString('pull_request_target', $combinedWorkflows); - $this->assertStringNotContainsString('setup-qemu-action', $combinedWorkflows); - $this->assertStringNotContainsString('QEMU', $combinedWorkflows); - foreach (['packages: write', 'secrets.', 'docker/login-action', 'cache-from', 'cache-to', 'environment:'] as $forbidden) { - $this->assertStringNotContainsString($forbidden, $candidateWorkflow); - } - $this->assertStringContainsString('runner: ubuntu-24.04', $validate); - $this->assertStringContainsString('runner: ubuntu-24.04-arm', $validate); - foreach ([$validate, $publish, $qualification] as $job) { - $this->assertStringContainsString($matrixRunner, $job); - } - foreach ([$artifactIdentity, $candidateEvidence, $assembly, $promotion, $recovery, $movingChannel, $publicationEvidence] as $job) { - $this->assertStringContainsString($aggregationRunner, $job); - } - $this->assertStringContainsString('revision_tag: ${{ steps.identity.outputs.revision_tag }}', $artifactIdentity); - $this->assertStringContainsString( - 'revision_tag="sha-${GITHUB_SHA}-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"', - $artifactIdentity, - ); - $this->assertStringContainsString('echo "revision_tag=$revision_tag" >> "$GITHUB_OUTPUT"', $artifactIdentity); - $this->assertStringNotContainsString( - 'REVISION_TAG: sha-${{ github.sha }}-run-${{ github.run_id }}-${{ github.run_attempt }}', - $publicationWorkflow, - ); - $this->assertStringContainsString('platforms: ${{ matrix.platform }}', $validate); - $this->assertStringContainsString('contents: read', $validate); - $this->assertStringNotContainsString('packages: write', $validate); - $this->assertStringNotContainsString('secrets.', $validate); - $this->assertStringNotContainsString('docker/login-action', $validate); - $this->assertStringNotContainsString('cache-from', $validate); - $this->assertStringNotContainsString('cache-to', $validate); - $this->assertStringContainsString('no-cache: true', $validate); - $this->assertStringContainsString('push: false', $validate); - $this->assertStringContainsString('needs: [validate]', $candidateEvidence); - $this->assertStringContainsString('summarize-devcontainer-evidence.py', $candidateEvidence); - $this->assertStringContainsString('900', $candidateEvidence); - - $this->assertStringContainsString("github.repository == 'durable-workflow/sample-app'", $publish); - $this->assertStringContainsString("github.ref == 'refs/heads/main'", $publish); - $this->assertStringContainsString('needs: [artifact-identity]', $publish); - $this->assertStringContainsString( - 'REVISION_TAG: ${{ needs.artifact-identity.outputs.revision_tag }}', - $publish, - ); - $this->assertStringContainsString('runner: ubuntu-24.04', $publish); - $this->assertStringContainsString('runner: ubuntu-24.04-arm', $publish); - $this->assertStringContainsString('packages: write', $publish); - $this->assertStringContainsString('secrets.DOCKERHUB_TOKEN', $publish); - $this->assertStringContainsString('platforms: ${{ matrix.platform }}', $publish); - $this->assertStringContainsString('${{ env.REVISION_TAG }}-${{ matrix.suffix }}', $publish); - $this->assertStringContainsString('provenance: mode=max', $publish); - $this->assertStringContainsString('sbom: true', $publish); - $this->assertStringContainsString('no-cache: true', $publish); - $this->assertStringNotContainsString('cache-from', $publish); - $this->assertStringNotContainsString('cache-to', $publish); - - $this->assertStringContainsString('needs: [artifact-identity, publish-architecture]', $assembly); - $this->assertStringContainsString("github.ref == 'refs/heads/main'", $assembly); - $this->assertStringContainsString('imagetools create', $assembly); - $this->assertStringContainsString('cmp ghcr-index.json dockerhub-index.json', $assembly); - $this->assertStringContainsString('needs: [artifact-identity, assemble-indexes]', $qualification); - $this->assertStringContainsString('runner: ubuntu-24.04', $qualification); - $this->assertStringContainsString('runner: ubuntu-24.04-arm', $qualification); - $this->assertStringContainsString('DEVCONTAINER_REQUIRE_ANONYMOUS_PULL: 1', $qualification); - $this->assertStringContainsString('linux/amd64', $qualification); - $this->assertStringContainsString('linux/arm64', $qualification); - $this->assertStringNotContainsString('secrets.', $qualification); - $this->assertStringNotContainsString('docker/login-action', $qualification); - $this->assertStringContainsString('needs: [artifact-identity, assemble-indexes, qualify-published]', $promotion); - $this->assertStringContainsString("github.ref == 'refs/heads/main'", $promotion); - $this->assertStringContainsString("inputs.recover_revision_tag != ''", $recovery); - $this->assertStringContainsString("github.repository == 'durable-workflow/sample-app'", $recovery); - $this->assertStringContainsString("github.ref == 'refs/heads/main'", $recovery); - $this->assertStringContainsString('packages: write', $recovery); - $this->assertStringContainsString('secrets.DOCKERHUB_TOKEN', $recovery); - $this->assertStringContainsString('^sha-[0-9a-f]{40}-run-[0-9]+-[0-9]+$', $recovery); - $this->assertStringContainsString('cmp ghcr-source.json dockerhub-source.json', $recovery); - $this->assertStringContainsString('architectures != {"amd64", "arm64"}', $recovery); - $this->assertStringContainsString('cmp ghcr-main.json dockerhub-main.json', $recovery); - $this->assertStringContainsString('needs: [artifact-identity, promote-main]', $movingChannel); - $this->assertStringContainsString('anonymous-docker-config', $movingChannel); - $this->assertStringContainsString('needs: [verify-main]', $publicationEvidence); - $this->assertStringContainsString('summarize-devcontainer-evidence.py', $publicationEvidence); - $this->assertStringContainsString('compressed_platform_bytes', $publish); - $this->assertStringContainsString('largest_compressed_layer_bytes', $publish); - $this->assertStringContainsString('compressed_layer_count', $publish); - $this->assertStringContainsString('within_size_budget', $publish); - $this->assertStringContainsString("always() && steps.build.outcome == 'success'", $publish); - $this->assertStringContainsString("MAX_COMPRESSED_PLATFORM_BYTES: '1200000000'", $candidateWorkflow); - $this->assertStringContainsString("MAX_COMPRESSED_LAYER_BYTES: '400000000'", $candidateWorkflow); - $this->assertStringContainsString("MAX_COMPRESSED_PLATFORM_BYTES: '1200000000'", $publicationWorkflow); - $this->assertStringContainsString("MAX_COMPRESSED_LAYER_BYTES: '400000000'", $publicationWorkflow); - $this->assertStringContainsString('900', $publicationEvidence); - - preg_match_all('/^\s*uses:\s+[^@\s]+@([^\s#]+)/m', $combinedWorkflows, $actionRefs); - $this->assertNotEmpty($actionRefs[1]); - foreach ($actionRefs[1] as $ref) { - $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $ref); - } - } - - public function test_qualification_never_builds_and_records_fresh_and_warm_phases(): void - { - $script = $this->contents('scripts/ci/qualify-devcontainer-image.sh'); - $databaseOverrides = $this->contents('scripts/ci/qualify-devcontainer-database-overrides.sh'); - $dockerfile = $this->contents('.devcontainer/docker/Dockerfile'); - $entrypoint = $this->contents('.devcontainer/docker/start-container'); - $identityWaiter = $this->contents('.devcontainer/docker/wait-for-identity-ready'); - $identityCompose = $this->contents('scripts/ci/devcontainer-identity.sh'); - $imageVerifier = $this->contents('.devcontainer/docker/verify-image.sh'); - $dockerfile = $this->contents('.devcontainer/docker/Dockerfile'); - $postCreate = $this->contents('.devcontainer/post-create.sh'); - - $this->assertStringNotContainsString('docker compose build', $script); - $this->assertStringContainsString('up --detach --no-build --wait', $script); - $this->assertStringContainsString('up --detach --no-build --force-recreate --wait', $script); - $this->assertStringContainsString('environment_builds', $script); - $this->assertStringContainsString('normalize_architecture', $script); - $this->assertStringContainsString('anonymous_pull_verification', $script); - $this->assertStringContainsString('runner', $script); - $this->assertStringContainsString('export SAMPLE_APP_UID="$(id -u)"', $script); - $this->assertStringContainsString('prepare_qualification_checkout', $script); - $this->assertStringContainsString('sudo chown -R "${SAMPLE_APP_UID}:${qualification_gid}"', $script); - $this->assertStringContainsString('stat --format=%u .env', $script); - $this->assertStringContainsString('[[ "$(id -u)" == "$SAMPLE_APP_UID" ]]', $script); - $this->assertStringContainsString('exec -T --user laravel laravel .devcontainer/post-create.sh', $script); - $this->assertStringContainsString('php artisan migrate:status --no-interaction', $script); - $this->assertStringContainsString('php artisan migrate:status --pending=1 --no-interaction', $script); - $this->assertStringContainsString('SELECT COUNT(*) FROM migrations', $script); - $this->assertStringContainsString('information_schema.tables', $script); - $this->assertStringContainsString('expected_migration_count=50', $script); - $this->assertStringContainsString('expected_table_count=49', $script); - $this->assertStringContainsString('test "$migration_count" = 50', $dockerfile); - $this->assertStringContainsString('[ "$(query_database "SELECT COUNT(*) FROM migrations")" = 50 ]', $databaseOverrides); - preg_match_all('/^verify_database_schema$/m', $script, $schemaVerifications, PREG_OFFSET_CAPTURE); - $this->assertCount(4, $schemaVerifications[0]); - $this->assertLessThan( - strpos($script, 'dependency_bootstrap_started_ms='), - $schemaVerifications[0][0][1], - ); - $this->assertStringContainsString('redis-cli -h redis --raw ping', $script); - $this->assertStringContainsString('docker version >/dev/null', $script); - $this->assertStringContainsString('docker compose version >/dev/null', $script); - $this->assertStringContainsString('second_app_key', $script); - $this->assertStringContainsString('qualify-devcontainer-database-overrides.sh', $script); - $this->assertStringContainsString('database_override_ms', $script); - $this->assertStringContainsString('DB_DATABASE=codespaces_override', $databaseOverrides); - $this->assertStringContainsString('test ! -e /var/lib/mysql/.sample-app-codespaces-seed', $databaseOverrides); - $this->assertStringContainsString('php artisan migrate:status --pending=1', $databaseOverrides); - $this->assertStringContainsString('codespaces_testing_probe', $databaseOverrides); - $this->assertStringContainsString('codespaces-override-probe@example.invalid', $databaseOverrides); - $this->assertStringContainsString('run --rm --no-deps mysql-seed', $databaseOverrides); - $this->assertStringContainsString('force-recreate --wait laravel microservice', $databaseOverrides); - $this->assertTrue(is_executable($this->repoPath('scripts/ci/qualify-devcontainer-database-overrides.sh'))); - $this->assertStringContainsString('status --porcelain --untracked-files=no', $script); - $this->assertStringContainsString('http://localhost/', $script); - $this->assertStringContainsString('exec -T laravel sshd -t', $script); - $this->assertStringContainsString('/dev/tcp/127.0.0.1/22', $script); - $this->assertStringContainsString('laravel@127.0.0.1 id -u', $script); - $this->assertStringContainsString('-o BatchMode=yes', $script); - $this->assertStringContainsString('prepare_project_permissions', $entrypoint); - $this->assertStringContainsString("ssh-keygen -A\n sshd -t", $entrypoint); - $this->assertStringContainsString('remap_laravel_uid', $entrypoint); - $this->assertStringContainsString('prepare_docker_socket_access', $entrypoint); - $this->assertStringContainsString('stat --format=%g "$docker_socket"', $entrypoint); - $this->assertStringContainsString('usermod --append --groups "$socket_group" laravel', $entrypoint); - $this->assertStringContainsString('gosu laravel test -w "$docker_socket"', $entrypoint); - $this->assertStringContainsString('SAMPLE_APP_LOCAL_GROUP_FILE', $entrypoint); - $this->assertStringContainsString('SAMPLE_APP_IDENTITY_OPERATION_TIMEOUT_SECONDS', $entrypoint); - $this->assertStringContainsString('run_bounded_identity_operation', $entrypoint); - $this->assertStringContainsString('adding-docker-socket-group', $entrypoint); - $this->assertStringNotContainsString('getent group', $entrypoint); - $this->assertStringContainsString('rm -f "$identity_readiness_marker"', $entrypoint); - $this->assertStringContainsString('publish_identity_readiness', $entrypoint); - $this->assertStringContainsString('chown 0:0 "$marker_temporary"', $entrypoint); - $this->assertStringContainsString('chmod 0444 "$marker_temporary"', $entrypoint); - $this->assertLessThan( - strrpos($entrypoint, ' publish_identity_readiness'), - strrpos($entrypoint, ' prepare_docker_socket_access'), - ); - $this->assertStringContainsString('SAMPLE_APP_IDENTITY_READY_TIMEOUT_SECONDS', $identityWaiter); - $this->assertStringContainsString('until identity_is_ready; do', $identityWaiter); - $this->assertStringContainsString('Timed out waiting for development-container identity readiness', $identityWaiter); - $this->assertStringContainsString('Active startup stage:', $identityWaiter); - $this->assertStringContainsString( - 'exec -T --user root "$service" wait-for-devcontainer-identity', - $identityCompose, - ); - $this->assertStringContainsString('run_in_ready_devcontainer laravel bash -euc', $script); - $this->assertStringContainsString('run_in_ready_devcontainer laravel .devcontainer/post-create.sh', $databaseOverrides); - $this->assertStringContainsString('[[ " $(id -G) " == *" ${socket_gid} "* ]]', $script); - $this->assertStringContainsString('/usr/local/bin/wait-for-devcontainer-identity', $postCreate); - $this->assertStringContainsString('exec sg "$socket_group"', $postCreate); - $this->assertStringNotContainsString('getent group', $postCreate); - $this->assertStringContainsString('SAMPLE_APP_UID must be a positive, non-root decimal user ID.', $entrypoint); - $this->assertStringContainsString('SAMPLE_APP_LOCAL_PASSWD_FILE', $entrypoint); - $this->assertStringNotContainsString('getent passwd', $entrypoint); - $this->assertStringContainsString('update_local_passwd_uid laravel "$requested_uid"', $entrypoint); - $this->assertStringNotContainsString('usermod --uid "$requested_uid" laravel', $entrypoint); - $this->assertStringContainsString('verifying-prepared-toolchain-access', $entrypoint); - $this->assertStringContainsString('chmod -R g=u', $dockerfile); - $this->assertStringContainsString('verify-prepared-permissions', $imageVerifier); - $this->assertStringContainsString( - 'for prepared_home in "${COMPOSER_HOME:?}" "${CARGO_HOME:?}"', - $script, - ); - $this->assertStringContainsString('composer validate', $script); - $this->assertStringContainsString( - 'with-disposable-composer-state composer validate', - $script, - ); - $this->assertStringContainsString( - 'with-disposable-composer-state composer check-platform-reqs --no-dev', - $script, - ); - $this->assertStringNotContainsString('cargo metadata', $script); - $this->assertStringContainsString( - <<<'SHELL' -with-group-shared-umask cargo check \ - --bins \ - --locked \ - --offline \ - --manifest-path=playground/templates/rust/Cargo.toml -SHELL, - $script, - ); - $this->assertStringContainsString('--env DEVCONTAINER_DEPENDENCY_SCOPE=laravel', $script); - $this->assertStringContainsString('--env DEVCONTAINER_DEPENDENCY_SCOPE=microservice', $script); - $this->assertStringContainsString('chown -R laravel:laravel "$generated_dir"', $entrypoint); - $this->assertStringContainsString('for language in php python rust; do', $script); - $this->assertStringContainsString( - 'fresh_total_ms=$(( image_pull_ms + container_readiness_ms + dependency_bootstrap_ms + application_readiness_ms + playground_journey_ms ))', - $script, - ); - - foreach ([ - 'image_pull', - 'container_readiness', - 'dependency_bootstrap', - 'application_readiness', - 'playground_journey_ms', - 'database_override_ms', - 'fresh_total_ms', - 'warm_rebuild_ms', - ] as $timingKey) { - $this->assertStringContainsString($timingKey, $script); - } - } - - public function test_identity_readiness_waits_for_a_non_default_uid_marker(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-identity-ready-'.bin2hex(random_bytes(8)); - $marker = $temporaryDirectory.'/identity-ready'; - $socketPath = $temporaryDirectory.'/docker.sock'; - $filesystem->mkdir($temporaryDirectory, 0700); - $socket = stream_socket_server('unix://'.$socketPath, $errorCode, $errorMessage); - $this->assertIsResource($socket, $errorMessage); - - $fakeStat = $temporaryDirectory.'/stat'; - file_put_contents($fakeStat, <<<'BASH' -#!/usr/bin/env bash -if [[ "${@: -1}" == "$FAKE_IDENTITY_MARKER" ]]; then - case "$1" in - --format=%u|--format=%g) - printf '0\n' - exit 0 - ;; - esac -fi -exec /usr/bin/stat "$@" -BASH); - chmod($fakeStat, 0700); - - $waiter = new Process( - ['bash', $this->repoPath('.devcontainer/docker/wait-for-identity-ready')], - env: [ - 'PATH' => $temporaryDirectory.':'.getenv('PATH'), - 'FAKE_IDENTITY_MARKER' => $marker, - 'SAMPLE_APP_DOCKER_SOCKET' => $socketPath, - 'SAMPLE_APP_IDENTITY_READY_MARKER' => $marker, - 'SAMPLE_APP_IDENTITY_READY_POLL_INTERVAL_SECONDS' => '0.02', - 'SAMPLE_APP_IDENTITY_READY_TIMEOUT_SECONDS' => '2', - 'SAMPLE_APP_UID' => '12345', - ], - ); - - try { - $waiter->start(); - usleep(100_000); - - $this->assertTrue($waiter->isRunning(), $waiter->getErrorOutput()); - $this->assertFileDoesNotExist($marker); - - $socketGid = filegroup($socketPath); - $this->assertIsInt($socketGid); - file_put_contents($marker, "uid=12345\nsocket_gid={$socketGid}\n"); - chmod($marker, 0444); - - $waiter->wait(); - $this->assertSame(0, $waiter->getExitCode(), $waiter->getErrorOutput()); - } finally { - fclose($socket); - $filesystem->remove($temporaryDirectory); - } - } - - public function test_identity_readiness_accepts_a_service_without_a_docker_socket(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-identity-ready-no-socket-'.bin2hex(random_bytes(8)); - $marker = $temporaryDirectory.'/identity-ready'; - $missingSocket = $temporaryDirectory.'/docker.sock'; - $filesystem->mkdir($temporaryDirectory, 0700); - - $fakeStat = $temporaryDirectory.'/stat'; - file_put_contents($fakeStat, <<<'BASH' -#!/usr/bin/env bash -if [[ "${@: -1}" == "$FAKE_IDENTITY_MARKER" ]]; then - case "$1" in - --format=%u|--format=%g) - printf '0\n' - exit 0 - ;; - esac -fi -exec /usr/bin/stat "$@" -BASH); - chmod($fakeStat, 0700); - - file_put_contents($marker, "uid=12345\nsocket_gid=absent\n"); - chmod($marker, 0444); - - $waiter = new Process( - ['bash', $this->repoPath('.devcontainer/docker/wait-for-identity-ready')], - env: [ - 'PATH' => $temporaryDirectory.':'.getenv('PATH'), - 'FAKE_IDENTITY_MARKER' => $marker, - 'SAMPLE_APP_DOCKER_SOCKET' => $missingSocket, - 'SAMPLE_APP_IDENTITY_READY_MARKER' => $marker, - 'SAMPLE_APP_IDENTITY_READY_TIMEOUT_SECONDS' => '2', - 'SAMPLE_APP_UID' => '12345', - ], - ); - - try { - $waiter->run(); - $this->assertSame(0, $waiter->getExitCode(), $waiter->getErrorOutput()); - } finally { - $filesystem->remove($temporaryDirectory); - } - } - - public function test_identity_timeout_reports_the_active_startup_stage(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-identity-stage-'.bin2hex(random_bytes(8)); - $stage = $temporaryDirectory.'/identity-stage'; - $filesystem->mkdir($temporaryDirectory, 0700); - file_put_contents($stage, "adding-docker-socket-group\n"); - - $waiter = new Process( - ['bash', $this->repoPath('.devcontainer/docker/wait-for-identity-ready')], - env: [ - 'SAMPLE_APP_DOCKER_SOCKET' => $temporaryDirectory.'/docker.sock', - 'SAMPLE_APP_IDENTITY_READY_MARKER' => $temporaryDirectory.'/identity-ready', - 'SAMPLE_APP_IDENTITY_STAGE_MARKER' => $stage, - 'SAMPLE_APP_IDENTITY_READY_POLL_INTERVAL_SECONDS' => '0.02', - 'SAMPLE_APP_IDENTITY_READY_TIMEOUT_SECONDS' => '1', - 'SAMPLE_APP_UID' => '12345', - ], - ); - - try { - $waiter->run(); - $this->assertNotSame(0, $waiter->getExitCode()); - $this->assertStringContainsString('Active startup stage:', $waiter->getErrorOutput()); - $this->assertStringContainsString('adding-docker-socket-group', $waiter->getErrorOutput()); - } finally { - $filesystem->remove($temporaryDirectory); - } - } - - public function test_non_default_uid_socket_group_mutation_times_out_with_actionable_stage(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-socket-group-ready-'.bin2hex(random_bytes(8)); - $stage = $temporaryDirectory.'/identity-stage'; - $events = $temporaryDirectory.'/events'; - $socketPath = $temporaryDirectory.'/docker.sock'; - $filesystem->mkdir($temporaryDirectory, 0700); - $socket = stream_socket_server('unix://'.$socketPath, $errorCode, $errorMessage); - $this->assertIsResource($socket, $errorMessage); - $socketGid = filegroup($socketPath); - $this->assertIsInt($socketGid); - - file_put_contents($temporaryDirectory.'/passwd', "laravel:x:1000:1000::/home/laravel:/bin/bash\n"); - file_put_contents($temporaryDirectory.'/group', "dockerlocal:x:{$socketGid}:\n"); - - file_put_contents($temporaryDirectory.'/usermod', <<<'BASH' -#!/usr/bin/env bash -printf 'usermod %s\n' "$*" >> "$FAKE_EVENTS" -if [[ "$1" == "--uid" ]]; then - awk -F: -v OFS=: -v uid="$2" '$1 == "laravel" { $3 = uid } { print }' \ - "$FAKE_PASSWD" > "${FAKE_PASSWD}.new" - mv "${FAKE_PASSWD}.new" "$FAKE_PASSWD" - exit 0 -fi -if [[ "$1" == "--append" ]]; then - while :; do :; done -fi -exit 64 -BASH); - file_put_contents($temporaryDirectory.'/chown', <<<'BASH' -#!/usr/bin/env bash -printf 'chown %s\n' "$*" >> "$FAKE_EVENTS" -BASH); - file_put_contents($temporaryDirectory.'/install', <<<'BASH' -#!/usr/bin/env bash -target="${@: -1}" -mkdir -p "$target" -chmod 0755 "$target" -BASH); - file_put_contents($temporaryDirectory.'/getent', <<<'BASH' -#!/usr/bin/env bash -printf 'getent %s\n' "$*" >> "$FAKE_EVENTS" -exit 90 -BASH); - foreach (['usermod', 'chown', 'install', 'getent'] as $executable) { - chmod($temporaryDirectory.'/'.$executable, 0700); - } - - $process = new Process( - [ - 'bash', - '-euc', - 'source "$1"; remap_laravel_uid; prepare_docker_socket_access', - 'bash', - $this->repoPath('.devcontainer/docker/start-container'), - ], - env: [ - 'PATH' => $temporaryDirectory.':'.getenv('PATH'), - 'FAKE_EVENTS' => $events, - 'FAKE_PASSWD' => $temporaryDirectory.'/passwd', - 'SAMPLE_APP_DOCKER_SOCKET' => $socketPath, - 'SAMPLE_APP_IDENTITY_OPERATION_TIMEOUT_SECONDS' => '1', - 'SAMPLE_APP_IDENTITY_READY_MARKER' => $temporaryDirectory.'/identity-ready', - 'SAMPLE_APP_IDENTITY_STAGE_MARKER' => $stage, - 'SAMPLE_APP_LOCAL_GROUP_FILE' => $temporaryDirectory.'/group', - 'SAMPLE_APP_LOCAL_PASSWD_FILE' => $temporaryDirectory.'/passwd', - 'SAMPLE_APP_UID' => '12345', - ], - ); - $process->setTimeout(5); - $startedAt = microtime(true); - - try { - $process->run(); - $this->assertNotSame(0, $process->getExitCode()); - $this->assertLessThan(4.0, microtime(true) - $startedAt); - $this->assertStringContainsString( - "Adding laravel to Docker socket group dockerlocal (gid {$socketGid}) timed out after 1s.", - $process->getErrorOutput(), - ); - $this->assertSame("adding-docker-socket-group\n", file_get_contents($stage)); - - $recordedEvents = file_get_contents($events); - $this->assertIsString($recordedEvents); - $this->assertStringContainsString('usermod --append --groups dockerlocal laravel', $recordedEvents); - $this->assertStringNotContainsString('usermod --uid 12345 laravel', $recordedEvents); - $this->assertStringNotContainsString('getent ', $recordedEvents); - } finally { - fclose($socket); - $filesystem->remove($temporaryDirectory); - } - } - - public function test_simultaneous_non_default_uid_remaps_do_not_invoke_recursive_usermod(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-parallel-uid-remap-'.bin2hex(random_bytes(8)); - $filesystem->mkdir($temporaryDirectory, 0700); - - file_put_contents($temporaryDirectory.'/usermod', <<<'BASH' -#!/usr/bin/env bash -sleep 10 -BASH); - file_put_contents($temporaryDirectory.'/chown', <<<'BASH' -#!/usr/bin/env bash -exit 0 -BASH); - file_put_contents($temporaryDirectory.'/install', <<<'BASH' -#!/usr/bin/env bash -target="${@: -1}" -mkdir -p "$target" -chmod 0755 "$target" -BASH); - file_put_contents($temporaryDirectory.'/gosu', <<<'BASH' -#!/usr/bin/env bash -shift -exec "$@" -BASH); - chmod($temporaryDirectory.'/usermod', 0700); - chmod($temporaryDirectory.'/chown', 0700); - chmod($temporaryDirectory.'/install', 0700); - chmod($temporaryDirectory.'/gosu', 0700); - - $processes = []; - foreach ([12345, 12346] as $index => $requestedUid) { - $runtimeDirectory = $temporaryDirectory.'/runtime-'.$index; - $homeDirectory = $temporaryDirectory.'/home-'.$index; - $playgroundDirectory = $temporaryDirectory.'/playground-'.$index; - $passwd = $temporaryDirectory.'/passwd-'.$index; - $filesystem->mkdir([$runtimeDirectory, $homeDirectory, $playgroundDirectory], 0775); - file_put_contents($passwd, "laravel:x:1000:1000::{$homeDirectory}:/bin/bash\n"); - chmod($passwd, 0644); - - $processes[] = new Process( - [ - 'bash', - '-euc', - 'source "$1"; remap_laravel_uid', - 'bash', - $this->repoPath('.devcontainer/docker/start-container'), - ], - env: [ - 'PATH' => $temporaryDirectory.':'.getenv('PATH'), - 'SAMPLE_APP_IDENTITY_OPERATION_TIMEOUT_SECONDS' => '1', - 'SAMPLE_APP_IDENTITY_READY_MARKER' => $runtimeDirectory.'/identity-ready', - 'SAMPLE_APP_IDENTITY_STAGE_MARKER' => $runtimeDirectory.'/identity-stage', - 'SAMPLE_APP_LARAVEL_HOME' => $homeDirectory, - 'SAMPLE_APP_LOCAL_PASSWD_FILE' => $passwd, - 'SAMPLE_APP_PREPARED_PLAYGROUND_ROOT' => $playgroundDirectory, - 'SAMPLE_APP_UID' => (string) $requestedUid, - ], - ); - } - - try { - $startedAt = microtime(true); - foreach ($processes as $process) { - $process->start(); - } - foreach ($processes as $process) { - $process->wait(); - $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - } - $this->assertLessThan(4.0, microtime(true) - $startedAt); - - foreach ([12345, 12346] as $index => $requestedUid) { - $passwd = file_get_contents($temporaryDirectory.'/passwd-'.$index); - $this->assertIsString($passwd); - $this->assertStringContainsString("laravel:x:{$requestedUid}:1000:", $passwd); - } - } finally { - foreach ($processes as $process) { - $process->stop(0); - } - $filesystem->remove($temporaryDirectory); - } - } - - public function test_database_override_cold_start_waits_for_non_default_uid_before_post_create(): void - { - $filesystem = new Filesystem; - $temporaryDirectory = sys_get_temp_dir().'/sample-app-database-override-ready-'.bin2hex(random_bytes(8)); - $marker = $temporaryDirectory.'/identity-ready'; - $events = $temporaryDirectory.'/events'; - $filesystem->mkdir($temporaryDirectory, 0700); - - $fakeDocker = $temporaryDirectory.'/docker'; - file_put_contents($fakeDocker, <<<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -arguments=" $* " -case "$arguments" in - *" up --detach --no-build laravel microservice ") - printf 'containers-started\n' >> "$FAKE_EVENTS" - ( - sleep 0.15 - printf 'uid=%s\nsocket_gid=9876\n' "$SAMPLE_APP_UID" > "$FAKE_IDENTITY_MARKER" - printf 'identity-ready\n' >> "$FAKE_EVENTS" - ) & - ;; - *" exec -T --user root laravel wait-for-devcontainer-identity ") - printf 'wait-started\n' >> "$FAKE_EVENTS" - for ((attempt = 0; attempt < 100; attempt++)); do - if [[ -r "$FAKE_IDENTITY_MARKER" ]] \ - && grep -Fx "uid=$SAMPLE_APP_UID" "$FAKE_IDENTITY_MARKER" >/dev/null; then - printf 'wait-completed\n' >> "$FAKE_EVENTS" - exit 0 - fi - sleep 0.02 - done - exit 1 - ;; - *" exec -T --user laravel laravel .devcontainer/post-create.sh ") - grep -Fx "uid=$SAMPLE_APP_UID" "$FAKE_IDENTITY_MARKER" >/dev/null - printf 'post-create-started\n' >> "$FAKE_EVENTS" - ;; - *" up --detach --no-build --wait ") - printf 'services-healthy\n' >> "$FAKE_EVENTS" - ;; - *) - printf 'Unexpected docker command: %s\n' "$*" >&2 - exit 64 - ;; -esac -BASH); - chmod($fakeDocker, 0700); - - $process = new Process( - [ - 'bash', - '-euc', - 'source "$1"; bootstrap_devcontainer_application', - 'bash', - $this->repoPath('scripts/ci/qualify-devcontainer-database-overrides.sh'), - ], - env: [ - 'PATH' => $temporaryDirectory.':'.getenv('PATH'), - 'FAKE_EVENTS' => $events, - 'FAKE_IDENTITY_MARKER' => $marker, - 'SAMPLE_APP_UID' => '12345', - ], - ); - - try { - $process->mustRun(); - $this->assertSame( - [ - 'containers-started', - 'wait-started', - 'identity-ready', - 'wait-completed', - 'post-create-started', - 'services-healthy', - ], - file($events, FILE_IGNORE_NEW_LINES), - ); - } finally { - $filesystem->remove($temporaryDirectory); - } - } - - private function jobBlock(string $workflow, string $job): string - { - $lines = preg_split('/\R/', $workflow); - $this->assertIsArray($lines); - $marker = " {$job}:"; - $start = array_search($marker, $lines, true); - $this->assertIsInt($start, "Workflow is missing job {$job}."); - $end = count($lines); - - for ($index = $start + 1; $index < count($lines); $index++) { - if (preg_match('/^ [a-zA-Z0-9_-]+:$/', $lines[$index]) === 1) { - $end = $index; - break; - } - } - - return implode("\n", array_slice($lines, $start, $end - $start)); - } - - private function contents(string $path): string - { - $contents = file_get_contents($this->repoPath($path)); - $this->assertIsString($contents); + $matrix = $jobs['build']['strategy']['matrix']['include']; + $this->assertSame(['linux/amd64', 'linux/arm64'], array_column($matrix, 'platform')); + $this->assertSame(['ubuntu-24.04', 'ubuntu-24.04-arm'], array_column($matrix, 'runner')); - return $contents; + $source = (string) file_get_contents($this->path('.github/workflows/devcontainer-image.yml')); + $this->assertStringContainsString('ghcr.io/durable-workflow/sample-app-devcontainer', $source); + $this->assertStringContainsString('durableworkflow/sample-app-devcontainer', $source); + $this->assertStringContainsString('provenance: mode=max', $source); + $this->assertStringContainsString('sbom: true', $source); + $this->assertStringContainsString('DEVCONTAINER_REQUIRE_ANONYMOUS_PULL', $source); + $this->assertStringContainsString('Publish main channel', $source); + $this->assertStringNotContainsString('upload-artifact', $source); + $this->assertStringNotContainsString('recover_revision_tag', $source); } - private function repoPath(string $path): string + private function path(string $path): string { return dirname(__DIR__, 2).'/'.$path; } diff --git a/tests/Unit/OnboardingVersionPinsTest.php b/tests/Unit/OnboardingVersionPinsTest.php deleted file mode 100644 index abb5bbe..0000000 --- a/tests/Unit/OnboardingVersionPinsTest.php +++ /dev/null @@ -1,31 +0,0 @@ -repoPath('polyglot/docker-compose.yml')); - - $this->assertArrayNotHasKey('name', $compose); - } - - public function test_polyglot_server_does_not_publish_a_fixed_host_port(): void - { - $composePath = $this->repoPath('polyglot/docker-compose.yml'); - $compose = Yaml::parseFile($composePath); - $server = $compose['services']['server'] ?? []; - - $this->assertArrayNotHasKey('ports', $server); - $this->assertSame(['8080'], $server['expose'] ?? null); - - $composeYaml = (string) file_get_contents($composePath); - $this->assertStringNotContainsString('${SERVER_PORT:-8080}:8080', $composeYaml); - $this->assertStringNotContainsString('SERVER_PORT:-8080', $composeYaml); - } - - public function test_polyglot_compose_artifacts_come_from_resolved_environment(): void - { - $compose = Yaml::parseFile($this->repoPath('polyglot/docker-compose.yml')); - $services = $compose['services'] ?? []; - $resolvedServerImage = $this->requiredResolvedEnv('DURABLE_SERVER_IMAGE'); - $resolvedCliVersion = $this->requiredResolvedEnv('DURABLE_WORKFLOW_CLI_VERSION'); - $resolvedPythonVersion = $this->requiredResolvedEnv('DURABLE_WORKFLOW_PYTHON_SDK_VERSION'); - $resolvedRustVersion = $this->requiredResolvedEnv('DURABLE_WORKFLOW_RUST_SDK_VERSION'); - $resolvedPhpSdkVersion = $this->requiredResolvedEnv('DURABLE_WORKFLOW_PHP_SDK_VERSION'); - $resolvedWorkflowVersion = $this->requiredResolvedEnv('DURABLE_WORKFLOW_WORKFLOW_VERSION'); - $resolvedWaterlineVersion = $this->requiredResolvedEnv('DURABLE_WORKFLOW_WATERLINE_VERSION'); - - foreach (['bootstrap', 'server'] as $serviceName) { - $this->assertSame($resolvedServerImage, $services[$serviceName]['image'] ?? null); - } - - $this->assertSame( - $resolvedServerImage, - $services['smoke']['environment']['DURABLE_SERVER_IMAGE'] ?? null, - ); - $this->assertSame( - $resolvedCliVersion, - $services['smoke']['environment']['DURABLE_WORKFLOW_CLI_VERSION'] ?? null, - ); - $this->assertSame( - $resolvedPythonVersion, - $services['smoke']['environment']['DURABLE_WORKFLOW_PYTHON_SDK_VERSION'] ?? null, - ); - $this->assertSame( - $resolvedRustVersion, - $services['smoke']['environment']['DURABLE_WORKFLOW_RUST_SDK_VERSION'] ?? null, - ); - $this->assertSame( - $resolvedPhpSdkVersion, - $services['smoke']['environment']['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null, - ); - $this->assertSame( - $resolvedWorkflowVersion, - $services['smoke']['environment']['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null, - ); - $this->assertSame( - $resolvedWaterlineVersion, - $services['smoke']['environment']['DURABLE_WORKFLOW_WATERLINE_VERSION'] ?? null, - ); - - foreach ([ - 'python-workflow-worker', - 'python-activity-worker', - 'smoke', - 'demo', - ] as $serviceName) { - $buildArgs = $services[$serviceName]['build']['args'] ?? []; - $this->assertSame($resolvedPythonVersion, $buildArgs['DURABLE_WORKFLOW_PYTHON_SDK_VERSION'] ?? null); - $this->assertSame( - '${DURABLE_WORKFLOW_PYTHON_FASTAVRO_VERSION:-1.12.2}', - $buildArgs['FASTAVRO_PYTHON_VERSION'] ?? null, - ); - } - - foreach (['python-activity-worker', 'smoke', 'demo'] as $serviceName) { - $buildArgs = $services[$serviceName]['build']['args'] ?? []; - $this->assertSame($resolvedCliVersion, $buildArgs['DURABLE_WORKFLOW_CLI_VERSION'] ?? null); - } - - foreach ([ - 'php-same-workflow-worker', - 'php-same-activity-worker', - 'php-workflow-worker', - 'polyglot-workflow-worker', - 'php-to-rust-workflow-worker', - 'php-query-worker', - 'php-activity-worker', - ] as $serviceName) { - $buildArgs = $services[$serviceName]['build']['args'] ?? []; - $this->assertSame('./php_worker', $services[$serviceName]['build']['context'] ?? null); - $this->assertSame($resolvedPhpSdkVersion, $buildArgs['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null); - $this->assertArrayNotHasKey('DURABLE_WORKFLOW_WORKFLOW_VERSION', $buildArgs); - $this->assertArrayNotHasKey('DURABLE_WORKFLOW_WATERLINE_VERSION', $buildArgs); - } - - $waterlineBuildArgs = $services['waterline']['build']['args'] ?? []; - $this->assertSame('polyglot/laravel/Dockerfile', $services['waterline']['build']['dockerfile'] ?? null); - $this->assertSame($resolvedPhpSdkVersion, $waterlineBuildArgs['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null); - $this->assertSame($resolvedWorkflowVersion, $waterlineBuildArgs['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null); - $this->assertSame($resolvedWaterlineVersion, $waterlineBuildArgs['DURABLE_WORKFLOW_WATERLINE_VERSION'] ?? null); - - foreach (['rust-workflow-worker', 'rust-activity-worker'] as $serviceName) { - $buildArgs = $services[$serviceName]['build']['args'] ?? []; - $this->assertSame($resolvedRustVersion, $buildArgs['DURABLE_WORKFLOW_RUST_SDK_VERSION'] ?? null); - $this->assertSame('${DURABLE_WORKFLOW_RUST_AVRO_VERSION:-0.21.0}', $buildArgs['APACHE_AVRO_RUST_VERSION'] ?? null); - } - - $smokeShell = (string) file_get_contents($this->repoPath('polyglot/python_worker/scripts/smoke.sh')); - $this->assertStringContainsString('require_artifact_env DURABLE_SERVER_IMAGE', $smokeShell); - $this->assertDoesNotMatchRegularExpression('/durableworkflow\/server:0\.2\.\d+/', $smokeShell); - - $smokeDriver = (string) file_get_contents($this->repoPath('polyglot/python_worker/scripts/polyglot_smoke.py')); - $this->assertStringContainsString('SERVER_PIN = required_env("DURABLE_SERVER_IMAGE")', $smokeDriver); - $this->assertDoesNotMatchRegularExpression('/durableworkflow\/server:0\.2\.\d+/', $smokeDriver); - } - - public function test_php_workers_share_one_project_scoped_image_identity(): void - { - $compose = Yaml::parseFile($this->repoPath('polyglot/docker-compose.yml')); - $services = $compose['services'] ?? []; - $expectedImage = '${COMPOSE_PROJECT_NAME:-sample-app-polyglot}-php-sdk-worker:latest'; - - foreach ([ - 'php-same-workflow-worker', - 'php-same-activity-worker', - 'php-workflow-worker', - 'polyglot-workflow-worker', - 'php-to-rust-workflow-worker', - 'php-query-worker', - 'php-activity-worker', - ] as $serviceName) { - $this->assertSame($expectedImage, $services[$serviceName]['image'] ?? null); - } - } - - public function test_sample_app_compose_can_build_against_resolved_php_artifacts(): void - { - $compose = Yaml::parseFile($this->repoPath('docker-compose.yml')); - $services = $compose['services'] ?? []; - $dockerfile = (string) file_get_contents($this->repoPath('Dockerfile')); - $installScript = (string) file_get_contents($this->repoPath('scripts/install-composer-artifacts.sh')); - $script = (string) file_get_contents($this->repoPath('scripts/compose-conformance.sh')); - - foreach (['app', 'seed'] as $serviceName) { - $buildArgs = $services[$serviceName]['build']['args'] ?? []; - - $this->assertSame( - '${DURABLE_WORKFLOW_PHP_SDK_PIN:-}', - $buildArgs['DURABLE_WORKFLOW_PHP_SDK_PIN'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_PHP_SDK_VERSION:-}', - $buildArgs['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_WORKFLOW_PIN:-}', - $buildArgs['DURABLE_WORKFLOW_WORKFLOW_PIN'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_WORKFLOW_VERSION:-}', - $buildArgs['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_WATERLINE_PIN:-}', - $buildArgs['DURABLE_WORKFLOW_WATERLINE_PIN'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_WATERLINE_VERSION:-}', - $buildArgs['DURABLE_WORKFLOW_WATERLINE_VERSION'] ?? null, - ); - $this->assertSame( - '${SAMPLE_APP_COMMIT:-}', - $buildArgs['SAMPLE_APP_COMMIT'] ?? null, - ); - } - - $this->assertSame($services['app']['image'] ?? null, $services['worker']['image'] ?? null); - $this->assertArrayNotHasKey('build', $services['worker'] ?? []); - - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_PHP_SDK_PIN=\n", $dockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_PHP_SDK_VERSION=\n", $dockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_WORKFLOW_PIN=\n", $dockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_WATERLINE_PIN=\n", $dockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_WORKFLOW_VERSION=\n", $dockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_WATERLINE_VERSION=\n", $dockerfile); - $this->assertStringContainsString("ARG SAMPLE_APP_COMMIT=\n", $dockerfile); - $this->assertStringContainsString('ENV SAMPLE_APP_COMMIT=${SAMPLE_APP_COMMIT}', $dockerfile); - $this->assertSame( - '${DURABLE_WORKFLOW_RUST_SDK_VERSION:-}', - $services['app']['environment']['DURABLE_WORKFLOW_RUST_SDK_VERSION'] ?? null, - ); - $this->assertStringContainsString('-e DURABLE_WORKFLOW_RUST_SDK_VERSION', $script); - $this->assertStringContainsString( - 'COPY scripts/install-composer-artifacts.sh /usr/local/bin/install-composer-artifacts', - $dockerfile, - ); - $this->assertStringContainsString('RUN bash /usr/local/bin/install-composer-artifacts', $dockerfile); - $this->assertStringContainsString('artifact_constraint_from_pin', $installScript); - $this->assertStringContainsString('locked_package_version durable-workflow/sdk', $installScript); - $this->assertStringContainsString('locked_package_version durable-workflow/workflow', $installScript); - $this->assertStringContainsString('locked_package_version durable-workflow/waterline', $installScript); - $this->assertStringContainsString('composer install "${install_flags[@]}"', $installScript); - $this->assertStringContainsString('composer require --no-update', $installScript); - $this->assertStringContainsString('composer update durable-workflow/sdk durable-workflow/workflow durable-workflow/waterline', $installScript); - $this->assertStringContainsString( - <<<'SH' -composer update durable-workflow/sdk durable-workflow/workflow durable-workflow/waterline \ - "${update_flags[@]}" -SH, - $installScript, - ); - $this->assertStringContainsString( - <<<'SH' -update_flags=( - --with-dependencies - --no-dev - --no-scripts - --no-autoloader - --prefer-dist - --no-interaction -) -SH, - $installScript, - ); - $this->assertStringContainsString( - <<<'SH' -if [[ "$locked_php_sdk_version" == "$php_sdk_constraint" && "$locked_workflow_version" == "$workflow_constraint" && "$locked_waterline_version" == "$waterline_constraint" ]]; then - composer install "${install_flags[@]}" - exit 0 -fi - -composer require --no-update -SH, - $installScript, - ); - $this->assertStringContainsString('build_runtime_image_for_artifact_tuple', $script); - $this->assertStringContainsString('start_services_and_wait_for_readiness', $script); - $this->assertStringContainsString('docker compose build app', $script); - $this->assertStringContainsString('docker compose up -d --no-build --wait app worker', $script); - $this->assertStringContainsString('export SAMPLE_APP_COMMIT="$sample_app_commit"', $script); - $this->assertStringContainsString('--output="${metadata_container_path}"', $script); - $this->assertStringContainsString('docker compose cp "app:${metadata_container_abs}" "$metadata_path"', $script); - $this->assertStringContainsString( - '"durable-workflow/sdk" => getenv("DURABLE_WORKFLOW_PHP_SDK_VERSION")', - $script, - ); - $this->assertOrdered( - $script, - 'export SAMPLE_APP_COMMIT="$sample_app_commit"', - "printf '\\n==> resolving current published artifact tuple\\n'", - "\n build_runtime_image_for_artifact_tuple\n", - "\n start_services_and_wait_for_readiness\n", - 'app php artisan app:conformance', - 'docker compose cp "app:${metadata_container_abs}" "$metadata_path"', - ); - $this->assertStringContainsString('-e DURABLE_WORKFLOW_PHP_SDK_VERSION', $script); - $this->assertStringContainsString('-e DURABLE_WORKFLOW_WORKFLOW_VERSION', $script); - $this->assertStringContainsString('-e DURABLE_WORKFLOW_WATERLINE_VERSION', $script); - } - - public function test_sample_app_image_installs_playwright_browser_dependencies(): void - { - $dockerfile = (string) file_get_contents($this->repoPath('Dockerfile')); - - $this->assertStringContainsString('npx playwright install --with-deps chromium', $dockerfile); - $this->assertStringContainsString('node docker/playwright-smoke.js', $dockerfile); - $this->assertFileExists($this->repoPath('docker/playwright-smoke.js')); - } - - public function test_sample_app_browser_conformance_uses_a_non_hsts_network_alias(): void - { - $compose = Yaml::parseFile($this->repoPath('docker-compose.yml')); - $aliases = $compose['services']['app']['networks']['default']['aliases'] ?? []; - $script = (string) file_get_contents($this->repoPath('scripts/compose-conformance.sh')); - - $this->assertContains('sample-app', $aliases); - $this->assertStringContainsString( - 'SAMPLE_APP_CONFORMANCE_URL:-http://sample-app:8000', - $script, - ); - } - - public function test_polyglot_validation_derives_compose_project_from_actions_run_context(): void - { - $workflowPath = $this->repoPath('.github/workflows/polyglot-validation.yml'); - $workflow = Yaml::parseFile($workflowPath); - $steps = $workflow['jobs']['smoke']['steps'] ?? []; - - $setupStepIndex = null; - $firstComposeStepIndex = null; - $setupRun = null; - - foreach ($steps as $index => $step) { - if (($step['name'] ?? null) === 'Set isolated Compose project') { - $setupStepIndex = $index; - $setupRun = (string) ($step['run'] ?? ''); - } - - if ( - $firstComposeStepIndex === null - && is_string($step['run'] ?? null) - && str_contains($step['run'], 'docker compose') - ) { - $firstComposeStepIndex = $index; - } - } - - $this->assertNotNull($setupStepIndex); - $this->assertNotNull($firstComposeStepIndex); - $this->assertLessThan($firstComposeStepIndex, $setupStepIndex); - $this->assertStringContainsString('COMPOSE_PROJECT_NAME=$project', (string) $setupRun); - $this->assertStringContainsString('$GITHUB_ENV', (string) $setupRun); - $this->assertStringContainsString('GITHUB_RUN_ID', (string) $setupRun); - $this->assertStringContainsString('GITHUB_JOB', (string) $setupRun); - $this->assertStringContainsString('GITHUB_RUN_ATTEMPT', (string) $setupRun); - $this->assertDoesNotMatchRegularExpression( - '/COMPOSE_PROJECT_NAME:\s*sample-app-polyglot\s*(?:\R|$)/', - (string) file_get_contents($workflowPath), - ); - } - - public function test_compose_smoke_uses_an_ephemeral_host_port_and_isolates_retries(): void - { - $workflowPath = $this->repoPath('.github/workflows/smoke.yml'); - $workflow = Yaml::parseFile($workflowPath); - $job = $workflow['jobs']['compose'] ?? []; - $environment = $job['env'] ?? []; - $steps = $job['steps'] ?? []; - - $this->assertSame('0', $environment['APP_PORT'] ?? null); - $this->assertStringContainsString('github.run_id', $environment['COMPOSE_PROJECT_NAME'] ?? ''); - $this->assertStringContainsString('github.run_attempt', $environment['COMPOSE_PROJECT_NAME'] ?? ''); - $this->assertStringNotContainsString('18080', (string) file_get_contents($workflowPath)); - - $teardownSteps = array_values(array_filter( - $steps, - static fn (array $step): bool => ($step['name'] ?? null) === 'Tear down stack', - )); - - $this->assertCount(1, $teardownSteps); - $this->assertSame('always()', $teardownSteps[0]['if'] ?? null); - $this->assertStringContainsString('docker compose down', $teardownSteps[0]['run'] ?? ''); - } - - public function test_polyglot_worker_long_polls_are_ci_bounded(): void - { - $compose = Yaml::parseFile($this->repoPath('polyglot/docker-compose.yml')); - $services = $compose['services'] ?? []; - $serverEnv = $this->serverEnvironment($compose); - - $this->assertSame( - '5', - $services['python-workflow-worker']['environment']['DURABLE_WORKFLOW_POLL_TIMEOUT_SECONDS'] ?? null, - ); - $this->assertSame( - '5', - $services['python-activity-worker']['environment']['DURABLE_WORKFLOW_POLL_TIMEOUT_SECONDS'] ?? null, - ); - - foreach ([ - 'php-workflow-worker', - 'polyglot-workflow-worker', - 'php-to-rust-workflow-worker', - 'php-query-worker', - 'php-activity-worker', - 'php-same-workflow-worker', - 'php-same-activity-worker', - ] as $serviceName) { - $this->assertContains( - '--poll-timeout=5', - $services[$serviceName]['command'] ?? [], - sprintf('Expected %s to bound worker poll timeouts for CI smoke.', $serviceName), - ); - } - - $serverPollTimeout = (int) ($serverEnv['DW_WORKER_POLL_TIMEOUT'] ?? 0); - $pythonPollTimeout = (int) ( - $services['python-workflow-worker']['environment']['DURABLE_WORKFLOW_POLL_TIMEOUT_SECONDS'] ?? 0 - ); - - $this->assertGreaterThan(0, $serverPollTimeout); - $this->assertLessThan( - $pythonPollTimeout, - $serverPollTimeout, - 'The server long-poll window must stay below the Python worker HTTP poll timeout.', - ); - } - - public function test_polyglot_smoke_covers_both_same_language_corners(): void - { - $compose = Yaml::parseFile($this->repoPath('polyglot/docker-compose.yml')); - $services = $compose['services'] ?? []; - $smoke = (string) file_get_contents($this->repoPath('polyglot/python_worker/scripts/polyglot_smoke.py')); - - $this->assertArrayHasKey('php-same-workflow-worker', $services); - $this->assertArrayHasKey('php-same-activity-worker', $services); - $this->assertStringContainsString('php_same_language', $smoke); - $this->assertStringContainsString('python_same_language', $smoke); - } - - public function test_featured_polyglot_workflow_uses_three_distinct_runtime_routes(): void - { - $compose = Yaml::parseFile($this->repoPath('polyglot/docker-compose.yml')); - $services = $compose['services'] ?? []; - $workflowWorker = $services['polyglot-workflow-worker'] ?? []; - $demo = $services['demo'] ?? []; - $queues = [ - $workflowWorker['environment']['POLYGLOT_WORKFLOW_TASK_QUEUE'] ?? null, - $workflowWorker['environment']['POLYGLOT_PHP2PY_TASK_QUEUE'] ?? null, - $workflowWorker['environment']['POLYGLOT_TO_RUST_TASK_QUEUE'] ?? null, - ]; - - $this->assertSame( - ['polyglot-workflow', 'polyglot-php-to-python', 'polyglot-to-rust'], - $queues, - ); - $this->assertCount(3, array_unique($queues)); - $this->assertContains('--worker-id=polyglot-workflow-worker', $workflowWorker['command'] ?? []); - $this->assertSame( - ['python', '/app/scripts/polyglot_workflow_smoke.py'], - $demo['command'] ?? null, - ); - foreach (['polyglot-workflow-worker', 'python-activity-worker', 'rust-activity-worker'] as $service) { - $this->assertSame('service_started', $demo['depends_on'][$service]['condition'] ?? null); - } - - $worker = (string) file_get_contents($this->repoPath('polyglot/php_worker/worker.php')); - $python = (string) file_get_contents($this->repoPath('polyglot/python_worker/activities.py')); - $rust = (string) file_get_contents($this->repoPath('polyglot/rust_worker/src/main.rs')); - $command = (string) file_get_contents($this->repoPath('scripts/polyglot.sh')); - - $this->assertStringContainsString("'polyglot.PolyglotWorkflow'", $worker); - $this->assertStringContainsString("['queue' => \$pythonQueue]", $worker); - $this->assertStringContainsString("['queue' => \$rustQueue]", $worker); - $this->assertStringContainsString('@activity.defn(name="polyglot.php-to-python.tally")', $python); - $this->assertStringContainsString('worker.register_activity("polyglot.php-to-rust.receipt"', $rust); - $this->assertStringContainsString('scripts/resolve-current-artifacts.sh', $command); - $this->assertStringContainsString('polyglot-workflow-worker', $command); - $this->assertStringContainsString('python-activity-worker', $command); - $this->assertStringContainsString('rust-activity-worker', $command); - $this->assertStringContainsString('run --rm --no-deps demo', $command); - } - - public function test_featured_polyglot_command_runs_from_a_clean_environment(): void - { - $commands = $this->runPolyglotDemoWithFakeDocker(); - - $this->assertStringContainsString('compose version', $commands[0] ?? ''); - $this->assertStringContainsString( - 'build polyglot-workflow-worker python-activity-worker rust-activity-worker demo', - implode("\n", $commands), - ); - $this->assertStringContainsString( - 'pull --policy missing bootstrap server mysql redis', - implode("\n", $commands), - ); - $this->assertStringContainsString( - 'up --detach --no-build --wait --wait-timeout 180 server polyglot-workflow-worker python-activity-worker rust-activity-worker', - implode("\n", $commands), - ); - $this->assertStringContainsString('run --rm --no-deps demo', $commands[array_key_last($commands)] ?? ''); - } - - public function test_polyglot_validation_uses_one_stable_runtime_lifecycle_for_both_cache_paths(): void - { - $workflow = Yaml::parseFile($this->repoPath('.github/workflows/polyglot-validation.yml')); - $job = $workflow['jobs']['smoke'] ?? []; - $steps = $job['steps'] ?? []; - $validationSteps = array_values(array_filter( - $steps, - static fn (array $step): bool => ($step['name'] ?? null) === 'Validate one stable polyglot runtime lifecycle', - )); - $script = (string) file_get_contents($this->repoPath('scripts/polyglot-validation.sh')); - - $this->assertSame(['cold-cache', 'warm-cache'], $job['strategy']['matrix']['cache_mode'] ?? null); - $this->assertSame('${{ matrix.cache_mode }}', $job['env']['POLYGLOT_BUILD_CACHE_MODE'] ?? null); - $this->assertCount(1, $validationSteps); - $this->assertSame( - 'timeout --signal=TERM --kill-after=60s 1620s scripts/polyglot-validation.sh', - trim((string) ($validationSteps[0]['run'] ?? '')), - ); - $this->assertStringContainsString('docker compose build --pull --no-cache "${build_services[@]}"', $script); - $this->assertSame(3, substr_count($script, 'docker compose build')); - $this->assertSame(1, substr_count($script, 'docker compose build "${build_services[@]}"')); - $this->assertSame(1, substr_count($script, "docker compose up \\\n")); - $this->assertStringContainsString('--no-build', $script); - $this->assertStringContainsString('"${topology_services[@]}"', $script); - $this->assertStringContainsString('waterline', $script); - $this->assertStringContainsString('php-query-worker', $script); - $this->assertStringContainsString('--readiness-only', $script); - $this->assertStringContainsString('POLYGLOT_PHP_TASK_CODEC_REJECTION_EVIDENCE=', $script); - $this->assertStringContainsString('POLYGLOT_PYTHON_TASK_CODEC_REJECTION_EVIDENCE=', $script); - $this->assertStringContainsString('POLYGLOT_RUST_TASK_CODEC_REJECTION_EVIDENCE=', $script); - $this->assertStringContainsString('task_codec_rejection_probe.php', $script); - $this->assertStringContainsString('task_codec_rejection_probe.py', $script); - $this->assertStringContainsString('--entrypoint task-codec-rejection-probe', $script); - $this->assertStringContainsString('assert_server_stable "worker registration"', $script); - $this->assertStringContainsString('assert_server_stable "polyglot smoke"', $script); - $this->assertStringContainsString('trap cleanup EXIT', $script); - $this->assertStringContainsString('docker compose down', $script); - $this->assertStringContainsString('--remove-orphans', $script); - $this->assertStringContainsString('--rmi local', $script); - $this->assertStringNotContainsString('sleep ', $script); - } - - public function test_polyglot_validation_exposes_a_stable_check_for_the_cache_matrix(): void - { - $workflow = Yaml::parseFile($this->repoPath('.github/workflows/polyglot-validation.yml')); - $job = $workflow['jobs']['polyglot-qualification'] ?? []; - $steps = $job['steps'] ?? []; - - $this->assertSame('polyglot smoke (PHP/Python/Rust)', $job['name'] ?? null); - $this->assertSame( - '${{ always() && github.server_url == \'https://github.com\' }}', - $job['if'] ?? null, - ); - $this->assertSame(['smoke'], $job['needs'] ?? null); - $this->assertSame('${{ needs.smoke.result }}', $steps[0]['env']['SMOKE_RESULT'] ?? null); - $this->assertSame('test "$SMOKE_RESULT" = success', $steps[0]['run'] ?? null); - } - - public function test_polyglot_validation_executes_cold_and_warm_cache_paths_without_split_startup(): void - { - foreach (['cold-cache' => 1, 'warm-cache' => 2] as $cacheMode => $expectedBuilds) { - $commands = $this->runPolyglotValidationWithFakeDocker($cacheMode); - $builds = array_values(array_filter( - $commands, - static fn (string $command): bool => str_starts_with($command, 'compose build '), - )); - $startups = array_values(array_filter( - $commands, - static fn (string $command): bool => str_starts_with($command, 'compose up '), - )); - $readinessIndex = $this->firstCommandIndex($commands, '--readiness-only'); - $rustCodecProbeIndex = $this->firstCommandIndex( - $commands, - '--entrypoint task-codec-rejection-probe rust-workflow-worker', - ); - $smokeIndex = $this->firstCommandIndex( - $commands, - 'POLYGLOT_PHP_TASK_CODEC_REJECTION_EVIDENCE=', - ); - $startupIndex = $this->firstCommandIndex($commands, 'compose up '); - $teardownIndex = $this->firstCommandIndex($commands, 'compose down '); - - $this->assertCount($expectedBuilds, $builds); - $this->assertCount(1, $startups); - $this->assertStringContainsString('--no-build', $startups[0]); - $this->assertStringContainsString('server', $startups[0]); - $this->assertStringContainsString('python-activity-worker', $startups[0]); - $this->assertStringContainsString('php-query-worker', $startups[0]); - $this->assertStringContainsString('rust-activity-worker', $startups[0]); - $this->assertStringContainsString('waterline', $startups[0]); - $this->assertStringContainsString('--no-deps', $commands[$readinessIndex]); - $this->assertStringContainsString('--no-deps', $commands[$rustCodecProbeIndex]); - $this->assertStringNotContainsString('--build', $commands[$readinessIndex]); - $this->assertStringNotContainsString('--build', $commands[$smokeIndex]); - $this->assertLessThan($startupIndex, $this->firstCommandIndex($commands, 'compose build ')); - $this->assertLessThan($readinessIndex, $startupIndex); - $this->assertLessThan($smokeIndex, $readinessIndex); - $this->assertLessThan($teardownIndex, $smokeIndex); - $this->assertSame($teardownIndex, array_key_last($commands)); - - if ($cacheMode === 'cold-cache') { - $this->assertStringContainsString('--pull --no-cache', $builds[0]); - } else { - $this->assertStringContainsString('--pull', $builds[0]); - $this->assertStringNotContainsString('--no-cache', implode("\n", $builds)); - $this->assertStringNotContainsString('--pull', $builds[1]); - } - } - } - - public function test_polyglot_validation_fails_promptly_with_the_first_worker_startup_exception(): void - { - $output = []; - $exitCode = 0; - $startedAt = microtime(true); - - $this->runPolyglotValidationWithFakeDocker( - 'cold-cache', - 'python-activity-worker', - $output, - $exitCode, - ); - - $this->assertNotSame(0, $exitCode); - $this->assertLessThan(10.0, microtime(true) - $startedAt); - $rendered = implode("\n", $output); - $this->assertStringContainsString( - "TypeError: Client.register_worker() missing 1 required keyword-only argument: 'capability_manifest'", - $rendered, - ); - $this->assertStringContainsString('first retained worker startup exception', $rendered); - $this->assertStringNotContainsString('timed out after 90s', $rendered); - } - - public function test_polyglot_smoke_installs_published_cli_and_configures_waterline(): void - { - $compose = Yaml::parseFile($this->repoPath('polyglot/docker-compose.yml')); - $services = $compose['services'] ?? []; - $dockerfile = (string) file_get_contents($this->repoPath('polyglot/python_worker/Dockerfile')); - $pythonWorkflowDockerfile = (string) file_get_contents($this->repoPath('polyglot/python_workflow/Dockerfile')); - $phpDockerfile = (string) file_get_contents($this->repoPath('polyglot/php_worker/Dockerfile')); - $phpWorker = (string) file_get_contents($this->repoPath('polyglot/php_worker/worker.php')); - $laravelDockerfile = (string) file_get_contents($this->repoPath('polyglot/laravel/Dockerfile')); - $phpEntrypoint = (string) file_get_contents($this->repoPath('docker/entrypoint.sh')); - $smokeShell = (string) file_get_contents($this->repoPath('polyglot/python_worker/scripts/smoke.sh')); - $composerJson = json_decode( - (string) file_get_contents($this->repoPath('composer.json')), - true, - flags: JSON_THROW_ON_ERROR, - ); - $composerLock = json_decode( - (string) file_get_contents($this->repoPath('composer.lock')), + $tuple = json_decode( + (string) file_get_contents($this->path('polyglot/qualified-artifact-tuple.json')), true, flags: JSON_THROW_ON_ERROR, ); - $lockedPackages = array_column($composerLock['packages'] ?? [], null, 'name'); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_CLI_VERSION\n", $dockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_PYTHON_SDK_VERSION\n", $dockerfile); - $this->assertStringContainsString('ARG FASTAVRO_PYTHON_VERSION=1.12.2', $dockerfile); - $this->assertStringContainsString('test -n "$DURABLE_WORKFLOW_CLI_VERSION"', $dockerfile); - $this->assertStringContainsString('test -n "$DURABLE_WORKFLOW_PYTHON_SDK_VERSION"', $dockerfile); - $this->assertStringContainsString('https://durable-workflow.com/install.sh', $dockerfile); - $this->assertStringContainsString('VERSION="${DURABLE_WORKFLOW_CLI_VERSION}"', $dockerfile); - $this->assertStringContainsString( - 'durable-workflow==${DURABLE_WORKFLOW_PYTHON_SDK_VERSION}', - $dockerfile, - ); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_PYTHON_SDK_VERSION\n", $pythonWorkflowDockerfile); - $this->assertStringContainsString('ARG FASTAVRO_PYTHON_VERSION=1.12.2', $pythonWorkflowDockerfile); - $this->assertStringContainsString('test -n "$DURABLE_WORKFLOW_PYTHON_SDK_VERSION"', $pythonWorkflowDockerfile); - $this->assertStringContainsString( - 'durable-workflow==${DURABLE_WORKFLOW_PYTHON_SDK_VERSION}', - $pythonWorkflowDockerfile, - ); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_PHP_SDK_PIN=\n", $phpDockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_PHP_SDK_VERSION\n", $phpDockerfile); - $this->assertStringNotContainsString('composer require --no-dev', $phpDockerfile); - $this->assertStringContainsString('"durable-workflow/sdk:${constraint}"', $phpDockerfile); - $this->assertStringContainsString('composer show apache/avro', $phpDockerfile); - $this->assertStringContainsString('! composer show durable-workflow/workflow', $phpDockerfile); - $this->assertStringContainsString('! composer show laravel/framework', $phpDockerfile); - $this->assertStringNotContainsString('DURABLE_WORKFLOW_WATERLINE', $phpDockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_PHP_SDK_PIN=\n", $laravelDockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_PHP_SDK_VERSION\n", $laravelDockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_WORKFLOW_PIN=\n", $laravelDockerfile); - $this->assertStringContainsString("ARG DURABLE_WORKFLOW_WATERLINE_PIN=\n", $laravelDockerfile); - $this->assertStringContainsString('RUN bash /usr/local/bin/install-composer-artifacts', $laravelDockerfile); - $this->assertStringNotContainsString('ARG DURABLE_WORKFLOW_CLI_VERSION=0.1.', $dockerfile); - $this->assertStringNotContainsString('ARG DURABLE_WORKFLOW_PYTHON_SDK_VERSION=0.4.', $dockerfile); - $this->assertStringNotContainsString('ARG DURABLE_WORKFLOW_PYTHON_SDK_VERSION=0.4.', $pythonWorkflowDockerfile); - $this->assertStringNotContainsString('ARG DURABLE_WORKFLOW_PHP_SDK_VERSION=2.0.0-', $phpDockerfile); - $this->assertStringNotContainsString('2.0.0-alpha.', $phpDockerfile); - $this->assertStringContainsString('use Composer\\InstalledVersions;', $phpWorker); - $this->assertStringContainsString('use DurableWorkflow\\Client;', $phpWorker); - $this->assertStringContainsString('use DurableWorkflow\\Worker;', $phpWorker); - $this->assertStringContainsString("'polyglot.php-to-python.typed-error'", $phpWorker); - $this->assertStringContainsString("'polyglot.php.signal-query'", $phpWorker); - $this->assertStringContainsString("'package' => 'apache/avro'", $phpWorker); - $this->assertStringNotContainsString('Illuminate\\', $phpWorker); - $this->assertStringNotContainsString('Workflow\\V2', $phpWorker); - $this->assertStringContainsString('append_env_var WATERLINE_PATH', $phpEntrypoint); - $this->assertStringContainsString('append_env_var WATERLINE_ENGINE_SOURCE', $phpEntrypoint); - $this->assertStringContainsString('append_env_var WATERLINE_NAMESPACE', $phpEntrypoint); - $this->assertStringContainsString('append_env_var WATERLINE_ALLOW_UNAUTHENTICATED', $phpEntrypoint); - $this->assertStringContainsString( - 'DURABLE_WORKFLOW_CLI_PIN="dw==${DURABLE_WORKFLOW_CLI_VERSION}"', - $smokeShell, - ); - $this->assertStringContainsString('require_artifact_env DURABLE_WORKFLOW_CLI_VERSION', $smokeShell); - $this->assertStringContainsString('require_artifact_env DURABLE_WORKFLOW_PYTHON_SDK_VERSION', $smokeShell); - $this->assertStringContainsString('require_artifact_env DURABLE_WORKFLOW_RUST_SDK_VERSION', $smokeShell); - $this->assertStringContainsString('require_artifact_env DURABLE_WORKFLOW_PHP_SDK_VERSION', $smokeShell); - $this->assertStringContainsString('require_artifact_env DURABLE_WORKFLOW_WORKFLOW_VERSION', $smokeShell); - $this->assertStringContainsString('require_artifact_env DURABLE_WORKFLOW_WATERLINE_VERSION', $smokeShell); - $this->assertStringContainsString('DURABLE_WORKFLOW_PHP_SDK_PIN:=}', $smokeShell); - $this->assertStringContainsString('DURABLE_WORKFLOW_WORKFLOW_PIN:=}', $smokeShell); - $this->assertStringContainsString('DURABLE_WORKFLOW_WATERLINE_PIN:=}', $smokeShell); - $this->assertStringContainsString('${DURABLE_WORKFLOW_PHP_SDK_PIN#durable-workflow/sdk:}', $smokeShell); - $this->assertStringContainsString( - 'DURABLE_WORKFLOW_PHP_SDK_PIN="durable-workflow/sdk:${DURABLE_WORKFLOW_PHP_SDK_VERSION}@beta"', - $smokeShell, - ); - $this->assertStringContainsString( - 'DURABLE_WORKFLOW_WORKFLOW_PIN="durable-workflow/workflow:${DURABLE_WORKFLOW_WORKFLOW_VERSION}@beta"', - $smokeShell, - ); - $this->assertStringContainsString( - '${DURABLE_WORKFLOW_WATERLINE_PIN#durable-workflow/waterline:}', - $smokeShell, - ); - $this->assertStringContainsString( - 'DURABLE_WORKFLOW_WATERLINE_PIN="durable-workflow/waterline:${DURABLE_WORKFLOW_WATERLINE_VERSION}@beta"', - $smokeShell, - ); - $this->assertStringNotContainsString('DURABLE_WORKFLOW_WATERLINE_VERSION:=2.0.0-alpha.50', $smokeShell); - $this->assertStringNotContainsString('DURABLE_WORKFLOW_PHP_SDK_VERSION:=2.0.0-', $smokeShell); - $this->assertStringNotContainsString('DURABLE_WORKFLOW_WATERLINE_VERSION:=2.0.0-', $smokeShell); - $this->assertArrayNotHasKey('repositories', $composerJson); - foreach ([ - 'durable-workflow/sdk' => 'https://github.com/durable-workflow/sdk-php.git', - 'durable-workflow/workflow' => 'https://github.com/durable-workflow/workflow.git', - 'durable-workflow/waterline' => 'https://github.com/durable-workflow/waterline.git', - ] as $package => $sourceUrl) { - $packageVersion = $composerJson['require'][$package] ?? null; - $this->assertIsString($packageVersion); - $this->assertMatchesRegularExpression('/^2\.0\.0-rc\.\d+$/', $packageVersion); - $this->assertIsArray($lockedPackages[$package] ?? null); - $this->assertSame($packageVersion, $lockedPackages[$package]['version'] ?? null); - $this->assertSame( - $packageVersion, - $lockedPackages[$package]['extra']['durable-workflow']['product-train'] ?? null, - ); - $this->assertSame($sourceUrl, $lockedPackages[$package]['source']['url'] ?? null); - $sourceReference = $lockedPackages[$package]['source']['reference'] ?? null; - $this->assertIsString($sourceReference); - $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $sourceReference); - $this->assertSame($sourceReference, $lockedPackages[$package]['dist']['reference'] ?? null); - $this->assertStringContainsString( - $sourceReference, - $lockedPackages[$package]['dist']['url'] ?? '', - ); - $this->assertSame( - 'https://packagist.org/downloads/', - $lockedPackages[$package]['notification-url'] ?? null, - ); - } - - $this->assertArrayHasKey('waterline', $services); - $this->assertSame('v2', $services['waterline']['environment']['WATERLINE_ENGINE_SOURCE'] ?? null); - $this->assertSame('default', $services['waterline']['environment']['WATERLINE_NAMESPACE'] ?? null); - $this->assertSame('true', $services['waterline']['environment']['WATERLINE_ALLOW_UNAUTHENTICATED'] ?? null); - $this->assertSame('mysql', $services['waterline']['environment']['DB_HOST'] ?? null); - $this->assertSame(3306, $services['waterline']['environment']['DB_PORT'] ?? null); - $this->assertSame('durable_workflow', $services['waterline']['environment']['DB_DATABASE'] ?? null); - $this->assertSame('workflow', $services['waterline']['environment']['DB_USERNAME'] ?? null); - $this->assertSame('workflow', $services['waterline']['environment']['DB_PASSWORD'] ?? null); - $this->assertSame('mysql', $services['waterline']['environment']['SHARED_DB_HOST'] ?? null); - $this->assertSame(3306, $services['waterline']['environment']['SHARED_DB_PORT'] ?? null); - $this->assertSame('durable_workflow', $services['waterline']['environment']['SHARED_DB_DATABASE'] ?? null); - $this->assertSame('workflow', $services['waterline']['environment']['SHARED_DB_USERNAME'] ?? null); - $this->assertSame('workflow', $services['waterline']['environment']['SHARED_DB_PASSWORD'] ?? null); - $this->assertSame(['8081'], $services['waterline']['expose'] ?? null); - $this->assertSame( - ['CMD', 'curl', '-f', 'http://localhost:8081/waterline/api/v2/health'], - $services['waterline']['healthcheck']['test'] ?? null, - ); - $this->assertArrayHasKey('waterline', $services['smoke']['depends_on'] ?? []); - $this->assertSame('service_healthy', $services['smoke']['depends_on']['waterline']['condition'] ?? null); - $this->assertSame( - 'http://waterline:8081/waterline', - $services['smoke']['environment']['DURABLE_WORKFLOW_WATERLINE_URL'] ?? null, - ); - $this->assertSame( - 'http://waterline:8081/polyglot/conformance/artifacts', - $services['smoke']['environment']['DURABLE_WORKFLOW_ARTIFACT_PROBE_URL'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_CLI_PIN:-}', - $services['smoke']['environment']['DURABLE_WORKFLOW_CLI_PIN'] ?? null, - ); - $this->assertSame( - $this->requiredResolvedEnv('DURABLE_WORKFLOW_PYTHON_SDK_VERSION'), - $services['smoke']['environment']['DURABLE_WORKFLOW_PYTHON_SDK_VERSION'] ?? null, - ); - $this->assertSame( - $this->requiredResolvedEnv('DURABLE_WORKFLOW_RUST_SDK_VERSION'), - $services['smoke']['environment']['DURABLE_WORKFLOW_RUST_SDK_VERSION'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_PHP_SDK_PIN:-}', - $services['smoke']['environment']['DURABLE_WORKFLOW_PHP_SDK_PIN'] ?? null, - ); - $this->assertSame( - $this->requiredResolvedEnv('DURABLE_WORKFLOW_PHP_SDK_VERSION'), - $services['smoke']['environment']['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_WORKFLOW_PIN:-}', - $services['smoke']['environment']['DURABLE_WORKFLOW_WORKFLOW_PIN'] ?? null, - ); - $this->assertSame( - $this->requiredResolvedEnv('DURABLE_WORKFLOW_WORKFLOW_VERSION'), - $services['smoke']['environment']['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null, - ); + $this->assertSame('durable-workflow.sample-app.polyglot-qualified-artifact-tuple', $tuple['schema']); + $this->assertSame(1, $tuple['schemaVersion']); $this->assertSame( - '${DURABLE_WORKFLOW_WATERLINE_PIN:-}', - $services['smoke']['environment']['DURABLE_WORKFLOW_WATERLINE_PIN'] ?? null, - ); - $this->assertSame( - $this->requiredResolvedEnv('DURABLE_WORKFLOW_WATERLINE_VERSION'), - $services['smoke']['environment']['DURABLE_WORKFLOW_WATERLINE_VERSION'] ?? null, + ['cli', 'sdk-php', 'sdk-python', 'sdk-rust', 'server', 'waterline', 'workflow'], + array_keys($tuple['artifacts']), ); - foreach ([ - 'php-same-workflow-worker', - 'php-same-activity-worker', - 'php-workflow-worker', - 'polyglot-workflow-worker', - 'php-to-rust-workflow-worker', - 'php-query-worker', - 'php-activity-worker', - ] as $serviceName) { - $this->assertSame( - '${DURABLE_WORKFLOW_PHP_SDK_PIN:-}', - $services[$serviceName]['build']['args']['DURABLE_WORKFLOW_PHP_SDK_PIN'] ?? null, - ); - $this->assertSame( - $this->requiredResolvedEnv('DURABLE_WORKFLOW_PHP_SDK_VERSION'), - $services[$serviceName]['build']['args']['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null, - ); - $this->assertArrayNotHasKey('DURABLE_WORKFLOW_WORKFLOW_PIN', $services[$serviceName]['build']['args']); - $this->assertArrayNotHasKey('DURABLE_WORKFLOW_WATERLINE_PIN', $services[$serviceName]['build']['args']); + foreach ($tuple['artifacts'] as $version) { + $this->assertMatchesRegularExpression('/^2\.\d+\.\d+$/', $version); } + $assignments = $this->resolveArtifacts(); + $this->assertSame('durableworkflow/server:'.$tuple['artifacts']['server'], $assignments['DURABLE_SERVER_IMAGE']); + $this->assertSame($tuple['artifacts']['cli'], $assignments['DURABLE_WORKFLOW_CLI_VERSION']); + $this->assertSame($tuple['artifacts']['sdk-php'], $assignments['DURABLE_WORKFLOW_PHP_SDK_VERSION']); + $this->assertSame($tuple['artifacts']['sdk-python'], $assignments['DURABLE_WORKFLOW_PYTHON_SDK_VERSION']); + $this->assertSame($tuple['artifacts']['sdk-rust'], $assignments['DURABLE_WORKFLOW_RUST_SDK_VERSION']); + $this->assertSame($tuple['artifacts']['workflow'], $assignments['DURABLE_WORKFLOW_WORKFLOW_VERSION']); + $this->assertSame($tuple['artifacts']['waterline'], $assignments['DURABLE_WORKFLOW_WATERLINE_VERSION']); $this->assertSame( - '${DURABLE_WORKFLOW_WORKFLOW_PIN:-}', - $services['waterline']['build']['args']['DURABLE_WORKFLOW_WORKFLOW_PIN'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_PHP_SDK_PIN:-}', - $services['waterline']['build']['args']['DURABLE_WORKFLOW_PHP_SDK_PIN'] ?? null, - ); - $this->assertSame( - $this->requiredResolvedEnv('DURABLE_WORKFLOW_PHP_SDK_VERSION'), - $services['waterline']['build']['args']['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null, - ); - $this->assertSame( - $this->requiredResolvedEnv('DURABLE_WORKFLOW_WORKFLOW_VERSION'), - $services['waterline']['build']['args']['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_WATERLINE_PIN:-}', - $services['waterline']['build']['args']['DURABLE_WORKFLOW_WATERLINE_PIN'] ?? null, - ); - } - - public function test_committed_waterline_assets_match_current_locked_package(): void - { - $packageAssets = $this->directoryFileHashes( - $this->repoPath('vendor/durable-workflow/waterline/public'), - ); - - foreach ([ - 'public/vendor/waterline', - 'microservice/public/vendor/waterline', - ] as $publishedDirectory) { - $this->assertSame( - $packageAssets, - $this->directoryFileHashes($this->repoPath($publishedDirectory)), - $publishedDirectory, - ); - } - } - - public function test_polyglot_laravel_services_use_valid_aes_256_app_keys(): void - { - $compose = Yaml::parseFile($this->repoPath('polyglot/docker-compose.yml')); - $serverEnv = $this->serverEnvironment($compose); - $waterlineEnv = $compose['services']['waterline']['environment'] ?? []; - - $this->assertLaravelAppKeySupportsAes256($serverEnv['APP_KEY'] ?? null, 'server APP_KEY'); - $this->assertLaravelAppKeySupportsAes256($waterlineEnv['APP_KEY'] ?? null, 'waterline APP_KEY'); - } - - public function test_polyglot_artifact_resolver_preserves_explicit_overrides(): void - { - $assignments = $this->resolveArtifactAssignments([ - 'DURABLE_SERVER_IMAGE' => 'ghcr.io/example/server:9.9.9', - 'DURABLE_WORKFLOW_CLI_PIN' => 'example/cli:9.9.8', - 'DURABLE_WORKFLOW_RUST_SDK_VERSION' => '9.9.7', - 'DURABLE_WORKFLOW_PHP_SDK_PIN' => 'durable-workflow/sdk:0.1.777', - 'DURABLE_WORKFLOW_WORKFLOW_PIN' => 'durable-workflow/workflow:2.0.0-alpha.777', - 'DURABLE_WORKFLOW_WATERLINE_PIN' => 'durable-workflow/waterline:2.0.0-alpha.778', - ]); - - $this->assertSame('ghcr.io/example/server:9.9.9', $assignments['DURABLE_SERVER_IMAGE'] ?? null); - $this->assertSame('9.9.9', $assignments['DURABLE_SERVER_VERSION'] ?? null); - $this->assertSame('example/cli:9.9.8', $assignments['DURABLE_WORKFLOW_CLI_PIN'] ?? null); - $this->assertSame('9.9.8', $assignments['DURABLE_WORKFLOW_CLI_VERSION'] ?? null); - $this->assertSame('9.9.7', $assignments['DURABLE_WORKFLOW_RUST_SDK_VERSION'] ?? null); - $this->assertSame( - 'durable-workflow/sdk:0.1.777', - $assignments['DURABLE_WORKFLOW_PHP_SDK_PIN'] ?? null, - ); - $this->assertSame('0.1.777', $assignments['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null); - $this->assertSame( - 'durable-workflow/workflow:2.0.0-alpha.777', - $assignments['DURABLE_WORKFLOW_WORKFLOW_PIN'] ?? null, + 'durable-workflow/sdk:'.$tuple['artifacts']['sdk-php'], + $assignments['DURABLE_WORKFLOW_PHP_SDK_PIN'], ); - $this->assertSame('2.0.0-alpha.777', $assignments['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null); - $this->assertSame( - 'durable-workflow/waterline:2.0.0-alpha.778', - $assignments['DURABLE_WORKFLOW_WATERLINE_PIN'] ?? null, - ); - $this->assertSame('2.0.0-alpha.778', $assignments['DURABLE_WORKFLOW_WATERLINE_VERSION'] ?? null); } - public function test_polyglot_artifact_resolver_normalizes_legacy_cli_package_pin(): void + public function test_artifact_resolver_accepts_stable_overrides_and_rejects_prereleases(): void { - $assignments = $this->resolveArtifactAssignments([ - 'DURABLE_WORKFLOW_CLI_PIN' => 'durable-workflow/cli:0.1.64', + $assignments = $this->resolveArtifacts([ + 'SAMPLE_APP_RUST_SDK_VERSION' => '2.3.4', + 'SAMPLE_APP_PHP_SDK_PIN' => 'durable-workflow/sdk:2.4.5', ]); - $this->assertSame('0.1.64', $assignments['DURABLE_WORKFLOW_CLI_VERSION'] ?? null); - $this->assertSame('dw==0.1.64', $assignments['DURABLE_WORKFLOW_CLI_PIN'] ?? null); - } - - public function test_polyglot_artifact_resolver_loads_complete_official_tuple(): void - { - $assignments = $this->resolveArtifactAssignments(); + $this->assertSame('2.3.4', $assignments['DURABLE_WORKFLOW_RUST_SDK_VERSION']); + $this->assertSame('2.4.5', $assignments['DURABLE_WORKFLOW_PHP_SDK_VERSION']); + $this->assertSame('durable-workflow/sdk:2.4.5', $assignments['DURABLE_WORKFLOW_PHP_SDK_PIN']); - $this->assertSame('durableworkflow/server:2.0.0-beta.7', $assignments['DURABLE_SERVER_IMAGE'] ?? null); - $this->assertSame('2.0.0-beta.7', $assignments['DURABLE_SERVER_VERSION'] ?? null); - $this->assertSame('2.0.0-beta.7', $assignments['DURABLE_WORKFLOW_CLI_VERSION'] ?? null); - $this->assertSame('dw==2.0.0-beta.7', $assignments['DURABLE_WORKFLOW_CLI_PIN'] ?? null); - $this->assertSame('2.0.0-beta.7', $assignments['DURABLE_WORKFLOW_PYTHON_SDK_VERSION'] ?? null); - $this->assertSame('2.0.0-beta.7', $assignments['DURABLE_WORKFLOW_RUST_SDK_VERSION'] ?? null); - $this->assertSame('2.0.0-beta.7', $assignments['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null); - $this->assertSame( - 'durable-workflow/sdk:2.0.0-beta.7@beta', - $assignments['DURABLE_WORKFLOW_PHP_SDK_PIN'] ?? null, + $process = new Process( + [$this->path('scripts/resolve-current-artifacts.sh')], + env: ['SAMPLE_APP_RUST_SDK_VERSION' => '2.0.0-rc.99'], ); - $this->assertSame('2.0.0-beta.7', $assignments['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null); - $this->assertSame( - 'durable-workflow/workflow:2.0.0-beta.7@beta', - $assignments['DURABLE_WORKFLOW_WORKFLOW_PIN'] ?? null, - ); - $this->assertSame('2.0.0-beta.7', $assignments['DURABLE_WORKFLOW_WATERLINE_VERSION'] ?? null); - $this->assertSame( - 'durable-workflow/waterline:2.0.0-beta.7@beta', - $assignments['DURABLE_WORKFLOW_WATERLINE_PIN'] ?? null, - ); - } + $process->run(); - public function test_polyglot_artifact_resolver_loads_exact_release_candidate_tuple(): void - { - $assignments = $this->resolveArtifactAssignments([ - 'DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE' => $this->repoPath('tests/Fixtures/release-candidate-artifact-tuple.json'), - ]); - $serverVersion = '2.0.0-rc.2'; - $componentVersion = '2.0.0-rc.1'; - - $this->assertSame('durableworkflow/server:'.$serverVersion, $assignments['DURABLE_SERVER_IMAGE'] ?? null); - $this->assertSame($serverVersion, $assignments['DURABLE_SERVER_VERSION'] ?? null); - $this->assertSame($componentVersion, $assignments['DURABLE_WORKFLOW_CLI_VERSION'] ?? null); - $this->assertSame('dw=='.$componentVersion, $assignments['DURABLE_WORKFLOW_CLI_PIN'] ?? null); - $this->assertSame($componentVersion, $assignments['DURABLE_WORKFLOW_PYTHON_SDK_VERSION'] ?? null); - $this->assertSame($componentVersion, $assignments['DURABLE_WORKFLOW_RUST_SDK_VERSION'] ?? null); - $this->assertSame($componentVersion, $assignments['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null); - $this->assertSame('durable-workflow/sdk:'.$componentVersion.'@beta', $assignments['DURABLE_WORKFLOW_PHP_SDK_PIN'] ?? null); - $this->assertSame($componentVersion, $assignments['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null); - $this->assertSame('durable-workflow/workflow:'.$componentVersion.'@beta', $assignments['DURABLE_WORKFLOW_WORKFLOW_PIN'] ?? null); - $this->assertSame($componentVersion, $assignments['DURABLE_WORKFLOW_WATERLINE_VERSION'] ?? null); - $this->assertSame('durable-workflow/waterline:'.$componentVersion.'@beta', $assignments['DURABLE_WORKFLOW_WATERLINE_PIN'] ?? null); + $this->assertFalse($process->isSuccessful()); + $this->assertStringContainsString('must be a stable 2.x version', $process->getErrorOutput()); } - public function test_polyglot_artifact_resolver_binds_task_codec_evidence_to_the_exact_tuple(): void + public function test_polyglot_compose_uses_isolated_runtime_services_and_resolved_artifacts(): void { - $version = '2.0.0-rc.53'; - $evidence = static function ( - string $runtime, - string $artifact, - ?string $artifactVersion = null, - ) use ($version): string { - return json_encode([ - 'schema' => 'durable-workflow.sample-app.task-codec-rejection-probe', - 'version' => 1, - 'runtime' => $runtime, - 'artifact' => [ - 'name' => $artifact, - 'version' => $artifactVersion ?? $version, - ], - 'rejection_outcomes' => [], - 'valid_controls' => [], - 'summary' => ['status' => 'passed', 'failed_count' => 0], - ], JSON_THROW_ON_ERROR); - }; - $phpEvidence = $evidence('php', 'durable-workflow/sdk'); - $assignments = $this->resolveArtifactAssignments([ - 'DURABLE_WORKFLOW_PHP_SDK_VERSION' => $version, - 'DURABLE_WORKFLOW_PYTHON_SDK_VERSION' => $version, - 'DURABLE_WORKFLOW_RUST_SDK_VERSION' => $version, - 'POLYGLOT_PHP_TASK_CODEC_REJECTION_EVIDENCE' => $phpEvidence, - 'POLYGLOT_PYTHON_TASK_CODEC_REJECTION_EVIDENCE' => $evidence( - 'python', - 'durable-workflow', - '2.0.0rc53', - ), - 'POLYGLOT_RUST_TASK_CODEC_REJECTION_EVIDENCE' => $evidence('rust', 'durable-workflow'), - ], false, '--task-codec-evidence'); + $compose = Yaml::parseFile($this->path('polyglot/docker-compose.yml')); + $services = $compose['services']; - $this->assertSame( - json_decode($phpEvidence, true, flags: JSON_THROW_ON_ERROR), - json_decode( - $assignments['POLYGLOT_PHP_TASK_CODEC_REJECTION_EVIDENCE'] ?? '', - true, - flags: JSON_THROW_ON_ERROR, - ), - ); - } - - public function test_polyglot_artifact_resolver_rejects_genuinely_unknown_tuple_key(): void - { - $fixture = $this->repoPath('tests/Fixtures/unknown-artifact-tuple.json'); - $resolver = $this->repoPath('scripts/resolve-current-artifacts.sh'); - $command = sprintf( - 'env -i PATH=%s DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE=%s bash %s 2>&1', - escapeshellarg((string) getenv('PATH')), - escapeshellarg($fixture), - escapeshellarg($resolver), - ); - - exec($command, $output, $exitCode); - - $this->assertSame(1, $exitCode); - $this->assertStringContainsString( - 'contains unknown artifact keys: unknown-sdk', - implode("\n", $output), - ); - } - - public function test_polyglot_artifact_resolver_uses_current_tuple_source_by_default(): void - { - $assignments = $this->resolveArtifactAssignments([ - 'DURABLE_WORKFLOW_CURRENT_ARTIFACT_TUPLE_URL' => 'file://'.$this->repoPath('tests/Fixtures/release-candidate-artifact-tuple.json'), - ], false); - $artifactResolver = (string) file_get_contents($this->repoPath('scripts/resolve-current-artifacts.sh')); - - $this->assertSame('durableworkflow/server:2.0.0-rc.2', $assignments['DURABLE_SERVER_IMAGE'] ?? null); - $this->assertSame('2.0.0-rc.1', $assignments['DURABLE_WORKFLOW_CLI_VERSION'] ?? null); - $this->assertSame('2.0.0-rc.1', $assignments['DURABLE_WORKFLOW_PYTHON_SDK_VERSION'] ?? null); - $this->assertSame('2.0.0-rc.1', $assignments['DURABLE_WORKFLOW_RUST_SDK_VERSION'] ?? null); - $this->assertSame('2.0.0-rc.1', $assignments['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null); - $this->assertSame('2.0.0-rc.1', $assignments['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null); - $this->assertSame('2.0.0-rc.1', $assignments['DURABLE_WORKFLOW_WATERLINE_VERSION'] ?? null); - $this->assertStringContainsString('https://durable-workflow.com/docs-page-release-audit.json', $artifactResolver); - $this->assertStringContainsString('must expose one 2.0 beta or release-candidate channel', $artifactResolver); - $this->assertStringContainsString('must expose one synchronized 2.0 beta version', $artifactResolver); - $this->assertStringNotContainsString('latest_dockerhub_server_version', $artifactResolver); - } - - public function test_polyglot_artifact_resolver_rejects_mixed_beta_generations(): void - { - $resolver = $this->repoPath('scripts/resolve-current-artifacts.sh'); - $fixture = $this->repoPath('tests/Fixtures/lagging-artifact-tuple.json'); - $command = sprintf( - 'env -i PATH=%s DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE=%s bash %s 2>&1', - escapeshellarg((string) getenv('PATH')), - escapeshellarg($fixture), - escapeshellarg($resolver), - ); - - exec($command, $output, $exitCode); - - $this->assertSame(1, $exitCode); - $this->assertStringContainsString('must expose one synchronized 2.0 beta version', implode("\n", $output)); - } - - public function test_polyglot_artifact_resolver_keeps_pinned_tuple_explicit(): void - { - $assignments = $this->resolveArtifactAssignments([ - 'DURABLE_WORKFLOW_ARTIFACT_SOURCE' => 'pinned', - ]); - $tuple = json_decode( - (string) file_get_contents($this->repoPath('polyglot/qualified-artifact-tuple.json')), - true, - flags: JSON_THROW_ON_ERROR, - ); - $artifacts = $tuple['artifacts'] ?? []; + $this->assertArrayNotHasKey('name', $compose); + $this->assertArrayNotHasKey('ports', $services['server']); + $this->assertSame(['8080'], $services['server']['expose']); - $this->assertSame('durable-workflow.sample-app.polyglot-qualified-artifact-tuple', $tuple['schema'] ?? null); - $this->assertSame(1, $tuple['schemaVersion'] ?? null); - foreach ($artifacts as $version) { - $this->assertIsString($version); - $this->assertMatchesRegularExpression('/^2\.0\.0-rc\.\d+$/', $version); + foreach (['mysql', 'redis', 'bootstrap', 'server', 'polyglot-workflow-worker', 'python-activity-worker', 'rust-activity-worker', 'demo'] as $service) { + $this->assertArrayHasKey($service, $services); } - $this->assertSame('durableworkflow/server:'.$artifacts['server'], $assignments['DURABLE_SERVER_IMAGE'] ?? null); - $this->assertSame($artifacts['server'], $assignments['DURABLE_SERVER_VERSION'] ?? null); - $this->assertSame($artifacts['cli'], $assignments['DURABLE_WORKFLOW_CLI_VERSION'] ?? null); - $this->assertSame('dw=='.$artifacts['cli'], $assignments['DURABLE_WORKFLOW_CLI_PIN'] ?? null); - $this->assertSame($artifacts['sdk-python'], $assignments['DURABLE_WORKFLOW_PYTHON_SDK_VERSION'] ?? null); - $this->assertSame($artifacts['sdk-rust'], $assignments['DURABLE_WORKFLOW_RUST_SDK_VERSION'] ?? null); - $this->assertSame($artifacts['sdk-php'], $assignments['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null); $this->assertSame( - 'durable-workflow/sdk:'.$artifacts['sdk-php'].'@beta', - $assignments['DURABLE_WORKFLOW_PHP_SDK_PIN'] ?? null, + '${DURABLE_SERVER_IMAGE:?run ../scripts/resolve-current-artifacts.sh before starting polyglot compose}', + $services['server']['image'], ); - $this->assertSame($artifacts['workflow'], $assignments['DURABLE_WORKFLOW_WORKFLOW_VERSION'] ?? null); $this->assertSame( - 'durable-workflow/workflow:'.$artifacts['workflow'].'@beta', - $assignments['DURABLE_WORKFLOW_WORKFLOW_PIN'] ?? null, + '${DURABLE_WORKFLOW_PYTHON_SDK_VERSION:?run ../scripts/resolve-current-artifacts.sh before starting polyglot compose}', + $services['python-activity-worker']['build']['args']['DURABLE_WORKFLOW_PYTHON_SDK_VERSION'], ); - $this->assertSame($artifacts['waterline'], $assignments['DURABLE_WORKFLOW_WATERLINE_VERSION'] ?? null); $this->assertSame( - 'durable-workflow/waterline:'.$artifacts['waterline'].'@beta', - $assignments['DURABLE_WORKFLOW_WATERLINE_PIN'] ?? null, + '${DURABLE_WORKFLOW_RUST_SDK_VERSION:?run ../scripts/resolve-current-artifacts.sh before starting polyglot compose}', + $services['rust-activity-worker']['build']['args']['DURABLE_WORKFLOW_RUST_SDK_VERSION'], ); - } - - public function test_polyglot_validation_exercises_qualified_artifacts_for_push_and_pull_requests(): void - { - $workflowPath = $this->repoPath('.github/workflows/polyglot-validation.yml'); - $workflow = Yaml::parseFile($workflowPath); - $triggers = $workflow['on'] ?? []; - $job = $workflow['jobs']['smoke'] ?? []; - $steps = $job['steps'] ?? []; - $resolveSteps = array_values(array_filter( - $steps, - static fn (array $step): bool => ($step['name'] ?? null) === 'Resolve qualified artifact tuple', - )); - - $this->assertArrayHasKey('push', $triggers); - $this->assertArrayHasKey('pull_request', $triggers); - $this->assertSame('pinned', $job['env']['DURABLE_WORKFLOW_ARTIFACT_SOURCE'] ?? null); - - $this->assertCount(1, $resolveSteps); - $resolution = (string) ($resolveSteps[0]['run'] ?? ''); - $this->assertStringContainsString('scripts/resolve-current-artifacts.sh', $resolution); - $this->assertStringContainsString('echo "$assignment" >> "$GITHUB_ENV"', $resolution); - } - - public function test_polyglot_smoke_metadata_covers_required_conformance_surfaces(): void - { - $smoke = (string) file_get_contents($this->repoPath('polyglot/python_worker/scripts/polyglot_smoke.py')); - - foreach ([ - 'cli_start_result', - 'signals_queries', - 'type_matrix', - 'typed_errors', - 'waterline', - 'required_env_version', - 'polyglot.python.signal-query', - 'polyglot.php.signal-query', - 'polyglot.python-to-php.binary-type-roundtrip', - 'polyglot.php-to-python.binary-type-roundtrip', - 'polyglot.rust.signal-query', - 'polyglot.rust-to-python.binary-type-roundtrip', - 'polyglot.python-to-rust.binary-type-roundtrip', - 'polyglot.rust-to-php.binary-type-roundtrip', - 'polyglot.php-to-rust.binary-type-roundtrip', - 'polyglot.python-to-php.typed-error', - 'polyglot.php-to-python.typed-error', - 'REQUIRED_ARTIFACT_VERSIONS', - '"sdk-rust": required_env_version("DURABLE_WORKFLOW_RUST_SDK_VERSION")', - '"version_source": "rust_worker_registration"', - '"exercised": rust_exercised', - '"execution_evidence": "runtime_matrix" if rust_exercised else None', - 'cargo add durable-workflow@', - '"rust_execution": True', - '"publishedDependencies": {"officialApacheAvro": avro_packages}', - '"artifactVersions": artifact_versions', - '"requiredArtifactVersions": REQUIRED_ARTIFACT_VERSIONS', - '"artifactProbe":', - '"artifact_versions_current": True', - '"waterline_assets_current": True', - '"artifact_blocked"', - 'def waterline_asset_findings(', - '"stale_assets": stale_assets', - 'PHP worker advertised standalone SDK', - '"artifact": "durable-workflow/sdk"', - '"role": "framework-neutral standalone client and remote worker SDK"', - '"artifact": "durable-workflow/workflow"', - '"role": "embedded Laravel engine and Waterline host"', - ] as $needle) { - $this->assertStringContainsString($needle, $smoke); - } - - $this->assertStringContainsString('"workflow_start_result_driver": "dw CLI"', $smoke); - $this->assertStringContainsString('"signal_driver": "dw CLI"', $smoke); - $this->assertStringContainsString('"query_driver": "dw CLI"', $smoke); - $this->assertStringContainsString('"result_driver": "dw CLI"', $smoke); - $this->assertStringContainsString('def wait_for_signal_wait_open(', $smoke); - $this->assertStringContainsString('durable_wait = wait_for_signal_wait_open(wid, SIGNAL_NAME)', $smoke); - $this->assertStringContainsString('"durable_wait_before_signal"', $smoke); - $this->assertStringContainsString('"query_after_signal"', $smoke); - $this->assertStringNotContainsString('server_php_worker_query_routing', $smoke); - $this->assertStringNotContainsString('"blocked_surfaces": blocked_surfaces', $smoke); - $this->assertStringContainsString('こんにちは', $smoke); - $this->assertStringContainsString('binary_base64', $smoke); - $this->assertStringContainsString('"exercised": False', $smoke); - - $pythonWorkflow = (string) file_get_contents($this->repoPath('polyglot/python_workflow/workflow.py')); - $this->assertStringContainsString('POLYGLOT_SIGNAL_CONDITION_KEY = f"polyglot.signal.{POLYGLOT_SIGNAL_NAME}"', $pythonWorkflow); - $this->assertStringContainsString('key=POLYGLOT_SIGNAL_CONDITION_KEY', $pythonWorkflow); - } - - public function test_python_typed_error_activity_worker_refreshes_manual_registration(): void - { - $activities = (string) file_get_contents($this->repoPath('polyglot/python_worker/activities.py')); - - $this->assertStringContainsString('max_concurrent_activity_tasks=1', $activities); - $this->assertStringContainsString('heartbeat_typed_error_worker', $activities); - $this->assertStringContainsString('asyncio.create_task(', $activities); - $this->assertStringContainsString('client.heartbeat_worker(', $activities); - $this->assertStringContainsString('task_slots={"activity_available": 1}', $activities); - $this->assertStringContainsString('POLYGLOT_TYPED_ERROR_HEARTBEAT_SECONDS', $activities); - } - - public function test_polyglot_waterline_probe_reports_installed_php_artifacts(): void - { - $routes = (string) file_get_contents($this->repoPath('routes/web.php')); - $smoke = (string) file_get_contents($this->repoPath('polyglot/python_worker/scripts/polyglot_smoke.py')); - - $this->assertStringContainsString("Route::get('/polyglot/conformance/artifacts'", $routes); - $this->assertStringContainsString("app()->environment('testing')", $routes); - $this->assertStringContainsString('InstalledVersions::getPrettyVersion($package)', $routes); - $this->assertStringContainsString("'sdk-php'", $routes); - $this->assertStringContainsString("'durable-workflow/sdk'", $routes); - $this->assertStringContainsString("'durable-workflow/workflow'", $routes); - $this->assertStringContainsString("'durable-workflow/waterline'", $routes); - $this->assertStringContainsString("'apache-avro-php'", $routes); - $this->assertStringContainsString("'apache/avro'", $routes); - $this->assertStringContainsString("'assets' => [", $routes); - $this->assertStringContainsString("'waterline' => [", $routes); - $this->assertStringContainsString("public_path('vendor/waterline/mix-manifest.json')", $routes); - $this->assertStringContainsString("base_path('vendor/durable-workflow/waterline/public/mix-manifest.json')", $routes); - $this->assertStringContainsString("'current' =>", $routes); - - $this->assertStringContainsString('def fetch_php_artifact_probe()', $smoke); - $this->assertStringContainsString('"http://waterline:8081/polyglot/conformance/artifacts"', $smoke); - $this->assertStringContainsString('fetch_json_url(php_artifact_probe_url(), label="PHP artifact probe")', $smoke); - $this->assertStringContainsString('?history_limit=all', $smoke); - $this->assertStringContainsString('def php_artifact_versions(', $smoke); - $this->assertStringContainsString('def php_waterline_assets(', $smoke); - $this->assertStringContainsString('"sdk-php": php_sdk_worker_version', $smoke); - $this->assertStringContainsString('"workflow": php_versions.get("workflow")', $smoke); - $this->assertStringContainsString('"waterline": php_versions.get("waterline")', $smoke); - $this->assertStringContainsString('"assets": php_waterline_assets(php_probe)', $smoke); - $this->assertStringContainsString('"artifact_probe_error": php_probe_error', $smoke); - } - - public function test_polyglot_smoke_uses_supported_dw_connection_and_input_configuration(): void - { - $smoke = (string) file_get_contents($this->repoPath('polyglot/python_worker/scripts/polyglot_smoke.py')); - - $this->assertStringContainsString('env["DURABLE_WORKFLOW_SERVER_URL"] = SERVER_URL', $smoke); - $this->assertStringContainsString('env["DURABLE_WORKFLOW_NAMESPACE"] = NAMESPACE', $smoke); - $this->assertStringContainsString('env["DURABLE_WORKFLOW_AUTH_TOKEN"] = TOKEN', $smoke); - $this->assertStringContainsString('cmd = [DW, *args]', $smoke); - $this->assertStringNotContainsString('"--server",', $smoke); - $this->assertStringNotContainsString('"--namespace",', $smoke); - $this->assertStringNotContainsString('"--token",', $smoke); - - $this->assertStringContainsString('f"--input={json_arg(input_args)}"', $smoke); - $this->assertStringNotContainsString("\"--input\",\n json.dumps", $smoke); - } - - public function test_polyglot_php_signal_query_is_a_required_surface(): void - { - $smoke = (string) file_get_contents($this->repoPath('polyglot/python_worker/scripts/polyglot_smoke.py')); - - $this->assertStringContainsString('class DwCommandError(RuntimeError):', $smoke); - $this->assertStringContainsString( - '("php_signal_query", "polyglot.php.signal-query", PHP2PY_QUEUE, "php")', - $smoke, - ); - $this->assertStringNotContainsString( - 'def is_php_query_routing_blocker(error: DwCommandError) -> bool:', - $smoke, - ); - $this->assertStringNotContainsString( - 'php_query_blocked_payload', - $smoke, - ); - $this->assertStringNotContainsString( - '"status": "blocked"', - $smoke, - ); - } - - public function test_polyglot_rust_services_execute_the_published_crate(): void - { - $compose = Yaml::parseFile($this->repoPath('polyglot/docker-compose.yml')); - $services = $compose['services'] ?? []; - $cargo = (string) file_get_contents($this->repoPath('polyglot/rust_worker/Cargo.toml')); - $lock = (string) file_get_contents($this->repoPath('polyglot/rust_worker/Cargo.lock')); - $dockerfile = (string) file_get_contents($this->repoPath('polyglot/rust_worker/Dockerfile')); - $worker = (string) file_get_contents($this->repoPath('polyglot/rust_worker/src/main.rs')); - $codecProbe = (string) file_get_contents( - $this->repoPath('polyglot/rust_worker/src/bin/task_codec_rejection_probe.rs'), - ); - $tuple = json_decode( - (string) file_get_contents($this->repoPath('polyglot/qualified-artifact-tuple.json')), - true, - flags: JSON_THROW_ON_ERROR, - ); - - $this->assertArrayHasKey('rust-workflow-worker', $services); - $this->assertArrayHasKey('rust-activity-worker', $services); - $this->assertSame('workflow', $services['rust-workflow-worker']['environment']['POLYGLOT_RUST_MODE'] ?? null); - $this->assertSame('activity', $services['rust-activity-worker']['environment']['POLYGLOT_RUST_MODE'] ?? null); - $this->assertSame(1, preg_match('/durable-workflow = "=(2\.0\.0-(?:beta|rc)\.\d+)"/', $cargo, $matches)); - $this->assertSame($tuple['artifacts']['sdk-rust'] ?? null, $matches[1]); - $this->assertMatchesRegularExpression( - '/name = "durable-workflow"\nversion = "'.preg_quote($matches[1], '/').'"/', - $lock, + $this->assertSame( + '${DURABLE_WORKFLOW_PHP_SDK_VERSION:?run ../scripts/resolve-current-artifacts.sh before starting polyglot compose}', + $services['polyglot-workflow-worker']['build']['args']['DURABLE_WORKFLOW_PHP_SDK_VERSION'], ); - $this->assertStringContainsString('apache-avro = "=0.21.0"', $cargo); - $this->assertStringContainsString('COPY Cargo.toml Cargo.lock ./', $dockerfile); - $this->assertStringContainsString('cargo add "durable-workflow@=${DURABLE_WORKFLOW_RUST_SDK_VERSION}"', $dockerfile); - $this->assertStringContainsString('task_codec_rejection_probe', $dockerfile); - $this->assertStringNotContainsString('cargo update -p durable-workflow --precise', $dockerfile); - $this->assertStringNotContainsString('path =', $cargo); - $this->assertStringContainsString('polyglot.rust.greeter', $worker); - $this->assertStringContainsString('polyglot.rust-to-python.greeter', $worker); - $this->assertStringContainsString('polyglot.rust-to-php.greeter', $worker); - $this->assertStringContainsString('polyglot.php-to-rust.echo', $worker); - $this->assertStringContainsString('polyglot.php-to-rust.receipt', $worker); - $this->assertStringContainsString('polyglot.python-to-rust.echo', $worker); - $this->assertStringContainsString('verify_official_avro_runtime', $worker); - $this->assertStringContainsString('.run_once()', $codecProbe); - $this->assertStringContainsString('unsupported_payload_codec', $codecProbe); - $this->assertStringContainsString('handler_calls == 0', $codecProbe); - } - - public function test_waterline_config_exposes_v2_engine_source_and_namespace(): void - { - $config = (string) file_get_contents($this->repoPath('config/waterline.php')); - - $this->assertStringContainsString("'engine_source' => env('WATERLINE_ENGINE_SOURCE', 'auto')", $config); - $this->assertStringContainsString("'namespace' => env('WATERLINE_NAMESPACE')", $config); - $this->assertStringContainsString("'allow_unauthenticated' => env('WATERLINE_ALLOW_UNAUTHENTICATED', false)", $config); } - public function test_polyglot_waterline_provider_allows_conformance_opt_in_without_users(): void + public function test_featured_workflow_routes_php_to_python_and_rust(): void { - $provider = (string) file_get_contents($this->repoPath('app/Providers/WaterlineServiceProvider.php')); - - $this->assertStringContainsString("filter_var(config('waterline.allow_unauthenticated'), FILTER_VALIDATE_BOOL)", $provider); - $this->assertStringContainsString('Waterline::auth', $provider); - $this->assertStringContainsString('function ($user = null)', $provider); - } - - private function repoPath(string $path): string - { - return dirname(__DIR__, 2).'/'.$path; - } + $worker = (string) file_get_contents($this->path('polyglot/php_worker/worker.php')); + $compose = Yaml::parseFile($this->path('polyglot/docker-compose.yml')); + $environment = $compose['services']['polyglot-workflow-worker']['environment']; - /** - * @return list - */ - private function runPolyglotValidationWithFakeDocker( - string $cacheMode, - ?string $failedService = null, - ?array &$runOutput = null, - ?int &$runExitCode = null, - ): array { - $temporaryDirectory = sys_get_temp_dir().'/polyglot-validation-'.bin2hex(random_bytes(6)); - $dockerPath = $temporaryDirectory.'/docker'; - $logPath = $temporaryDirectory.'/docker.log'; - - mkdir($temporaryDirectory, 0700, true); - file_put_contents($dockerPath, <<<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -printf '%s\n' "$*" >> "$POLYGLOT_FAKE_DOCKER_LOG" - -if [[ "${1:-}" == "compose" && "${2:-}" == "ps" && "${3:-}" == "-q" && "${4:-}" == "server" ]]; then - printf 'stable-server-container\n' -elif [[ "${1:-}" == "compose" && "${2:-}" == "ps" && "${3:-}" == "--all" && "${4:-}" == "-q" ]]; then - printf '%s-container\n' "${5:-unknown}" -elif [[ "${1:-}" == "inspect" && "${3:-}" == "{{.State.Running}}" ]]; then - printf 'true\n' -elif [[ "${1:-}" == "inspect" && "${3:-}" == "{{if .State.Health}}{{.State.Health.Status}}{{end}}" ]]; then - printf 'healthy\n' -elif [[ "${1:-}" == "inspect" && "${3:-}" == "{{.State.Status}}" ]]; then - if [[ -n "${POLYGLOT_FAKE_FAILED_SERVICE:-}" && "${4:-}" == "${POLYGLOT_FAKE_FAILED_SERVICE}-container" ]]; then - printf 'exited\n' - else - printf 'running\n' - fi -elif [[ "${1:-}" == "wait" ]]; then - if [[ -z "${POLYGLOT_FAKE_FAILED_SERVICE:-}" || "${2:-}" != "${POLYGLOT_FAKE_FAILED_SERVICE}-container" ]]; then - sleep 5 - fi -elif [[ "$*" == *"task_codec_rejection_probe.php"* || "$*" == *"task_codec_rejection_probe.py"* || "$*" == *"task-codec-rejection-probe"* ]]; then - runtime=python - artifact=durable-workflow - version="$DURABLE_WORKFLOW_PYTHON_SDK_VERSION" - if [[ "$*" == *"task_codec_rejection_probe.php"* ]]; then - runtime=php - artifact=durable-workflow/sdk - version="$DURABLE_WORKFLOW_PHP_SDK_VERSION" - elif [[ "$*" == *"task-codec-rejection-probe"* ]]; then - runtime=rust - version="$DURABLE_WORKFLOW_RUST_SDK_VERSION" - fi - printf '{"schema":"durable-workflow.sample-app.task-codec-rejection-probe","version":1,"runtime":"%s","artifact":{"name":"%s","version":"%s"}}\n' \ - "$runtime" "$artifact" "$version" -elif [[ "$*" == *"--readiness-only"* && -n "${POLYGLOT_FAKE_FAILED_SERVICE:-}" ]]; then - sleep 5 -elif [[ "${1:-}" == "compose" && "${2:-}" == "logs" && "$*" == *"${POLYGLOT_FAKE_FAILED_SERVICE:-missing}"* ]]; then - printf "TypeError: Client.register_worker() missing 1 required keyword-only argument: 'capability_manifest'\n" -fi -BASH); - chmod($dockerPath, 0700); - - $environment = [ - 'PATH' => $temporaryDirectory.PATH_SEPARATOR.getenv('PATH'), - 'COMPOSE_PROJECT_NAME' => 'polyglot-script-test-'.$cacheMode, - 'POLYGLOT_BUILD_CACHE_MODE' => $cacheMode, - 'POLYGLOT_FAKE_DOCKER_LOG' => $logPath, - 'POLYGLOT_BUILD_TIMEOUT_SECONDS' => '5', - 'POLYGLOT_WARM_BUILD_TIMEOUT_SECONDS' => '5', - 'POLYGLOT_IMAGE_PULL_TIMEOUT_SECONDS' => '5', - 'POLYGLOT_TOPOLOGY_TIMEOUT_SECONDS' => '5', - 'POLYGLOT_REGISTRATION_STEP_TIMEOUT_SECONDS' => '5', - 'POLYGLOT_SMOKE_TIMEOUT_SECONDS' => '5', - 'POLYGLOT_CLEANUP_TIMEOUT_SECONDS' => '5', - 'POLYGLOT_FAKE_FAILED_SERVICE' => $failedService ?? '', - 'DURABLE_WORKFLOW_PHP_SDK_VERSION' => '2.0.0-rc.53', - 'DURABLE_WORKFLOW_PYTHON_SDK_VERSION' => '2.0.0-rc.40', - 'DURABLE_WORKFLOW_RUST_SDK_VERSION' => '2.0.0-rc.38', - ]; - $command = 'env'; - foreach ($environment as $name => $value) { - $command .= ' '.escapeshellarg($name.'='.$value); - } - $command .= ' bash '.escapeshellarg($this->repoPath('scripts/polyglot-validation.sh')).' 2>&1'; - - try { - exec($command, $output, $exitCode); - if ($failedService === null) { - $this->assertSame(0, $exitCode, implode("\n", $output)); - } - $runOutput = $output; - $runExitCode = $exitCode; - - $commands = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - $this->assertIsArray($commands); - - return array_values($commands); - } finally { - @unlink($dockerPath); - @unlink($logPath); - @rmdir($temporaryDirectory); - } + $this->assertStringContainsString("'polyglot.PolyglotWorkflow'", $worker); + $this->assertStringContainsString("'polyglot.php-to-python.tally'", $worker); + $this->assertStringContainsString("'polyglot.php-to-rust.receipt'", $worker); + $this->assertSame('polyglot-workflow', $environment['POLYGLOT_WORKFLOW_TASK_QUEUE']); + $this->assertSame('polyglot-php-to-python', $environment['POLYGLOT_PHP2PY_TASK_QUEUE']); + $this->assertSame('polyglot-to-rust', $environment['POLYGLOT_TO_RUST_TASK_QUEUE']); } - /** - * @return list - */ - private function runPolyglotDemoWithFakeDocker(): array + public function test_documented_polyglot_command_runs_one_service_mode_journey(): void { - $temporaryDirectory = sys_get_temp_dir().'/polyglot-demo-'.bin2hex(random_bytes(6)); - $dockerPath = $temporaryDirectory.'/docker'; - $logPath = $temporaryDirectory.'/docker.log'; - - mkdir($temporaryDirectory, 0700, true); - file_put_contents($dockerPath, <<<'BASH' -#!/usr/bin/env bash -set -euo pipefail + $script = (string) file_get_contents($this->path('scripts/polyglot.sh')); + $workflow = Yaml::parseFile($this->path('.github/workflows/polyglot-validation.yml')); + $steps = $workflow['jobs']['smoke']['steps']; -printf '%s\n' "$*" >> "$POLYGLOT_FAKE_DOCKER_LOG" -BASH); - chmod($dockerPath, 0700); + $this->assertStringContainsString('scripts/resolve-current-artifacts.sh', $script); + $this->assertStringContainsString('polyglot-workflow-worker', $script); + $this->assertStringContainsString('python-activity-worker', $script); + $this->assertStringContainsString('rust-activity-worker', $script); + $this->assertStringContainsString('pull --policy always bootstrap server', $script); + $this->assertStringContainsString('run --rm --no-deps demo', $script); + $this->assertStringNotContainsString('docs-page-release-audit', $script); - $environment = [ - 'PATH' => $temporaryDirectory.PATH_SEPARATOR.getenv('PATH'), - 'DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE' => $this->repoPath('tests/Fixtures/synthetic-artifact-tuple.json'), - 'POLYGLOT_COMPOSE_PROJECT_NAME' => 'sample-app-polyglot-command-test', - 'POLYGLOT_FAKE_DOCKER_LOG' => $logPath, - ]; - $command = 'env -i'; - foreach ($environment as $name => $value) { - $command .= ' '.escapeshellarg($name.'='.$value); - } - $command .= ' bash '.escapeshellarg($this->repoPath('scripts/polyglot.sh')).' 2>&1'; - - try { - exec($command, $output, $exitCode); - $this->assertSame(0, $exitCode, implode("\n", $output)); - - $commands = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - $this->assertIsArray($commands); - - return array_values($commands); - } finally { - @unlink($dockerPath); - @unlink($logPath); - @rmdir($temporaryDirectory); - } + $commands = array_column($steps, 'run'); + $this->assertContains('scripts/polyglot.sh', $commands); + $this->assertArrayNotHasKey('strategy', $workflow['jobs']['smoke']); } - /** - * @param list $commands - */ - private function firstCommandIndex(array $commands, string $fragment): int + /** @param array $environment */ + private function resolveArtifacts(array $environment = []): array { - foreach ($commands as $index => $command) { - if (str_contains($command, $fragment)) { - return $index; - } - } - - $this->fail(sprintf('Did not observe Docker command containing [%s].', $fragment)); - } - - /** - * @param array $env - * @return array - */ - private function resolveArtifactAssignments( - array $env = [], - bool $includeFixture = true, - string $arguments = '', - ): array { - $command = 'env -i PATH='.escapeshellarg((string) getenv('PATH')); - if ($includeFixture) { - $env = [ - 'DURABLE_WORKFLOW_ARTIFACT_TUPLE_FILE' => $this->repoPath('tests/Fixtures/synthetic-artifact-tuple.json'), - ...$env, - ]; - } - foreach ($env as $name => $value) { - $command .= ' '.escapeshellarg($name.'='.$value); - } - $command .= ' bash '.escapeshellarg($this->repoPath('scripts/resolve-current-artifacts.sh')); - if ($arguments !== '') { - $command .= ' '.escapeshellarg($arguments); - } - - $output = []; - $exitCode = 0; - exec($command, $output, $exitCode); - - $this->assertSame(0, $exitCode, implode("\n", $output)); + $process = new Process( + [$this->path('scripts/resolve-current-artifacts.sh')], + env: $environment, + ); + $process->mustRun(); $assignments = []; - foreach ($output as $line) { - $parts = explode('=', $line, 2); - $this->assertCount(2, $parts, sprintf('Expected NAME=value assignment, got %s', $line)); - $assignments[$parts[0]] = $parts[1]; + foreach (preg_split('/\R/', trim($process->getOutput())) ?: [] as $line) { + [$name, $value] = explode('=', $line, 2); + $assignments[$name] = $value; } return $assignments; } - /** - * @param array $compose - * @return array - */ - private function serverEnvironment(array $compose): array + private function path(string $path): string { - $serverEnvironment = $compose['x-server-env'] ?? null; - - if (! is_array($serverEnvironment)) { - $serverEnvironment = $compose['services']['server']['environment'] ?? []; - } - - return is_array($serverEnvironment) ? $serverEnvironment : []; - } - - private function requiredResolvedEnv(string $name): string - { - return sprintf('${%s:?run ../scripts/resolve-current-artifacts.sh before starting polyglot compose}', $name); - } - - /** - * @return array - */ - private function directoryFileHashes(string $directory): array - { - $hashes = []; - $files = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), - ); - - foreach ($files as $file) { - if (! $file->isFile()) { - continue; - } - - $relativePath = substr($file->getPathname(), strlen($directory) + 1); - $hashes[$relativePath] = hash_file('sha256', $file->getPathname()); - } - - ksort($hashes); - - return $hashes; - } - - private function assertOrdered(string $haystack, string ...$needles): void - { - $previous = -1; - - foreach ($needles as $needle) { - $position = strpos($haystack, $needle); - - $this->assertNotFalse($position, sprintf('Missing expected script fragment [%s].', $needle)); - $this->assertGreaterThan($previous, $position, sprintf( - 'Expected script fragment [%s] to appear after the previous fragment.', - $needle, - )); - - $previous = $position; - } - } - - private function assertLaravelAppKeySupportsAes256(mixed $key, string $label): void - { - $this->assertIsString($key, $label); - $this->assertStringStartsWith('base64:', $key, $label); - - $decoded = base64_decode(substr($key, strlen('base64:')), true); - - $this->assertIsString($decoded, $label); - $this->assertSame( - 32, - strlen($decoded), - sprintf('%s must decode to 32 bytes for Laravel AES-256-CBC middleware.', $label), - ); + return dirname(__DIR__, 2).'/'.$path; } } diff --git a/tests/Unit/ServiceModeOnboardingContractTest.php b/tests/Unit/ServiceModeOnboardingContractTest.php deleted file mode 100644 index b8812f0..0000000 --- a/tests/Unit/ServiceModeOnboardingContractTest.php +++ /dev/null @@ -1,394 +0,0 @@ -repoPath('polyglot/service-mode.yml')); - $services = $compose['services'] ?? []; - $developmentImage = '${SAMPLE_APP_DEVCONTAINER_IMAGE:-ghcr.io/durable-workflow/sample-app-devcontainer:main}'; - - foreach ($services as $service) { - $this->assertArrayNotHasKey('build', $service); - } - foreach (['worker-app-setup', 'observer-app-setup', 'waterline-migrate', 'php-worker', 'waterline', 'waterline-embedded', 'journey', 'browser-smoke'] as $serviceName) { - $this->assertSame($developmentImage, $services[$serviceName]['image'] ?? null); - } - foreach (['python-setup', 'python-worker'] as $serviceName) { - $this->assertSame('python:3.12-slim', $services[$serviceName]['image'] ?? null); - } - foreach (['bootstrap', 'server'] as $serviceName) { - $this->assertSame( - '${DURABLE_SERVER_IMAGE:?resolve the current artifact tuple first}', - $services[$serviceName]['image'] ?? null, - ); - } - - $this->assertSame( - '${DURABLE_WORKFLOW_PHP_SDK_VERSION:?resolve artifacts first}', - $services['worker-app-setup']['environment']['DURABLE_WORKFLOW_PHP_SDK_VERSION'] ?? null, - ); - $this->assertSame( - '${DURABLE_WORKFLOW_PYTHON_SDK_VERSION:?resolve artifacts first}', - $services['python-setup']['environment']['DURABLE_WORKFLOW_PYTHON_SDK_VERSION'] ?? null, - ); - $this->assertSame( - ['/bin/sh', '/source/scripts/setup-service-mode-python.sh'], - $services['python-setup']['entrypoint'] ?? null, - ); - $this->assertSame( - WelcomeWorkflow::PHP_TASK_QUEUE, - $services['php-worker']['environment']['DURABLE_WORKFLOW_TASK_QUEUE'] ?? null, - ); - $this->assertSame( - WelcomeWorkflow::PYTHON_TASK_QUEUE, - $services['python-worker']['environment']['DURABLE_WORKFLOW_TASK_QUEUE'] ?? null, - ); - foreach (['php-worker', 'journey'] as $serviceName) { - $environment = $services[$serviceName]['environment'] ?? []; - $this->assertSame('http://server:8080', $environment['DURABLE_WORKFLOW_RUNTIME_URL'] ?? null); - $this->assertArrayNotHasKey('DURABLE_WORKFLOW_ENDPOINT', $environment); - $this->assertArrayNotHasKey('DURABLE_WORKFLOW_TOKEN', $environment); - $this->assertArrayNotHasKey('DURABLE_WORKFLOW_CLIENT_TOKEN', $environment); - $this->assertArrayNotHasKey('DURABLE_WORKFLOW_WORKER_TOKEN', $environment); - $this->assertSame('test-token', $environment['DURABLE_WORKFLOW_PROCESS_TOKEN'] ?? null); - } - $this->assertSame( - 'worker', - $services['php-worker']['environment']['DURABLE_WORKFLOW_PROCESS_ROLE'] ?? null, - ); - $this->assertSame( - 'client', - $services['journey']['environment']['DURABLE_WORKFLOW_PROCESS_ROLE'] ?? null, - ); - foreach (['php-worker', 'waterline', 'waterline-embedded', 'journey'] as $serviceName) { - $this->assertSame('file', $services[$serviceName]['environment']['SESSION_DRIVER'] ?? null); - } - $this->assertSame('service', $services['waterline']['environment']['WATERLINE_BACKEND'] ?? null); - $this->assertSame('embedded', $services['waterline-embedded']['environment']['WATERLINE_BACKEND'] ?? null); - $this->assertSame('v2', $services['waterline-embedded']['environment']['WATERLINE_ENGINE_SOURCE'] ?? null); - $this->assertSame( - 'http://server:8080', - $services['waterline']['environment']['WATERLINE_SERVER_ENDPOINT'] ?? null, - ); - $this->assertContains('service-observer-app:/observer:ro', $services['browser-smoke']['volumes'] ?? []); - } - - public function test_waterline_migrations_gate_observer_readiness(): void - { - $compose = Yaml::parseFile($this->repoPath('polyglot/service-mode.yml')); - $services = $compose['services'] ?? []; - $migration = $services['waterline-migrate'] ?? []; - - $this->assertSame( - [ - 'php', - 'artisan', - 'migrate', - '--path=vendor/durable-workflow/waterline/database/migrations', - '--force', - '--no-interaction', - ], - $migration['entrypoint'] ?? null, - ); - $this->assertContains('service-observer-app:/var/www/html', $migration['volumes'] ?? []); - $this->assertSame( - 'service_healthy', - $migration['depends_on']['mysql']['condition'] ?? null, - ); - $this->assertSame( - 'service_completed_successfully', - $migration['depends_on']['observer-app-setup']['condition'] ?? null, - ); - $this->assertSame( - 'service_completed_successfully', - $services['waterline']['depends_on']['waterline-migrate']['condition'] ?? null, - ); - $this->assertSame( - 'service_completed_successfully', - $services['waterline-embedded']['depends_on']['waterline-migrate']['condition'] ?? null, - ); - - $script = (string) file_get_contents($this->repoPath('scripts/service-mode.sh')); - $setup = strpos($script, 'run_phase "application and language setup"'); - $database = strpos($script, 'run_phase "database readiness"'); - $migrations = strpos($script, 'run_phase "Waterline database migrations"'); - $readiness = strpos($script, 'run_phase "service startup and readiness"'); - - $this->assertIsInt($setup); - $this->assertIsInt($database); - $this->assertIsInt($migrations); - $this->assertIsInt($readiness); - $this->assertTrue($setup < $database && $database < $migrations && $migrations < $readiness); - } - - public function test_waterline_migration_failure_stops_in_the_migration_phase(): void - { - $temporaryDirectory = sys_get_temp_dir().'/service-mode-migration-'.bin2hex(random_bytes(6)); - $dockerPath = $temporaryDirectory.'/docker'; - $dockerLog = $temporaryDirectory.'/docker.log'; - - $this->assertTrue(mkdir($temporaryDirectory, 0700)); - $this->assertNotFalse(file_put_contents($dockerPath, <<<'BASH' -#!/usr/bin/env bash -set -euo pipefail - -printf '%s\n' "$*" >> "$SERVICE_MODE_FAKE_DOCKER_LOG" - -has_exit_code_from=false -for argument in "$@"; do - if [[ "$argument" == "--exit-code-from" ]]; then - has_exit_code_from=true - fi -done - -if [[ "$has_exit_code_from" == true && "${*: -1}" == "waterline-migrate" ]]; then - exit 37 -fi -BASH)); - $this->assertTrue(chmod($dockerPath, 0700)); - - try { - $process = new Process( - ['bash', $this->repoPath('scripts/service-mode.sh')], - env: [ - 'PATH' => $temporaryDirectory.PATH_SEPARATOR.getenv('PATH'), - 'COMPOSE_PROJECT_NAME' => 'service-mode-migration-test', - 'DURABLE_WORKFLOW_ARTIFACT_SOURCE' => 'pinned', - 'SERVICE_MODE_EVIDENCE_PATH' => $temporaryDirectory.'/evidence.json', - 'SERVICE_MODE_FAKE_DOCKER_LOG' => $dockerLog, - ], - ); - $process->run(); - - $output = $process->getOutput().$process->getErrorOutput(); - $this->assertSame(37, $process->getExitCode(), $output); - $this->assertStringContainsString( - 'Service mode failed during phase: Waterline database migrations.', - $process->getErrorOutput(), - ); - $this->assertStringNotContainsString('==> service startup and readiness', $output); - - $commands = (string) file_get_contents($dockerLog); - $this->assertStringContainsString( - 'up --no-build --force-recreate --no-deps --exit-code-from waterline-migrate waterline-migrate', - $commands, - ); - } finally { - @unlink($dockerPath); - @unlink($dockerLog); - @unlink($temporaryDirectory.'/evidence.json'); - @rmdir($temporaryDirectory); - } - } - - public function test_entrypoint_resolves_qualified_artifacts_and_keeps_builds_disabled(): void - { - $script = (string) file_get_contents($this->repoPath('scripts/service-mode.sh')); - $workflow = Yaml::parseFile($this->repoPath('.github/workflows/smoke.yml')); - - $this->assertStringContainsString('scripts/resolve-current-artifacts.sh', $script); - $this->assertStringContainsString( - 'DURABLE_WORKFLOW_ARTIFACT_SOURCE:-pinned', - $script, - ); - $this->assertSame( - 'pinned', - $workflow['jobs']['service-mode']['env']['DURABLE_WORKFLOW_ARTIFACT_SOURCE'] ?? null, - ); - $this->assertStringContainsString('--no-build', $script); - $this->assertStringNotContainsString('docker compose build', $script); - $this->assertStringContainsString('sample-app-service-mode', $script); - $this->assertStringContainsString('service-mode-evidence.json', $script); - $this->assertStringContainsString('waterline-mount-readiness.mjs', $script); - $this->assertStringContainsString('run-service-mode-dialog-visual.mjs', $script); - $this->assertStringContainsString( - 'vendor/durable-workflow/waterline/scripts/ci/run-detail-visual.mjs', - $script, - ); - $this->assertStringContainsString('--base-url http://waterline-embedded:8082', $script); - $this->assertStringContainsString('--service-base-url http://waterline:8081', $script); - $this->assertStringContainsString('Composer\\InstalledVersions::getPrettyVersion', $script); - $this->assertStringContainsString('SERVICE_MODE_SAMPLE_APP_REVISION', $script); - $this->assertStringContainsString('SERVICE_MODE_MOUNT_EVIDENCE', $script); - $this->assertStringContainsString('SERVICE_MODE_DIALOG_EVIDENCE', $script); - $this->assertStringContainsString('SERVICE_MODE_RUN_DETAIL_EVIDENCE', $script); - - $retentionSteps = array_values(array_filter( - $workflow['jobs']['service-mode']['steps'] ?? [], - static fn (array $step): bool => ($step['uses'] ?? null) - === 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a', - )); - $this->assertCount(1, $retentionSteps); - $evidencePaths = $retentionSteps[0]['with']['path'] ?? ''; - $this->assertStringContainsString('service-mode-*-waterline-dialogs/**', $evidencePaths); - $this->assertStringContainsString('service-mode-*-waterline-run-detail/**', $evidencePaths); - } - - public function test_observer_bootstrap_publishes_assets_from_the_installed_waterline_package(): void - { - $script = (string) file_get_contents($this->repoPath('scripts/setup-service-mode-app.sh')); - - $this->assertStringContainsString( - '${DURABLE_WORKFLOW_PHP_SDK_VERSION:?Resolve the current PHP SDK version first}', - $script, - ); - $this->assertStringContainsString( - '"durable-workflow/sdk:${DURABLE_WORKFLOW_PHP_SDK_VERSION}"', - $script, - ); - $this->assertStringNotContainsString('durable-workflow/sdk:^2.0@RC', $script); - $this->assertStringContainsString('if [[ "$role" == observer ]]', $script); - $this->assertStringContainsString('php artisan waterline:publish --no-interaction', $script); - $this->assertLessThan( - strpos($script, 'php artisan waterline:publish --no-interaction'), - strpos($script, 'composer dump-autoload --no-dev --optimize --no-interaction'), - ); - } - - public function test_qualified_php_artifacts_match_the_bootable_laravel_graph(): void - { - $tuple = json_decode( - (string) file_get_contents($this->repoPath('polyglot/qualified-artifact-tuple.json')), - true, - flags: JSON_THROW_ON_ERROR, - ); - $composer = json_decode( - (string) file_get_contents($this->repoPath('composer.json')), - true, - flags: JSON_THROW_ON_ERROR, - ); - - foreach ([ - 'sdk-php' => 'durable-workflow/sdk', - 'workflow' => 'durable-workflow/workflow', - 'waterline' => 'durable-workflow/waterline', - ] as $artifact => $package) { - $this->assertSame( - $composer['require'][$package] ?? null, - $tuple['artifacts'][$artifact] ?? null, - ); - } - } - - public function test_composer_artifact_validation_records_the_exact_installable_graph(): void - { - $tuple = json_decode( - (string) file_get_contents($this->repoPath('polyglot/qualified-artifact-tuple.json')), - true, - flags: JSON_THROW_ON_ERROR, - ); - $process = new Process([ - PHP_BINARY, - $this->repoPath('scripts/ci/validate-composer-artifact-graph.php'), - ]); - $process->mustRun(); - - $evidence = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); - $this->assertSame( - 'durable-workflow.sample-app.composer-artifact-graph/v1', - $evidence['schema'] ?? null, - ); - $this->assertSame( - [ - 'server' => $tuple['artifacts']['server'] ?? null, - 'sdk-php' => $tuple['artifacts']['sdk-php'] ?? null, - 'workflow' => $tuple['artifacts']['workflow'] ?? null, - 'waterline' => $tuple['artifacts']['waterline'] ?? null, - ], - $evidence['artifacts'] ?? null, - ); - $this->assertNull($evidence['waterline-requires-sdk-php'] ?? null); - } - - public function test_composer_artifact_validation_rejects_a_stale_root_sdk_pin(): void - { - $composer = json_decode( - (string) file_get_contents($this->repoPath('composer.json')), - true, - flags: JSON_THROW_ON_ERROR, - ); - $tuple = json_decode( - (string) file_get_contents($this->repoPath('polyglot/qualified-artifact-tuple.json')), - true, - flags: JSON_THROW_ON_ERROR, - ); - $qualifiedSdk = $tuple['artifacts']['sdk-php'] ?? null; - $staleSdk = '2.0.0-rc.1'; - $this->assertIsString($qualifiedSdk); - $this->assertNotSame($qualifiedSdk, $staleSdk); - $composer['require']['durable-workflow/sdk'] = $staleSdk; - $temporaryComposer = tempnam(sys_get_temp_dir(), 'sample-app-composer-'); - $this->assertNotFalse($temporaryComposer); - $this->assertNotFalse(file_put_contents( - $temporaryComposer, - json_encode($composer, JSON_THROW_ON_ERROR), - )); - - try { - $process = new Process([ - PHP_BINARY, - $this->repoPath('scripts/ci/validate-composer-artifact-graph.php'), - $temporaryComposer, - $this->repoPath('composer.lock'), - $this->repoPath('polyglot/qualified-artifact-tuple.json'), - ]); - $process->run(); - - $this->assertSame(1, $process->getExitCode()); - $this->assertStringContainsString( - "durable-workflow/sdk root requirement \"{$staleSdk}\" does not match qualified sdk-php {$qualifiedSdk}", - $process->getErrorOutput(), - ); - } finally { - @unlink($temporaryComposer); - } - } - - public function test_python_setup_is_repeatable_and_fails_closed(): void - { - $scriptPath = $this->repoPath('scripts/setup-service-mode-python.sh'); - $script = (string) file_get_contents($scriptPath); - - $this->assertTrue(is_executable($scriptPath)); - $this->assertStringContainsString('set -eu', $script); - $this->assertStringContainsString('python_version=', $script); - $this->assertStringContainsString('if [ "$installed" = "$python_version" ]', $script); - $this->assertStringContainsString('if [ "$installed" != "$python_version" ]', $script); - } - - public function test_entrypoint_verifies_the_exact_waterline_run_before_reporting_success(): void - { - $script = (string) file_get_contents($this->repoPath('scripts/service-mode.sh')); - - $this->assertStringContainsString('waterline_page_path=', $script); - $this->assertStringContainsString('waterline_api_path=', $script); - $this->assertStringContainsString('exec -T waterline curl --fail', $script); - $this->assertStringContainsString( - 'selection.instance_id !== journey.workflow_id', - $script, - ); - $this->assertStringContainsString( - 'selection.selected_run_id !== journey.run_id', - $script, - ); - $this->assertLessThan( - strpos($script, 'Browser proof:'), - strpos($script, 'selection.selected_run_id !== journey.run_id'), - ); - } - - private function repoPath(string $path): string - { - return dirname(__DIR__, 2).'/'.$path; - } -} From 836453a2ae4ab3938a11e17476be920448eb7843 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 1 Sep 2026 11:30:43 +0000 Subject: [PATCH 2/3] Guard devcontainer publication on main --- .github/workflows/ci.yml | 4 +++- .github/workflows/devcontainer-image.yml | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d33fe74..3a64a0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,4 +197,6 @@ jobs: - name: Require microservice tests run: test "${{ needs.microservice-test.result }}" = success - name: Require action policy - run: test "${{ needs.action-policy.result }}" = success + env: + ACTION_POLICY_RESULT: ${{ needs.action-policy.result }} + run: test "$ACTION_POLICY_RESULT" = success diff --git a/.github/workflows/devcontainer-image.yml b/.github/workflows/devcontainer-image.yml index e7209af..0997859 100644 --- a/.github/workflows/devcontainer-image.yml +++ b/.github/workflows/devcontainer-image.yml @@ -38,6 +38,7 @@ env: jobs: build: name: Build ${{ matrix.platform }} + if: github.ref == 'refs/heads/main' runs-on: ${{ matrix.runner }} timeout-minutes: 20 permissions: @@ -89,6 +90,7 @@ jobs: assemble: name: Assemble multi-platform image + if: github.ref == 'refs/heads/main' needs: [build] runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -128,6 +130,7 @@ jobs: qualify: name: Qualify ${{ matrix.platform }} + if: github.ref == 'refs/heads/main' needs: [assemble] runs-on: ${{ matrix.runner }} timeout-minutes: 20 @@ -161,6 +164,7 @@ jobs: promote: name: Publish main channel + if: github.ref == 'refs/heads/main' needs: [assemble, qualify] runs-on: ubuntu-24.04 timeout-minutes: 5 From 68d01b379fb52d7d26005474636851a6942784a7 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 1 Sep 2026 11:35:14 +0000 Subject: [PATCH 3/3] Make polyglot cleanup self-contained --- .github/workflows/polyglot-validation.yml | 6 +----- README.md | 6 +----- scripts/polyglot.sh | 15 ++++++++++++++- tests/Unit/PolyglotComposeContractTest.php | 2 ++ 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/polyglot-validation.yml b/.github/workflows/polyglot-validation.yml index f2f735b..56d28f3 100644 --- a/.github/workflows/polyglot-validation.yml +++ b/.github/workflows/polyglot-validation.yml @@ -32,8 +32,4 @@ jobs: logs --no-color --timestamps - name: Tear down stack if: always() - run: >- - docker compose --project-directory polyglot - -f polyglot/docker-compose.yml - -p "$POLYGLOT_COMPOSE_PROJECT_NAME" - down --volumes --remove-orphans + run: scripts/polyglot.sh down diff --git a/README.md b/README.md index 6cdc918..e6efc6c 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,7 @@ The isolated `sample-app-polyglot-demo` Compose project remains available for inspection. Stop it with: ```bash -docker compose \ - --project-directory polyglot \ - -f polyglot/docker-compose.yml \ - -p sample-app-polyglot-demo \ - down --volumes --remove-orphans +scripts/polyglot.sh down ``` See [polyglot/README.md](polyglot/README.md) for the worker layout and the diff --git a/scripts/polyglot.sh b/scripts/polyglot.sh index 2c22dfd..265f02d 100755 --- a/scripts/polyglot.sh +++ b/scripts/polyglot.sh @@ -26,6 +26,19 @@ export COMPOSE_PROJECT_NAME="$project_name" compose=(docker compose --project-directory "$repo_root/polyglot" -f "$compose_file") +case "${1:-}" in + '') ;; + down) + printf '==> PolyglotWorkflow: removing Compose project %s\n' "$COMPOSE_PROJECT_NAME" + "${compose[@]}" down --volumes --remove-orphans + exit 0 + ;; + *) + printf 'Usage: %s [down]\n' "${0##*/}" >&2 + exit 2 + ;; +esac + printf '==> PolyglotWorkflow: building PHP %s, Python %s, and Rust %s workers\n' \ "$DURABLE_WORKFLOW_PHP_SDK_VERSION" \ "$DURABLE_WORKFLOW_PYTHON_SDK_VERSION" \ @@ -56,5 +69,5 @@ printf '%s\n' '==> PolyglotWorkflow: running one PHP -> Python -> Rust workflow "${compose[@]}" run --rm --no-deps demo printf 'PolyglotWorkflow stack remains available in Compose project %s.\n' "$COMPOSE_PROJECT_NAME" -printf 'Stop it with: POLYGLOT_COMPOSE_PROJECT_NAME=%s docker compose -f polyglot/docker-compose.yml down --volumes --remove-orphans\n' \ +printf 'Stop it with: POLYGLOT_COMPOSE_PROJECT_NAME=%s scripts/polyglot.sh down\n' \ "$COMPOSE_PROJECT_NAME" diff --git a/tests/Unit/PolyglotComposeContractTest.php b/tests/Unit/PolyglotComposeContractTest.php index 93eb4ec..74ae738 100644 --- a/tests/Unit/PolyglotComposeContractTest.php +++ b/tests/Unit/PolyglotComposeContractTest.php @@ -120,11 +120,13 @@ public function test_documented_polyglot_command_runs_one_service_mode_journey() $this->assertStringContainsString('python-activity-worker', $script); $this->assertStringContainsString('rust-activity-worker', $script); $this->assertStringContainsString('pull --policy always bootstrap server', $script); + $this->assertStringContainsString('down --volumes --remove-orphans', $script); $this->assertStringContainsString('run --rm --no-deps demo', $script); $this->assertStringNotContainsString('docs-page-release-audit', $script); $commands = array_column($steps, 'run'); $this->assertContains('scripts/polyglot.sh', $commands); + $this->assertContains('scripts/polyglot.sh down', $commands); $this->assertArrayNotHasKey('strategy', $workflow['jobs']['smoke']); }