From 1e3f893110fa81e46437e5bf2ff21fb96d1a07f3 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 1 Sep 2026 09:52:44 +0000 Subject: [PATCH] Streamline PHP SDK first-visit experience --- .github/workflows/ci.yml | 321 +--- .github/workflows/docs.yml | 48 +- .../workflows/external-link-diagnostics.yml | 63 - .../framework-bridges-published-smoke.yml | 1629 ----------------- README.md | 714 +------- composer.json | 6 +- docs/portal/frameworks/laravel.md | 7 +- docs/sdk-reference.md | 685 +++++++ package.json | 7 +- scripts/check-docs-analytics-browser.mjs | 816 --------- scripts/check-docs-examples-contract.json | 6 +- scripts/ci/classify-ci-qualification.py | 306 ---- .../ci/fixtures/docs-links/broken-internal.md | 3 - .../docs-links/external-dns-failure.md | 3 - .../ci/fixtures/docs-links/malformed-url.md | 3 - scripts/ci/test-ci-qualification.py | 454 ----- scripts/ci/test-workflow-trust-boundaries.py | 810 -------- scripts/qualify-docs-analytics-deployment.mjs | 507 ----- ...qualify-docs-analytics-deployment.test.mjs | 564 ------ ...qualify-quickstart-contract-deployment.mjs | 405 ---- ...fy-quickstart-contract-deployment.test.mjs | 405 ---- ...ualify-quickstart-release-availability.mjs | 85 - 22 files changed, 787 insertions(+), 7060 deletions(-) delete mode 100644 .github/workflows/external-link-diagnostics.yml delete mode 100644 .github/workflows/framework-bridges-published-smoke.yml create mode 100644 docs/sdk-reference.md delete mode 100644 scripts/check-docs-analytics-browser.mjs delete mode 100644 scripts/ci/classify-ci-qualification.py delete mode 100644 scripts/ci/fixtures/docs-links/broken-internal.md delete mode 100644 scripts/ci/fixtures/docs-links/external-dns-failure.md delete mode 100644 scripts/ci/fixtures/docs-links/malformed-url.md delete mode 100644 scripts/ci/test-ci-qualification.py delete mode 100644 scripts/ci/test-workflow-trust-boundaries.py delete mode 100644 scripts/qualify-docs-analytics-deployment.mjs delete mode 100644 scripts/qualify-docs-analytics-deployment.test.mjs delete mode 100644 scripts/qualify-quickstart-contract-deployment.mjs delete mode 100644 scripts/qualify-quickstart-contract-deployment.test.mjs delete mode 100644 scripts/qualify-quickstart-release-availability.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eccf705..c76b16d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,6 @@ concurrency: group: ci-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} -env: - # Pull requests on an explicitly identified alternate CI server use focused - # candidate admission. Removing this opt-in fails safe to the complete gate. - ALTERNATE_CI_FOCUSED_ADMISSION: 'true' - jobs: action-policy: name: Central action policy preflight @@ -49,203 +44,8 @@ jobs: --target sdk-php --workflow-directory .github/workflows - qualification-route: - name: Resolve qualification route - runs-on: ubuntu-latest - timeout-minutes: 3 - outputs: - route: ${{ steps.classify.outputs.route }} - route_reason: ${{ steps.classify.outputs.route_reason }} - categories: ${{ steps.classify.outputs.categories }} - changed_path_reason: ${{ steps.classify.outputs.changed_path_reason }} - changed_count: ${{ steps.classify.outputs.changed_count }} - started_at: ${{ steps.clock.outputs.started_at }} - steps: - - name: Record candidate start - id: clock - run: echo "started_at=$(date +%s)" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - fetch-depth: 0 - persist-credentials: false - - name: Resolve portable qualification route - id: classify - env: - QUALIFICATION_BASE_SHA: ${{ github.event.pull_request.base.sha }} - QUALIFICATION_EVENT_NAME: ${{ github.event_name }} - QUALIFICATION_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - QUALIFICATION_SERVER_URL: ${{ github.server_url }} - run: | - python scripts/ci/classify-ci-qualification.py \ - --root . \ - --server-url "$QUALIFICATION_SERVER_URL" \ - --event-name "$QUALIFICATION_EVENT_NAME" \ - --alternate-ci-focused-admission "${ALTERNATE_CI_FOCUSED_ADMISSION:-}" \ - --base-ref "$QUALIFICATION_BASE_SHA" \ - --head-ref "$QUALIFICATION_HEAD_SHA" \ - --github-output "$GITHUB_OUTPUT" - - name: Prove qualification routing and privileged workflow contracts - run: | - python scripts/ci/test-ci-qualification.py - python scripts/ci/test-workflow-trust-boundaries.py - - name: Prove an external outage cannot fail offline qualification - uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 - with: - args: >- - --no-progress - --offline - --include-fragments - --root-dir . - scripts/ci/fixtures/docs-links/external-dns-failure.md - - name: Exercise broken internal link fixture - id: broken-internal-link - continue-on-error: true - uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 - with: - args: >- - --no-progress - --offline - --include-fragments - --root-dir . - scripts/ci/fixtures/docs-links/broken-internal.md - - name: Require broken internal links to fail qualification - env: - FIXTURE_OUTCOME: ${{ steps.broken-internal-link.outcome }} - run: test "$FIXTURE_OUTCOME" = failure - - name: Exercise malformed URL fixture - id: malformed-url - continue-on-error: true - uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 - with: - args: >- - --no-progress - --offline - --include-fragments - --root-dir . - scripts/ci/fixtures/docs-links/malformed-url.md - - name: Require malformed URLs to fail qualification - env: - FIXTURE_OUTCOME: ${{ steps.malformed-url.outcome }} - run: test "$FIXTURE_OUTCOME" = failure - - focused-candidate: - name: Focused candidate evidence - needs: qualification-route - if: ${{ needs.qualification-route.outputs.route == 'focused' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - fetch-depth: 0 - persist-credentials: false - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 - with: - php-version: '8.3' - tools: composer:v2 - coverage: none - - name: Validate package structure - run: composer validate --strict - - name: Scan the candidate public boundary - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - if [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]] \ - && [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] \ - && git cat-file -e "${BASE_SHA}^{commit}" \ - && git cat-file -e "${HEAD_SHA}^{commit}"; then - range="${BASE_SHA}..${HEAD_SHA}" - elif [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] \ - && git rev-parse --verify --quiet "${HEAD_SHA}^" >/dev/null; then - range="${HEAD_SHA}^..${HEAD_SHA}" - else - echo "Unable to establish a safe public-boundary revision range." >&2 - exit 1 - fi - PUBLIC_BOUNDARY_GIT_RANGE="$range" scripts/check-public-boundary.sh - - name: Install change-relevant Composer dependencies - if: >- - ${{ - contains(needs.qualification-route.outputs.categories, 'docs') - || contains(needs.qualification-route.outputs.categories, 'runtime') - }} - run: composer install --no-interaction --prefer-dist --no-progress - - name: Exercise one representative PHP runtime - if: ${{ contains(needs.qualification-route.outputs.categories, 'runtime') }} - run: | - find src tests examples -name '*.php' -print0 | xargs -0 -n1 php -l - composer test - composer benchmark-avro-value - composer check-boundary - - name: Require relevant regression evidence - if: ${{ contains(needs.qualification-route.outputs.categories, 'runtime') }} - env: - CORPUS_BASE_REF: ${{ github.event.pull_request.base.sha }} - run: | - arguments=() - if [[ "$CORPUS_BASE_REF" =~ ^[0-9a-f]{40}$ ]] && [[ ! "$CORPUS_BASE_REF" =~ ^0+$ ]]; then - arguments+=(--base-ref "$CORPUS_BASE_REF") - fi - python scripts/ci/validate-regression-corpus.py "${arguments[@]}" - python scripts/ci/test-regression-corpus-policy.py - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - if: >- - ${{ - contains(needs.qualification-route.outputs.categories, 'docs') - || contains(needs.qualification-route.outputs.categories, 'docs-browser') - }} - with: - node-version: '24' - cache: npm - - name: Install documentation dependencies - if: >- - ${{ - contains(needs.qualification-route.outputs.categories, 'docs') - || contains(needs.qualification-route.outputs.categories, 'docs-browser') - }} - run: npm ci - - name: Exercise focused documentation contracts - if: ${{ contains(needs.qualification-route.outputs.categories, 'docs') }} - run: | - npm run check:docs-examples - npm run test:quickstart-contract-deployment - composer docs - npm run check:docs - php scripts/check-docs-analytics.php build/site - - name: Check repository-owned documentation links offline - if: ${{ contains(needs.qualification-route.outputs.categories, 'docs') }} - uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 - with: - args: >- - --no-progress - --offline - --include-fragments - --root-dir . - README.md docs/*.md - - name: Check rendered documentation links - if: ${{ contains(needs.qualification-route.outputs.categories, 'docs') }} - uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 - with: - args: >- - --no-progress - --offline - --root-dir build/site - build/site - - name: Install responsive documentation browser dependencies - if: ${{ contains(needs.qualification-route.outputs.categories, 'docs-browser') }} - run: npx playwright install chromium --with-deps - - name: Exercise responsive documentation browser evidence - if: ${{ contains(needs.qualification-route.outputs.categories, 'docs-browser') }} - run: | - npm run test:docs-browser-failures - npm run test:docs-analytics-deployment - npm run check:docs-analytics-browser -- build/site - npm run check:docs-browser regression-corpus: name: Regression corpus - needs: qualification-route - if: ${{ needs.qualification-route.outputs.route == 'complete' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -273,8 +73,6 @@ jobs: test: name: PHP ${{ matrix.php }} - needs: qualification-route - if: ${{ needs.qualification-route.outputs.route == 'complete' }} runs-on: ubuntu-latest timeout-minutes: 15 strategy: @@ -300,8 +98,6 @@ jobs: framework-compat: name: ${{ matrix.framework }} ${{ matrix.version }} - needs: qualification-route - if: ${{ needs.qualification-route.outputs.route == 'complete' }} runs-on: ubuntu-latest timeout-minutes: 10 strategy: @@ -506,8 +302,6 @@ jobs: laravel-transition-compat: name: Laravel ${{ matrix.source }} to service mode - needs: qualification-route - if: ${{ needs.qualification-route.outputs.route == 'complete' }} runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -540,8 +334,6 @@ jobs: run: scripts/ci/laravel-service-transition-smoke.sh analyse: - needs: qualification-route - if: ${{ needs.qualification-route.outputs.route == 'complete' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -555,8 +347,6 @@ jobs: - run: composer analyse docs: - needs: qualification-route - if: ${{ needs.qualification-route.outputs.route == 'complete' }} runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -573,7 +363,6 @@ jobs: - run: composer install --no-interaction --prefer-dist - run: npm ci - run: npm run check:docs-examples - - run: npm run test:quickstart-contract-deployment - run: composer docs - run: npm run check:docs - run: php scripts/check-docs-analytics.php build/site @@ -586,27 +375,7 @@ jobs: --include-fragments --root-dir . README.md docs/*.md - - name: Check rendered documentation links - uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 - with: - args: >- - --no-progress - --offline - --root-dir build/site - build/site - - run: npx playwright install chromium --with-deps - - name: Test missing-resource browser evidence - run: npm run test:docs-browser-failures - - name: Test deployed Cloudflare transport qualification - run: npm run test:docs-analytics-deployment - - name: Check Cloudflare analytics in Chromium - run: npm run check:docs-analytics-browser -- build/site - - name: Check portal and API layouts in Chromium - run: npm run check:docs-browser - package-smoke: - needs: qualification-route - if: ${{ needs.qualification-route.outputs.route == 'complete' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -635,14 +404,13 @@ jobs: target-branch-qualification: name: Target branch qualification - if: ${{ always() }} + if: ${{ always() && github.server_url == 'https://github.com' }} needs: - action-policy - - qualification-route - - focused-candidate - regression-corpus - test - framework-compat + - laravel-transition-compat - analyse - docs - package-smoke @@ -654,85 +422,20 @@ jobs: env: ACTION_POLICY_RESULT: ${{ needs.action-policy.result }} run: test "$ACTION_POLICY_RESULT" = success - - - name: Require a recognized qualification route - env: - ROUTE: ${{ needs.qualification-route.outputs.route }} - ROUTE_RESULT: ${{ needs.qualification-route.result }} - run: | - test "$ROUTE_RESULT" = success - case "$ROUTE" in - focused|complete|sentinel) ;; - *) echo "Unrecognized or unavailable qualification route: $ROUTE" >&2; exit 1 ;; - esac - - name: Require selected candidate or target evidence + - name: Require every product check env: - ROUTE: ${{ needs.qualification-route.outputs.route }} - FOCUSED_RESULT: ${{ needs.focused-candidate.result }} + CORPUS_RESULT: ${{ needs.regression-corpus.result }} TEST_RESULT: ${{ needs.test.result }} FRAMEWORK_RESULT: ${{ needs.framework-compat.result }} - CORPUS_RESULT: ${{ needs.regression-corpus.result }} + LARAVEL_TRANSITION_RESULT: ${{ needs.laravel-transition-compat.result }} ANALYSIS_RESULT: ${{ needs.analyse.result }} DOCS_RESULT: ${{ needs.docs.result }} PACKAGE_RESULT: ${{ needs.package-smoke.result }} run: | - if [ "$ROUTE" = focused ]; then - test "$FOCUSED_RESULT" = success - test "$TEST_RESULT" = skipped - test "$FRAMEWORK_RESULT" = skipped - test "$CORPUS_RESULT" = skipped - test "$ANALYSIS_RESULT" = skipped - test "$DOCS_RESULT" = skipped - test "$PACKAGE_RESULT" = skipped - echo "Focused candidate evidence succeeded; complete compatibility is deferred to the landed SHA." - elif [ "$ROUTE" = complete ]; then - test "$FOCUSED_RESULT" = skipped - test "$TEST_RESULT" = success - test "$FRAMEWORK_RESULT" = success - test "$CORPUS_RESULT" = success - test "$ANALYSIS_RESULT" = success - test "$DOCS_RESULT" = success - test "$PACKAGE_RESULT" = success - echo "Complete GitHub target qualification succeeded." - elif [ "$ROUTE" = sentinel ]; then - test "$FOCUSED_RESULT" = skipped - test "$TEST_RESULT" = skipped - test "$FRAMEWORK_RESULT" = skipped - test "$CORPUS_RESULT" = skipped - test "$ANALYSIS_RESULT" = skipped - test "$DOCS_RESULT" = skipped - test "$PACKAGE_RESULT" = skipped - echo "Alternate-CI target sentinel succeeded; GitHub owns landed-SHA qualification." - else - exit 1 - fi - - name: Record candidate-to-decision timing - if: ${{ always() && needs.qualification-route.result == 'success' }} - env: - CATEGORIES: ${{ needs.qualification-route.outputs.categories }} - CHANGED_COUNT: ${{ needs.qualification-route.outputs.changed_count }} - PATH_REASON: ${{ needs.qualification-route.outputs.changed_path_reason }} - ROUTE: ${{ needs.qualification-route.outputs.route }} - ROUTE_REASON: ${{ needs.qualification-route.outputs.route_reason }} - STARTED_AT: ${{ needs.qualification-route.outputs.started_at }} - run: | - elapsed_seconds=$(( $(date +%s) - STARTED_AT )) - before_elapsed_seconds_lower_bound=300 - after_elapsed_seconds=$elapsed_seconds - printf '%s\n' \ - "route=$ROUTE route_reason=$ROUTE_REASON categories=${CATEGORIES:-none} changed_count=$CHANGED_COUNT path_reason=$PATH_REASON" \ - "before_elapsed_seconds_lower_bound=$before_elapsed_seconds_lower_bound" \ - "after_elapsed_seconds=$after_elapsed_seconds" \ - "focused_cells=PHP-8.3 complete_cells=PHP-8.1,PHP-8.2,PHP-8.3,PHP-8.4,Laravel-9,Laravel-10,Laravel-11,Laravel-12,Laravel-13,Symfony-6.4,Symfony-7,Symfony-8" - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - { - echo "### Qualification timing" - echo - echo "| Measurement | Seconds |" - echo "| --- | ---: |" - echo "| Representative pre-change candidate (observed lower bound) | >${before_elapsed_seconds_lower_bound} |" - echo "| This candidate to aggregate decision | ${after_elapsed_seconds} |" - echo - echo "Route: \`${ROUTE}\`; focused PHP/framework cells: PHP 8.3 only; complete landed-SHA cells: 4 PHP + 8 framework." - } >> "$GITHUB_STEP_SUMMARY" - fi + test "$CORPUS_RESULT" = success + test "$TEST_RESULT" = success + test "$FRAMEWORK_RESULT" = success + test "$LARAVEL_TRANSITION_RESULT" = success + test "$ANALYSIS_RESULT" = success + test "$DOCS_RESULT" = success + test "$PACKAGE_RESULT" = success diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9695a8a..8ad03f3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,15 +10,11 @@ on: - '.eleventy.cjs' - 'scripts/check-docs-*' - 'scripts/finalize-api-reference.php' - - 'scripts/qualify-quickstart-*' - - 'scripts/qualify-docs-analytics*' - 'README.md' - 'composer.json' - 'package.json' - 'package-lock.json' - '.github/workflows/docs.yml' - schedule: - - cron: '41 * * * *' workflow_dispatch: permissions: @@ -31,8 +27,6 @@ concurrency: jobs: build: runs-on: ubuntu-latest - outputs: - release_published: ${{ steps.release-availability.outputs.release_published }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 @@ -46,12 +40,7 @@ jobs: cache: npm - run: composer install --no-interaction --prefer-dist - run: npm ci - - name: Resolve the exact published portal release - id: release-availability - if: github.server_url == 'https://github.com' - run: node scripts/qualify-quickstart-release-availability.mjs - run: npm run check:docs-examples - - run: npm run test:quickstart-contract-deployment - name: Check repository-owned documentation links offline uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 with: @@ -90,10 +79,6 @@ jobs: - run: npx playwright install chromium --with-deps - name: Test missing-resource browser evidence run: npm run test:docs-browser-failures - - name: Test deployed Cloudflare transport qualification - run: npm run test:docs-analytics-deployment - - name: Check Cloudflare analytics in Chromium - run: npm run check:docs-analytics-browser -- build/site - name: Check portal and API layouts in Chromium run: npm run check:docs-browser - name: Bind Pages artifact to the exact source revision @@ -114,8 +99,7 @@ jobs: deploy: if: >- github.server_url == 'https://github.com' && - github.ref == 'refs/heads/main' && - needs.build.outputs.release_published == 'true' + github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest permissions: @@ -128,33 +112,3 @@ jobs: steps: - id: deployment uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 - - qualify-deployment: - if: github.server_url == 'https://github.com' && github.ref == 'refs/heads/main' - needs: deploy - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - actions: read - contents: read - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 - with: - php-version: '8.3' - tools: composer:v2 - coverage: none - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: '24' - cache: npm - - run: npm ci - - run: npx playwright install chromium --with-deps - - name: Qualify deployed quickstart references - env: - GITHUB_TOKEN: ${{ github.token }} - run: npm run qualify:quickstart-contract-deployment - - name: Qualify deployed analytics transports - run: >- - npm run qualify:docs-analytics-deployment -- - --source-revision "${{ github.sha }}" diff --git a/.github/workflows/external-link-diagnostics.yml b/.github/workflows/external-link-diagnostics.yml deleted file mode 100644 index 4bc2537..0000000 --- a/.github/workflows/external-link-diagnostics.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: External documentation link diagnostics - -on: - schedule: - - cron: '37 6 * * 1' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: sdk-php-external-link-diagnostics - cancel-in-progress: false - -jobs: - diagnose: - name: Record external link responses (non-blocking) - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 - with: - php-version: '8.3' - tools: composer:v2 - coverage: none - - run: composer install --no-interaction --prefer-dist --no-progress - - run: composer docs -- --template="$PWD/.phpdoc/template" - - name: Record exact external GET statuses and URLs - id: external-links - continue-on-error: true - uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 - with: - args: >- - --no-progress - --method get - --verbose - --format json - --output external-link-diagnostics.json - --include ^https?:// - --exclude https://php.durable-workflow.com/ - README.md docs/*.md build/api/**/*.html build/api/**/*.css - - name: Bind the request method to the diagnostic report - if: always() - run: | - if test -s external-link-diagnostics.json; then - jq '{method: "GET", lychee: .}' \ - external-link-diagnostics.json > external-link-diagnostics-with-method.json - else - printf '%s\n' \ - '{"method":"GET","lychee":null,"error":"Lychee did not produce a report"}' \ - > external-link-diagnostics-with-method.json - fi - - name: Retain external link diagnostics - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: sdk-php-external-link-diagnostics-${{ github.run_id }} - path: external-link-diagnostics-with-method.json - if-no-files-found: error - retention-days: 30 diff --git a/.github/workflows/framework-bridges-published-smoke.yml b/.github/workflows/framework-bridges-published-smoke.yml deleted file mode 100644 index 701b4c8..0000000 --- a/.github/workflows/framework-bridges-published-smoke.yml +++ /dev/null @@ -1,1629 +0,0 @@ -name: Published framework bridge smoke - -on: - workflow_dispatch: - inputs: - sdk_version: - description: Exact published Packagist version, such as 2.0.0 or 2.0.0-rc.9 - required: true - type: string - server_version: - description: Exact current Server version declared by the published SDK - required: true - type: string - python_sdk_version: - description: >- - Exact synchronized Python SDK product version, such as 2.0.0 or - 2.0.0-rc.9 - required: true - type: string - -permissions: - contents: read - -jobs: - framework-service-mode: - name: ${{ matrix.framework }} / ${{ matrix.runtime }} published workflow - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 25 - environment: published-service-smoke - strategy: - fail-fast: false - matrix: - include: - - framework: laravel - runtime: standalone-server - transport: job-local-server - - framework: laravel - runtime: managed-cloud - transport: protected-cloud - - framework: symfony - runtime: standalone-server - transport: job-local-server - - framework: symfony - runtime: managed-cloud - transport: protected-cloud - steps: - - name: Validate the exact published SDK version - env: - SDK_VERSION: ${{ inputs.sdk_version }} - run: | - if [[ ! "$SDK_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then - echo "sdk_version must be an exact stable, alpha, beta, or release-candidate version" >&2 - exit 1 - fi - - - name: Validate the exact current Server version - env: - SERVER_VERSION: ${{ inputs.server_version }} - run: | - if [[ ! "$SERVER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then - echo "server_version must be an exact stable, alpha, beta, or release-candidate version" >&2 - exit 1 - fi - - - name: Validate the exact published Python SDK version - env: - PYTHON_SDK_VERSION: ${{ inputs.python_sdk_version }} - run: | - if [[ ! "$PYTHON_SDK_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then - echo "python_sdk_version must be an exact stable, alpha, beta, or release-candidate product version" >&2 - exit 1 - fi - python_sdk_pep440_version="${PYTHON_SDK_VERSION/-alpha./a}" - python_sdk_pep440_version="${python_sdk_pep440_version/-beta./b}" - python_sdk_pep440_version="${python_sdk_pep440_version/-rc./rc}" - echo "PYTHON_SDK_PEP440_VERSION=$python_sdk_pep440_version" >> "$GITHUB_ENV" - - - name: Validate managed Cloud configuration - if: matrix.transport == 'protected-cloud' - env: - DURABLE_WORKFLOW_SERVER_URL: ${{ secrets.DURABLE_WORKFLOW_SERVER_URL }} - DURABLE_WORKFLOW_NAMESPACE: ${{ secrets.DURABLE_WORKFLOW_NAMESPACE }} - DURABLE_WORKFLOW_CLIENT_TOKEN: ${{ secrets.DURABLE_WORKFLOW_CLIENT_TOKEN }} - DURABLE_WORKFLOW_WORKER_TOKEN: ${{ secrets.DURABLE_WORKFLOW_WORKER_TOKEN }} - run: | - required=( - DURABLE_WORKFLOW_SERVER_URL - DURABLE_WORKFLOW_NAMESPACE - DURABLE_WORKFLOW_CLIENT_TOKEN - DURABLE_WORKFLOW_WORKER_TOKEN - ) - missing=() - for secret_name in "${required[@]}"; do - if [ -z "${!secret_name:-}" ]; then - missing+=("$secret_name") - fi - done - if [ "${#missing[@]}" -ne 0 ]; then - printf 'Managed Cloud qualification is missing protected secret(s):' >&2 - printf ' %s' "${missing[@]}" >&2 - printf '\n' >&2 - exit 1 - fi - if [ "$DURABLE_WORKFLOW_CLIENT_TOKEN" = "$DURABLE_WORKFLOW_WORKER_TOKEN" ]; then - echo 'Managed Cloud client and worker credentials must be distinct.' >&2 - exit 1 - fi - - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 - with: - php-version: '8.4' - tools: composer:v2 - coverage: none - - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: '3.12' - - - name: Install and verify the exact published Python activity worker - env: - PYTHON_SDK_VERSION: ${{ inputs.python_sdk_version }} - run: | - python -m pip install --disable-pip-version-check \ - "durable-workflow==${PYTHON_SDK_PEP440_VERSION}" - python - <<'PY' - import importlib.metadata - import os - - requested = os.environ["PYTHON_SDK_VERSION"] - expected = os.environ["PYTHON_SDK_PEP440_VERSION"] - actual = importlib.metadata.version("durable-workflow") - if actual != expected: - raise RuntimeError( - "Installed Python SDK distribution version " - f"{actual} does not match requested product version {requested} " - f"(PEP 440: {expected})." - ) - print(f"requested_python_sdk_version={requested}") - print(f"requested_python_sdk_pep440_version={expected}") - print(f"installed_python_sdk_version={actual}") - with open( - os.environ["GITHUB_STEP_SUMMARY"], - "a", - encoding="utf-8", - ) as summary: - summary.write("## Published Python SDK identity\n\n") - summary.write( - "| Requested product version | Exact PEP 440 version " - "| Installed distribution version |\n" - ) - summary.write("| --- | --- | --- |\n") - summary.write(f"| `{requested}` | `{expected}` | `{actual}` |\n") - PY - - - name: Resolve the immutable published SDK release - env: - SDK_VERSION: ${{ inputs.sdk_version }} - run: | - composer show durable-workflow/sdk "$SDK_VERSION" --all --format=json \ - > "$RUNNER_TEMP/sdk-release.json" - php <<'PHP' - "$RUNNER_TEMP/framework-application" - if [ "$FRAMEWORK" = laravel ]; then - composer create-project laravel/laravel:^13.0 "$application" --no-interaction --prefer-dist - else - composer create-project symfony/skeleton:^8.0 "$application" --no-interaction --prefer-dist - composer require --working-dir="$application" symfony/console symfony/framework-bundle \ - --no-interaction --prefer-dist - fi - composer require --working-dir="$application" "durable-workflow/sdk:${SDK_VERSION}" \ - --no-interaction --prefer-dist - - - name: Verify the installed SDK release identity - env: - SDK_VERSION: ${{ inputs.sdk_version }} - SERVER_VERSION: ${{ inputs.server_version }} - run: | - application="$(cat "$RUNNER_TEMP/framework-application")" - SDK_LOCK_FILE="$application/composer.lock" php <<'PHP' - ($package['name'] ?? null) === 'durable-workflow/sdk', - )); - if (count($packages) !== 1 || ($packages[0]['version'] ?? null) !== $version) { - throw new RuntimeException('Installed SDK package identity or version does not match the request.'); - } - $installedReferences = []; - foreach (['source', 'dist'] as $kind) { - $reference = $packages[0][$kind]['reference'] ?? null; - if ($reference === null) { - continue; - } - if (!is_string($reference) || preg_match('/\A[0-9a-f]{40}\z/i', $reference) !== 1) { - throw new RuntimeException("Installed {$kind} reference is not an immutable commit."); - } - $expectedReference = $release[$kind]['reference'] ?? null; - if (!is_string($expectedReference) || strtolower($reference) !== strtolower($expectedReference)) { - throw new RuntimeException("Installed {$kind} reference does not match the requested release."); - } - $installedReferences[] = strtolower($reference); - } - if ($installedReferences === []) { - throw new RuntimeException('Installed SDK has no immutable source or distribution reference.'); - } - $packageManifest = json_decode( - file_get_contents(dirname(getenv('SDK_LOCK_FILE')).'/vendor/durable-workflow/sdk/composer.json'), - true, - flags: JSON_THROW_ON_ERROR, - ); - if (($packageManifest['extra']['durable-workflow']['supported-server-versions'] ?? null) !== getenv('SERVER_VERSION')) { - throw new RuntimeException('Requested Server version does not match the published SDK compatibility baseline.'); - } - PHP - - - name: Configure Laravel through auto-discovery and vendor publish - if: matrix.framework == 'laravel' - run: | - application="$(cat "$RUNNER_TEMP/framework-application")" - cd "$application" - mkdir -p app/Actions app/Activities app/Support app/Workflows - tee app/Support/PublishedRoleCredentialProbe.php >/dev/null <<'PHP' - getenv($name) !== false, - '_ENV' => array_key_exists($name, $_ENV), - '_SERVER' => array_key_exists($name, $_SERVER), - 'laravel_env' => class_exists(Env::class) - ? Env::get($name) !== null - : null, - ]; - } - $role = getenv('DURABLE_WORKFLOW_PROCESS_ROLE'); - file_put_contents($log, json_encode([ - 'stage' => $stage, - 'role_is_client' => $role === 'client', - 'role_is_worker' => $role === 'worker', - 'presence' => $presence, - ], JSON_THROW_ON_ERROR).PHP_EOL, FILE_APPEND | LOCK_EX); - } - } - PHP - tee app/Providers/PublishedRoleCredentialProbeProvider.php >/dev/null <<'PHP' - /dev/null <<'PHP' - /dev/null <<'PHP' - /dev/null <<'PHP' - prefix->value() === '') { - throw new \LogicException('Laravel did not inject the workflow dependency.'); - } - - $version = $context->getVersion('published-framework-greeting', 1, 1); - - $released = $context->waitCondition( - static fn (): bool => $context->signals('published.release') !== [], - key: 'published-laravel-release', - timeout: 60, - ); - if (!$released) { - return ['greetings' => ['published Laravel condition wait timed out'], 'version' => $version]; - } - - $restart = $context->all([ - static fn () => $context->activity('laravel.greet', ["{$name} restart"]), - static fn () => $context->parallel([ - static fn () => $context->childWorkflow('laravel.child-greeting', [$name]), - static fn () => $context->sleep(30), - ]), - ]); - - $activities = $context->all([ - static fn () => $context->activity('laravel.greet', [$name]), - static fn () => $context->activity('laravel.greet', ["{$name} activity two"]), - ]); - $children = $context->parallel([ - static fn () => $context->childWorkflow('laravel.child-greeting', ["{$name} child one"]), - static fn () => $context->childWorkflow('laravel.child-greeting', ["{$name} child two"]), - ]); - - $failureActivity = null; - try { - $context->all([ - static fn () => $context->activity('laravel.greet', ["{$name} failure sibling"]), - static fn () => $context->activity( - 'laravel.fail', - [$name], - ['retry_policy' => ['max_attempts' => 1]], - ), - ]); - } catch (ActivityFailed $exception) { - $failureActivity = $exception->activityType; - } - - return compact('restart', 'activities', 'children', 'failureActivity', 'version'); - } - - #[Signal('published.release')] - public function release(): void {} - } - PHP - tee app/Workflows/PublishedGreetingChildWorkflow.php >/dev/null <<'PHP' - /dev/null <<'PHP' - prefix->value() === '') { - throw new \LogicException('Laravel did not inject the saga workflow dependency.'); - } - - return $context->saga()->run(static function (Saga $saga) use ($context, $tripId): array { - $flight = $context->activity('python.reserve-flight', [$tripId]); - $saga->addCompensation('python.cancel-flight', [$tripId, $flight]); - - $hotel = $context->activity('python.reserve-hotel', [$tripId]); - $saga->addCompensation('python.cancel-hotel', [$tripId, $hotel]); - - $context->activity( - 'python.charge-card', - [$tripId], - ['retry_policy' => ['max_attempts' => 1]], - ); - - return compact('flight', 'hotel'); - }); - } - } - PHP - tee app/Activities/PublishedGreetingActivities.php >/dev/null <<'PHP' - prefix->value()}, {$name}"; - } - - #[Activity('laravel.fail')] - public function fail(ActivityContext $context, string $name): never - { - throw new \RuntimeException("intentional qualification failure for {$name}"); - } - } - PHP - tee published-saga-activities.py >/dev/null <<'PY' - import asyncio - import os - import signal - - from durable_workflow import Client, Worker, activity - - - @activity.defn(name="python.reserve-flight") - def reserve_flight(trip_id: str) -> str: - return f"flight:{trip_id}" - - - @activity.defn(name="python.reserve-hotel") - def reserve_hotel(trip_id: str) -> str: - return f"hotel:{trip_id}" - - - @activity.defn(name="python.charge-card") - def charge_card(trip_id: str) -> None: - raise RuntimeError(f"card declined for {trip_id}") - - - @activity.defn(name="python.cancel-hotel") - def cancel_hotel(trip_id: str, reservation_id: str) -> str: - return f"cancelled:{reservation_id}" - - - @activity.defn(name="python.cancel-flight") - async def cancel_flight(trip_id: str, reservation_id: str) -> str: - await asyncio.sleep(10) - return f"cancelled:{reservation_id}" - - - async def main() -> None: - async with Client( - os.environ["DURABLE_WORKFLOW_RUNTIME_URL"], - namespace=os.environ["DURABLE_WORKFLOW_NAMESPACE"], - token=os.environ["DURABLE_WORKFLOW_WORKER_TOKEN"], - ) as client: - worker = Worker( - client, - task_queue=os.environ["DURABLE_WORKFLOW_TASK_QUEUE"], - workflows=[], - activities=[ - reserve_flight, - reserve_hotel, - charge_card, - cancel_hotel, - cancel_flight, - ], - ) - loop = asyncio.get_running_loop() - for signal_name in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler( - signal_name, - lambda: asyncio.create_task(worker.stop()), - ) - await worker.run() - - - if __name__ == "__main__": - asyncio.run(main()) - PY - tee app/Actions/StartPublishedGreeting.php >/dev/null <<'PHP' - workflows->start( - \App\Workflows\PublishedGreetingWorkflow::class, - ['Ada'], - workflowId: $workflowId, - )->result(timeoutSeconds: 90, pollIntervalSeconds: 1); - } - } - PHP - tee -a routes/console.php >/dev/null <<'PHP' - - Artisan::command('durable-workflow:published-greeting', function (): void { - $client = app(\DurableWorkflow\Client::class); - $actualVersion = $client->clusterInfo()->version; - if ($actualVersion !== env('QUALIFIED_SERVER_VERSION')) { - throw new \RuntimeException("Published endpoint is Server {$actualVersion}; expected ".env('QUALIFIED_SERVER_VERSION').'.'); - } - $workflowId = env('DURABLE_WORKFLOW_WORKFLOW_ID'); - $phase = env('PUBLISHED_WORKFLOW_PHASE'); - $workflows = app(\DurableWorkflow\Bridge\Laravel\LaravelWorkflowClientInterface::class); - if ($phase === 'start') { - $handle = $workflows->start( - \App\Workflows\PublishedGreetingWorkflow::class, - ['Ada'], - workflowId: $workflowId, - ); - $deadline = microtime(true) + 30; - do { - $execution = $client->describeWorkflow($workflowId); - if ($execution->status === 'waiting') { - break; - } - usleep(250_000); - } while (microtime(true) < $deadline); - if ($execution->status !== 'waiting' || !is_string($execution->runId)) { - throw new \RuntimeException('Published Laravel workflow did not open its condition wait.'); - } - try { - $handle->signal('published.release'); - } catch (\DurableWorkflow\Exception\SignalFailed $exception) { - throw new \RuntimeException( - 'Published Laravel release signal failed: '.json_encode([ - 'status' => $exception->status, - 'reason' => $exception->reason, - 'details' => $exception->details, - ], JSON_THROW_ON_ERROR), - previous: $exception, - ); - } - - $deadline = microtime(true) + 20; - do { - $history = $client->workflowHistory($workflowId, $execution->runId); - $events = $history['events'] ?? $history['history_events'] ?? []; - $restartEvents = array_values(array_filter( - is_array($events) ? $events : [], - static function (mixed $event): bool { - if (!is_array($event) || !is_array($event['payload'] ?? null)) { - return false; - } - $path = $event['payload']['parallel_group_path'] ?? null; - $outer = is_array($path) && is_array($path[0] ?? null) ? $path[0] : []; - - return ($outer['parallel_group_kind'] ?? null) === 'mixed' - && ($outer['parallel_group_size'] ?? null) === 3; - }, - )); - $restartTypes = array_column($restartEvents, 'event_type'); - if (in_array('ActivityCompleted', $restartTypes, true) - && in_array('TimerScheduled', $restartTypes, true) - && !in_array('TimerFired', $restartTypes, true) - ) { - return; - } - usleep(250_000); - } while (microtime(true) < $deadline); - throw new \RuntimeException('Published Laravel workflow did not expose a partially completed mixed group before restart.'); - } - if ($phase !== 'finish') { - throw new \RuntimeException('Published Laravel workflow phase must be start or finish.'); - } - - $execution = $client->describeWorkflow($workflowId); - if (!is_string($execution->runId) || $execution->runId === '') { - throw new \RuntimeException('Published Laravel workflow has no run identity for cold replay.'); - } - $handle = $workflows->handle( - \App\Workflows\PublishedGreetingWorkflow::class, - $workflowId, - $execution->runId, - ); - $result = $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1); - if ($result !== [ - 'restart' => ['hello from Laravel, Ada restart', ['child Ada', null]], - 'activities' => [ - 'hello from Laravel, Ada', - 'hello from Laravel, Ada activity two', - ], - 'children' => ['child Ada child one', 'child Ada child two'], - 'failureActivity' => 'laravel.fail', - 'version' => 1, - ]) { - throw new \RuntimeException('Published Laravel workflow returned an unexpected upgraded result.'); - } - $history = $client->workflowHistory($workflowId, $execution->runId); - $events = $history['events'] ?? $history['history_events'] ?? []; - $markers = array_values(array_filter( - is_array($events) ? $events : [], - static fn (mixed $event): bool => is_array($event) - && ($event['event_type'] ?? $event['type'] ?? null) === 'VersionMarkerRecorded', - )); - if (count($markers) !== 1) { - throw new \RuntimeException('Published Laravel workflow did not retain exactly one version marker.'); - } - $payload = is_array($markers[0]['payload'] ?? null) ? $markers[0]['payload'] : []; - if (($payload['change_id'] ?? null) !== 'published-framework-greeting' - || ($payload['version'] ?? null) !== 1) { - throw new \RuntimeException('Published Laravel workflow changed its durable version decision.'); - } - $scheduled = array_values(array_filter( - is_array($events) ? $events : [], - static fn (mixed $event): bool => is_array($event) - && is_array($event['payload'] ?? null) - && in_array($event['event_type'] ?? $event['type'] ?? null, [ - 'ActivityScheduled', - 'ChildWorkflowScheduled', - 'TimerScheduled', - ], true) - && is_array($event['payload']['parallel_group_path'] ?? null), - )); - $groups = []; - $sequences = []; - foreach ($scheduled as $event) { - $payload = $event['payload']; - $path = $payload['parallel_group_path']; - $outer = $path[0] ?? null; - if (!is_array($outer)) { - throw new \RuntimeException('Published Laravel history contains an invalid parallel path.'); - } - $groups[$outer['parallel_group_id']][] = [ - 'event_type' => $event['event_type'] ?? $event['type'] ?? null, - 'outer' => $outer, - 'path_depth' => count($path), - ]; - $sequences[] = $payload['sequence'] ?? null; - } - $groupShapes = array_map(static function (array $members): array { - $first = $members[0]; - $depths = array_column($members, 'path_depth'); - sort($depths); - - return [ - 'kind' => $first['outer']['parallel_group_kind'] ?? null, - 'size' => $first['outer']['parallel_group_size'] ?? null, - 'types' => array_column($members, 'event_type'), - 'depths' => $depths, - ]; - }, $groups); - $matches = static fn (array $shape, string $kind, array $types, array $depths): bool => - $shape['kind'] === $kind - && $shape['size'] === count($types) - && $shape['types'] === $types - && $shape['depths'] === $depths; - $activityGroups = array_values(array_filter( - $groupShapes, - static fn (array $shape): bool => $matches( - $shape, - 'activity', - ['ActivityScheduled', 'ActivityScheduled'], - [1, 1], - ), - )); - $childGroups = array_values(array_filter( - $groupShapes, - static fn (array $shape): bool => $matches( - $shape, - 'child', - ['ChildWorkflowScheduled', 'ChildWorkflowScheduled'], - [1, 1], - ), - )); - $mixedGroups = array_values(array_filter( - $groupShapes, - static fn (array $shape): bool => $matches( - $shape, - 'mixed', - ['ActivityScheduled', 'ChildWorkflowScheduled', 'TimerScheduled'], - [1, 2, 2], - ), - )); - $failures = array_values(array_filter( - is_array($events) ? $events : [], - static fn (mixed $event): bool => is_array($event) - && ($event['event_type'] ?? $event['type'] ?? null) === 'ActivityFailed' - && ($event['payload']['activity_type'] ?? null) === 'laravel.fail' - && is_array($event['payload']['parallel_group_path'] ?? null), - )); - if (count($scheduled) !== 9 - || count(array_unique($sequences, SORT_REGULAR)) !== 9 - || count($activityGroups) !== 2 - || count($childGroups) !== 1 - || count($mixedGroups) !== 1 - || count($failures) !== 1 - ) { - throw new \RuntimeException('Published Laravel history did not prove every parallel qualification cell exactly once.'); - } - }); - - Artisan::command('durable-workflow:published-saga', function (): void { - $client = app(\DurableWorkflow\Client::class); - $workflowId = env('DURABLE_WORKFLOW_SAGA_ID'); - $phase = env('PUBLISHED_SAGA_PHASE'); - $workflows = app(\DurableWorkflow\Bridge\Laravel\LaravelWorkflowClientInterface::class); - if ($phase === 'start') { - $workflows->start( - \App\Workflows\PublishedSagaWorkflow::class, - ['trip-published'], - workflowId: $workflowId, - ); - $deadline = microtime(true) + 45; - do { - $execution = $client->describeWorkflow($workflowId); - if (is_string($execution->runId) && $execution->runId !== '') { - $history = $client->workflowHistory($workflowId, $execution->runId); - $events = $history['events'] ?? $history['history_events'] ?? []; - $scheduledFlightCompensation = array_values(array_filter( - is_array($events) ? $events : [], - static fn (mixed $event): bool => is_array($event) - && ($event['event_type'] ?? $event['type'] ?? null) === 'ActivityScheduled' - && ($event['payload']['activity_type'] ?? null) === 'python.cancel-flight', - )); - $completedFlightCompensation = array_values(array_filter( - is_array($events) ? $events : [], - static fn (mixed $event): bool => is_array($event) - && ($event['event_type'] ?? $event['type'] ?? null) === 'ActivityCompleted' - && ($event['payload']['activity_type'] ?? null) === 'python.cancel-flight', - )); - if (count($scheduledFlightCompensation) === 1 - && $completedFlightCompensation === []) { - return; - } - } - usleep(250_000); - } while (microtime(true) < $deadline); - throw new \RuntimeException('Published saga did not reach its in-flight final compensation.'); - } - if ($phase !== 'finish') { - throw new \RuntimeException('Published saga phase must be start or finish.'); - } - - $execution = $client->describeWorkflow($workflowId); - if (!is_string($execution->runId) || $execution->runId === '') { - throw new \RuntimeException('Published saga has no run identity after worker restart.'); - } - $handle = $workflows->handle( - \App\Workflows\PublishedSagaWorkflow::class, - $workflowId, - $execution->runId, - ); - try { - $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1); - throw new \RuntimeException('Published saga silently completed after its forward failure.'); - } catch (\DurableWorkflow\Exception\WorkflowFailed $failure) { - if ($failure->failureType !== \DurableWorkflow\Exception\ActivityFailed::class - || !str_contains($failure->getMessage(), 'card declined')) { - throw new \RuntimeException( - 'Published saga did not preserve its typed forward failure.', - previous: $failure, - ); - } - } - - $history = $client->workflowHistory($workflowId, $execution->runId); - $events = $history['events'] ?? $history['history_events'] ?? []; - $activityTypes = static function (string $eventType) use ($events): array { - return array_values(array_map( - static fn (array $event): string => (string) $event['payload']['activity_type'], - array_filter( - is_array($events) ? $events : [], - static fn (mixed $event): bool => is_array($event) - && ($event['event_type'] ?? $event['type'] ?? null) === $eventType - && is_string($event['payload']['activity_type'] ?? null) - && str_starts_with($event['payload']['activity_type'], 'python.'), - ), - )); - }; - $scheduled = $activityTypes('ActivityScheduled'); - $completed = $activityTypes('ActivityCompleted'); - $failed = $activityTypes('ActivityFailed'); - if ($scheduled !== [ - 'python.reserve-flight', - 'python.reserve-hotel', - 'python.charge-card', - 'python.cancel-hotel', - 'python.cancel-flight', - ] || $completed !== [ - 'python.reserve-flight', - 'python.reserve-hotel', - 'python.cancel-hotel', - 'python.cancel-flight', - ] || $failed !== ['python.charge-card']) { - throw new \RuntimeException('Published cross-language saga history changed order or duplicated a compensation.'); - } - fwrite(STDOUT, json_encode([ - 'runtime' => env('RUNTIME_TARGET'), - 'scheduled' => $scheduled, - 'completed' => $completed, - 'failed' => $failed, - 'worker_restart_during_compensation' => true, - ], JSON_THROW_ON_ERROR).PHP_EOL); - }); - - Artisan::command('durable-workflow:published-fake', function (): void { - $fake = \DurableWorkflow\Bridge\Laravel\Facades\DurableWorkflow::fake() - ->setWorkflowResult('published-laravel-fake', [ - 'restart' => ['hello from Laravel, Ada restart', ['child Ada', null]], - 'activities' => [ - 'hello from Laravel, Ada', - 'hello from Laravel, Ada activity two', - ], - 'children' => ['child Ada child one', 'child Ada child two'], - 'failureActivity' => 'laravel.fail', - 'version' => 1, - ]); - $result = app(\App\Actions\StartPublishedGreeting::class)( - 'published-laravel-fake', - ); - if ($result !== [ - 'restart' => ['hello from Laravel, Ada restart', ['child Ada', null]], - 'activities' => [ - 'hello from Laravel, Ada', - 'hello from Laravel, Ada activity two', - ], - 'children' => ['child Ada child one', 'child Ada child two'], - 'failureActivity' => 'laravel.fail', - 'version' => 1, - ]) { - throw new \RuntimeException('Published Laravel fake returned an unexpected result.'); - } - $fake->assertWorkflowStarted( - \App\Workflows\PublishedGreetingWorkflow::class, - ['Ada'], - workflowId: 'published-laravel-fake', - ); - $fake->assertResultRequested('published-laravel-fake'); - $fake->setWorkflowResult('published-laravel-saga-fake', [ - 'status' => 'compensated', - 'compensations' => ['python.cancel-hotel', 'python.cancel-flight'], - ]); - $sagaResult = $fake->start( - \App\Workflows\PublishedSagaWorkflow::class, - ['trip-published'], - workflowId: 'published-laravel-saga-fake', - )->result(); - if (($sagaResult['status'] ?? null) !== 'compensated') { - throw new \RuntimeException('Published Laravel fake returned an unexpected saga result.'); - } - $fake->assertWorkflowStarted( - \App\Workflows\PublishedSagaWorkflow::class, - ['trip-published'], - workflowId: 'published-laravel-saga-fake', - ); - $fake->assertResultRequested('published-laravel-saga-fake'); - }); - PHP - php artisan vendor:publish --tag=durable-workflow-config --force - sed -i "/'handlers' => \[/a\ App\\\\Workflows\\\\PublishedGreetingWorkflow::class,\n App\\\\Workflows\\\\PublishedGreetingChildWorkflow::class,\n App\\\\Workflows\\\\PublishedSagaWorkflow::class,\n App\\\\Activities\\\\PublishedGreetingActivities::class," config/durable-workflow.php - - - name: Prove the published Laravel fake without a runtime - if: matrix.framework == 'laravel' - run: | - application="$(cat "$RUNNER_TEMP/framework-application")" - cd "$application" - php artisan durable-workflow:published-fake - - - name: Configure Symfony Bundle and autowired handlers - if: matrix.framework == 'symfony' - run: | - application="$(cat "$RUNNER_TEMP/framework-application")" - cd "$application" - mkdir -p src/Workflow src/Activity config/packages - sed -i "/return \[/a\ DurableWorkflow\\\\Bridge\\\\Symfony\\\\DurableWorkflowBundle::class => ['all' => true]," config/bundles.php - tee config/packages/durable_workflow.yaml >/dev/null <<'YAML' - durable_workflow: - endpoint: '%env(DURABLE_WORKFLOW_RUNTIME_URL)%' - namespace: '%env(DURABLE_WORKFLOW_NAMESPACE)%' - task_queue: '%env(DURABLE_WORKFLOW_TASK_QUEUE)%' - credentials: - control_token: '%env(default::DURABLE_WORKFLOW_CLIENT_TOKEN)%' - worker_token: '%env(default::DURABLE_WORKFLOW_WORKER_TOKEN)%' - handlers: - - App\Workflow\PublishedGreetingWorkflow - - App\Activity\PublishedGreetingActivities - YAML - tee src/Workflow/PublishedGreetingWorkflow.php >/dev/null <<'PHP' - getVersion('published-framework-greeting', 1, 1); - - $released = $context->waitCondition( - static fn (): bool => $context->signals('published.release') !== [], - key: 'published-symfony-release', - timeout: 60, - ); - if (!$released) { - return ['greeting' => 'published Symfony condition wait timed out']; - } - - $greetings = $context->parallel([ - static fn () => $context->activity('published.symfony.greet', [$name]), - static fn () => $context->activity('published.symfony.greet', ["{$name} again"]), - ]); - - return compact('greetings', 'version'); - } - - #[Signal('published.release')] - public function release(): void {} - } - PHP - tee src/Activity/PublishedGreetingActivities.php >/dev/null <<'PHP' - /dev/null 2>&1 || true - docker network rm "$runtime_network" >/dev/null 2>&1 || true - } - trap cleanup EXIT - - docker network create "$runtime_network" >/dev/null - docker run --detach \ - --name "$mysql_container" \ - --network "$runtime_network" \ - --tmpfs /var/lib/mysql:rw,noexec,nosuid,size=1024m \ - --env MYSQL_DATABASE=durable_workflow \ - --env MYSQL_USER=workflow \ - --env MYSQL_PASSWORD="$mysql_password" \ - --env MYSQL_ROOT_PASSWORD="$mysql_root_password" \ - mysql:8.0.43 >/dev/null - docker run --detach \ - --name "$redis_container" \ - --network "$runtime_network" \ - --tmpfs /data:rw,noexec,nosuid,size=256m \ - redis:7-alpine >/dev/null - - mysql_ready=false - redis_ready=false - for _attempt in $(seq 1 60); do - if docker exec "$mysql_container" \ - mysqladmin ping --host=127.0.0.1 --user=root \ - --password="$mysql_root_password" --silent >/dev/null 2>&1; then - mysql_ready=true - fi - if [ "$(docker exec "$redis_container" redis-cli ping 2>/dev/null || true)" = PONG ]; then - redis_ready=true - fi - if [ "$mysql_ready" = true ] && [ "$redis_ready" = true ]; then - break - fi - sleep 1 - done - if [ "$mysql_ready" != true ] || [ "$redis_ready" != true ]; then - echo 'Standalone Server dependencies did not become ready.' >&2 - docker logs "$mysql_container" >&2 || true - docker logs "$redis_container" >&2 || true - exit 1 - fi - - server_environment=( - --env APP_ENV=local - --env DW_SERVER_KEY="$server_key" - --env DB_CONNECTION=mysql - --env DB_HOST="$mysql_container" - --env DB_PORT=3306 - --env DB_DATABASE=durable_workflow - --env DB_USERNAME=workflow - --env DB_PASSWORD="$mysql_password" - --env REDIS_HOST="$redis_container" - --env QUEUE_CONNECTION=redis - --env CACHE_STORE=redis - --env DW_AUTH_DRIVER=token - --env DW_OPERATOR_TOKEN="$client_token" - --env DW_WORKER_TOKEN="$worker_token" - --env DW_AUTH_BACKWARD_COMPATIBLE=false - ) - - if ! docker run \ - --name "$bootstrap_container" \ - --network "$runtime_network" \ - "${server_environment[@]}" \ - "$server_image" server-bootstrap; then - echo 'Standalone Server bootstrap and migrations failed.' >&2 - docker logs "$bootstrap_container" >&2 || true - exit 1 - fi - docker rm "$bootstrap_container" >/dev/null - - docker run --detach \ - --name "$server_container" \ - --network "$runtime_network" \ - --publish 127.0.0.1::8080 \ - "${server_environment[@]}" \ - "$server_image" >/dev/null - published_port="$(docker port "$server_container" 8080/tcp | awk -F: 'END {print $NF}')" - if [ -z "$published_port" ]; then - echo 'Standalone Server did not publish its workflow API port.' >&2 - exit 1 - fi - runtime_url="http://127.0.0.1:${published_port}" - - workflow_ready=false - for _attempt in $(seq 1 60); do - if curl --fail --silent --show-error \ - "$runtime_url/api/ready" >"$RUNNER_TEMP/standalone-ready.json"; then - if php -r ' - $ready = json_decode(file_get_contents($argv[1]), true, flags: JSON_THROW_ON_ERROR); - exit(($ready["status"] ?? null) === "ready" ? 0 : 1); - ' "$RUNNER_TEMP/standalone-ready.json"; then - workflow_ready=true - break - fi - fi - if ! docker inspect --format '{{.State.Running}}' "$server_container" 2>/dev/null | grep -Fxq true; then - break - fi - sleep 1 - done - if [ "$workflow_ready" != true ]; then - echo 'Standalone Server did not report workflow readiness at /api/ready.' >&2 - cat "$RUNNER_TEMP/standalone-ready.json" >&2 2>/dev/null || true - docker logs "$server_container" >&2 || true - exit 1 - fi - - { - printf 'DURABLE_WORKFLOW_RUNTIME_URL=%s\n' "$runtime_url" - printf 'DURABLE_WORKFLOW_NAMESPACE=default\n' - printf 'DURABLE_WORKFLOW_CLIENT_TOKEN=%s\n' "$client_token" - printf 'DURABLE_WORKFLOW_WORKER_TOKEN=%s\n' "$worker_token" - printf 'STANDALONE_RUNTIME_NETWORK=%s\n' "$runtime_network" - printf 'STANDALONE_MYSQL_CONTAINER=%s\n' "$mysql_container" - printf 'STANDALONE_REDIS_CONTAINER=%s\n' "$redis_container" - printf 'STANDALONE_SERVER_CONTAINER=%s\n' "$server_container" - } >> "$GITHUB_ENV" - trap - EXIT - - - name: Prepare the framework runtime qualification - run: | - tee "$RUNNER_TEMP/framework-runtime.sh" >/dev/null <<'BASH' - #!/usr/bin/env bash - set -euo pipefail - - case "${RUNTIME_TARGET}:${SELECTED_TRANSPORT}" in - standalone-server:job-local-server|managed-cloud:protected-cloud) ;; - *) - echo "Runtime ${RUNTIME_TARGET} cannot use transport ${SELECTED_TRANSPORT}." >&2 - exit 1 - ;; - esac - - required=( - DURABLE_WORKFLOW_RUNTIME_URL - DURABLE_WORKFLOW_NAMESPACE - DURABLE_WORKFLOW_CLIENT_TOKEN - DURABLE_WORKFLOW_WORKER_TOKEN - ) - missing=() - for variable in "${required[@]}"; do - if [ -z "${!variable:-}" ]; then - missing+=("$variable") - fi - done - if [ "${#missing[@]}" -ne 0 ]; then - printf 'Published runtime configuration is incomplete; missing value(s):' >&2 - printf ' %s' "${missing[@]}" >&2 - printf '\n' >&2 - exit 1 - fi - if [ "$DURABLE_WORKFLOW_CLIENT_TOKEN" = "$DURABLE_WORKFLOW_WORKER_TOKEN" ]; then - echo 'Published runtime client and worker credentials must be distinct.' >&2 - exit 1 - fi - - application="$(cat "$RUNNER_TEMP/framework-application")" - cd "$application" - SDK_LOCK_FILE="$PWD/composer.lock" php <<'PHP' - ($candidate['name'] ?? null) === 'durable-workflow/sdk', - ))[0] ?? null; - if (!is_array($package)) { - throw new RuntimeException('The installed Durable Workflow package identity is unavailable.'); - } - fwrite(STDOUT, json_encode([ - 'installed_sdk_version' => $package['version'] ?? null, - 'installed_sdk_source_reference' => $package['source']['reference'] ?? null, - 'installed_sdk_dist_reference' => $package['dist']['reference'] ?? null, - ], JSON_THROW_ON_ERROR).PHP_EOL); - PHP - queue="php-${FRAMEWORK}-${RUNTIME_TARGET}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - workflow_id="php-${FRAMEWORK}-${RUNTIME_TARGET}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - saga_id="${workflow_id}-saga" - export DURABLE_WORKFLOW_TASK_QUEUE="$queue" - export DURABLE_WORKFLOW_WORKFLOW_ID="$workflow_id" - export DURABLE_WORKFLOW_SAGA_ID="$saga_id" - export DURABLE_WORKFLOW_TYPE="published.${FRAMEWORK}.greeter" - - if [ "$FRAMEWORK" = laravel ]; then - env -u DURABLE_WORKFLOW_TOKEN \ - -u DURABLE_WORKFLOW_CLIENT_TOKEN \ - -u DURABLE_WORKFLOW_WORKER_TOKEN \ - -u DURABLE_WORKFLOW_PROCESS_ROLE \ - -u DURABLE_WORKFLOW_PROCESS_TOKEN \ - php artisan config:cache - DURABLE_WORKFLOW_CONFIG_CACHE="$PWD/bootstrap/cache/config.php" php <<'PHP' - str_starts_with($line, $name.'='), - )) !== 0; - } - fwrite(STDOUT, json_encode([ - 'stage' => 'dotenv-file', - 'credential_presence' => $configured, - ], JSON_THROW_ON_ERROR).PHP_EOL); - if (in_array(true, $configured, true)) { - throw new RuntimeException('Fresh Laravel .env contains a Durable Workflow credential variable.'); - } - PHP - laravel_client_token="$DURABLE_WORKFLOW_CLIENT_TOKEN" - laravel_worker_token="$DURABLE_WORKFLOW_WORKER_TOKEN" - unset \ - DURABLE_WORKFLOW_TOKEN \ - DURABLE_WORKFLOW_CLIENT_TOKEN \ - DURABLE_WORKFLOW_WORKER_TOKEN \ - DURABLE_WORKFLOW_PROCESS_ROLE \ - DURABLE_WORKFLOW_PROCESS_TOKEN - export DURABLE_WORKFLOW_ROLE_PROBE_LOG="$RUNNER_TEMP/laravel-role-presence-${RUNTIME_TARGET}.jsonl" - : > "$DURABLE_WORKFLOW_ROLE_PROBE_LOG" - fi - - if [ "$FRAMEWORK" = symfony ]; then - tee durable-client.php >/dev/null <<'PHP' - boot(); - $client = $kernel->getContainer()->get(WorkflowClientInterface::class); - if (!$client instanceof WorkflowClientInterface || !$client instanceof Client) { - throw new RuntimeException('Framework did not provide the injectable workflow client.'); - } - $actualVersion = $client->clusterInfo()->version; - if ($actualVersion !== getenv('QUALIFIED_SERVER_VERSION')) { - throw new RuntimeException("Published endpoint is Server {$actualVersion}; expected ".getenv('QUALIFIED_SERVER_VERSION').'.'); - } - $workflowId = (string) getenv('DURABLE_WORKFLOW_WORKFLOW_ID'); - $phase = getenv('PUBLISHED_WORKFLOW_PHASE'); - if ($phase === 'start') { - $client->startWorkflow( - (string) getenv('DURABLE_WORKFLOW_TYPE'), - $workflowId, - (string) getenv('DURABLE_WORKFLOW_TASK_QUEUE'), - ['published framework'], - ); - $deadline = microtime(true) + 30; - do { - $execution = $client->describeWorkflow($workflowId); - if ($execution->status === 'waiting') { - exit(0); - } - usleep(250_000); - } while (microtime(true) < $deadline); - throw new RuntimeException('Published Symfony workflow did not open its condition wait.'); - } - if ($phase !== 'finish') { - throw new RuntimeException('Published Symfony workflow phase must be start or finish.'); - } - - $execution = $client->describeWorkflow($workflowId); - if (!is_string($execution->runId) || $execution->runId === '') { - throw new RuntimeException('Published Symfony workflow has no run identity for cold replay.'); - } - $handle = $client->workflowHandle($workflowId, $execution->runId); - try { - $handle->signal('published.release'); - } catch (\DurableWorkflow\Exception\SignalFailed $exception) { - throw new RuntimeException( - 'Published Symfony release signal failed: '.json_encode([ - 'status' => $exception->status, - 'reason' => $exception->reason, - 'details' => $exception->details, - ], JSON_THROW_ON_ERROR), - previous: $exception, - ); - } - $result = $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1); - if ($result !== [ - 'greetings' => ['hello, published framework', 'hello, published framework again'], - 'version' => 1, - ]) { - throw new RuntimeException('Published framework workflow returned an unexpected upgraded result.'); - } - $history = $client->workflowHistory($workflowId, $execution->runId); - $events = $history['events'] ?? $history['history_events'] ?? []; - $markers = array_values(array_filter( - is_array($events) ? $events : [], - static fn (mixed $event): bool => is_array($event) - && ($event['event_type'] ?? $event['type'] ?? null) === 'VersionMarkerRecorded', - )); - if (count($markers) !== 1) { - throw new RuntimeException('Published Symfony workflow did not retain exactly one version marker.'); - } - $payload = is_array($markers[0]['payload'] ?? null) ? $markers[0]['payload'] : []; - if (($payload['change_id'] ?? null) !== 'published-framework-greeting' - || ($payload['version'] ?? null) !== 1) { - throw new RuntimeException('Published Symfony workflow changed its durable version decision.'); - } - $parallel = array_values(array_filter( - is_array($events) ? $events : [], - static fn (mixed $event): bool => is_array($event) - && is_array($event['payload'] ?? null) - && in_array($event['event_type'] ?? $event['type'] ?? null, ['ActivityScheduled', 'ActivityCompleted'], true) - && ($event['payload']['parallel_group_kind'] ?? null) === 'activity', - )); - $parallelPayloads = array_column($parallel, 'payload'); - $parallelIds = array_values(array_unique(array_column($parallelPayloads, 'parallel_group_id'))); - $parallelIndexes = array_column($parallelPayloads, 'parallel_group_index'); - sort($parallelIndexes); - if (count($parallel) < 4 - || count($parallelIds) !== 1 - || $parallelIndexes !== [0, 0, 1, 1] - || array_filter($parallelPayloads, static fn (array $entry): bool => !is_array($entry['parallel_group_path'] ?? null)) !== [] - ) { - throw new RuntimeException('Published Symfony history did not retain its parallel group/path diagnostics.'); - } - PHP - fi - - cleanup_workers() { - if [ -n "${worker_pid:-}" ]; then - kill -TERM "$worker_pid" 2>/dev/null || true - fi - if [ -n "${python_worker_pid:-}" ]; then - kill -TERM "$python_worker_pid" 2>/dev/null || true - fi - } - - start_worker() { - local worker_log="$1" - if [ "$FRAMEWORK" = laravel ]; then - env \ - -u DURABLE_WORKFLOW_TOKEN \ - -u DURABLE_WORKFLOW_CLIENT_TOKEN \ - -u DURABLE_WORKFLOW_WORKER_TOKEN \ - DURABLE_WORKFLOW_PROCESS_ROLE=worker \ - DURABLE_WORKFLOW_PROCESS_TOKEN="$laravel_worker_token" \ - php laravel-role-launch.php durable-workflow:worker >"$worker_log" 2>&1 & - else - env -u DURABLE_WORKFLOW_CLIENT_TOKEN php bin/console durable-workflow:worker >"$worker_log" 2>&1 & - fi - worker_pid=$! - trap cleanup_workers EXIT - if [ "$FRAMEWORK" = laravel ]; then - local ready=false - for _attempt in $(seq 1 30); do - if grep -Fq 'Registered and polling:' "$worker_log"; then - ready=true - break - fi - if ! kill -0 "$worker_pid" 2>/dev/null; then - break - fi - sleep 1 - done - if [ "$ready" != true ] \ - || ! grep -Fq "queue=${queue}" "$worker_log" \ - || ! grep -Fq 'workflows=[' "$worker_log" \ - || ! grep -Fq 'laravel.greeting' "$worker_log" \ - || ! grep -Fq 'laravel.child-greeting' "$worker_log" \ - || ! grep -Fq 'laravel.compensated-trip' "$worker_log" \ - || ! grep -Fq 'activities=[' "$worker_log" \ - || ! grep -Fq 'laravel.greet' "$worker_log" \ - || ! grep -Fq 'laravel.fail' "$worker_log" \ - || ! grep -Fq 'credential_role=worker' "$worker_log"; then - echo "Laravel worker did not register the qualification handlers and task queue ${queue}." >&2 - cat "$worker_log" >&2 - exit 1 - fi - fi - } - - stop_worker() { - local worker_log="$1" - kill -TERM "$worker_pid" 2>/dev/null || true - local worker_status=0 - wait "$worker_pid" || worker_status=$? - worker_pid='' - if grep -Eq 'worker\.shutdown_failed|HTTP[[:space:]]+403|403[[:space:]]+Forbidden' "$worker_log"; then - echo 'Framework worker reported an authorization or graceful-shutdown failure.' >&2 - cat "$worker_log" >&2 - exit 1 - fi - if [ "$worker_status" -ne 0 ]; then - echo "Framework worker exited with status $worker_status." >&2 - cat "$worker_log" >&2 - exit "$worker_status" - fi - } - - start_python_worker() { - env \ - -u DURABLE_WORKFLOW_TOKEN \ - -u DURABLE_WORKFLOW_CLIENT_TOKEN \ - DURABLE_WORKFLOW_WORKER_TOKEN="$laravel_worker_token" \ - python published-saga-activities.py >python-saga-worker.log 2>&1 & - python_worker_pid=$! - trap cleanup_workers EXIT - sleep 2 - if ! kill -0 "$python_worker_pid" 2>/dev/null; then - echo 'Published Python saga activity worker exited before qualification.' >&2 - cat python-saga-worker.log >&2 - exit 1 - fi - } - - stop_python_worker() { - kill -TERM "$python_worker_pid" 2>/dev/null || true - local python_status=0 - wait "$python_worker_pid" || python_status=$? - python_worker_pid='' - if [ "$python_status" -ne 0 ]; then - echo "Published Python saga activity worker exited with status $python_status." >&2 - cat python-saga-worker.log >&2 - exit "$python_status" - fi - } - - run_client_phase() { - local phase="$1" - if [ "$FRAMEWORK" = laravel ]; then - env \ - -u DURABLE_WORKFLOW_TOKEN \ - -u DURABLE_WORKFLOW_CLIENT_TOKEN \ - -u DURABLE_WORKFLOW_WORKER_TOKEN \ - DURABLE_WORKFLOW_PROCESS_ROLE=client \ - DURABLE_WORKFLOW_PROCESS_TOKEN="$laravel_client_token" \ - PUBLISHED_WORKFLOW_PHASE="$phase" \ - php laravel-role-launch.php durable-workflow:published-greeting - else - PUBLISHED_WORKFLOW_PHASE="$phase" env -u DURABLE_WORKFLOW_WORKER_TOKEN php durable-client.php - fi - } - - run_saga_phase() { - local phase="$1" - env \ - -u DURABLE_WORKFLOW_TOKEN \ - -u DURABLE_WORKFLOW_CLIENT_TOKEN \ - -u DURABLE_WORKFLOW_WORKER_TOKEN \ - DURABLE_WORKFLOW_PROCESS_ROLE=client \ - DURABLE_WORKFLOW_PROCESS_TOKEN="$laravel_client_token" \ - PUBLISHED_SAGA_PHASE="$phase" \ - php laravel-role-launch.php durable-workflow:published-saga - } - - start_worker worker-initial.log - run_client_phase start - stop_worker worker-initial.log - - if [ "$FRAMEWORK" = laravel ]; then - workflow_source='app/Workflows/PublishedGreetingWorkflow.php' - else - workflow_source='src/Workflow/PublishedGreetingWorkflow.php' - fi - sed -i \ - "s/getVersion('published-framework-greeting', 1, 1)/getVersion('published-framework-greeting', 1, 2)/" \ - "$workflow_source" - if ! grep -Fq "getVersion('published-framework-greeting', 1, 2)" "$workflow_source"; then - echo 'Published workflow code upgrade did not raise the supported maximum.' >&2 - exit 1 - fi - - start_worker worker-upgraded.log - run_client_phase finish - stop_worker worker-upgraded.log - - if [ "$FRAMEWORK" = laravel ]; then - start_python_worker - start_worker worker-saga-initial.log - run_saga_phase start - stop_worker worker-saga-initial.log - - start_worker worker-saga-restarted.log - run_saga_phase finish - stop_worker worker-saga-restarted.log - stop_python_worker - fi - - if [ "$FRAMEWORK" = laravel ]; then - DURABLE_WORKFLOW_ROLE_PROBE_LOG="$DURABLE_WORKFLOW_ROLE_PROBE_LOG" php <<'PHP' - json_decode($line, true, flags: JSON_THROW_ON_ERROR), - file(getenv('DURABLE_WORKFLOW_ROLE_PROBE_LOG'), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES), - ); - $observed = []; - foreach ($entries as $entry) { - fwrite(STDOUT, json_encode($entry, JSON_THROW_ON_ERROR).PHP_EOL); - $role = ($entry['role_is_client'] ?? false) ? 'client' : 'worker'; - $stage = $entry['stage'] ?? ''; - $presence = $entry['presence'] ?? []; - foreach (['DURABLE_WORKFLOW_TOKEN', 'DURABLE_WORKFLOW_CLIENT_TOKEN', 'DURABLE_WORKFLOW_WORKER_TOKEN'] as $name) { - if (($presence[$name]['getenv'] ?? null) !== false) { - throw new RuntimeException("Laravel {$role} {$stage} retained ambient {$name}."); - } - } - foreach (['DURABLE_WORKFLOW_PROCESS_ROLE', 'DURABLE_WORKFLOW_PROCESS_TOKEN'] as $name) { - if (($presence[$name]['getenv'] ?? null) !== true) { - throw new RuntimeException("Laravel {$role} {$stage} lost its explicit {$name} handoff."); - } - } - $observed["{$role}:{$stage}"] = true; - } - foreach (['client', 'worker'] as $role) { - foreach (['shell-entry', 'before-bootstrap', 'after-bootstrap'] as $stage) { - if (!isset($observed["{$role}:{$stage}"])) { - throw new RuntimeException("Laravel {$role} did not report credential presence at {$stage}."); - } - } - } - fwrite(STDOUT, json_encode([ - 'laravel_role_probe_complete' => true, - 'entry_count' => count($entries), - ], JSON_THROW_ON_ERROR).PHP_EOL); - PHP - fi - trap - EXIT - BASH - chmod +x "$RUNNER_TEMP/framework-runtime.sh" - - - name: Complete the published workflow against standalone Server - if: matrix.transport == 'job-local-server' - env: - FRAMEWORK: ${{ matrix.framework }} - QUALIFIED_SERVER_VERSION: ${{ inputs.server_version }} - RUNTIME_TARGET: ${{ matrix.runtime }} - SELECTED_TRANSPORT: ${{ matrix.transport }} - run: "$RUNNER_TEMP/framework-runtime.sh" - - - name: Complete the published workflow against managed Cloud - if: matrix.transport == 'protected-cloud' - env: - DURABLE_WORKFLOW_RUNTIME_URL: ${{ secrets.DURABLE_WORKFLOW_SERVER_URL }} - DURABLE_WORKFLOW_NAMESPACE: ${{ secrets.DURABLE_WORKFLOW_NAMESPACE }} - DURABLE_WORKFLOW_CLIENT_TOKEN: ${{ secrets.DURABLE_WORKFLOW_CLIENT_TOKEN }} - DURABLE_WORKFLOW_WORKER_TOKEN: ${{ secrets.DURABLE_WORKFLOW_WORKER_TOKEN }} - FRAMEWORK: ${{ matrix.framework }} - QUALIFIED_SERVER_VERSION: ${{ inputs.server_version }} - RUNTIME_TARGET: ${{ matrix.runtime }} - SELECTED_TRANSPORT: ${{ matrix.transport }} - run: "$RUNNER_TEMP/framework-runtime.sh" - - - name: Stop the standalone Server and isolated state - if: always() && matrix.transport == 'job-local-server' - run: | - docker rm -f \ - "$STANDALONE_SERVER_CONTAINER" \ - "$STANDALONE_REDIS_CONTAINER" \ - "$STANDALONE_MYSQL_CONTAINER" >/dev/null 2>&1 || true - docker network rm "$STANDALONE_RUNTIME_NETWORK" >/dev/null 2>&1 || true diff --git a/README.md b/README.md index 6777e7a..c726aea 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,61 @@ # Durable Workflow PHP SDK -The first-party, framework-neutral PHP SDK for applications and remote workers -that connect to a standalone [Durable Workflow server](https://github.com/durable-workflow/server). -It targets PHP 8.1 or newer and does not require Laravel or the embedded -`durable-workflow/workflow` engine. - -## Choose the PHP execution model - -- **Laravel adoption:** start with the [ownership-first transition - guide](https://php.durable-workflow.com/frameworks/laravel/) when moving a - Laravel Workflow v1 application to v2 embedded or service mode, or when - moving embedded v2 to service mode. Laravel 9 through 13 are supported on - both v2 destinations. -- **Plain PHP service mode:** follow the quickstart below when a framework-neutral - application and remote worker connect to Durable Workflow Cloud or a - self-hosted Server. -- **Symfony service mode:** use the [Symfony bridge](#symfony-service-mode) from - this SDK for autowired remote handlers and a managed console worker. -- **Embedded Laravel workflows:** use - [`durable-workflow/workflow`](https://php.durable-workflow.com/frameworks/laravel/) - when the Laravel application itself should own durable state and execute - through Laravel queues. That is a different deployment model, not a - prerequisite for this SDK. - -## Plain PHP quickstart - -Create an empty Composer project and install the current published package: +

+ CI status + Packagist version + Supported PHP versions + MIT license +

+ +The first-party PHP client and worker SDK for +[Durable Workflow Cloud](https://cloud.durable-workflow.com/early-access) and +self-hosted [Durable Workflow Server](https://github.com/durable-workflow/server). +Use it from plain PHP, Laravel, or Symfony to run durable workflows outside the +application process while keeping framework-native configuration, dependency +injection, commands, logging, and tests. + +## Install ```bash -mkdir durable-php-quickstart -cd durable-php-quickstart -composer init --name=acme/durable-php-quickstart --no-interaction -composer require 'durable-workflow/sdk:^2.0' +composer require durable-workflow/sdk:^2.0 ``` -The stable 2.0 package declares its verified Server baseline in package -metadata. Earlier 2.0 prereleases and pre-1.0 SDK releases remain historical -rather than alternate supported baselines. +The SDK requires PHP 8.1 or newer. It uses the official `apache/avro` package +for portable payloads and accepts any PSR-18 HTTP client. -To install directly from the source repository before a tagged release: +## Choose Your Path -```bash -composer config repositories.durable-workflow-sdk vcs https://github.com/durable-workflow/sdk-php -composer require durable-workflow/sdk:dev-main -``` - -The SDK uses the official [`apache/avro`](https://packagist.org/packages/apache/avro) -package for schema parsing and binary payload encoding. Guzzle is included as -the default PSR-18 transport; any PSR-18 client and PSR-17 factories can be -injected instead. - -### Choose Cloud or Server without rewriting the URL - -Set one runtime URI exactly as provisioned: - -```bash -# Self-hosted Server: pass the bare origin. The SDK appends one /api segment. -export DURABLE_WORKFLOW_RUNTIME_URL='http://localhost:8080' -export DURABLE_WORKFLOW_NAMESPACE='default' - -# Durable Workflow Cloud: instead use both values returned by provisioning. -# export DURABLE_WORKFLOW_RUNTIME_URL='https://cloud.example/api/runtime/v1/namespaces/' -# export DURABLE_WORKFLOW_NAMESPACE='' - -export DURABLE_WORKFLOW_TASK_QUEUE="php-quickstart-$(php -r 'echo bin2hex(random_bytes(8));')" -``` - -The Cloud URL already contains `/api/runtime/v1/namespaces/...`; do not trim -that prefix or replace it with the Cloud control-plane URL. Keep the separately -provisioned Cloud namespace value unchanged as well. The SDK appends its -endpoint `/api` after the namespace runtime URI. For Server, pass an origin such -as `http://localhost:8080`, not `http://localhost:8080/api`, so the request path -contains one `/api` segment rather than two. - -Inject credentials through the process environment or a secret manager. Client -operations and worker polling are separate roles, so keep their variables -separate even when a development Server is configured with one shared token: - -```bash -read -rsp 'Client credential: ' DURABLE_WORKFLOW_CLIENT_TOKEN; echo -export DURABLE_WORKFLOW_CLIENT_TOKEN -read -rsp 'Worker credential: ' DURABLE_WORKFLOW_WORKER_TOKEN; echo -export DURABLE_WORKFLOW_WORKER_TOKEN -``` - -The prompts do not echo values. Do not put these exports in source files, -commit an `.env` file, or print either value in diagnostics. - -### Create the three example files - -`bootstrap.php` resolves Composer consistently when the example is in a clean -project, this SDK checkout, an installed SDK package, or a Sample App -playground/container that copies the files beside its own `vendor/` directory. - - -```php - ```php activity('quickstart.php.greet', [$name]); + $greeting = $context->activity('example.greet', [$name]); return ['greeting' => $greeting]; } @@ -163,539 +63,83 @@ final class GreeterWorkflow final class GreetingActivities { - #[Activity('quickstart.php.greet')] + #[Activity('example.greet')] public function greet(ActivityContext $context, string $name): string { - return "hello, {$name}"; + return "Hello, {$name}"; } } - -$client = new Client( - quickstartEnvironment('DURABLE_WORKFLOW_RUNTIME_URL'), - namespace: quickstartEnvironment('DURABLE_WORKFLOW_NAMESPACE'), - workerToken: quickstartEnvironment('DURABLE_WORKFLOW_WORKER_TOKEN'), -); - -Worker::create($client, quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE')) - ->register(GreeterWorkflow::class, GreetingActivities::class) - ->run(); ``` -`client.php` starts a unique workflow and waits for its completed result. +Register the classes on a task queue and start polling: - ```php -startWorkflow( - workflowType: 'quickstart.php.greeter', - workflowId: $workflowId, - taskQueue: quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE'), - input: ['PHP'], -); - -$result = $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1); - -echo json_encode( - ['workflow_id' => $workflowId, 'result' => $result], - JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES, -).PHP_EOL; -``` - -The files above ship under [`examples/`](examples/). Copy all three into the -new project if you prefer not to create them from the visible blocks. - -### Start the worker, workflow, and result read - -Run the worker in the first terminal with only its role credential: - -```bash -env -u DURABLE_WORKFLOW_CLIENT_TOKEN php worker.php -``` - -In a second terminal with the same runtime, namespace, and task queue, start the -workflow and read the result with only the client credential: - -```bash -env -u DURABLE_WORKFLOW_WORKER_TOKEN php client.php -``` - -The output contains a new workflow ID and this result shape: - -```json -{"workflow_id":"php-quickstart-…","result":{"greeting":"hello, PHP"}} -``` - -Stop the worker with `Ctrl+C` after the client completes. - -`register()` is the preferred class-oriented API: it discovers every attributed -handler in the supplied classes before polling. For generated handlers or other -callable-first code, the direct alternative is explicit and does not use -attributes: - -```php -$worker = Worker::create($client, $taskQueue) - ->registerWorkflow('quickstart.php.greeter', $workflowHandler) - ->registerActivity('quickstart.php.greet', $activityHandler); -``` - -Both callables receive the same `WorkflowContext` and `ActivityContext` values -shown in the class-oriented example. Do not call `register()` with un-attributed -classes; use one complete registration style or the other. - -Release metadata and the machine-readable -[quickstart contract](docs/quickstart-contract.json) together name the exact -package and Server compatibility line, runtime URL forms, role-specific -environment variables, package-resolvable executable sources, expected result, -and public published-artifact qualification identity that keep this path in sync. -For the stable 2.0 release, `product-train` names the exact SDK artifact and -`supported-server-versions` names the exact standalone Server artifact -qualified with it. Earlier prereleases remain historical and do not activate -compatibility shims. - -`npm run qualify:quickstart-contract-deployment` verifies that deployed contract -and its public workflow evidence. Local runs may omit `GITHUB_TOKEN` and use -anonymous GitHub API access, limited to 30 seconds and five redirects, but fail -explicitly if the workflow cannot be verified or the anonymous quota is -exhausted. Supplying a token raises that quota; the qualifier sends it only to -the exact `https://api.github.com` origin while requesting workflow metadata, -never to the portal, schema, web evidence, redirect destinations, or the public -contract itself. - -## Workflow handles and control-plane APIs - -`WorkflowHandle` distinguishes the stable workflow instance from a selected -run. Its ordinary operations follow whichever run is current after a -continue-as-new transition. The `*SelectedRun()` methods retain the original -run guard and fail rather than silently targeting a successor. - -## Control-plane administration and discovery - -`Client::withNamespace()` returns an immutable namespace selection that keeps -the configured authentication and transport. Workflow payload encoding remains -Apache Avro. Workflow -visibility results and the newly covered administrative surfaces use SDK model -types while retaining the complete server payload in each model's `raw` -property. - -```php -use DurableWorkflow\Model\ServiceOperationOptions; - -$orders = $client->withNamespace('orders-prod'); - -$page = $orders->listWorkflows( - workflowType: 'orders.process', - status: 'running', - query: 'CustomerId = "42"', - pageSize: 25, -); -$schedulePage = $orders->listSchedules( - status: 'paused', - workflowType: 'reports.rollup', - query: 'Region = "eu-west"', - pageSize: 25, -); - -$attributes = $orders->listSearchAttributes(); -$orders->createSearchAttribute('OrderTotal', 'double'); -$orders->deleteSearchAttribute('TemporaryField'); - -$orders->setNamespaceExternalStorage( - 'orders-prod', - 's3', - thresholdBytes: 2 * 1024 * 1024, - config: ['bucket' => 'workflow-payloads'], + getenv('DURABLE_WORKFLOW_RUNTIME_URL'), + namespace: getenv('DURABLE_WORKFLOW_NAMESPACE'), + workerToken: getenv('DURABLE_WORKFLOW_WORKER_TOKEN'), ); -$operation = $orders->startServiceOperation( - 'payments', - 'Cards', - 'authorize', - ['amount' => 4200, 'currency' => 'USD'], - new ServiceOperationOptions(idempotencyKey: 'order-42-authorization'), -); - -$call = $operation->describe(); -$operation->cancel('customer request'); -$cluster = $orders->clusterInfo(); -``` - -`listSchedules()` returns a typed page with `schedules`, `nextPageToken`, and -the original response in `raw`. It supports server-side `status`, workflow -type, visibility-query, page-size, and continuation-token filtering. Pass a -non-null `nextPageToken` back unchanged with the same namespace and filters to -read the next page. See the protocol guide for the paging and error contract. - -`startServiceOperation()` explicitly starts an asynchronous call and returns a -`ServiceOperationHandle`. `executeServiceOperation()` honors the catalog mode, -waits for completion by default, and returns a `ServiceOperationDescription`. -Arguments use the official Apache Avro payload codec. - -## Run a remote PHP worker - -The preferred service-mode API discovers handler contracts from ordinary PHP -classes. Attributes name the server contract while method signatures describe -its arguments. Calls on `WorkflowContext` suspend the workflow's Fiber at each -durable step. Replay resumes that call with its recorded value, or throws its -recorded failure there, without repeating external work. - -```php -activity('greet', [$name]); - - return ['greeting' => $greeting]; - } - - #[Query] - public function status(QueryContext $context): array - { - return ['events' => count($context->history)]; - } - - #[Signal('set-language')] - public function setLanguage(string $language): void - { - // This declaration is reflected for admission; run() consumes signals. - } - - #[Update] - public function rename(QueryContext $context, string $name): string - { - return $name; - } -} - -final class GreetingActivities -{ - #[Activity] - public function greet(ActivityContext $context, string $name): string - { - return "hello, {$name}"; - } -} - -$client = new Client('http://server:8080', token: 'dev-token-123'); - -Worker::create($client, 'php-workers') - ->register(GreeterWorkflow::class, GreetingActivities::class) +Worker::create($client, 'greetings') + ->register(GreetingWorkflow::class, GreetingActivities::class) ->run(); ``` -`register()` resolves class names once and validates every attributed method -before registration or polling. With no container, concrete classes with no -required constructor arguments are instantiated automatically. Pass any PSR-11 -`ContainerInterface` as the third argument to `Worker::create()` when handlers -have application dependencies. - -Attributed workflow classes have a replay-scoped lifecycle. Registration -captures a clean handler template, then each workflow task replay, query, and -update runs on a fresh shallow clone. Mutable properties on the workflow object -therefore cannot cross workflow IDs, runs, or replay attempts, while -constructor-injected collaborators retain their configured identity. Keep -workflow-local mutable state directly on the handler; injected collaborators -are shared services and must not be used to hold execution-local state. -Workflow handler classes must remain cloneable. - -Activity services have worker-scoped lifetimes instead: their resolved instance -is reused for activity tasks, so they can retain clients and other service -resources. The explicit `registerWorkflow()`, `registerQuery()`, and -`registerUpdate()` low-level methods also invoke the supplied callable as-is; -state captured by such a callable remains owned by the application. Use -attribute-based workflow registration when the SDK should provide replay-state -isolation. - -Pass a PSR-3 `LoggerInterface` with the named `logger` argument; lifecycle, -retry, shutdown, and handler failures then use the application's normal logging -pipeline. The optional `diagnosticListener` receives the same event names and -structured context. - -Signal methods are signature declarations for server admission and are not -invoked. The workflow reads their committed values with -`$context->signals('set-language')` during replay. Query and update methods are -executed with immutable `QueryContext` state. - -The PHP SDK does not expose update-validator authoring. Worker registration -therefore declares an empty `update_validators` list for every workflow type; -capability discovery and Server admission must not infer validator parity from -the presence of ordinary update handlers. - -The callable registration methods remain the intentional low-level escape -hatch. For example, replay-consumed signals can be declared directly: +Start the workflow from a client process using a client-role credential: ```php -$worker->declareSignal( - 'counter', - 'increment', - static fn (int $amount): mixed => null, +$client = new Client( + getenv('DURABLE_WORKFLOW_RUNTIME_URL'), + namespace: getenv('DURABLE_WORKFLOW_NAMESPACE'), + controlToken: getenv('DURABLE_WORKFLOW_CLIENT_TOKEN'), ); -``` - -Call `$context->heartbeat($details)` from a long-running activity. It throws -`ActivityCancelled` when the server requests cancellation. `Worker::run()` -installs SIGINT/SIGTERM handlers when `pcntl` is available, stops accepting new -tasks, and lets the active synchronous task settle before returning. The -managed worker also returns when any task poll reports a terminal typed outcome -such as `stale_worker_registration`, `draining`, or `stopped`; empty and timeout -polls remain idle. Registration also negotiates the worker heartbeat cadence. -Managed long polls are bounded by that cadence and heartbeat checks run between -workflow, activity, and query polls, so an idle polling cycle cannot silently -consume the server's registration freshness window. Invalid advertised cadence -values leave the worker's configured safe fallback in effect. - -Low-level worker integrations can call `pollWorkflowTaskResponse()`, -`pollActivityTaskResponse()`, and `pollQueryTaskResponse()` to receive the -complete server envelope, including `poll_status`, `reason`, protocol metadata, -and any future fields. The existing task-only poll methods delegate to these -response methods and still return the leased task or `null`. Use -`DurableWorkflow\Worker\PollResponse::isTerminal()` to apply the same typed -terminal-outcome classification as the managed worker. - -When a poll fails with a complete worker-protocol envelope explicitly marked as -transient, the managed worker retries that same poll with capped backoff while -keeping heartbeats and graceful shutdown responsive. Pass a -`transientPollRetryObserver` callback to the `Worker` constructor to record the -task kind, consecutive attempt, selected delay, and typed server exception. -Authentication failures, malformed responses, and generic server errors remain -fatal. - -## Receive repeated input with Message Streams - -Inbound Message Streams deliver repeated, ordered application input to a stable -workflow instance. The application appends a stable message identity through -`WorkflowHandle::appendMessage()`, while workflow code consumes one message or -a bounded ordered batch through `messageStream()`. Runtime-owned cursors survive -replay, worker replacement, server restart, and continue-as-new. - -See the task-oriented [Message Streams guide](https://php.durable-workflow.com/build/message-streams/) -and the shipped [client](examples/message-stream-client.php) and -[worker](examples/message-stream-worker.php) examples. - -## Laravel service mode - -Laravel 9 through 13 auto-discover the service provider from the same SDK package. -Publish the environment-backed configuration, add attributed handler services, -and start the supervised Artisan command: - -```bash -composer require 'durable-workflow/sdk:^2.0' -php artisan vendor:publish --tag=durable-workflow-config -php artisan config:cache -php artisan durable-workflow:worker -``` - -Set `DURABLE_WORKFLOW_RUNTIME_URL` to a self-hosted Server origin or the complete -Cloud runtime base URI. Set `DURABLE_WORKFLOW_NAMESPACE` and -`DURABLE_WORKFLOW_TASK_QUEUE`, then choose shared-token or scoped authentication. -For scoped Cloud authentication, inject credentials at the process boundary: - -| Laravel process | Inject | Do not inject | -| --- | --- | --- | -| Web, queue, or other application process | `DURABLE_WORKFLOW_CLIENT_TOKEN` | `DURABLE_WORKFLOW_WORKER_TOKEN` | -| `php artisan durable-workflow:worker` | `DURABLE_WORKFLOW_WORKER_TOKEN` | `DURABLE_WORKFLOW_CLIENT_TOKEN` | - -The service provider gives the injectable application interfaces only the client -credential and creates a separate worker client only for the worker factory. -For a self-hosted deployment that uses one credential for both roles, inject -`DURABLE_WORKFLOW_TOKEN` instead. Supply secret values through the deployment -platform's process environment or secret store, not a generated configuration -file or a committed `.env` file. - -The published configuration deliberately has no credential entries. Build and -deploy one cached configuration without any Durable Workflow credential in the -cache-building environment, then inject only the required role credential when -each application or worker process starts. Resolving either documented client -interface is credential-lazy; the provider resolves the client credential when -the interface performs its first application-client operation, after Laravel has -loaded the cache. Resolving `Client` directly is the explicit eager low-level -path and validates the application credential immediately. -Applications upgrading an older published configuration should republish it or -remove its `credentials` block before rebuilding the cache. List handler classes -in `config/durable-workflow.php`: -```php -'handlers' => [ - App\Workflows\GreeterWorkflow::class, - App\Activities\GreetingActivities::class, -], -``` - -Laravel resolves every handler through its container, so ordinary constructor -injection works. Inject `LaravelWorkflowClientInterface` to start an attributed -workflow service class on the configured default queue; explicit IDs and -`WorkflowStartOptions` remain available. Inject `WorkflowClientInterface` for -low-level cross-language string contracts; like the Laravel-shaped interface, -it is safe to constructor-inject into services that may be discovered by a -worker-only Artisan process. Inject `Client` directly only when eager application -credential validation and its broader concrete API are intentional. -Worker diagnostics use Laravel's PSR logger and dispatch -`WorkerDiagnosticEvent` through Laravel events. The event name is available in -its `name` property and includes lifecycle, retry, handler-failure, and shutdown -events. After server registration, Artisan prints a registered-and-polling line -with the runtime host, namespace, queue, workflow and activity types, and -credential role; it never includes credential values. - -In a Laravel test, `DurableWorkflow::fake()` replaces the class-shaped Laravel -client and its low-level transport. It returns `LaravelWorkflowClientFake` for -result setup and service-class interaction assertions: - -```php -$workflows = DurableWorkflow::fake() - ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']); - -// Exercise application code, then use the framework-independent assertions. -$workflows->assertWorkflowStarted(GreeterWorkflow::class, ['Ada']); -``` - -## Symfony service mode - -Symfony 6.4, 7, and 8 applications register the Bundle from the SDK package in -`config/bundles.php`: +$handle = $client->startWorkflow( + workflowType: 'example.greeting', + workflowId: 'greeting-'.bin2hex(random_bytes(12)), + taskQueue: 'greetings', + input: ['PHP'], +); -```php -return [ - // ... - DurableWorkflow\Bridge\Symfony\DurableWorkflowBundle::class => ['all' => true], -]; +var_dump($handle->result()); ``` -Configure Server or Cloud through environment processors. Attributed services -under Symfony's normal autoconfigured imports are registered as handlers. The -optional `handlers` list also registers classes outside those imports as -autowired services: - -```yaml -# config/packages/durable_workflow.yaml -durable_workflow: - endpoint: '%env(DURABLE_WORKFLOW_RUNTIME_URL)%' - namespace: '%env(DURABLE_WORKFLOW_NAMESPACE)%' - task_queue: '%env(DURABLE_WORKFLOW_TASK_QUEUE)%' - credentials: - control_token: '%env(default::DURABLE_WORKFLOW_CLIENT_TOKEN)%' - worker_token: '%env(default::DURABLE_WORKFLOW_WORKER_TOKEN)%' - handlers: - - App\Workflow\GreeterWorkflow - - App\Activity\GreetingActivities -``` +For Cloud, use the complete namespace runtime URL exactly as provisioned. For +self-hosted Server, use its origin such as `http://localhost:8080`. Keep client +and worker credentials in separate processes. -Inject `DURABLE_WORKFLOW_CLIENT_TOKEN` only into web and other application -processes. Inject `DURABLE_WORKFLOW_WORKER_TOKEN` only into the process running -`php bin/console durable-workflow:worker`; the `default::` processors leave the -opposite scoped credential unset. The Bundle binds the public autowired client -to the client credential and gives its private worker client only the worker -credential. Self-hosted deployments can instead set `credentials.token` from -`DURABLE_WORKFLOW_TOKEN` and inject that shared credential into both -processes. Keep values in the deployment platform's environment or secret store, -not YAML, generated container files, or committed environment files. - -Run `php bin/console durable-workflow:worker`. `Client` and -`WorkflowClientInterface` are public autowired services. Handler services retain -normal Symfony autowiring, worker messages use the standard PSR logger when it -is installed, and `WorkerDiagnosticEvent` is dispatched through Symfony's event -dispatcher under the diagnostic name. A `KernelTestCase` can use -`InteractsWithDurableWorkflow::fakeDurableWorkflow()` to replace the autowired -interface with the same assertion-capable fake used by plain PHP and Laravel. - -Both console commands accept `--queue` and `--poll-timeout`. They require -`ext-pcntl` so SIGINT and SIGTERM always request a graceful worker shutdown. -Configuration errors, an unreachable endpoint, rejected credentials, and -worker-protocol or contract mismatches are reported with remediation specific -to the failing boundary. Neither bridge stores workflow state or installs the -embedded Laravel workflow engine. - -## Test workflow code and interactions - -The testing namespace has no PHPUnit dependency. Its assertions throw -`DurableWorkflow\Testing\AssertionFailed`, so they work with PHPUnit, Pest, or -plain PHP. Application services can type their dependency as -`WorkflowClientInterface`; both the network `Client` and `WorkflowClientFake` -implement that interface and return handles with the same interaction methods. +The complete [plain PHP quickstart](https://php.durable-workflow.com/getting-started/first-workflow/) +includes the three runnable files, environment setup, expected output, and +common failure diagnostics. -```php -use DurableWorkflow\Testing\WorkerTestHarness; -use DurableWorkflow\Testing\WorkflowClientFake; - -$worker = Worker::create($client, 'php-workers') - ->register(GreeterWorkflow::class, GreetingActivities::class); -$handlers = new WorkerTestHarness($worker); -$handlers->assertWorkflowEmits('greeter', 'schedule_activity', ['Ada']); -$handlers->assertActivityResult('greet', 'hello, Ada', ['Ada']); -$handlers->assertQueryResult('greeter', 'status', ['events' => 0]); -$handlers->assertUpdateResult('greeter', 'rename', 'Grace', ['Grace']); -$handlers->assertRegistered('signal', 'set-language', 'greeter'); - -$workflows = (new WorkflowClientFake()) - ->setQueryResult('greeting-1', 'status', 'running') - ->setUpdateResult('greeting-1', 'rename', 'accepted') - ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']); -$handle = $workflows->startWorkflow('greeter', 'greeting-1', 'php-workers', ['Ada']); -$handle->signal('set-language', ['en']); -$handle->query('status'); -$handle->update('rename', ['Grace']); -$handle->result(); - -$workflows->assertWorkflowStarted('greeter', ['Ada']); -$workflows->assertSignalSent('greeting-1', 'set-language', ['en']); -$workflows->assertQueryRequested('greeting-1', 'status'); -$workflows->assertUpdateRequested('greeting-1', 'rename', ['Grace']); -$workflows->assertResultRequested('greeting-1'); -``` +## Capabilities -Workflow tasks additionally require an acknowledged lease renewal before user -code runs. Typed transient renewal pressure is retried with the original task -ID, attempt, and lease owner; shutdown or a terminal/lost lease prevents task -execution and completion. +- Workflows, activities, child workflows, timers, retries, and heartbeats +- Signals, queries, updates, condition waits, and message streams +- Parallel work, sagas, cancellation, continue-as-new, and version markers +- Schedules, search attributes, memo, external payloads, and worker versioning +- Replay testing, in-memory client fakes, Laravel and Symfony test helpers +- Stable workflow handles and machine-readable runtime diagnostics -See [`examples/`](examples), the authored -[PHP developer portal](https://php.durable-workflow.com/), the generated -[API reference](https://php.durable-workflow.com/api/), and -[`docs/protocol.md`](docs/protocol.md) for the complete client, schedule, -namespace, visibility, search-attribute, service-operation, discovery, -authentication, worker, query, and update surfaces. +See the [complete SDK reference](docs/sdk-reference.md) for control-plane APIs, +worker configuration, Message Streams, framework setup, and testing examples. +The generated [API reference](https://php.durable-workflow.com/api/) documents +every public class and method. ## Development ```bash composer install -composer validate --strict composer test composer analyse -composer docs +composer benchmark-avro-value ``` -The dependency-boundary check rejects Laravel, Illuminate, the embedded -workflow package, and the standalone server package in both declared and -resolved production dependencies. +See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution and validation details. + +## License + +MIT diff --git a/composer.json b/composer.json index 4140dca..99bdd07 100644 --- a/composer.json +++ b/composer.json @@ -1,10 +1,10 @@ { "name": "durable-workflow/sdk", - "description": "Framework-neutral PHP client and worker SDK for the Durable Workflow server", + "description": "PHP client and worker SDK for Durable Workflow Cloud and self-hosted Server", "type": "library", "license": "MIT", - "keywords": ["durable", "workflow", "orchestration", "worker", "sdk"], - "homepage": "https://durable-workflow.com", + "keywords": ["cloud", "durable", "laravel", "orchestration", "php", "sdk", "symfony", "worker", "workflow"], + "homepage": "https://php.durable-workflow.com/", "support": { "docs": "https://php.durable-workflow.com/", "issues": "https://github.com/durable-workflow/sdk-php/issues", diff --git a/docs/portal/frameworks/laravel.md b/docs/portal/frameworks/laravel.md index c6571fb..13bf6d3 100644 --- a/docs/portal/frameworks/laravel.md +++ b/docs/portal/frameworks/laravel.md @@ -51,10 +51,9 @@ when it intentionally overrides the Laravel defaults. The public [Laravel adoption contract](/laravel-adoption-contract.json) is the machine-readable authority for this matrix, supported Laravel/PHP cells, -continuity surfaces, and qualification destinations. It uses release channels, -not copied prerelease sequence numbers. Its embedded transition row references -the transition manifest shipped by `durable-workflow/workflow`; the service SDK -does not publish a second embedded support policy. +continuity surfaces, and qualification destinations. Its embedded transition +row references the transition manifest shipped by `durable-workflow/workflow`, +which owns the embedded support policy. ## Plan continuity before changing packages diff --git a/docs/sdk-reference.md b/docs/sdk-reference.md new file mode 100644 index 0000000..6fed831 --- /dev/null +++ b/docs/sdk-reference.md @@ -0,0 +1,685 @@ +# PHP SDK Reference + +This is the detailed repository reference for the first-party PHP client and +worker SDK. Start with the [root README](../README.md) or the +[PHP developer portal](https://php.durable-workflow.com/) for the shortest +runnable path. + +## Choose the PHP execution model + +- **Laravel adoption:** start with the [ownership-first transition + guide](https://php.durable-workflow.com/frameworks/laravel/) when moving a + Laravel Workflow v1 application to v2 embedded or service mode, or when + moving embedded v2 to service mode. Laravel 9 through 13 are supported on + both v2 destinations. +- **Plain PHP service mode:** follow the quickstart below when a framework-neutral + application and remote worker connect to Durable Workflow Cloud or a + self-hosted Server. +- **Symfony service mode:** use the [Symfony bridge](#symfony-service-mode) from + this SDK for autowired remote handlers and a managed console worker. +- **Embedded Laravel workflows:** use + [`durable-workflow/workflow`](https://php.durable-workflow.com/frameworks/laravel/) + when the Laravel application itself should own durable state and execute + through Laravel queues. That is a different deployment model, not a + prerequisite for this SDK. + +## Plain PHP quickstart + +Create an empty Composer project and install the current published package: + +```bash +mkdir durable-php-quickstart +cd durable-php-quickstart +composer init --name=acme/durable-php-quickstart --no-interaction +composer require 'durable-workflow/sdk:^2.0' +``` + +The package declares its supported Server baseline in Composer metadata. + +Contributors testing the current source branch can install it directly: + +```bash +composer config repositories.durable-workflow-sdk vcs https://github.com/durable-workflow/sdk-php +composer require durable-workflow/sdk:dev-main +``` + +The SDK uses the official [`apache/avro`](https://packagist.org/packages/apache/avro) +package for schema parsing and binary payload encoding. Guzzle is included as +the default PSR-18 transport; any PSR-18 client and PSR-17 factories can be +injected instead. + +### Choose Cloud or Server without rewriting the URL + +Set one runtime URI exactly as provisioned: + +```bash +# Self-hosted Server: pass the bare origin. The SDK appends one /api segment. +export DURABLE_WORKFLOW_RUNTIME_URL='http://localhost:8080' +export DURABLE_WORKFLOW_NAMESPACE='default' + +# Durable Workflow Cloud: instead use both values returned by provisioning. +# export DURABLE_WORKFLOW_RUNTIME_URL='https://cloud.example/api/runtime/v1/namespaces/' +# export DURABLE_WORKFLOW_NAMESPACE='' + +export DURABLE_WORKFLOW_TASK_QUEUE="php-quickstart-$(php -r 'echo bin2hex(random_bytes(8));')" +``` + +The Cloud URL already contains `/api/runtime/v1/namespaces/...`; do not trim +that prefix or replace it with the Cloud control-plane URL. Keep the separately +provisioned Cloud namespace value unchanged as well. The SDK appends its +endpoint `/api` after the namespace runtime URI. For Server, pass an origin such +as `http://localhost:8080`, not `http://localhost:8080/api`, so the request path +contains one `/api` segment rather than two. + +Inject credentials through the process environment or a secret manager. Client +operations and worker polling are separate roles, so keep their variables +separate even when a development Server is configured with one shared token: + +```bash +read -rsp 'Client credential: ' DURABLE_WORKFLOW_CLIENT_TOKEN; echo +export DURABLE_WORKFLOW_CLIENT_TOKEN +read -rsp 'Worker credential: ' DURABLE_WORKFLOW_WORKER_TOKEN; echo +export DURABLE_WORKFLOW_WORKER_TOKEN +``` + +The prompts do not echo values. Do not put these exports in source files, +commit an `.env` file, or print either value in diagnostics. + +### Create the three example files + +`bootstrap.php` resolves Composer consistently when the example is in a clean +project, this SDK checkout, an installed SDK package, or a Sample App +playground/container that copies the files beside its own `vendor/` directory. + + +```php + +```php +activity('quickstart.php.greet', [$name]); + + return ['greeting' => $greeting]; + } +} + +final class GreetingActivities +{ + #[Activity('quickstart.php.greet')] + public function greet(ActivityContext $context, string $name): string + { + return "hello, {$name}"; + } +} + +$client = new Client( + quickstartEnvironment('DURABLE_WORKFLOW_RUNTIME_URL'), + namespace: quickstartEnvironment('DURABLE_WORKFLOW_NAMESPACE'), + workerToken: quickstartEnvironment('DURABLE_WORKFLOW_WORKER_TOKEN'), +); + +Worker::create($client, quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE')) + ->register(GreeterWorkflow::class, GreetingActivities::class) + ->run(); +``` + +`client.php` starts a unique workflow and waits for its completed result. + + +```php +startWorkflow( + workflowType: 'quickstart.php.greeter', + workflowId: $workflowId, + taskQueue: quickstartEnvironment('DURABLE_WORKFLOW_TASK_QUEUE'), + input: ['PHP'], +); + +$result = $handle->result(timeoutSeconds: 90, pollIntervalSeconds: 1); + +echo json_encode( + ['workflow_id' => $workflowId, 'result' => $result], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES, +).PHP_EOL; +``` + +The files above ship under [`examples/`](../examples/). Copy all three into the +new project if you prefer not to create them from the visible blocks. + +### Start the worker, workflow, and result read + +Run the worker in the first terminal with only its role credential: + +```bash +env -u DURABLE_WORKFLOW_CLIENT_TOKEN php worker.php +``` + +In a second terminal with the same runtime, namespace, and task queue, start the +workflow and read the result with only the client credential: + +```bash +env -u DURABLE_WORKFLOW_WORKER_TOKEN php client.php +``` + +The output contains a new workflow ID and this result shape: + +```json +{"workflow_id":"php-quickstart-…","result":{"greeting":"hello, PHP"}} +``` + +Stop the worker with `Ctrl+C` after the client completes. + +`register()` is the preferred class-oriented API: it discovers every attributed +handler in the supplied classes before polling. For generated handlers or other +callable-first code, the direct alternative is explicit and does not use +attributes: + +```php +$worker = Worker::create($client, $taskQueue) + ->registerWorkflow('quickstart.php.greeter', $workflowHandler) + ->registerActivity('quickstart.php.greet', $activityHandler); +``` + +Both callables receive the same `WorkflowContext` and `ActivityContext` values +shown in the class-oriented example. Do not call `register()` with un-attributed +classes; use one complete registration style or the other. + +The machine-readable [quickstart contract](quickstart-contract.json) records +the supported runtime URL forms, role-specific environment variables, +package-owned source files, and expected result. Documentation builds verify +that the visible examples still match those runnable files. + +## Workflow handles and control-plane APIs + +`WorkflowHandle` distinguishes the stable workflow instance from a selected +run. Its ordinary operations follow whichever run is current after a +continue-as-new transition. The `*SelectedRun()` methods retain the original +run guard and fail rather than silently targeting a successor. + +## Control-plane administration and discovery + +`Client::withNamespace()` returns an immutable namespace selection that keeps +the configured authentication and transport. Workflow payload encoding remains +Apache Avro. Workflow +visibility results and the newly covered administrative surfaces use SDK model +types while retaining the complete server payload in each model's `raw` +property. + +```php +use DurableWorkflow\Model\ServiceOperationOptions; + +$orders = $client->withNamespace('orders-prod'); + +$page = $orders->listWorkflows( + workflowType: 'orders.process', + status: 'running', + query: 'CustomerId = "42"', + pageSize: 25, +); +$schedulePage = $orders->listSchedules( + status: 'paused', + workflowType: 'reports.rollup', + query: 'Region = "eu-west"', + pageSize: 25, +); + +$attributes = $orders->listSearchAttributes(); +$orders->createSearchAttribute('OrderTotal', 'double'); +$orders->deleteSearchAttribute('TemporaryField'); + +$orders->setNamespaceExternalStorage( + 'orders-prod', + 's3', + thresholdBytes: 2 * 1024 * 1024, + config: ['bucket' => 'workflow-payloads'], +); + +$operation = $orders->startServiceOperation( + 'payments', + 'Cards', + 'authorize', + ['amount' => 4200, 'currency' => 'USD'], + new ServiceOperationOptions(idempotencyKey: 'order-42-authorization'), +); + +$call = $operation->describe(); +$operation->cancel('customer request'); +$cluster = $orders->clusterInfo(); +``` + +`listSchedules()` returns a typed page with `schedules`, `nextPageToken`, and +the original response in `raw`. It supports server-side `status`, workflow +type, visibility-query, page-size, and continuation-token filtering. Pass a +non-null `nextPageToken` back unchanged with the same namespace and filters to +read the next page. See the protocol guide for the paging and error contract. + +`startServiceOperation()` explicitly starts an asynchronous call and returns a +`ServiceOperationHandle`. `executeServiceOperation()` honors the catalog mode, +waits for completion by default, and returns a `ServiceOperationDescription`. +Arguments use the official Apache Avro payload codec. + +## Run a remote PHP worker + +The preferred service-mode API discovers handler contracts from ordinary PHP +classes. Attributes name the server contract while method signatures describe +its arguments. Calls on `WorkflowContext` suspend the workflow's Fiber at each +durable step. Replay resumes that call with its recorded value, or throws its +recorded failure there, without repeating external work. + +```php +activity('greet', [$name]); + + return ['greeting' => $greeting]; + } + + #[Query] + public function status(QueryContext $context): array + { + return ['events' => count($context->history)]; + } + + #[Signal('set-language')] + public function setLanguage(string $language): void + { + // This declaration is reflected for admission; run() consumes signals. + } + + #[Update] + public function rename(QueryContext $context, string $name): string + { + return $name; + } +} + +final class GreetingActivities +{ + #[Activity] + public function greet(ActivityContext $context, string $name): string + { + return "hello, {$name}"; + } +} + +$client = new Client('http://server:8080', token: 'dev-token-123'); + +Worker::create($client, 'php-workers') + ->register(GreeterWorkflow::class, GreetingActivities::class) + ->run(); +``` + +`register()` resolves class names once and validates every attributed method +before registration or polling. With no container, concrete classes with no +required constructor arguments are instantiated automatically. Pass any PSR-11 +`ContainerInterface` as the third argument to `Worker::create()` when handlers +have application dependencies. + +Attributed workflow classes have a replay-scoped lifecycle. Registration +captures a clean handler template, then each workflow task replay, query, and +update runs on a fresh shallow clone. Mutable properties on the workflow object +therefore cannot cross workflow IDs, runs, or replay attempts, while +constructor-injected collaborators retain their configured identity. Keep +workflow-local mutable state directly on the handler; injected collaborators +are shared services and must not be used to hold execution-local state. +Workflow handler classes must remain cloneable. + +Activity services have worker-scoped lifetimes instead: their resolved instance +is reused for activity tasks, so they can retain clients and other service +resources. The explicit `registerWorkflow()`, `registerQuery()`, and +`registerUpdate()` low-level methods also invoke the supplied callable as-is; +state captured by such a callable remains owned by the application. Use +attribute-based workflow registration when the SDK should provide replay-state +isolation. + +Pass a PSR-3 `LoggerInterface` with the named `logger` argument; lifecycle, +retry, shutdown, and handler failures then use the application's normal logging +pipeline. The optional `diagnosticListener` receives the same event names and +structured context. + +Signal methods are signature declarations for server admission and are not +invoked. The workflow reads their committed values with +`$context->signals('set-language')` during replay. Query and update methods are +executed with immutable `QueryContext` state. + +The PHP SDK does not expose update-validator authoring. Worker registration +therefore declares an empty `update_validators` list for every workflow type; +capability discovery and Server admission must not infer validator parity from +the presence of ordinary update handlers. + +The callable registration methods remain the intentional low-level escape +hatch. For example, replay-consumed signals can be declared directly: + +```php +$worker->declareSignal( + 'counter', + 'increment', + static fn (int $amount): mixed => null, +); +``` + +Call `$context->heartbeat($details)` from a long-running activity. It throws +`ActivityCancelled` when the server requests cancellation. `Worker::run()` +installs SIGINT/SIGTERM handlers when `pcntl` is available, stops accepting new +tasks, and lets the active synchronous task settle before returning. The +managed worker also returns when any task poll reports a terminal typed outcome +such as `stale_worker_registration`, `draining`, or `stopped`; empty and timeout +polls remain idle. Registration also negotiates the worker heartbeat cadence. +Managed long polls are bounded by that cadence and heartbeat checks run between +workflow, activity, and query polls, so an idle polling cycle cannot silently +consume the server's registration freshness window. Invalid advertised cadence +values leave the worker's configured safe fallback in effect. + +Low-level worker integrations can call `pollWorkflowTaskResponse()`, +`pollActivityTaskResponse()`, and `pollQueryTaskResponse()` to receive the +complete server envelope, including `poll_status`, `reason`, protocol metadata, +and any future fields. The existing task-only poll methods delegate to these +response methods and still return the leased task or `null`. Use +`DurableWorkflow\Worker\PollResponse::isTerminal()` to apply the same typed +terminal-outcome classification as the managed worker. + +When a poll fails with a complete worker-protocol envelope explicitly marked as +transient, the managed worker retries that same poll with capped backoff while +keeping heartbeats and graceful shutdown responsive. Pass a +`transientPollRetryObserver` callback to the `Worker` constructor to record the +task kind, consecutive attempt, selected delay, and typed server exception. +Authentication failures, malformed responses, and generic server errors remain +fatal. + +## Receive repeated input with Message Streams + +Inbound Message Streams deliver repeated, ordered application input to a stable +workflow instance. The application appends a stable message identity through +`WorkflowHandle::appendMessage()`, while workflow code consumes one message or +a bounded ordered batch through `messageStream()`. Runtime-owned cursors survive +replay, worker replacement, server restart, and continue-as-new. + +See the task-oriented [Message Streams guide](https://php.durable-workflow.com/build/message-streams/) +and the shipped [client](../examples/message-stream-client.php) and +[worker](../examples/message-stream-worker.php) examples. + +## Laravel service mode + +Laravel 9 through 13 auto-discover the service provider from the same SDK package. +Publish the environment-backed configuration, add attributed handler services, +and start the supervised Artisan command: + +```bash +composer require 'durable-workflow/sdk:^2.0' +php artisan vendor:publish --tag=durable-workflow-config +php artisan config:cache +php artisan durable-workflow:worker +``` + +Set `DURABLE_WORKFLOW_RUNTIME_URL` to a self-hosted Server origin or the complete +Cloud runtime base URI. Set `DURABLE_WORKFLOW_NAMESPACE` and +`DURABLE_WORKFLOW_TASK_QUEUE`, then choose shared-token or scoped authentication. +For scoped Cloud authentication, inject credentials at the process boundary: + +| Laravel process | Inject | Do not inject | +| --- | --- | --- | +| Web, queue, or other application process | `DURABLE_WORKFLOW_CLIENT_TOKEN` | `DURABLE_WORKFLOW_WORKER_TOKEN` | +| `php artisan durable-workflow:worker` | `DURABLE_WORKFLOW_WORKER_TOKEN` | `DURABLE_WORKFLOW_CLIENT_TOKEN` | + +The service provider gives the injectable application interfaces only the client +credential and creates a separate worker client only for the worker factory. +For a self-hosted deployment that uses one credential for both roles, inject +`DURABLE_WORKFLOW_TOKEN` instead. Supply secret values through the deployment +platform's process environment or secret store, not a generated configuration +file or a committed `.env` file. + +The published configuration deliberately has no credential entries. Build and +deploy one cached configuration without any Durable Workflow credential in the +cache-building environment, then inject only the required role credential when +each application or worker process starts. Resolving either documented client +interface is credential-lazy; the provider resolves the client credential when +the interface performs its first application-client operation, after Laravel has +loaded the cache. Resolving `Client` directly is the explicit eager low-level +path and validates the application credential immediately. +Applications upgrading an older published configuration should republish it or +remove its `credentials` block before rebuilding the cache. List handler classes +in `config/durable-workflow.php`: + +```php +'handlers' => [ + App\Workflows\GreeterWorkflow::class, + App\Activities\GreetingActivities::class, +], +``` + +Laravel resolves every handler through its container, so ordinary constructor +injection works. Inject `LaravelWorkflowClientInterface` to start an attributed +workflow service class on the configured default queue; explicit IDs and +`WorkflowStartOptions` remain available. Inject `WorkflowClientInterface` for +low-level cross-language string contracts; like the Laravel-shaped interface, +it is safe to constructor-inject into services that may be discovered by a +worker-only Artisan process. Inject `Client` directly only when eager application +credential validation and its broader concrete API are intentional. +Worker diagnostics use Laravel's PSR logger and dispatch +`WorkerDiagnosticEvent` through Laravel events. The event name is available in +its `name` property and includes lifecycle, retry, handler-failure, and shutdown +events. After server registration, Artisan prints a registered-and-polling line +with the runtime host, namespace, queue, workflow and activity types, and +credential role; it never includes credential values. + +In a Laravel test, `DurableWorkflow::fake()` replaces the class-shaped Laravel +client and its low-level transport. It returns `LaravelWorkflowClientFake` for +result setup and service-class interaction assertions: + +```php +$workflows = DurableWorkflow::fake() + ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']); + +// Exercise application code, then use the framework-independent assertions. +$workflows->assertWorkflowStarted(GreeterWorkflow::class, ['Ada']); +``` + +## Symfony service mode + +Symfony 6.4, 7, and 8 applications register the Bundle from the SDK package in +`config/bundles.php`: + +```php +return [ + // ... + DurableWorkflow\Bridge\Symfony\DurableWorkflowBundle::class => ['all' => true], +]; +``` + +Configure Server or Cloud through environment processors. Attributed services +under Symfony's normal autoconfigured imports are registered as handlers. The +optional `handlers` list also registers classes outside those imports as +autowired services: + +```yaml +# config/packages/durable_workflow.yaml +durable_workflow: + endpoint: '%env(DURABLE_WORKFLOW_RUNTIME_URL)%' + namespace: '%env(DURABLE_WORKFLOW_NAMESPACE)%' + task_queue: '%env(DURABLE_WORKFLOW_TASK_QUEUE)%' + credentials: + control_token: '%env(default::DURABLE_WORKFLOW_CLIENT_TOKEN)%' + worker_token: '%env(default::DURABLE_WORKFLOW_WORKER_TOKEN)%' + handlers: + - App\Workflow\GreeterWorkflow + - App\Activity\GreetingActivities +``` + +Inject `DURABLE_WORKFLOW_CLIENT_TOKEN` only into web and other application +processes. Inject `DURABLE_WORKFLOW_WORKER_TOKEN` only into the process running +`php bin/console durable-workflow:worker`; the `default::` processors leave the +opposite scoped credential unset. The Bundle binds the public autowired client +to the client credential and gives its private worker client only the worker +credential. Self-hosted deployments can instead set `credentials.token` from +`DURABLE_WORKFLOW_TOKEN` and inject that shared credential into both +processes. Keep values in the deployment platform's environment or secret store, +not YAML, generated container files, or committed environment files. + +Run `php bin/console durable-workflow:worker`. `Client` and +`WorkflowClientInterface` are public autowired services. Handler services retain +normal Symfony autowiring, worker messages use the standard PSR logger when it +is installed, and `WorkerDiagnosticEvent` is dispatched through Symfony's event +dispatcher under the diagnostic name. A `KernelTestCase` can use +`InteractsWithDurableWorkflow::fakeDurableWorkflow()` to replace the autowired +interface with the same assertion-capable fake used by plain PHP and Laravel. + +Both console commands accept `--queue` and `--poll-timeout`. They require +`ext-pcntl` so SIGINT and SIGTERM always request a graceful worker shutdown. +Configuration errors, an unreachable endpoint, rejected credentials, and +worker-protocol or contract mismatches are reported with remediation specific +to the failing boundary. Neither bridge stores workflow state or installs the +embedded Laravel workflow engine. + +## Test workflow code and interactions + +The testing namespace has no PHPUnit dependency. Its assertions throw +`DurableWorkflow\Testing\AssertionFailed`, so they work with PHPUnit, Pest, or +plain PHP. Application services can type their dependency as +`WorkflowClientInterface`; both the network `Client` and `WorkflowClientFake` +implement that interface and return handles with the same interaction methods. + +```php +use DurableWorkflow\Testing\WorkerTestHarness; +use DurableWorkflow\Testing\WorkflowClientFake; + +$worker = Worker::create($client, 'php-workers') + ->register(GreeterWorkflow::class, GreetingActivities::class); +$handlers = new WorkerTestHarness($worker); +$handlers->assertWorkflowEmits('greeter', 'schedule_activity', ['Ada']); +$handlers->assertActivityResult('greet', 'hello, Ada', ['Ada']); +$handlers->assertQueryResult('greeter', 'status', ['events' => 0]); +$handlers->assertUpdateResult('greeter', 'rename', 'Grace', ['Grace']); +$handlers->assertRegistered('signal', 'set-language', 'greeter'); + +$workflows = (new WorkflowClientFake()) + ->setQueryResult('greeting-1', 'status', 'running') + ->setUpdateResult('greeting-1', 'rename', 'accepted') + ->setWorkflowResult('greeting-1', ['greeting' => 'hello, Ada']); +$handle = $workflows->startWorkflow('greeter', 'greeting-1', 'php-workers', ['Ada']); +$handle->signal('set-language', ['en']); +$handle->query('status'); +$handle->update('rename', ['Grace']); +$handle->result(); + +$workflows->assertWorkflowStarted('greeter', ['Ada']); +$workflows->assertSignalSent('greeting-1', 'set-language', ['en']); +$workflows->assertQueryRequested('greeting-1', 'status'); +$workflows->assertUpdateRequested('greeting-1', 'rename', ['Grace']); +$workflows->assertResultRequested('greeting-1'); +``` + +Workflow tasks additionally require an acknowledged lease renewal before user +code runs. Typed transient renewal pressure is retried with the original task +ID, attempt, and lease owner; shutdown or a terminal/lost lease prevents task +execution and completion. + +See [`examples/`](../examples/), the authored +[PHP developer portal](https://php.durable-workflow.com/), the generated +[API reference](https://php.durable-workflow.com/api/), and +[`docs/protocol.md`](protocol.md) for the complete client, schedule, +namespace, visibility, search-attribute, service-operation, discovery, +authentication, worker, query, and update surfaces. + +## Development + +```bash +composer install +composer validate --strict +composer test +composer analyse +composer docs +``` + +The dependency-boundary check rejects Laravel, Illuminate, the embedded +workflow package, and the standalone server package in both declared and +resolved production dependencies. diff --git a/package.json b/package.json index 6c8b718..f35d45c 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,7 @@ "check:docs": "node scripts/check-docs-portal.mjs build/site", "check:docs-browser": "node scripts/check-docs-browser.mjs build/site", "check:docs-examples": "node scripts/check-docs-examples.mjs", - "check:docs-analytics-browser": "node scripts/check-docs-analytics-browser.mjs", - "qualify:quickstart-contract-deployment": "node scripts/qualify-quickstart-contract-deployment.mjs", - "test:docs-browser-failures": "node --test scripts/check-docs-browser-failures.test.mjs", - "qualify:docs-analytics-deployment": "node scripts/qualify-docs-analytics-deployment.mjs", - "test:quickstart-contract-deployment": "node --test scripts/qualify-quickstart-contract-deployment.test.mjs", - "test:docs-analytics-deployment": "node --test scripts/qualify-docs-analytics-deployment.test.mjs" + "test:docs-browser-failures": "node --test scripts/check-docs-browser-failures.test.mjs" }, "devDependencies": { "@11ty/eleventy": "3.1.6", diff --git a/scripts/check-docs-analytics-browser.mjs b/scripts/check-docs-analytics-browser.mjs deleted file mode 100644 index 39bc431..0000000 --- a/scripts/check-docs-analytics-browser.mjs +++ /dev/null @@ -1,816 +0,0 @@ -import assert from 'node:assert/strict'; -import {spawn} from 'node:child_process'; -import {once} from 'node:events'; -import {readFile, unlink, writeFile} from 'node:fs/promises'; -import http from 'node:http'; -import net from 'node:net'; -import path from 'node:path'; -import process from 'node:process'; -import {chromium} from 'playwright'; -import { - assertNoBrowserFailures, - formatHttpFailure, - formatRequestFailure, -} from './check-docs-browser-failures.mjs'; - -const SITE_HOSTNAME = 'php.durable-workflow.com'; -const TEST_TOKEN = '00000000000000000000000000000000'; -const BEACON_URL = 'https://static.cloudflareinsights.com/beacon.min.js'; -const RUM_URL = 'https://cloudflareinsights.com/cdn-cgi/rum'; -const PROMOTION_EVENT_URL = 'https://cloud.durable-workflow.com/early-access/promotion-events'; -const PROMOTION_SOURCE = 'sdk-php-reference'; -const cycleCount = 2; -const buildDirectory = path.resolve(process.argv[2] ?? 'build/api'); -const runtimeTemplate = (await readFile(path.join(buildDirectory, 'analytics/analytics.js'), 'utf8')) - .replace('__CLOUDFLARE_WEB_ANALYTICS_TOKEN__', TEST_TOKEN); -const promotionRequests = []; -let expectedPromotionOrigin; -let runtimeSource; -const viewports = [ - ['compact-height', {width: 1280, height: 360}], - ['desktop', {width: 1440, height: 900}], - ['intermediate-landscape', {width: 1024, height: 768}], - ['intermediate-portrait', {width: 768, height: 1024}], - ['mobile', {width: 390, height: 844}], - ['compact-mobile', {width: 320, height: 844}], - ['compact-narrow-height', {width: 640, height: 360}], - ['compact-mobile-height', {width: 390, height: 360}], -]; -const pages = [ - ['neighboring API', '/api/classes/DurableWorkflow-Worker-WorkflowContext.html', 'api'], - ['Client API', '/api/classes/DurableWorkflow-Client.html', 'api'], - ['root', '/index.html', 'portal'], -]; - -async function availablePort() { - const server = net.createServer(); - server.unref(); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - const address = server.address(); - assert(address && typeof address === 'object'); - await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); - return address.port; -} - -async function waitForServer(port) { - for (let attempt = 0; attempt < 50; attempt += 1) { - try { - await new Promise((resolve, reject) => { - const socket = net.connect(port, '127.0.0.1'); - socket.once('connect', () => { socket.destroy(); resolve(); }); - socket.once('error', reject); - }); - return; - } catch (_error) { - await new Promise(resolve => setTimeout(resolve, 100)); - } - } - throw new Error(`PHP documentation server did not start on port ${port}.`); -} - -async function waitForPromotionRequests(count) { - for (let attempt = 0; attempt < 50; attempt += 1) { - if (promotionRequests.length >= count) return; - await new Promise(resolve => setTimeout(resolve, 100)); - } - throw new Error(`Promotion receiver recorded ${promotionRequests.length} of ${count} expected requests.`); -} - -function expectedPromotionRequest(event) { - return { - accepted: true, - body: {source: PROMOTION_SOURCE, event}, - headers: { - authorization: null, - contentType: 'text/plain', - cookie: null, - origin: expectedPromotionOrigin, - referer: `${expectedPromotionOrigin}/`, - secFetchMode: null, - secFetchSite: null, - }, - method: 'POST', - path: '/early-access/promotion-events', - }; -} - -async function assertReachableControls(page, label, scope = 'body') { - const result = await page.evaluate((scopeSelector) => { - const selector = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"]'; - const unreachable = []; - const root = document.querySelector(scopeSelector); - for (const element of root?.querySelectorAll(selector) || []) { - if (element.closest('[inert]')) continue; - if ( - element.closest('.phpdocumentor-sidebar') - && !document.querySelector('.phpdocumentor-sidebar__menu-button')?.checked - ) continue; - const style = getComputedStyle(element); - const box = element.getBoundingClientRect(); - const visibleBox = { - top: Math.max(0, box.top), - right: Math.min(innerWidth, box.right), - bottom: Math.min(innerHeight, box.bottom), - left: Math.max(0, box.left), - }; - let excludedByAncestor = false; - let clippedByAncestor = false; - for (let parent = element.parentElement; parent; parent = parent.parentElement) { - const parentStyle = getComputedStyle(parent); - excludedByAncestor ||= parentStyle.visibility === 'hidden' - || parentStyle.display === 'none' - || parentStyle.pointerEvents === 'none' - || Number(parentStyle.opacity) === 0; - const parentBox = parent.getBoundingClientRect(); - if (['auto', 'hidden', 'scroll', 'clip'].includes(parentStyle.overflowX)) { - clippedByAncestor ||= parentBox.left > box.left || parentBox.right < box.right; - visibleBox.left = Math.max(visibleBox.left, parentBox.left); - visibleBox.right = Math.min(visibleBox.right, parentBox.right); - } - if (['auto', 'hidden', 'scroll', 'clip'].includes(parentStyle.overflowY)) { - clippedByAncestor ||= parentBox.top > box.top || parentBox.bottom < box.bottom; - visibleBox.top = Math.max(visibleBox.top, parentBox.top); - visibleBox.bottom = Math.min(visibleBox.bottom, parentBox.bottom); - } - } - if ( - excludedByAncestor - || style.visibility === 'hidden' - || style.display === 'none' - || style.pointerEvents === 'none' - || Number(style.opacity) === 0 - || box.width < 1 - || box.height < 1 - || box.right <= 0 - || box.left >= innerWidth - || box.bottom <= 0 - || box.top >= innerHeight - ) continue; - const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); - const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); - if (visibleWidth <= 0 || visibleHeight <= 0) continue; - - const center = {x: box.left + box.width / 2, y: box.top + box.height / 2}; - if (center.x < 0 || center.x >= innerWidth || center.y < 0 || center.y >= innerHeight) continue; - const centerHit = center.x >= visibleBox.left - && center.x < visibleBox.right - && center.y >= visibleBox.top - && center.y < visibleBox.bottom - ? document.elementFromPoint(center.x, center.y) - : null; - const centerReachable = Boolean(centerHit === element - || element.contains(centerHit) - || centerHit?.contains(element)); - if (!centerReachable) { - unreachable.push({ - element: element.outerHTML.slice(0, 180), - clippedByAncestor, - box: box.toJSON(), - visibleBox, - }); - } - } - return { - unreachable, - viewportWidth: document.documentElement.clientWidth, - documentWidth: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth), - overflowing: [...document.querySelectorAll('body *')].flatMap(element => { - const box = element.getBoundingClientRect(); - return box.right > innerWidth + 1 || box.left < -1 - ? [{element: element.outerHTML.slice(0, 180), box: box.toJSON()}] - : []; - }).slice(0, 10), - }; - }, scope); - assert.equal( - result.documentWidth, - result.viewportWidth, - `${label} has horizontal overflow: ${JSON.stringify(result.overflowing)}`, - ); - assert.deepEqual(result.unreachable, [], `${label} has unreachable controls`); -} - -async function assertPromotionActionContainment(page, label) { - const result = await page.evaluate(() => { - const promotion = document.querySelector('.dw-cloud-promotion'); - const action = promotion?.querySelector('.dw-cloud-promotion__action'); - if (!promotion || !action) return {present: false}; - - const promotionBox = promotion.getBoundingClientRect(); - const actionBox = action.getBoundingClientRect(); - return { - present: true, - promotionBox: promotionBox.toJSON(), - actionBox: actionBox.toJSON(), - contained: actionBox.left >= promotionBox.left - 1 - && actionBox.right <= promotionBox.right + 1, - }; - }); - - assert.equal(result.present, true, `${label} lost its Cloud promotion`); - assert.equal( - result.contained, - true, - `${label} promotion action escaped its card: ${JSON.stringify(result)}`, - ); -} - -async function assertTableOfContentsMetadataLegibility(page, label) { - const result = await page.evaluate(() => { - const readableWidth = 12 * Number.parseFloat(getComputedStyle(document.documentElement).fontSize); - const failures = [...document.querySelectorAll( - '.phpdocumentor-content > section:first-of-type .phpdocumentor-table-of-contents__entry', - )].flatMap(entry => { - const metadata = entry.querySelector(':scope > span'); - if (!metadata?.textContent.trim()) return []; - - const entryBox = entry.getBoundingClientRect(); - const metadataBox = metadata.getBoundingClientRect(); - const minimumWidth = Math.min(readableWidth, entryBox.width); - return metadataBox.width + 1 < minimumWidth - ? [{ - entry: entry.outerHTML.slice(0, 180), - entryWidth: entryBox.width, - metadataWidth: metadataBox.width, - minimumWidth, - }] - : []; - }); - - return { - metadataCount: document.querySelectorAll( - '.phpdocumentor-content > section:first-of-type .phpdocumentor-table-of-contents__entry > span', - ).length, - failures, - }; - }); - - assert.ok(result.metadataCount > 0, `${label} lost its table-of-contents metadata`); - assert.deepEqual(result.failures, [], `${label} collapsed table-of-contents metadata`); -} - -async function assertPrimaryApiContentUsesAvailableWidth(page, label) { - const result = await page.evaluate(() => { - const content = document.querySelector('.phpdocumentor-content'); - const primary = content?.querySelector(':scope > section:first-of-type'); - const onThisPage = content?.querySelector(':scope > .phpdocumentor-on-this-page__sidebar'); - if (!content || !primary || !onThisPage) return {present: false}; - - const contentStyle = getComputedStyle(content); - const onThisPageStyle = getComputedStyle(onThisPage); - const onThisPageBox = onThisPage.getBoundingClientRect(); - const primaryBox = primary.getBoundingClientRect(); - const availableWidth = content.clientWidth - - Number.parseFloat(contentStyle.paddingLeft) - - Number.parseFloat(contentStyle.paddingRight); - - return { - present: true, - availableWidth, - primaryWidth: primaryBox.width, - onThisPageVisible: onThisPageStyle.display !== 'none' && onThisPageBox.width > 0, - }; - }); - - assert.equal(result.present, true, `${label} lost its primary API content`); - if (result.onThisPageVisible) return; - assert.ok( - Math.abs(result.primaryWidth - result.availableWidth) <= 1, - `${label} primary API content does not use its available width: ${JSON.stringify(result)}`, - ); -} - -async function assertOnThisPageUtilityReachability(page, label) { - const result = await page.evaluate(async () => { - const scrollport = document.querySelector('.phpdocumentor-on-this-page__content'); - if (!scrollport) return {present: false}; - - const settle = () => new Promise(resolve => { - requestAnimationFrame(() => requestAnimationFrame(resolve)); - }); - const originalScrollTop = scrollport.scrollTop; - const initialScrollportBox = scrollport.getBoundingClientRect(); - const failures = []; - const entries = [...scrollport.querySelectorAll('a[href]')]; - - for (const entry of entries) { - let scrollportBox = scrollport.getBoundingClientRect(); - let entryBox = entry.getBoundingClientRect(); - const entryCenterInContent = entryBox.top - scrollportBox.top - + scrollport.scrollTop - + entryBox.height / 2; - scrollport.scrollTop = Math.max( - 0, - Math.min( - scrollport.scrollHeight - scrollport.clientHeight, - entryCenterInContent - scrollport.clientHeight / 2, - ), - ); - await settle(); - - scrollportBox = scrollport.getBoundingClientRect(); - entryBox = entry.getBoundingClientRect(); - const center = {x: entryBox.left + entryBox.width / 2, y: entryBox.top + entryBox.height / 2}; - const centerInsideScrollport = center.x >= scrollportBox.left - && center.x < scrollportBox.right - && center.y >= Math.max(0, scrollportBox.top) - && center.y < Math.min(innerHeight, scrollportBox.bottom); - const hit = centerInsideScrollport ? document.elementFromPoint(center.x, center.y) : null; - const centerReachable = Boolean(hit === entry || entry.contains(hit) || hit?.contains(entry)); - if (!centerInsideScrollport || !centerReachable) { - failures.push({ - entry: entry.outerHTML.slice(0, 180), - center, - centerInsideScrollport, - centerReachable, - scrollportBox: scrollportBox.toJSON(), - }); - } - } - - scrollport.scrollTop = originalScrollTop; - await settle(); - return { - present: true, - viewportHeight: innerHeight, - scrollportBox: initialScrollportBox.toJSON(), - entryCount: entries.length, - failures, - }; - }); - - if (!result.present) return; - assert.ok(result.entryCount > 0, `${label} lost its On this page entries`); - assert.ok( - result.scrollportBox.top >= 0 && result.scrollportBox.bottom <= result.viewportHeight - 1, - `${label} On this page scrollport extends outside the usable viewport: ${JSON.stringify(result.scrollportBox)}`, - ); - assert.deepEqual(result.failures, [], `${label} has unreachable On this page entries`); -} - -async function assertFloatingUtilitiesClearReadableContent(page, label, scope = '.phpdocumentor-content') { - const collisions = await page.evaluate((scopeSelector) => { - function isVisible(element) { - const style = getComputedStyle(element); - const box = element.getBoundingClientRect(); - return style.visibility !== 'hidden' - && style.display !== 'none' - && style.pointerEvents !== 'none' - && Number(style.opacity) !== 0 - && box.width > 0 - && box.height > 0 - && box.right > 0 - && box.left < innerWidth - && box.bottom > 0 - && box.top < innerHeight; - } - - function intersection(first, second) { - const width = Math.min(first.right, second.right) - Math.max(first.left, second.left); - const height = Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top); - return width > 0 && height > 0 ? width * height : 0; - } - - function visibleTextBox(textBox, element) { - const visible = { - top: Math.max(0, textBox.top), - right: Math.min(innerWidth, textBox.right), - bottom: Math.min(innerHeight, textBox.bottom), - left: Math.max(0, textBox.left), - }; - for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) { - const style = getComputedStyle(ancestor); - const box = ancestor.getBoundingClientRect(); - if (['auto', 'hidden', 'scroll', 'clip'].includes(style.overflowX)) { - visible.left = Math.max(visible.left, box.left); - visible.right = Math.min(visible.right, box.right); - } - if (['auto', 'hidden', 'scroll', 'clip'].includes(style.overflowY)) { - visible.top = Math.max(visible.top, box.top); - visible.bottom = Math.min(visible.bottom, box.bottom); - } - } - visible.width = Math.max(0, visible.right - visible.left); - visible.height = Math.max(0, visible.bottom - visible.top); - return visible; - } - - const controls = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"]'; - const utilities = [...document.querySelectorAll(controls)].filter(element => { - const position = getComputedStyle(element).position; - return isVisible(element) - && (position === 'fixed' || position === 'sticky' || element.matches('[data-floating-utility], .phpdocumentor-back-to-top')); - }); - const collisions = []; - for (const root of document.querySelectorAll(scopeSelector)) { - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - for (let node = walker.nextNode(); node; node = walker.nextNode()) { - const parent = node.parentElement; - if (!parent || !node.textContent.trim() || parent.closest('script, style, .visually-hidden')) continue; - if (!isVisible(parent)) continue; - const range = document.createRange(); - range.selectNodeContents(node); - for (const textBox of range.getClientRects()) { - const visibleBox = visibleTextBox(textBox, parent); - if ( - visibleBox.width < 1 - || visibleBox.height < 1 - ) continue; - for (const utility of utilities) { - if (utility.contains(parent) || parent.contains(utility)) continue; - const area = intersection(utility.getBoundingClientRect(), visibleBox); - if (area > 1) { - collisions.push({ - utility: utility.outerHTML.slice(0, 180), - content: node.textContent.trim().replace(/\s+/g, ' ').slice(0, 100), - overlapArea: Math.round(area), - utilityBox: utility.getBoundingClientRect().toJSON(), - contentBox: visibleBox, - }); - } - } - } - } - } - return collisions; - }, scope); - assert.deepEqual(collisions, [], `${label} has a floating utility over readable primary content`); -} - -async function floatingUtilityCandidateScrollPositions(page) { - return page.evaluate(() => { - const utility = document.querySelector('.phpdocumentor-back-to-top'); - const content = document.querySelector('.phpdocumentor-content'); - if (!utility || !content) return []; - - const utilityBox = utility.getBoundingClientRect(); - const maximumScroll = Math.max(0, document.documentElement.scrollHeight - innerHeight); - const positions = new Set([0, maximumScroll]); - - function addCandidate(box) { - const horizontalOverlap = Math.min(box.right, utilityBox.right) - Math.max(box.left, utilityBox.left); - if (horizontalOverlap <= 1 || box.width < 1 || box.height < 1) return; - - const documentTop = box.top + scrollY; - const documentBottom = box.bottom + scrollY; - const centered = documentTop - utilityBox.top - Math.max(0, (utilityBox.height - box.height) / 2); - const position = Math.max(0, Math.min(maximumScroll, centered)); - const projectedTop = documentTop - position; - const projectedBottom = documentBottom - position; - const verticalOverlap = Math.min(projectedBottom, utilityBox.bottom) - Math.max(projectedTop, utilityBox.top); - if (verticalOverlap > 1) positions.add(Math.round(position)); - } - - const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); - for (let node = walker.nextNode(); node; node = walker.nextNode()) { - const parent = node.parentElement; - if (!parent || !node.textContent.trim() || parent.closest('script, style, .visually-hidden')) continue; - const style = getComputedStyle(parent); - if ( - style.visibility === 'hidden' - || style.display === 'none' - || style.pointerEvents === 'none' - || Number(style.opacity) === 0 - ) continue; - const range = document.createRange(); - range.selectNodeContents(node); - for (const box of range.getClientRects()) addCandidate(box); - } - - for (const element of content.querySelectorAll('canvas, img, input, button, select, table, textarea, video')) { - const style = getComputedStyle(element); - if (style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) !== 0) { - addCandidate(element.getBoundingClientRect()); - } - } - - return [...positions].sort((first, second) => first - second); - }); -} - -async function assertFloatingUtilityGeometry(page, label, expectedVisible = true) { - const utility = page.locator('.phpdocumentor-back-to-top'); - assert.equal(await utility.count(), 1, `${label} lost its back-to-top utility`); - assert.equal(await utility.isVisible(), expectedVisible, `${label} back-to-top visibility drifted`); - if (!expectedVisible) return; - - const positions = await floatingUtilityCandidateScrollPositions(page); - for (const position of positions) { - await page.evaluate(scrollPosition => new Promise(resolve => { - scrollTo(0, scrollPosition); - requestAnimationFrame(() => requestAnimationFrame(resolve)); - }), position); - await assertReachableControls(page, `${label} at scroll ${position}`); - await assertFloatingUtilitiesClearReadableContent(page, `${label} at scroll ${position}`); - } - - const maximumScroll = positions.at(-1) ?? 0; - if (maximumScroll > 0) { - await page.evaluate(scrollPosition => scrollTo(0, scrollPosition), maximumScroll); - await utility.click(); - await page.waitForFunction(() => scrollY === 0); - } -} - -async function exercisePage( - browser, - origin, - viewportName, - viewport, - pageName, - pagePath, - pageKind, - edgeInjected = false, - cycle = null, -) { - const context = await browser.newContext({viewport, reducedMotion: 'reduce'}); - const page = await context.newPage(); - const consoleErrors = []; - const pageErrors = []; - const requestFailures = []; - const httpErrors = []; - const rumRequests = []; - const promotionRequestStart = promotionRequests.length; - let renderedPagePath = pagePath; - let edgeFixturePath; - page.on('console', message => { - if (message.type() === 'error') consoleErrors.push(message.text()); - }); - page.on('pageerror', error => pageErrors.push(error.message)); - page.on('requestfailed', request => { - const errorText = request.failure()?.errorText ?? 'unknown failure'; - if (request.url() === promotionEventUrl && errorText === 'net::ERR_ABORTED') return; - requestFailures.push(formatRequestFailure(request)); - }); - page.on('response', response => { - if (response.status() >= 400) httpErrors.push(formatHttpFailure(response)); - }); - const cycleLabel = cycle === null ? '' : ` cycle ${cycle}`; - const label = `${pageName} ${viewportName}${cycleLabel}${edgeInjected ? ' with edge injection' : ''}`; - - try { - await page.route('**/analytics/analytics.js', route => route.fulfill({ - body: runtimeSource, - contentType: 'application/javascript', - })); - await page.route(`${BEACON_URL}*`, route => route.fulfill({ - body: `window.__cloudflareBeaconExecutions = (window.__cloudflareBeaconExecutions || 0) + 1; - fetch('${RUM_URL}', {method: 'POST', body: '{}'});`, - contentType: 'application/javascript', - })); - await page.route(`${RUM_URL}*`, route => { - rumRequests.push(route.request().method()); - return route.fulfill({ - status: 200, - contentType: 'text/plain', - body: 'ok', - headers: {'access-control-allow-origin': '*'}, - }); - }); - if (edgeInjected) { - const renderedHtml = await readFile(path.join(buildDirectory, pagePath), 'utf8'); - const edgeSnippet = ``; - edgeFixturePath = path.join(path.dirname(path.join(buildDirectory, pagePath)), 'analytics-edge-injection.html'); - renderedPagePath = `${path.posix.dirname(pagePath)}/analytics-edge-injection.html`; - await writeFile(edgeFixturePath, renderedHtml.replace('', `${edgeSnippet}`)); - } - - const response = await page.goto(`${origin}${renderedPagePath}`, {waitUntil: 'networkidle'}); - assertNoBrowserFailures(label, {httpErrors, requestFailures, pageErrors, consoleErrors}); - assert.equal(response?.status(), 200, `${label} did not render`); - await page.locator(pageKind === 'api' ? '.phpdocumentor-content' : '#main-content').waitFor(); - await page.waitForFunction(() => window.__cloudflareBeaconExecutions === 1); - const analytics = await page.evaluate(({beaconUrl}) => ({ - retiredUi: document.querySelectorAll('.dw-analytics-consent, .dw-analytics-preferences, #durable-workflow-analytics-consent, #durable-workflow-analytics-preferences').length, - google: [...document.scripts].filter(script => /googletagmanager|google-analytics/.test(script.src)).length, - runtimes: [...document.scripts].filter(script => script.src.endsWith('/analytics/analytics.js')).map(script => script.type), - beacons: [...document.scripts].filter(script => script.src.startsWith(beaconUrl)).map(script => { - let configuration; - try { - configuration = JSON.parse(script.dataset.cfBeacon || 'null'); - } catch (_error) { - configuration = null; - } - return { - tokenOnly: configuration !== null - && Object.keys(configuration).length === 1 - && /^[a-f0-9]{32}$/.test(configuration.token), - type: script.type, - hasAsync: script.hasAttribute('async'), - hasDefer: script.hasAttribute('defer'), - }; - }), - localStorageEntries: localStorage.length, - sessionStorageEntries: sessionStorage.length, - loaderIdCount: document.querySelectorAll('#durable-workflow-cloudflare-web-analytics').length, - executions: window.__cloudflareBeaconExecutions, - }), {beaconUrl: BEACON_URL}); - assert.equal(analytics.retiredUi, 0, `${label} restored retired analytics UI`); - assert.equal(analytics.google, 0, `${label} restored Google analytics`); - assert.deepEqual(analytics.runtimes, ['module'], `${label} must load one module eligibility runtime`); - assert.deepEqual(analytics.beacons, [{tokenOnly: true, type: 'module', hasAsync: false, hasDefer: false}], `${label} must use one supported Cloudflare module loader`); - assert.equal(analytics.loaderIdCount, edgeInjected ? 0 : 1, `${label} duplicate-beacon guard failed`); - assert.equal(analytics.executions, 1, `${label} executed the beacon more than once`); - assert.deepEqual(rumRequests, ['POST'], `${label} did not emit one successful RUM request`); - await page.locator('.dw-cloud-promotion').scrollIntoViewIfNeeded(); - await waitForPromotionRequests(promotionRequestStart + 1); - assert.deepEqual( - promotionRequests.slice(promotionRequestStart), - [expectedPromotionRequest('impression')], - `${label} did not emit one browser-realistic bounded promotion impression`, - ); - assert.equal(analytics.localStorageEntries, 0, `${label} wrote local storage`); - assert.equal(analytics.sessionStorageEntries, 0, `${label} wrote session storage`); - assert.deepEqual(await context.cookies(), [], `${label} wrote cookies`); - await assertPromotionActionContainment(page, `${label} default`); - await assertReachableControls(page, `${label} default`); - if (edgeInjected) { - assertNoBrowserFailures(label, {httpErrors, requestFailures, pageErrors, consoleErrors}); - return; - } - if (pageKind === 'api') { - assert.equal(await page.locator('.phpdocumentor-title__link').count(), 1, `${label} lost its title link`); - assert.equal(await page.locator('.phpdocumentor-search__field').count(), 1, `${label} lost search`); - if (pageName === 'Client API') { - await assertTableOfContentsMetadataLegibility(page, `${label} default`); - } - await assertPrimaryApiContentUsesAvailableWidth(page, `${label} default`); - if (viewportName === 'compact-height') { - await assertOnThisPageUtilityReachability(page, `${label} default`); - } - await assertFloatingUtilityGeometry(page, `${label} default`); - - const sidebarMenu = page.locator('.phpdocumentor-sidebar__menu-icon'); - if (await sidebarMenu.isVisible()) { - assert.equal(await sidebarMenu.textContent(), 'Open navigation', `${label} sidebar did not expose its collapsed state`); - assert.match( - await sidebarMenu.evaluate(element => getComputedStyle(element).letterSpacing), - /^(?:normal|0px)$/, - `${label} sidebar restored generated-theme letter spacing`, - ); - await sidebarMenu.click(); - assert.equal(await page.locator('.phpdocumentor-sidebar__menu-button').isChecked(), true, `${label} sidebar did not open`); - assert.equal(await sidebarMenu.textContent(), 'Close navigation', `${label} sidebar did not expose a dismiss action`); - assert.equal( - await page.locator('.phpdocumentor-sidebar__menu-button').getAttribute('aria-expanded'), - 'true', - `${label} sidebar did not expose its expanded state`, - ); - const title = await page.locator('.phpdocumentor-title__link').evaluate(element => ({ - clientWidth: element.clientWidth, - scrollWidth: element.scrollWidth, - text: element.textContent.trim(), - })); - assert.equal(title.text, 'Durable Workflow PHP SDK — API Reference', `${label} clipped the API title text`); - assert.ok(title.scrollWidth <= title.clientWidth, `${label} clipped the API title box`); - await assertReachableControls(page, `${label} open sidebar`); - await assertFloatingUtilitiesClearReadableContent(page, `${label} open sidebar`, '.phpdocumentor-sidebar'); - await sidebarMenu.click(); - } - - const search = page.locator('.phpdocumentor-search__field'); - await search.pressSequentially('Workflow'); - await page.locator('.phpdocumentor-search-results:not(.phpdocumentor-search-results--hidden)').waitFor(); - assert.ok(await page.locator('.phpdocumentor-search-results__entry').count() > 0, `${label} search has no results`); - assert.ok( - await page.locator('[data-api-reference-search-background][inert]').count() >= 3, - `${label} search did not isolate its background`, - ); - assert.equal(await search.isEditable(), true, `${label} search could not refine its query`); - await search.press('Control+A'); - await search.pressSequentially('Client'); - await page.waitForTimeout(350); - await page.waitForFunction(() => ( - document.querySelector('.phpdocumentor-search__field')?.value === 'Client' - && document.querySelectorAll('.phpdocumentor-search-results__entry').length > 0 - )); - await assertReachableControls(page, `${label} open search`); - await assertFloatingUtilitiesClearReadableContent(page, `${label} open search`, '.phpdocumentor-search-results'); - await page.locator('.phpdocumentor-search-results__close').click(); - await page.waitForFunction(() => ( - document.querySelector('.phpdocumentor-search-results') - ?.classList.contains('phpdocumentor-search-results--hidden') - && !document.querySelector('[data-api-reference-search-background]') - )); - assert.equal( - await page.locator('.phpdocumentor-back-to-top').isVisible(), - true, - `${label} did not restore its back-to-top utility`, - ); - await assertReachableControls(page, `${label} after closing search`); - } else { - assert.equal(await page.locator('.brand').count(), 1, `${label} lost the authored portal identity`); - } - - const promotionAction = page.locator('.dw-cloud-promotion__action'); - assert.equal( - await promotionAction.getAttribute('href'), - 'https://cloud.durable-workflow.com/early-access#source=sdk-php-reference', - `${label} promotion action lost its public early-access destination`, - ); - await promotionAction.evaluate(action => { - action.addEventListener('click', event => event.preventDefault(), {once: true}); - }); - await promotionAction.click(); - await waitForPromotionRequests(promotionRequestStart + 2); - await page.waitForTimeout(100); - assert.deepEqual( - promotionRequests.slice(promotionRequestStart), - [expectedPromotionRequest('impression'), expectedPromotionRequest('click')], - `${label} did not emit exactly one bounded promotion click`, - ); - assertNoBrowserFailures(label, {httpErrors, requestFailures, pageErrors, consoleErrors}); - } finally { - await context.close(); - if (edgeFixturePath) await unlink(edgeFixturePath); - } -} - -const port = await availablePort(); -const promotionPort = await availablePort(); -const promotionEventUrl = `http://cloud.durable-workflow.com:${promotionPort}/early-access/promotion-events`; -expectedPromotionOrigin = `http://${SITE_HOSTNAME}:${port}`; -runtimeSource = runtimeTemplate.replace(PROMOTION_EVENT_URL, promotionEventUrl); -const promotionServer = http.createServer(async (request, response) => { - let contents = ''; - for await (const chunk of request) contents += chunk; - - let body; - try { - body = JSON.parse(contents); - } catch (_error) { - body = null; - } - const headers = { - authorization: request.headers.authorization ?? null, - contentType: request.headers['content-type'] ?? null, - cookie: request.headers.cookie ?? null, - origin: request.headers.origin ?? null, - referer: request.headers.referer ?? null, - secFetchMode: request.headers['sec-fetch-mode'] ?? null, - secFetchSite: request.headers['sec-fetch-site'] ?? null, - }; - const boundedBody = body !== null - && Object.keys(body).sort().join(',') === 'event,source' - && body.source === PROMOTION_SOURCE - && ['impression', 'click'].includes(body.event); - const accepted = request.method === 'POST' - && request.url === '/early-access/promotion-events' - && boundedBody - && headers.authorization === null - && headers.contentType === 'text/plain' - && headers.cookie === null - && headers.origin === expectedPromotionOrigin - && headers.referer === `${expectedPromotionOrigin}/`; - promotionRequests.push({ - accepted, - body, - headers, - method: request.method, - path: request.url, - }); - - response.writeHead(accepted ? 204 : 403, { - 'Access-Control-Allow-Origin': expectedPromotionOrigin, - 'Cache-Control': 'no-store', - Vary: 'Origin', - }); - response.end(); -}); -promotionServer.listen(promotionPort, '127.0.0.1'); -await once(promotionServer, 'listening'); -const server = spawn('php', ['-S', `127.0.0.1:${port}`, '-t', buildDirectory], {stdio: 'ignore'}); -let browser; -try { - await waitForServer(port); - const launchOptions = { - headless: true, - args: [ - `--host-resolver-rules=MAP ${SITE_HOSTNAME} 127.0.0.1, MAP cloud.durable-workflow.com 127.0.0.1`, - '--no-proxy-server', - ], - }; - if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) { - launchOptions.executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; - } - browser = await chromium.launch(launchOptions); - const origin = `http://${SITE_HOSTNAME}:${port}`; - for (let cycle = 1; cycle <= cycleCount; cycle += 1) { - for (const [viewportName, viewport] of viewports) { - for (const [pageName, pagePath, pageKind] of pages) { - await exercisePage(browser, origin, viewportName, viewport, pageName, pagePath, pageKind, false, cycle); - } - } - } - const desktopViewport = viewports.find(([name]) => name === 'desktop')?.[1]; - assert.ok(desktopViewport); - await exercisePage(browser, origin, 'desktop', desktopViewport, 'nested API', pages[1][1], 'api', true); - process.stdout.write('Validated responsive floating-control geometry and browser evidence on root, Client, and neighboring PHP reference pages.\n'); -} finally { - await browser?.close(); - server.kill('SIGTERM'); - await Promise.race([once(server, 'exit'), new Promise(resolve => setTimeout(resolve, 1000))]); - promotionServer.close(); - await once(promotionServer, 'close'); -} diff --git a/scripts/check-docs-examples-contract.json b/scripts/check-docs-examples-contract.json index e449ee9..2b1ed66 100644 --- a/scripts/check-docs-examples-contract.json +++ b/scripts/check-docs-examples-contract.json @@ -3,19 +3,19 @@ "examples": [ { "id": "php.quickstart.bootstrap", - "path": "README.md", + "path": "docs/sdk-reference.md", "language": "php", "source": "examples/bootstrap.php" }, { "id": "php.quickstart.worker", - "path": "README.md", + "path": "docs/sdk-reference.md", "language": "php", "source": "examples/worker.php" }, { "id": "php.quickstart.client", - "path": "README.md", + "path": "docs/sdk-reference.md", "language": "php", "source": "examples/client.php", "workflowIdentity": { diff --git a/scripts/ci/classify-ci-qualification.py b/scripts/ci/classify-ci-qualification.py deleted file mode 100644 index 8660660..0000000 --- a/scripts/ci/classify-ci-qualification.py +++ /dev/null @@ -1,306 +0,0 @@ -#!/usr/bin/env python3 -"""Select the portable CI route and focused PHP SDK evidence categories.""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -from collections.abc import Sequence -from dataclasses import asdict, dataclass -from pathlib import Path, PurePosixPath -from urllib.parse import urlparse - - -GITHUB_SERVER_URL = "https://github.com" -FOCUSED = "focused" -COMPLETE = "complete" -SENTINEL = "sentinel" - -ALL_FOCUSED_CATEGORIES = ("docs", "docs-browser", "runtime") -OBJECT_ID = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?") - - -@dataclass(frozen=True) -class Qualification: - route: str - route_reason: str - categories: tuple[str, ...] - changed_path_reason: str - changed_count: int - - -class ChangedPathIdentityError(RuntimeError): - """Raised when an exact pull-request path set cannot be established.""" - - -def is_identified_alternate_server(server_url: str) -> bool: - try: - parsed = urlparse(server_url) - hostname = parsed.hostname - except ValueError: - return False - return ( - parsed.scheme in {"http", "https"} - and bool(hostname) - and parsed.username is None - and parsed.password is None - and parsed.path in {"", "/"} - and not parsed.params - and not parsed.query - and not parsed.fragment - ) - - -def select_route( - server_url: str, - event_name: str, - alternate_ci_focused_admission: str, -) -> tuple[str, str]: - if server_url == GITHUB_SERVER_URL: - if event_name == "pull_request": - return FOCUSED, "github-pull-request" - return COMPLETE, "github-target-or-dispatch" - - alternate_is_identified = ( - is_identified_alternate_server(server_url) - and alternate_ci_focused_admission == "true" - ) - if alternate_is_identified and event_name == "pull_request": - return FOCUSED, "alternate-ci-pull-request" - if alternate_is_identified and event_name == "push": - return SENTINEL, "alternate-ci-target-sentinel" - - return COMPLETE, "unidentified-environment-fail-safe" - - -def is_canonical_repo_path(path: str) -> bool: - if not path or path.startswith("/") or "\\" in path: - return False - if any(character in path for character in "\0\r\n"): - return False - parts = PurePosixPath(path).parts - return ( - bool(parts) - and all(part not in {"", ".", ".."} for part in parts) - and str(PurePosixPath(path)) == path - ) - - -def path_categories(path: str) -> set[str]: - categories: set[str] = set() - - if path == "composer.json" or path.startswith("src/"): - categories.add("docs") - - if ( - path - in { - ".eleventy.cjs", - "CHANGELOG.md", - "CONTRIBUTING.md", - "README.md", - "package.json", - "package-lock.json", - ".github/workflows/external-link-diagnostics.yml", - "scripts/finalize-api-reference.php", - "scripts/render-quickstart-docs.php", - } - or path.startswith((".phpdoc/", "docs/")) - or path.startswith("scripts/ci/fixtures/docs-links/") - or path.startswith("scripts/check-docs-") - or path.startswith("scripts/qualify-docs-") - or path.startswith("scripts/qualify-quickstart-") - or path == ".github/workflows/docs.yml" - ): - categories.add("docs") - - if ( - path - in { - ".eleventy.cjs", - ".github/workflows/docs.yml", - "package.json", - "package-lock.json", - "scripts/check-docs-browser.mjs", - "scripts/check-docs-analytics-browser.mjs", - "scripts/finalize-api-reference.php", - } - or path.startswith(".phpdoc/") - or path.startswith("docs/portal/") - or path.startswith(("scripts/check-docs-browser-", "scripts/qualify-docs-")) - ): - categories.add("docs-browser") - - if ( - path.startswith(("src/", "tests/", "examples/", "benchmarks/", "resources/")) - or path - in { - "composer.json", - "composer.lock", - "phpstan.neon", - "phpstan-framework.neon", - "phpunit.xml.dist", - "regression-corpus-policy.json", - } - or path.startswith("scripts/check-dependency-") - or path == "scripts/ci/run-replay-regression-fixture.php" - or "regression-corpus" in path - ): - categories.add("runtime") - - if path in { - ".github/workflows/framework-bridges-published-smoke.yml", - ".github/workflows/service-mode-published-smoke.yml", - }: - categories.add("runtime") - - if path in { - ".github/workflows/ci.yml", - ".github/workflows/public-boundary.yml", - "scripts/check-public-boundary.sh", - "scripts/ci/classify-ci-qualification.py", - "scripts/ci/test-ci-qualification.py", - "scripts/ci/test-workflow-trust-boundaries.py", - }: - categories.add("ci") - - return categories - - -def classify_changed_files(changed_files: Sequence[str]) -> tuple[tuple[str, ...], str]: - paths = tuple(sorted(set(changed_files))) - if not paths or any(not is_canonical_repo_path(path) for path in paths): - return ALL_FOCUSED_CATEGORIES, "changed-path-identity-unavailable" - - categories: set[str] = set() - for path in paths: - selected = path_categories(path) - if not selected: - return ALL_FOCUSED_CATEGORIES, "unclassified-path-fail-safe" - categories.update(selected) - - return tuple(sorted(categories)), "changed-paths-classified" - - -def changed_files_between(root: Path, base_ref: str, head_ref: str) -> tuple[str, ...]: - if not OBJECT_ID.fullmatch(base_ref) or not OBJECT_ID.fullmatch(head_ref): - raise ChangedPathIdentityError( - "base and head revisions must be immutable object IDs" - ) - - try: - for revision in (base_ref, head_ref): - subprocess.run( - ["git", "cat-file", "-e", f"{revision}^{{commit}}"], - cwd=root, - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - result = subprocess.run( - [ - "git", - "diff", - "--name-only", - "-z", - "--no-renames", - f"{base_ref}...{head_ref}", - "--", - ], - cwd=root, - check=True, - capture_output=True, - ) - return tuple( - path.decode("utf-8") for path in result.stdout.split(b"\0") if path - ) - except (OSError, subprocess.CalledProcessError, UnicodeDecodeError) as error: - raise ChangedPathIdentityError( - "unable to resolve the pull-request path set" - ) from error - - -def qualify( - *, - root: Path, - server_url: str, - event_name: str, - alternate_ci_focused_admission: str, - base_ref: str, - head_ref: str, - changed_files: Sequence[str] | None = None, -) -> Qualification: - route, route_reason = select_route( - server_url, - event_name, - alternate_ci_focused_admission, - ) - if route != FOCUSED: - return Qualification(route, route_reason, (), "not-a-focused-route", 0) - - if changed_files is None: - try: - paths = changed_files_between(root, base_ref, head_ref) - except ChangedPathIdentityError: - categories = ALL_FOCUSED_CATEGORIES - path_reason = "changed-path-identity-unavailable" - changed_count = 0 - else: - categories, path_reason = classify_changed_files(paths) - changed_count = len(paths) - else: - categories, path_reason = classify_changed_files(changed_files) - changed_count = len(set(changed_files)) - - return Qualification( - route, - route_reason, - categories, - path_reason, - changed_count, - ) - - -def write_github_output(path: Path, qualification: Qualification) -> None: - with path.open("a", encoding="utf-8") as output: - print(f"route={qualification.route}", file=output) - print(f"route_reason={qualification.route_reason}", file=output) - print(f"categories={','.join(qualification.categories)}", file=output) - print(f"changed_path_reason={qualification.changed_path_reason}", file=output) - print(f"changed_count={qualification.changed_count}", file=output) - - -def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path.cwd()) - parser.add_argument("--server-url", default="") - parser.add_argument("--event-name", default="") - parser.add_argument("--alternate-ci-focused-admission", default="") - parser.add_argument("--base-ref", default="") - parser.add_argument("--head-ref", default="") - parser.add_argument("--changed-file", action="append", dest="changed_files") - parser.add_argument("--github-output", type=Path) - return parser.parse_args(argv) - - -def main(argv: Sequence[str] | None = None) -> int: - args = parse_args(argv) - qualification = qualify( - root=args.root.resolve(), - server_url=args.server_url, - event_name=args.event_name, - alternate_ci_focused_admission=args.alternate_ci_focused_admission, - base_ref=args.base_ref, - head_ref=args.head_ref, - changed_files=args.changed_files, - ) - if args.github_output is not None: - write_github_output(args.github_output, qualification) - print(json.dumps(asdict(qualification), sort_keys=True)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/ci/fixtures/docs-links/broken-internal.md b/scripts/ci/fixtures/docs-links/broken-internal.md deleted file mode 100644 index 49433b6..0000000 --- a/scripts/ci/fixtures/docs-links/broken-internal.md +++ /dev/null @@ -1,3 +0,0 @@ -# Broken internal link fixture - -[Missing repository document](missing.md) diff --git a/scripts/ci/fixtures/docs-links/external-dns-failure.md b/scripts/ci/fixtures/docs-links/external-dns-failure.md deleted file mode 100644 index 068f010..0000000 --- a/scripts/ci/fixtures/docs-links/external-dns-failure.md +++ /dev/null @@ -1,3 +0,0 @@ -# External outage fixture - -[Third-party documentation with a guaranteed DNS failure](https://third-party-link-outage.invalid/reference) diff --git a/scripts/ci/fixtures/docs-links/malformed-url.md b/scripts/ci/fixtures/docs-links/malformed-url.md deleted file mode 100644 index 5e8458f..0000000 --- a/scripts/ci/fixtures/docs-links/malformed-url.md +++ /dev/null @@ -1,3 +0,0 @@ -# Malformed URL fixture - -[Invalid IPv6 URL](https://[invalid) diff --git a/scripts/ci/test-ci-qualification.py b/scripts/ci/test-ci-qualification.py deleted file mode 100644 index 1649803..0000000 --- a/scripts/ci/test-ci-qualification.py +++ /dev/null @@ -1,454 +0,0 @@ -#!/usr/bin/env python3 -"""Behavior and workflow contracts for portable PHP SDK qualification.""" - -from __future__ import annotations - -import importlib.util -import json -import os -import re -import subprocess -import sys -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -CLASSIFIER = ROOT / "scripts/ci/classify-ci-qualification.py" -WORKFLOW = ROOT / ".github/workflows/ci.yml" -API_REFERENCE_WORKFLOW = ROOT / ".github/workflows/docs.yml" -EXTERNAL_LINK_WORKFLOW = ROOT / ".github/workflows/external-link-diagnostics.yml" -LARAVEL_ADOPTION_CONTRACT = ROOT / "docs/laravel-adoption-contract.json" - -SPEC = importlib.util.spec_from_file_location("ci_qualification", CLASSIFIER) -if SPEC is None or SPEC.loader is None: - raise RuntimeError("Unable to load CI qualification classifier") -MODULE = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - - -def workflow_job_source(source: str, name: str) -> str: - marker = f" {name}:\n" - if marker not in source: - raise AssertionError(f"workflow does not define the {name} job") - job = source.split(marker, 1)[1] - next_job = re.search(r"(?m)^ [a-z][a-z0-9-]*:\s*$", job) - return job if next_job is None else job[: next_job.start()] - - -def workflow_step_script(job: str, name: str) -> str: - marker = f" - name: {name}\n" - if marker not in job: - raise AssertionError(f"job does not define the {name} step") - step = job.split(marker, 1)[1] - run_marker = " run: |\n" - if run_marker not in step: - raise AssertionError(f"{name} is not a shell step") - source = step.split(run_marker, 1)[1] - lines = [] - for line in source.splitlines(): - if line and not line.startswith(" "): - break - lines.append(line[10:] if line else "") - return "\n".join(lines) - - -class RouteClassificationTest(unittest.TestCase): - def route( - self, - server_url: str, - event_name: str, - admission: str = "true", - ) -> str: - return MODULE.select_route(server_url, event_name, admission)[0] - - def test_github_pull_requests_use_bounded_focused_evidence(self) -> None: - self.assertEqual( - MODULE.FOCUSED, - self.route("https://github.com", "pull_request"), - ) - - def test_github_target_and_manual_runs_use_complete_qualification(self) -> None: - for event_name in ("push", "workflow_dispatch", "schedule", ""): - with self.subTest(event_name=event_name): - self.assertEqual( - MODULE.COMPLETE, - self.route("https://github.com", event_name), - ) - - def test_identified_alternate_ci_uses_focused_pr_and_target_sentinel(self) -> None: - server_url = "https://ci.example.test" - self.assertEqual(MODULE.FOCUSED, self.route(server_url, "pull_request")) - self.assertEqual(MODULE.SENTINEL, self.route(server_url, "push")) - - def test_unidentified_environments_fail_safe_to_complete(self) -> None: - cases = ( - ("", "pull_request", "true"), - ("not-a-url", "pull_request", "true"), - ("https://[", "pull_request", "true"), - ("https://ci.example.test", "pull_request", ""), - ("https://ci.example.test", "pull_request", "false"), - ("https://ci.example.test/path", "pull_request", "true"), - ("https://user@ci.example.test", "pull_request", "true"), - ("https://ci.example.test", "workflow_dispatch", "true"), - ) - for server_url, event_name, admission in cases: - with self.subTest( - server_url=server_url, - event_name=event_name, - admission=admission, - ): - self.assertEqual( - MODULE.COMPLETE, - self.route(server_url, event_name, admission), - ) - - -class ChangedPathClassificationTest(unittest.TestCase): - def classify(self, paths: list[str]) -> tuple[tuple[str, ...], str]: - return MODULE.classify_changed_files(paths) - - def test_known_surfaces_select_only_relevant_focused_evidence(self) -> None: - cases = { - "docs-prose": (["docs/quickstart.md"], ("docs",)), - "portal-content": ( - ["docs/portal/frameworks/laravel.md"], - ("docs", "docs-browser"), - ), - "docs-browser-template": ( - [".phpdoc/template/assets/api-reference.css"], - ("docs", "docs-browser"), - ), - "docs-browser-check": ( - ["scripts/check-docs-analytics-browser.mjs"], - ("docs", "docs-browser"), - ), - "api-reference-finalizer": ( - ["scripts/finalize-api-reference.php"], - ("docs", "docs-browser"), - ), - "quickstart-deployment-check": ( - ["scripts/qualify-quickstart-contract-deployment.mjs"], - ("docs",), - ), - "quickstart-release-availability": ( - ["scripts/qualify-quickstart-release-availability.mjs"], - ("docs",), - ), - "docs-link-fixture": ( - ["scripts/ci/fixtures/docs-links/external-dns-failure.md"], - ("docs",), - ), - "external-link-diagnostics": ( - [".github/workflows/external-link-diagnostics.yml"], - ("docs",), - ), - "generated-reference-source": ( - ["src/Client.php"], - ("docs", "runtime"), - ), - "runtime-test": (["tests/ClientTest.php"], ("runtime",)), - "runtime-fixture-runner": ( - ["scripts/ci/run-replay-regression-fixture.php"], - ("runtime",), - ), - "published-runtime-smoke": ( - [".github/workflows/service-mode-published-smoke.yml"], - ("runtime",), - ), - "ci": ([".github/workflows/ci.yml"], ("ci",)), - } - for name, (paths, expected) in cases.items(): - with self.subTest(name=name): - categories, reason = self.classify(paths) - self.assertEqual(expected, categories) - self.assertEqual("changed-paths-classified", reason) - - def test_api_reference_composer_input_selects_generated_docs_and_runtime_evidence( - self, - ) -> None: - deployment = API_REFERENCE_WORKFLOW.read_text() - self.assertIn(" - 'composer.json'", deployment) - - categories, reason = self.classify(["composer.json"]) - - self.assertEqual(("docs", "runtime"), categories) - self.assertEqual("changed-paths-classified", reason) - - def test_mixed_changes_combine_relevant_categories(self) -> None: - categories, _reason = self.classify( - [ - "README.md", - "src/Client.php", - ".github/workflows/service-mode-published-smoke.yml", - ] - ) - self.assertEqual( - ("docs", "runtime"), - categories, - ) - - def test_missing_invalid_or_unknown_paths_select_every_focused_category( - self, - ) -> None: - for paths in ([], ["../outside"], ["new-product-surface.txt"]): - with self.subTest(paths=paths): - categories, _reason = self.classify(paths) - self.assertEqual(MODULE.ALL_FOCUSED_CATEGORIES, categories) - - -class WorkflowQualificationContractTest(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.source = WORKFLOW.read_text() - - def test_complete_matrix_remains_on_target_push_and_manual_dispatch(self) -> None: - self.assertIn(" push:\n branches: [main]", self.source) - self.assertIn(" workflow_dispatch:", self.source) - - for name in ( - "regression-corpus", - "test", - "framework-compat", - "laravel-transition-compat", - "analyse", - "docs", - "package-smoke", - ): - with self.subTest(job=name): - job = workflow_job_source(self.source, name) - self.assertIn(" needs: qualification-route", job) - self.assertIn( - "needs.qualification-route.outputs.route == 'complete'", - job, - ) - - def test_laravel_jobs_match_the_published_adoption_matrix(self) -> None: - contract = json.loads(LARAVEL_ADOPTION_CONTRACT.read_text()) - framework_job = workflow_job_source(self.source, "framework-compat") - qualified = contract["framework"]["qualification_matrix"] - - self.assertEqual(len(qualified), framework_job.count("framework: Laravel")) - for cell in qualified: - package, constraint = cell["package"].split(":", 1) - block = ( - " - framework: Laravel\n" - f" version: '{cell['laravel']}'\n" - f" php: '{cell['php']}'\n" - f" package: '{package}:{constraint}'\n" - f" bootstrap: {cell['bootstrap']}\n" - " script: laravel.php" - ) - with self.subTest(cell=cell): - self.assertIn(block, framework_job) - - def test_fresh_laravel_cached_role_regression_is_executable(self) -> None: - framework_job = workflow_job_source(self.source, "framework-compat") - install = workflow_step_script( - framework_job, - "Install SDK into a fresh Laravel application for cached role isolation", - ) - exercise = workflow_step_script( - framework_job, - "Exercise fresh Laravel cached role isolation", - ) - - self.assertIn("composer create-project laravel/laravel:^13.0", install) - self.assertIn( - 'composer --working-dir="$application" require durable-workflow/sdk:@dev', - install, - ) - self.assertIn("bash tests/compat/laravel-fresh-config-cache.sh", exercise) - shell_reproduction = ( - ROOT / "tests/compat/laravel-fresh-config-cache.sh" - ).read_text() - for marker in ( - "php artisan config:cache", - 'php "$role_launcher" "$application" durable-workflow:worker', - 'php "$role_launcher" "$application" durable-workflow:application-client-probe', - "DURABLE_WORKFLOW_PROCESS_ROLE=worker", - "DURABLE_WORKFLOW_PROCESS_ROLE=client", - "DURABLE_WORKFLOW_PROCESS_TOKEN=", - "--assert-probes", - ): - self.assertIn(marker, shell_reproduction) - self.assertNotIn("Symfony\\Component\\Process", shell_reproduction) - role_launcher = ( - ROOT / "tests/compat/laravel-fresh-role-launch.php" - ).read_text() - for stage in ("shell-entry", "before-bootstrap"): - self.assertIn(f"LaravelFreshRoleProbe::record('{stage}')", role_launcher) - self.assertIn("LaravelFreshRoleProbe::record('after-bootstrap')", shell_reproduction) - self.assertLess( - framework_job.index( - "- name: Install SDK into a fresh Laravel application for cached role isolation" - ), - framework_job.index("- name: Exercise fresh Laravel cached role isolation"), - ) - - def test_established_laravel_transitions_are_executable(self) -> None: - transition_job = workflow_job_source(self.source, "laravel-transition-compat") - self.assertIn("source_mode: embedded_v1", transition_job) - self.assertIn("workflow: '^1.0'", transition_job) - self.assertIn("source_mode: embedded_v2", transition_job) - self.assertIn("workflow: '^2.0'", transition_job) - self.assertEqual( - 1, - transition_job.count( - "- name: Exercise the established Laravel application transition" - ), - ) - - def test_focused_gate_covers_structure_security_and_relevant_changes(self) -> None: - focused = workflow_job_source(self.source, "focused-candidate") - self.assertIn("needs.qualification-route.outputs.route == 'focused'", focused) - route = workflow_job_source(self.source, "qualification-route") - self.assertIn("python scripts/ci/test-ci-qualification.py", route) - self.assertIn("python scripts/ci/test-workflow-trust-boundaries.py", route) - self.assertIn("scripts/check-public-boundary.sh", focused) - self.assertIn("composer test", focused) - self.assertIn("npm run test:docs-analytics-deployment", focused) - self.assertIn("npm run test:docs-browser-failures", focused) - self.assertIn("--offline", focused) - self.assertIn("npx playwright install chromium --with-deps", focused) - self.assertIn("npm run check:docs-analytics-browser -- build/site", focused) - self.assertIn("npm run check:docs-browser", focused) - self.assertGreaterEqual(focused.count("'docs-browser'"), 3) - self.assertNotIn("matrix:", focused) - - def test_source_qualification_uses_only_deterministic_link_checks(self) -> None: - for source in (self.source, API_REFERENCE_WORKFLOW.read_text()): - root_dirs = re.findall(r"--root-dir\s+(\S+)", source) - self.assertGreater(len(root_dirs), 0) - self.assertFalse( - any(root.startswith(("/", "${{")) for root in root_dirs), - "link roots must be portable relative paths", - ) - - workflows = ( - workflow_job_source(self.source, "focused-candidate"), - workflow_job_source(self.source, "docs"), - workflow_job_source(API_REFERENCE_WORKFLOW.read_text(), "build"), - ) - for workflow in workflows: - with self.subTest(workflow=workflow[:40]): - self.assertIn( - "docker://lycheeverse/lychee@sha256:" - "e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d", - workflow, - ) - self.assertEqual(2, workflow.count("--offline")) - self.assertEqual(1, workflow.count("--include-fragments")) - root_dirs = re.findall(r"--root-dir\s+(\S+)", workflow) - self.assertCountEqual([".", "build/site"], root_dirs) - self.assertIn("build/site", workflow) - self.assertNotIn("--base-url /github/workspace/build/api/", workflow) - self.assertNotIn( - "--base-url ${{ github.workspace }}/build/api/", workflow - ) - self.assertNotIn("--base-url file:", workflow) - self.assertNotIn( - "--root-dir ${{ github.workspace }}/build/api", - workflow, - ) - self.assertNotIn("--exclude-path build/api/graphs/classes.html", workflow) - self.assertNotIn("build/api/**/*.html build/api/**/*.css", workflow) - self.assertNotIn("--method get", workflow) - self.assertIn("npm run test:docs-browser-failures", workflow) - self.assertIn("npm run check:docs-analytics-browser -- build/site", workflow) - - def test_link_regression_fixtures_have_one_required_ci_owner(self) -> None: - route = workflow_job_source(self.source, "qualification-route") - workflow_sources = self.source + API_REFERENCE_WORKFLOW.read_text() - fixtures = ( - "scripts/ci/fixtures/docs-links/external-dns-failure.md", - "scripts/ci/fixtures/docs-links/broken-internal.md", - "scripts/ci/fixtures/docs-links/malformed-url.md", - ) - - for fixture in fixtures: - with self.subTest(fixture=fixture): - self.assertIn(fixture, route) - self.assertEqual(1, workflow_sources.count(fixture)) - - self.assertIn("steps.broken-internal-link.outcome", route) - self.assertIn("steps.malformed-url.outcome", route) - self.assertEqual(2, route.count("continue-on-error: true")) - self.assertEqual(3, route.count("--offline")) - - def test_external_reachability_is_scheduled_and_non_blocking(self) -> None: - workflow = EXTERNAL_LINK_WORKFLOW.read_text() - diagnostic = workflow_job_source(workflow, "diagnose") - - self.assertIn(" schedule:", workflow) - self.assertNotIn(" push:", workflow) - self.assertNotIn(" pull_request:", workflow) - self.assertIn(" continue-on-error: true", diagnostic) - self.assertIn("--method get", diagnostic) - self.assertIn("--verbose", diagnostic) - self.assertIn("--format json", diagnostic) - self.assertIn("--output external-link-diagnostics.json", diagnostic) - self.assertIn("--include ^https?://", diagnostic) - self.assertNotIn("--offline", diagnostic) - self.assertIn("'{method: \"GET\", lychee: .}'", diagnostic) - self.assertIn("external-link-diagnostics-with-method.json", diagnostic) - self.assertIn("actions/upload-artifact@", diagnostic) - - def test_aggregate_decision_requires_the_selected_route_to_pass(self) -> None: - decision = workflow_job_source(self.source, "target-branch-qualification") - self.assertIn('test "$FOCUSED_RESULT" = success', decision) - self.assertIn('test "$TEST_RESULT" = success', decision) - self.assertIn('test "$FRAMEWORK_RESULT" = success', decision) - self.assertIn("before_elapsed_seconds_lower_bound=300", decision) - self.assertIn("after_elapsed_seconds=$elapsed_seconds", decision) - - def test_aggregate_decision_fails_when_selected_evidence_fails(self) -> None: - decision = workflow_job_source(self.source, "target-branch-qualification") - script = workflow_step_script( - decision, - "Require selected candidate or target evidence", - ) - skipped = { - "FOCUSED_RESULT": "skipped", - "TEST_RESULT": "skipped", - "FRAMEWORK_RESULT": "skipped", - "CORPUS_RESULT": "skipped", - "ANALYSIS_RESULT": "skipped", - "DOCS_RESULT": "skipped", - "PACKAGE_RESULT": "skipped", - } - complete = { - **skipped, - "TEST_RESULT": "success", - "FRAMEWORK_RESULT": "success", - "CORPUS_RESULT": "success", - "ANALYSIS_RESULT": "success", - "DOCS_RESULT": "success", - "PACKAGE_RESULT": "success", - } - cases = ( - ({**skipped, "ROUTE": "focused", "FOCUSED_RESULT": "success"}, 0), - ({**skipped, "ROUTE": "focused", "FOCUSED_RESULT": "failure"}, 1), - ({**complete, "ROUTE": "complete"}, 0), - ({**complete, "ROUTE": "complete", "TEST_RESULT": "failure"}, 1), - ( - {**complete, "ROUTE": "complete", "FRAMEWORK_RESULT": "failure"}, - 1, - ), - ({**skipped, "ROUTE": "sentinel"}, 0), - ) - for environment, expected_status in cases: - with self.subTest(environment=environment): - result = subprocess.run( - ["bash", "-eu", "-o", "pipefail", "-c", script], - env={**os.environ, **environment}, - check=False, - capture_output=True, - text=True, - ) - self.assertEqual(expected_status, int(result.returncode != 0)) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/ci/test-workflow-trust-boundaries.py b/scripts/ci/test-workflow-trust-boundaries.py deleted file mode 100644 index fb082b9..0000000 --- a/scripts/ci/test-workflow-trust-boundaries.py +++ /dev/null @@ -1,810 +0,0 @@ -#!/usr/bin/env python3 -"""Focused regressions for privileged native workflow dispatch boundaries.""" - -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] - - -def workflow_source(name: str) -> str: - return (REPOSITORY_ROOT / ".github" / "workflows" / name).read_text() - - -def job_source(source: str, name: str) -> str: - lines = source.splitlines() - start = lines.index(f" {name}:") + 1 - end = next( - ( - index - for index, line in enumerate(lines[start:], start=start) - if line.startswith(" ") and not line.startswith(" ") - ), - len(lines), - ) - return "\n".join(lines[start:end]) - - -def step_source(job: str, name: str) -> str: - lines = job.splitlines() - marker = f" - name: {name}" - start = lines.index(marker) - end = next( - ( - index - for index, line in enumerate(lines[start + 1 :], start=start + 1) - if line.startswith(" - ") - ), - len(lines), - ) - return "\n".join(lines[start:end]) - - -def workflow_dispatch_input_source(source: str, name: str) -> str: - lines = source.splitlines() - marker = f" {name}:" - start = lines.index(marker) - end = next( - ( - index - for index, line in enumerate(lines[start + 1 :], start=start + 1) - if line.startswith(" ") and not line.startswith(" ") - ), - len(lines), - ) - return "\n".join(lines[start:end]) - - -def step_script(step: str) -> str: - lines = step.splitlines() - start = lines.index(" run: |") + 1 - script: list[str] = [] - for line in lines[start:]: - if line and not line.startswith(" "): - break - script.append(line[10:] if line else "") - return "\n".join(script) - - -def job_condition(source: str) -> str: - lines = source.splitlines() - for index, line in enumerate(lines): - if not line.startswith(" if:"): - continue - value = line.removeprefix(" if:").strip() - if value not in {">", ">-", "|", "|-"}: - return value - - continuation: list[str] = [] - for candidate in lines[index + 1 :]: - if candidate.startswith(" "): - continuation.append(candidate.strip()) - continue - break - return " ".join(continuation) - raise AssertionError("privileged job has no job-level condition") - - -class PrivilegedWorkflowDispatchBoundaryTest(unittest.TestCase): - def assert_main_only(self, job: str, expected: str) -> None: - self.assertEqual(expected, job_condition(job)) - self.assertLess(job.index(" if:"), job.index(" steps:")) - - def test_api_reference_deployer_rejects_branch_built_artifacts(self) -> None: - source = workflow_source("docs.yml") - self.assertIn(" workflow_dispatch:", source) - deploy = job_source(source, "deploy") - self.assert_main_only( - deploy, - "github.server_url == 'https://github.com' && " - "github.ref == 'refs/heads/main' && " - "needs.build.outputs.release_published == 'true'", - ) - for privileged_marker in ( - "environment:", - "id-token: write", - "pages: write", - "actions/deploy-pages@", - ): - self.assertIn(privileged_marker, deploy) - - def test_published_smokes_reject_caller_selected_refs_before_secret_use( - self, - ) -> None: - workflows = { - "framework-bridges-published-smoke.yml": ( - "framework-service-mode", - ( - "secrets.DURABLE_WORKFLOW_SERVER_URL", - "secrets.DURABLE_WORKFLOW_NAMESPACE", - "secrets.DURABLE_WORKFLOW_CLIENT_TOKEN", - "secrets.DURABLE_WORKFLOW_WORKER_TOKEN", - ), - ), - "service-mode-published-smoke.yml": ( - "source-free-service-mode", - ( - "secrets.DURABLE_WORKFLOW_SERVER_URL", - "secrets.DURABLE_WORKFLOW_NAMESPACE", - "secrets.DURABLE_WORKFLOW_CLIENT_TOKEN", - "secrets.DURABLE_WORKFLOW_WORKER_TOKEN", - ), - ), - } - for workflow, (job, secret_markers) in workflows.items(): - with self.subTest(workflow=workflow): - source = workflow_source(workflow) - self.assertIn(" workflow_dispatch:", source) - smoke = job_source(source, job) - self.assert_main_only(smoke, "github.ref == 'refs/heads/main'") - for privileged_marker in ( - "environment: published-service-smoke", - *secret_markers, - ): - self.assertIn(privileged_marker, smoke) - self.assertLess( - smoke.index(" if:"), smoke.index(privileged_marker) - ) - - def test_published_smokes_accept_only_exact_release_versions(self) -> None: - workflows = { - "framework-bridges-published-smoke.yml": "framework-service-mode", - "service-mode-published-smoke.yml": "source-free-service-mode", - } - exact_versions = ( - "0.1.16", - "2.0.0", - "2.0.0-alpha.1", - "2.0.0-beta.21", - "2.0.0-rc.9", - ) - mutable_selectors = ( - "dev-main", - "dev-feature#0123456789abcdef0123456789abcdef01234567", - "2.x", - "2.0.*", - "^2.0", - "~2.0.0", - ">=2.0.0", - "2.0.0 || 3.0.0", - "2.0.0 as 3.0.0", - "2.0.0@dev", - "2.0.0-rc.9@RC", - "https://github.com/durable-workflow/sdk-php.git", - ) - - for workflow, job in workflows.items(): - smoke = job_source(workflow_source(workflow), job) - validation = step_source(smoke, "Validate the exact published SDK version") - script = step_script(validation) - self.assertLess( - smoke.index(validation), smoke.index("shivammathur/setup-php@") - ) - - for version in exact_versions: - with self.subTest(workflow=workflow, accepted=version): - result = subprocess.run( - ["bash", "-eu", "-o", "pipefail", "-c", script], - env={**os.environ, "SDK_VERSION": version}, - check=False, - capture_output=True, - text=True, - ) - self.assertEqual(0, result.returncode, result.stderr) - - for selector in mutable_selectors: - with self.subTest(workflow=workflow, rejected=selector): - result = subprocess.run( - ["bash", "-eu", "-o", "pipefail", "-c", script], - env={**os.environ, "SDK_VERSION": selector}, - check=False, - capture_output=True, - text=True, - ) - self.assertNotEqual(0, result.returncode) - - def test_published_smokes_verify_the_installed_release_reference(self) -> None: - workflows = { - "framework-bridges-published-smoke.yml": ( - "framework-service-mode", - "Create a fresh framework application from published artifacts", - "Prepare the framework runtime qualification", - ), - "service-mode-published-smoke.yml": ( - "source-free-service-mode", - "Install only the published package", - "Complete a class-oriented workflow against the published endpoint", - ), - } - for workflow, (job, install_name, runtime_name) in workflows.items(): - with self.subTest(workflow=workflow): - smoke = job_source(workflow_source(workflow), job) - resolve = step_source( - smoke, "Resolve the immutable published SDK release" - ) - install = step_source(smoke, install_name) - verify = step_source(smoke, "Verify the installed SDK release identity") - runtime = step_source(smoke, runtime_name) - - self.assertLess(smoke.index(resolve), smoke.index(install)) - self.assertLess(smoke.index(install), smoke.index(verify)) - self.assertLess(smoke.index(verify), smoke.index(runtime)) - for marker in ( - 'composer show durable-workflow/sdk "$SDK_VERSION" --all --format=json', - "($metadata['name'] ?? null) !== 'durable-workflow/sdk'", - "($metadata['versions'] ?? null) !== [$version]", - "preg_match('/\\A[0-9a-f]{40}\\z/i', $reference)", - ): - self.assertIn(marker, resolve) - for marker in ( - "composer.lock", - "($package['name'] ?? null) === 'durable-workflow/sdk'", - "($packages[0]['version'] ?? null) !== $version", - "strtolower($reference) !== strtolower($expectedReference)", - ): - self.assertIn(marker, verify) - - def test_framework_smoke_requires_an_exact_python_sdk_product_version( - self, - ) -> None: - source = workflow_source("framework-bridges-published-smoke.yml") - declared_input = workflow_dispatch_input_source( - source, - "python_sdk_version", - ) - smoke = job_source(source, "framework-service-mode") - validation = step_source( - smoke, - "Validate the exact published Python SDK version", - ) - script = step_script(validation) - - self.assertIn(" required: true", declared_input) - self.assertIn(" type: string", declared_input) - self.assertIn( - "PYTHON_SDK_VERSION: ${{ inputs.python_sdk_version }}", - validation, - ) - self.assertLess( - smoke.index(validation), - smoke.index("actions/setup-python@"), - ) - - exact_versions = { - "0.1.16": "0.1.16", - "2.0.0": "2.0.0", - "2.0.0-alpha.1": "2.0.0a1", - "2.0.0-beta.21": "2.0.0b21", - "2.0.0-rc.9": "2.0.0rc9", - } - mutable_or_non_product_versions = ( - "", - "2.0.0rc9", - "2.0.0-rc9", - "2.0.*", - "^2.0", - "~2.0.0", - ">=2.0.0", - "latest", - ) - - for product_version, pep440_version in exact_versions.items(): - with ( - self.subTest(accepted=product_version), - tempfile.TemporaryDirectory() as directory, - ): - github_env = Path(directory) / "github-env" - result = subprocess.run( - ["bash", "-eu", "-o", "pipefail", "-c", script], - env={ - **os.environ, - "GITHUB_ENV": str(github_env), - "PYTHON_SDK_VERSION": product_version, - }, - check=False, - capture_output=True, - text=True, - ) - self.assertEqual(0, result.returncode, result.stderr) - self.assertEqual( - f"PYTHON_SDK_PEP440_VERSION={pep440_version}\n", - github_env.read_text(), - ) - - for version in mutable_or_non_product_versions: - with ( - self.subTest(rejected=version), - tempfile.TemporaryDirectory() as directory, - ): - result = subprocess.run( - ["bash", "-eu", "-o", "pipefail", "-c", script], - env={ - **os.environ, - "GITHUB_ENV": str(Path(directory) / "github-env"), - "PYTHON_SDK_VERSION": version, - }, - check=False, - capture_output=True, - text=True, - ) - self.assertNotEqual(0, result.returncode) - - def test_framework_smoke_installs_and_reports_the_exact_python_sdk(self) -> None: - smoke = job_source( - workflow_source("framework-bridges-published-smoke.yml"), - "framework-service-mode", - ) - install = step_source( - smoke, - "Install and verify the exact published Python activity worker", - ) - - self.assertIn( - '"durable-workflow==${PYTHON_SDK_PEP440_VERSION}"', - install, - ) - self.assertNotIn("durable-workflow~=", install) - self.assertIn('expected = os.environ["PYTHON_SDK_PEP440_VERSION"]', install) - self.assertIn("if actual != expected:", install) - self.assertIn("requested_python_sdk_version=", install) - self.assertIn("installed_python_sdk_version=", install) - self.assertIn('os.environ["GITHUB_STEP_SUMMARY"]', install) - - def test_service_mode_smoke_binds_the_declared_server_release(self) -> None: - source = workflow_source("service-mode-published-smoke.yml") - smoke = job_source( - source, - "source-free-service-mode", - ) - validate = step_source(smoke, "Validate the exact qualified Server version") - verify = step_source(smoke, "Verify the installed SDK release identity") - runtime = step_source( - smoke, - "Complete a class-oriented workflow against the published endpoint", - ) - - self.assertIn("server_version:", source) - self.assertIn("SERVER_VERSION: ${{ inputs.server_version }}", validate) - self.assertIn("supported-server-versions", verify) - self.assertIn("getenv('SERVER_VERSION')", verify) - self.assertIn("QUALIFIED_SERVER_VERSION: ${{ inputs.server_version }}", runtime) - self.assertIn("$client->clusterInfo()->version", runtime) - - def test_published_laravel_smoke_qualifies_both_runtime_destinations(self) -> None: - journey = json.loads( - (REPOSITORY_ROOT / "docs/laravel-adoption-contract.json").read_text() - )["representative_journey"] - source = workflow_source("framework-bridges-published-smoke.yml") - smoke = job_source(source, "framework-service-mode") - validate = step_source(smoke, "Validate the exact current Server version") - verify = step_source(smoke, "Verify the installed SDK release identity") - configure = step_source( - smoke, "Configure Laravel through auto-discovery and vendor publish" - ) - fake = step_source( - smoke, "Prove the published Laravel fake without a runtime" - ) - cloud_validation = step_source(smoke, "Validate managed Cloud configuration") - standalone_runtime = step_source(smoke, "Start the exact standalone Server") - runtime_driver = step_source( - smoke, "Prepare the framework runtime qualification" - ) - standalone_execution = step_source( - smoke, "Complete the published workflow against standalone Server" - ) - cloud_execution = step_source( - smoke, "Complete the published workflow against managed Cloud" - ) - runtime = runtime_driver + standalone_execution + cloud_execution - - self.assertIn("server_version:", source) - self.assertIn("SERVER_VERSION: ${{ inputs.server_version }}", validate) - self.assertIn("supported-server-versions", verify) - self.assertIn("getenv('SERVER_VERSION')", verify) - destinations = { - "standalone-server": "job-local-server", - "managed-cloud": "protected-cloud", - } - for destination, transport in destinations.items(): - self.assertIn( - " - framework: laravel\n" - f" runtime: {destination}\n" - f" transport: {transport}", - smoke, - ) - self.assertIn( - " - framework: symfony\n" - f" runtime: {destination}\n" - f" transport: {transport}", - smoke, - ) - for absent_alias in ( - "DURABLE_WORKFLOW_STANDALONE_SERVER_URL", - "DURABLE_WORKFLOW_STANDALONE_SERVER_NAMESPACE", - "DURABLE_WORKFLOW_STANDALONE_SERVER_CLIENT_TOKEN", - "DURABLE_WORKFLOW_STANDALONE_SERVER_WORKER_TOKEN", - "DURABLE_WORKFLOW_CLOUD_URL", - "DURABLE_WORKFLOW_CLOUD_NAMESPACE", - "DURABLE_WORKFLOW_CLOUD_CLIENT_TOKEN", - "DURABLE_WORKFLOW_CLOUD_WORKER_TOKEN", - ): - self.assertNotIn(absent_alias, smoke) - for protected_secret in ( - "secrets.DURABLE_WORKFLOW_SERVER_URL", - "secrets.DURABLE_WORKFLOW_NAMESPACE", - "secrets.DURABLE_WORKFLOW_CLIENT_TOKEN", - "secrets.DURABLE_WORKFLOW_WORKER_TOKEN", - ): - self.assertIn(protected_secret, cloud_validation) - self.assertIn(protected_secret, cloud_execution) - - for standalone_marker in ( - 'server_image="durableworkflow/server:${SERVER_VERSION}"', - "mysql:8.0.43", - "redis:7-alpine", - "--tmpfs /var/lib/mysql:", - "--tmpfs /data:", - '"$server_image" server-bootstrap', - "DW_OPERATOR_TOKEN", - "DW_WORKER_TOKEN", - "DW_AUTH_BACKWARD_COMPATIBLE=false", - '"$runtime_url/api/ready"', - '($ready["status"] ?? null) === "ready"', - ): - self.assertIn(standalone_marker, standalone_runtime) - self.assertNotIn("secrets.", standalone_runtime) - self.assertNotIn("secrets.", standalone_execution) - self.assertIn( - "standalone-server:job-local-server|managed-cloud:protected-cloud", - runtime_driver, - ) - - for marker in ( - "private PublishedGreetingPrefix $prefix", - "private LaravelWorkflowClientInterface $workflows", - "return $this->workflows->start(", - "PublishedGreetingWorkflow::class", - "php laravel-role-launch.php durable-workflow:worker", - ): - self.assertIn(marker, configure + runtime) - self.assertIn("php artisan durable-workflow:published-fake", fake) - self.assertIn("assertWorkflowStarted", configure) - self.assertIn("assertResultRequested", configure) - self.assertNotIn("secrets.", fake) - self.assertLess(smoke.index(fake), smoke.index(runtime_driver)) - self.assertIn("QUALIFIED_SERVER_VERSION: ${{ inputs.server_version }}", runtime) - self.assertIn("clusterInfo()->version", runtime) - self.assertIn( - "php laravel-role-launch.php durable-workflow:published-greeting", - runtime, - ) - for versioning_marker in ( - "getVersion('published-framework-greeting', 1, 1)", - "getVersion('published-framework-greeting', 1, 2)", - "VersionMarkerRecorded", - "run_client_phase start", - "run_client_phase finish", - "worker-initial.log", - "worker-upgraded.log", - "count($markers) !== 1", - ): - self.assertIn(versioning_marker, configure + runtime) - for concurrency_marker in ( - "childWorkflow('laravel.child-greeting'", - "#[Activity('laravel.fail')]", - "parallel_group_path", - "count($scheduled) !== 9", - "count($mixedGroups) !== 1", - "count($failures) !== 1", - "partially completed mixed group", - ): - self.assertIn(concurrency_marker, configure + runtime) - self.assertNotIn("continue-on-error", runtime) - self.assertIn(f"#[Workflow('{journey['workflow_type']}')]", configure) - self.assertIn(f"#[Activity('{journey['activity_type']}')]", configure) - self.assertIn(repr(journey["input"][0]), configure) - self.assertIn(repr(journey["result"]), configure) - - def test_framework_runtime_transport_scripts_are_valid_bash(self) -> None: - smoke = job_source( - workflow_source("framework-bridges-published-smoke.yml"), - "framework-service-mode", - ) - standalone = step_script(step_source(smoke, "Start the exact standalone Server")) - prepare = step_script( - step_source(smoke, "Prepare the framework runtime qualification") - ) - driver = prepare.split("<<'BASH'\n", 1)[1].rsplit("\nBASH\n", 1)[0] - - for name, script in ( - ("standalone transport", standalone), - ("runtime driver", driver), - ): - with self.subTest(script=name): - syntax = subprocess.run( - ["bash", "-n"], - input=script, - check=False, - capture_output=True, - text=True, - ) - self.assertEqual(0, syntax.returncode, syntax.stderr) - - def test_published_framework_release_signal_is_zero_argument_and_diagnostic( - self, - ) -> None: - smoke = job_source( - workflow_source("framework-bridges-published-smoke.yml"), - "framework-service-mode", - ) - - self.assertEqual(2, smoke.count("#[Signal('published.release')]")) - self.assertEqual(2, smoke.count("public function release(): void {}")) - self.assertEqual(2, smoke.count("$handle->signal('published.release');")) - self.assertNotIn("$handle->signal('published.release',", smoke) - self.assertEqual( - 2, - smoke.count( - "catch (\\DurableWorkflow\\Exception\\SignalFailed $exception)" - ), - ) - self.assertEqual(2, smoke.count("'reason' => $exception->reason")) - self.assertEqual(2, smoke.count("'details' => $exception->details")) - - def test_published_smoke_credentials_are_runtime_step_scoped(self) -> None: - framework = job_source( - workflow_source("framework-bridges-published-smoke.yml"), - "framework-service-mode", - ) - cloud_validation = step_source( - framework, "Validate managed Cloud configuration" - ) - cloud_runtime = step_source( - framework, "Complete the published workflow against managed Cloud" - ) - non_cloud_steps = framework.replace(cloud_validation, "").replace( - cloud_runtime, "" - ) - self.assertNotIn("secrets.", framework[: framework.index(" steps:")]) - self.assertNotIn("secrets.", non_cloud_steps) - for step in (cloud_validation, cloud_runtime): - self.assertLess(step.index(" env:"), step.index(" run:")) - for marker in ( - "secrets.DURABLE_WORKFLOW_SERVER_URL", - "secrets.DURABLE_WORKFLOW_NAMESPACE", - "secrets.DURABLE_WORKFLOW_CLIENT_TOKEN", - "secrets.DURABLE_WORKFLOW_WORKER_TOKEN", - ): - self.assertEqual(2, framework.count(marker)) - self.assertIn(marker, cloud_validation) - self.assertIn(marker, cloud_runtime) - - service = job_source( - workflow_source("service-mode-published-smoke.yml"), - "source-free-service-mode", - ) - runtime = step_source( - service, "Complete a class-oriented workflow against the published endpoint" - ) - non_runtime = service.replace(runtime, "") - self.assertNotIn("secrets.", service[: service.index(" steps:")]) - self.assertNotIn("secrets.", non_runtime) - self.assertLess(runtime.index(" env:"), runtime.index(" run:")) - for marker in ( - "secrets.DURABLE_WORKFLOW_SERVER_URL", - "secrets.DURABLE_WORKFLOW_NAMESPACE", - "secrets.DURABLE_WORKFLOW_CLIENT_TOKEN", - "secrets.DURABLE_WORKFLOW_WORKER_TOKEN", - ): - self.assertEqual(1, service.count(marker)) - self.assertIn(marker, runtime) - - def test_published_smokes_fail_closed_on_incomplete_or_shared_credentials( - self, - ) -> None: - workflows = { - "framework-bridges-published-smoke.yml": ( - "framework-service-mode", - "Validate managed Cloud configuration", - "DURABLE_WORKFLOW_SERVER_URL", - "DURABLE_WORKFLOW_CLIENT_TOKEN", - ), - "service-mode-published-smoke.yml": ( - "source-free-service-mode", - "Complete a class-oriented workflow against the published endpoint", - "DURABLE_WORKFLOW_SERVER_URL", - "DURABLE_WORKFLOW_CLIENT_TOKEN", - ), - } - for workflow, ( - job, - runtime_name, - endpoint_name, - client_token_name, - ) in workflows.items(): - with self.subTest(workflow=workflow): - smoke = job_source(workflow_source(workflow), job) - runtime = step_source(smoke, runtime_name) - guard = step_script(runtime).split("\n\n", 1)[0] - environment = { - **os.environ, - endpoint_name: "https://runtime.example", - "DURABLE_WORKFLOW_NAMESPACE": "published-sdk-smoke", - client_token_name: "client-secret-value", - "DURABLE_WORKFLOW_WORKER_TOKEN": "worker-secret-value", - } - - missing = subprocess.run( - ["bash", "-eu", "-o", "pipefail", "-c", guard], - env={ - key: value - for key, value in environment.items() - if key != "DURABLE_WORKFLOW_NAMESPACE" - }, - check=False, - capture_output=True, - text=True, - ) - self.assertNotEqual(0, missing.returncode) - self.assertIn("DURABLE_WORKFLOW_NAMESPACE", missing.stderr) - self.assertNotIn("client-secret-value", missing.stderr) - self.assertNotIn("worker-secret-value", missing.stderr) - - shared = subprocess.run( - ["bash", "-eu", "-o", "pipefail", "-c", guard], - env={ - **environment, - "DURABLE_WORKFLOW_WORKER_TOKEN": "client-secret-value", - }, - check=False, - capture_output=True, - text=True, - ) - self.assertNotEqual(0, shared.returncode) - self.assertIn( - "client and worker credentials must be distinct", shared.stderr - ) - self.assertNotIn("client-secret-value", shared.stderr) - - complete = subprocess.run( - ["bash", "-eu", "-o", "pipefail", "-c", guard], - env=environment, - check=False, - capture_output=True, - text=True, - ) - self.assertEqual(0, complete.returncode, complete.stderr) - - def test_published_smokes_keep_role_credentials_on_their_own_operations( - self, - ) -> None: - service = step_source( - job_source( - workflow_source("service-mode-published-smoke.yml"), - "source-free-service-mode", - ), - "Complete a class-oriented workflow against the published endpoint", - ) - framework_job = job_source( - workflow_source("framework-bridges-published-smoke.yml"), - "framework-service-mode", - ) - framework = step_source( - framework_job, "Prepare the framework runtime qualification" - ) - laravel_configuration = step_source( - framework_job, "Configure Laravel through auto-discovery and vendor publish" - ) - symfony_configuration = step_source( - framework_job, "Configure Symfony Bundle and autowired handlers" - ) - quickstart_worker = (REPOSITORY_ROOT / "examples" / "worker.php").read_text() - quickstart_client = (REPOSITORY_ROOT / "examples" / "client.php").read_text() - - for runtime in (service, framework): - self.assertIn("env -u DURABLE_WORKFLOW_WORKER_TOKEN php", runtime) - self.assertNotIn("DURABLE_WORKFLOW_AUTH_TOKEN", runtime) - - self.assertIn( - "controlToken: quickstartEnvironment('DURABLE_WORKFLOW_CLIENT_TOKEN')", - quickstart_client, - ) - self.assertIn("env -u DURABLE_WORKFLOW_CLIENT_TOKEN php", service) - self.assertIn( - 'example_dir="$consumer/vendor/durable-workflow/sdk/examples"', - service, - ) - self.assertIn('cp "$example_dir/$source" "$consumer/$source"', service) - self.assertNotIn("tee worker.php", service) - self.assertNotIn("tee client.php", service) - self.assertIn( - "private LaravelWorkflowClientInterface $workflows", - laravel_configuration, - ) - self.assertIn("$this->workflows->start(", laravel_configuration) - self.assertNotIn("$this->workflows->startWorkflow(", laravel_configuration) - self.assertIn( - "app(\\App\\Actions\\StartPublishedGreeting::class)", - laravel_configuration, - ) - self.assertIn("php artisan config:cache", framework) - self.assertIn("-u DURABLE_WORKFLOW_TOKEN", framework) - self.assertIn("-u DURABLE_WORKFLOW_CLIENT_TOKEN", framework) - self.assertIn("-u DURABLE_WORKFLOW_WORKER_TOKEN", framework) - self.assertIn("bootstrap/cache/config.php", framework) - self.assertIn("$configuration['durable-workflow']['credentials']", framework) - self.assertIn("DURABLE_WORKFLOW_PROCESS_ROLE=worker", framework) - self.assertIn("DURABLE_WORKFLOW_PROCESS_ROLE=client", framework) - self.assertIn("DURABLE_WORKFLOW_PROCESS_TOKEN=", framework) - self.assertIn("'stage' => 'dotenv-file'", framework) - self.assertIn("'shell-entry'", laravel_configuration) - self.assertIn("'before-bootstrap'", laravel_configuration) - self.assertIn("'after-bootstrap'", laravel_configuration) - self.assertIn("'installed_sdk_version'", framework) - self.assertIn("'installed_sdk_source_reference'", framework) - self.assertIn( - "$kernel->getContainer()->get(WorkflowClientInterface::class)", framework - ) - self.assertIn("env -u DURABLE_WORKFLOW_CLIENT_TOKEN php", framework) - self.assertIn("Registered and polling:", framework) - self.assertIn("workflows=[", framework) - self.assertIn("laravel.greeting", framework) - self.assertIn("laravel.child-greeting", framework) - self.assertIn("activities=[", framework) - self.assertIn("laravel.greet", framework) - self.assertIn("laravel.fail", framework) - self.assertIn( - "workerToken: quickstartEnvironment('DURABLE_WORKFLOW_WORKER_TOKEN')", - quickstart_worker, - ) - self.assertNotIn("DURABLE_WORKFLOW_WORKER_TOKEN", quickstart_client) - self.assertNotIn("DURABLE_WORKFLOW_CLIENT_TOKEN", quickstart_worker) - self.assertIn( - "control_token: '%env(default::DURABLE_WORKFLOW_CLIENT_TOKEN)%'", - symfony_configuration, - ) - self.assertIn( - "worker_token: '%env(default::DURABLE_WORKFLOW_WORKER_TOKEN)%'", - symfony_configuration, - ) - self.assertNotIn("DURABLE_WORKFLOW_TOKEN", symfony_configuration) - - def test_published_smokes_require_graceful_worker_shutdown(self) -> None: - workflows = { - "framework-bridges-published-smoke.yml": ( - "framework-service-mode", - "Prepare the framework runtime qualification", - "php laravel-role-launch.php durable-workflow:published-greeting", - ), - "service-mode-published-smoke.yml": ( - "source-free-service-mode", - "Complete a class-oriented workflow against the published endpoint", - "env -u DURABLE_WORKFLOW_WORKER_TOKEN php client.php", - ), - } - for workflow, (job, runtime_name, client) in workflows.items(): - with self.subTest(workflow=workflow): - runtime = step_script( - step_source( - job_source(workflow_source(workflow), job), runtime_name - ) - ) - self.assertIn(client, runtime) - self.assertIn('wait "$worker_pid" || worker_status=$?', runtime) - self.assertNotIn('wait "$worker_pid" 2>/dev/null || true', runtime) - self.assertIn("worker\\.shutdown_failed", runtime) - self.assertIn("HTTP[[:space:]]+403", runtime) - self.assertIn("403[[:space:]]+Forbidden", runtime) - self.assertIn('if [ "$worker_status" -ne 0 ]', runtime) - self.assertNotIn(f"{client} || true", runtime) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/qualify-docs-analytics-deployment.mjs b/scripts/qualify-docs-analytics-deployment.mjs deleted file mode 100644 index bad1873..0000000 --- a/scripts/qualify-docs-analytics-deployment.mjs +++ /dev/null @@ -1,507 +0,0 @@ -import assert from 'node:assert/strict'; -import process from 'node:process'; -import {setTimeout as delay} from 'node:timers/promises'; -import {pathToFileURL} from 'node:url'; -import {chromium} from 'playwright'; - -const SITE_HOSTNAME = 'php.durable-workflow.com'; -const PAGE_PATH = '/api/namespaces/durableworkflow-worker.html'; -export const BEACON_URL = 'https://static.cloudflareinsights.com/beacon.min.js'; -export const RUM_URL = 'https://cloudflareinsights.com/cdn-cgi/rum'; -export const DEPLOYMENT_AUDIT_URL = `https://${SITE_HOSTNAME}/deployment-audit.json`; -export const DEPLOYMENT_AUDIT_SCHEMA = 'durable-workflow.sdk-php.docs-deployment/v1'; -export const PROMOTION_EVENT_URL = 'https://cloud.durable-workflow.com/early-access/promotion-events'; -export const PROMOTION_SOURCE = 'sdk-php-reference'; -export const PROMOTION_DESTINATION = 'https://cloud.durable-workflow.com/early-access#source=sdk-php-reference'; -export const QUALIFICATION_EVENT = 'qualification'; - -const DEFAULT_TARGET_URL = new URL(PAGE_PATH, `https://${SITE_HOSTNAME}`); -const RUM_ENDPOINT = new URL(RUM_URL); -const PROMOTION_VIEWPORTS = [ - ['desktop', {width: 1440, height: 900}], - ['intermediate', {width: 768, height: 1024}], - ['mobile', {width: 390, height: 844}], - ['short-height', {width: 640, height: 360}], -]; -const PROMOTION_PAGES = [ - ['root', '/'], - ['Client API', '/api/classes/DurableWorkflow-Client.html'], -]; -const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/; - -function isRumRequest(url) { - return url.hostname === RUM_ENDPOINT.hostname && url.pathname === RUM_ENDPOINT.pathname; -} - -async function waitForCount(items, count, description) { - for (let attempt = 0; attempt < 50; attempt += 1) { - if (items.length >= count) return; - await delay(100); - } - assert.fail(`${description}: observed ${items.length} of ${count} requests.`); -} - -function promotionQualificationRewriteScript(eventUrl, source) { - return ` - (() => { - const eventUrl = ${JSON.stringify(eventUrl)}; - const source = ${JSON.stringify(source)}; - const nativeFetch = window.fetch.bind(window); - - window.fetch = function (input, init) { - const requestUrl = typeof input === 'string' ? input : input.url; - if (requestUrl !== eventUrl) return nativeFetch(input, init); - - const options = init || {}; - let initiatedPayload = null; - try { - initiatedPayload = JSON.parse(options.body); - } catch (_error) { - // The qualification fails on the recorded initiation shape below. - } - window.recordPromotionQualificationInitiation(initiatedPayload); - - return nativeFetch(input, { - ...options, - body: JSON.stringify({source, event: ${JSON.stringify(QUALIFICATION_EVENT)}}), - }); - }; - })(); - `; -} - -export async function verifyDeployedRevision(sourceRevision, contract = {}) { - assert.match( - sourceRevision ?? '', - SOURCE_REVISION_PATTERN, - 'The deployed PHP documentation source revision must be an exact commit SHA.', - ); - const auditUrl = contract.auditUrl ?? DEPLOYMENT_AUDIT_URL; - const fetchImpl = contract.fetchImpl ?? globalThis.fetch; - const attempts = contract.attempts ?? 12; - const retryDelayMs = contract.retryDelayMs ?? 10_000; - assert(Number.isInteger(attempts) && attempts > 0, 'Deployment audit attempts must be a positive integer.'); - assert(Number.isFinite(retryDelayMs) && retryDelayMs >= 0, 'Deployment audit retry delay must be non-negative.'); - - let lastObservation = 'the deployment audit was unavailable'; - for (let attempt = 1; attempt <= attempts; attempt += 1) { - try { - const response = await fetchImpl(auditUrl, { - cache: 'no-store', - credentials: 'omit', - headers: {accept: 'application/json'}, - redirect: 'error', - referrerPolicy: 'no-referrer', - }); - const contents = await response.text(); - let audit = null; - try { - audit = JSON.parse(contents); - } catch (_error) { - lastObservation = `the deployment audit returned invalid JSON with HTTP ${response.status}`; - } - if ( - response.status === 200 - && audit !== null - && typeof audit === 'object' - && !Array.isArray(audit) - && Object.keys(audit).sort().join(',') === 'schema,source_revision' - && audit.schema === DEPLOYMENT_AUDIT_SCHEMA - && audit.source_revision === sourceRevision - ) { - return audit; - } - if (audit !== null) { - lastObservation = `the deployment audit did not identify source revision ${sourceRevision}`; - } - } catch (error) { - lastObservation = `the deployment audit request failed: ${error.message}`; - } - - if (attempt < attempts) await delay(retryDelayMs); - } - - assert.fail(`The exact deployed PHP documentation candidate was not confirmed: ${lastObservation}.`); -} - -function normalizedHeaders(headers) { - return Object.freeze(Object.fromEntries( - Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]), - )); -} - -function promotionRequestContract(request, targetOrigin, source, event, eventUrl) { - assert.deepEqual(JSON.parse(request.body || 'null'), {source, event}); - assert.equal(request.method, 'POST'); - assert.equal(request.url, eventUrl); - const {headers} = request; - assert.equal(headers.authorization, undefined, 'Promotion analytics sent authorization data.'); - assert.equal(headers.cookie, undefined, 'Promotion analytics sent cookies.'); - assert.equal(headers['content-type'], 'text/plain'); - assert.equal(headers.origin, targetOrigin, 'Promotion analytics did not send the documentation origin.'); - assert.equal(headers.referer, `${targetOrigin}/`, 'Promotion analytics exposed more than its origin referrer.'); - assert.equal(headers['sec-fetch-mode'], 'cors'); - assert.equal(headers['sec-fetch-site'], 'same-site'); -} - -async function promotionResponseContract(response, targetOrigin) { - const headers = await response.allHeaders(); - assert.equal(response.status(), 204, 'The deployed receiver rejected promotion qualification.'); - assert.equal( - headers['access-control-allow-origin'], - targetOrigin, - 'The promotion receiver did not allow the documentation origin.', - ); - assert.match(headers['cache-control'] ?? '', /(?:^|,)\s*no-store\s*(?:,|$)/i, 'The promotion response was cacheable.'); - assert( - (headers.vary ?? '').split(',').map(value => value.trim().toLowerCase()).includes('origin'), - 'The promotion response did not vary by Origin.', - ); - assert.equal(headers['set-cookie'], undefined, 'The promotion receiver wrote a cookie.'); -} - -export async function qualifyPromotionReceiverValidation(contract = {}) { - const eventUrl = contract.eventUrl ?? PROMOTION_EVENT_URL; - const targetOrigin = contract.targetOrigin ?? `https://${SITE_HOSTNAME}`; - const fetchImpl = contract.fetchImpl ?? globalThis.fetch; - const invalidPayloads = [ - {source: `${PROMOTION_SOURCE}-invalid`, event: QUALIFICATION_EVENT}, - {source: PROMOTION_SOURCE, event: `${QUALIFICATION_EVENT}-invalid`}, - ]; - - for (const payload of invalidPayloads) { - const response = await fetchImpl(eventUrl, { - body: JSON.stringify(payload), - cache: 'no-store', - credentials: 'omit', - headers: { - 'content-type': 'text/plain', - origin: targetOrigin, - referer: `${targetOrigin}/`, - }, - method: 'POST', - redirect: 'error', - }); - assert.equal( - response.status, - 422, - `The promotion receiver accepted an invalid ${payload.source === PROMOTION_SOURCE ? 'event' : 'source'}.`, - ); - assert.equal(response.headers.get('set-cookie'), null, 'Promotion validation wrote a cookie.'); - } -} - -export async function qualifyAnalyticsTransport(context, target = DEFAULT_TARGET_URL) { - const targetUrl = target instanceof URL ? target : new URL(target); - const page = await context.newPage(); - const errors = []; - let beaconResponseStatus; - let rumResponse; - - page.on('console', message => { - if (message.type() === 'error') errors.push('browser console error'); - }); - page.on('pageerror', () => errors.push('uncaught browser page error')); - page.on('requestfailed', request => { - const url = new URL(request.url()); - if (url.href.startsWith(BEACON_URL) || isRumRequest(url)) { - errors.push(`Cloudflare request failed: ${url.hostname}${url.pathname}`); - } - }); - page.on('response', response => { - const url = new URL(response.url()); - if (url.href.startsWith(BEACON_URL)) beaconResponseStatus = response.status(); - if (isRumRequest(url)) { - rumResponse = {method: response.request().method(), status: response.status()}; - } - }); - - const response = await page.goto(targetUrl.href, {waitUntil: 'networkidle'}); - assert.equal(response?.status(), 200, 'The deployed nested API-reference page did not render.'); - await page.locator('.phpdocumentor-content').waitFor(); - await page.waitForFunction(({beaconUrl}) => ( - [...document.scripts].some(script => script.src.startsWith(beaconUrl)) - ), {beaconUrl: BEACON_URL}); - - const contract = await page.evaluate(({beaconUrl}) => { - const beacons = [...document.scripts].filter(script => script.src.startsWith(beaconUrl)); - const beacon = beacons[0]; - let configuration; - try { - configuration = JSON.parse(beacon?.dataset.cfBeacon || 'null'); - } catch (_error) { - configuration = null; - } - return { - beaconCount: beacons.length, - module: beacon?.type === 'module', - tokenOnly: configuration !== null - && Object.keys(configuration).length === 1 - && /^[a-f0-9]{32}$/.test(configuration.token), - deferred: beacon?.hasAttribute('defer') === true, - retiredUiCount: document.querySelectorAll('.dw-analytics-consent, .dw-analytics-preferences, #durable-workflow-analytics-consent, #durable-workflow-analytics-preferences').length, - googleCount: [...document.scripts].filter(script => /googletagmanager|google-analytics/.test(script.src)).length, - localStorageEntries: localStorage.length, - sessionStorageEntries: sessionStorage.length, - }; - }, {beaconUrl: BEACON_URL}); - - assert.deepEqual(contract, { - beaconCount: 1, - module: true, - tokenOnly: true, - deferred: false, - retiredUiCount: 0, - googleCount: 0, - localStorageEntries: 0, - sessionStorageEntries: 0, - }, 'The deployed page does not satisfy the supported cookie-free Cloudflare loader contract.'); - assert.deepEqual(await context.cookies(targetUrl.origin), [], 'The deployed analytics page wrote cookies.'); - assert(beaconResponseStatus && beaconResponseStatus >= 200 && beaconResponseStatus < 300, 'The deployed Cloudflare beacon module request did not succeed.'); - - for (let attempt = 0; attempt < 30 && rumResponse === undefined; attempt += 1) { - await delay(500); - } - if (rumResponse === undefined) { - await page.goto('about:blank'); - for (let attempt = 0; attempt < 30 && rumResponse === undefined; attempt += 1) { - await delay(500); - } - } - assert.equal(rumResponse?.method, 'POST', 'The deployed Cloudflare RUM request did not use POST.'); - assert(rumResponse.status >= 200 && rumResponse.status < 300, 'The deployed Cloudflare RUM request did not succeed.'); - assert.deepEqual(errors, [], 'The deployed analytics page emitted browser errors.'); -} - -export async function qualifyPromotionTransport(context, target, contract = {}) { - const targetUrl = target instanceof URL ? target : new URL(target); - const eventUrl = contract.eventUrl ?? PROMOTION_EVENT_URL; - const source = contract.source ?? PROMOTION_SOURCE; - const destination = contract.destination ?? PROMOTION_DESTINATION; - const navigationTimeoutMs = contract.navigationTimeoutMs ?? 30_000; - const destinationUrl = destination.split('#')[0]; - const page = await context.newPage(); - const errors = []; - const promotionRequests = []; - const promotionResponses = []; - const initiatedPromotionEvents = []; - const ignoredNetworkRequests = new Set(); - const pendingPromotionRequests = new Map(); - - function capturePageErrors(browserPage) { - browserPage.on('console', message => { - if (message.type() === 'error') errors.push(`console: ${message.text()}`); - }); - browserPage.on('pageerror', error => errors.push(`page: ${error.message}`)); - } - - capturePageErrors(page); - // Playwright can lose Chromium-generated fetch headers after this page is replaced by same-tab navigation. - // Pair the raw request events now so the qualification asserts an immutable pre-navigation snapshot. - const networkSession = await context.newCDPSession(page); - await networkSession.send('Network.enable'); - function capturePromotionRequest(requestId) { - const observation = pendingPromotionRequests.get(requestId); - if (!observation?.request) return; - if (observation.request.url !== eventUrl) { - pendingPromotionRequests.delete(requestId); - return; - } - if (!observation.headers) return; - - promotionRequests.push(Object.freeze({ - body: observation.request.postData ?? null, - headers: normalizedHeaders({...observation.request.headers, ...observation.headers}), - method: observation.request.method, - url: observation.request.url, - })); - pendingPromotionRequests.delete(requestId); - } - networkSession.on('Network.requestWillBeSent', event => { - if (event.request.url !== eventUrl) { - if (pendingPromotionRequests.delete(event.requestId)) return; - ignoredNetworkRequests.add(event.requestId); - return; - } - const observation = pendingPromotionRequests.get(event.requestId) ?? {}; - observation.request = event.request; - pendingPromotionRequests.set(event.requestId, observation); - capturePromotionRequest(event.requestId); - }); - networkSession.on('Network.requestWillBeSentExtraInfo', event => { - if (ignoredNetworkRequests.delete(event.requestId)) return; - const observation = pendingPromotionRequests.get(event.requestId) ?? {}; - observation.headers = event.headers; - pendingPromotionRequests.set(event.requestId, observation); - capturePromotionRequest(event.requestId); - }); - await page.exposeFunction('recordPromotionQualificationInitiation', payload => { - initiatedPromotionEvents.push(payload); - }); - await page.addInitScript({content: promotionQualificationRewriteScript(eventUrl, source)}); - context.on('requestfailed', request => { - if (request.url() === eventUrl && request.failure()?.errorText === 'net::ERR_ABORTED') return; - if (request.url() === eventUrl || request.url().startsWith(destinationUrl)) { - errors.push(`request: ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ''}`); - } - }); - context.on('response', response => { - if (response.url() === eventUrl) promotionResponses.push(response); - }); - - const response = await page.goto(targetUrl.href, {waitUntil: 'networkidle'}); - assert.equal(response?.status(), 200, `The deployed PHP reference page did not render: ${targetUrl.href}`); - const promotion = page.locator(`[data-promotion-source="${source}"]`); - const action = promotion.locator('[data-promotion-action="early-access"]'); - await promotion.waitFor(); - await promotion.scrollIntoViewIfNeeded(); - await waitForCount(promotionResponses, 1, 'Promotion qualification did not reach the deployed receiver'); - await waitForCount(promotionRequests, 1, 'Promotion qualification request metadata was not observable'); - await delay(150); - assert.equal(promotionRequests.length, 1, 'The deployed page emitted more than one initial qualification.'); - assert.equal(promotionResponses.length, 1, 'The receiver returned more than one initial qualification response.'); - promotionRequestContract(promotionRequests[0], targetUrl.origin, source, QUALIFICATION_EVENT, eventUrl); - await promotionResponseContract(promotionResponses[0], targetUrl.origin); - assert.equal(await action.getAttribute('href'), destination, 'The promotion lost its public early-access destination.'); - - await context.route(destinationUrl, async route => { - await waitForCount(promotionResponses, 2, 'Promotion click qualification did not reach the deployed receiver'); - await route.continue(); - }, {times: 1}); - const destinationMatches = url => { - const resolvedUrl = new URL(url); - resolvedUrl.hash = ''; - return resolvedUrl.href === destinationUrl; - }; - let destinationPage = page; - if (await action.getAttribute('target') === '_blank') { - [destinationPage] = await Promise.all([ - context.waitForEvent('page', {timeout: navigationTimeoutMs}), - action.click(), - ]); - capturePageErrors(destinationPage); - await destinationPage.waitForURL(destinationMatches, { - timeout: navigationTimeoutMs, - waitUntil: 'load', - }); - } else { - await Promise.all([ - page.waitForURL(destinationMatches, { - timeout: navigationTimeoutMs, - waitUntil: 'load', - }), - action.click(), - ]); - } - await waitForCount(promotionResponses, 2, 'Promotion click qualification did not reach the deployed receiver'); - await waitForCount(promotionRequests, 2, 'Promotion click request metadata was not observable'); - await delay(150); - assert.equal(promotionRequests.length, 2, 'The deployed page emitted duplicate promotion events.'); - assert.equal(promotionResponses.length, 2, 'The receiver returned duplicate promotion responses.'); - promotionRequestContract(promotionRequests[1], targetUrl.origin, source, QUALIFICATION_EVENT, eventUrl); - await promotionResponseContract(promotionResponses[1], targetUrl.origin); - assert.deepEqual( - initiatedPromotionEvents, - [ - {source, event: 'impression'}, - {source, event: 'click'}, - ], - 'The deployed promotion did not initiate exactly one impression and one click.', - ); - assert.equal(destinationPage.url(), destinationUrl, 'The public early-access form did not consume the source attribution fragment.'); - const destinationStatus = await destinationPage.evaluate(() => ( - performance.getEntriesByType('navigation')[0]?.responseStatus - )); - assert.equal(destinationStatus, 200, 'The public early-access form did not return HTTP 200.'); - const promotionSourceField = destinationPage.locator('input[type="hidden"][name="promotion_source"]'); - const destinationForm = destinationPage.locator('form').filter({has: promotionSourceField}); - await destinationForm.waitFor(); - assert.equal( - await destinationForm.evaluate(form => form.action), - destinationUrl, - 'The public early-access form lost its submission destination.', - ); - assert.equal( - await promotionSourceField.inputValue(), - source, - 'The public early-access form did not retain the bounded promotion source.', - ); - assert.equal( - await destinationForm.locator('input[type="radio"][name="intent"]:checked').inputValue(), - 'cohort', - 'The public early-access form did not select the intended launch cohort.', - ); - assert.deepEqual(errors, [], 'The deployed promotion contract emitted browser errors.'); -} - -export async function qualifyDeployedAnalytics({ - sourceRevision, - revisionContract, - browserType = chromium, - analyticsQualifier = qualifyAnalyticsTransport, - promotionQualifier = qualifyPromotionTransport, - receiverBoundaryQualifier = qualifyPromotionReceiverValidation, -} = {}) { - await verifyDeployedRevision(sourceRevision, revisionContract); - await receiverBoundaryQualifier(); - - const launchOptions = {headless: true}; - if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) { - launchOptions.executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; - } - const browser = await browserType.launch(launchOptions); - try { - const context = await browser.newContext({ - reducedMotion: 'reduce', - viewport: {width: 1440, height: 900}, - }); - try { - await analyticsQualifier(context); - } finally { - await context.close(); - } - - for (const [viewportName, viewport] of PROMOTION_VIEWPORTS) { - for (const [pageName, pagePath] of PROMOTION_PAGES) { - const promotionContext = await browser.newContext({ - reducedMotion: 'reduce', - viewport, - }); - try { - await promotionQualifier( - promotionContext, - new URL(pagePath, `https://${SITE_HOSTNAME}`), - ); - } catch (error) { - error.message = `${pageName} at ${viewportName}: ${error.message}`; - throw error; - } finally { - await promotionContext.close(); - } - } - } - } finally { - await browser.close(); - } - - process.stdout.write( - `Confirmed deployed revision ${sourceRevision}, Cloudflare transport, and all eight root/Client ` - + 'desktop, intermediate, mobile, and short-height checks through only the non-aggregating promotion ' - + 'qualification path while preserving bounded impression/click initiation and attributed destination behavior.\n', - ); -} - -export function sourceRevisionArgument(args) { - assert.deepEqual( - args.slice(0, 1), - ['--source-revision'], - 'Usage: qualify-docs-analytics-deployment.mjs --source-revision <40-character commit SHA>', - ); - assert.equal(args.length, 2, 'The deployment qualifier accepts exactly one source revision.'); - assert.match(args[1], SOURCE_REVISION_PATTERN, 'The source revision must be a 40-character lowercase commit SHA.'); - return args[1]; -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await qualifyDeployedAnalytics({sourceRevision: sourceRevisionArgument(process.argv.slice(2))}); -} diff --git a/scripts/qualify-docs-analytics-deployment.test.mjs b/scripts/qualify-docs-analytics-deployment.test.mjs deleted file mode 100644 index 947888d..0000000 --- a/scripts/qualify-docs-analytics-deployment.test.mjs +++ /dev/null @@ -1,564 +0,0 @@ -import assert from 'node:assert/strict'; -import {once} from 'node:events'; -import {readFile} from 'node:fs/promises'; -import http from 'node:http'; -import {after, before, test} from 'node:test'; -import process from 'node:process'; -import {chromium} from 'playwright'; -import { - BEACON_URL, - DEPLOYMENT_AUDIT_SCHEMA, - qualifyDeployedAnalytics, - qualifyAnalyticsTransport, - qualifyPromotionReceiverValidation, - qualifyPromotionTransport, - PROMOTION_SOURCE, - QUALIFICATION_EVENT, - RUM_URL, - sourceRevisionArgument, - verifyDeployedRevision, -} from './qualify-docs-analytics-deployment.mjs'; - -const VALID_TOKEN = '00000000000000000000000000000000'; -const SOURCE_REVISION = 'a'.repeat(40); -let browser; -let origin; -let promotionOrigin; -let promotionServer; -const promotionEvents = []; -let server; - -function analyticsPage(token = VALID_TOKEN) { - return ` - - -
API reference
- - - `; -} - -function promotionPage(eventUrl, destination, {target = null} = {}) { - const targetAttribute = target === null ? '' : ` target="${target}"`; - return ` - - -
API reference
- - - - `; -} - -function earlyAccessPage({ - formPath = '/early-access', - retainSource = true, - selectedIntent = 'cohort', -} = {}) { - return ` - - -
-

Request Cloud early access

-
- - - - -
-
- - - `; -} - -before(async () => { - promotionServer = http.createServer(async (request, response) => { - if (request.method === 'GET' && request.url === '/favicon.ico') { - response.writeHead(204); - response.end(); - return; - } - if (request.method === 'GET' && request.url === '/early-access') { - response.writeHead(200, {'content-type': 'text/html'}); - response.end(earlyAccessPage()); - return; - } - if (request.method === 'GET' && request.url === '/unattributed-early-access') { - response.writeHead(200, {'content-type': 'text/html'}); - response.end(earlyAccessPage({ - formPath: '/unattributed-early-access', - retainSource: false, - })); - return; - } - if (request.method === 'GET' && request.url === '/wrong-intent-early-access') { - response.writeHead(200, {'content-type': 'text/html'}); - response.end(earlyAccessPage({ - formPath: '/wrong-intent-early-access', - selectedIntent: 'updates', - })); - return; - } - if (request.method !== 'POST') { - response.writeHead(404); - response.end(); - return; - } - - let body = ''; - for await (const chunk of request) body += chunk; - const parsedBody = JSON.parse(body); - promotionEvents.push({ - body: parsedBody, - headers: request.headers, - method: request.method, - path: request.url, - }); - const boundedQualification = Object.keys(parsedBody).sort().join(',') === 'event,source' - && parsedBody.source === PROMOTION_SOURCE - && parsedBody.event === 'qualification'; - const responseStatus = request.url === '/failed-promotion-events' - ? 403 - : boundedQualification ? 204 : 422; - response.writeHead(responseStatus, { - 'access-control-allow-origin': origin, - 'cache-control': 'no-store', - vary: 'Origin', - }); - response.end(); - }); - promotionServer.listen(0, '127.0.0.1'); - await once(promotionServer, 'listening'); - const promotionAddress = promotionServer.address(); - assert(promotionAddress && typeof promotionAddress === 'object'); - promotionOrigin = `http://127.0.0.1:${promotionAddress.port}`; - - server = http.createServer((request, response) => { - if ( - request.url === '/promotion' - || request.url === '/failed-promotion' - || request.url === '/unattributed-promotion' - || request.url === '/wrong-intent-promotion' - ) { - const failed = request.url === '/failed-promotion'; - const destinationPath = { - '/unattributed-promotion': '/unattributed-early-access', - '/wrong-intent-promotion': '/wrong-intent-early-access', - }[request.url] ?? '/early-access'; - response.writeHead(200, {'content-type': 'text/html'}); - response.end(promotionPage( - `${promotionOrigin}/${failed ? 'failed-promotion-events' : 'promotion-events'}`, - `${promotionOrigin}${destinationPath}#source=${PROMOTION_SOURCE}`, - {target: request.url === '/wrong-intent-promotion' ? '_blank' : null}, - )); - return; - } - response.writeHead(request.url === '/failed-page' ? 500 : 200, {'content-type': 'text/html'}); - response.end(analyticsPage(request.url === '/malformed-loader' ? 'missing' : VALID_TOKEN)); - }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - const address = server.address(); - assert(address && typeof address === 'object'); - origin = `http://127.0.0.1:${address.port}`; - const launchOptions = {headless: true}; - if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) { - launchOptions.executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; - } - browser = await chromium.launch(launchOptions); -}); - -after(async () => { - await browser?.close(); - if (server?.listening) { - server.close(); - await once(server, 'close'); - } - if (promotionServer?.listening) { - promotionServer.close(); - await once(promotionServer, 'close'); - } -}); - -async function transportContext({beaconStatus = 200, rumMethod = 'POST', rumStatus = 200} = {}) { - const context = await browser.newContext({ - reducedMotion: 'reduce', - viewport: {width: 1440, height: 900}, - }); - const requests = {aggregation: [], rum: []}; - - await context.route('https://api.cloudflare.com/**', route => { - requests.aggregation.push(route.request().url()); - return route.abort(); - }); - await context.route(`${BEACON_URL}*`, route => route.fulfill({ - status: beaconStatus, - contentType: 'application/javascript', - headers: {'access-control-allow-origin': '*'}, - body: `fetch('${RUM_URL}', {method: '${rumMethod}', body: ${rumMethod === 'POST' ? "'{}'" : 'undefined'}});`, - })); - await context.route(`${RUM_URL}*`, route => { - requests.rum.push(route.request().method()); - return route.fulfill({ - status: rumStatus, - contentType: 'text/plain', - headers: {'access-control-allow-origin': '*'}, - body: 'ok', - }); - }); - - return {context, requests}; -} - -test('successful browser transport does not depend on analytics aggregation', async () => { - const {context, requests} = await transportContext(); - try { - await qualifyAnalyticsTransport(context, `${origin}/valid`); - assert.deepEqual(requests.rum, ['POST']); - assert.deepEqual(requests.aggregation, []); - } finally { - await context.close(); - } -}); - -test('malformed loader credentials fail deployed transport qualification', async () => { - const {context} = await transportContext(); - try { - await assert.rejects( - qualifyAnalyticsTransport(context, `${origin}/malformed-loader`), - /supported cookie-free Cloudflare loader contract/, - ); - } finally { - await context.close(); - } -}); - -test('an unsuccessful deployed page fails transport qualification', async () => { - const {context} = await transportContext(); - try { - await assert.rejects( - qualifyAnalyticsTransport(context, `${origin}/failed-page`), - /nested API-reference page did not render/, - ); - } finally { - await context.close(); - } -}); - -test('failed beacon module requests fail deployed transport qualification', async () => { - const {context} = await transportContext({beaconStatus: 503}); - try { - await assert.rejects( - qualifyAnalyticsTransport(context, `${origin}/failed-beacon`), - /Cloudflare beacon module request did not succeed/, - ); - } finally { - await context.close(); - } -}); - -test('failed RUM posts fail deployed transport qualification', async () => { - const {context} = await transportContext({rumStatus: 503}); - try { - await assert.rejects( - qualifyAnalyticsTransport(context, `${origin}/failed-rum`), - /Cloudflare RUM request did not succeed/, - ); - } finally { - await context.close(); - } -}); - -test('a successful non-POST RUM request fails deployed transport qualification', async () => { - const {context} = await transportContext({rumMethod: 'GET'}); - try { - await assert.rejects( - qualifyAnalyticsTransport(context, `${origin}/wrong-rum-method`), - /Cloudflare RUM request did not use POST/, - ); - } finally { - await context.close(); - } -}); - -test('live promotion qualification uses only the non-aggregating receiver path', async () => { - assert.equal(QUALIFICATION_EVENT, 'qualification'); - promotionEvents.length = 0; - const context = await browser.newContext({ - reducedMotion: 'reduce', - viewport: {width: 390, height: 844}, - }); - try { - await context.addCookies([{ - name: 'private-session', - value: 'must-not-leave-the-browser', - url: promotionOrigin, - }]); - await qualifyPromotionTransport(context, `${origin}/promotion`, { - destination: `${promotionOrigin}/early-access#source=${PROMOTION_SOURCE}`, - eventUrl: `${promotionOrigin}/promotion-events`, - }); - assert.equal(context.pages().length, 1, 'promotion qualification must follow the customer same-page navigation'); - assert.deepEqual( - promotionEvents.map(event => ({ - body: event.body, - headers: { - origin: event.headers.origin, - secFetchMode: event.headers['sec-fetch-mode'], - secFetchSite: event.headers['sec-fetch-site'], - }, - method: event.method, - path: event.path, - })), - [ - { - body: {source: PROMOTION_SOURCE, event: 'qualification'}, - headers: {origin, secFetchMode: 'cors', secFetchSite: 'same-site'}, - method: 'POST', - path: '/promotion-events', - }, - { - body: {source: PROMOTION_SOURCE, event: 'qualification'}, - headers: {origin, secFetchMode: 'cors', secFetchSite: 'same-site'}, - method: 'POST', - path: '/promotion-events', - }, - ], - ); - } finally { - await context.close(); - } -}); - -test('live receiver validation fails closed for invalid sources and events', async () => { - promotionEvents.length = 0; - - await qualifyPromotionReceiverValidation({ - eventUrl: `${promotionOrigin}/promotion-events`, - targetOrigin: origin, - }); - - assert.deepEqual( - promotionEvents.map(event => event.body), - [ - {source: `${PROMOTION_SOURCE}-invalid`, event: QUALIFICATION_EVENT}, - {source: PROMOTION_SOURCE, event: `${QUALIFICATION_EVENT}-invalid`}, - ], - ); - assert(promotionEvents.every(event => event.headers.cookie === undefined)); - assert(promotionEvents.every(event => event.headers.authorization === undefined)); - assert(promotionEvents.every(event => event.headers.origin === origin)); - assert(promotionEvents.every(event => event.headers.referer === `${origin}/`)); -}); - -test('promotion qualification rejects an unsuccessful receiver response', async () => { - const context = await browser.newContext({ - reducedMotion: 'reduce', - viewport: {width: 1440, height: 900}, - }); - try { - await assert.rejects( - qualifyPromotionTransport(context, `${origin}/failed-promotion`, { - destination: `${promotionOrigin}/early-access#source=${PROMOTION_SOURCE}`, - eventUrl: `${promotionOrigin}/failed-promotion-events`, - }), - /receiver rejected promotion qualification/, - ); - } finally { - await context.close(); - } -}); - -test('promotion qualification rejects a destination that drops source attribution', async () => { - const context = await browser.newContext({ - reducedMotion: 'reduce', - viewport: {width: 1440, height: 900}, - }); - try { - await assert.rejects( - qualifyPromotionTransport(context, `${origin}/unattributed-promotion`, { - destination: `${promotionOrigin}/unattributed-early-access#source=${PROMOTION_SOURCE}`, - eventUrl: `${promotionOrigin}/promotion-events`, - }), - /did not retain the bounded promotion source/, - ); - } finally { - await context.close(); - } -}); - -test('promotion qualification rejects a destination that selects the wrong intent', async () => { - const context = await browser.newContext({ - reducedMotion: 'reduce', - viewport: {width: 1440, height: 900}, - }); - try { - await assert.rejects( - qualifyPromotionTransport(context, `${origin}/wrong-intent-promotion`, { - destination: `${promotionOrigin}/wrong-intent-early-access#source=${PROMOTION_SOURCE}`, - eventUrl: `${promotionOrigin}/promotion-events`, - navigationTimeoutMs: 2_000, - }), - /did not select the intended launch cohort/, - ); - } finally { - await context.close(); - } -}); - -test('the deployment workflow keeps credential validation but has no aggregation dependency', async () => { - const workflow = await readFile(new URL('../.github/workflows/docs.yml', import.meta.url), 'utf8'); - const qualification = await readFile(new URL('./qualify-docs-analytics-deployment.mjs', import.meta.url), 'utf8'); - - assert.match(workflow, /CLOUDFLARE_WEB_ANALYTICS_TOKEN: \$\{\{ vars\.CLOUDFLARE_WEB_ANALYTICS_TOKEN \}\}/); - assert.match(workflow, /build\/site\/deployment-audit\.json/); - assert.match(workflow, /durable-workflow\.sdk-php\.docs-deployment\/v1/); - assert.match( - workflow, - /npm run qualify:docs-analytics-deployment --\s+--source-revision "\$\{\{ github\.sha \}\}"/, - ); - assert.doesNotMatch(workflow, /CLOUDFLARE_ACCOUNT_ID|CLOUDFLARE_ANALYTICS_API_TOKEN/); - assert.doesNotMatch(qualification, /api\.cloudflare\.com|rumPageloadEventsAdaptiveGroups|observedPageViews/); -}); - -test('the deployment audit must identify the exact source revision', async () => { - const observations = [ - {schema: DEPLOYMENT_AUDIT_SCHEMA, source_revision: 'b'.repeat(40)}, - {schema: DEPLOYMENT_AUDIT_SCHEMA, source_revision: SOURCE_REVISION}, - ]; - const requests = []; - - await verifyDeployedRevision(SOURCE_REVISION, { - attempts: 2, - retryDelayMs: 0, - fetchImpl: async (url, options) => { - requests.push({url, options}); - return { - status: 200, - text: async () => JSON.stringify(observations.shift()), - }; - }, - }); - - assert.equal(requests.length, 2); - assert(requests.every(request => request.options.credentials === 'omit')); - assert(requests.every(request => request.options.redirect === 'error')); -}); - -test('live browser verification cannot begin before the deployed revision matches', async () => { - let browserLaunched = false; - - await assert.rejects( - qualifyDeployedAnalytics({ - sourceRevision: SOURCE_REVISION, - revisionContract: { - attempts: 1, - retryDelayMs: 0, - fetchImpl: async () => ({ - status: 200, - text: async () => JSON.stringify({ - schema: DEPLOYMENT_AUDIT_SCHEMA, - source_revision: 'b'.repeat(40), - }), - }), - }, - browserType: { - launch: async () => { - browserLaunched = true; - throw new Error('browser launch must not be reached'); - }, - }, - receiverBoundaryQualifier: async () => { - throw new Error('receiver validation must not be reached'); - }, - }), - /exact deployed PHP documentation candidate was not confirmed/, - ); - - assert.equal(browserLaunched, false); -}); - -test('deployment qualification retains the eight-page viewport matrix', async () => { - const calls = []; - const browser = { - close: async () => calls.push('browser-close'), - newContext: async options => ({ - close: async () => calls.push(['context-close', options.viewport]), - options, - }), - }; - - await qualifyDeployedAnalytics({ - sourceRevision: SOURCE_REVISION, - revisionContract: { - attempts: 1, - fetchImpl: async () => ({ - status: 200, - text: async () => JSON.stringify({ - schema: DEPLOYMENT_AUDIT_SCHEMA, - source_revision: SOURCE_REVISION, - }), - }), - }, - browserType: { - launch: async () => { - calls.push('browser-launch'); - return browser; - }, - }, - analyticsQualifier: async context => calls.push(['analytics', context.options.viewport]), - promotionQualifier: async (context, target) => calls.push([ - 'promotion', - context.options.viewport, - target.pathname, - ]), - receiverBoundaryQualifier: async () => calls.push('receiver-boundary'), - }); - - assert.deepEqual(calls.slice(0, 2), ['receiver-boundary', 'browser-launch']); - assert.deepEqual( - calls.filter(call => Array.isArray(call) && call[0] === 'promotion'), - [ - ['promotion', {width: 1440, height: 900}, '/'], - ['promotion', {width: 1440, height: 900}, '/api/classes/DurableWorkflow-Client.html'], - ['promotion', {width: 768, height: 1024}, '/'], - ['promotion', {width: 768, height: 1024}, '/api/classes/DurableWorkflow-Client.html'], - ['promotion', {width: 390, height: 844}, '/'], - ['promotion', {width: 390, height: 844}, '/api/classes/DurableWorkflow-Client.html'], - ['promotion', {width: 640, height: 360}, '/'], - ['promotion', {width: 640, height: 360}, '/api/classes/DurableWorkflow-Client.html'], - ], - ); -}); - -test('the deployment entrypoint requires one exact source revision', () => { - assert.equal(sourceRevisionArgument(['--source-revision', SOURCE_REVISION]), SOURCE_REVISION); - assert.throws(() => sourceRevisionArgument([]), /Usage:/); - assert.throws( - () => sourceRevisionArgument(['--source-revision', 'main']), - /40-character lowercase commit SHA/, - ); -}); diff --git a/scripts/qualify-quickstart-contract-deployment.mjs b/scripts/qualify-quickstart-contract-deployment.mjs deleted file mode 100644 index ff71069..0000000 --- a/scripts/qualify-quickstart-contract-deployment.mjs +++ /dev/null @@ -1,405 +0,0 @@ -import {spawn} from 'node:child_process'; -import {mkdtemp, readFile, rm, stat, writeFile} from 'node:fs/promises'; -import {tmpdir} from 'node:os'; -import {join} from 'node:path'; -import {pathToFileURL} from 'node:url'; -import Ajv2020 from 'ajv/dist/2020.js'; -import addFormats from 'ajv-formats'; - -const DEFAULT_CONTRACT_URL = 'https://php.durable-workflow.com/quickstart-contract.json'; -const GITHUB_API_ORIGIN = 'https://api.github.com'; -const MAX_GITHUB_ERROR_BODY_BYTES = 4096; -const MAX_GITHUB_ERROR_MESSAGE_CHARACTERS = 512; -const MAX_REDIRECTS = 5; -const SOURCE_NAMES = ['bootstrap', 'client', 'worker']; - -function assert(condition, message) { - if (!condition) throw new Error(message); -} - -function publicUrl(value, context) { - const url = new URL(value); - const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost'; - assert(url.protocol === 'https:' || (url.protocol === 'http:' && loopback), `${context} must use HTTPS`); - assert(url.username === '' && url.password === '', `${context} must not contain credentials`); - return url; -} - -function jsonPointer(document, pointer, context) { - assert( - typeof pointer === 'string' && pointer.startsWith('/'), - `${context} must be an RFC 6901 JSON Pointer`, - ); - - return pointer.slice(1).split('/').reduce((value, rawSegment) => { - const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~'); - assert(value !== null && typeof value === 'object' && segment in value, `${context} does not resolve`); - return value[segment]; - }, document); -} - -function boundedRateLimitHeader(response, name, maximumDigits = 13) { - const value = response.headers.get(name); - return value && new RegExp(`^\\d{1,${maximumDigits}}$`).test(value) ? value : null; -} - -async function boundedGitHubErrorMessage(response) { - if (!response.body) return null; - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let body = ''; - let bytes = 0; - - try { - while (true) { - const {done, value} = await reader.read(); - if (done) break; - bytes += value.byteLength; - if (bytes > MAX_GITHUB_ERROR_BODY_BYTES) { - await reader.cancel(); - return null; - } - body += decoder.decode(value, {stream: true}); - } - body += decoder.decode(); - } finally { - reader.releaseLock(); - } - - try { - const document = JSON.parse(body); - if ( - document === null - || typeof document !== 'object' - || Array.isArray(document) - || typeof document.message !== 'string' - || document.message.length > MAX_GITHUB_ERROR_MESSAGE_CHARACTERS - ) { - return null; - } - return document.message; - } catch { - return null; - } -} - -function rateLimitDiagnostics(status, remaining, retryAfter, reset) { - const diagnostics = [`HTTP ${status}`, `remaining=${remaining ?? 'unknown'}`]; - if (retryAfter !== null) diagnostics.push(`retry-after=${retryAfter}`); - if (reset !== null) diagnostics.push(`reset=${reset}`); - return diagnostics.join('; '); -} - -async function workflowEvidenceHttpError(response, url, githubToken) { - const status = response.status; - const remaining = boundedRateLimitHeader(response, 'x-ratelimit-remaining'); - const reset = boundedRateLimitHeader(response, 'x-ratelimit-reset'); - const retryAfter = boundedRateLimitHeader(response, 'retry-after', 6); - - if (status === 401) { - return new Error( - 'qualification evidence API authentication failed (HTTP 401); ' - + 'check that GITHUB_TOKEN is valid and has actions: read permission', - ); - } - if ((status === 403 || status === 429) && remaining === '0') { - return new Error( - `qualification evidence API GitHub primary rate limit exhausted (${rateLimitDiagnostics(status, remaining, retryAfter, reset)}); ` - + `${githubToken ? 'check the token rate limit' : 'set GITHUB_TOKEN for local qualification'} and retry`, - ); - } - if (status === 403 || status === 429) { - const message = await boundedGitHubErrorMessage(response); - const hasSecondaryMessage = message !== null && /\bsecondary rate limit(?:s|ing)?\b/i.test(message); - if (retryAfter !== null || hasSecondaryMessage) { - const retryGuidance = retryAfter === null - ? 'wait before retrying and reduce request concurrency' - : `retry after ${retryAfter} seconds and reduce request concurrency`; - return new Error( - `qualification evidence API GitHub secondary rate limit exceeded (${rateLimitDiagnostics(status, remaining, retryAfter, reset)}); ${retryGuidance}`, - ); - } - } - if (status === 429) { - return new Error( - `qualification evidence API GitHub rate limit response (${rateLimitDiagnostics(status, remaining, retryAfter, reset)}); wait before retrying`, - ); - } - if (status === 403) { - return new Error( - 'qualification evidence API access was forbidden (HTTP 403); ' - + `${githubToken ? 'check that GITHUB_TOKEN has actions: read permission' : 'set GITHUB_TOKEN for local qualification'}`, - ); - } - if (status === 404) { - return new Error( - 'qualification evidence workflow is missing or inaccessible (HTTP 404); ' - + 'verify the public workflow API URL and actions: read access', - ); - } - - return new Error(`qualification evidence API returned HTTP ${status} from ${url.origin}`); -} - -async function fetchResponse(url, context, { - authenticateGitHubWorkflow = false, - fetchImpl = fetch, - githubToken, - workflowEvidence = false, -} = {}) { - let requestUrl = publicUrl(url, context); - const signal = AbortSignal.timeout(30_000); - - for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { - const headers = { - accept: 'application/json, text/html;q=0.9, */*;q=0.1', - 'user-agent': 'durable-workflow-quickstart-contract-qualifier', - }; - if ( - redirects === 0 - && authenticateGitHubWorkflow - && githubToken - && requestUrl.origin === GITHUB_API_ORIGIN - ) { - headers.authorization = `Bearer ${githubToken}`; - headers['x-github-api-version'] = '2022-11-28'; - } - - const response = await fetchImpl(requestUrl, { - headers, - redirect: 'manual', - signal, - }); - if ([301, 302, 303, 307, 308].includes(response.status)) { - assert(redirects < MAX_REDIRECTS, `${context} exceeded ${MAX_REDIRECTS} redirects`); - const location = response.headers.get('location'); - assert(location, `${context} returned a redirect without a location`); - await response.body?.cancel(); - requestUrl = publicUrl(new URL(location, requestUrl), `${context} redirect`); - continue; - } - if (!response.ok) { - if (workflowEvidence && requestUrl.origin === GITHUB_API_ORIGIN) { - throw await workflowEvidenceHttpError(response, requestUrl, githubToken); - } - throw new Error(`${context} returned HTTP ${response.status} from ${requestUrl.origin}`); - } - return response; - } - - throw new Error(`${context} exceeded ${MAX_REDIRECTS} redirects`); -} - -async function fetchJson(url, context, options) { - const response = await fetchResponse(url, context, options); - try { - return await response.json(); - } catch (error) { - throw new Error(`${context} did not return JSON: ${error.message}`); - } -} - -function validateContract(contract, schema) { - assert(contract?.schema_version === 2, 'deployed quickstart contract must use schema version 2'); - assert(contract.$schema === schema?.$id, 'deployed quickstart contract and schema identities must match'); - assert(schema?.properties?.schema_version?.const === 2, 'deployed schema must describe contract version 2'); - assert( - schema?.$defs?.reference_resolution && schema?.$defs?.composer_package_path, - 'deployed schema must define package reference resolution semantics', - ); - assert( - contract.reference_resolution?.version === 1, - 'deployed contract must use reference resolution semantics version 1', - ); - assert(contract.package?.name === 'durable-workflow/sdk', 'deployed contract names the wrong Composer package'); - assert( - /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta|rc)\.[0-9]+)?$/.test( - contract.package?.published_version || '', - ), - 'deployed contract must select an exact published package version', - ); - assert( - contract.package?.composer_requirement === contract.package.published_version, - 'deployed contract must derive its Composer requirement from the published version', - ); - assert(!('published_smoke' in contract), 'deployed contract exposes a repository-local smoke path'); - - const sourceNames = Object.keys(contract.sources || {}).sort(); - assert( - JSON.stringify(sourceNames) === JSON.stringify(SOURCE_NAMES), - 'deployed contract must expose exactly the bootstrap, client, and worker sources', - ); - - const sourcePaths = new Map(); - for (const name of SOURCE_NAMES) { - const reference = contract.sources[name]; - assert(reference?.kind === 'composer_package_path', `source ${name} must be a Composer package path`); - const base = contract.reference_resolution?.bases?.[reference.base]; - assert(base?.kind === 'composer_package', `source ${name} must select a Composer package base`); - assert( - jsonPointer(contract, base.package_pointer, `source ${name} package pointer`) === contract.package, - `source ${name} must resolve against the declared package coordinate`, - ); - assert( - typeof reference.path === 'string' - && reference.path.length > 0 - && !reference.path.startsWith('/') - && !reference.path.includes('\\') - && !reference.path.split('/').some((segment) => segment === '' || segment === '..'), - `source ${name} must use a safe package-relative path`, - ); - sourcePaths.set(name, reference.path); - } - - const provenance = contract.qualification_provenance; - assert( - typeof provenance?.subject_base === 'string' - && provenance.subject_base in contract.reference_resolution.bases, - 'qualification provenance must select a declared package base', - ); - const evidence = provenance?.evidence; - assert( - evidence?.kind === 'github_actions_workflow', - 'qualification evidence must identify a public GitHub Actions workflow', - ); - publicUrl(evidence.api_url, 'qualification evidence API URL'); - publicUrl(evidence.web_url, 'qualification evidence web URL'); - assert( - jsonPointer(contract, evidence.version_input?.value_pointer, 'qualification version input pointer') - === contract.package.published_version, - 'qualification evidence must bind its input to the published package version', - ); - assert( - typeof evidence.version_input?.name === 'string' && evidence.version_input.name.length > 0, - 'qualification evidence must name its exact-version input', - ); - - const schemaValidator = new Ajv2020({allErrors: true}); - addFormats(schemaValidator); - const validateQuickstart = schemaValidator.compile(schema); - assert( - validateQuickstart(contract), - `deployed quickstart contract does not satisfy its schema: ${schemaValidator.errorsText(validateQuickstart.errors)}`, - ); - - return {evidence, sourcePaths}; -} - -function run(command, args, options = {}) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, {stdio: 'inherit', ...options}); - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code === 0) { - resolve(); - return; - } - reject(new Error(`${command} exited with ${signal ? `signal ${signal}` : `status ${code}`}`)); - }); - }); -} - -async function installPublishedPackage(contract) { - const consumer = await mkdtemp(join(tmpdir(), 'durable-workflow-quickstart-contract-')); - const composerEnvironment = {...process.env}; - delete composerEnvironment.GITHUB_TOKEN; - await writeFile( - join(consumer, 'composer.json'), - `${JSON.stringify({name: 'durable-workflow/quickstart-contract-qualifier'}, null, 2)}\n`, - ); - await run(process.env.COMPOSER_BINARY || 'composer', [ - 'require', - `${contract.package.name}:${contract.package.published_version}`, - '--working-dir', - consumer, - '--no-interaction', - '--prefer-dist', - '--no-plugins', - '--no-scripts', - '--no-audit', - ], {env: composerEnvironment}); - - return { - root: join(consumer, 'vendor', ...contract.package.name.split('/')), - cleanup: async () => rm(consumer, {recursive: true, force: true}), - }; -} - -async function verifyInstalledPackage(contract, sourcePaths, packageRoot) { - const packageManifest = JSON.parse(await readFile(join(packageRoot, 'composer.json'), 'utf8')); - assert(packageManifest.name === contract.package.name, 'installed package name does not match the contract'); - assert( - packageManifest.extra?.['durable-workflow']?.['product-train'] === contract.package.published_version, - 'installed package release identity does not match the contract', - ); - - for (const [name, relativePath] of sourcePaths) { - let source; - try { - source = await stat(join(packageRoot, relativePath)); - } catch { - throw new Error(`published package source ${name} is not consumable`); - } - assert(source.isFile() && source.size > 0, `published package source ${name} is not consumable`); - } -} - -async function verifyEvidence(evidence, {fetchImpl, githubToken}) { - const workflow = await fetchJson(evidence.api_url, 'qualification evidence API', { - authenticateGitHubWorkflow: true, - fetchImpl, - githubToken, - workflowEvidence: true, - }); - assert( - typeof workflow?.state === 'string' && workflow.state.length > 0, - 'qualification evidence API did not report workflow state', - ); - assert( - workflow.state === 'active', - `qualification evidence workflow is inactive (state=${JSON.stringify(workflow.state.slice(0, 80))})`, - ); - assert( - Number.isInteger(workflow.id) && workflow.id > 0 && typeof workflow.name === 'string' && workflow.name, - 'qualification evidence API did not resolve a workflow identity', - ); - await fetchResponse(evidence.web_url, 'qualification evidence web page', {fetchImpl, githubToken}); -} - -export async function qualifyDeployment({ - contractUrl = DEFAULT_CONTRACT_URL, - fetchImpl = fetch, - githubToken = process.env.GITHUB_TOKEN, - packageRoot, -} = {}) { - const contract = await fetchJson(contractUrl, 'deployed quickstart contract', {fetchImpl, githubToken}); - const schema = await fetchJson(contract?.$schema, 'deployed quickstart contract schema', { - fetchImpl, - githubToken, - }); - const {evidence, sourcePaths} = validateContract(contract, schema); - - const installation = packageRoot - ? {root: packageRoot, cleanup: async () => {}} - : await installPublishedPackage(contract); - try { - await verifyInstalledPackage(contract, sourcePaths, installation.root); - await verifyEvidence(evidence, {fetchImpl, githubToken}); - } finally { - await installation.cleanup(); - } - - return { - package: `${contract.package.name}:${contract.package.published_version}`, - sources: Object.fromEntries(sourcePaths), - evidence: evidence.web_url, - }; -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const result = await qualifyDeployment({contractUrl: process.env.QUICKSTART_CONTRACT_URL}); - console.log( - `Qualified ${result.package}: ${Object.values(result.sources).join(', ')}; evidence ${result.evidence}`, - ); -} diff --git a/scripts/qualify-quickstart-contract-deployment.test.mjs b/scripts/qualify-quickstart-contract-deployment.test.mjs deleted file mode 100644 index 0caaaa1..0000000 --- a/scripts/qualify-quickstart-contract-deployment.test.mjs +++ /dev/null @@ -1,405 +0,0 @@ -import assert from 'node:assert/strict'; -import {readFile} from 'node:fs/promises'; -import {createServer} from 'node:http'; -import test from 'node:test'; -import {fileURLToPath} from 'node:url'; - -import {qualifyDeployment} from './qualify-quickstart-contract-deployment.mjs'; -import {resolvePublishedRelease} from './qualify-quickstart-release-availability.mjs'; - -const repoRoot = new URL('../', import.meta.url); -const packageRoot = fileURLToPath(repoRoot); -const sourceContract = JSON.parse( - await readFile(new URL('docs/quickstart-contract.json', repoRoot), 'utf8'), -); -const sourceManifest = JSON.parse( - await readFile(new URL('composer.json', repoRoot), 'utf8'), -); -const sourceSchema = JSON.parse( - await readFile(new URL('docs/quickstart-contract.schema.v2.json', repoRoot), 'utf8'), -); - -async function fixture(mutator = () => {}) { - const contract = structuredClone(sourceContract); - const schema = structuredClone(sourceSchema); - const requests = []; - mutator(contract, schema); - - const server = createServer((request, response) => { - requests.push({authorization: request.headers.authorization, url: request.url}); - const origin = `http://127.0.0.1:${server.address().port}`; - const documents = { - '/quickstart-contract.json': contract, - '/quickstart-contract.schema.v2.json': schema, - '/workflow': {id: 1, name: 'Published service-mode smoke', state: 'active'}, - '/workflow-inactive': {id: 1, name: 'Published service-mode smoke', state: 'disabled_manually'}, - }; - - if (request.url === '/workflow/runs') { - response.writeHead(200, {'content-type': 'text/html'}); - response.end('Public qualification'); - return; - } - if (!(request.url in documents)) { - response.writeHead(404); - response.end(); - return; - } - response.writeHead(200, {'content-type': 'application/json'}); - response.end(JSON.stringify(documents[request.url])); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - - const origin = `http://127.0.0.1:${server.address().port}`; - contract.$schema = `${origin}/quickstart-contract.schema.v2.json`; - schema.$id = contract.$schema; - schema.properties.$schema.const = contract.$schema; - contract.qualification_provenance.evidence.api_url = `${origin}/workflow`; - contract.qualification_provenance.evidence.web_url = `${origin}/workflow/runs`; - delete schema.$defs.github_actions_workflow.properties.api_url.pattern; - delete schema.$defs.github_actions_workflow.properties.web_url.pattern; - - return { - contractUrl: `${origin}/quickstart-contract.json`, - contract, - origin, - requests, - close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), - }; -} - -test('deployed references resolve through the exact published package and public evidence', async () => { - const deployed = await fixture(); - try { - const result = await qualifyDeployment({ - contractUrl: deployed.contractUrl, - githubToken: 'fixture-token', - packageRoot, - }); - assert.deepEqual(Object.keys(result.sources).sort(), ['bootstrap', 'client', 'worker']); - assert.equal( - result.package, - `${sourceContract.package.name}:${sourceContract.package.published_version}`, - ); - assert(deployed.requests.length >= 4); - assert(deployed.requests.every((request) => request.authorization === undefined)); - } finally { - await deployed.close(); - } -}); - -test('workflow authentication is removed from redirects and non-GitHub origins', async () => { - const deployed = await fixture(); - const requests = []; - const apiUrl = - 'https://api.github.com/repos/durable-workflow/sdk-php/actions/workflows/service-mode-published-smoke.yml'; - const sameOriginRedirect = - 'https://api.github.com/repos/durable-workflow/sdk-php/actions/workflows/redirected.yml'; - const redirectedUrl = 'https://api.github.com.attacker.invalid/workflow'; - deployed.contract.qualification_provenance.evidence.api_url = apiUrl; - - const fetchImpl = async (input, options) => { - const url = new URL(input); - const authorization = new Headers(options.headers).get('authorization'); - requests.push({authorization, url: url.href}); - if (url.href === apiUrl) { - return new Response(null, {status: 302, headers: {location: sameOriginRedirect}}); - } - if (url.href === sameOriginRedirect) { - return new Response(null, {status: 302, headers: {location: redirectedUrl}}); - } - if (url.href === redirectedUrl) { - return Response.json({id: 1, name: 'Published service-mode smoke', state: 'active'}); - } - return fetch(input, options); - }; - - try { - await qualifyDeployment({ - contractUrl: deployed.contractUrl, - fetchImpl, - githubToken: 'fixture-token', - packageRoot, - }); - assert.equal( - requests.find((request) => request.url === apiUrl)?.authorization, - 'Bearer fixture-token', - ); - assert.equal( - requests.find((request) => request.url === sameOriginRedirect)?.authorization, - null, - ); - assert.equal( - requests.find((request) => request.url === redirectedUrl)?.authorization, - null, - ); - assert(deployed.requests.every((request) => request.authorization === undefined)); - } finally { - await deployed.close(); - } -}); - -test('portal and schema requests cannot opt into GitHub workflow authentication', async () => { - const deployed = await fixture(); - const githubContractUrl = 'https://api.github.com/portal/quickstart-contract.json'; - const githubSchemaUrl = 'https://api.github.com/portal/quickstart-contract.schema.v2.json'; - deployed.contract.$schema = githubSchemaUrl; - const schema = structuredClone(sourceSchema); - schema.$id = githubSchemaUrl; - schema.properties.$schema.const = githubSchemaUrl; - deployed.contract.qualification_provenance.evidence = structuredClone( - sourceContract.qualification_provenance.evidence, - ); - const requests = []; - - const fetchImpl = async (input, options) => { - const url = new URL(input); - requests.push({ - authorization: new Headers(options.headers).get('authorization'), - url: url.href, - }); - if (url.href === githubContractUrl) return Response.json(deployed.contract); - if (url.href === githubSchemaUrl) return Response.json(schema); - if (url.href === deployed.contract.qualification_provenance.evidence.api_url) { - return Response.json({id: 1, name: 'Published service-mode smoke', state: 'active'}); - } - if (url.href === deployed.contract.qualification_provenance.evidence.web_url) { - return new Response('Public qualification', { - headers: {'content-type': 'text/html'}, - }); - } - return fetch(input, options); - }; - - try { - await qualifyDeployment({ - contractUrl: githubContractUrl, - fetchImpl, - githubToken: 'fixture-token', - packageRoot, - }); - assert.equal(requests.find((request) => request.url === githubContractUrl)?.authorization, null); - assert.equal(requests.find((request) => request.url === githubSchemaUrl)?.authorization, null); - } finally { - await deployed.close(); - } -}); - -test('GitHub workflow failures have bounded, actionable classifications', async (t) => { - const cases = [ - { - name: 'authentication failure', - response: () => new Response('sensitive upstream body', {status: 401}), - expected: /authentication failed \(HTTP 401\).*actions: read/, - }, - { - name: 'secondary limit with nonzero primary quota', - response: () => new Response('sensitive upstream body', { - status: 403, - headers: { - 'retry-after': '60', - 'x-ratelimit-remaining': '4999', - 'x-ratelimit-reset': '1790000000', - }, - }), - expected: /secondary rate limit exceeded \(HTTP 403; remaining=4999; retry-after=60; reset=1790000000\); retry after 60 seconds/, - }, - { - name: 'secondary limit with absent primary quota', - response: () => Response.json( - {message: 'You have exceeded a secondary rate limit. Sensitive detail is omitted.'}, - {status: 403}, - ), - expected: /secondary rate limit exceeded \(HTTP 403; remaining=unknown\); wait before retrying/, - }, - { - name: 'primary rate limit exhaustion', - response: () => new Response('sensitive upstream body', { - status: 403, - headers: {'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1790000000'}, - }), - expected: /primary rate limit exhausted \(HTTP 403; remaining=0; reset=1790000000\).*retry/, - }, - { - name: 'permission denial', - response: () => Response.json({message: 'Resource not accessible by integration'}, {status: 403}), - expected: /access was forbidden \(HTTP 403\).*actions: read/, - }, - { - name: 'HTTP 429 without primary quota evidence', - response: () => new Response('sensitive upstream body', {status: 429}), - expected: /GitHub rate limit response \(HTTP 429; remaining=unknown\); wait before retrying/, - }, - { - name: 'oversized structured message is not trusted', - response: () => Response.json( - {message: `secondary rate limit ${'sensitive'.repeat(600)}`}, - {status: 403, headers: {'retry-after': 'invalid', 'x-ratelimit-reset': 'invalid'}}, - ), - expected: /access was forbidden \(HTTP 403\).*actions: read/, - }, - { - name: 'missing workflow', - response: () => new Response('sensitive upstream body', {status: 404}), - expected: /workflow is missing or inaccessible \(HTTP 404\).*public workflow API URL/, - }, - ]; - - for (const failure of cases) { - await t.test(failure.name, async () => { - const deployed = await fixture(); - deployed.contract.qualification_provenance.evidence.api_url = - 'https://api.github.com/repos/durable-workflow/sdk-php/actions/workflows/missing.yml'; - const fetchImpl = async (input, options) => { - if (new URL(input).origin === 'https://api.github.com') return failure.response(); - return fetch(input, options); - }; - try { - await assert.rejects( - qualifyDeployment({ - contractUrl: deployed.contractUrl, - fetchImpl, - githubToken: 'fixture-secret-token', - packageRoot, - }), - (error) => { - assert.match(error.message, failure.expected); - assert(!error.message.includes('sensitive upstream body')); - assert(!error.message.includes('Sensitive detail')); - assert(!error.message.includes('fixture-secret-token')); - assert(error.message.length < 300); - return true; - }, - ); - } finally { - await deployed.close(); - } - }); - } -}); - -test('inactive workflow evidence is distinct from transport failures', async () => { - const deployed = await fixture(); - deployed.contract.qualification_provenance.evidence.api_url = `${deployed.origin}/workflow-inactive`; - try { - await assert.rejects( - qualifyDeployment({contractUrl: deployed.contractUrl, githubToken: '', packageRoot}), - /workflow is inactive \(state="disabled_manually"\)/, - ); - } finally { - await deployed.close(); - } -}); - -test('the deployed quickstart instance is rejected when its schema omits workflow authoring', async () => { - const deployed = await fixture((_contract, schema) => { - schema.required = schema.required.filter((property) => property !== 'workflow_authoring'); - delete schema.properties.workflow_authoring; - delete schema.$defs.workflow_authoring; - }); - try { - await assert.rejects( - qualifyDeployment({contractUrl: deployed.contractUrl, packageRoot}), - /deployed quickstart contract does not satisfy its schema/, - ); - } finally { - await deployed.close(); - } -}); - -test('legacy repository-relative source strings are rejected', async () => { - const deployed = await fixture((contract) => { - contract.sources.bootstrap = 'examples/bootstrap.php'; - }); - try { - await assert.rejects( - qualifyDeployment({contractUrl: deployed.contractUrl, packageRoot}), - /source bootstrap must be a Composer package path/, - ); - } finally { - await deployed.close(); - } -}); - -test('missing published package files are rejected', async () => { - const deployed = await fixture((contract) => { - contract.sources.client.path = 'examples/missing-client.php'; - }); - try { - await assert.rejects( - qualifyDeployment({contractUrl: deployed.contractUrl, packageRoot}), - /published package source client is not consumable/, - ); - } finally { - await deployed.close(); - } -}); - -test('unresolvable qualification identities are rejected', async () => { - const deployed = await fixture((contract) => { - contract.qualification_provenance.evidence.version_input.value_pointer = '/package/missing_version'; - }); - try { - await assert.rejects( - qualifyDeployment({contractUrl: deployed.contractUrl, packageRoot}), - /qualification version input pointer does not resolve/, - ); - } finally { - await deployed.close(); - } -}); - -test('the deployment workflow runs public quickstart reference qualification', async () => { - const workflow = await readFile(new URL('../.github/workflows/docs.yml', import.meta.url), 'utf8'); - assert.match(workflow, /npm run qualify:quickstart-contract-deployment/); - assert.match(workflow, /- 'scripts\/qualify-quickstart-\*'/); - assert.match(workflow, /needs\.build\.outputs\.release_published == 'true'/); - assert.match(workflow, /schedule:\n\s+- cron:/); - assert.match( - workflow, - /qualify-deployment:[\s\S]*?permissions:\n\s+actions: read\n\s+contents: read[\s\S]*?GITHUB_TOKEN: \$\{\{ github\.token \}\}/, - ); -}); - -test('portal deployment waits for the exact source-declared release', async () => { - const requests = []; - const unavailable = await resolvePublishedRelease(sourceManifest, { - inspector: async (packageName, version) => { - requests.push([packageName, version]); - return null; - }, - }); - - assert.deepEqual(requests, [[sourceContract.package.name, sourceContract.package.published_version]]); - assert.equal( - sourceManifest.extra['durable-workflow']['product-train'], - sourceContract.package.published_version, - ); - assert.deepEqual(unavailable, { - release: sourceContract.package.published_version, - published: false, - }); -}); - -test('portal deployment accepts only exact Composer release metadata', async () => { - const manifest = { - extra: {'durable-workflow': {'product-train': sourceContract.package.published_version}}, - }; - const published = await resolvePublishedRelease(manifest, { - inspector: async () => ({ - name: sourceContract.package.name, - versions: [sourceContract.package.published_version], - }), - }); - assert.equal(published.published, true); - - await assert.rejects( - resolvePublishedRelease(manifest, { - inspector: async () => ({ - name: sourceContract.package.name, - versions: ['2.0.0-rc.14'], - }), - }), - /did not resolve only the source-declared release identity/, - ); -}); diff --git a/scripts/qualify-quickstart-release-availability.mjs b/scripts/qualify-quickstart-release-availability.mjs deleted file mode 100644 index 10d0dfd..0000000 --- a/scripts/qualify-quickstart-release-availability.mjs +++ /dev/null @@ -1,85 +0,0 @@ -import assert from 'node:assert/strict'; -import {spawn} from 'node:child_process'; -import {appendFile, readFile} from 'node:fs/promises'; -import process from 'node:process'; -import {pathToFileURL} from 'node:url'; - -const PACKAGE_NAME = 'durable-workflow/sdk'; -const RELEASE_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta|rc)\.[0-9]+)?$/; -const DIAGNOSTIC_LIMIT = 1000; - -function inspectPublishedPackage(packageName, version) { - return new Promise((resolve, reject) => { - const child = spawn(process.env.COMPOSER_BINARY || 'composer', [ - 'show', - packageName, - version, - '--all', - '--format=json', - ], {stdio: ['ignore', 'pipe', 'pipe']}); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', chunk => { stdout += chunk; }); - child.stderr.on('data', chunk => { stderr += chunk; }); - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code !== 0) { - const detail = stderr.trim().replace(/\s+/g, ' ').slice(0, DIAGNOSTIC_LIMIT); - console.warn( - `Composer cannot resolve ${packageName}:${version} yet (${signal ? `signal ${signal}` : `status ${code}`})${detail ? `: ${detail}` : ''}`, - ); - resolve(null); - return; - } - - try { - resolve(JSON.parse(stdout)); - } catch (error) { - reject(new Error(`Composer returned invalid JSON for ${packageName}:${version}: ${error.message}`)); - } - }); - }); -} - -export async function resolvePublishedRelease(manifest, {inspector = inspectPublishedPackage} = {}) { - const release = manifest?.extra?.['durable-workflow']?.['product-train']; - assert( - typeof release === 'string' && RELEASE_PATTERN.test(release), - 'Composer metadata must declare one exact PHP SDK release identity', - ); - - const metadata = await inspector(PACKAGE_NAME, release); - if (metadata === null) return {release, published: false}; - - assert.equal(metadata?.name, PACKAGE_NAME, 'Composer resolved the wrong package identity'); - assert.deepEqual( - metadata?.versions, - [release], - 'Composer did not resolve only the source-declared release identity', - ); - return {release, published: true}; -} - -async function main() { - const manifest = JSON.parse( - await readFile(new URL('../composer.json', import.meta.url), 'utf8'), - ); - const state = await resolvePublishedRelease(manifest); - if (process.env.GITHUB_OUTPUT) { - await appendFile( - process.env.GITHUB_OUTPUT, - `release_published=${state.published}\n`, - ); - } - console.log( - state.published - ? `${PACKAGE_NAME}:${state.release} is available for portal deployment.` - : `${PACKAGE_NAME}:${state.release} is not published; preserving the current portal deployment.`, - ); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -}